Initial commit: v1.0
This commit is contained in:
503
server.js
Normal file
503
server.js
Normal 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} ║
|
||||
║ ║
|
||||
║ Откройте браузер и перейдите по адресу выше ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
Reference in New Issue
Block a user