909 lines
38 KiB
HTML
909 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Захват изображений</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: #0f172a;
|
||
color: #f1f5f9;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 2rem;
|
||
}
|
||
|
||
.header {
|
||
text-align: center;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.header h1 {
|
||
font-size: 2rem;
|
||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
#cesiumContainer {
|
||
width: 512px;
|
||
height: 512px;
|
||
border: 2px solid #334155;
|
||
border-radius: 0.5rem;
|
||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.status {
|
||
margin-top: 2rem;
|
||
padding: 1rem 2rem;
|
||
background: #1e293b;
|
||
border-radius: 0.5rem;
|
||
border: 1px solid #334155;
|
||
text-align: center;
|
||
}
|
||
|
||
.status h2 {
|
||
font-size: 1.2rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.progress {
|
||
font-size: 1.5rem;
|
||
color: #10b981;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.log {
|
||
margin-top: 1rem;
|
||
max-width: 600px;
|
||
width: 100%;
|
||
background: #1e293b;
|
||
border-radius: 0.5rem;
|
||
padding: 1rem;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.log-entry {
|
||
margin-bottom: 0.5rem;
|
||
color: #94a3b8;
|
||
}
|
||
</style>
|
||
<link href="https://cesium.com/downloads/cesiumjs/releases/1.121/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
|
||
<script src="https://cesium.com/downloads/cesiumjs/releases/1.121/Build/Cesium/Cesium.js"></script>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="header">
|
||
<h1>Захват изображений</h1>
|
||
</div>
|
||
|
||
<div id="cesiumContainer"></div>
|
||
|
||
<div id="mapPanel" style="position:fixed;top:12px;right:12px;width:300px;background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px;z-index:1000;box-shadow:0 10px 30px rgba(0,0,0,0.3);">
|
||
<div id="mapLabel" style="font-size:0.85rem;color:#94a3b8;margin-bottom:6px;font-weight:500;">Карта зоны</div>
|
||
<canvas id="mapCanvas" width="284" height="284" style="display:block;background:#0f172a;border-radius:4px;"></canvas>
|
||
</div>
|
||
|
||
<div class="status">
|
||
<h2>Статус</h2>
|
||
<div class="progress" id="progress">Загрузка...</div>
|
||
</div>
|
||
|
||
<div class="log" id="log"></div>
|
||
|
||
<script>
|
||
const progressEl = document.getElementById('progress');
|
||
const logEl = document.getElementById('log');
|
||
|
||
const METERS_PER_DEG_LAT = 111320;
|
||
const DMS_VALUE_REGEX = /^\s*(\d+)\s*(?:°|В°)\s*(\d+)\s*'\s*([\d.]+)\s*"?\s*([NSEW])\s*$/i;
|
||
const DMS_TOKEN_REGEX = /\d+\s*(?:°|В°)\s*\d+\s*'\s*[\d.]+\s*"?\s*[NSEW]/gi;
|
||
|
||
function log(message) {
|
||
const entry = document.createElement('div');
|
||
entry.className = 'log-entry';
|
||
const timestamp = new Date().toLocaleTimeString('ru-RU');
|
||
entry.textContent = `[${timestamp}] ${message}`;
|
||
logEl.appendChild(entry);
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
}
|
||
|
||
function setProgress(text) {
|
||
progressEl.textContent = text;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function loadSettings() {
|
||
const response = await fetch('/api/get-settings');
|
||
const data = await response.json();
|
||
if (!data.success || !data.settings) {
|
||
throw new Error('Не удалось загрузить настройки');
|
||
}
|
||
return data.settings;
|
||
}
|
||
|
||
function metersToLat(meters) {
|
||
return meters / METERS_PER_DEG_LAT;
|
||
}
|
||
|
||
function metersToLon(meters, latDeg) {
|
||
return meters / (METERS_PER_DEG_LAT * Math.cos((latDeg * Math.PI) / 180));
|
||
}
|
||
|
||
function dmsToDecimal(dms) {
|
||
const match = String(dms).trim().match(DMS_VALUE_REGEX);
|
||
if (!match) {
|
||
throw new Error(`Неверный формат координат: ${dms}`);
|
||
}
|
||
|
||
const degrees = Number.parseInt(match[1], 10);
|
||
const minutes = Number.parseInt(match[2], 10);
|
||
const seconds = Number.parseFloat(match[3]);
|
||
const direction = match[4].toUpperCase();
|
||
|
||
let decimal = degrees + minutes / 60 + seconds / 3600;
|
||
if (direction === 'S' || direction === 'W') {
|
||
decimal = -decimal;
|
||
}
|
||
|
||
return decimal;
|
||
}
|
||
|
||
function parseCoordinatePair(value) {
|
||
const tokens = String(value || '').match(DMS_TOKEN_REGEX) || [];
|
||
if (tokens.length !== 2) {
|
||
throw new Error(`Ожидалась пара DMS: ${value}`);
|
||
}
|
||
const coords = tokens.map((t) => ({
|
||
value: dmsToDecimal(t),
|
||
direction: t.trim().slice(-1).toUpperCase()
|
||
}));
|
||
const lat = coords.find((c) => c.direction === 'N' || c.direction === 'S');
|
||
const lon = coords.find((c) => c.direction === 'E' || c.direction === 'W');
|
||
if (!lat || !lon) {
|
||
throw new Error(`В паре должны быть широта и долгота: ${value}`);
|
||
}
|
||
return { lat: lat.value, lon: lon.value };
|
||
}
|
||
|
||
function getBoundsFromSettings(settings) {
|
||
const p1 = parseCoordinatePair(settings.point1);
|
||
const p2 = parseCoordinatePair(settings.point2);
|
||
return {
|
||
minLat: Math.min(p1.lat, p2.lat),
|
||
maxLat: Math.max(p1.lat, p2.lat),
|
||
minLon: Math.min(p1.lon, p2.lon),
|
||
maxLon: Math.max(p1.lon, p2.lon)
|
||
};
|
||
}
|
||
|
||
function computeGrid(bounds, tileMeters) {
|
||
const anchorLat = bounds.maxLat;
|
||
const latSpanM = (bounds.maxLat - bounds.minLat) * METERS_PER_DEG_LAT;
|
||
const lonSpanM = (bounds.maxLon - bounds.minLon) * METERS_PER_DEG_LAT * Math.cos((anchorLat * Math.PI) / 180);
|
||
const cols = Math.max(1, Math.floor(lonSpanM / tileMeters) + 1);
|
||
const rows = Math.max(1, Math.floor(latSpanM / tileMeters) + 1);
|
||
return { cols, rows };
|
||
}
|
||
|
||
function generateTiles(bounds, step, tileMeters) {
|
||
const { cols, rows } = computeGrid(bounds, tileMeters);
|
||
const tiles = [];
|
||
let index = 1;
|
||
|
||
for (let row = 0; row < rows; row += step) {
|
||
for (let col = 0; col < cols; col += step) {
|
||
const lat = bounds.maxLat - metersToLat(row * tileMeters);
|
||
const lon = bounds.minLon + metersToLon(col * tileMeters, lat);
|
||
tiles.push({ index: index++, row, col, lat, lon });
|
||
}
|
||
}
|
||
|
||
return { tiles, gridRows: rows, gridCols: cols, tileSpanM: tileMeters };
|
||
}
|
||
|
||
function captureCropped(viewer, cropSize, format = 'png', quality = 0.92) {
|
||
const srcCanvas = viewer.scene.canvas;
|
||
const { width, height } = srcCanvas;
|
||
const size = Math.min(cropSize, width, height);
|
||
const out = document.createElement('canvas');
|
||
out.width = size;
|
||
out.height = size;
|
||
const sx = Math.floor((width - size) / 2);
|
||
const sy = Math.floor((height - size) / 2);
|
||
out.getContext('2d').drawImage(srcCanvas, sx, sy, size, size, 0, 0, size, size);
|
||
const mime = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
||
return out.toDataURL(mime, quality);
|
||
}
|
||
|
||
async function detect3DContent(viewer, terrainProvider, tiles, tileSpanM) {
|
||
const SAMPLE_GRID = 3;
|
||
const BUILDING_THRESHOLD_M = 5;
|
||
const MIN_POINTS_WITH_BUILDING = 1;
|
||
|
||
const sceneCarto = [];
|
||
const terrainCarto = [];
|
||
const offsetM = tileSpanM / 3;
|
||
|
||
tiles.forEach((tile) => {
|
||
const latOffsetDeg = offsetM / 111320;
|
||
const lonOffsetDeg = offsetM / (111320 * Math.cos((tile.lat * Math.PI) / 180));
|
||
for (let dy = -1; dy <= 1; dy++) {
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
const lon = tile.lon + dx * lonOffsetDeg;
|
||
const lat = tile.lat + dy * latOffsetDeg;
|
||
sceneCarto.push(Cesium.Cartographic.fromDegrees(lon, lat));
|
||
terrainCarto.push(Cesium.Cartographic.fromDegrees(lon, lat));
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
await Promise.all([
|
||
viewer.scene.sampleHeightMostDetailed(sceneCarto),
|
||
Cesium.sampleTerrainMostDetailed(terrainProvider, terrainCarto)
|
||
]);
|
||
} catch (e) {
|
||
log(`Ошибка при анализе 3D: ${e.message}`);
|
||
tiles.forEach(t => { t.has3D = true; });
|
||
return;
|
||
}
|
||
|
||
const perTile = SAMPLE_GRID * SAMPLE_GRID;
|
||
tiles.forEach((tile, i) => {
|
||
let buildingPoints = 0;
|
||
for (let j = 0; j < perTile; j++) {
|
||
const sceneH = sceneCarto[i * perTile + j]?.height;
|
||
const terrainH = terrainCarto[i * perTile + j]?.height;
|
||
if (Number.isFinite(sceneH) && Number.isFinite(terrainH)) {
|
||
if ((sceneH - terrainH) > BUILDING_THRESHOLD_M) {
|
||
buildingPoints++;
|
||
}
|
||
}
|
||
}
|
||
tile.has3D = buildingPoints >= MIN_POINTS_WITH_BUILDING;
|
||
});
|
||
}
|
||
|
||
async function postJson(url, body) {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
throw new Error(result.error || `Ошибка запроса ${url}`);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function saveDbCrop(settings, tile, png, resX, resY, sizeTag) {
|
||
return postJson('/api/save-db-crop', {
|
||
...settings,
|
||
col: tile.col,
|
||
row: tile.row,
|
||
lat: tile.lat,
|
||
lon: tile.lon,
|
||
res_x: resX,
|
||
res_y: resY,
|
||
size_tag: sizeTag,
|
||
png
|
||
});
|
||
}
|
||
|
||
async function saveDroneFrame(settings, heightValue, rotValue, frameIdx, jpeg) {
|
||
return postJson('/api/save-drone-frame', {
|
||
...settings,
|
||
height: heightValue,
|
||
rot: rotValue,
|
||
frameIdx,
|
||
jpeg
|
||
});
|
||
}
|
||
|
||
async function saveTrajectory(settings, heightValue, rotValue, trajectory) {
|
||
return postJson('/api/save-trajectory', {
|
||
...settings,
|
||
height: heightValue,
|
||
rot: rotValue,
|
||
trajectory
|
||
});
|
||
}
|
||
|
||
async function saveMergeTif(settings, png) {
|
||
return postJson('/api/save-merge-tif', {
|
||
...settings,
|
||
png
|
||
});
|
||
}
|
||
|
||
async function saveAnnotations(settings, positive, semiPositive) {
|
||
return postJson('/api/save-annotations', {
|
||
...settings,
|
||
positive,
|
||
semi_positive: semiPositive
|
||
});
|
||
}
|
||
|
||
async function waitForSceneReady(viewer, tileset, timeoutMs = 30000, minWaitMs = 1500) {
|
||
return new Promise((resolve) => {
|
||
const start = Date.now();
|
||
let stableFrames = 0;
|
||
let sawPending = false;
|
||
const STABLE_NEEDED = 10;
|
||
|
||
function onPostRender() {
|
||
const elapsed = Date.now() - start;
|
||
const stats = tileset ? tileset.statistics : null;
|
||
const pending = stats
|
||
? (stats.numberOfPendingRequests + stats.numberOfTilesProcessing)
|
||
: 0;
|
||
|
||
if (pending > 0) {
|
||
sawPending = true;
|
||
stableFrames = 0;
|
||
} else if (elapsed >= minWaitMs) {
|
||
stableFrames++;
|
||
if (stableFrames >= STABLE_NEEDED) {
|
||
viewer.scene.postRender.removeEventListener(onPostRender);
|
||
resolve();
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (elapsed > timeoutMs) {
|
||
viewer.scene.postRender.removeEventListener(onPostRender);
|
||
log(`Таймаут ожидания карты (pending=${pending}, sawPending=${sawPending})`);
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
viewer.scene.requestRender();
|
||
}
|
||
|
||
viewer.scene.postRender.addEventListener(onPostRender);
|
||
viewer.scene.requestRender();
|
||
});
|
||
}
|
||
|
||
async function createViewer(canvasSize) {
|
||
Cesium.Ion.defaultAccessToken =
|
||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI3NjcxNWZiYy1mZjFiLTQzNGEtYmEwNy1hMmU0ZDg4NWI2ZGUiLCJpZCI6Mzc3NjU2LCJpYXQiOjE3NjgyMTk2NzJ9.D5DL5zrDi4yK6OPgq-ypC4hM5hfqYKODvppgakc7y3g';
|
||
|
||
const creditContainer = document.createElement('div');
|
||
creditContainer.style.display = 'none';
|
||
|
||
const viewer = new Cesium.Viewer('cesiumContainer', {
|
||
globe: false,
|
||
skyAtmosphere: new Cesium.SkyAtmosphere(),
|
||
sceneModePicker: false,
|
||
baseLayerPicker: false,
|
||
geocoder: Cesium.IonGeocodeProviderType?.GOOGLE || false,
|
||
timeline: false,
|
||
animation: false,
|
||
creditContainer: creditContainer,
|
||
contextOptions: {
|
||
webgl: {
|
||
preserveDrawingBuffer: true
|
||
}
|
||
}
|
||
});
|
||
|
||
const outputWidth = canvasSize;
|
||
const outputHeight = canvasSize;
|
||
viewer.scene.canvas.style.width = `${outputWidth}px`;
|
||
viewer.scene.canvas.style.height = `${outputHeight}px`;
|
||
viewer.container.style.width = `${outputWidth}px`;
|
||
viewer.container.style.height = `${outputHeight}px`;
|
||
const cesiumContainerEl = document.getElementById('cesiumContainer');
|
||
cesiumContainerEl.style.width = `${outputWidth}px`;
|
||
cesiumContainerEl.style.height = `${outputHeight}px`;
|
||
viewer.resize();
|
||
|
||
let tileset;
|
||
try {
|
||
tileset = await Cesium.Cesium3DTileset.fromIonAssetId(2275207);
|
||
viewer.scene.primitives.add(tileset);
|
||
if (tileset.readyPromise) {
|
||
await tileset.readyPromise;
|
||
}
|
||
log('Tileset загружен');
|
||
} catch (error) {
|
||
log(`Ошибка загрузки tileset: ${error.message}`);
|
||
}
|
||
|
||
return { viewer, tileset };
|
||
}
|
||
|
||
const mapCanvas = document.getElementById('mapCanvas');
|
||
const mapCtx = mapCanvas.getContext('2d');
|
||
const mapLabel = document.getElementById('mapLabel');
|
||
const MAP_PADDING = 16;
|
||
let mapState = null;
|
||
const mapTileCache = new Map();
|
||
let mapTileRedrawScheduled = false;
|
||
|
||
function mapViewport() {
|
||
if (!mapState) return null;
|
||
const { bounds } = mapState;
|
||
const w = mapCanvas.width - MAP_PADDING * 2;
|
||
const h = mapCanvas.height - MAP_PADDING * 2;
|
||
const lonRange = Math.max(1e-9, bounds.maxLon - bounds.minLon);
|
||
const latRange = Math.max(1e-9, bounds.maxLat - bounds.minLat);
|
||
const midLat = (bounds.maxLat + bounds.minLat) / 2;
|
||
const aspectGeo = (lonRange * Math.cos(midLat * Math.PI / 180)) / latRange;
|
||
const aspectCanvas = w / h;
|
||
let sw, sh;
|
||
if (aspectGeo > aspectCanvas) { sw = w; sh = w / aspectGeo; }
|
||
else { sh = h; sw = h * aspectGeo; }
|
||
const ox = MAP_PADDING + (w - sw) / 2;
|
||
const oy = MAP_PADDING + (h - sh) / 2;
|
||
return { ox, oy, sw, sh, lonRange, latRange, bounds };
|
||
}
|
||
|
||
function mapProject(lat, lon, vp) {
|
||
return {
|
||
x: vp.ox + ((lon - vp.bounds.minLon) / vp.lonRange) * vp.sw,
|
||
y: vp.oy + ((vp.bounds.maxLat - lat) / vp.latRange) * vp.sh
|
||
};
|
||
}
|
||
|
||
function mapLonToX(lon, z) {
|
||
const n = 2 ** z;
|
||
return ((lon + 180) / 360) * n * 256;
|
||
}
|
||
|
||
function mapLatToY(lat, z) {
|
||
const n = 2 ** z;
|
||
const rad = (lat * Math.PI) / 180;
|
||
return ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n * 256;
|
||
}
|
||
|
||
function mapChooseZoom(vp) {
|
||
const lonRange = Math.max(1e-9, vp.bounds.maxLon - vp.bounds.minLon);
|
||
const z = Math.floor(Math.log2((360 * vp.sw) / (lonRange * 256)));
|
||
return Math.max(2, Math.min(18, Number.isFinite(z) ? z : 12));
|
||
}
|
||
|
||
function mapGetTile(z, x, y) {
|
||
const n = 2 ** z;
|
||
if (y < 0 || y >= n) {
|
||
return null;
|
||
}
|
||
const wrappedX = ((x % n) + n) % n;
|
||
const key = `${z}/${wrappedX}/${y}`;
|
||
if (mapTileCache.has(key)) {
|
||
return mapTileCache.get(key);
|
||
}
|
||
|
||
const tile = { status: 'loading', img: null };
|
||
mapTileCache.set(key, tile);
|
||
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
tile.status = 'ready';
|
||
tile.img = img;
|
||
if (!mapTileRedrawScheduled) {
|
||
mapTileRedrawScheduled = true;
|
||
setTimeout(() => {
|
||
mapTileRedrawScheduled = false;
|
||
mapDraw();
|
||
}, 0);
|
||
}
|
||
};
|
||
img.onerror = () => {
|
||
tile.status = 'error';
|
||
};
|
||
img.src = `https://tile.openstreetmap.org/${z}/${wrappedX}/${y}.png`;
|
||
|
||
return tile;
|
||
}
|
||
|
||
function mapDrawTiles(vp) {
|
||
const z = mapChooseZoom(vp);
|
||
const xMin = mapLonToX(vp.bounds.minLon, z);
|
||
const xMax = mapLonToX(vp.bounds.maxLon, z);
|
||
const yMin = mapLatToY(vp.bounds.maxLat, z);
|
||
const yMax = mapLatToY(vp.bounds.minLat, z);
|
||
|
||
const mapW = Math.max(1, xMax - xMin);
|
||
const mapH = Math.max(1, yMax - yMin);
|
||
const scaleX = vp.sw / mapW;
|
||
const scaleY = vp.sh / mapH;
|
||
|
||
const tileXStart = Math.floor(xMin / 256);
|
||
const tileXEnd = Math.floor(xMax / 256);
|
||
const tileYStart = Math.floor(yMin / 256);
|
||
const tileYEnd = Math.floor(yMax / 256);
|
||
|
||
for (let ty = tileYStart; ty <= tileYEnd; ty += 1) {
|
||
for (let tx = tileXStart; tx <= tileXEnd; tx += 1) {
|
||
const tile = mapGetTile(z, tx, ty);
|
||
if (!tile || tile.status !== 'ready' || !tile.img) continue;
|
||
|
||
const tilePxX = tx * 256;
|
||
const tilePxY = ty * 256;
|
||
const dx = vp.ox + (tilePxX - xMin) * scaleX;
|
||
const dy = vp.oy + (tilePxY - yMin) * scaleY;
|
||
const dw = 256 * scaleX;
|
||
const dh = 256 * scaleY;
|
||
mapCtx.drawImage(tile.img, dx, dy, dw, dh);
|
||
}
|
||
}
|
||
}
|
||
|
||
function mapDraw() {
|
||
mapCtx.fillStyle = '#0f172a';
|
||
mapCtx.fillRect(0, 0, mapCanvas.width, mapCanvas.height);
|
||
if (!mapState) return;
|
||
const vp = mapViewport();
|
||
if (!vp) return;
|
||
mapCtx.fillStyle = 'rgba(15,23,42,0.65)';
|
||
mapCtx.fillRect(vp.ox, vp.oy, vp.sw, vp.sh);
|
||
mapDrawTiles(vp);
|
||
mapCtx.strokeStyle = '#ef4444';
|
||
mapCtx.lineWidth = 2;
|
||
mapCtx.strokeRect(vp.ox, vp.oy, vp.sw, vp.sh);
|
||
mapCtx.fillStyle = '#38bdf8';
|
||
mapState.tiles.forEach((t) => {
|
||
const p = mapProject(t.lat, t.lon, vp);
|
||
mapCtx.beginPath();
|
||
mapCtx.arc(p.x, p.y, 2, 0, Math.PI * 2);
|
||
mapCtx.fill();
|
||
});
|
||
mapCtx.fillStyle = '#22c55e';
|
||
mapState.visited.forEach((v) => {
|
||
const p = mapProject(v.lat, v.lon, vp);
|
||
mapCtx.beginPath();
|
||
mapCtx.arc(p.x, p.y, 3, 0, Math.PI * 2);
|
||
mapCtx.fill();
|
||
});
|
||
if (mapState.active) {
|
||
const p = mapProject(mapState.active.lat, mapState.active.lon, vp);
|
||
mapCtx.fillStyle = mapState.done ? '#10b981' : '#fbbf24';
|
||
mapCtx.strokeStyle = '#0f172a';
|
||
mapCtx.lineWidth = 2;
|
||
mapCtx.beginPath();
|
||
mapCtx.arc(p.x, p.y, 6, 0, Math.PI * 2);
|
||
mapCtx.fill();
|
||
mapCtx.stroke();
|
||
}
|
||
}
|
||
|
||
function mapSetInit(bounds, tiles) {
|
||
mapState = {
|
||
bounds,
|
||
tiles: tiles.map((t) => ({ lat: t.lat, lon: t.lon })),
|
||
visited: [],
|
||
active: null,
|
||
done: false
|
||
};
|
||
mapLabel.textContent = `Карта зоны — ${tiles.length} точек`;
|
||
mapDraw();
|
||
}
|
||
|
||
function mapUpdate(idx, total, lat, lon) {
|
||
if (!mapState) return;
|
||
if (mapState.active) mapState.visited.push(mapState.active);
|
||
mapState.active = { lat, lon };
|
||
mapLabel.textContent = `Карта зоны — ${idx}/${total}`;
|
||
mapDraw();
|
||
}
|
||
|
||
function mapDone() {
|
||
if (!mapState) return;
|
||
mapState.done = true;
|
||
mapLabel.textContent = `Карта зоны — готово`;
|
||
mapDraw();
|
||
}
|
||
|
||
mapDraw();
|
||
|
||
async function main() {
|
||
setProgress('Загрузка настроек...');
|
||
log('Загрузка настроек...');
|
||
const settings = await loadSettings();
|
||
|
||
const sceneMeta = {
|
||
dataset: settings.dataset || 'Country',
|
||
country: settings.country || 'Custom',
|
||
city: settings.city || 'Scene',
|
||
region: settings.region || 'Region',
|
||
point1: settings.point1 || '',
|
||
point2: settings.point2 || '',
|
||
tile_m: settings.tile_m
|
||
};
|
||
|
||
const step = Math.max(1, Number.parseInt(settings.grid_step, 10) || 1);
|
||
const tileMeters = Math.max(1, Number(settings.tile_m) || 1);
|
||
const heightAbsolute = settings.height_absolute !== undefined ? Boolean(settings.height_absolute) : true;
|
||
const droneSize = Math.max(1, Number.parseInt(settings.drone_size, 10) || 512);
|
||
const dbCropSizes = Array.from(new Set(
|
||
String(settings.db_crop_sizes || "")
|
||
.split(",")
|
||
.map((v) => Number.parseInt(v.trim(), 10))
|
||
.filter((v) => Number.isInteger(v) && v > 0)
|
||
));
|
||
if (dbCropSizes.length === 0) {
|
||
dbCropSizes.push(512);
|
||
}
|
||
const queryFovDeg = Math.min(179, Math.max(1, Number(settings.query_fov_deg) || 30));
|
||
const queryPitchDeg = -90;
|
||
const preloadEnabled = settings.preload !== undefined ? Boolean(settings.preload) : true;
|
||
const skipEmpty = Boolean(settings.skip_empty);
|
||
const offset = 0.0005;
|
||
const ROT_VALUE = 0;
|
||
|
||
const enabledHeights = [];
|
||
if (settings.height_100) enabledHeights.push(Math.max(1, Number(settings.height_100_value) || 100));
|
||
if (settings.height_125) enabledHeights.push(Math.max(1, Number(settings.height_125_value) || 125));
|
||
if (settings.height_150) enabledHeights.push(Math.max(1, Number(settings.height_150_value) || 150));
|
||
if (enabledHeights.length === 0) {
|
||
throw new Error('Не выбрана ни одна высота (100/125/150)');
|
||
}
|
||
|
||
const bounds = getBoundsFromSettings(settings);
|
||
const { tiles, gridRows, gridCols, tileSpanM } = generateTiles(bounds, step, tileMeters);
|
||
|
||
mapSetInit(bounds, tiles);
|
||
|
||
const totalShots = tiles.length * enabledHeights.length;
|
||
log(`Сетка: ${gridCols}x${gridRows}, точек: ${tiles.length}, высот: ${enabledHeights.length}, кадров: ${totalShots}`);
|
||
log(`Размеры DB: ${dbCropSizes.join(", ")} px | Query: ${droneSize}px`);
|
||
log(`Параметры Query: FOV ${queryFovDeg}°, pitch ${queryPitchDeg}° (top-down)`);
|
||
setProgress(`Подготовка ${totalShots} кадров...`);
|
||
|
||
if (typeof Cesium === 'undefined') {
|
||
throw new Error('Карта не загружена');
|
||
}
|
||
|
||
const maxDbCropSize = Math.max(...dbCropSizes);
|
||
const canvasSize = Math.max(maxDbCropSize, droneSize);
|
||
const { viewer, tileset } = await createViewer(canvasSize);
|
||
viewer.camera.frustum.fovy = Cesium.Math.toRadians(queryFovDeg);
|
||
|
||
const terrainProvider = await Cesium.createWorldTerrainAsync();
|
||
if (heightAbsolute) {
|
||
tiles.forEach((tile) => {
|
||
tile.cameraAlts = enabledHeights.slice();
|
||
});
|
||
log(`Высоты камеры: ${enabledHeights.join(', ')}м абсолютные`);
|
||
} else {
|
||
log('Определение высот рельефа...');
|
||
const cartographics = tiles.map(t => Cesium.Cartographic.fromDegrees(t.lon, t.lat));
|
||
const sampledPositions = await Cesium.sampleTerrainMostDetailed(terrainProvider, cartographics);
|
||
tiles.forEach((tile, i) => {
|
||
tile.groundHeight = sampledPositions[i].height || 0;
|
||
tile.cameraAlts = enabledHeights.map(h => tile.groundHeight + h);
|
||
});
|
||
const minH = Math.round(Math.min(...tiles.map(t => t.groundHeight)));
|
||
const maxH = Math.round(Math.max(...tiles.map(t => t.groundHeight)));
|
||
log(`Рельеф: мин ${minH}м, макс ${maxH}м. Высоты: ${enabledHeights.join(', ')}м над рельефом`);
|
||
}
|
||
|
||
if (skipEmpty) {
|
||
log('Анализ наличия 3D-зданий в зонах...');
|
||
await detect3DContent(viewer, terrainProvider, tiles, tileSpanM);
|
||
const skipped = tiles.filter(t => !t.has3D).length;
|
||
log(`Зон со зданиями: ${tiles.length - skipped}, без зданий (пропуск): ${skipped}`);
|
||
} else {
|
||
tiles.forEach(t => { t.has3D = true; });
|
||
}
|
||
|
||
viewer.scene.postRender.addEventListener(async function once() {
|
||
viewer.scene.postRender.removeEventListener(once);
|
||
|
||
log('Сохранение merge.tif...');
|
||
const centerLat = (bounds.minLat + bounds.maxLat) / 2;
|
||
const centerLon = (bounds.minLon + bounds.maxLon) / 2;
|
||
const latSpanM = (bounds.maxLat - bounds.minLat) * METERS_PER_DEG_LAT;
|
||
const lonSpanM = (bounds.maxLon - bounds.minLon) * METERS_PER_DEG_LAT * Math.cos((centerLat * Math.PI) / 180);
|
||
const mergeSpanM = Math.max(latSpanM, lonSpanM);
|
||
const mergeFovDeg = 60;
|
||
const mergeAlt = Math.max(300, (mergeSpanM / 2) / Math.tan((mergeFovDeg * Math.PI / 180) / 2));
|
||
viewer.camera.frustum.fovy = Cesium.Math.toRadians(mergeFovDeg);
|
||
viewer.camera.setView({
|
||
destination: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, mergeAlt),
|
||
orientation: {
|
||
heading: 0,
|
||
pitch: Cesium.Math.toRadians(-90),
|
||
roll: 0
|
||
}
|
||
});
|
||
viewer.scene.requestRender();
|
||
await waitForSceneReady(viewer, tileset, 20000, 1500);
|
||
const mergePng = captureCropped(viewer, canvasSize, 'png');
|
||
await saveMergeTif(sceneMeta, mergePng);
|
||
viewer.camera.frustum.fovy = Cesium.Math.toRadians(queryFovDeg);
|
||
|
||
log('Разогрев tileset...');
|
||
const avgBaseAlt = tiles.reduce((sum, t) => sum + t.cameraAlts[0], 0) / tiles.length;
|
||
viewer.camera.setView({
|
||
destination: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, avgBaseAlt + 500),
|
||
orientation: {
|
||
heading: 0,
|
||
pitch: Cesium.Math.toRadians(-90),
|
||
roll: 0
|
||
}
|
||
});
|
||
viewer.scene.requestRender();
|
||
await waitForSceneReady(viewer, tileset, 30000, 2500);
|
||
|
||
if (preloadEnabled) {
|
||
log('Этап 1/2: предзагрузка тайлов...');
|
||
let preIdx = 0;
|
||
for (let i = 0; i < tiles.length; i += 1) {
|
||
const tile = tiles[i];
|
||
if (!tile.has3D) continue;
|
||
for (let h = 0; h < enabledHeights.length; h += 1) {
|
||
preIdx += 1;
|
||
setProgress(`Прогрузка ${preIdx} / ${totalShots}`);
|
||
|
||
viewer.camera.setView({
|
||
destination: Cesium.Cartesian3.fromDegrees(tile.lon, tile.lat, tile.cameraAlts[h]),
|
||
orientation: {
|
||
heading: 0,
|
||
pitch: Cesium.Math.toRadians(-90),
|
||
roll: 0
|
||
}
|
||
});
|
||
|
||
viewer.scene.requestRender();
|
||
await waitForSceneReady(viewer, tileset, 20000);
|
||
}
|
||
}
|
||
log('Этап 2/2: сохранение скриншотов...');
|
||
} else {
|
||
log('Предзагрузка отключена, сразу сохраняем скриншоты...');
|
||
}
|
||
|
||
const cameraFramesByHeight = {};
|
||
enabledHeights.forEach((h) => { cameraFramesByHeight[h] = []; });
|
||
const positiveAnnotations = {};
|
||
const semiPositiveAnnotations = {};
|
||
const availableTileSet = new Set(
|
||
tiles.filter((t) => t.has3D).map((t) => `${t.col}:${t.row}`)
|
||
);
|
||
const preferredDbSize = dbCropSizes.includes(512) ? 512 : dbCropSizes[0];
|
||
|
||
const fovVerticalDeg = Cesium.Math.toDegrees(viewer.camera.frustum.fovy);
|
||
|
||
let shotIdx = 0;
|
||
for (let i = 0; i < tiles.length; i += 1) {
|
||
const tile = tiles[i];
|
||
if (!tile.has3D) {
|
||
log(`Точка ${tile.index}: 2D-зона, пропуск`);
|
||
shotIdx += enabledHeights.length;
|
||
continue;
|
||
}
|
||
|
||
mapUpdate(tile.index, tiles.length, tile.lat, tile.lon);
|
||
|
||
const dbAlt = tile.cameraAlts[0];
|
||
viewer.camera.setView({
|
||
destination: Cesium.Cartesian3.fromDegrees(tile.lon, tile.lat, dbAlt),
|
||
orientation: {
|
||
heading: 0,
|
||
pitch: Cesium.Math.toRadians(-90),
|
||
roll: 0
|
||
}
|
||
});
|
||
viewer.scene.requestRender();
|
||
await waitForSceneReady(viewer, tileset, 20000);
|
||
for (let s = 0; s < dbCropSizes.length; s += 1) {
|
||
const dbSize = dbCropSizes[s];
|
||
const satPng = captureCropped(viewer, dbSize, 'png');
|
||
await saveDbCrop(sceneMeta, tile, satPng, dbSize, dbSize, dbSize);
|
||
}
|
||
|
||
for (let h = 0; h < enabledHeights.length; h += 1) {
|
||
shotIdx += 1;
|
||
const heightValue = enabledHeights[h];
|
||
const cameraAlt = tile.cameraAlts[h];
|
||
setProgress(`Сохранение ${shotIdx} / ${totalShots}`);
|
||
|
||
const droneLat = tile.lat - offset;
|
||
const droneLon = tile.lon;
|
||
const dronePitchDeg = queryPitchDeg;
|
||
const droneHeadingDeg = 0;
|
||
|
||
viewer.camera.setView({
|
||
destination: Cesium.Cartesian3.fromDegrees(droneLon, droneLat, cameraAlt),
|
||
orientation: {
|
||
heading: Cesium.Math.toRadians(droneHeadingDeg),
|
||
pitch: Cesium.Math.toRadians(dronePitchDeg),
|
||
roll: 0
|
||
}
|
||
});
|
||
viewer.scene.requestRender();
|
||
await waitForSceneReady(viewer, tileset, 20000);
|
||
const droneJpeg = captureCropped(viewer, droneSize, 'jpeg');
|
||
await saveDroneFrame(sceneMeta, heightValue, ROT_VALUE, i, droneJpeg);
|
||
|
||
const variantName = `height${heightValue}_rot${ROT_VALUE}`;
|
||
const frameKey = `${variantName}_${String(i).padStart(2, "0")}`;
|
||
const positiveFilename = `crop_${tile.col}_${tile.row}_${preferredDbSize}.png`;
|
||
positiveAnnotations[frameKey] = [positiveFilename];
|
||
|
||
const neighbors = [
|
||
[tile.col - 1, tile.row],
|
||
[tile.col + 1, tile.row],
|
||
[tile.col, tile.row - 1],
|
||
[tile.col, tile.row + 1]
|
||
];
|
||
const semi = neighbors
|
||
.filter(([c, r]) => availableTileSet.has(`${c}:${r}`))
|
||
.map(([c, r]) => `crop_${c}_${r}_${preferredDbSize}.png`);
|
||
semiPositiveAnnotations[frameKey] = semi;
|
||
|
||
const ecef = Cesium.Cartesian3.fromDegrees(droneLon, droneLat, cameraAlt);
|
||
cameraFramesByHeight[heightValue].push({
|
||
position: { x: ecef.x, y: ecef.y, z: ecef.z },
|
||
rotation: { x: dronePitchDeg, y: droneHeadingDeg, z: 0 },
|
||
coordinate: {
|
||
latitude: droneLat,
|
||
longitude: droneLon,
|
||
altitude: cameraAlt
|
||
},
|
||
fovVertical: fovVerticalDeg
|
||
});
|
||
}
|
||
}
|
||
|
||
log('Сохранение траекторий...');
|
||
const numFrames = cameraFramesByHeight[enabledHeights[0]].length;
|
||
const frameRate = 30;
|
||
for (let h = 0; h < enabledHeights.length; h += 1) {
|
||
const heightValue = enabledHeights[h];
|
||
const trajectory = {
|
||
name: `height${heightValue}_rot${ROT_VALUE}`,
|
||
width: droneSize,
|
||
height: droneSize,
|
||
frameRate,
|
||
numFrames,
|
||
durationSeconds: numFrames / frameRate,
|
||
cameraFrames: cameraFramesByHeight[heightValue]
|
||
};
|
||
await saveTrajectory(sceneMeta, heightValue, ROT_VALUE, trajectory);
|
||
log(`Траектория height${heightValue}_rot${ROT_VALUE}.json сохранена`);
|
||
}
|
||
|
||
log('Сохранение аннотаций positive/semi_positive...');
|
||
await saveAnnotations(sceneMeta, positiveAnnotations, semiPositiveAnnotations);
|
||
|
||
mapDone();
|
||
|
||
setProgress('Готово');
|
||
log(`Готово: захват завершен (${totalShots} кадров)`);
|
||
});
|
||
}
|
||
|
||
window.addEventListener('load', () => {
|
||
main().catch((error) => {
|
||
setProgress('Ошибка');
|
||
log(`Ошибка: ${error.message}`);
|
||
console.error(error);
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|