Добавить 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:
+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")
|
||||
|
||||
Reference in New Issue
Block a user