diff --git a/.gitignore b/.gitignore
index 13ca68d..55e1db8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,10 @@ frontend/web/dist/
# Generated PointNet sonar dataset
sonar_dataset/
+# MLE simulator seafloor runs
+mle_runs/
+gbo_runs/
+
# OS/editor files
.DS_Store
Thumbs.db
diff --git a/backend/dataset_generator.py b/backend/dataset_generator.py
index de60852..e8d8bb8 100644
--- a/backend/dataset_generator.py
+++ b/backend/dataset_generator.py
@@ -9,8 +9,10 @@ Target class 1 = user-provided object; class 0 = seafloor.
from __future__ import annotations
+import json
import math
import random
+from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -18,6 +20,7 @@ from scene_generator import (
apply_transform,
export_npy_float64,
export_obj,
+ parse_obj_labeled_points,
parse_obj_points,
)
@@ -474,6 +477,10 @@ def resolve_output_dir(output_dir: str | Path = "sonar_dataset") -> Path:
return out
+DATASET_RUN_FILENAME = "dataset_run.json"
+DATASET_RUN_VERSION = 1
+
+
def _safe_object_stem(object_name: str | None) -> str:
stem = Path(object_name or "object").stem
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in stem).strip("._-")
@@ -503,6 +510,221 @@ def make_generation_run_dir(base_dir: Path, object_name: str | None = None) -> P
return path
+def _normalize_model_filename(name: str | None) -> str | None:
+ """Keep the full uploaded basename, e.g. ``airplane2.obj``."""
+ if not name:
+ return None
+ base = Path(str(name).strip()).name
+ return base or None
+
+
+def build_run_manifest(
+ *,
+ run_dir: Path,
+ base_dir: Path,
+ count: int,
+ seed: int,
+ output_dir: str,
+ object_name: str | None,
+ object_scale: float,
+ object_scale_is_max: bool,
+ beam_count: int,
+ length_count: int,
+ object_vertex_count: int,
+ stats: dict[str, Any],
+ written: list[dict[str, Any]],
+) -> dict[str, Any]:
+ model_filename = _normalize_model_filename(object_name)
+ return {
+ "version": DATASET_RUN_VERSION,
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
+ "runName": run_dir.name,
+ "outputDir": str(run_dir),
+ "baseDir": str(base_dir),
+ "objectName": model_filename,
+ "settings": {
+ "count": int(count),
+ "seed": int(seed),
+ "outputDir": str(output_dir),
+ "objectScale": float(object_scale),
+ "objectScaleIsMax": bool(object_scale_is_max),
+ "beamCount": int(beam_count),
+ "lengthCount": int(length_count),
+ "objectName": model_filename,
+ },
+ "objectVertexCount": int(object_vertex_count),
+ "stats": stats,
+ "written": written,
+ }
+
+
+def write_run_manifest(run_dir: Path, manifest: dict[str, Any]) -> Path:
+ run_dir.mkdir(parents=True, exist_ok=True)
+ path = run_dir / DATASET_RUN_FILENAME
+ path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
+ return path
+
+
+def read_run_manifest(run_dir: Path) -> dict[str, Any] | None:
+ path = run_dir / DATASET_RUN_FILENAME
+ if not path.is_file():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def _scene_entry_from_files(run_dir: Path, stem: str) -> dict[str, Any]:
+ npy_path = run_dir / f"{stem}.npy"
+ obj_path = run_dir / f"{stem}.obj"
+ entry: dict[str, Any] = {
+ "stem": stem,
+ "visibility": "unknown",
+ "hasObject": None,
+ "pointCount": None,
+ "objectPointCount": None,
+ }
+ if npy_path.is_file():
+ try:
+ rows = load_npy_float64_rows(npy_path)
+ object_point_count = sum(1 for row in rows if int(row[6]) == 1)
+ entry["pointCount"] = len(rows)
+ entry["objectPointCount"] = object_point_count
+ entry["hasObject"] = object_point_count > 0
+ except (OSError, ValueError, IndexError):
+ pass
+ elif obj_path.is_file():
+ try:
+ text = obj_path.read_text(encoding="utf-8", errors="ignore")
+ labeled = parse_obj_labeled_points(text)
+ if labeled:
+ object_point_count = sum(1 for row in labeled if int(row[3]) == 1)
+ entry["pointCount"] = len(labeled)
+ entry["objectPointCount"] = object_point_count
+ entry["hasObject"] = object_point_count > 0
+ else:
+ points = parse_obj_points(text)
+ entry["pointCount"] = len(points)
+ except OSError:
+ pass
+ return entry
+
+
+def scan_run_scenes(run_dir: Path) -> list[dict[str, Any]]:
+ stems: set[str] = set()
+ for pattern in ("*.obj", "*.npy"):
+ for path in run_dir.glob(pattern):
+ if path.is_file():
+ stems.add(path.stem)
+ return [_scene_entry_from_files(run_dir, stem) for stem in sorted(stems)]
+
+
+def _run_has_scene_files(path: Path) -> bool:
+ return any(path.glob("*.obj")) or any(path.glob("*.npy"))
+
+
+def _relative_to_project(path: Path) -> str:
+ project_root = Path(__file__).resolve().parent.parent
+ try:
+ return str(path.relative_to(project_root))
+ except ValueError:
+ return str(path)
+
+
+def list_dataset_runs(output_dir: str | Path = "sonar_dataset") -> list[dict[str, Any]]:
+ """List dataset run folders under the base output directory."""
+ base = resolve_output_dir(output_dir)
+ runs: list[dict[str, Any]] = []
+
+ if base.is_dir() and _run_has_scene_files(base):
+ manifest = read_run_manifest(base)
+ written = manifest.get("written") if manifest else scan_run_scenes(base)
+ runs.append(
+ {
+ "runName": "(корень)",
+ "outputDir": str(base),
+ "loadPath": _relative_to_project(base),
+ "sceneCount": len(written),
+ "hasSettings": manifest is not None,
+ "generatedAt": manifest.get("generatedAt") if manifest else None,
+ "objectName": (manifest or {}).get("objectName")
+ or (manifest or {}).get("settings", {}).get("objectName"),
+ }
+ )
+
+ if not base.is_dir():
+ return runs
+
+ for child in sorted(base.iterdir(), key=lambda p: p.name, reverse=True):
+ if not child.is_dir() or not _run_has_scene_files(child):
+ continue
+ manifest = read_run_manifest(child)
+ written = manifest.get("written") if manifest else scan_run_scenes(child)
+ runs.append(
+ {
+ "runName": child.name,
+ "outputDir": str(child),
+ "loadPath": _relative_to_project(child),
+ "sceneCount": len(written),
+ "hasSettings": manifest is not None,
+ "generatedAt": manifest.get("generatedAt") if manifest else None,
+ "objectName": (manifest or {}).get("objectName")
+ or (manifest or {}).get("settings", {}).get("objectName"),
+ }
+ )
+ return runs
+
+
+def load_dataset_run(output_dir: str | Path) -> dict[str, Any]:
+ """Load a dataset run folder: settings, stats and scene list."""
+ run_dir = resolve_output_dir(output_dir)
+ if not run_dir.is_dir():
+ raise FileNotFoundError(f"Dataset folder not found: {run_dir}")
+ if not _run_has_scene_files(run_dir):
+ raise FileNotFoundError(f"No scene files in dataset folder: {run_dir}")
+
+ manifest = read_run_manifest(run_dir)
+ written = manifest.get("written") if manifest else scan_run_scenes(run_dir)
+ if not written:
+ raise FileNotFoundError(f"No scenes found in dataset folder: {run_dir}")
+
+ settings = dict((manifest or {}).get("settings") or {})
+ model_filename = (
+ settings.get("objectName")
+ or (manifest.get("objectName") if manifest else None)
+ )
+ stats = dict((manifest or {}).get("stats") or {})
+ if not stats:
+ stats = {
+ "total": len(written),
+ "withObject": sum(1 for item in written if item.get("hasObject")),
+ "withoutObject": sum(1 for item in written if item.get("hasObject") is False),
+ }
+
+ base_dir = Path(manifest["baseDir"]) if manifest and manifest.get("baseDir") else run_dir.parent
+ return {
+ "outputDir": str(run_dir),
+ "baseDir": str(base_dir),
+ "runName": manifest.get("runName") if manifest else run_dir.name,
+ "generatedAt": manifest.get("generatedAt") if manifest else None,
+ "hasSettings": manifest is not None,
+ "settings": settings,
+ "objectVertexCount": (manifest or {}).get("objectVertexCount"),
+ "stats": stats,
+ "written": written,
+ "count": settings.get("count") or len(written),
+ "seed": settings.get("seed"),
+ "beamCount": settings.get("beamCount"),
+ "lengthCount": settings.get("lengthCount"),
+ "objectScale": settings.get("objectScale"),
+ "objectScaleIsMax": settings.get("objectScaleIsMax"),
+ "objectName": model_filename,
+ "classLabels": {"0": "background", "1": "object"},
+ }
+
+
def _downsample_points(points: list[list[float]], max_points: int) -> list[list[float]]:
max_points = max(100, int(max_points))
if len(points) <= max_points:
@@ -584,8 +806,10 @@ def load_scene_preview(
labeled = [[float(r[0]), float(r[1]), float(r[2]), float(r[6])] for r in rows]
elif obj_path.is_file():
text = obj_path.read_text(encoding="utf-8", errors="ignore")
- points = parse_obj_points(text)
- labeled = [[p[0], p[1], p[2], 0.0] for p in points]
+ labeled = parse_obj_labeled_points(text)
+ if not labeled:
+ points = parse_obj_points(text)
+ labeled = [[p[0], p[1], p[2], 0.0] for p in points]
else:
raise FileNotFoundError(f"Scene not found: {safe}.npy / {safe}.obj")
@@ -613,7 +837,11 @@ def write_scene_files(
npy_path = output_dir / f"{stem}.npy"
obj_path = output_dir / f"{stem}.obj"
npy_path.write_bytes(export_npy_float64(scene["rows"]))
- obj_path.write_text(export_obj(scene["points"], object_name=stem), encoding="utf-8")
+ classes = [int(r[6]) for r in scene["rows"]]
+ obj_path.write_text(
+ export_obj(scene["points"], object_name=stem, classes=classes),
+ encoding="utf-8",
+ )
return {"npy": str(npy_path), "obj": str(obj_path), "stem": stem}
@@ -662,6 +890,8 @@ def iter_generate_dataset(
if length_count > 1024:
raise ValueError("length_count (Длина) must be <= 1024")
+ object_name = _normalize_model_filename(object_name)
+
base = resolve_output_dir(output_dir)
run_dir = make_generation_run_dir(base, object_name)
template = normalize_object_points(object_points)
@@ -773,6 +1003,23 @@ def iter_generate_dataset(
"written": written,
"preview": preview,
}
+ manifest = build_run_manifest(
+ run_dir=run_dir,
+ base_dir=base,
+ count=count,
+ seed=int(seed),
+ output_dir=str(output_dir),
+ object_name=object_name,
+ object_scale=object_scale,
+ object_scale_is_max=object_scale_is_max,
+ beam_count=beam_count,
+ length_count=length_count,
+ object_vertex_count=len(template),
+ stats=stats,
+ written=written,
+ )
+ write_run_manifest(run_dir, manifest)
+ result["settingsPath"] = str(run_dir / DATASET_RUN_FILENAME)
yield {"type": "done", "result": result}
diff --git a/backend/main.py b/backend/main.py
index ed6e2fe..5fe4704 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -18,7 +18,15 @@ from pydantic import BaseModel
from builtin_presets import BUILTIN_PRESETS, get_builtin_preset
from demo_generator import DEMO_SURFACE_TYPES, demo_payload
from pipeline_insights import compute_insights
-from dataset_generator import iter_generate_dataset, load_object_points_from_obj_text, load_scene_preview
+from dataset_generator import (
+ iter_generate_dataset,
+ list_dataset_runs,
+ load_dataset_run,
+ load_object_points_from_obj_text,
+ load_scene_preview,
+ resolve_output_dir,
+)
+from mle_simulator import load_last_settings, prepare_mle_scene, save_last_settings, save_survey_surface
from scene_generator import (
catalog_payload as generator_catalog_payload,
export_npy_float64,
@@ -123,6 +131,54 @@ class DatasetPreviewBody(BaseModel):
maxPoints: int = 25000
+class DatasetLoadBody(BaseModel):
+ outputDir: str
+
+
+class MlePrepareBody(BaseModel):
+ seed: int = 42
+ sizeX: float = 40.0
+ sizeY: float = 60.0
+ resX: int = 80
+ resY: int = 120
+ outputDir: str = "mle_runs"
+ settings: dict[str, Any] | None = None
+
+
+class MleSaveSurfaceBody(BaseModel):
+ outputDir: str
+ vertices: list[list[float]]
+ faces: list[list[int]]
+ filename: str = "seafloor.obj"
+
+
+class MleSettingsBody(BaseModel):
+ outputDir: str = "mle_runs"
+ settings: dict[str, Any]
+
+
+class GboPrepareBody(BaseModel):
+ seed: int = 42
+ sizeX: float = 40.0
+ sizeY: float = 60.0
+ resX: int = 80
+ resY: int = 120
+ outputDir: str = "gbo_runs"
+ settings: dict[str, Any] | None = None
+
+
+class GboSaveSurfaceBody(BaseModel):
+ outputDir: str
+ vertices: list[list[float]]
+ faces: list[list[int]]
+ filename: str = "seafloor.obj"
+
+
+class GboSettingsBody(BaseModel):
+ outputDir: str = "gbo_runs"
+ settings: dict[str, Any]
+
+
def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
return preset
@@ -481,6 +537,26 @@ def dataset_preview(body: DatasetPreviewBody) -> dict[str, Any]:
raise HTTPException(status_code=500, detail=f"Failed to load scene: {exc}") from exc
+@app.get("/api/dataset/runs")
+def dataset_runs(outputDir: str = "sonar_dataset") -> dict[str, Any]:
+ try:
+ base_path = resolve_output_dir(outputDir or "sonar_dataset")
+ runs = list_dataset_runs(outputDir or "sonar_dataset")
+ return {"baseDir": str(base_path), "runs": runs}
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to list dataset runs: {exc}") from exc
+
+
+@app.post("/api/dataset/load")
+def dataset_load(body: DatasetLoadBody) -> dict[str, Any]:
+ try:
+ return load_dataset_run(body.outputDir)
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to load dataset run: {exc}") from exc
+
+
@app.post("/api/generator/export")
def generator_export(body: GeneratorExportBody) -> Response:
from urllib.parse import quote
@@ -718,5 +794,123 @@ def dataset_spa() -> FileResponse:
return index()
+@app.get("/mle")
+def mle_spa() -> FileResponse:
+ return index()
+
+
+@app.post("/api/mle/prepare")
+def mle_prepare(body: MlePrepareBody) -> dict[str, Any]:
+ try:
+ result = prepare_mle_scene(
+ seed=body.seed,
+ size_x=body.sizeX,
+ size_y=body.sizeY,
+ res_x=body.resX,
+ res_y=body.resY,
+ output_dir=body.outputDir or "mle_runs",
+ settings=body.settings,
+ )
+ if body.settings:
+ try:
+ save_last_settings(body.settings, output_dir=body.outputDir or "mle_runs")
+ except OSError:
+ pass
+ return result
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to prepare MLE scene: {exc}") from exc
+
+
+@app.get("/api/mle/settings")
+def mle_get_settings(outputDir: str = "mle_runs") -> dict[str, Any]:
+ return load_last_settings(output_dir=outputDir or "mle_runs")
+
+
+@app.put("/api/mle/settings")
+def mle_put_settings(body: MleSettingsBody) -> dict[str, Any]:
+ try:
+ return save_last_settings(body.settings or {}, output_dir=body.outputDir or "mle_runs")
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to save MLE settings: {exc}") from exc
+
+
+@app.post("/api/mle/save-surface")
+def mle_save_surface(body: MleSaveSurfaceBody) -> dict[str, Any]:
+ try:
+ return save_survey_surface(
+ output_dir=body.outputDir,
+ vertices=body.vertices,
+ faces=body.faces,
+ filename=body.filename or "seafloor.obj",
+ )
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to save survey surface: {exc}") from exc
+
+
+@app.get("/gbo")
+def gbo_spa() -> FileResponse:
+ return index()
+
+
+@app.post("/api/gbo/prepare")
+def gbo_prepare(body: GboPrepareBody) -> dict[str, Any]:
+ try:
+ result = prepare_mle_scene(
+ seed=body.seed,
+ size_x=body.sizeX,
+ size_y=body.sizeY,
+ res_x=body.resX,
+ res_y=body.resY,
+ output_dir=body.outputDir or "gbo_runs",
+ settings=body.settings,
+ )
+ if body.settings:
+ try:
+ save_last_settings(body.settings, output_dir=body.outputDir or "gbo_runs")
+ except OSError:
+ pass
+ return result
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to prepare GBO scene: {exc}") from exc
+
+
+@app.get("/api/gbo/settings")
+def gbo_get_settings(outputDir: str = "gbo_runs") -> dict[str, Any]:
+ return load_last_settings(output_dir=outputDir or "gbo_runs")
+
+
+@app.put("/api/gbo/settings")
+def gbo_put_settings(body: GboSettingsBody) -> dict[str, Any]:
+ try:
+ return save_last_settings(body.settings or {}, output_dir=body.outputDir or "gbo_runs")
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to save GBO settings: {exc}") from exc
+
+
+@app.post("/api/gbo/save-surface")
+def gbo_save_surface(body: GboSaveSurfaceBody) -> dict[str, Any]:
+ try:
+ return save_survey_surface(
+ output_dir=body.outputDir,
+ vertices=body.vertices,
+ faces=body.faces,
+ filename=body.filename or "seafloor.obj",
+ )
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to save GBO survey surface: {exc}") from exc
+
+
if (WEB_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
diff --git a/backend/mle_simulator.py b/backend/mle_simulator.py
new file mode 100644
index 0000000..7e5dd37
--- /dev/null
+++ b/backend/mle_simulator.py
@@ -0,0 +1,321 @@
+"""Multibeam echosounder (МЛЭ) simulator helpers: seafloor mesh + OBJ export."""
+
+from __future__ import annotations
+
+import json
+import math
+import random
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+def resolve_mle_dir(output_dir: str | Path = "mle_runs") -> Path:
+ out = Path(output_dir)
+ if not out.is_absolute():
+ project_root = Path(__file__).resolve().parent.parent
+ out = project_root / out
+ return out
+
+
+LAST_SETTINGS_FILENAME = "_last_settings.json"
+
+
+def save_last_settings(
+ settings: dict[str, Any],
+ *,
+ output_dir: str | Path = "mle_runs",
+) -> dict[str, Any]:
+ """Persist UI preset so it survives app restarts."""
+ base = resolve_mle_dir(output_dir)
+ base.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "savedAt": datetime.now(timezone.utc).isoformat(),
+ "settings": settings or {},
+ }
+ path = base / LAST_SETTINGS_FILENAME
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ return {"ok": True, "path": str(path), "savedAt": payload["savedAt"]}
+
+
+def load_last_settings(*, output_dir: str | Path = "mle_runs") -> dict[str, Any]:
+ """Load last UI preset from mle_runs/_last_settings.json."""
+ path = resolve_mle_dir(output_dir) / LAST_SETTINGS_FILENAME
+ if not path.is_file():
+ return {"settings": None, "savedAt": None, "path": str(path)}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {"settings": None, "savedAt": None, "path": str(path)}
+ settings = data.get("settings") if isinstance(data, dict) else None
+ if not isinstance(settings, dict):
+ settings = data if isinstance(data, dict) else None
+ saved_at = data.get("savedAt") if isinstance(data, dict) else None
+ return {"settings": settings, "savedAt": saved_at, "path": str(path)}
+
+
+def _height_at(
+ x: float,
+ y: float,
+ *,
+ base_z: float,
+ amplitude: float,
+ frequency: float,
+ hills: list[tuple[float, float, float, float]],
+ valleys: list[tuple[float, float, float, float]],
+ bumps: list[tuple[float, float, float, float]],
+) -> float:
+ z = base_z + amplitude * math.sin(frequency * x) * math.cos(frequency * 0.7 * y)
+ for hx, hy, hamp, hrad in hills:
+ d2 = (x - hx) ** 2 + (y - hy) ** 2
+ z += hamp * math.exp(-d2 / max(hrad * hrad, 1e-6))
+ for vx, vy, vamp, vrad in valleys:
+ d2 = (x - vx) ** 2 + (y - vy) ** 2
+ z -= vamp * math.exp(-d2 / max(vrad * vrad, 1e-6))
+ for bx, by, bamp, brad in bumps:
+ d2 = (x - bx) ** 2 + (y - by) ** 2
+ z += bamp * math.exp(-d2 / max(brad * brad, 1e-6))
+ return z
+
+
+def build_seafloor_params(
+ seed: int = 42,
+ *,
+ size_x: float = 40.0,
+ size_y: float = 60.0,
+) -> dict[str, Any]:
+ rng = random.Random(int(seed))
+ size_x = max(4.0, float(size_x))
+ size_y = max(4.0, float(size_y))
+ base_z = rng.uniform(-8.0, -3.0)
+ amplitude = rng.uniform(0.15, 0.6)
+ frequency = rng.uniform(0.15, 0.55)
+ hills = [
+ (
+ rng.uniform(-size_x * 0.4, size_x * 0.4),
+ rng.uniform(-size_y * 0.4, size_y * 0.4),
+ rng.uniform(0.3, 1.4),
+ rng.uniform(2.0, 8.0),
+ )
+ for _ in range(rng.randint(2, 5))
+ ]
+ valleys = [
+ (
+ rng.uniform(-size_x * 0.4, size_x * 0.4),
+ rng.uniform(-size_y * 0.4, size_y * 0.4),
+ rng.uniform(0.2, 0.9),
+ rng.uniform(2.0, 7.0),
+ )
+ for _ in range(rng.randint(1, 4))
+ ]
+ bumps = [
+ (
+ rng.uniform(-size_x * 0.45, size_x * 0.45),
+ rng.uniform(-size_y * 0.45, size_y * 0.45),
+ rng.uniform(0.05, 0.4),
+ rng.uniform(0.4, 2.0),
+ )
+ for _ in range(rng.randint(8, 20))
+ ]
+ return {
+ "seed": int(seed),
+ "sizeX": size_x,
+ "sizeY": size_y,
+ "baseZ": base_z,
+ "amplitude": amplitude,
+ "frequency": frequency,
+ "hills": hills,
+ "valleys": valleys,
+ "bumps": bumps,
+ }
+
+
+def sample_seafloor_grid(
+ params: dict[str, Any],
+ *,
+ res_x: int = 80,
+ res_y: int = 120,
+) -> dict[str, Any]:
+ """Build a triangulated seafloor mesh over [-sizeX/2, sizeX/2] × [-sizeY/2, sizeY/2]."""
+ res_x = max(4, int(res_x))
+ res_y = max(4, int(res_y))
+ size_x = float(params["sizeX"])
+ size_y = float(params["sizeY"])
+ half_x = size_x * 0.5
+ half_y = size_y * 0.5
+
+ vertices: list[list[float]] = []
+ heights: list[list[float]] = []
+ for j in range(res_y):
+ row: list[float] = []
+ y = -half_y if res_y == 1 else (-half_y + size_y * j / (res_y - 1))
+ for i in range(res_x):
+ x = -half_x if res_x == 1 else (-half_x + size_x * i / (res_x - 1))
+ z = _height_at(
+ x,
+ y,
+ base_z=float(params["baseZ"]),
+ amplitude=float(params["amplitude"]),
+ frequency=float(params["frequency"]),
+ hills=params["hills"],
+ valleys=params["valleys"],
+ bumps=params["bumps"],
+ )
+ vertices.append([x, y, z])
+ row.append(z)
+ heights.append(row)
+
+ faces: list[list[int]] = []
+ for j in range(res_y - 1):
+ for i in range(res_x - 1):
+ a = j * res_x + i
+ b = a + 1
+ c = a + res_x
+ d = c + 1
+ faces.append([a + 1, c + 1, b + 1]) # 1-based OBJ indices
+ faces.append([b + 1, c + 1, d + 1])
+
+ return {
+ "resX": res_x,
+ "resY": res_y,
+ "vertices": vertices,
+ "faces": faces,
+ "heights": heights,
+ "vertexCount": len(vertices),
+ "faceCount": len(faces),
+ }
+
+
+def export_mesh_obj(
+ vertices: list[list[float]],
+ faces: list[list[int]],
+ *,
+ object_name: str = "seafloor",
+) -> str:
+ safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "seafloor")) or "seafloor"
+ lines = [
+ f"# DotsToSurface MLE seafloor ({len(vertices)} vertices, {len(faces)} faces)",
+ f"o {safe}",
+ ]
+ for v in vertices:
+ lines.append(f"v {v[0]:.8f} {v[1]:.8f} {v[2]:.8f}")
+ for f in faces:
+ lines.append(f"f {f[0]} {f[1]} {f[2]}")
+ return "\n".join(lines) + "\n"
+
+
+def write_mesh_obj_file(
+ path: Path,
+ vertices: list[list[float]],
+ faces: list[list[int]],
+ *,
+ object_name: str = "seafloor",
+) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ export_mesh_obj(vertices, faces, object_name=object_name),
+ encoding="utf-8",
+ )
+ return path
+
+
+def save_survey_surface(
+ *,
+ output_dir: str | Path,
+ vertices: list[list[float]],
+ faces: list[list[int]],
+ filename: str = "seafloor.obj",
+) -> dict[str, Any]:
+ """Overwrite the survey OBJ inside an existing MLE run folder."""
+ run_dir = Path(output_dir)
+ if not run_dir.is_absolute():
+ run_dir = resolve_mle_dir(run_dir)
+ if not run_dir.is_dir():
+ raise FileNotFoundError(f"MLE run folder not found: {run_dir}")
+ if not vertices:
+ raise ValueError("vertices must not be empty")
+ obj_path = write_mesh_obj_file(
+ run_dir / filename,
+ vertices,
+ faces,
+ object_name="seafloor",
+ )
+ return {
+ "outputDir": str(run_dir),
+ "seafloorObj": str(obj_path),
+ "vertexCount": len(vertices),
+ "faceCount": len(faces),
+ }
+
+
+def prepare_mle_scene(
+ *,
+ seed: int = 42,
+ size_x: float = 40.0,
+ size_y: float = 60.0,
+ res_x: int = 80,
+ res_y: int = 120,
+ output_dir: str | Path = "mle_runs",
+ settings: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Generate seafloor for simulation; create run folder with empty survey OBJ."""
+ base = resolve_mle_dir(output_dir)
+ base.mkdir(parents=True, exist_ok=True)
+ stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ run_dir = base / stamp
+ n = 2
+ while run_dir.exists():
+ run_dir = base / f"{stamp}_{n}"
+ n += 1
+ run_dir.mkdir(parents=True, exist_ok=False)
+
+ params = build_seafloor_params(seed, size_x=size_x, size_y=size_y)
+ mesh = sample_seafloor_grid(params, res_x=res_x, res_y=res_y)
+ # Survey OBJ starts empty and is filled from multibeam hits during motion.
+ obj_path = run_dir / "seafloor.obj"
+ obj_path.write_text(
+ "# DotsToSurface MLE survey surface (populated during AUV motion)\no seafloor\n",
+ encoding="utf-8",
+ )
+ # Keep full terrain for reference / debugging (not the survey panel content).
+ write_mesh_obj_file(
+ run_dir / "terrain_full.obj",
+ mesh["vertices"],
+ mesh["faces"],
+ object_name="terrain_full",
+ )
+
+ manifest = {
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
+ "runName": run_dir.name,
+ "outputDir": str(run_dir),
+ "seafloorObj": str(obj_path),
+ "params": params,
+ "mesh": {
+ "resX": mesh["resX"],
+ "resY": mesh["resY"],
+ "vertexCount": mesh["vertexCount"],
+ "faceCount": mesh["faceCount"],
+ },
+ "settings": settings or {},
+ }
+ (run_dir / "mle_run.json").write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+
+ return {
+ "runName": run_dir.name,
+ "outputDir": str(run_dir),
+ "seafloorObj": str(obj_path),
+ "params": params,
+ "mesh": {
+ "resX": mesh["resX"],
+ "resY": mesh["resY"],
+ "vertices": mesh["vertices"],
+ "faces": mesh["faces"],
+ "heights": mesh["heights"],
+ "vertexCount": mesh["vertexCount"],
+ "faceCount": mesh["faceCount"],
+ },
+ }
diff --git a/backend/scene_generator.py b/backend/scene_generator.py
index 78409fc..f7b6316 100644
--- a/backend/scene_generator.py
+++ b/backend/scene_generator.py
@@ -649,13 +649,87 @@ def export_ply(points: list[list[float]]) -> str:
return header + body + ("\n" if points else "")
-def export_obj(points: list[list[float]], object_name: str = "cloud") -> str:
+def export_obj(
+ points: list[list[float]],
+ object_name: str = "cloud",
+ classes: list[int | float] | None = None,
+) -> str:
safe_name = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in (object_name or "cloud")) or "cloud"
- lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"]
- for p in points:
- lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
+ if classes is None:
+ lines = [f"# DotsToSurface point cloud ({len(points)} vertices)", f"o {safe_name}"]
+ for p in points:
+ lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
+ return "\n".join(lines) + "\n"
+
+ if len(classes) != len(points):
+ raise ValueError("classes length must match points length")
+
+ class_names = {0: "background", 1: "object"}
+ grouped: dict[int, list[list[float]]] = {}
+ for point, cls in zip(points, classes):
+ grouped.setdefault(int(cls), []).append(point)
+
+ lines = [
+ f"# DotsToSurface labeled point cloud ({len(points)} vertices)",
+ "# Classes: background=0, object=1",
+ f"o {safe_name}",
+ ]
+ for cls_id in sorted(grouped.keys()):
+ group_name = class_names.get(cls_id, f"class_{cls_id}")
+ lines.append(f"o {group_name}")
+ lines.append(f"# class {cls_id}")
+ for p in grouped[cls_id]:
+ lines.append(f"v {p[0]:.8f} {p[1]:.8f} {p[2]:.8f}")
return "\n".join(lines) + "\n"
+
+def _class_from_obj_group(name: str) -> float | None:
+ key = (name or "").strip().lower()
+ if key == "background":
+ return 0.0
+ if key == "object":
+ return 1.0
+ if key.startswith("class_"):
+ try:
+ return float(key.split("_", 1)[1])
+ except (IndexError, ValueError):
+ return None
+ return None
+
+
+def parse_obj_labeled_points(text: str) -> list[list[float]]:
+ """Extract [x, y, z, class] from OBJ with class groups or ``# class N`` markers."""
+ labeled: list[list[float]] = []
+ current_class = 0.0
+ for raw in text.splitlines():
+ line = raw.strip()
+ if not line:
+ continue
+ lower = line.lower()
+ if lower.startswith("# class "):
+ try:
+ current_class = float(line.split()[-1])
+ except ValueError:
+ pass
+ continue
+ if lower.startswith("o "):
+ cls = _class_from_obj_group(line[2:])
+ if cls is not None:
+ current_class = cls
+ continue
+ if lower.startswith("v "):
+ parts = line.split()
+ if len(parts) < 4:
+ continue
+ try:
+ labeled.append(
+ [float(parts[1]), float(parts[2]), float(parts[3]), current_class]
+ )
+ except ValueError:
+ continue
+ return labeled
+
+
def parse_obj_points(text: str) -> list[list[float]]:
"""Extract vertex positions from Wavefront OBJ (ignores faces/materials)."""
points: list[list[float]] = []
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 9808a00..836469a 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -11,6 +11,9 @@ services:
USER_PRESETS_DIR: /app/data/user-presets
volumes:
- ../presets:/app/presets:ro
+ - ../sonar_dataset:/app/sonar_dataset
+ - ../mle_runs:/app/mle_runs
+ - ../gbo_runs:/app/gbo_runs
- dottosurface-user-presets:/app/data/user-presets
volumes:
diff --git a/frontend/web/src/App.vue b/frontend/web/src/App.vue
index 711ec8f..955c2ac 100644
--- a/frontend/web/src/App.vue
+++ b/frontend/web/src/App.vue
@@ -47,6 +47,20 @@ onMounted(async () => {
>
Генератор Датасета
+