Добавить Vue 3 web-dashboard с расширенным API и Docker-сборкой.

Полноценный браузерный UI (Pinia, Three.js) с паритетом Qt: редактор цепочки, wizard, пресеты, метрики и 3D viewer; API расширен для catalog/validate/demo/user-presets; CLI отдаёт step metrics в JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 14:53:53 +03:00
co-authored by Cursor
parent 45b2ed6e22
commit 18a58f2e85
33 changed files with 4304 additions and 56 deletions
+80
View File
@@ -0,0 +1,80 @@
"""Built-in pipeline presets (mirrors MainWindow::applyPreset)."""
from __future__ import annotations
from typing import Any
BUILTIN_PRESETS: list[dict[str, Any]] = [
{
"title": "LiDAR-скан",
"idValue": "LiDAR_scan",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": [
"pcl_remove_nan",
"keep_largest_cluster",
"pcl_statistical_outlier",
"pcl_voxel_grid",
],
"reconstructionPlugin": "surface_fallback",
"stageDefaults": {},
},
},
{
"title": "RGB-D камера",
"idValue": "RGBD_camera",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": [
"pcl_remove_nan",
"pcl_statistical_outlier",
"pcl_radius_outlier",
"pcl_voxel_grid",
],
"reconstructionPlugin": "pcl_greedy_triangulation",
"stageDefaults": {},
},
},
{
"title": "Синтетика",
"idValue": "Synthetic_clean",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": ["pcl_remove_nan", "downsample_dense", "pcl_voxel_grid"],
"reconstructionPlugin": "pcl_greedy_triangulation",
"stageDefaults": {},
},
},
{
"title": "Fast",
"idValue": "Fast",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": ["pcl_remove_nan", "pcl_voxel_grid"],
"reconstructionPlugin": "surface_fallback",
"stageDefaults": {},
},
},
{
"title": "Robust",
"idValue": "Robust",
"config": {
"profile": "desktop_debug",
"preprocessPlugins": [
"pcl_remove_nan",
"pcl_voxel_grid",
"pcl_statistical_outlier",
"pcl_radius_outlier",
],
"reconstructionPlugin": "surface_fallback",
"stageDefaults": {},
},
},
]
def get_builtin_preset(preset_id: str) -> dict[str, Any] | None:
for preset in BUILTIN_PRESETS:
if preset["idValue"] == preset_id:
return preset
return None
+70
View File
@@ -0,0 +1,70 @@
"""Demo point cloud generation (mirrors generateDemoPoints in mainwindow.cpp)."""
from __future__ import annotations
import math
import random
from typing import Any
def generate_demo_points(surface_type: str, count: int = 350) -> list[list[float]]:
points: list[list[float]] = []
rng = random.Random()
for _ in range(count):
if surface_type == "Дно реки + труба":
if rng.random() < 0.35:
angle = rng.random() * 2.0 * math.pi
y = rng.random() * 2.6 - 1.3
pipe_radius = 0.22
jitter = rng.random() * 0.02 - 0.01
radial = pipe_radius + jitter
x = 0.0 + radial * math.cos(angle)
z = -0.62 + radial * math.sin(angle)
else:
x = rng.random() * 4.0 - 2.0
y = rng.random() * 3.0 - 1.5
waviness = 0.11 * math.cos(2.2 * y)
channel = 0.08 * x * x
noise = rng.random() * 0.04 - 0.02
z = -0.45 + channel + waviness + noise
elif surface_type == "Тор":
u = rng.random() * 2.0 * math.pi
v = rng.random() * 2.0 * math.pi
major_r = 1.0
minor_r = 0.35
jitter = rng.random() * 0.02 - 0.01
radial = minor_r + jitter
x = (major_r + radial * math.cos(v)) * math.cos(u)
y = (major_r + radial * math.cos(v)) * math.sin(u)
z = radial * math.sin(v)
elif surface_type == "Волна":
x = rng.random() * 2.4 - 1.2
y = rng.random() * 2.4 - 1.2
noise = rng.random() * 0.03 - 0.015
z = 0.35 * math.sin(2.5 * x) * math.cos(2.5 * y) + noise
else:
u = rng.random() * 2.0 - 1.0
theta = rng.random() * 2.0 * math.pi
r = 1.0 + rng.random() * 0.08 - 0.04
s = math.sqrt(max(0.0, 1.0 - u * u))
x = r * s * math.cos(theta)
y = r * s * math.sin(theta)
z = r * u
points.append([x, y, z])
return points
DEMO_SURFACE_TYPES = ["Сфера", "Тор", "Волна", "Дно реки + труба"]
def demo_payload(surface_type: str, count: int = 350) -> dict[str, Any]:
if surface_type not in DEMO_SURFACE_TYPES:
surface_type = "Сфера"
points = generate_demo_points(surface_type, count)
return {
"surfaceType": surface_type,
"pointCount": len(points),
"points": points,
"sourceLabel": f"demo: {surface_type}",
}
+306 -39
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import struct
import subprocess
import tempfile
import uuid
@@ -10,18 +11,26 @@ from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
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 stage_meta import STAGE_META, STAGE_META_BY_ID, catalog_payload, defaults_for_stage
APP_ROOT = Path(__file__).resolve().parent.parent
WEB_ROOT = APP_ROOT / "web"
WEB_DIST = APP_ROOT / "web-vue" / "dist"
WEB_LEGACY = APP_ROOT / "web"
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")
)
DOTSTOSIRFACE_BIN = Path(os.environ.get("DOTSTOSIRFACE_BIN", "/usr/local/bin/DotsToSirface"))
app = FastAPI(title="DotsToSirface Web API", version="1.0.0")
app = FastAPI(title="DotsToSirface Web API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -30,10 +39,36 @@ app.add_middleware(
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]]
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"
@@ -62,6 +97,38 @@ def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
}
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()
@@ -76,18 +143,102 @@ def list_preset_files() -> list[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(DOTSTOSIRFACE_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(DOTSTOSIRFACE_BIN),
"binaryExists": str(DOTSTOSIRFACE_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[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)
@@ -96,7 +247,20 @@ def presets() -> list[dict[str, Any]]:
"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
@@ -110,58 +274,140 @@ def default_config() -> dict[str, Any]:
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/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 = File(...),
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 DOTSTOSIRFACE_BIN.is_file():
raise HTTPException(status_code=500, detail=f"Binary not found: {DOTSTOSIRFACE_BIN}")
suffix = Path(file.filename or "cloud.ply").suffix or ".ply"
work_id = uuid.uuid4().hex
work_dir = Path(tempfile.gettempdir()) / "dotstosirface" / work_id
work_dir.mkdir(parents=True, exist_ok=True)
input_path = work_dir / f"input{suffix}"
config_path = work_dir / "pipeline_config.json"
output_path = work_dir / "result.json"
try:
content = await file.read()
input_path.write_bytes(content)
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 or ".ply"
input_path = work_dir / f"input{suffix}"
input_path.write_bytes(await file.read())
else:
raise HTTPException(status_code=400, detail="Provide file or demo_surface.")
if config_json:
pipeline_config = json.loads(config_json)
elif preset_id:
preset_path = next((p for p in list_preset_files() if p.stem == preset_id), None)
if preset_path is None:
raise HTTPException(status_code=400, detail=f"Unknown preset: {preset_id}")
with preset_path.open("r", encoding="utf-8") as handle:
pipeline_config = preset_to_pipeline_config(json.load(handle))
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)
config_path.write_text(json.dumps(pipeline_config, indent=2), encoding="utf-8")
command = [
str(DOTSTOSIRFACE_BIN),
"--cli",
"--input",
str(input_path),
"--config-json",
str(config_path),
"--output-json",
str(output_path),
]
completed = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
)
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)
@@ -172,20 +418,41 @@ async def run_pipeline(
with output_path.open("r", encoding="utf-8") as handle:
result = json.load(handle)
result["stdout"] = completed.stdout.strip()
result["workId"] = work_id
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: # pragma: no cover - defensive path for API boundary
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()) / "dotstosirface" / 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("/")
def index() -> FileResponse:
return FileResponse(WEB_ROOT / "index.html")
if (WEB_DIST / "index.html").is_file():
return FileResponse(WEB_DIST / "index.html")
return FileResponse(WEB_LEGACY / "index.html")
app.mount("/static", StaticFiles(directory=WEB_ROOT), name="static")
if (WEB_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
if WEB_LEGACY.is_dir() and not (WEB_DIST / "index.html").is_file():
app.mount("/static", StaticFiles(directory=WEB_LEGACY), name="static")
+66
View File
@@ -0,0 +1,66 @@
"""Pipeline chain insights (mirrors MainWindow::recomputeInsights)."""
from __future__ import annotations
from typing import Any
def compute_insights(
preprocess_plugins: list[str],
reconstruction_plugin: str,
) -> dict[str, Any]:
warnings: list[str] = []
risk_state = False
stages = [s for s in preprocess_plugins if s]
voxel_idx = stages.index("pcl_voxel_grid") if "pcl_voxel_grid" in stages else -1
remove_nan_idx = stages.index("pcl_remove_nan") if "pcl_remove_nan" in stages else -1
remove_nan_normals_idx = (
stages.index("pcl_remove_nan_normals") if "pcl_remove_nan_normals" in stages else -1
)
outlier_indexes = []
for stage_id in (
"pcl_statistical_outlier",
"pcl_radius_outlier",
"pcl_model_outlier",
"pcl_shadow_points",
):
if stage_id in stages:
outlier_indexes.append(stages.index(stage_id))
first_outlier_idx = min(outlier_indexes) if outlier_indexes else -1
if not stages:
risk_state = True
warnings.append("Добавьте хотя бы один этап препроцессинга перед реконструкцией.")
else:
if first_outlier_idx < 0:
warnings.append("Добавьте outlier removal для стабилизации триангуляции.")
if first_outlier_idx >= 0:
nan_idxs = [i for i in (remove_nan_idx, remove_nan_normals_idx) if i >= 0]
first_nan_preclean_idx = min(nan_idxs) if nan_idxs else -1
if first_nan_preclean_idx < 0 or first_nan_preclean_idx > first_outlier_idx:
warnings.append(
"Удалите NaN сразу после загрузки/обрезки: иначе статистические фильтры работают нестабильно."
)
if voxel_idx >= 0 and first_outlier_idx >= 0 and first_outlier_idx > voxel_idx:
warnings.append(
"OutlierRemoval стоит после VoxelGrid: лучше сначала очистить шум, затем прореживать."
)
if reconstruction_plugin == "pcl_greedy_triangulation" and voxel_idx < 0:
warnings.append("Greedy triangulation обычно работает лучше после voxel downsampling.")
if risk_state:
chain_health = "Risk"
recommendation = warnings[0] if warnings else "Проверьте порядок этапов пайплайна."
elif warnings:
chain_health = "Warning"
recommendation = warnings[0]
else:
chain_health = "OK"
recommendation = "Pipeline looks balanced. Use Compare snapshots for A/B tuning."
return {
"chainHealth": chain_health,
"warningsList": warnings,
"recommendation": recommendation,
}
+63
View File
@@ -0,0 +1,63 @@
"""Stage metadata with default parameter strings (from MainWindow kStageMeta)."""
from __future__ import annotations
from typing import Any
STAGE_META: list[dict[str, str]] = [
{"id": "keep_largest_cluster", "title": "Крупнейший кластер", "category": "Обрезка", "family": "preprocess", "hint": "Удаляет мелкие фрагменты и оставляет основной объект.", "defaults": "clusterJoinDistanceScale=5.0"},
{"id": "pcl_remove_nan", "title": "Remove NaN Points", "category": "NaN (предочистка)", "family": "preprocess", "hint": "Удаляет точки с NaN/Inf в X/Y/Z сразу после загрузки/обрезки.", "defaults": ""},
{"id": "pcl_remove_nan_normals", "title": "Remove NaN Normals", "category": "NaN (предочистка)", "family": "preprocess", "hint": "Удаляет точки с невалидными нормалями.", "defaults": ""},
{"id": "pcl_pass_through", "title": "PassThrough", "category": "Обрезка", "family": "preprocess", "hint": "Фильтр по диапазону одной координатной оси.", "defaults": "axis=z,min=-1.0,max=1.0"},
{"id": "pcl_crop_box", "title": "CropBox", "category": "Обрезка", "family": "preprocess", "hint": "Обрезка по границам 3D-параллелепипеда.", "defaults": "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"},
{"id": "pcl_crop_hull", "title": "CropHull", "category": "Обрезка", "family": "preprocess", "hint": "Обрезка по выпуклой области.", "defaults": "minX=-1.0,minY=-1.0,minZ=-1.0,maxX=1.0,maxY=1.0,maxZ=1.0"},
{"id": "pcl_frustum_culling", "title": "Frustum Culling", "category": "Обрезка", "family": "preprocess", "hint": "Оставляет точки в пирамиде видимости.", "defaults": "near=0.1,far=5.0,hfov=70,vfov=50"},
{"id": "pcl_plane_clipper_3d", "title": "PlaneClipper3D", "category": "Обрезка", "family": "preprocess", "hint": "Отсечение по плоскости ax+by+cz+d=0.", "defaults": "a=0.0,b=0.0,c=1.0,d=0.0,keepPositive=true"},
{"id": "pcl_conditional_removal", "title": "Conditional Removal", "category": "Условия/Индексы", "family": "preprocess", "hint": "Удаление точек по диапазону Z.", "defaults": "zMin=-1.0,zMax=1.0"},
{"id": "pcl_extract_indices", "title": "Extract Indices", "category": "Условия/Индексы", "family": "preprocess", "hint": "Извлечение каждой N-й точки.", "defaults": "nth=2"},
{"id": "pcl_functor_filter", "title": "Functor Filter", "category": "Условия/Индексы", "family": "preprocess", "hint": "Фильтрация по расстоянию до начала координат.", "defaults": "radiusMax=2.5"},
{"id": "pcl_project_inliers", "title": "ProjectInliers", "category": "Нормали", "family": "preprocess", "hint": "Проецирование точек на плоскость.", "defaults": "a=0.0,b=0.0,c=1.0,d=0.0"},
{"id": "pcl_normal_refinement", "title": "Normal Refinement", "category": "Нормали", "family": "preprocess", "hint": "Уточнение геометрии локальным усреднением.", "defaults": "radius=0.1,iterations=1"},
{"id": "pcl_bilateral_filter", "title": "Bilateral Filter", "category": "Сглаживание", "family": "preprocess", "hint": "Двустороннее сглаживание.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
{"id": "pcl_fast_bilateral_filter", "title": "Fast Bilateral Filter", "category": "Сглаживание", "family": "preprocess", "hint": "Быстрая версия bilateral.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
{"id": "pcl_fast_bilateral_filter_omp", "title": "Fast Bilateral Filter OMP", "category": "Сглаживание", "family": "preprocess", "hint": "Многопоточный bilateral.", "defaults": "sigmaS=0.08,sigmaR=0.05"},
{"id": "pcl_convolution", "title": "Convolution", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро свертки.", "defaults": "sigma=0.08,kernel=3"},
{"id": "pcl_gaussian_kernel", "title": "Gaussian Kernel", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро.", "defaults": "sigma=0.08,kernel=3"},
{"id": "pcl_gaussian_kernel_rgb", "title": "Gaussian Kernel RGB", "category": "Сглаживание", "family": "preprocess", "hint": "Гауссово ядро RGB.", "defaults": "sigma=0.08,kernel=3"},
{"id": "pcl_voxel_grid_occlusion", "title": "VoxelGrid Occlusion Estimation", "category": "Морфология", "family": "preprocess", "hint": "Оценка окклюзии по вокселям.", "defaults": "leaf=0.12,minHits=2"},
{"id": "downsample_dense", "title": "Прореживание плотности", "category": "Прореживание", "family": "preprocess", "hint": "Снижает число точек на плотных облаках.", "defaults": "downsampleCellScale=0.8"},
{"id": "pcl_voxel_grid", "title": "PCL Voxel Grid", "category": "Прореживание", "family": "preprocess", "hint": "Воксельное прореживание.", "defaults": "leaf=0.02"},
{"id": "pcl_statistical_outlier", "title": "Статистическая фильтрация", "category": "Шум", "family": "preprocess", "hint": "Удаляет выбросы по статистике соседей.", "defaults": "meanK=24,stddev=1.2"},
{"id": "pcl_radius_outlier", "title": "Радиусная фильтрация", "category": "Шум", "family": "preprocess", "hint": "Удаляет точки без соседей в радиусе.", "defaults": "radius=0.04,minNeighbors=8"},
{"id": "pcl_model_outlier", "title": "Model Outlier Removal", "category": "Шум", "family": "preprocess", "hint": "Удаляет отклонения от модели.", "defaults": "threshold=0.03"},
{"id": "pcl_shadow_points", "title": "Shadow Points Removal", "category": "Шум", "family": "preprocess", "hint": "Удаляет теневые точки.", "defaults": "shadowThreshold=0.2"},
{"id": "pcl_approximate_voxel_grid", "title": "Approximate Voxel Grid", "category": "Прореживание", "family": "preprocess", "hint": "Ускоренное воксельное прореживание.", "defaults": "leaf=0.03"},
{"id": "pcl_voxel_grid_label", "title": "Voxel Grid Label", "category": "Прореживание", "family": "preprocess", "hint": "Воксели с метками.", "defaults": "leaf=0.04"},
{"id": "pcl_voxel_grid_covariance", "title": "Voxel Grid Covariance", "category": "Прореживание", "family": "preprocess", "hint": "Воксели с ковариациями.", "defaults": "leaf=0.04"},
{"id": "pcl_grid_minimum", "title": "Grid Minimum", "category": "Прореживание", "family": "preprocess", "hint": "Минимум Z в ячейке.", "defaults": "resolution=0.05"},
{"id": "pcl_farthest_point_sampling", "title": "Farthest Point Sampling", "category": "Прореживание", "family": "preprocess", "hint": "Наиболее удалённые точки.", "defaults": "sample=800"},
{"id": "pcl_normal_space_sampling", "title": "Normal Space Sampling", "category": "Прореживание", "family": "preprocess", "hint": "Выборка по нормалям.", "defaults": "sample=800"},
{"id": "pcl_sampling_surface_normal", "title": "Sampling Surface Normal", "category": "Прореживание", "family": "preprocess", "hint": "Выборка по нормалям поверхности.", "defaults": "sample=800"},
{"id": "surface_fallback", "title": "Fallback Surface", "category": "Реконструкция", "family": "reconstruction", "hint": "Базовая реконструкция.", "defaults": "neighborRadiusScale=3.5,runEveryNthFrame=1"},
{"id": "pcl_greedy_triangulation", "title": "PCL Greedy Triangulation", "category": "Реконструкция", "family": "reconstruction", "hint": "Жадная триангуляция.", "defaults": "searchRadius=0.08,mu=2.5,maxNearest=100,maxSurfaceAngle=0.8"},
{"id": "pcl_poisson_reconstruction", "title": "PCL Poisson Reconstruction", "category": "Реконструкция", "family": "reconstruction", "hint": "Poisson реконструкция.", "defaults": "poissonDepth=8,samplesPerNode=1.5"},
]
STAGE_META_BY_ID: dict[str, dict[str, str]] = {item["id"]: item for item in STAGE_META}
def defaults_for_stage(stage_id: str) -> str:
meta = STAGE_META_BY_ID.get(stage_id)
return meta["defaults"] if meta else ""
def catalog_payload() -> dict[str, Any]:
# Catalog groups come from frontend module at build time; API exposes stage meta + ids.
return {
"stageMeta": STAGE_META,
"reconstructions": [
"surface_fallback",
"pcl_greedy_triangulation",
"pcl_poisson_reconstruction",
],
}