Реструктуризация проекта и генератор синтетических датасетов эхолота.
Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
python-multipart==0.0.20
|
||||
@@ -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
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user