504 lines
19 KiB
JavaScript
504 lines
19 KiB
JavaScript
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} ║
|
||
║ ║
|
||
║ Откройте браузер и перейдите по адресу выше ║
|
||
║ ║
|
||
╚════════════════════════════════════════════════════════════╝
|
||
`);
|
||
});
|