Добавить PLY-загрузку и Docker Web UI с PCL-пайплайном.

PLY читается в FilePointCloudSource, CLI отдаёт геометрию в output JSON,
а Docker/FastAPI/Three.js дают веб-запуск пайплайна без конфликта libpq на хосте.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 10:39:34 +03:00
co-authored by Cursor
parent 98afe5af5a
commit 45b2ed6e22
17 changed files with 1332 additions and 36 deletions
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import json
import os
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
from fastapi.staticfiles import StaticFiles
APP_ROOT = Path(__file__).resolve().parent.parent
WEB_ROOT = APP_ROOT / "web"
PRESETS_DIRS = [APP_ROOT / "presets", APP_ROOT / "docker"]
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.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def preset_to_pipeline_config(preset: dict[str, Any]) -> dict[str, Any]:
if "preprocessPlugins" in preset and "reconstructionPlugin" in preset:
return preset
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 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
@app.get("/api/health")
def health() -> dict[str, str]:
return {
"status": "ok",
"binary": str(DOTSTOSIRFACE_BIN),
"binaryExists": str(DOTSTOSIRFACE_BIN.is_file()),
}
@app.get("/api/presets")
def presets() -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
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),
"config": preset_to_pipeline_config(data),
}
)
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.post("/api/run")
async def run_pipeline(
file: UploadFile = File(...),
preset_id: str | None = Form(default=None),
config_json: str | None = Form(default=None),
) -> 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 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))
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,
)
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["stdout"] = completed.stdout.strip()
result["workId"] = work_id
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
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/")
def index() -> FileResponse:
return FileResponse(WEB_ROOT / "index.html")
app.mount("/static", StaticFiles(directory=WEB_ROOT), name="static")