Добавить 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:
@@ -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,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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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