const express = require("express"); const fs = require("fs").promises; const path = require("path"); const { PNG } = require("pngjs"); const UTIF = require("utif"); const app = express(); const PORT = Math.max(1, Number.parseInt(process.env.PORT, 10) || 3000); app.use(express.json({ limit: "50mb" })); app.use(express.static(__dirname)); const SETTINGS_FILE = path.join(__dirname, "settings.json"); const OUTPUT_DIR = path.join(__dirname, "output"); 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 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((token) => ({ value: dmsToDecimal(token), direction: token.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 text1 = String(settings.point1 || "").trim(); const text2 = String(settings.point2 || "").trim(); if (!text1 || !text2) { throw new Error("Координаты не указаны"); } const p1 = parseCoordinatePair(text1); const p2 = parseCoordinatePair(text2); 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 decimalToDMS(decimal, isLatitude) { const absolute = Math.abs(decimal); const degrees = Math.floor(absolute); const minutesFloat = (absolute - degrees) * 60; let minutes = Math.floor(minutesFloat); let seconds = Math.round((minutesFloat - minutes) * 60); if (seconds === 60) { seconds = 0; minutes += 1; } const direction = isLatitude ? decimal >= 0 ? "N" : "S" : decimal >= 0 ? "E" : "W"; return `${degrees}°${minutes}'${seconds}"${direction}`; } function normalizeSettings(rawSettings) { const source = rawSettings || {}; const settings = { dataset: String(source.dataset || "Country").trim() || "Country", country: String(source.country || "Custom").trim() || "Custom", city: String(source.city || "Scene").trim() || "Scene", region: String(source.region || "Region").trim() || "Region", point1: String(source.point1 || "").trim(), point2: String(source.point2 || "").trim(), tile_m: Number(source.tile_m), grid_step: Math.max(1, Number.parseInt(source.grid_step, 10) || 1), height_100: source.height_100 !== undefined ? Boolean(source.height_100) : true, height_125: source.height_125 !== undefined ? Boolean(source.height_125) : true, height_150: source.height_150 !== undefined ? Boolean(source.height_150) : true, height_100_value: Math.max(1, Number(source.height_100_value) || 100), height_125_value: Math.max(1, Number(source.height_125_value) || 125), height_150_value: Math.max(1, Number(source.height_150_value) || 150), height_absolute: source.height_absolute !== undefined ? Boolean(source.height_absolute) : true, db_crop_sizes: String(source.db_crop_sizes || "100,150,200,250,300,400,500,512,600,800,1000").trim(), drone_size: Math.max(1, Number.parseInt(source.drone_size, 10) || 512), query_fov_deg: Math.min(179, Math.max(1, Number(source.query_fov_deg) || 30)), query_pitch_deg: -90, preload: source.preload !== undefined ? Boolean(source.preload) : true, skip_empty: Boolean(source.skip_empty) }; const uniqueDbSizes = 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 (!Number.isFinite(settings.tile_m) || settings.tile_m <= 0) { throw new Error("Tile (метры) должен быть больше 0"); } if (!settings.height_100 && !settings.height_125 && !settings.height_150) { throw new Error("Выберите хотя бы одну высоту (100/125/150)"); } if (uniqueDbSizes.length !== 11) { throw new Error("DB crop sizes: нужно ровно 11 уникальных размеров"); } settings.db_crop_sizes = uniqueDbSizes.join(","); getBoundsFromSettings(settings); return settings; } function normalizeSceneSettings(rawSettings) { const source = rawSettings || {}; return { dataset: String(source.dataset || "Country").trim() || "Country", country: String(source.country || "Custom").trim() || "Custom", city: String(source.city || "Scene").trim() || "Scene", region: String(source.region || "Region").trim() || "Region" }; } 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 generateCoordinates(settings) { const bounds = getBoundsFromSettings(settings); const tileMeters = Math.max(1, Number(settings.tile_m) || 1); const step = Math.max(1, settings.grid_step || 1); const { cols, rows } = computeGrid(bounds, tileMeters); const coordinates = []; 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); coordinates.push({ index: index++, row, col, lat, lon, latDMS: decimalToDMS(lat, true), lonDMS: decimalToDMS(lon, false) }); } } return { coordinates, bounds, gridRows: rows, gridCols: cols }; } function dataUrlToBuffer(dataUrl) { if (typeof dataUrl !== "string") { throw new Error("Некорректные данные изображения"); } const match = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/); if (!match) { throw new Error("Ожидался PNG или JPEG в формате data URL"); } return Buffer.from(match[2], "base64"); } function pngBufferToTiffBuffer(pngBuffer) { const png = PNG.sync.read(pngBuffer); return Buffer.from(UTIF.encodeImage(png.data, png.width, png.height)); } function sanitizeNameSegment(name, fallback) { const clean = String(name || "") .trim() .replace(/[\\/:*?"<>|]/g, "_") .replace(/\s+/g, "_"); return clean || fallback; } function getScenePath(settings) { return path.join( OUTPUT_DIR, sanitizeNameSegment(settings.dataset, "Country"), sanitizeNameSegment(settings.country, "Custom"), sanitizeNameSegment(settings.city, "Scene"), sanitizeNameSegment(settings.region, "Region") ); } app.post("/api/save-settings", async (req, res) => { try { const settings = normalizeSettings(req.body); await fs.writeFile(SETTINGS_FILE, JSON.stringify(settings, null, 2)); res.json({ success: true, message: "Настройки сохранены" }); } catch (error) { console.error("Ошибка сохранения настроек:", error); res.status(500).json({ success: false, error: error.message }); } }); app.post("/api/create-folders", async (req, res) => { try { const settings = normalizeSettings(req.body); const { coordinates, bounds, gridRows, gridCols } = generateCoordinates(settings); const scenePath = getScenePath(settings); const regionName = sanitizeNameSegment(settings.region, "Region"); await fs.mkdir(path.join(scenePath, "DB", "img"), { recursive: true }); await fs.mkdir(path.join(scenePath, "query"), { recursive: true }); await fs.writeFile(path.join(scenePath, "DB", "db_postion.txt"), ""); const regionTxt = `${bounds.maxLat} ${bounds.minLon}\n` + `${bounds.minLat} ${bounds.maxLon}\n`; await fs.writeFile(path.join(scenePath, `${regionName}.txt`), regionTxt); const folders = [ { name: regionName, path: scenePath, coordinates_count: coordinates.length } ]; res.json({ success: true, folders, gridRows, gridCols, bounds, pointsCount: coordinates.length, scenePath, message: `Сцена создана, точек: ${coordinates.length}` }); } catch (error) { console.error("Ошибка создания сцены:", error); res.status(500).json({ success: false, error: error.message }); } }); app.post("/api/save-db-crop", async (req, res) => { try { const settings = normalizeSceneSettings(req.body); const col = Number.parseInt(req.body.col, 10); const row = Number.parseInt(req.body.row, 10); const lat = Number(req.body.lat); const lon = Number(req.body.lon); const resX = Number.parseInt(req.body.res_x, 10) || 0; const resY = Number.parseInt(req.body.res_y, 10) || 0; if (!Number.isInteger(col) || !Number.isInteger(row)) { throw new Error("Некорректные col/row"); } if (!Number.isFinite(lat) || !Number.isFinite(lon)) { throw new Error("Некорректные координаты crop"); } const pngBuffer = dataUrlToBuffer(req.body.png); const scenePath = getScenePath(settings); const imgDir = path.join(scenePath, "DB", "img"); await fs.mkdir(imgDir, { recursive: true }); const sizeTag = Number.parseInt(req.body.size_tag, 10); const suffix = Number.isInteger(sizeTag) && sizeTag > 0 ? `_${sizeTag}` : ""; const filename = `crop_${col}_${row}${suffix}.png`; await fs.writeFile(path.join(imgDir, filename), pngBuffer); const line = `${filename} ${lon} ${lat} ${resX} ${resY}\n`; await fs.appendFile(path.join(scenePath, "DB", "db_postion.txt"), line); res.json({ success: true, filename }); } catch (error) { console.error("Ошибка сохранения DB crop:", error); res.status(500).json({ success: false, error: error.message }); } }); app.post("/api/save-merge-tif", async (req, res) => { try { const settings = normalizeSceneSettings(req.body); const pngBuffer = dataUrlToBuffer(req.body.png); const tiffBuffer = pngBufferToTiffBuffer(pngBuffer); const bounds = getBoundsFromSettings(req.body); const scenePath = getScenePath(settings); const dbDir = path.join(scenePath, "DB"); await fs.mkdir(dbDir, { recursive: true }); await fs.writeFile(path.join(dbDir, "merge.tif"), tiffBuffer); await fs.writeFile(path.join(dbDir, "geo.tif"), tiffBuffer); await fs.writeFile( path.join(dbDir, "geo_bounds.json"), JSON.stringify( { crs: "EPSG:4326", bounds }, null, 2 ) ); res.json({ success: true, files: ["merge.tif", "geo.tif", "geo_bounds.json"] }); } catch (error) { console.error("Ошибка сохранения merge.tif:", error); res.status(500).json({ success: false, error: error.message }); } }); app.post("/api/save-drone-frame", async (req, res) => { try { const settings = normalizeSceneSettings(req.body); const heightValue = Number(req.body.height); const rotValue = Number.isFinite(Number(req.body.rot)) ? Number(req.body.rot) : 0; const frameIdx = Number.parseInt(req.body.frameIdx, 10); if (!Number.isFinite(heightValue) || heightValue <= 0) { throw new Error("Некорректная высота"); } if (!Number.isInteger(frameIdx) || frameIdx < 0) { throw new Error("Некорректный frameIdx"); } const jpegBuffer = dataUrlToBuffer(req.body.jpeg); const scenePath = getScenePath(settings); const variantName = `height${heightValue}_rot${rotValue}`; const footageDir = path.join(scenePath, "query", variantName, "footage"); await fs.mkdir(footageDir, { recursive: true }); const frameStr = String(frameIdx).padStart(2, "0"); const filename = `${variantName}_${frameStr}.jpeg`; await fs.writeFile(path.join(footageDir, filename), jpegBuffer); res.json({ success: true, filename }); } catch (error) { console.error("Ошибка сохранения drone frame:", error); res.status(500).json({ success: false, error: error.message }); } }); app.post("/api/save-trajectory", async (req, res) => { try { const settings = normalizeSceneSettings(req.body); const heightValue = Number(req.body.height); const rotValue = Number.isFinite(Number(req.body.rot)) ? Number(req.body.rot) : 0; const trajectory = req.body.trajectory; if (!Number.isFinite(heightValue) || heightValue <= 0) { throw new Error("Некорректная высота"); } if (!trajectory || !Array.isArray(trajectory.cameraFrames)) { throw new Error("Некорректная траектория"); } const scenePath = getScenePath(settings); const variantName = `height${heightValue}_rot${rotValue}`; const variantDir = path.join(scenePath, "query", variantName); await fs.mkdir(variantDir, { recursive: true }); const filePath = path.join(variantDir, `${variantName}.json`); await fs.writeFile(filePath, JSON.stringify(trajectory, null, 2)); res.json({ success: true, filename: `${variantName}.json` }); } catch (error) { console.error("Ошибка сохранения trajectory:", error); res.status(500).json({ success: false, error: error.message }); } }); app.post("/api/save-annotations", async (req, res) => { try { const settings = normalizeSceneSettings(req.body); const positive = req.body.positive || {}; const semiPositive = req.body.semi_positive || {}; if (typeof positive !== "object" || Array.isArray(positive) || typeof semiPositive !== "object" || Array.isArray(semiPositive)) { throw new Error("Некорректные аннотации"); } const scenePath = getScenePath(settings); await fs.mkdir(scenePath, { recursive: true }); await fs.writeFile(path.join(scenePath, "positive.json"), JSON.stringify(positive, null, 2)); await fs.writeFile(path.join(scenePath, "semi_positive.json"), JSON.stringify(semiPositive, null, 2)); res.json({ success: true, files: ["positive.json", "semi_positive.json"] }); } catch (error) { console.error("Ошибка сохранения аннотаций:", error); res.status(500).json({ success: false, error: error.message }); } }); app.post("/api/start-capture", async (req, res) => { try { const settings = normalizeSettings(req.body); await fs.writeFile(SETTINGS_FILE, JSON.stringify(settings, null, 2)); res.json({ success: true, url: `http://localhost:${PORT}/capture.html`, message: "Захват запущен" }); } catch (error) { console.error("Ошибка запуска захвата:", error); res.status(500).json({ success: false, error: error.message }); } }); app.get("/api/get-settings", async (req, res) => { try { const data = await fs.readFile(SETTINGS_FILE, "utf8"); const settings = JSON.parse(data); settings.query_pitch_deg = -90; res.json({ success: true, settings }); } catch (error) { res.json({ success: true, settings: { dataset: "Country", country: "German", city: "Hannover", region: "Custom", point1: `52°25'10.28"N 9°39'10.04"E`, point2: `52°25'07.34"N 9°39'23.65"E`, tile_m: 49, grid_step: 1, height_100: true, height_125: true, height_150: true, height_100_value: 100, height_125_value: 125, height_150_value: 150, height_absolute: true, db_crop_sizes: "100,150,200,250,300,400,500,512,600,800,1000", drone_size: 512, query_fov_deg: 30, query_pitch_deg: -90, preload: true, skip_empty: false } }); } }); app.listen(PORT, () => { console.log(` ╔════════════════════════════════════════════════════════════╗ ║ ║ ║ 🗺️ SandCastle Server запущен! ║ ║ ║ ║ 📌 URL: http://localhost:${PORT} ║ ║ 📂 Папка вывода: ${OUTPUT_DIR} ║ ║ ║ ║ Откройте браузер и перейдите по адресу выше ║ ║ ║ ╚════════════════════════════════════════════════════════════╝ `); });