Initial commit: v1.0

This commit is contained in:
Azidaan
2026-04-16 15:26:59 +03:00
commit 5c5404b122
23 changed files with 7564 additions and 0 deletions

908
capture.html Normal file
View File

@@ -0,0 +1,908 @@
<!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>