viewer on-demand — relancer viser à la demande depuis le dashboard
Le viser de demo.py était tué dès que le PLY était écrit (pour libérer la VRAM),
donc les liens dans le dashboard menaient vers ERR_CONNECTION_REFUSED.
Ajout d'un viewer standalone indépendant :
- scripts/viser_ply.py : charge un PLY via open3d + sert via viser (sans GPU),
subsample random à 2M pts max pour rester fluide
- app/main.py : routes POST /jobs/{id}/view et /stitches/{id}/view qui scp
le script sur le worker et lancent un viser détaché (nohup+setsid+disown via
wrapper shell déposé sur le worker)
- templates : remplace <a href> par <button class=viewer-btn> qui POST puis
window.open de l'URL retournée
- Dockerfile : copie scripts/ dans l'image (nécessaire pour scp-er viser_ply.py)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
78
app/main.py
78
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} </dev/null >/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}"}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
</span>
|
||||
<span class="label">
|
||||
{{ j.auv }}/{{ j.gopro_serial }}/{{ j.segment_label }}
|
||||
{% if j.status == 'done' and j.viser_url %}
|
||||
<a class="ext" href="{{ j.viser_url }}" target="_blank">viser</a>
|
||||
{% if j.status == 'done' and j.ply_path %}
|
||||
<button class="ext viewer-btn" data-view-url="jobs/{{ j.id }}/view">viser</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="dur">{{ j._duration }}</span>
|
||||
@@ -56,7 +56,7 @@
|
||||
{% else %}merge final{% endif %}
|
||||
{% if s._duration %}<span class="dur muted"> — {{ s._duration }}</span>{% endif %}
|
||||
{% if s.status == 'done' and s.output_ply %}
|
||||
<span class="ext" title="{{ s.output_ply }}">PLY</span>
|
||||
<button class="ext viewer-btn" data-view-url="stitches/{{ s.id }}/view" title="{{ s.output_ply }}">viser</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if s.status in ('queued','running') %}
|
||||
|
||||
@@ -24,5 +24,24 @@
|
||||
<p class="muted">Chargement…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.viewer-btn');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
const url = btn.dataset.viewUrl;
|
||||
btn.textContent = '…';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.url) window.open(data.url, '_blank');
|
||||
else alert(data.detail || 'Erreur lancement viewer');
|
||||
} catch (err) { alert('Erreur réseau: ' + err); }
|
||||
btn.textContent = 'viser';
|
||||
btn.disabled = false;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
53
scripts/viser_ply.py
Normal file
53
scripts/viser_ply.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user