feat: frame QC scoring + viser per-AUV button

Stage 04 frame extract:
- New lib_frame_qc.py: per-frame Laplacian/contrast/blue-dominance scoring
- Classes: bottom_visible / water_no_bottom / turbid_water / out_of_water
- Sample 1/5 frames after extraction, write qc.json per segment
- Record metrics (frames_total, frames_bottom_visible, bottom_visible_pct)
- Mark job degraded when bottom_visible_pct < 50%

Per-AUV viser view:
- scripts/viser_auv.py loads all PLYs of an AUV, color per file
- POST /pipeline/missions/{id}/auvs/{auv}/view rsyncs ply -> worker
- launches viser on hashed port 9300+, returns URL
- _pipeline.html exposes AUV list, JS handler opens viser tab
This commit is contained in:
Ubuntu
2026-05-11 11:05:37 +00:00
parent 1a4fffd2c1
commit 82f71fcc96
7 changed files with 625 additions and 0 deletions

View File

@@ -320,12 +320,17 @@ async def partial_pipeline(request: Request):
counts = {}
for j in jobs:
counts[j["status"]] = counts.get(j["status"], 0) + 1
auvs: list[str] = []
for j in jobs:
if j["auv_id"] and j["auv_id"] not in auvs:
auvs.append(j["auv_id"])
data["missions"].append({
"id": m["id"],
"name": m["name"],
"status": m["status"],
"jobs": [dict(j) for j in jobs],
"counts": counts,
"auvs": auvs,
})
except Exception as e:
data["error"] = str(e)[:200]
@@ -571,6 +576,86 @@ async def live_job(job_id: int):
return {"url": row["viser_url"]}
VISER_AUV_BASE = 9300
PIPELINE_DATA_BASE = Path(os.environ.get("COSMA_PIPELINE_DATA", "/cosma-pipeline/data"))
@app.post("/pipeline/missions/{mission_id}/auvs/{auv_id}/view")
async def view_auv(mission_id: int, auv_id: str):
"""Launch viser showing all PLYs for one AUV from a mission."""
if not PIPELINE_DB.exists():
raise HTTPException(404, "state.db introuvable")
import hashlib
import shutil
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = tmp.name
shutil.copy2(str(PIPELINE_DB), tmp_path)
try:
with sqlite3.connect(tmp_path) as conn:
conn.row_factory = sqlite3.Row
m = conn.execute(
"SELECT name FROM missions WHERE id=?", (mission_id,)
).fetchone()
finally:
try:
os.unlink(tmp_path)
except Exception:
pass
if not m:
raise HTTPException(404, "mission introuvable")
mission_name = m["name"]
ply_dir_local = PIPELINE_DATA_BASE / mission_name / "ply" / auv_id
if not ply_dir_local.exists():
return JSONResponse(
{"ok": False, "error": f"PLY dir {ply_dir_local} pas encore (stitch pas done)"},
status_code=409,
)
h = int(hashlib.md5(f"{mission_id}-{auv_id}".encode()).hexdigest()[:6], 16)
port = VISER_AUV_BASE + (h % 100)
worker = WORKERS[1] if len(WORKERS) > 1 else WORKERS[0]
alias = worker["ssh_alias"]
host = worker["host"]
worker_dir = f"/tmp/cosma-viser-auv/{mission_name}/{auv_id}"
rsync = await asyncio.create_subprocess_exec(
"rsync", "-az", "--delete",
"-e", "ssh -o BatchMode=yes -o StrictHostKeyChecking=no",
f"{ply_dir_local}/", f"{alias}:{worker_dir}/",
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE,
)
_, err = await rsync.communicate()
if rsync.returncode != 0:
raise HTTPException(500, f"rsync failed: {err.decode()[:200]}")
local_script = Path(__file__).parent.parent / "scripts" / "viser_auv.py"
scp = await asyncio.create_subprocess_exec(
"scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no",
str(local_script), f"{alias}:/tmp/viser_auv.py",
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE,
)
_, err = await scp.communicate()
if scp.returncode != 0:
raise HTTPException(500, f"scp failed: {err.decode()[:200]}")
venv_py = f"{worker.get('lingbot_path', '/root/ai-video/lingbot-map')}/.venv/bin/python"
launch_cmd = (
f"pkill -f 'viser_auv.py.*--port {port}' 2>/dev/null ; sleep 1 ; "
f"setsid nohup {venv_py} /tmp/viser_auv.py --ply-dir {worker_dir} --port {port} "
f"</dev/null >/tmp/viser_auv_{port}.log 2>&1 & disown ; sleep 0.3"
)
launch = await asyncio.create_subprocess_exec(
"ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", alias, launch_cmd,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE,
)
await launch.communicate()
await asyncio.sleep(4)
return {"ok": True, "url": f"http://{host}:{port}/", "auv_id": auv_id, "port": port}
@app.post("/stitches/{stitch_id}/view")
async def view_stitch(stitch_id: int):
with closing(db()) as conn:

View File

@@ -220,3 +220,12 @@ code { background: rgba(255,255,255,0.05); padding: 0 0.25rem; border-radius: 3p
.status-degraded, .pj-badge.status-degraded { color: var(--warn); background: rgba(245,197,24,0.1); }
.status-error, .pj-badge.status-error { color: var(--err); background: rgba(255,92,122,0.1); }
.status-ingested, .pm-status.status-ingested { color: var(--accent); background: rgba(95,208,255,0.12); }
/* AUV viser buttons (per-mission) */
.pm-auvs { display: flex; gap: 0.4rem; flex-wrap: wrap; margin: 0.3rem 0 0.5rem; align-items: center; }
.pm-auvs-label { color: var(--muted, #888); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; }
.btn-viser-auv { font-size: 0.72rem; padding: 2px 8px; background: transparent;
border: 1px solid var(--accent, #4af); color: var(--accent, #4af); border-radius: 3px;
cursor: pointer; font-family: inherit; }
.btn-viser-auv:hover { background: var(--accent, #4af); color: #062036; }
.btn-viser-auv:disabled { opacity: 0.5; cursor: wait; }

View File

@@ -16,6 +16,16 @@
{% if m.counts.get('error') %}<span class="cnt err">{{ m.counts.error }} error</span>{% endif %}
</span>
</div>
{% if m.auvs %}
<div class="pm-auvs">
<span class="pm-auvs-label">Viser AUV:</span>
{% for auv_id in m.auvs %}
<button class="btn-viser-auv"
data-url="/pipeline/missions/{{ m.id }}/auvs/{{ auv_id }}/view"
type="button">{{ auv_id }} ↗</button>
{% endfor %}
</div>
{% endif %}
<table class="pipeline-jobs-table">
<thead>
<tr><th>AUV</th><th>Segment</th><th>Stage</th><th>Status</th><th>Worker</th><th>Duree</th></tr>

View File

@@ -104,6 +104,31 @@ document.addEventListener('click', async (e) => {
btn.textContent = 'viser ↗';
btn.disabled = false;
});
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-viser-auv');
if (!btn) return;
e.preventDefault();
const url = btn.dataset.url;
if (!url) return;
const original = btn.textContent;
btn.textContent = 'launch…';
btn.disabled = true;
try {
const res = await fetch(url, { method: 'POST' });
const d = await res.json().catch(() => ({}));
if (res.ok && d.url) {
window.open(d.url, '_blank');
} else {
alert(d.error || d.detail || ('HTTP ' + res.status));
}
} catch (err) {
alert('Erreur réseau: ' + err);
} finally {
btn.textContent = original;
btn.disabled = false;
}
});
</script>
</body>
</html>