From 9dd6a82d0890a375c5df571fb7fd0780dbb32a7f Mon Sep 17 00:00:00 2001 From: Flag Date: Wed, 22 Apr 2026 21:28:06 +0000 Subject: [PATCH] =?UTF-8?q?dashboard=20+=20dispatcher=20=C2=97=20UX=20prop?= =?UTF-8?q?s,=20trim=20head+tail,=20cols,=20link=20direct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashboard: - job_id, AUV GP1/GP2 (serial en tooltip), segment_label, duree reelle, nb frames, nb hors-eau trimes - lien viser plain (plus de POST ni popup). Affiche uniquement si job.done ET viser_url persistee (demo.py kept alive) - CSS minimal: flex row, separateurs, skipped en italic mute dispatcher: - trim head ET tail (AUV hors-eau en debut + fin de session) - migration DB: trimmed_head, trimmed_tail, video_duration_s - do_extract persiste total_duration_s + trimmed counts via set_status - run_one: RuntimeError(skipped_short) preserve le status=skipped - min_frames underwater pour skip les segments trop courts - ram_budget 0.45 -> 0.35 (OOM rc=137 avec 8237 frames sur 62GB RAM) --- app/main.py | 17 +++ app/static/style.css | 12 ++ app/templates/_jobs_table.html | 10 +- .../__pycache__/dispatcher.cpython-311.pyc | Bin 37897 -> 43751 bytes scripts/dispatcher.py | 113 ++++++++++++++---- 5 files changed, 123 insertions(+), 29 deletions(-) diff --git a/app/main.py b/app/main.py index 4ce65a9..152ef43 100644 --- a/app/main.py +++ b/app/main.py @@ -146,12 +146,29 @@ def _build_acquisitions(): "SELECT * FROM stitches ORDER BY level DESC, auv" ).fetchall() + # Assign GP1/GP2 labels per AUV by enumerating distinct serials in fixed order. + gp_by_serial: dict[tuple[int, str], str] = {} + for j in jobs: + key = (j["acquisition_id"], j["auv"]) + serials = gp_by_serial.setdefault(key, []) + if j["gopro_serial"] not in serials: + serials.append(j["gopro_serial"]) + gp_label: dict[tuple[int, str, str], str] = {} + for (acq_id, auv), serials in gp_by_serial.items(): + for idx, ser in enumerate(sorted(serials)): + gp_label[(acq_id, auv, ser)] = f"GP{idx + 1}" + by_acq: dict[int, list[dict]] = {} by_acq_total: dict[int, int] = {} for j in jobs: d = dict(j) dur_s = _job_duration_s(j) d["_duration"] = _fmt_dur(dur_s) + d["gp_label"] = gp_label.get((j["acquisition_id"], j["auv"], j["gopro_serial"]), "?") + d["video_duration_fmt"] = _fmt_dur(int(j["video_duration_s"] or 0)) if (j["video_duration_s"] or 0) > 0 else "—" + d["trimmed_total"] = (j["trimmed_head"] or 0) + (j["trimmed_tail"] or 0) + # Only expose a native viser link when port is listening. Probed on render via TCP check. + d["native_viser_url"] = None # filled below by_acq.setdefault(j["acquisition_id"], []).append(d) by_acq_total[j["acquisition_id"]] = by_acq_total.get(j["acquisition_id"], 0) + dur_s diff --git a/app/static/style.css b/app/static/style.css index 08c8539..f43ac2a 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -115,3 +115,15 @@ button { background: transparent; color: var(--accent); border: 1px solid var(-- button:hover { border-color: var(--accent); } a { color: var(--accent); } code { background: rgba(255,255,255,0.05); padding: 0 0.25rem; border-radius: 3px; } + +/* Job row columns: id · AUV-GP · segment · meta · progress · viser */ +.job-item .label { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 14px; } +.job-item .job-id { font-family: monospace; color: var(--mut, #666); font-size: 12px; min-width: 32px; } +.job-item .auv-gp { font-weight: 600; min-width: 100px; } +.job-item .seg { color: var(--mut, #666); font-variant-numeric: tabular-nums; min-width: 90px; } +.job-item .meta { color: var(--mut, #888); font-size: 12px; font-variant-numeric: tabular-nums; } +.job-item .meta::before { content: '· '; opacity: 0.5; } +.job-item .viser-link { text-decoration: none; padding: 2px 8px; border: 1px solid var(--accent, #4a9); border-radius: 3px; color: var(--accent, #4a9); font-size: 12px; } +.job-item .viser-link:hover { background: var(--accent, #4a9); color: white; } +.job-item.skipped { opacity: 0.55; } +.job-item.skipped .label { font-style: italic; } diff --git a/app/templates/_jobs_table.html b/app/templates/_jobs_table.html index be9911a..648c369 100644 --- a/app/templates/_jobs_table.html +++ b/app/templates/_jobs_table.html @@ -18,13 +18,15 @@ {% else %}â– {% endif %} - {{ j.auv }}/{{ j.gopro_serial }}/{{ j.segment_label }} + #{{ j.id }} + {{ j.auv }} {{ j.gp_label }} + {{ j.segment_label }} + {% if j.video_duration_fmt != '—' %}{{ j.video_duration_fmt }}{% if j.frame_count %} · {{ j.frame_count }} f{% if j.trimmed_total %} · −{{ j.trimmed_total }} hors-eau{% endif %}{% endif %}{% endif %} {% if j.status in ('extracting','running') %} {{ j.progress }}% {% endif %} - {% if j.status == 'done' and j.ply_path %} - - + {% if j.status == 'done' and j.viser_url %} + viser {% endif %} {{ j._duration }} diff --git a/scripts/__pycache__/dispatcher.cpython-311.pyc b/scripts/__pycache__/dispatcher.cpython-311.pyc index 4b2de32011298a20588da23164f5afecada75397..3ea38245c37d5cecefe2c2c26a1dc65ea94bff68 100644 GIT binary patch delta 10587 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 H9^n52^GWFn delta 5610 zcmb7I4RBM(wZ1Fq%9i{mS+>DoW8pt68yka-!3G=f4>n*3w!zqtL>Ag>BO^;+e=^g9avuDpach8>Pv*-H5Ul@+OU`Rcil46zMxlwS{ce>|z>TFVSe8Cvm_R@Y;VemeZZI9@+}(rK_M_L@8~AwvsBe1=_{5pSD7~1oYdWtpfdZhfFowtLXq;4~(UB z7j1{OhWh9xXqVAJx*6K#&~`vuOFIuq+v^OHVjWz;I|j1ax38pKz^bRa=@t-Zpj)B0 zN?A?2cN@@O=CVj2zUmCn=aC^EB2ix74;Be(uGtdE_ye}$MuzmD&46p}VR>Zq1GK%g`BO-Hej zbny4G=8{hSaaN^#FR-`A7SF0PZuUYi~47ki}A2e(~LKp$pj4ef23;^Q?k&aIfsRIZR0Dfs+7T-|d zussN!&%jUp6Tpp8i97QuV-p2`CAGN1x~%T(Zk0U`k}pgAtMzXh&LnV>G>0E+cxzL{&@3G@-q#p4Q>!NO z7>L31tVENvvpNK1MNwPa5kh*~37YT1oPMPhZDV{$;EHnfrjhV2nT)2CE`9fRf(j{nld5A4KNPB_!CWv$l8=CiDqbXZ;we*B#D2#x+*U-B2m+n5WFudVgj5s zE=QB;Z1lyJe1?Es_{E=Rxw~bKa8O$_mv?JvR*;`7v~q;+r+jJXO72~z=izYXo#v$R z)M$!Wx;%cdvBW@~e6(>9$wy(5%)>2C>%HJ~G&PzQP2TKQnU??7Xja&g_USV!u#y=lVrbd8ny?6!WK=%Ebm+apzXhMHedXS^>DYT#NZ1S~uFmrv8vxA5K`PdOd!n zo;}L%X=@}~`H8lV;F>tN?y%`7%#we!zN+ZzN}$*i0In?EG^hpxy6g{ns5<=+^M(zx zw&7vd`h>g67hqAOOqj7Cq4^ahBo4ox;8Uq@0MZ+WaaSh$ zBC59WuWcx?lfAlOZ@-=p@@SB=18jod+)!2~vQ~n3m@y@w>Bf*((-Xar<=m>q;@SHo zm{2nT_#QcvKir<4^aJR88@Ve@{Q36Lq!hc}VI z1yG_EANcq5_dx6pFJaIyMtEgpNx^Oy4QH$w_6`S}o7;Px)Tf4m0pCu)a?Z&5@eRBA z!;#|USiCSN3LBx=Vvi%0G_CAnnE#j$0Lp2hC&M0R3H*adwI!UO28Wqfaq^t#=L#lk zG1slL*9uxQX1JGMi5449lM@GfQ+A;=KPJn7WAtA5e8m$iLqWDR-C3{)XIyAcPHg@!Tp(Gfc4J8%HM!yTY}RaycDV z${#y3QY9NM8u%*ajQ#ha^Q34)fuz|$`ql%VPYqFv23Ps*2dW_(|KnJ$(N5EO%HajM zjx*r=d0YVT5UCexrl{qhC6Gw%{E5Rm|+!%17nLTiFvUjVu)8=JS;yzY77B$IQs|zKRHA&SRYVOoQo@u0w0dIftYdH0dMP)_~i$mwlF?t$Dph3_V&AFFw8!PgxZ zJ6_6qCF@oB)r?o=S8{*S_QUo6w*KwJZ9h%i_VdJTK-}>MIqSwXBiaGtZ)X0%ql>C> zAHG()rmm?-dcBae5b5=D9$J+xVQpT>gvW_hpLE<^Yf7BFsa`>IYt=v1uk{` zWQh=pDC40Lp@u{t;%Mi?C+EwRAS%V4Jy|MSLqLUR4|#k6ZaStwAk(jSD{Ol_!G3A6MDhe8cD{=JDcX}B6;gR?|j#q2SjcDib$3SOa` zJZwN^)j;BYhv&Uj?0=vU$xWBo`$)e7FpQr)?~k2u!UltnR&bDjk}Ey#PbG?B=Fu(M zE~a>>FEF5+w+NEqGC|3P-=pvy3#%upyTTM;GmIcZXLX~8QucMU^9=-y5=-a5JzbbA zmSq|CZ%RD#Ij7tTv?W&eT$5o0ZyQ%4TZSrPDMcwHs9NNSIw}#V6)1V@BXJ!v1Q*AF z+lh=R*{(ZfC&xk7GfftbtWbmC;rY8wi|i;&o8#wDyilYKF}0~zo8w>Bg4>1`gybVg zArD^H%16GKIWjdiaeFN8z-g2+$G6UxaA-<0@wOx^kXXYz#_yQVn`p@}r4RH?w18S8 zFp((}!mtB^03w3Uj?^IGon3FHyVPfZeU8h?5#j{ zBLHlV{VHn!YhowpR&Ow%Df=`(=U=KA=|wTI(?p~$V)tc)mk^pjOHYKC!M{M@RajlJ z8!(`!HMgu?v$cJTd)qyo8(KQM; sqlite3.Connection: return conn +def _migrate(): + """Idempotent schema upgrades for fields added after initial release.""" + with closing(db()) as conn: + cols = {r["name"] for r in conn.execute("PRAGMA table_info(jobs)")} + for col, ddl in ( + ("trimmed_head", "INTEGER DEFAULT 0"), + ("trimmed_tail", "INTEGER DEFAULT 0"), + ("video_duration_s", "REAL DEFAULT 0"), + ): + if col not in cols: + conn.execute(f"ALTER TABLE jobs ADD COLUMN {col} {ddl}") + + +_migrate() + + def ssh(alias: str, cmd: str, timeout: int = 30) -> tuple[int, str, str]: p = subprocess.run( ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", alias, cmd], @@ -148,37 +164,68 @@ def count_frames(worker: dict, frames_dir: str) -> int: _AUTO_TRIM_SCRIPT = r""" import cv2, glob, os, sys frames_dir = sys.argv[1] -need_streak = 10 # consecutive underwater frames required to lock start +need_streak = 10 # consecutive underwater frames required to lock start/end paths = sorted(glob.glob(os.path.join(frames_dir, 'frame_*.jpg'))) if not paths: - print('TRIM_RESULT 0 0'); sys.exit(0) + print('TRIM_RESULT 0 0 0'); sys.exit(0) + +def is_underwater(path): + img = cv2.imread(path, cv2.IMREAD_REDUCED_COLOR_4) + if img is None: + return None + b, g, r = [float(c) for c in cv2.mean(img)[:3]] + # Red is absorbed by water → R < G and R < B on underwater shots. + return r < g - 5 and r < b - 5 + +# Scan from the start for the first sustained underwater run. start = 0 streak = 0 for i, p in enumerate(paths): - img = cv2.imread(p, cv2.IMREAD_REDUCED_COLOR_4) - if img is None: + uw = is_underwater(p) + if uw is None: continue - mean_b, mean_g, mean_r = [float(c) for c in cv2.mean(img)[:3]] - # Underwater = red is absorbed → R noticeably lower than both G and B - underwater = mean_r < mean_g - 5 and mean_r < mean_b - 5 - if underwater: + if uw: streak += 1 if streak >= need_streak: start = i - need_streak + 1 break else: streak = 0 -if start <= 0: - print(f'TRIM_RESULT 0 {len(paths)}'); sys.exit(0) + +# Scan from the end for the last sustained underwater run. +end = len(paths) +streak = 0 +for j in range(len(paths) - 1, -1, -1): + uw = is_underwater(paths[j]) + if uw is None: + continue + if uw: + streak += 1 + if streak >= need_streak: + end = j + need_streak # exclusive + break + else: + streak = 0 + +if end <= start: + # Sanity: never delete everything. + start = 0 + end = len(paths) + +removed_head = start +removed_tail = len(paths) - end for p in paths[:start]: try: os.remove(p) except OSError: pass -print(f'TRIM_RESULT {start} {len(paths) - start}') +for p in paths[end:]: + try: os.remove(p) + except OSError: pass +print(f'TRIM_RESULT {removed_head} {removed_tail} {end - start}') """ -def trim_above_water_prefix(worker: dict, frames_dir: str) -> tuple[int, int]: - """Delete leading out-of-water frames. Returns (removed, remaining).""" +def trim_above_water_prefix(worker: dict, frames_dir: str) -> tuple[int, int, int]: + """Delete leading and trailing out-of-water frames. Returns (head, tail, remaining).""" script_remote = f"/tmp/cosma-trim-{os.getpid()}.py" # Write script on worker and run it inside the lingbot-map venv (has cv2) rc, _, err = ssh( @@ -188,20 +235,20 @@ def trim_above_water_prefix(worker: dict, frames_dir: str) -> tuple[int, int]: ) if rc != 0: print(f" ↳ trim script upload failed: {err[:150]}") - return (0, 0) + return (0, 0, 0) rc, out, err = ssh( worker["ssh_alias"], f"source {shlex.quote(worker['lingbot_path'])}/.venv/bin/activate && " f"python3 {shlex.quote(script_remote)} {shlex.quote(frames_dir)}; rm -f {shlex.quote(script_remote)}", - timeout=600, + timeout=1200, ) for line in out.splitlines(): if line.startswith("TRIM_RESULT"): parts = line.split() - removed, remaining = int(parts[1]), int(parts[2]) - return (removed, remaining) + head, tail, remaining = int(parts[1]), int(parts[2]), int(parts[3]) + return (head, tail, remaining) print(f" ↳ trim unexpected output: {out[:200]} / {err[:200]}") - return (0, 0) + return (0, 0, 0) def scp_to_worker(local_path: str, worker: dict, remote_path: str): @@ -255,6 +302,7 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: ssh(worker["ssh_alias"], f"mkdir -p {shlex.quote(frames_dir)} && rm -f {shlex.quote(frames_dir)}/frame_*.jpg") idx = 0 total_frames_est = 0 # will be computed after each scp + total_duration_s = 0.0 for v in videos: vf = f"fps={FPS},scale={IMG_W}:{IMG_H}" pattern = f"{frames_dir}/frame_%06d.jpg" @@ -264,6 +312,7 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: print(f" scp {_path_basename(v)} → {worker['host']}...") scp_to_worker(v, worker, worker_src) dur = video_duration_s(worker, worker_src) + total_duration_s += dur total_frames_est += max(1, int(dur * FPS)) exit_file = f"/tmp/cosma-ffmpeg-{job['id']}-{idx}.exit" @@ -297,11 +346,21 @@ def do_extract(job: sqlite3.Row, worker: dict) -> str: # are 1-11 GB each. Frames are already extracted so worker_src is no longer needed. ssh(worker["ssh_alias"], f"rm -f {shlex.quote(worker_src)}") set_status(job["id"], frame_count=idx, progress=min(99, idx * 100 // total_frames_est)) - # Drop the hors-eau prefix before reconstruction — always present at session start. - removed, remaining = trim_above_water_prefix(worker, frames_dir) - if removed: - print(f" ↳ job #{job['id']}: trimmed {removed} out-of-water frames, {remaining} kept") - set_status(job["id"], frame_count=remaining) + # Persist the measured video duration so the dashboard shows real length (segment_label + # from ingest is only the timestamp of the first MP4 and lies when a segment spans multiple). + set_status(job["id"], video_duration_s=total_duration_s) + # Drop the hors-eau prefix AND suffix before reconstruction — AUV is out-of-water at both ends. + head, tail, remaining = trim_above_water_prefix(worker, frames_dir) + if head or tail: + print(f" ↳ job #{job['id']}: trimmed head={head} tail={tail} out-of-water, {remaining} kept") + set_status(job["id"], frame_count=remaining, trimmed_head=head, trimmed_tail=tail) + # Skip jobs with too little underwater content to be worth reconstructing (e.g., brief + # surface checks that the auto-segmentation picked up as a dive). + min_frames = max(60, int(30 * FPS)) # need ~30 s of underwater footage minimum + if remaining < min_frames: + print(f" ↳ job #{job['id']}: only {remaining} underwater frames (<{min_frames}) — marking skipped") + set_status(job["id"], status="skipped", error=f"too short: {remaining} underwater frames") + raise RuntimeError("skipped_short") # Trim once per job so LVM thin pool on the host actually reclaims the freed blocks. ssh(worker["ssh_alias"], "sudo fstrim / 2>/dev/null || fstrim / 2>/dev/null", timeout=60) return frames_dir @@ -316,7 +375,7 @@ def do_reconstruct(job: sqlite3.Row, worker: dict, frames_dir: str) -> tuple[str # .87 has 23 GB RAM, .84 has 62 GB. Keep effective frame count ~4k to stay safe. frame_count = job["frame_count"] or 0 ram_gb = 23 if worker["host"] == "192.168.0.87" else 62 - ram_budget_gb = ram_gb * 0.45 # leave headroom for model + OS + cuda pinned buffers + ram_budget_gb = ram_gb * 0.35 # leave headroom for model + OS + cuda pinned buffers stride = 1 while frame_count * 3.15 / 1024 / stride > ram_budget_gb: stride += 1 @@ -521,7 +580,11 @@ def run_one(job: sqlite3.Row) -> bool: progress=100, log_tail=log, finished_at=_now_iso()) _maybe_create_per_auv_stitch(job_id) except Exception as e: - set_status(job_id, status="error", error=str(e)[:2000], finished_at=_now_iso()) + # do_extract raises "skipped_short" after flagging status='skipped' — don't override. + if "skipped_short" not in str(e): + set_status(job_id, status="error", error=str(e)[:2000], finished_at=_now_iso()) + else: + set_status(job_id, finished_at=_now_iso()) finally: release_worker(worker, estimated) return True