diff --git a/Dockerfile b/Dockerfile index 4a9667b..5c7ebd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN pip install --no-cache-dir \ python-multipart==0.0.20 COPY app/ ./app/ +COPY scripts/ ./scripts/ ENV COSMA_QC_DB=/var/lib/cosma-qc/jobs.db diff --git a/app/main.py b/app/main.py index a357bc6..de412b6 100644 --- a/app/main.py +++ b/app/main.py @@ -310,3 +310,81 @@ async def retry_stitch(stitch_id: int): (stitch_id,), ) return {"ok": True} + + +VIEWER_PORT_BASE = 8200 +VIEWER_SCRIPT_REMOTE = "/tmp/cosma-viser_ply.py" + + +def _worker_by_host(host: str) -> dict | None: + for w in WORKERS: + if w["host"] == host: + return w + return WORKERS[0] if WORKERS else None + + +async def _launch_viewer(worker: dict, ply_path: str, port: int) -> None: + alias = worker["ssh_alias"] + local_script = Path(__file__).parent.parent / "scripts" / "viser_ply.py" + proc = await asyncio.create_subprocess_exec( + "scp", "-o", "BatchMode=yes", str(local_script), f"{alias}:{VIEWER_SCRIPT_REMOTE}", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, + ) + _, err = await proc.communicate() + if proc.returncode != 0: + raise HTTPException(500, f"scp viser_ply.py failed: {err.decode()[:200]}") + wrapper = ( + f"#!/bin/bash\n" + f"pkill -f 'viser_ply.py.*--port {port}' 2>/dev/null\n" + f"sleep 1\n" + f"cd /home/floppyrj45/ai-video/lingbot-map\n" + f"source .venv/bin/activate\n" + f"exec python3 {VIEWER_SCRIPT_REMOTE} {ply_path!r} --port {port} --downsample 0\n" + ) + wrapper_path = f"/tmp/cosma-viser-launch-{port}.sh" + proc = await asyncio.create_subprocess_exec( + "ssh", "-o", "BatchMode=yes", alias, + f"cat > {wrapper_path} && chmod +x {wrapper_path}", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, + ) + _, err = await proc.communicate(wrapper.encode()) + if proc.returncode != 0: + raise HTTPException(500, f"wrapper write failed: {err.decode()[:200]}") + proc = await asyncio.create_subprocess_exec( + "ssh", "-o", "BatchMode=yes", alias, + f"setsid nohup {wrapper_path} /tmp/cosma-viser-{port}.log 2>&1 & disown; sleep 0.3", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + await asyncio.sleep(5) + + +@app.post("/jobs/{job_id}/view") +async def view_job(job_id: int): + with closing(db()) as conn: + row = conn.execute( + "SELECT ply_path, worker_host FROM jobs WHERE id=? AND status='done'", + (job_id,), + ).fetchone() + if not row or not row["ply_path"]: + raise HTTPException(404, "PLY non disponible") + worker = _worker_by_host(row["worker_host"]) or WORKERS[0] + port = VIEWER_PORT_BASE + job_id + await _launch_viewer(worker, row["ply_path"], port) + return {"url": f"http://{worker['host']}:{port}"} + + +@app.post("/stitches/{stitch_id}/view") +async def view_stitch(stitch_id: int): + with closing(db()) as conn: + row = conn.execute( + "SELECT output_ply, worker_host FROM stitches WHERE id=? AND status='done'", + (stitch_id,), + ).fetchone() + if not row or not row["output_ply"]: + raise HTTPException(404, "PLY stitch non disponible") + worker = _worker_by_host(row["worker_host"]) or WORKERS[0] + port = VIEWER_PORT_BASE + 1000 + stitch_id + await _launch_viewer(worker, row["output_ply"], port) + return {"url": f"http://{worker['host']}:{port}"} diff --git a/app/static/style.css b/app/static/style.css index d58b6f1..8f02c34 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -74,6 +74,11 @@ progress::-moz-progress-bar { background: var(--accent); } white-space: nowrap; } .job-list .dur { color: var(--muted); font-size: 0.78rem; } .job-list .ext { margin-left: 0.5rem; color: var(--accent); font-size: 0.75rem; } +button.ext.viewer-btn { background: transparent; border: 1px solid var(--accent); + color: var(--accent); font-size: 0.72rem; padding: 1px 6px; border-radius: 3px; + cursor: pointer; font-family: inherit; } +button.ext.viewer-btn:hover { background: var(--accent); color: #000; } +button.ext.viewer-btn:disabled { opacity: 0.6; cursor: wait; } .err-line { color: var(--err); font-size: 0.75rem; padding-left: 28px; padding-bottom: 0.25rem; } diff --git a/app/templates/_jobs_table.html b/app/templates/_jobs_table.html index 0bbcbf9..2525ce5 100644 --- a/app/templates/_jobs_table.html +++ b/app/templates/_jobs_table.html @@ -19,8 +19,8 @@ {{ j.auv }}/{{ j.gopro_serial }}/{{ j.segment_label }} - {% if j.status == 'done' and j.viser_url %} - viser + {% if j.status == 'done' and j.ply_path %} + {% endif %} {{ j._duration }} @@ -56,7 +56,7 @@ {% else %}merge final{% endif %} {% if s._duration %} — {{ s._duration }}{% endif %} {% if s.status == 'done' and s.output_ply %} - PLY + {% endif %} {% if s.status in ('queued','running') %} diff --git a/app/templates/index.html b/app/templates/index.html index aab473e..4b92445 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -24,5 +24,24 @@

Chargement…

+ + diff --git a/scripts/viser_ply.py b/scripts/viser_ply.py new file mode 100644 index 0000000..05c810d --- /dev/null +++ b/scripts/viser_ply.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Minimal standalone viser viewer for a single PLY point cloud. + +Usage: + python3 viser_ply.py path/to/cloud.ply --port 8200 +""" +from __future__ import annotations + +import argparse +import sys +import time + +import numpy as np + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("ply", help="PLY file to visualize") + ap.add_argument("--port", type=int, default=8200) + ap.add_argument("--point-size", type=float, default=0.01) + ap.add_argument("--downsample", type=float, default=0.0, + help="Voxel downsample size (0 = no downsample)") + ap.add_argument("--max-points", type=int, default=2_000_000, + help="Random subsample to this many points if cloud is larger") + args = ap.parse_args() + + try: + import open3d as o3d + import viser + except ImportError as e: + sys.exit(f"missing dep: {e}") + + pcd = o3d.io.read_point_cloud(args.ply) + if args.downsample > 0: + pcd = pcd.voxel_down_sample(args.downsample) + pts = np.asarray(pcd.points, dtype=np.float32) + cols = np.asarray(pcd.colors, dtype=np.float32) if pcd.has_colors() else np.ones_like(pts) * 0.7 + if len(pts) > args.max_points: + idx = np.random.choice(len(pts), args.max_points, replace=False) + pts, cols = pts[idx], cols[idx] + print(f"loaded {len(pts):,} pts from {args.ply}", flush=True) + + server = viser.ViserServer(host="0.0.0.0", port=args.port) + server.scene.add_point_cloud( + "/cloud", points=pts, colors=(cols * 255).astype(np.uint8), point_size=args.point_size + ) + print(f"viser listening on *:{args.port}") + while True: + time.sleep(60) + + +if __name__ == "__main__": + main()