Buckets:
| #!/usr/bin/env python3 | |
| """tools/review_tool/build_relabel_v3.py — owner validation page for the hand-event label v3 (pre-registered docs/decisions.md | |
| 2026-09-16T16:45:31Z, "Validation (before any adoption)"): FRESH v3 events on TEST routes with local video, frame-click protocol. | |
| Samples from results/targets_kin_causal_routes_v3/kin_causal_{regime}_test.npz (v3 E_c[:, 0, e] == 1 & EM_c[:, 0, e] & row_ok, i.e. the | |
| event falls in the first future step -> t_event = t_anchor + 0.2 s, exactly as the task-B items of build_review_tool.py): | |
| cell 1 engaged hand_return 34 cell 2 engaged hand_leave 34 cell 3 manual hand_return 34 | |
| at most 2 events per route per cell, seed 0, local video required, and EXCLUDING every event within +-5 s (same route) of the 34 | |
| already-reviewed engaged hand_return events (results/review_tool/items.json, task B, engaged, hand_return; passes 1-4) -> fresh items. | |
| Frames: dt = -1.0 ... +1.0 s in 0.1 s steps (21 frames) around t_event with the causal driver box (build_review_tool.draw), one seek per | |
| window then sequential reads (identical frames to per-frame seeks; verified in --self-check), into results/review_tool/img_v3/. | |
| Questions: hand_return -> the FIRST frame with a hand on the wheel (ret_dt=<dt>) | never_on | always_on | unsure; | |
| hand_leave -> the FIRST frame with both hands off the wheel (off_dt=<dt>) | never_off | always_off | unsure. | |
| The hand_return items of both regimes are shown in ONE shuffled block without the regime (regime-blind judgement; the regime stays in | |
| the items json for apply_review_v3.py); ids V3_<k>. | |
| RECALL block: 30 random engaged TEST 10 s windows (seed 0, at most 1 per route) with NO v3 hand event (hand_return and hand_leave, first | |
| future step) inside [t0 - 1 s, t0 + 10 s], all anchors of that range present (contiguous 0.2 s grid), row_ok, wheel reference defined | |
| and hands_on state defined (so v3 COULD have fired), not overlapping the +-5 s zones of the 34 reviewed events; 10 frames at 1 Hz | |
| (t0 ... t0 + 9 s); question 'did a hand return to the wheel and stay >= 1 s in this window?' yes | no | unsure; ids V3R_<k>. | |
| The items json records the size of the window population (n_valid_window_starts x 0.2 s) and the number of v3 hand_return events of the | |
| cell so that apply_review_v3.py can turn the window answers into a recall estimate. | |
| Outputs (new files only): results/review_tool/{relabel_v3.html, items_relabel_v3.json, img_v3/*.jpg, build_relabel_v3.log, | |
| run_build_relabel_v3_*.json}. Served by the running serve_review.py on port 8791 (/save, /labels); separate localStorage key; no | |
| inherited rater. Read-only on all data; nothing under results/targets_* or results/runs is touched. | |
| python tools/review_tool/build_relabel_v3.py [--n-per-cell 34] [--n-windows 30] [--seed 0] [--out results/review_tool] [--html-only] | |
| python tools/review_tool/build_relabel_v3.py --self-check # sequential-read vs per-frame-seek equality on one event window | |
| """ | |
| from __future__ import annotations | |
| import argparse, json, sys, time | |
| from pathlib import Path | |
| import numpy as np | |
| CWM3 = Path(__file__).resolve().parents[2] | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)); sys.path.insert(0, str(CWM3 / "common")) | |
| import build_review_tool as B # noqa: E402 (VIO, grab, draw, nearest_pose_row, FPS) | |
| from runmeta import write_run_json # noqa: E402 | |
| V3 = CWM3 / "results/targets_kin_causal_routes_v3" | |
| ITEMS_B = CWM3 / "results/review_tool/items.json" | |
| DTS = [round(x, 1) for x in np.arange(-2.0, 1.0001, 0.1)] # 31 frames (2026-09-16 owner request: 2 s of pre-context so the leave before a return is visible; was -1.0 … +1.0) | |
| CELLS = (("engaged", "hand_return"), ("engaged", "hand_leave"), ("manual", "hand_return")) | |
| EXCL_S = 5.0 # +-5 s around the 34 reviewed engaged hand_return events | |
| STEP_S = 0.2 # target grid | |
| WIN_S, WIN_PRE_S, WIN_FRAMES = 10.0, 1.0, 10 | |
| RESEEK_GAP = 60 # frames: larger gaps inside a window are crossed by a fresh seek (video_io convention) | |
| EVENT_CN = {"hand_return": "手回到方向盘(hand_return)", "hand_leave": "手离开方向盘(hand_leave)"} | |
| LABELS = {"hand_return": "ret_dt=<dt> | never_on | always_on | unsure", "hand_leave": "off_dt=<dt> | never_off | always_off | unsure", | |
| "recall_window": "yes | no | unsure"} | |
| # ------------------------------------------------------------------------------------------------------------- data | |
| def load_cell(reg: str): | |
| z = np.load(V3 / f"kin_causal_{reg}_test.npz", allow_pickle=True) | |
| ev = [str(s) for s in z["event_names"]] | |
| assert ev[:2] == ["hand_return", "hand_leave"], ev | |
| assert str(z["label_version"]) == "v3", z["label_version"] | |
| return {"uid": z["uid"].astype(str), "t": z["t"].astype(np.float64), "E1": z["E_c"][:, 0, :2] == 1, "EM1": z["EM_c"][:, 0, :2].astype(bool), | |
| "ok": z["row_ok"].astype(bool), "wd": z["wheel_ref_defined"].astype(bool), "S0": z["S0_c"].astype(np.float32), | |
| "d0": z["dwheel0_c"].astype(np.float32), "state_names": [str(s) for s in z["state_names"]], "event_names": ev} | |
| def reviewed_events(): | |
| """(uid, t_event, id) of the 34 engaged hand_return task-B items (the four-pass check) + all task-B items (informational flag).""" | |
| items = json.load(open(ITEMS_B, encoding="utf-8"))["items"] | |
| b = [i for i in items if i["task"] == "B"] | |
| rev = [(i["meta"]["uid"], float(i["meta"]["t_event"]), i["id"]) for i in b if i["meta"]["regime"] == "engaged" and i["meta"]["event"] == "hand_return"] | |
| allb = [(i["meta"]["uid"], float(i["meta"]["t_event"]), i["id"], i["meta"]["regime"], i["meta"]["event"]) for i in b] | |
| return rev, allb | |
| def near(uid: str, te: float, ref, tol: float): | |
| return [r[2] for r in ref if r[0] == uid and abs(te - r[1]) <= tol] | |
| def sample_events(z, e_idx: int, n: int, per_route: int, rng, rev, log): | |
| rows = np.where(z["ok"] & z["E1"][:, e_idx] & z["EM1"][:, e_idx])[0] | |
| vid = np.array([B.VIO.has_video(u) for u in z["uid"][rows]], bool) | |
| te = z["t"][rows] + STEP_S | |
| excl = np.zeros(len(rows), bool) | |
| for u, tr, _ in rev: | |
| excl |= (z["uid"][rows] == u) & (np.abs(te - tr) <= EXCL_S) | |
| cand = rows[vid & ~excl] | |
| log(f" candidates: rows {len(rows)} local-video {int(vid.sum())} excluded(+-{EXCL_S:.0f}s of the 34 reviewed) {int((vid & excl).sum())} -> {len(cand)} on {len(np.unique(z['uid'][cand]))} routes") | |
| cand = cand.copy(); rng.shuffle(cand) | |
| chosen, per = [], {} | |
| for r in cand: | |
| if len(chosen) >= n: break | |
| u = z["uid"][r] | |
| if per.get(u, 0) >= per_route: continue | |
| per[u] = per.get(u, 0) + 1; chosen.append(int(r)) | |
| return chosen, {"n_rows": int(len(rows)), "n_local_video": int(vid.sum()), "n_excluded_reviewed": int((vid & excl).sum()), "n_candidates": int(len(cand)), | |
| "n_candidate_routes": int(len(np.unique(z["uid"][cand])))} | |
| def valid_window_starts(z, rev, exclude_events=(0, 1)): | |
| """Indices i (anchor = window start t0 = t[i]) such that the anchors of [t0 - WIN_PRE_S, t0 + WIN_S] exist contiguously on the 0.2 s grid | |
| within the same route, are row_ok, have the wheel reference and the hands_on state defined, carry no v3 hand event (first future step) of | |
| the given kinds, and the window does not overlap a +-EXCL_S zone of a reviewed event.""" | |
| n_pre, n_post = int(round(WIN_PRE_S / STEP_S)), int(round(WIN_S / STEP_S)) | |
| good = z["ok"] & z["wd"] & np.isfinite(z["S0"][:, 0]) | |
| for e in exclude_events: | |
| good &= ~(z["E1"][:, e] & z["EM1"][:, e]) | |
| out = [] | |
| uids = z["uid"]; t = z["t"] | |
| bounds = np.flatnonzero(np.r_[True, uids[1:] != uids[:-1], True]) | |
| for a, b in zip(bounds[:-1], bounds[1:]): | |
| tt = t[a:b]; g = good[a:b]; m = b - a | |
| if m < n_pre + n_post + 1: continue | |
| cont = np.isclose(np.diff(tt), STEP_S, atol=1e-6) | |
| bad = np.concatenate([[0], np.cumsum(~g)]); badc = np.concatenate([[0], np.cumsum(~cont)]) | |
| i = np.arange(n_pre, m - n_post) # window start indices (relative) | |
| ok_g = (bad[i + n_post + 1] - bad[i - n_pre]) == 0 # all good in [i-n_pre, i+n_post] | |
| ok_c = (badc[i + n_post] - badc[i - n_pre]) == 0 # all steps contiguous in that range | |
| sel = i[ok_g & ok_c] | |
| u = uids[a] | |
| for tr in [r[1] for r in rev if r[0] == u]: # window [t0, t0 + WIN_S] must not intersect [tr - EXCL_S, tr + EXCL_S] | |
| sel = sel[~((tt[sel] <= tr + EXCL_S) & (tt[sel] + WIN_S >= tr - EXCL_S))] | |
| out.extend(int(a + s) for s in sel) | |
| return np.asarray(out, dtype=np.int64) | |
| def sample_windows(z, n: int, rng, rev, log): | |
| starts = valid_window_starts(z, rev) | |
| log(f" recall windows: valid starts {len(starts)} (= {len(starts) * STEP_S / 3600:.2f} h of start positions) on {len(np.unique(z['uid'][starts]))} routes; anchors row_ok {int(z['ok'].sum())}") | |
| st = starts.copy(); rng.shuffle(st) | |
| chosen, used = [], set() | |
| for i in st: | |
| if len(chosen) >= n: break | |
| u = z["uid"][i] | |
| if u in used or not B.VIO.has_video(u): continue | |
| used.add(u); chosen.append(int(i)) | |
| return chosen, {"n_valid_window_starts": int(len(starts)), "start_step_s": STEP_S, "window_s": WIN_S, "window_pre_s": WIN_PRE_S, | |
| "n_valid_window_routes": int(len(np.unique(z["uid"][starts]))), "excluded_v3_events_inside": ["hand_return", "hand_leave"]} | |
| # ------------------------------------------------------------------------------------------------------------- frames | |
| def read_frames(cap, fis): | |
| """{frame_idx: frame} for the sorted frame indices `fis`: one seek (build_review_tool.grab) at the first index, then sequential reads; | |
| a gap > RESEEK_GAP frames is crossed by a fresh grab. Sequential reads return exactly the frames the per-frame seeks would return | |
| (cap.set(POS_FRAMES) lands with the same header-fps conversion for every index of a 2 s window) — checked by --self-check.""" | |
| out, cur = {}, None | |
| for fi in fis: | |
| fi = int(fi) | |
| if cur is None or fi <= cur or fi - cur > RESEEK_GAP: | |
| fr = B.grab(cap, fi); cur = fi | |
| else: | |
| while cur < fi - 1: | |
| if not cap.grab(): break | |
| cur += 1 | |
| ok, fr = cap.read(); cur = fi | |
| if not ok: fr = B.grab(cap, fi) | |
| if fr is not None: out[fi] = fr | |
| return out | |
| def extract(item, u, times, dts, tag_event, img_dir, prefix, log): | |
| """times[k] = absolute time of frame k, dts[k] its offset label; writes <prefix>_<k:02d>.jpg with the causal driver box.""" | |
| import cv2 | |
| boxes = B.VIO.load_driver_boxes(u); vp = B.VIO.video_path(u) | |
| if boxes is None or not vp.exists(): | |
| log(f"{item['id']}: no boxes/video -> no frames"); return | |
| fis = [int(round(tt * B.FPS)) for tt in times] | |
| cap = cv2.VideoCapture(str(vp)) | |
| try: | |
| frames = read_frames(cap, fis) | |
| finally: | |
| cap.release() | |
| for k, (tt, dt, fi) in enumerate(zip(times, dts, fis)): | |
| fr = frames.get(fi) | |
| if fr is None: continue | |
| j = B.nearest_pose_row(boxes, tt); box = boxes["box"][j] if j is not None else None | |
| lab = (f"{'EVENT ' if (tag_event and dt == 0) else ''}t={tt:.1f}s ({dt:+.1f}s)") if tag_event else f"t={tt:.1f}s (+{dt:.0f}s)" | |
| fn = f"{prefix}_{k:02d}.jpg"; B.draw(fr, box, lab, img_dir / fn) | |
| item["frames"].append({"file": f"img_v3/{fn}", "t": float(tt), "dt": float(dt), "frame_idx": fi}) | |
| # ------------------------------------------------------------------------------------------------------------- page | |
| HTML = r"""<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>CockpitWM 标签 v3 核验(回盘 / 离盘 / 召回窗口)</title> | |
| <style> | |
| body{font-family:system-ui,Segoe UI,Arial,'Noto Sans CJK SC',sans-serif;margin:0;background:#f5f4f0;color:#111} | |
| header{position:sticky;top:0;background:#fff;border-bottom:1px solid #ccc;padding:8px 16px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;z-index:5} | |
| button{padding:6px 12px;border:1px solid #888;border-radius:6px;background:#fff;cursor:pointer}button.sel{background:#1f5fbf;color:#fff;border-color:#1f5fbf} | |
| .tab{padding:6px 12px;border-radius:6px;cursor:pointer;border:1px solid #bbb}.tab.active{background:#ddd6e5;border-color:#7a6b8f} | |
| main{padding:12px 16px;max-width:1500px;margin:0 auto} | |
| .item{background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px 12px;margin:10px 0}.item.done{border-color:#7ab27a;background:#f4fbf4}.item.cur{outline:3px solid #1f5fbf} | |
| .frames{display:flex;gap:4px;flex-wrap:wrap}.frames img{width:min(12.5vw,180px);border:2px solid #ccc;border-radius:3px;cursor:pointer}.frames img.sel{border-color:#d33;box-shadow:0 0 0 2px #d33} | |
| .frames.win img{width:min(18vw,290px)} | |
| .player{display:flex;gap:12px;align-items:flex-start;margin:6px 0}.player img{width:min(40vw,560px);border:2px solid #1f5fbf;border-radius:4px} | |
| .q{margin:8px 0 4px;font-weight:600}.meta{font-size:12px;color:#555;word-break:break-all}.ans{margin:6px 0;font-size:14px}.hint{font-size:12px;color:#666}.prog{font-size:13px} | |
| .def{margin:12px 16px;max-width:1500px;background:#fff8e1;border:1px solid #e0b400;border-radius:8px;padding:10px 12px;font-size:14px;line-height:1.5} | |
| input[type=text]{width:60%;padding:4px} | |
| </style></head><body> | |
| <header><b>CockpitWM 标签 v3 核验</b> | |
| <span class="tab active" data-tab="R" onclick="setTab('R')">回盘 hand_return</span> | |
| <span class="tab" data-tab="L" onclick="setTab('L')">离盘 hand_leave</span> | |
| <span class="tab" data-tab="W" onclick="setTab('W')">召回窗口</span> | |
| <span class="prog" id="prog"></span> | |
| <button onclick="jump(-1)">◀ 上一条 (p)</button><button onclick="jump(1)">下一条 ▶ (n)</button><span class="hint" id="sync">离线模式</span> | |
| <span class="hint">快捷键:, / . 前后一帧,空格播放,Enter = 把当前播放帧选为答案帧,1/2/3 = 三个按钮,n/p 翻页。每次选择自动保存(服务器 + 本浏览器)。</span></header> | |
| <div class="def"><b>v3 标签定义。</b>“手在方向盘上”= 至少一只手握住或放在方向盘(轮缘/轮辐)上;“离盘”= 两手都不在方向盘上。 | |
| <b>hand_return(回盘)</b>= 离盘状态持续 ≥ 1 s 之后手回到盘上并保持 ≥ 1 s,自动标签标在回盘的第一帧;<b>hand_leave(离盘)</b>= 在盘上 ≥ 1 s 之后两手都离开并保持 ≥ 1 s,标在离盘的第一帧。 | |
| 每条事件 31 帧(−2.0 … +1.0 s,0.1 s/帧;前 2 s 是回盘/离盘之前的上下文),<b>EVENT = dt 0</b> = 自动标签的时刻。<br> | |
| <b>回盘题</b>:点选第一张“至少一只手在盘上”的帧;若 31 帧中手<b>从未</b>放到盘上 → “从未回盘”;若<b>每一帧</b>都至少一只手在盘上(根本没离开过)→ “一直在盘上”;看不清 → “不确定”。<br> | |
| <b>离盘题</b>:点选第一张“两手都不在盘上”的帧;若 31 帧中<b>始终</b>至少一只手在盘上 → “从未离盘”;若<b>每一帧</b>两手都不在盘上(早已离开)→ “一直不在盘上”;看不清 → “不确定”。<br> | |
| <b>召回窗口题</b>:10 帧、每帧间隔 1 s(t0 … t0+9 s)。在这个 10 s 窗口里,是否有手<b>回到</b>方向盘上并保持 ≥ 1 s?是 / 否 / 不确定(自动标签认为窗口内没有回盘/离盘事件)。<br> | |
| 只看手,不看头;红框只标出驾驶员。不显示驾驭模式和以前各轮答案,请独立判断;备注可写 phone 等。</div> | |
| <main id="main"></main> | |
| <script> | |
| const ITEMS = __ITEMS__; | |
| const KEY = 'cwm3_review_relabel_v3_v1'; | |
| let store = JSON.parse(localStorage.getItem(KEY) || '{}'); let tab='R'; let cur = 0; let _pl=null; | |
| const SERVER = location.protocol.startsWith('http'); | |
| const BLOCK = {R:'hand_return', L:'hand_leave', W:'recall_window'}; | |
| function items(){ return ITEMS.filter(i=>i.meta.block===BLOCK[tab]); } | |
| function setSync(msg, ok){ const e=document.getElementById('sync'); e.textContent=msg; e.style.color= ok?'#2a7a2a':'#b00020'; } | |
| async function pushLabel(id){ if(!SERVER) return; const r=store[id]||{}; try{ const res=await fetch('/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id, label:r.label, note:r.note, rater:'', ts:r.ts})}); const o=await res.json(); setSync(`已保存到服务器(${o.n_labelled} 条)`, true);}catch(e){ setSync('服务器保存失败,仅本地保存', false);} } | |
| async function pullLabels(){ if(!SERVER) return; try{ const res=await fetch('/labels',{cache:'no-store'}); const o=await res.json(); let n=0; ITEMS.forEach(it=>{ const r=o[it.id]; if(r&&r.label&&!(store[it.id]&&store[it.id].label)){ store[it.id]={label:r.label,note:r.note,ts:r.ts}; n++; } }); save(); setSync('已从服务器同步', true); if(n) render(); }catch(e){ setSync('无法连接服务器(离线模式)', false);} } | |
| function save(){ localStorage.setItem(KEY, JSON.stringify(store)); const its=items(); const done=its.filter(i=>store[i.id]&&store[i.id].label).length; const all=ITEMS.filter(i=>store[i.id]&&store[i.id].label).length; document.getElementById('prog').textContent=`本块 ${done}/${its.length} 已标 · 总计 ${all}/${ITEMS.length}`; } | |
| function lab(id){ return (store[id]||{}).label||''; } | |
| function opts(it){ const b=it.meta.block; if(b==='hand_return') return [["never_on","31 帧中从未回盘"],['always_on','一直在盘上(没离开过)'],['unsure','不确定']]; | |
| if(b==='hand_leave') return [['never_off','31 帧中从未离盘(始终至少一手在盘上)'],['always_off','一直不在盘上(早已离开)'],['unsure','不确定']]; | |
| return [['yes','是:有手回到盘上并保持 ≥ 1 s'],['no','否:窗口内没有回盘'],['unsure','不确定 / 看不清']]; } | |
| function ansText(it,l){ if(!l) return '(未选)'; const o=opts(it).find(x=>x[0]===l); if(o) return o[1]; const dt=parseFloat(l.split('=')[1]); return (it.meta.block==='hand_return'?'首次回盘帧':'首次离盘帧')+` dt = ${dt>=0?'+':''}${dt.toFixed(1)} s`; } | |
| function pfx(it){ return it.meta.block==='hand_return'?'ret_dt=':'off_dt='; } | |
| function setLabel(id,v,idx){ store[id]=Object.assign(store[id]||{}, {label:v, ts:new Date().toISOString()}); save(); pushLabel(id); renderOne(idx); } | |
| function setNote(id,v){ store[id]=Object.assign(store[id]||{}, {note:v}); save(); clearTimeout(window._nt); window._nt=setTimeout(()=>pushLabel(id), 800); } | |
| function fmtDt(it,f){ return it.meta.block==='recall_window' ? `+${f.dt.toFixed(0)} s` : `dt=${f.dt>=0?'+':''}${f.dt.toFixed(1)} s${f.dt===0?' (EVENT)':''}`; } | |
| function plSet(idx,k){ const it=items()[idx]; k=Math.min(Math.max(parseInt(k),0),it.frames.length-1); const f=it.frames[k]; document.getElementById('pl_'+idx).src=f.file; document.getElementById('rg_'+idx).value=k; document.getElementById('pt_'+idx).textContent=fmtDt(it,f); } | |
| function plPlay(idx){ if(_pl){ clearInterval(_pl); _pl=null; return; } const it=items()[idx]; let k=parseInt(document.getElementById('rg_'+idx).value); _pl=setInterval(()=>{ k=(k+1)%it.frames.length; plSet(idx,k); }, it.meta.block==='recall_window'?500:150); } | |
| function plStep(d){ const rg=document.getElementById('rg_'+cur); if(rg) plSet(cur, parseInt(rg.value)+d); } | |
| function pickCur(){ const rg=document.getElementById('rg_'+cur); if(!rg) return; const it=items()[cur]; if(it.meta.block==='recall_window') return; setLabel(it.id, pfx(it)+it.frames[parseInt(rg.value)].dt.toFixed(1), cur); } | |
| function renderOne(idx){ const it=items()[idx]; const d=document.getElementById('it_'+idx); if(!d) return; const l=lab(it.id); d.className='item'+(l?' done':'')+(idx===cur?' cur':''); | |
| const win = it.meta.block==='recall_window'; | |
| const k0 = win ? 0 : Math.max(0,it.frames.findIndex(f=>f.dt===0)); | |
| if(!it.frames.length){ d.innerHTML=`<div class="meta">${it.id} · ${it.meta.uid}</div><i>(无本地视频或无姿态框,无法出图)</i>`; return; } | |
| const pick = win ? '' : ` <button onclick="cur=${idx};pickCur()">把当前帧选为答案帧 (Enter)</button>`; | |
| const player=`<div class="player"><img id="pl_${idx}" src="${it.frames[k0].file}"><div><input type="range" id="rg_${idx}" min="0" max="${it.frames.length-1}" value="${k0}" style="width:320px" oninput="plSet(${idx},this.value)"> <span id="pt_${idx}">${fmtDt(it,it.frames[k0])}</span> <button onclick="plPlay(${idx})">▶ / ⏸</button>${pick}</div></div>`; | |
| const thumbs=`<div class="frames${win?' win':''}">` + it.frames.map((f,k)=>{ const v=win?'':pfx(it)+f.dt.toFixed(1); const on = win ? `cur=${idx};plSet(${idx},${k})` : `cur=${idx};plSet(${idx},${k});setLabel('${it.id}','${v}',${idx})`; | |
| return `<img loading="lazy" class="${(!win&&l===v)?'sel':''}" src="${f.file}" title="${fmtDt(it,f)}${win?'':'(点击 = 选为答案帧)'}" onclick="${on}">`; }).join('') + `</div>`; | |
| let q, meta; | |
| if(it.meta.block==='hand_return'){ q=`第 ${idx+1}/${items().length} 条 · 自动标签 <b>hand_return(手回到方向盘)</b> 于 EVENT 帧(dt=0)。第一张“至少一只手在方向盘上”的帧是?(点小图)`; meta=`${it.id} · ${it.meta.uid} · t_event=${it.meta.t_event.toFixed(1)} s`; } | |
| else if(it.meta.block==='hand_leave'){ q=`第 ${idx+1}/${items().length} 条 · 自动标签 <b>hand_leave(手离开方向盘)</b> 于 EVENT 帧(dt=0)。第一张“两手都不在方向盘上”的帧是?(点小图)`; meta=`${it.id} · ${it.meta.uid} · t_event=${it.meta.t_event.toFixed(1)} s`; } | |
| else { q=`第 ${idx+1}/${items().length} 条 · 10 s 窗口(10 帧,1 s/帧)。这个窗口里是否有手<b>回到</b>方向盘上并保持 ≥ 1 s?`; meta=`${it.id} · ${it.meta.uid} · 窗口 t=${it.meta.t0.toFixed(1)} … ${(it.meta.t0+9).toFixed(1)} s`; } | |
| const btns = opts(it).map(([v,t],k)=>`<button class="${l===v?'sel':''}" onclick="setLabel('${it.id}','${v}',${idx})">${k+1}. ${t}</button>`).join(' '); | |
| d.innerHTML = `<div class="meta">${meta}</div>${player}${thumbs}<div class="q">${q}</div><div>${btns}</div> | |
| <div class="ans">当前答案:<b>${ansText(it,l)}</b></div> | |
| <div><input type="text" placeholder="备注(可选,如 phone)" value="${(store[it.id]||{}).note||''}" oninput="setNote('${it.id}',this.value)"></div>`; | |
| d.onclick=(e)=>{ cur=idx; highlight(); }; } | |
| function render(){ if(_pl){ clearInterval(_pl); _pl=null; } const m=document.getElementById('main'); m.innerHTML=''; items().forEach((it,idx)=>{ const d=document.createElement('div'); d.id='it_'+idx; m.appendChild(d); renderOne(idx); }); save(); highlight(); } | |
| function setTab(t){ tab=t; cur=0; document.querySelectorAll('.tab').forEach(e=>e.classList.toggle('active', e.dataset.tab===t)); render(); } | |
| function highlight(){ document.querySelectorAll('.item').forEach((e,i)=>e.classList.toggle('cur',i===cur)); } | |
| function jump(d){ cur=Math.min(Math.max(cur+d,0),items().length-1); highlight(); const el=document.getElementById('it_'+cur); if(el) el.scrollIntoView({behavior:'smooth',block:'start'}); } | |
| document.addEventListener('keydown',e=>{ if(e.target.tagName==='INPUT'&&e.target.type==='text') return; | |
| if(e.key==='n') jump(1); else if(e.key==='p') jump(-1); else if(e.key===',') plStep(-1); else if(e.key==='.') plStep(1); | |
| else if(e.key===' '){ e.preventDefault(); plPlay(cur); } else if(e.key==='Enter'){ pickCur(); } | |
| else if(/^[1-3]$/.test(e.key)){ const it=items()[cur]; const o=opts(it)[parseInt(e.key)-1]; if(o){ setLabel(it.id,o[0],cur); } } }); | |
| render(); pullLabels(); | |
| </script></body></html> | |
| """ | |
| # ------------------------------------------------------------------------------------------------------------- main | |
| def self_check(uid: str, te: float) -> int: | |
| """sequential reads (read_frames) vs per-frame seeks (B.grab) on one 21-frame window: max abs pixel difference must be 0.""" | |
| import cv2 | |
| vp = B.VIO.video_path(uid); fis = [int(round((te + dt) * B.FPS)) for dt in DTS] | |
| cap = cv2.VideoCapture(str(vp)); seq = read_frames(cap, fis); cap.release() | |
| cap = cv2.VideoCapture(str(vp)); ref = {fi: B.grab(cap, fi) for fi in fis}; cap.release() | |
| worst = 0; n = 0 | |
| for fi in fis: | |
| a, b = seq.get(fi), ref.get(fi) | |
| if a is None or b is None or a.shape != b.shape: print(f"frame {fi}: missing/shape mismatch"); return 1 | |
| worst = max(worst, int(np.abs(a.astype(np.int16) - b.astype(np.int16)).max())); n += 1 | |
| print(f"self-check {uid[:40]} t_event={te:.1f}: {n} frames, max |seq - seek| = {worst}") | |
| return 0 if worst == 0 else 1 | |
| def main(argv=None) -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--out", default=str(CWM3 / "results/review_tool")) | |
| ap.add_argument("--n-per-cell", type=int, default=34); ap.add_argument("--n-windows", type=int, default=30) | |
| ap.add_argument("--per-route", type=int, default=2); ap.add_argument("--seed", type=int, default=0) | |
| ap.add_argument("--html-only", action="store_true", help="rewrite relabel_v3.html from the existing items_relabel_v3.json (no extraction)") | |
| ap.add_argument("--self-check", action="store_true", help="sequential-read vs per-frame-seek equality on the first sampled engaged hand_return event") | |
| a = ap.parse_args(argv) | |
| out = Path(a.out); img = out / "img_v3"; img.mkdir(parents=True, exist_ok=True) | |
| if a.html_only: | |
| items = json.load(open(out / "items_relabel_v3.json", encoding="utf-8"))["items"] | |
| (out / "relabel_v3.html").write_text(HTML.replace("__ITEMS__", json.dumps(items, ensure_ascii=False)), encoding="utf-8") | |
| print(f"relabel_v3.html rewritten from items_relabel_v3.json ({len(items)} items)"); return 0 | |
| t0 = time.time(); logf = open(out / "build_relabel_v3.log", "a", encoding="utf-8") | |
| def log(s): | |
| print(s, flush=True); logf.write(f"{time.strftime('%H:%M:%S')} {s}\n"); logf.flush() | |
| log(f"build_relabel_v3 start seed={a.seed} n_per_cell={a.n_per_cell} n_windows={a.n_windows} per_route={a.per_route} v3={V3}") | |
| rev, allb = reviewed_events() | |
| log(f"reviewed engaged hand_return events to exclude (+-{EXCL_S:.0f} s): {len(rev)} on {len({r[0] for r in rev})} routes") | |
| rng = np.random.default_rng(a.seed) | |
| cells = {reg: load_cell(reg) for reg in ("engaged", "manual")} | |
| # ---- sample the three event cells (fixed order, one generator) ---------------------------------------------------------- | |
| picked, stats = [], {} | |
| for reg, ev in CELLS: | |
| z = cells[reg]; e_idx = z["event_names"].index(ev) | |
| log(f"cell {reg} {ev}:") | |
| rows, st = sample_events(z, e_idx, a.n_per_cell, a.per_route, rng, rev, log) | |
| stats[f"{reg}_{ev}"] = dict(st, n_chosen=len(rows), n_routes_chosen=int(len(np.unique(z["uid"][rows])))) | |
| log(f" chosen {len(rows)} on {stats[f'{reg}_{ev}']['n_routes_chosen']} routes") | |
| for r in rows: | |
| u, ta = z["uid"][r], float(z["t"][r]); te = ta + STEP_S | |
| picked.append({"block": ev, "regime": reg, "event": ev, "cell": f"{reg}_{ev}", "uid": u, "car": u.split("__")[0], "row": int(r), | |
| "t_anchor": ta, "t_event": te, "state0": [float(v) for v in z["S0"][r]], "state_names": z["state_names"], | |
| "dwheel0_c": float(z["d0"][r]) if np.isfinite(z["d0"][r]) else None, "wheel_ref_defined": bool(z["wd"][r]), | |
| "near_task_b_items_5s": near(u, te, [(x[0], x[1], f"{x[2]}:{x[3]}:{x[4]}") for x in allb], EXCL_S)}) | |
| # regime-blind hand_return block: shuffle engaged + manual together; hand_leave block after it | |
| order = [i for i, p in enumerate(picked) if p["block"] == "hand_return"]; rng.shuffle(order) | |
| order += [i for i, p in enumerate(picked) if p["block"] == "hand_leave"] | |
| items = [] | |
| for k, i in enumerate(order): | |
| m = picked[i]; m["event_cn"] = EVENT_CN[m["event"]]; m["frame_step_s"] = 0.1; m["label_version"] = "v3" | |
| items.append({"id": f"V3_{k:03d}", "task": "V3E", "meta": m, "frames": []}) | |
| # ---- recall windows ----------------------------------------------------------------------------------------------------- | |
| rng_w = np.random.default_rng(a.seed) | |
| ze = cells["engaged"] | |
| log("recall block (engaged test, 10 s windows, no v3 hand event inside):") | |
| wstarts, wst = sample_windows(ze, a.n_windows, rng_w, rev, log) | |
| n_ret_eng = int((ze["ok"] & ze["E1"][:, 0] & ze["EM1"][:, 0]).sum()); n_lea_eng = int((ze["ok"] & ze["E1"][:, 1] & ze["EM1"][:, 1]).sum()) | |
| stats["recall_windows"] = dict(wst, n_chosen=len(wstarts), n_v3_hand_return_engaged_test=n_ret_eng, n_v3_hand_leave_engaged_test=n_lea_eng, | |
| n_anchors_row_ok_engaged_test=int(ze["ok"].sum()), anchor_step_s=STEP_S) | |
| log(f" chosen {len(wstarts)} windows; engaged test v3 hand_return rows {n_ret_eng}, hand_leave rows {n_lea_eng}") | |
| witems = [] | |
| for k, i in enumerate(wstarts): | |
| u, t0w = ze["uid"][i], float(ze["t"][i]); j1 = i + int(round(WIN_S / STEP_S)) | |
| s0 = ze["S0"][i:j1 + 1, 0]; d0 = ze["d0"][i:j1 + 1] | |
| m = {"block": "recall_window", "regime": "engaged", "cell": "engaged_recall_window", "uid": u, "car": u.split("__")[0], "row0": int(i), "t0": t0w, "t1": t0w + WIN_S, | |
| "hands_on_share_v3": float(np.nanmean(s0)) if np.isfinite(s0).any() else None, "dwheel_median_v3": float(np.nanmedian(d0)) if np.isfinite(d0).any() else None, | |
| "frame_step_s": 1.0, "label_version": "v3", "near_task_b_items_5s": near(u, t0w + 5.0, [(x[0], x[1], f"{x[2]}:{x[3]}:{x[4]}") for x in allb], EXCL_S + 5.0)} | |
| witems.append({"id": f"V3R_{k:02d}", "task": "V3R", "meta": m, "frames": []}) | |
| if a.self_check: | |
| m = items[0]["meta"] if items[0]["meta"]["block"] == "hand_return" else items[0]["meta"] | |
| return self_check(m["uid"], m["t_event"]) | |
| # ---- extraction --------------------------------------------------------------------------------------------------------- | |
| t_ex = time.time(); n_fr = 0 | |
| for n, it in enumerate(items): | |
| m = it["meta"]; te = m["t_event"] | |
| extract(it, m["uid"], [te + dt for dt in DTS], DTS, True, img, it["id"], log); n_fr += len(it["frames"]) | |
| if (n + 1) % 10 == 0 or n + 1 == len(items): | |
| log(f"events {n + 1}/{len(items)} items, {n_fr} frames, {time.time() - t_ex:.0f}s") | |
| for n, it in enumerate(witems): | |
| m = it["meta"]; t0w = m["t0"] | |
| extract(it, m["uid"], [t0w + k for k in range(WIN_FRAMES)], list(range(WIN_FRAMES)), False, img, it["id"], log); n_fr += len(it["frames"]) | |
| if (n + 1) % 10 == 0 or n + 1 == len(witems): | |
| log(f"windows {n + 1}/{len(witems)} items, {n_fr} frames total, {time.time() - t_ex:.0f}s") | |
| wall_ex = time.time() - t_ex | |
| all_items = items + witems | |
| ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) | |
| rec = {"built_utc": ts, "seed": a.seed, "label_version": "v3", "source": str(V3.relative_to(CWM3)), "split": "test", "frame_step_s": 0.1, "dts": DTS, | |
| "window_s": WIN_S, "window_frames": WIN_FRAMES, "exclusion": f"events within +-{EXCL_S:.0f} s of the {len(rev)} reviewed engaged hand_return task-B items; windows not overlapping those zones", | |
| "labels": LABELS, "acceptance": "per cell precision >= 0.80 with Wilson 95 % lower bound >= 0.65 (pre-registration 2026-09-16T16:45:31Z)", | |
| "blocks": {"hand_return": "engaged + manual shuffled together, regime hidden on the page", "hand_leave": "engaged", "recall_window": "engaged"}, | |
| "sampling_stats": stats, "n_items": len(all_items), "n_frames": n_fr, "extraction_wall_s": round(wall_ex, 1), "items": all_items} | |
| json.dump(rec, open(out / "items_relabel_v3.json", "w", encoding="utf-8"), indent=1, ensure_ascii=False) | |
| (out / "relabel_v3.html").write_text(HTML.replace("__ITEMS__", json.dumps(all_items, ensure_ascii=False)), encoding="utf-8") | |
| write_run_json(out, "build_relabel_v3", [Path(__file__), Path(B.__file__)], [V3 / "kin_causal_engaged_test.npz", V3 / "kin_causal_manual_test.npz", ITEMS_B], | |
| a.seed, t0, extra={"n_items": len(all_items), "n_frames": n_fr, "extraction_wall_s": round(wall_ex, 1), "sampling_stats": stats, | |
| "outputs": ["relabel_v3.html", "items_relabel_v3.json", "img_v3/", "build_relabel_v3.log"]}, input_listing=False) | |
| log(f"done: {len(items)} event items + {len(witems)} windows = {len(all_items)} items, {n_fr} frames, extraction {wall_ex:.0f}s, total {time.time() - t0:.0f}s -> {out / 'relabel_v3.html'}") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |
Xet Storage Details
- Size:
- 32.5 kB
- Xet hash:
- b6395f3a6af5ab5b9631d0d5707db520ecc049bd50a4342af3dcbdf1ddb919b8
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.