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