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

503
server.js Normal file
View File

@@ -0,0 +1,503 @@
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}
║ ║
║ Откройте браузер и перейдите по адресу выше ║
║ ║
╚════════════════════════════════════════════════════════════╝
`);
});