Files
LisCastle/server.js
2026-04-16 15:26:59 +03:00

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