Реструктуризация проекта и генератор синтетических датасетов эхолота.

Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 12:25:00 +03:00
co-authored by Cursor
parent 18a58f2e85
commit 4f253b860f
134 changed files with 5263 additions and 863 deletions
+36
View File
@@ -0,0 +1,36 @@
# Backend (HTTP)
**Слой:** Backend — оркестрация и REST API. Вычисления выполняет C++ Engine через CLI.
## Роль
- Приём файлов и конфигурации пайплайна
- Пресеты, validate, wizard, demo-облака (Python)
- Запуск `DotsToSurface --cli` и возврат JSON
- Отдача собранного Vue frontend (`frontend/web/dist`)
## Entry point
- [`main.py`](main.py) — FastAPI
- Запуск: `uvicorn main:app --host 0.0.0.0 --port 8080`
## Зависимости
- **`DOTSTOSURFACE_BIN`** — путь к C++ бинарнику
- Без бинарника `/api/run` не работает
## Запуск приложения
Полный Web-стек (frontend + API + engine) — **в Docker**, см. [../docker/README.md](../docker/README.md).
Локальный `uvicorn` ниже — только для разработки backend без пересборки образа:
```bash
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export DOTSTOSURFACE_BIN=/path/to/DotsToSurface
uvicorn main:app --reload --port 8080
```
См. также: [../ARCHITECTURE.md](../ARCHITECTURE.md)
+80
View File
@@ -0,0 +1,80 @@
"""Built-in pipeline presets (mirrors MainWindow::applyPreset)."""
from __future__ import annotations
from typing import Any
BUILTIN_PRESETS: list[dict[str, Any]] = [
{
"title": "LiDAR-скан",
"idValue": "LiDAR_scan",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": [
"pcl_remove_nan",
"keep_largest_cluster",
"pcl_statistical_outlier",
"pcl_voxel_grid",
],
"reconstructionPlugin": "surface_fallback",
"stageDefaults": {},
},
},
{
"title": "RGB-D камера",
"idValue": "RGBD_camera",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": [
"pcl_remove_nan",
"pcl_statistical_outlier",
"pcl_radius_outlier",
"pcl_voxel_grid",
],
"reconstructionPlugin": "pcl_greedy_triangulation",
"stageDefaults": {},
},
},
{
"title": "Синтетика",
"idValue": "Synthetic_clean",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": ["pcl_remove_nan", "downsample_dense", "pcl_voxel_grid"],
"reconstructionPlugin": "pcl_greedy_triangulation",
"stageDefaults": {},
},
},
{
"title": "Fast",
"idValue": "Fast",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": ["pcl_remove_nan", "pcl_voxel_grid"],
"reconstructionPlugin": "surface_fallback",
"stageDefaults": {},
},
},
{
"title": "Robust",
"idValue": "Robust",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": [
"pcl_remove_nan",
"pcl_voxel_grid",
"pcl_statistical_outlier",
"pcl_radius_outlier",
],
"reconstructionPlugin": "surface_fallback",
"stageDefaults": {},
},
},
]
def get_builtin_preset(preset_id: str) -> dict[str, Any] | None:
for preset in BUILTIN_PRESETS:
if preset["idValue"] == preset_id:
return preset
return None
+785
View File
@@ -0,0 +1,785 @@
"""Batch synthetic sonar dataset generator for PointNet semantic segmentation.
Produces paired Area_X_scene_XXXX.npy + .obj files under sonar_dataset/.
Target class 1 = user-provided object (from .obj mesh vertices); class 0 = seafloor / clutter.
"""
from __future__ import annotations
import math
import random
from pathlib import Path
from typing import Any
from scene_generator import (
apply_transform,
export_npy_float64,
export_obj,
generate_box,
generate_pipe,
generate_sphere,
generate_torus,
parse_obj_points,
points_to_pointnet_rows,
)
# Full dataset layout (train / val / test).
AREA_LAYOUT: list[tuple[int, int]] = [
(1, 75),
(2, 75),
(3, 75),
(4, 75),
(5, 100),
(6, 100),
]
TOTAL_FULL_SCENES = sum(n for _, n in AREA_LAYOUT) # 500
VISIBILITY_TIERS = ("nearly_hidden", "partial", "visible")
# ---------------------------------------------------------------------------
# Area naming
# ---------------------------------------------------------------------------
def scene_index_to_area_name(index: int) -> tuple[int, int, str]:
"""Map 0-based global index → (area, scene_number_1based, stem).
Scene numbers restart at 0001 within each Area.
"""
if index < 0:
raise ValueError("scene index must be >= 0")
remaining = index
for area, count in AREA_LAYOUT:
if remaining < count:
scene_no = remaining + 1
stem = f"Area_{area}_scene_{scene_no:04d}"
return area, scene_no, stem
remaining -= count
scene_no = AREA_LAYOUT[-1][1] + remaining + 1
stem = f"Area_6_scene_{scene_no:04d}"
return 6, scene_no, stem
# ---------------------------------------------------------------------------
# Target object from user .obj
# ---------------------------------------------------------------------------
def normalize_object_points(points: list[list[float]]) -> list[list[float]]:
xs = [p[0] for p in points]
ys = [p[1] for p in points]
zs = [p[2] for p in points]
cx = (min(xs) + max(xs)) * 0.5
cy = (min(ys) + max(ys)) * 0.5
cz = (min(zs) + max(zs)) * 0.5
span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1e-6)
scale = 1.0 / span
return [[(p[0] - cx) * scale, (p[1] - cy) * scale, (p[2] - cz) * scale] for p in points]
def load_object_points_from_obj_text(text: str) -> list[list[float]]:
"""Parse OBJ vertices and normalize to unit local frame (centered, max span ≈ 1)."""
points = parse_obj_points(text)
if len(points) < 3:
raise ValueError("OBJ model must contain at least 3 vertices.")
return normalize_object_points(points)
def resample_object_points(
template: list[list[float]],
count: int,
*,
noise: float = 0.0,
seed: int = 1,
) -> list[list[float]]:
"""Subsample (or sample with replacement) template points to the requested count."""
if not template:
raise ValueError("Object template is empty.")
rng = random.Random(int(seed))
count = max(1, int(count))
out: list[list[float]] = []
n = len(template)
for _ in range(count):
src = template[rng.randrange(n)]
if noise > 0:
out.append(
[
src[0] + rng.uniform(-noise, noise),
src[1] + rng.uniform(-noise, noise),
src[2] + rng.uniform(-noise, noise),
]
)
else:
out.append([src[0], src[1], src[2]])
return out
def object_half_extent_z(points: list[list[float]]) -> float:
if not points:
return 0.35
zs = [p[2] for p in points]
return max(0.05, (max(zs) - min(zs)) * 0.5)
# ---------------------------------------------------------------------------
# Seafloor / clutter
# ---------------------------------------------------------------------------
def _seafloor_height(
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
z += amplitude * math.sin(frequency * x) * math.cos(frequency * 0.7 * y)
z += 0.35 * amplitude * math.sin(frequency * 1.7 * y + 0.4)
for cx, cy, height, radius in hills:
d2 = (x - cx) ** 2 + (y - cy) ** 2
if d2 < radius * radius * 4:
z += height * math.exp(-d2 / max(radius * radius, 1e-6))
for cx, cy, depth, radius in valleys:
d2 = (x - cx) ** 2 + (y - cy) ** 2
if d2 < radius * radius * 4:
z -= depth * math.exp(-d2 / max(radius * radius, 1e-6))
for cx, cy, height, radius in bumps:
d2 = (x - cx) ** 2 + (y - cy) ** 2
if d2 < radius * radius * 4:
z += height * math.exp(-d2 / max(radius * radius * 0.5, 1e-6))
return z
def _generate_seafloor(
rng: random.Random,
*,
beam_count: int = 45,
length_count: int | None = None,
) -> tuple[list[list[float]], dict[str, Any]]:
"""Sample seafloor as a square relief grid.
beam_count controls width resolution (X axis).
length_count controls length resolution (Y axis).
"""
beams = max(1, int(beam_count))
length_points = beams if length_count is None else max(1, int(length_count))
size_x = rng.uniform(8.0, 16.0)
size_y = rng.uniform(8.0, 16.0)
base_z = rng.uniform(-1.2, -0.2)
amplitude = rng.uniform(0.05, 0.35)
frequency = rng.uniform(0.4, 2.2)
noise = rng.uniform(0.005, 0.04)
hills = [
(
rng.uniform(-size_x * 0.4, size_x * 0.4),
rng.uniform(-size_y * 0.4, size_y * 0.4),
rng.uniform(0.15, 0.7),
rng.uniform(0.6, 2.2),
)
for _ in range(rng.randint(1, 4))
]
valleys = [
(
rng.uniform(-size_x * 0.4, size_x * 0.4),
rng.uniform(-size_y * 0.4, size_y * 0.4),
rng.uniform(0.1, 0.55),
rng.uniform(0.5, 2.0),
)
for _ in range(rng.randint(1, 3))
]
bumps = [
(
rng.uniform(-size_x * 0.45, size_x * 0.45),
rng.uniform(-size_y * 0.45, size_y * 0.45),
rng.uniform(0.03, 0.18),
rng.uniform(0.15, 0.55),
)
for _ in range(rng.randint(3, 12))
]
meta = {
"sizeX": size_x,
"sizeY": size_y,
"baseZ": base_z,
"amplitude": amplitude,
"frequency": frequency,
"hills": hills,
"valleys": valleys,
"bumps": bumps,
"beamCount": beams,
"lengthCount": length_points,
"gridWidthPoints": beams,
"gridLengthPoints": length_points,
}
half_x = size_x * 0.5
half_y = size_y * 0.5
points: list[list[float]] = []
for yi in range(length_points):
y = -half_y if length_points == 1 else (-half_y + size_y * yi / (length_points - 1))
for xi in range(beams):
x = -half_x if beams == 1 else (-half_x + size_x * xi / (beams - 1))
x += rng.uniform(-noise * 2, noise * 2)
yj = y + rng.uniform(-noise * 2, noise * 2)
z = _seafloor_height(
x,
yj,
base_z=base_z,
amplitude=amplitude,
frequency=frequency,
hills=hills,
valleys=valleys,
bumps=bumps,
)
z += rng.uniform(-noise, noise)
points.append([x, yj, z])
# Local noise clusters (false sonar clutter blobs)
for _ in range(rng.randint(1, 5)):
cx = rng.uniform(-half_x * 0.8, half_x * 0.8)
cy = rng.uniform(-half_y * 0.8, half_y * 0.8)
cz = _seafloor_height(
cx,
cy,
base_z=base_z,
amplitude=amplitude,
frequency=frequency,
hills=hills,
valleys=valleys,
bumps=bumps,
) + rng.uniform(0.0, 0.25)
n_blob = rng.randint(40, 280)
spread = rng.uniform(0.15, 0.7)
for _ in range(n_blob):
points.append(
[
cx + rng.gauss(0, spread),
cy + rng.gauss(0, spread),
cz + rng.gauss(0, spread * 0.35),
]
)
meta["pingCount"] = length_points
meta["swathBeams"] = beams
return points, meta
def _height_at(x: float, y: float, meta: dict[str, Any]) -> float:
return _seafloor_height(
x,
y,
base_z=float(meta["baseZ"]),
amplitude=float(meta["amplitude"]),
frequency=float(meta["frequency"]),
hills=meta["hills"],
valleys=meta["valleys"],
bumps=meta["bumps"],
)
def _generate_false_objects(rng: random.Random, meta: dict[str, Any]) -> list[list[float]]:
n_objects = rng.randint(0, 6)
points: list[list[float]] = []
half_x = float(meta["sizeX"]) * 0.5
half_y = float(meta["sizeY"]) * 0.5
for i in range(n_objects):
kind = rng.choice(["sphere", "box", "torus", "pipe"])
count = rng.randint(80, 900)
noise = rng.uniform(0.005, 0.03)
seed = rng.randint(0, 10_000_000)
if kind == "sphere":
local = generate_sphere(
{"radius": rng.uniform(0.08, 0.55), "count": count, "noise": noise, "seed": seed}
)
elif kind == "box":
local = generate_box(
{
"sizeX": rng.uniform(0.15, 1.2),
"sizeY": rng.uniform(0.15, 1.0),
"sizeZ": rng.uniform(0.08, 0.6),
"count": count,
"noise": noise,
"seed": seed,
}
)
elif kind == "torus":
major = rng.uniform(0.15, 0.6)
local = generate_torus(
{
"majorR": major,
"minorR": rng.uniform(0.03, major * 0.4),
"count": count,
"noise": noise,
"seed": seed,
}
)
else:
local = generate_pipe(
{
"length": rng.uniform(0.4, 2.5),
"radius": rng.uniform(0.04, 0.2),
"axis": rng.choice(["x", "y", "z"]),
"count": count,
"noise": noise,
"seed": seed,
}
)
tx = rng.uniform(-half_x * 0.75, half_x * 0.75)
ty = rng.uniform(-half_y * 0.75, half_y * 0.75)
floor_z = _height_at(tx, ty, meta)
# Rest on / slightly into seafloor
tz = floor_z + rng.uniform(-0.05, 0.35)
transform = {
"x": tx,
"y": ty,
"z": tz,
"rx": rng.uniform(-0.4, 0.4),
"ry": rng.uniform(-0.4, 0.4),
"rz": rng.uniform(0, 2 * math.pi),
}
world = apply_transform(local, transform)
# Drop points buried deep under seafloor
for p in world:
if p[2] >= _height_at(p[0], p[1], meta) - 0.02:
points.append(p)
return points
# ---------------------------------------------------------------------------
# Balance plan + single scene
# ---------------------------------------------------------------------------
def plan_scene_labels(count: int, seed: int) -> list[str]:
"""Return visibility label per scene: absent | nearly_hidden | partial | visible.
~50% absent; among present scenes, roughly equal nearly_hidden/partial/visible.
"""
count = max(0, int(count))
rng = random.Random(int(seed) ^ 0xA5A5_5A5A)
n_with = (count + 1) // 2 # ceil → ~50% with object
n_without = count - n_with
labels: list[str] = ["absent"] * n_without
for i in range(n_with):
labels.append(VISIBILITY_TIERS[i % 3])
rng.shuffle(labels)
return labels
def _place_object(
rng: random.Random,
meta: dict[str, Any],
visibility: str,
object_template: list[list[float]],
object_scale: float = 1.0,
) -> tuple[list[list[float]], dict[str, Any]]:
"""Sample, transform, and bury target object; return surviving world points + info."""
base_scale = max(0.01, float(object_scale))
if visibility == "nearly_hidden":
count = rng.randint(80, 600)
burial = rng.uniform(0.35, 0.75)
scale = base_scale * rng.uniform(0.7, 1.15)
elif visibility == "partial":
count = rng.randint(400, 2500)
burial = rng.uniform(0.12, 0.4)
scale = base_scale * rng.uniform(0.8, 1.3)
else: # visible
count = rng.randint(1500, 8000)
burial = rng.uniform(-0.05, 0.15)
scale = base_scale * rng.uniform(0.85, 1.4)
noise = rng.uniform(0.004, 0.025)
local = resample_object_points(
object_template,
count,
noise=noise,
seed=rng.randint(0, 10_000_000),
)
# Apply world scale to unit-normalized template
local = [[p[0] * scale, p[1] * scale, p[2] * scale] for p in local]
half_x = float(meta["sizeX"]) * 0.35
half_y = float(meta["sizeY"]) * 0.35
tx = rng.uniform(-half_x, half_x)
ty = rng.uniform(-half_y, half_y)
floor_z = _height_at(tx, ty, meta)
half_h = object_half_extent_z(local)
tz = floor_z + half_h * (1.0 - 2.0 * burial)
transform = {
"x": tx,
"y": ty,
"z": tz,
"rx": rng.uniform(-0.25, 0.25),
"ry": rng.uniform(-0.2, 0.2),
"rz": rng.uniform(0, 2 * math.pi),
}
world = apply_transform(local, transform)
kept: list[list[float]] = []
for p in world:
surface = _height_at(p[0], p[1], meta)
eps = 0.01 if visibility != "nearly_hidden" else -0.02
if p[2] >= surface + eps:
kept.append(p)
if visibility == "nearly_hidden" and len(kept) < 15 and world:
ranked = sorted(world, key=lambda p: p[2] - _height_at(p[0], p[1], meta), reverse=True)
kept = ranked[: max(15, min(40, len(ranked) // 8))]
info = {
"visibility": visibility,
"transform": transform,
"requestedCount": count,
"keptCount": len(kept),
"scale": scale,
"objectScale": base_scale,
"burial": burial,
"classLabel": "object",
"classId": 1,
}
return kept, info
def generate_sonar_scene(
*,
seed: int,
visibility: str = "absent",
object_points: list[list[float]] | None = None,
object_scale: float = 1.0,
beam_count: int = 45,
length_count: int | None = None,
) -> dict[str, Any]:
"""Build one unique sonar scene. visibility in absent|nearly_hidden|partial|visible."""
rng = random.Random(int(seed))
if visibility not in ("absent",) + VISIBILITY_TIERS:
raise ValueError(f"Unknown visibility: {visibility}")
if visibility != "absent" and not object_points:
raise ValueError("object_points required when visibility is not absent.")
floor_pts, meta = _generate_seafloor(rng, beam_count=beam_count, length_count=length_count)
clutter = _generate_false_objects(rng, meta)
jitter = rng.uniform(0.0, 0.015)
background = floor_pts + clutter
if jitter > 0:
background = [
[
p[0] + rng.uniform(-jitter, jitter),
p[1] + rng.uniform(-jitter, jitter),
p[2] + rng.uniform(-jitter, jitter),
]
for p in background
]
drop = rng.uniform(0.0, 0.12)
if drop > 0:
background = [p for p in background if rng.random() >= drop]
object_pts: list[list[float]] = []
object_info: dict[str, Any] | None = None
if visibility != "absent":
object_pts, object_info = _place_object(
rng,
meta,
visibility,
object_points,
object_scale=object_scale,
)
# class 0 = background, class 1 = object
rows = points_to_pointnet_rows(background, 0.0)
rows.extend(points_to_pointnet_rows(object_pts, 1.0))
rng.shuffle(rows)
xyz = [[r[0], r[1], r[2]] for r in rows]
return {
"seed": int(seed),
"visibility": visibility,
"hasObject": visibility != "absent",
"object": object_info,
"pointCount": len(rows),
"objectPointCount": len(object_pts),
"backgroundPointCount": len(background),
"rows": rows,
"points": xyz,
"meta": {
"sizeX": meta["sizeX"],
"sizeY": meta["sizeY"],
"beamCount": meta.get("beamCount", beam_count),
"lengthCount": meta.get("lengthCount", length_count if length_count is not None else beam_count),
"gridWidthPoints": meta.get("gridWidthPoints", beam_count),
"gridLengthPoints": meta.get(
"gridLengthPoints",
length_count if length_count is not None else beam_count,
),
"pingCount": meta.get("pingCount"),
"swathBeams": meta.get("swathBeams"),
"floorFeatures": {
"hills": len(meta["hills"]),
"valleys": len(meta["valleys"]),
"bumps": len(meta["bumps"]),
},
},
}
# ---------------------------------------------------------------------------
# Batch write / preview load
# ---------------------------------------------------------------------------
def resolve_output_dir(output_dir: str | Path = "sonar_dataset") -> Path:
out = Path(output_dir)
if not out.is_absolute():
project_root = Path(__file__).resolve().parent.parent
out = project_root / out
return out
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:
return points
step = max(1, len(points) // max_points)
return points[::step][:max_points]
def load_npy_float64_rows(path: Path) -> list[list[float]]:
"""Read float64 little-endian .npy array written by export_npy_float64."""
import re
import struct
data = path.read_bytes()
if data[:6] != b"\x93NUMPY":
raise ValueError(f"Not a NumPy .npy file: {path.name}")
major = data[6]
if major == 1:
hlen = struct.unpack_from("<H", data, 8)[0]
header = data[10 : 10 + hlen].decode("latin1")
offset = 10 + hlen
elif major == 2:
hlen = struct.unpack_from("<I", data, 8)[0]
header = data[12 : 12 + hlen].decode("latin1")
offset = 12 + hlen
else:
raise ValueError(f"Unsupported .npy version: {major}")
match = re.search(r"shape'\s*:\s*\((\d+)\s*,\s*(\d+)\)", header)
if not match:
match = re.search(r"shape':\s*\((\d+),\s*(\d+)\)", header)
if not match:
raise ValueError(f"Cannot parse .npy shape from {path.name}")
n_rows, n_cols = int(match.group(1)), int(match.group(2))
expected = n_rows * n_cols * 8
body = data[offset : offset + expected]
if len(body) < expected:
raise ValueError(f"Truncated .npy payload in {path.name}")
flat = struct.unpack("<" + "d" * (n_rows * n_cols), body)
return [list(flat[i * n_cols : (i + 1) * n_cols]) for i in range(n_rows)]
def _class_counts_from_labeled(points: list[list[float]]) -> dict[str, int]:
"""Count classes from rows shaped [x, y, z, class]."""
counts: dict[str, int] = {}
for row in points:
key = str(int(round(float(row[3] if len(row) > 3 else 0))))
counts[key] = counts.get(key, 0) + 1
return counts
def _class_counts(rows: list[list[float]]) -> dict[str, int]:
counts: dict[str, int] = {}
for row in rows:
key = str(int(round(float(row[6] if len(row) > 6 else 0))))
counts[key] = counts.get(key, 0) + 1
return counts
def load_scene_preview(
*,
stem: str,
output_dir: str | Path = "sonar_dataset",
max_points: int = 25000,
) -> dict[str, Any]:
"""Load labeled points for a written scene (prefer .npy) for the 3D viewer.
Each preview point is [x, y, z, class].
"""
safe = "".join(ch if ch.isalnum() or ch in "_-" else "" for ch in (stem or ""))
if not safe or safe != stem:
raise ValueError("Invalid scene stem.")
out = resolve_output_dir(output_dir)
npy_path = out / f"{safe}.npy"
obj_path = out / f"{safe}.obj"
labeled: list[list[float]]
if npy_path.is_file():
rows = load_npy_float64_rows(npy_path)
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]
else:
raise FileNotFoundError(f"Scene not found: {safe}.npy / {safe}.obj")
full_counts = _class_counts_from_labeled(labeled)
preview = _downsample_points(labeled, max_points)
return {
"stem": safe,
"outputDir": str(out),
"pointCount": len(labeled),
"previewCount": len(preview),
"points": preview,
"classCounts": full_counts,
"classLabels": {"0": "background", "1": "object"},
"obj": str(obj_path) if obj_path.is_file() else None,
"npy": str(npy_path) if npy_path.is_file() else None,
}
def write_scene_files(
scene: dict[str, Any],
output_dir: Path,
stem: str,
) -> dict[str, str]:
output_dir.mkdir(parents=True, exist_ok=True)
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")
return {"npy": str(npy_path), "obj": str(obj_path), "stem": stem}
def generate_dataset(
*,
count: int = 5,
seed: int = 42,
output_dir: str | Path = "sonar_dataset",
object_points: list[list[float]],
object_name: str | None = None,
object_scale: float = 1.0,
beam_count: int = 45,
length_count: int | None = None,
) -> dict[str, Any]:
"""Generate `count` unique scenes into output_dir with Area_X naming.
object_points: normalized template vertices from user .obj (class 1 = object).
object_scale: relative size multiplier vs unit-normalized mesh (1.0 = default).
beam_count: number of width points for seafloor grid (X axis).
length_count: number of length points for seafloor grid (Y axis). Defaults to beam_count.
"""
count = int(count)
if count < 1:
raise ValueError("count must be >= 1")
if count > 5000:
raise ValueError("count must be <= 5000")
if not object_points or len(object_points) < 3:
raise ValueError("A valid .obj model with at least 3 vertices is required.")
object_scale = float(object_scale)
if object_scale <= 0:
raise ValueError("object_scale must be > 0")
if object_scale > 100:
raise ValueError("object_scale must be <= 100")
beam_count = int(beam_count)
if beam_count < 1:
raise ValueError("beam_count (Кол-во лучей) must be >= 1")
if beam_count > 1024:
raise ValueError("beam_count (Кол-во лучей) must be <= 1024")
if length_count is None:
length_count = beam_count
length_count = int(length_count)
if length_count < 1:
raise ValueError("length_count (Длина) must be >= 1")
if length_count > 1024:
raise ValueError("length_count (Длина) must be <= 1024")
out = resolve_output_dir(output_dir)
template = normalize_object_points(object_points)
labels = plan_scene_labels(count, seed)
written: list[dict[str, Any]] = []
stats = {
"total": count,
"withObject": 0,
"withoutObject": 0,
"nearly_hidden": 0,
"partial": 0,
"visible": 0,
"absent": 0,
}
preview_points: list[list[float]] | None = None
preview_stem: str | None = None
preview_has_object = False
for i in range(count):
visibility = labels[i]
scene_seed = int(seed) + i * 10007 + 17
scene = generate_sonar_scene(
seed=scene_seed,
visibility=visibility,
object_points=template,
object_scale=object_scale,
beam_count=beam_count,
length_count=length_count,
)
area, scene_no, stem = scene_index_to_area_name(i)
paths = write_scene_files(scene, out, stem)
entry = {
"index": i,
"area": area,
"scene": scene_no,
"stem": stem,
"visibility": visibility,
"hasObject": scene["hasObject"],
"pointCount": scene["pointCount"],
"objectPointCount": scene["objectPointCount"],
"files": paths,
}
written.append(entry)
stats[visibility] = stats.get(visibility, 0) + 1
if scene["hasObject"]:
stats["withObject"] += 1
else:
stats["withoutObject"] += 1
if preview_points is None or (scene["hasObject"] and not preview_has_object):
preview_points = [[r[0], r[1], r[2], r[6]] for r in scene["rows"]]
preview_stem = stem
preview_has_object = bool(scene["hasObject"])
preview: dict[str, Any] | None = None
if preview_points is not None:
pts = _downsample_points(preview_points, 25000)
preview = {
"stem": preview_stem,
"points": pts,
"pointCount": len(preview_points),
"classCounts": _class_counts_from_labeled(preview_points),
"classLabels": {"0": "background", "1": "object"},
}
return {
"outputDir": str(out),
"count": count,
"seed": int(seed),
"beamCount": beam_count,
"lengthCount": length_count,
"objectName": object_name,
"objectScale": object_scale,
"objectVertexCount": len(template),
"classLabels": {"0": "background", "1": "object"},
"stats": stats,
"written": written,
"preview": preview,
}
+70
View File
@@ -0,0 +1,70 @@
"""Demo point cloud generation (mirrors generateDemoPoints in mainwindow.cpp)."""
from __future__ import annotations
import math
import random
from typing import Any
def generate_demo_points(surface_type: str, count: int = 350) -> list[list[float]]:
points: list[list[float]] = []
rng = random.Random()
for _ in range(count):
if surface_type == "Дно реки + труба":
if rng.random() < 0.35:
angle = rng.random() * 2.0 * math.pi
y = rng.random() * 2.6 - 1.3
pipe_radius = 0.22
jitter = rng.random() * 0.02 - 0.01
radial = pipe_radius + jitter
x = 0.0 + radial * math.cos(angle)
z = -0.62 + radial * math.sin(angle)
else:
x = rng.random() * 4.0 - 2.0
y = rng.random() * 3.0 - 1.5
waviness = 0.11 * math.cos(2.2 * y)
channel = 0.08 * x * x
noise = rng.random() * 0.04 - 0.02
z = -0.45 + channel + waviness + noise
elif surface_type == "Тор":
u = rng.random() * 2.0 * math.pi
v = rng.random() * 2.0 * math.pi
major_r = 1.0
minor_r = 0.35
jitter = rng.random() * 0.02 - 0.01
radial = minor_r + jitter
x = (major_r + radial * math.cos(v)) * math.cos(u)
y = (major_r + radial * math.cos(v)) * math.sin(u)
z = radial * math.sin(v)
elif surface_type == "Волна":
x = rng.random() * 2.4 - 1.2
y = rng.random() * 2.4 - 1.2
noise = rng.random() * 0.03 - 0.015
z = 0.35 * math.sin(2.5 * x) * math.cos(2.5 * y) + noise
else:
u = rng.random() * 2.0 - 1.0
theta = rng.random() * 2.0 * math.pi
r = 1.0 + rng.random() * 0.08 - 0.04
s = math.sqrt(max(0.0, 1.0 - u * u))
x = r * s * math.cos(theta)
y = r * s * math.sin(theta)
z = r * u
points.append([x, y, z])
return points
DEMO_SURFACE_TYPES = ["Сфера", "Тор", "Волна", "Дно реки + труба"]
def demo_payload(surface_type: str, count: int = 350) -> dict[str, Any]:
if surface_type not in DEMO_SURFACE_TYPES:
surface_type = "Сфера"
points = generate_demo_points(surface_type, count)
return {
"surfaceType": surface_type,
"pointCount": len(points),
"points": points,
"sourceLabel": f"demo: {surface_type}",
}
+709
View File
@@ -0,0 +1,709 @@
from __future__ import annotations
import json
import os
import struct
import subprocess
import tempfile
import uuid
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
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 generate_dataset, load_object_points_from_obj_text, load_scene_preview
from scene_generator import (
catalog_payload as generator_catalog_payload,
export_npy_float64,
export_obj,
export_ply,
export_xyz,
generate_layer,
layers_to_pointnet_rows,
merge_layers_world,
parse_obj_points,
points_to_pointnet_rows,
resolve_intersections,
)
from stage_meta import STAGE_META, STAGE_META_BY_ID, catalog_payload, defaults_for_stage
APP_ROOT = Path(__file__).resolve().parent.parent
WEB_DIST = APP_ROOT / "frontend" / "web" / "dist"
PRESETS_DIRS = [APP_ROOT / "presets", APP_ROOT / "docker"]
USER_PRESETS_DIR = Path(os.environ.get("USER_PRESETS_DIR", "/app/data/user-presets"))
DEFAULT_PIPELINE_CONFIG = Path(
os.environ.get("PIPELINE_CONFIG", APP_ROOT / "docker" / "default_pipeline.json")
)
DOTSTOSURFACE_BIN = Path(os.environ.get("DOTSTOSURFACE_BIN", "/usr/local/bin/DotsToSurface"))
app = FastAPI(title="DotsToSurface Web API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
USER_PRESETS_DIR.mkdir(parents=True, exist_ok=True)
class PipelineConfigBody(BaseModel):
profile: str = "desktop_debug"
preprocessPlugins: list[str] = []
reconstructionPlugin: str = "surface_fallback"
stageDefaults: dict[str, str] = {}
class ValidateConfigBody(BaseModel):
config: PipelineConfigBody
class WizardBody(BaseModel):
wizardProfile: str = "general"
wizardGoal: str = "balanced"
class UserPresetBody(BaseModel):
title: str
idValue: str | None = None
stages: list[dict[str, Any]]
class GeneratorLayerBody(BaseModel):
kind: str
type: str
params: dict[str, Any] | None = None
class GeneratorLayerItem(BaseModel):
id: str | None = None
kind: str
type: str
name: str | None = None
params: dict[str, Any] | None = None
transform: dict[str, Any] | None = None
points: list[list[float]] | None = None
label: str | None = None
color: str | None = None
class GeneratorResolveBody(BaseModel):
layers: list[GeneratorLayerItem]
eps: float = 0.01
clipSurfaceInsideObjects: bool = True
clipObjectsVsObjects: bool = True
class GeneratorExportBody(BaseModel):
points: list[list[float]] | None = None
layers: list[GeneratorLayerItem] | None = None
format: str = "xyz"
filename: str | None = None
# For points-only .npy export when layers are not provided (1=pipe, 0=other).
classLabel: float | None = None
class DatasetGenerateBody(BaseModel):
count: int = 5
seed: int = 42
outputDir: str = "sonar_dataset"
class DatasetPreviewBody(BaseModel):
stem: str
outputDir: str = "sonar_dataset"
maxPoints: int = 25000
def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
return preset
if "config" in preset and isinstance(preset["config"], dict):
return preset["config"]
preprocess_plugins: list[str] = []
reconstruction_plugin = "surface_fallback"
stage_defaults: dict[str, str] = {}
for stage in preset.get("stages", []):
if not stage.get("enabled", True):
continue
stage_id = str(stage.get("id", "")).strip()
if not stage_id:
continue
family = str(stage.get("family", "")).strip()
if family == "preprocess":
preprocess_plugins.append(stage_id)
elif family == "reconstruction":
reconstruction_plugin = stage_id
defaults = str(stage.get("defaults", "")).strip()
if defaults:
stage_defaults[stage_id] = defaults
return {
"profile": preset.get("profile", "desktop_debug"),
"preprocessPlugins": preprocess_plugins,
"reconstructionPlugin": reconstruction_plugin,
"stageDefaults": stage_defaults,
}
def config_to_stage_cards(config: dict[str, Any]) -> list[dict[str, Any]]:
cards: list[dict[str, Any]] = []
stage_defaults = config.get("stageDefaults", {})
for stage_id in config.get("preprocessPlugins", []):
meta = STAGE_META_BY_ID.get(stage_id, {})
cards.append(
{
"id": stage_id,
"title": meta.get("title", stage_id),
"category": meta.get("category", "Custom"),
"family": "preprocess",
"hint": meta.get("hint", ""),
"defaults": stage_defaults.get(stage_id, meta.get("defaults", "")),
"enabled": True,
}
)
recon_id = config.get("reconstructionPlugin", "surface_fallback")
recon_meta = STAGE_META_BY_ID.get(recon_id, {})
cards.append(
{
"id": recon_id,
"title": recon_meta.get("title", recon_id),
"category": recon_meta.get("category", "Реконструкция"),
"family": "reconstruction",
"hint": recon_meta.get("hint", ""),
"defaults": stage_defaults.get(recon_id, recon_meta.get("defaults", "")),
"enabled": True,
}
)
return cards
def list_preset_files() -> list[Path]:
files: list[Path] = []
seen: set[str] = set()
for directory in PRESETS_DIRS:
if not directory.is_dir():
continue
for path in sorted(directory.glob("*.json")):
if path.name in seen:
continue
seen.add(path.name)
files.append(path)
return files
def user_presets_path() -> Path:
return USER_PRESETS_DIR / "pipeline_presets.json"
def load_user_presets() -> list[dict[str, Any]]:
path = user_presets_path()
if not path.is_file():
return []
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, list) else []
def save_user_presets(presets: list[dict[str, Any]]) -> None:
with user_presets_path().open("w", encoding="utf-8") as handle:
json.dump(presets, handle, indent=2, ensure_ascii=False)
def write_binary_geometry(work_dir: Path, result: dict[str, Any]) -> str | None:
points = result.get("points") or []
triangles = result.get("triangleIndices") or []
if not points:
return None
bin_path = work_dir / "geometry.bin"
with bin_path.open("wb") as handle:
handle.write(struct.pack("<I", len(points)))
for point in points:
handle.write(struct.pack("<3f", float(point[0]), float(point[1]), float(point[2])))
handle.write(struct.pack("<I", len(triangles)))
for tri in triangles:
handle.write(struct.pack("<3i", int(tri[0]), int(tri[1]), int(tri[2])))
return str(bin_path)
def run_pipeline_command(input_path: Path, config: dict[str, Any], output_path: Path) -> subprocess.CompletedProcess[str]:
config_path = output_path.parent / "pipeline_config.json"
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
command = [
str(DOTSTOSURFACE_BIN),
"--cli",
"--input",
str(input_path),
"--config-json",
str(config_path),
"--output-json",
str(output_path),
]
return subprocess.run(command, capture_output=True, text=True, check=False)
def enrich_result(result: dict[str, Any], config: dict[str, Any], stdout: str, work_id: str) -> dict[str, Any]:
insights = compute_insights(
config.get("preprocessPlugins", []),
config.get("reconstructionPlugin", "surface_fallback"),
)
result.update(insights)
result["stdout"] = stdout
result["workId"] = work_id
result["stageCards"] = config_to_stage_cards(config)
result.setdefault(
"metrics",
{
"inputPoints": result.get("inputPoints", 0),
"afterPreprocess": result.get("afterPreprocess", 0),
"triangles": result.get("triangles", 0),
"reconstructMs": result.get("reconstructionMs", 0),
"clusters": result.get("clusters", 0),
"removedPoints": result.get("removedPoints", 0),
},
)
return result
@app.get("/api/health")
def health() -> dict[str, str]:
return {
"status": "ok",
"binary": str(DOTSTOSURFACE_BIN),
"binaryExists": str(DOTSTOSURFACE_BIN.is_file()),
"webDist": str(WEB_DIST.is_dir()),
}
@app.get("/api/catalog")
def catalog() -> dict[str, Any]:
return catalog_payload()
@app.get("/api/builtin-presets")
def builtin_presets() -> list[dict[str, Any]]:
return BUILTIN_PRESETS
@app.get("/api/presets")
def presets() -> list[dict[str, Any]]:
items = list(BUILTIN_PRESETS)
for path in list_preset_files():
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
items.append(
{
"id": path.stem,
"filename": path.name,
"title": data.get("title", path.stem),
"idValue": data.get("idValue", path.stem),
"config": preset_to_pipeline_config(data),
"stages": data.get("stages"),
}
)
for preset in load_user_presets():
items.append(
{
"id": preset.get("idValue", ""),
"title": preset.get("title", "User preset"),
"idValue": preset.get("idValue", ""),
"config": preset_to_pipeline_config(preset),
"stages": preset.get("stages"),
"user": True,
}
)
return items
@app.get("/api/default-config")
def default_config() -> dict[str, Any]:
if not DEFAULT_PIPELINE_CONFIG.is_file():
raise HTTPException(status_code=500, detail="Default pipeline config is missing.")
with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle:
return json.load(handle)
@app.get("/api/stage-defaults/{stage_id}")
def stage_defaults(stage_id: str) -> dict[str, str]:
return {"stageId": stage_id, "defaults": defaults_for_stage(stage_id)}
@app.post("/api/validate-config")
def validate_config(body: ValidateConfigBody) -> dict[str, Any]:
config = body.config.model_dump()
insights = compute_insights(config.get("preprocessPlugins", []), config.get("reconstructionPlugin", ""))
return {
"config": config,
"stageCards": config_to_stage_cards(config),
**insights,
}
@app.post("/api/wizard")
def wizard_suggestion(body: WizardBody) -> dict[str, Any]:
preset_id = "Synthetic_clean"
if body.wizardProfile == "urban_scan":
preset_id = "LiDAR_scan"
elif body.wizardProfile == "indoor_object":
preset_id = "RGBD_camera"
if body.wizardGoal == "speed":
if preset_id == "LiDAR_scan":
preset_id = "Synthetic_clean"
elif preset_id == "RGBD_camera":
preset_id = "Fast"
elif body.wizardGoal == "quality":
if preset_id == "Synthetic_clean":
preset_id = "RGBD_camera"
preset = get_builtin_preset(preset_id)
if preset is None:
raise HTTPException(status_code=500, detail="Wizard preset not found.")
config = preset["config"]
insights = compute_insights(config["preprocessPlugins"], config["reconstructionPlugin"])
return {
"presetId": preset_id,
"title": preset["title"],
"config": config,
"stageCards": config_to_stage_cards(config),
**insights,
}
@app.get("/api/demo")
def demo(surfaceType: str = "Сфера", count: int = 350) -> dict[str, Any]:
return demo_payload(surfaceType, count)
@app.get("/api/demo/types")
def demo_types() -> list[str]:
return DEMO_SURFACE_TYPES
@app.get("/api/generator/catalog")
def generator_catalog() -> dict[str, Any]:
return generator_catalog_payload()
@app.post("/api/generator/layer")
def generator_layer(body: GeneratorLayerBody) -> dict[str, Any]:
try:
return generate_layer(body.kind, body.type, body.params)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/generator/resolve-intersections")
def generator_resolve_intersections(body: GeneratorResolveBody) -> dict[str, Any]:
try:
layers = [item.model_dump() for item in body.layers]
resolved = resolve_intersections(
layers,
eps=body.eps,
clip_surface_inside_objects=body.clipSurfaceInsideObjects,
clip_objects_vs_objects=body.clipObjectsVsObjects,
)
return {
"layers": resolved,
"removedTotal": sum(int(layer.get("removedCount", 0)) for layer in resolved),
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/dataset/generate")
async def dataset_generate(
count: int = Form(5),
seed: int = Form(42),
outputDir: str = Form("sonar_dataset"),
objectScale: float = Form(1.0),
beamCount: int = Form(45),
lengthCount: int | None = Form(None),
model: UploadFile = File(...),
) -> dict[str, Any]:
filename = (model.filename or "").strip()
if not filename.lower().endswith(".obj"):
raise HTTPException(status_code=400, detail="Upload a .obj 3D model file.")
try:
raw = await model.read()
text = raw.decode("utf-8", errors="ignore")
object_points = load_object_points_from_obj_text(text)
return generate_dataset(
count=count,
seed=seed,
output_dir=outputDir or "sonar_dataset",
object_points=object_points,
object_name=filename,
object_scale=objectScale,
beam_count=beamCount,
length_count=lengthCount,
)
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 write dataset: {exc}") from exc
@app.post("/api/dataset/preview")
def dataset_preview(body: DatasetPreviewBody) -> dict[str, Any]:
try:
return load_scene_preview(
stem=body.stem,
output_dir=body.outputDir or "sonar_dataset",
max_points=body.maxPoints,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
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 scene: {exc}") from exc
@app.post("/api/generator/export")
def generator_export(body: GeneratorExportBody) -> Response:
from urllib.parse import quote
fmt = (body.format or "xyz").lower().lstrip(".")
if fmt not in ("xyz", "ply", "obj", "npy"):
raise HTTPException(status_code=400, detail="Supported formats: xyz, ply, obj, npy")
filename = body.filename
content: bytes
media: str
ext: str
if fmt == "npy":
try:
if body.layers:
rows = layers_to_pointnet_rows([item.model_dump() for item in body.layers])
elif body.points is not None:
label = 0.0 if body.classLabel is None else float(body.classLabel)
rows = points_to_pointnet_rows(body.points, label)
else:
raise HTTPException(status_code=400, detail="Provide points or layers to export.")
content = export_npy_float64(rows)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
media = "application/octet-stream"
ext = "npy"
else:
points = body.points
if points is None and body.layers:
try:
points = merge_layers_world([item.model_dump() for item in body.layers])
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if points is None:
raise HTTPException(status_code=400, detail="Provide points or layers to export.")
if fmt == "ply":
text = export_ply(points)
media = "application/octet-stream"
ext = "ply"
elif fmt == "obj":
stem = Path(body.filename or "cloud").stem or "cloud"
text = export_obj(points, object_name=stem)
media = "text/plain; charset=utf-8"
ext = "obj"
else:
text = export_xyz(points)
media = "text/plain; charset=utf-8"
ext = "xyz"
content = text.encode("utf-8")
out_name = filename or f"cloud.{ext}"
if not out_name.lower().endswith(f".{ext}"):
out_name = f"{out_name}.{ext}"
ascii_name = "".join(ch if 32 <= ord(ch) < 127 and ch not in '\\/"' else "_" for ch in out_name)
if not ascii_name.lower().endswith(f".{ext}"):
ascii_name = f"cloud.{ext}"
disposition = (
f"attachment; filename=\"{ascii_name}\"; "
f"filename*=UTF-8''{quote(out_name)}"
)
return Response(
content=content,
media_type=media,
headers={"Content-Disposition": disposition},
)
@app.get("/api/user-presets")
def get_user_presets() -> list[dict[str, Any]]:
return load_user_presets()
@app.post("/api/user-presets")
def post_user_preset(body: UserPresetBody) -> dict[str, Any]:
presets = load_user_presets()
preset_id = body.idValue or f"user:{body.title.lower().replace(' ', '_')}"
preset = {
"title": body.title,
"idValue": preset_id,
"stages": body.stages,
}
replaced = False
for index, existing in enumerate(presets):
if existing.get("idValue") == preset_id or existing.get("title") == body.title:
presets[index] = preset
replaced = True
break
if not replaced:
presets.append(preset)
save_user_presets(presets)
return preset
@app.post("/api/run")
async def run_pipeline(
file: UploadFile | None = File(default=None),
preset_id: str | None = Form(default=None),
config_json: str | None = Form(default=None),
demo_surface: str | None = Form(default=None),
geometry_format: str = Form(default="json"),
) -> dict[str, Any]:
if not DOTSTOSURFACE_BIN.is_file():
raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSURFACE_BIN}")
work_id = uuid.uuid4().hex
work_dir = Path(tempfile.gettempdir()) / "dottosurface" / work_id
work_dir.mkdir(parents=True, exist_ok=True)
output_path = work_dir / "result.json"
try:
if demo_surface:
demo = demo_payload(demo_surface)
input_path = work_dir / "demo.xyz"
lines = [f"{p[0]} {p[1]} {p[2]}" for p in demo["points"]]
input_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
elif file is not None:
suffix = Path(file.filename or "cloud.ply").suffix.lower() or ".ply"
raw = await file.read()
if suffix == ".obj":
try:
text = raw.decode("utf-8", errors="ignore")
obj_points = parse_obj_points(text)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid OBJ: {exc}") from exc
if not obj_points:
raise HTTPException(status_code=400, detail="OBJ has no vertices (v x y z).")
input_path = work_dir / "input.xyz"
input_path.write_text(
"\n".join(f"{p[0]} {p[1]} {p[2]}" for p in obj_points) + "\n",
encoding="utf-8",
)
else:
input_path = work_dir / f"input{suffix}"
input_path.write_bytes(raw)
else:
raise HTTPException(status_code=400, detail="Provide file or demo_surface.")
if config_json:
pipeline_config = json.loads(config_json)
elif preset_id:
builtin = get_builtin_preset(preset_id)
if builtin is not None:
pipeline_config = builtin["config"]
else:
preset_path = next((p for p in list_preset_files() if p.stem == preset_id), None)
if preset_path is None:
user_match = next(
(p for p in load_user_presets() if p.get("idValue") == preset_id),
None,
)
if user_match is None:
raise HTTPException(status_code=400, detail=f"Unknown preset: {preset_id}")
pipeline_config = preset_to_pipeline_config(user_match)
else:
with preset_path.open("r", encoding="utf-8") as handle:
pipeline_config = preset_to_pipeline_config(json.load(handle))
else:
with DEFAULT_PIPELINE_CONFIG.open("r", encoding="utf-8") as handle:
pipeline_config = json.load(handle)
completed = run_pipeline_command(input_path, pipeline_config, output_path)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or "Pipeline failed."
raise HTTPException(status_code=500, detail=detail)
if not output_path.is_file():
raise HTTPException(status_code=500, detail="Pipeline finished without output JSON.")
with output_path.open("r", encoding="utf-8") as handle:
result = json.load(handle)
result = enrich_result(result, pipeline_config, completed.stdout.strip(), work_id)
if geometry_format == "binary":
bin_path = write_binary_geometry(work_dir, result)
if bin_path:
result["geometryUrl"] = f"/api/geometry/{work_id}"
result.pop("points", None)
result.pop("triangleIndices", None)
return result
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"Invalid config JSON: {exc}") from exc
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/geometry/{work_id}")
def get_geometry(work_id: str) -> Response:
bin_path = Path(tempfile.gettempdir()) / "dottosurface" / work_id / "geometry.bin"
if not bin_path.is_file():
raise HTTPException(status_code=404, detail="Geometry not found.")
return Response(content=bin_path.read_bytes(), media_type="application/octet-stream")
@app.get("/airplane_reference.png")
def airplane_reference_image() -> FileResponse:
candidates = [
WEB_DIST / "airplane_reference.png",
APP_ROOT / "assets" / "airplane_reference.png",
APP_ROOT / "frontend" / "web" / "public" / "airplane_reference.png",
]
for path in candidates:
if path.is_file():
return FileResponse(path, media_type="image/png")
raise HTTPException(status_code=404, detail="Airplane reference image not found.")
@app.get("/")
def index() -> FileResponse:
index_path = WEB_DIST / "index.html"
if not index_path.is_file():
raise HTTPException(
status_code=503,
detail="Frontend not built. Run: cd frontend/web && npm install && npm run build",
)
return FileResponse(index_path)
@app.get("/generator")
def generator_spa() -> FileResponse:
return index()
@app.get("/dataset")
def dataset_spa() -> FileResponse:
return index()
if (WEB_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
+66
View File
@@ -0,0 +1,66 @@
"""Pipeline chain insights (mirrors MainWindow::recomputeInsights)."""
from __future__ import annotations
from typing import Any
def compute_insights(
preprocess_plugins: list[str],
reconstruction_plugin: str,
) -> dict[str, Any]:
warnings: list[str] = []
risk_state = False
stages = [s for s in preprocess_plugins if s]
voxel_idx = stages.index("pcl_voxel_grid") if "pcl_voxel_grid" in stages else -1
remove_nan_idx = stages.index("pcl_remove_nan") if "pcl_remove_nan" in stages else -1
remove_nan_normals_idx = (
stages.index("pcl_remove_nan_normals") if "pcl_remove_nan_normals" in stages else -1
)
outlier_indexes = []
for stage_id in (
"pcl_statistical_outlier",
"pcl_radius_outlier",
"pcl_model_outlier",
"pcl_shadow_points",
):
if stage_id in stages:
outlier_indexes.append(stages.index(stage_id))
first_outlier_idx = min(outlier_indexes) if outlier_indexes else -1
if not stages:
risk_state = True
warnings.append("Добавьте хотя бы один этап препроцессинга перед реконструкцией.")
else:
if first_outlier_idx < 0:
warnings.append("Добавьте outlier removal для стабилизации триангуляции.")
if first_outlier_idx >= 0:
nan_idxs = [i for i in (remove_nan_idx, remove_nan_normals_idx) if i >= 0]
first_nan_preclean_idx = min(nan_idxs) if nan_idxs else -1
if first_nan_preclean_idx < 0 or first_nan_preclean_idx > first_outlier_idx:
warnings.append(
"Удалите NaN сразу после загрузки/обрезки: иначе статистические фильтры работают нестабильно."
)
if voxel_idx >= 0 and first_outlier_idx >= 0 and first_outlier_idx > voxel_idx:
warnings.append(
"OutlierRemoval стоит после VoxelGrid: лучше сначала очистить шум, затем прореживать."
)
if reconstruction_plugin == "pcl_greedy_triangulation" and voxel_idx < 0:
warnings.append("Greedy triangulation обычно работает лучше после voxel downsampling.")
if risk_state:
chain_health = "Risk"
recommendation = warnings[0] if warnings else "Проверьте порядок этапов пайплайна."
elif warnings:
chain_health = "Warning"
recommendation = warnings[0]
else:
chain_health = "OK"
recommendation = "Пайплайн сбалансирован. Используйте «Сохр. конф.» для A/B-сравнения."
return {
"chainHealth": chain_health,
"warningsList": warnings,
"recommendation": recommendation,
}
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
python-multipart==0.0.20
+674
View File
@@ -0,0 +1,674 @@
"""Parametric scene generator: objects, terrain surfaces, intersection clipping, export."""
from __future__ import annotations
import math
import random
from typing import Any
# ---------------------------------------------------------------------------
# Catalog / default params
# ---------------------------------------------------------------------------
LAYER_CATALOG: list[dict[str, Any]] = [
{
"kind": "object",
"type": "pipe",
"label": "Труба",
"params": [
{"key": "length", "label": "Длина", "type": "number", "default": 2.6, "min": 0.2, "max": 20, "step": 0.1},
{"key": "radius", "label": "Радиус", "type": "number", "default": 0.22, "min": 0.02, "max": 5, "step": 0.01},
{"key": "axis", "label": "Ось", "type": "select", "default": "y", "options": ["x", "y", "z"]},
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "object",
"type": "sphere",
"label": "Сфера",
"params": [
{"key": "radius", "label": "Радиус", "type": "number", "default": 0.5, "min": 0.05, "max": 10, "step": 0.05},
{"key": "count", "label": "Точек", "type": "number", "default": 2000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.02, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "object",
"type": "box",
"label": "Параллелепипед",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 1.0, "min": 0.1, "max": 20, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 0.6, "min": 0.1, "max": 20, "step": 0.1},
{"key": "sizeZ", "label": "Размер Z", "type": "number", "default": 0.4, "min": 0.1, "max": 20, "step": 0.1},
{"key": "count", "label": "Точек", "type": "number", "default": 2000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "object",
"type": "torus",
"label": "Тор",
"params": [
{"key": "majorR", "label": "Большой R", "type": "number", "default": 1.0, "min": 0.1, "max": 10, "step": 0.05},
{"key": "minorR", "label": "Малый R", "type": "number", "default": 0.35, "min": 0.02, "max": 5, "step": 0.01},
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "surface",
"type": "ocean_floor",
"label": "Дно океана",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 3.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "amplitude", "label": "Амплитуда", "type": "number", "default": 0.12, "min": 0, "max": 2, "step": 0.01},
{"key": "frequency", "label": "Частота", "type": "number", "default": 2.2, "min": 0.1, "max": 20, "step": 0.1},
{"key": "channel", "label": "Канал", "type": "number", "default": 0.08, "min": 0, "max": 1, "step": 0.01},
{"key": "baseZ", "label": "База Z", "type": "number", "default": -0.45, "min": -20, "max": 20, "step": 0.05},
{"key": "count", "label": "Точек", "type": "number", "default": 4000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.02, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "surface",
"type": "wave",
"label": "Волна",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 2.4, "min": 0.5, "max": 50, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 2.4, "min": 0.5, "max": 50, "step": 0.1},
{"key": "amplitude", "label": "Амплитуда", "type": "number", "default": 0.35, "min": 0, "max": 5, "step": 0.05},
{"key": "frequency", "label": "Частота", "type": "number", "default": 2.5, "min": 0.1, "max": 20, "step": 0.1},
{"key": "count", "label": "Точек", "type": "number", "default": 3000, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.015, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
{
"kind": "surface",
"type": "flat",
"label": "Плоскость",
"params": [
{"key": "sizeX", "label": "Размер X", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "sizeY", "label": "Размер Y", "type": "number", "default": 4.0, "min": 0.5, "max": 50, "step": 0.1},
{"key": "z", "label": "Высота Z", "type": "number", "default": -0.5, "min": -20, "max": 20, "step": 0.05},
{"key": "count", "label": "Точек", "type": "number", "default": 2500, "min": 100, "max": 100000, "step": 100},
{"key": "noise", "label": "Шум", "type": "number", "default": 0.01, "min": 0, "max": 0.5, "step": 0.005},
{"key": "seed", "label": "Seed", "type": "number", "default": 1, "min": 0, "max": 999999, "step": 1},
],
},
]
_CATALOG_BY_KEY = {(item["kind"], item["type"]): item for item in LAYER_CATALOG}
LAYER_COLORS = {
("object", "pipe"): "#f59e0b",
("object", "sphere"): "#38bdf8",
("object", "box"): "#a78bfa",
("object", "torus"): "#34d399",
("surface", "ocean_floor"): "#64748b",
("surface", "wave"): "#94a3b8",
("surface", "flat"): "#78716c",
}
def catalog_payload() -> dict[str, Any]:
return {"layers": LAYER_CATALOG, "colors": {f"{k[0]}:{k[1]}": v for k, v in LAYER_COLORS.items()}}
def default_params(kind: str, type_name: str) -> dict[str, Any]:
entry = _CATALOG_BY_KEY.get((kind, type_name))
if entry is None:
raise ValueError(f"Unknown layer type: {kind}/{type_name}")
return {p["key"]: p["default"] for p in entry["params"]}
def merge_params(kind: str, type_name: str, params: dict[str, Any] | None) -> dict[str, Any]:
merged = default_params(kind, type_name)
if params:
for key, value in params.items():
if key in merged:
merged[key] = value
# Coerce numeric fields
entry = _CATALOG_BY_KEY[(kind, type_name)]
for p in entry["params"]:
key = p["key"]
if p["type"] == "number" and key in merged:
try:
merged[key] = float(merged[key])
if key in ("count", "seed"):
merged[key] = int(merged[key])
except (TypeError, ValueError):
merged[key] = p["default"]
if p["type"] == "select" and key in merged:
options = p.get("options") or []
if merged[key] not in options:
merged[key] = p["default"]
return merged
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
def _jitter(rng: random.Random, noise: float) -> float:
if noise <= 0:
return 0.0
return rng.uniform(-noise, noise)
def generate_pipe(params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
length = float(params["length"])
radius = float(params["radius"])
noise = float(params["noise"])
axis = params.get("axis", "y")
points: list[list[float]] = []
half = length * 0.5
for _ in range(count):
angle = rng.random() * 2.0 * math.pi
t = rng.uniform(-half, half)
radial = radius + _jitter(rng, noise)
cx = radial * math.cos(angle)
cy = radial * math.sin(angle)
if axis == "x":
points.append([t, cx, cy])
elif axis == "z":
points.append([cx, cy, t])
else:
points.append([cx, t, cy])
return points
def generate_sphere(params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
radius = float(params["radius"])
noise = float(params["noise"])
points: list[list[float]] = []
for _ in range(count):
u = rng.uniform(-1.0, 1.0)
theta = rng.random() * 2.0 * math.pi
r = radius + _jitter(rng, noise)
s = math.sqrt(max(0.0, 1.0 - u * u))
points.append([r * s * math.cos(theta), r * s * math.sin(theta), r * u])
return points
def generate_box(params: dict[str, Any]) -> list[list[float]]:
"""Sample points on the box surface."""
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
sx = float(params["sizeX"]) * 0.5
sy = float(params["sizeY"]) * 0.5
sz = float(params["sizeZ"]) * 0.5
noise = float(params["noise"])
faces = [
("x", sx, sy, sz),
("x", -sx, sy, sz),
("y", sy, sx, sz),
("y", -sy, sx, sz),
("z", sz, sx, sy),
("z", -sz, sx, sy),
]
areas = [abs(a[2]) * abs(a[3]) * 4.0 for a in faces]
total = sum(areas) or 1.0
points: list[list[float]] = []
for _ in range(count):
pick = rng.random() * total
acc = 0.0
face = faces[0]
for f, area in zip(faces, areas):
acc += area
if pick <= acc:
face = f
break
axis, fixed, u_max, v_max = face
u = rng.uniform(-u_max, u_max)
v = rng.uniform(-v_max, v_max)
jx, jy, jz = _jitter(rng, noise), _jitter(rng, noise), _jitter(rng, noise)
if axis == "x":
points.append([fixed + jx, u + jy, v + jz])
elif axis == "y":
points.append([u + jx, fixed + jy, v + jz])
else:
points.append([u + jx, v + jy, fixed + jz])
return points
def generate_torus(params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
major_r = float(params["majorR"])
minor_r = float(params["minorR"])
noise = float(params["noise"])
points: list[list[float]] = []
for _ in range(count):
u = rng.random() * 2.0 * math.pi
v = rng.random() * 2.0 * math.pi
radial = minor_r + _jitter(rng, noise)
x = (major_r + radial * math.cos(v)) * math.cos(u)
y = (major_r + radial * math.cos(v)) * math.sin(u)
z = radial * math.sin(v)
points.append([x, y, z])
return points
def height_ocean_floor(x: float, y: float, params: dict[str, Any]) -> float:
amplitude = float(params["amplitude"])
frequency = float(params["frequency"])
channel = float(params["channel"])
base_z = float(params["baseZ"])
waviness = amplitude * math.cos(frequency * y)
channel_term = channel * x * x
return base_z + channel_term + waviness
def height_wave(x: float, y: float, params: dict[str, Any]) -> float:
amplitude = float(params["amplitude"])
frequency = float(params["frequency"])
return amplitude * math.sin(frequency * x) * math.cos(frequency * y)
def height_flat(_x: float, _y: float, params: dict[str, Any]) -> float:
return float(params["z"])
def surface_height_fn(type_name: str):
if type_name == "ocean_floor":
return height_ocean_floor
if type_name == "wave":
return height_wave
if type_name == "flat":
return height_flat
raise ValueError(f"Unknown surface type: {type_name}")
def generate_surface(type_name: str, params: dict[str, Any]) -> list[list[float]]:
rng = random.Random(int(params["seed"]))
count = max(1, int(params["count"]))
size_x = float(params.get("sizeX", 2.0))
size_y = float(params.get("sizeY", 2.0))
noise = float(params["noise"])
height_fn = surface_height_fn(type_name)
half_x = size_x * 0.5
half_y = size_y * 0.5
points: list[list[float]] = []
for _ in range(count):
x = rng.uniform(-half_x, half_x)
y = rng.uniform(-half_y, half_y)
z = height_fn(x, y, params) + _jitter(rng, noise)
points.append([x, y, z])
return points
_GENERATORS = {
("object", "pipe"): generate_pipe,
("object", "sphere"): generate_sphere,
("object", "box"): generate_box,
("object", "torus"): generate_torus,
}
def generate_layer(kind: str, type_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
if (kind, type_name) not in _CATALOG_BY_KEY:
raise ValueError(f"Unknown layer type: {kind}/{type_name}")
merged = merge_params(kind, type_name, params)
if kind == "surface":
points = generate_surface(type_name, merged)
else:
points = _GENERATORS[(kind, type_name)](merged)
entry = _CATALOG_BY_KEY[(kind, type_name)]
return {
"kind": kind,
"type": type_name,
"label": entry["label"],
"params": merged,
"pointCount": len(points),
"points": points,
"color": LAYER_COLORS.get((kind, type_name), "#7dd3fc"),
}
# ---------------------------------------------------------------------------
# Transforms & intersections
# ---------------------------------------------------------------------------
def normalize_transform(transform: dict[str, Any] | None) -> dict[str, float]:
t = transform or {}
return {
"x": float(t.get("x", 0.0) or 0.0),
"y": float(t.get("y", 0.0) or 0.0),
"z": float(t.get("z", 0.0) or 0.0),
"rx": float(t.get("rx", 0.0) or 0.0),
"ry": float(t.get("ry", 0.0) or 0.0),
"rz": float(t.get("rz", 0.0) or 0.0),
}
def _rotate_xyz(x: float, y: float, z: float, rx: float, ry: float, rz: float) -> tuple[float, float, float]:
"""Euler XYZ (same as Three.js Object3D.rotation default order)."""
cx, sx = math.cos(rx), math.sin(rx)
cy, sy = math.cos(ry), math.sin(ry)
cz, sz = math.cos(rz), math.sin(rz)
y, z = y * cx - z * sx, y * sx + z * cx
x, z = x * cy + z * sy, -x * sy + z * cy
x, y = x * cz - y * sz, x * sz + y * cz
return x, y, z
def _rotate_xyz_inverse(x: float, y: float, z: float, rx: float, ry: float, rz: float) -> tuple[float, float, float]:
cx, sx = math.cos(rx), math.sin(rx)
cy, sy = math.cos(ry), math.sin(ry)
cz, sz = math.cos(rz), math.sin(rz)
x, y = x * cz + y * sz, -x * sz + y * cz
x, z = x * cy - z * sy, x * sy + z * cy
y, z = y * cx + z * sx, -y * sx + z * cx
return x, y, z
def apply_transform(points: list[list[float]], transform: dict[str, Any] | None) -> list[list[float]]:
t = normalize_transform(transform)
out: list[list[float]] = []
for p in points:
x, y, z = _rotate_xyz(p[0], p[1], p[2], t["rx"], t["ry"], t["rz"])
out.append([x + t["x"], y + t["y"], z + t["z"]])
return out
def _world_to_local(point: list[float], transform: dict[str, Any] | None) -> tuple[float, float, float]:
t = normalize_transform(transform)
x = point[0] - t["x"]
y = point[1] - t["y"]
z = point[2] - t["z"]
return _rotate_xyz_inverse(x, y, z, t["rx"], t["ry"], t["rz"])
def object_sdf(type_name: str, params: dict[str, Any], local: tuple[float, float, float]) -> float:
"""Signed distance: negative = inside."""
if type_name == "imported":
# No analytic SDF for imported clouds — skip solid clipping.
return 1.0
x, y, z = local
if type_name == "sphere":
return math.sqrt(x * x + y * y + z * z) - float(params["radius"])
if type_name == "pipe":
radius = float(params["radius"])
half = float(params["length"]) * 0.5
axis = params.get("axis", "y")
if axis == "x":
radial = math.sqrt(y * y + z * z) - radius
axial = abs(x) - half
elif axis == "z":
radial = math.sqrt(x * x + y * y) - radius
axial = abs(z) - half
else:
radial = math.sqrt(x * x + z * z) - radius
axial = abs(y) - half
# Approximate solid cylinder: inside if radial < 0 and axial < 0
outside = max(radial, axial)
if radial < 0 and axial < 0:
return max(radial, axial)
if axial > 0 and radial < 0:
return axial
if radial > 0 and axial < 0:
return radial
return math.sqrt(max(radial, 0) ** 2 + max(axial, 0) ** 2) if outside > 0 else outside
if type_name == "box":
hx = float(params["sizeX"]) * 0.5
hy = float(params["sizeY"]) * 0.5
hz = float(params["sizeZ"]) * 0.5
qx = abs(x) - hx
qy = abs(y) - hy
qz = abs(z) - hz
outside = math.sqrt(max(qx, 0) ** 2 + max(qy, 0) ** 2 + max(qz, 0) ** 2)
inside = min(max(qx, qy, qz), 0.0)
return outside + inside
if type_name == "torus":
major_r = float(params["majorR"])
minor_r = float(params["minorR"])
q = math.sqrt(x * x + y * y) - major_r
return math.sqrt(q * q + z * z) - minor_r
return 1.0
def point_below_surface(
world_pt: list[float],
surf_type: str,
surf_params: dict[str, Any],
surf_transform: dict[str, Any] | None,
eps: float,
) -> bool:
"""True if world point is below the heightfield in the surface local frame."""
lx, ly, lz = _world_to_local(world_pt, surf_transform)
size_x = float(surf_params.get("sizeX", 1e9))
size_y = float(surf_params.get("sizeY", 1e9))
if abs(lx) > size_x * 0.5 + eps or abs(ly) > size_y * 0.5 + eps:
return False
h = surface_height_fn(surf_type)(lx, ly, surf_params)
return lz < h + eps
def resolve_intersections(
layers: list[dict[str, Any]],
*,
eps: float = 0.01,
clip_surface_inside_objects: bool = True,
clip_objects_vs_objects: bool = True,
) -> list[dict[str, Any]]:
"""Return layers with points updated (local coords preserved via inverse transform)."""
prepared: list[dict[str, Any]] = []
for layer in layers:
kind = layer["kind"]
type_name = layer["type"]
transform = normalize_transform(layer.get("transform"))
local_points = layer.get("points")
if type_name == "imported":
params = dict(layer.get("params") or {})
if not local_points:
raise ValueError("Imported layer has no points.")
label = layer.get("label") or "OBJ"
color = layer.get("color") or "#f472b6"
else:
params = merge_params(kind, type_name, layer.get("params"))
if not local_points:
generated = generate_layer(kind, type_name, params)
local_points = generated["points"]
label = layer.get("label") or _CATALOG_BY_KEY[(kind, type_name)]["label"]
color = layer.get("color") or LAYER_COLORS.get((kind, type_name), "#7dd3fc")
world = apply_transform(local_points, transform)
prepared.append({
"id": layer.get("id"),
"kind": kind,
"type": type_name,
"params": params,
"transform": transform,
"local_points": local_points,
"world_points": world,
"color": color,
"label": label,
})
surfaces = [p for p in prepared if p["kind"] == "surface"]
objects = [p for p in prepared if p["kind"] == "object"]
result: list[dict[str, Any]] = []
for layer in prepared:
keep_local: list[list[float]] = []
keep_world: list[list[float]] = []
for local_pt, world_pt in zip(layer["local_points"], layer["world_points"]):
drop = False
if layer["kind"] == "object":
for surf in surfaces:
if point_below_surface(
world_pt, surf["type"], surf["params"], surf["transform"], eps
):
drop = True
break
if not drop and clip_objects_vs_objects:
for other in objects:
if other is layer:
continue
local_in_other = _world_to_local(world_pt, other["transform"])
if object_sdf(other["type"], other["params"], local_in_other) < -eps:
drop = True
break
elif layer["kind"] == "surface" and clip_surface_inside_objects:
for obj in objects:
local_in_obj = _world_to_local(world_pt, obj["transform"])
if object_sdf(obj["type"], obj["params"], local_in_obj) < -eps:
drop = True
break
if not drop:
keep_local.append([local_pt[0], local_pt[1], local_pt[2]])
keep_world.append(world_pt)
result.append({
"id": layer["id"],
"kind": layer["kind"],
"type": layer["type"],
"label": layer["label"],
"params": layer["params"],
"transform": layer["transform"],
"color": layer["color"],
"pointCount": len(keep_local),
"points": keep_local,
"removedCount": len(layer["local_points"]) - len(keep_local),
})
return result
def merge_layers_world(layers: list[dict[str, Any]]) -> list[list[float]]:
merged: list[list[float]] = []
for layer in layers:
kind = layer["kind"]
type_name = layer["type"]
transform = normalize_transform(layer.get("transform"))
points = layer.get("points")
if not points:
if type_name == "imported":
continue
params = merge_params(kind, type_name, layer.get("params"))
points = generate_layer(kind, type_name, params)["points"]
merged.extend(apply_transform(points, transform))
return merged
def layer_semantic_class(layer: dict[str, Any]) -> float:
"""Binary PointNet label: 1 = pipe, 0 = everything else."""
type_name = str(layer.get("type") or "").lower()
if type_name == "pipe":
return 1.0
# Optional name hint for renamed imported clouds
name = str(layer.get("name") or layer.get("label") or "").lower()
if "pipe" in name or "труб" in name:
return 1.0
return 0.0
def points_to_pointnet_rows(points: list[list[float]], class_label: float) -> list[list[float]]:
"""XYZRGB+class rows; RGB forced to 0; float values (stored as float64 in .npy)."""
c = float(class_label)
rows: list[list[float]] = []
for p in points:
rows.append([float(p[0]), float(p[1]), float(p[2]), 0.0, 0.0, 0.0, c])
return rows
def layers_to_pointnet_rows(layers: list[dict[str, Any]]) -> list[list[float]]:
rows: list[list[float]] = []
for layer in layers:
kind = layer.get("kind") or "object"
type_name = layer.get("type") or "imported"
transform = normalize_transform(layer.get("transform"))
points = layer.get("points")
if not points:
if type_name == "imported":
continue
params = merge_params(kind, type_name, layer.get("params"))
points = generate_layer(kind, type_name, params)["points"]
world = apply_transform(points, transform)
rows.extend(points_to_pointnet_rows(world, layer_semantic_class(layer)))
return rows
def export_npy_float64(rows: list[list[float]]) -> bytes:
"""Write NumPy .npy v1.0 binary array shape (N, C) dtype float64 little-endian."""
import struct
n = len(rows)
cols = len(rows[0]) if n else 7
if n and any(len(r) != cols for r in rows):
raise ValueError("All rows must have the same length for .npy export.")
header = "{'descr': '<f8', 'fortran_order': False, 'shape': (%d, %d), }" % (n, cols)
# Pad so magic(6)+ver(2)+hlen(2)+header is multiple of 64.
preamble = 10
pad = 64 - ((preamble + len(header) + 1) % 64)
if pad == 64:
pad = 0
header_padded = (header + (" " * pad) + "\n").encode("latin1")
out = bytearray()
out += b"\x93NUMPY"
out += struct.pack("<BB", 1, 0)
out += struct.pack("<H", len(header_padded))
out += header_padded
for row in rows:
for value in row:
out += struct.pack("<d", float(value))
return bytes(out)
def export_xyz(points: list[list[float]]) -> str:
return "\n".join(f"{p[0]:.8f} {p[1]:.8f} {p[2]:.8f}" for p in points) + ("\n" if points else "")
def export_ply(points: list[list[float]]) -> str:
header = (
"ply\n"
"format ascii 1.0\n"
f"element vertex {len(points)}\n"
"property float x\n"
"property float y\n"
"property float z\n"
"end_header\n"
)
body = "\n".join(f"{p[0]:.8f} {p[1]:.8f} {p[2]:.8f}" for p in points)
return header + body + ("\n" if points else "")
def export_obj(points: list[list[float]], object_name: str = "cloud") -> 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}")
return "\n".join(lines) + "\n"
def parse_obj_points(text: str) -> list[list[float]]:
"""Extract vertex positions from Wavefront OBJ (ignores faces/materials)."""
points: list[list[float]] = []
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.lower().startswith("v "):
parts = line.split()
if len(parts) < 4:
continue
try:
points.append([float(parts[1]), float(parts[2]), float(parts[3])])
except ValueError:
continue
return points
+63
View File
@@ -0,0 +1,63 @@
"""Stage metadata with default parameter strings (from MainWindow kStageMeta)."""
from __future__ import annotations
from typing import Any
STAGE_META: list[dict[str, str]] = [
{"id": "keep_largest_cluster", "title": "Крупнейший кластер", "category": "Обрезка", "family": "preprocess", "hint": "Удаляет мелкие фрагменты и оставляет основной объект.", "defaults": "clusterJoinDistanceScale=5.0"},
{"id": "pcl_remove_nan", "title": "Remove NaN Points", "category": "NaN (предочистка)", "family": "preprocess", "hint": "Удаляет точки с NaN/Inf в X/Y/Z сразу после загрузки/обрезки.", "defaults": ""},
{"id": "pcl_remove_nan_normals", "title": "Remove NaN Normals", "category": "NaN (предочистка)", "family": "preprocess", "hint": "Удаляет точки с невалидными нормалями.", "defaults": ""},
{"id": "pcl_pass_through", "title": "PassThrough", "category": "Обрезка", "family": "preprocess", "hint": "Фильтр по диапазону одной координатной оси.", "defaults": "axis=z,min=-1.0,max=1.0"},
{"id": "pcl_crop_box", "title": "CropBox", "category": "Обрезка", "family": "preprocess", "hint": "Обрезка по границам 3D-параллелепипеда.", "defaults": "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"},
{"id": "pcl_crop_hull", "title": "CropHull", "category": "Обрезка", "family": "preprocess", "hint": "Обрезка по выпуклой области.", "defaults": "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"},
{"id": "pcl_frustum_culling", "title": "Frustum Culling", "category": "Обрезка", "family": "preprocess", "hint": "Оставляет точки в пирамиде видимости.", "defaults": "near=0.1,far=5.0,hfov=70,vfov=50"},
{"id": "pcl_plane_clipper_3d", "title": "PlaneClipper3D", "category": "Обрезка", "family": "preprocess", "hint": "Отсечение по плоскости ax+by+cz+d=0.", "defaults": "a=0.0,b=0.0,c=1.0,d=0.0,keepPositive=true"},
{"id": "pcl_conditional_removal", "title": "Conditional Removal", "category": "Условия/Индексы", "family": "preprocess", "hint": "Удаление точек по диапазону Z.", "defaults": "zMin=-1.0,zMax=1.0"},
{"id": "pcl_extract_indices", "title": "Extract Indices", "category": "Условия/Индексы", "family": "preprocess", "hint": "Извлечение каждой N-й точки.", "defaults": "nth=2"},
{"id": "pcl_functor_filter", "title": "Functor Filter", "category": "Условия/Индексы", "family": "preprocess", "hint": "Фильтрация по расстоянию до начала координат.", "defaults": "radiusMax=2.5"},
{"id": "pcl_project_inliers", "title": "ProjectInliers", "category": "Нормали", "family": "preprocess", "hint": "Проецирование точек на плоскость.", "defaults": "a=0.0,b=0.0,c=1.0,d=0.0"},
{"id": "pcl_normal_refinement", "title": "Normal Refinement", "category": "Нормали", "family": "preprocess", "hint": "Уточнение геометрии локальным усреднением.", "defaults": "radius=0.1,iterations=1"},
{"id": "pcl_bilateral_filter", "title": "Bilateral Filter", "category": "Сглаживание", "family": "preprocess", "hint": "Двустороннее сглаживание.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
{"id": "pcl_fast_bilateral_filter", "title": "Fast Bilateral Filter", "category": "Сглаживание", "family": "preprocess", "hint": "Быстрая версия bilateral.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
{"id": "pcl_fast_bilateral_filter_omp", "title": "Fast Bilateral Filter OMP", "category": "Сглаживание", "family": "preprocess", "hint": "Многопоточный bilateral.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
{"id": "pcl_convolution", "title": "Convolution", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро свертки.", "defaults": "sigma=0.08,kernel=3"},
{"id": "pcl_gaussian_kernel", "title": "Gaussian Kernel", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро.", "defaults": "sigma=0.08,kernel=3"},
{"id": "pcl_gaussian_kernel_rgb", "title": "Gaussian Kernel RGB", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро RGB.", "defaults": "sigma=0.08,kernel=3"},
{"id": "pcl_voxel_grid_occlusion", "title": "VoxelGrid Occlusion Estimation", "category": "Морфология", "family": "preprocess", "hint": "Оценка окклюзии по вокселям.", "defaults": "leaf=0.12,minHits=2"},
{"id": "downsample_dense", "title": "Прореживание плотности", "category": "Прореживание", "family": "preprocess", "hint": "Снижает число точек на плотных облаках.", "defaults": "downsampleCellScale=0.8"},
{"id": "pcl_voxel_grid", "title": "PCL Voxel Grid", "category": "Прореживание", "family": "preprocess", "hint": "Воксельное прореживание.", "defaults": "leaf=0.02"},
{"id": "pcl_statistical_outlier", "title": "Статистическая фильтрация", "category": "Шум", "family": "preprocess", "hint": "Удаляет выбросы по статистике соседей.", "defaults": "meanK=24,stddev=1.2"},
{"id": "pcl_radius_outlier", "title": "Радиусная фильтрация", "category": "Шум", "family": "preprocess", "hint": "Удаляет точки без соседей в радиусе.", "defaults": "radius=0.04,minNeighbors=8"},
{"id": "pcl_model_outlier", "title": "Model Outlier Removal", "category": "Шум", "family": "preprocess", "hint": "Удаляет отклонения от модели.", "defaults": "threshold=0.03"},
{"id": "pcl_shadow_points", "title": "Shadow Points Removal", "category": "Шум", "family": "preprocess", "hint": "Удаляет теневые точки.", "defaults": "shadowThreshold=0.2"},
{"id": "pcl_approximate_voxel_grid", "title": "Approximate Voxel Grid", "category": "Прореживание", "family": "preprocess", "hint": "Ускоренное воксельное прореживание.", "defaults": "leaf=0.03"},
{"id": "pcl_voxel_grid_label", "title": "Voxel Grid Label", "category": "Прореживание", "family": "preprocess", "hint": "Воксели с метками.", "defaults": "leaf=0.04"},
{"id": "pcl_voxel_grid_covariance", "title": "Voxel Grid Covariance", "category": "Прореживание", "family": "preprocess", "hint": "Воксели с ковариациями.", "defaults": "leaf=0.04"},
{"id": "pcl_grid_minimum", "title": "Grid Minimum", "category": "Прореживание", "family": "preprocess", "hint": "Минимум Z в ячейке.", "defaults": "resolution=0.05"},
{"id": "pcl_farthest_point_sampling", "title": "Farthest Point Sampling", "category": "Прореживание", "family": "preprocess", "hint": "Наиболее удалённые точки.", "defaults": "sample=800"},
{"id": "pcl_normal_space_sampling", "title": "Normal Space Sampling", "category": "Прореживание", "family": "preprocess", "hint": "Выборка по нормалям.", "defaults": "sample=800"},
{"id": "pcl_sampling_surface_normal", "title": "Sampling Surface Normal", "category": "Прореживание", "family": "preprocess", "hint": "Выборка по нормалям поверхности.", "defaults": "sample=800"},
{"id": "surface_fallback", "title": "Fallback Surface", "category": "Реконструкция", "family": "reconstruction", "hint": "Базовая реконструкция.", "defaults": "neighborRadiusScale=3.5,runEveryNthFrame=1"},
{"id": "pcl_greedy_triangulation", "title": "PCL Greedy Triangulation", "category": "Реконструкция", "family": "reconstruction", "hint": "Жадная триангуляция.", "defaults": "searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8"},
{"id": "pcl_poisson_reconstruction", "title": "PCL Poisson Reconstruction", "category": "Реконструкция", "family": "reconstruction", "hint": "Poisson реконструкция.", "defaults": "poissonDepth=8,samplesPerNode=1.5"},
]
STAGE_META_BY_ID: dict[str, dict[str, str]] = {item["id"]: item for item in STAGE_META}
def defaults_for_stage(stage_id: str) -> str:
meta = STAGE_META_BY_ID.get(stage_id)
return meta["defaults"] if meta else ""
def catalog_payload() -> dict[str, Any]:
# Catalog groups come from frontend module at build time; API exposes stage meta + ids.
return {
"stageMeta": STAGE_META,
"reconstructions": [
"surface_fallback",
"pcl_greedy_triangulation",
"pcl_poisson_reconstruction",
],
}