Files
LisCastle/capture.html
2026-04-16 15:26:59 +03:00

909 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>