PLY читается в FilePointCloudSource, CLI отдаёт геометрию в output JSON, а Docker/FastAPI/Three.js дают веб-запуск пайплайна без конфликта libpq на хосте. Co-authored-by: Cursor <cursoragent@cursor.com>
192 lines
6.2 KiB
Python
192 lines
6.2 KiB
Python
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")
|