Buckets:
| #!/usr/bin/env python3 | |
| """tools/review_tool/build_review_tool.py — standalone HTML review tool (no server) for two human checks requested 2026-09-16: | |
| Task A driver association: the 64 routes flagged by data/qc_driver_box_suspects.py (r(head_turn) < 0.3 or hands_on agreement < 0.5) — | |
| 6 frames per route with the CAUSAL driver box; the reviewer marks 司机 / 乘客 / 不确定 / 无人或看不清. | |
| Task B automatic label validation: 200 automatic state-switch events on the TEST split (hand_leave / hand_return / head_turn_onset from | |
| results/targets_kin_causal_routes_v2/kin_causal_{regime}_test.npz, event at the first future step -> t_e = t + 0.2 s), | |
| 5 frames at t_e-1.0, -0.5, 0, +0.5, +1.0 s with the box; the reviewer marks 正确 / 错误 / 时间偏差 / 不确定. | |
| Read-only on all data; writes only results/review_tool/{index.html,items.json,img/*.jpg}. Labels are kept in the browser (localStorage) | |
| and exported as CSV/JSON by the reviewer; tools/review_tool/apply_review.py turns the export into driver_box_verified.json + a summary table. | |
| python tools/review_tool/build_review_tool.py [--n-b 200] [--seed 0] [--out results/review_tool] | |
| """ | |
| 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(CWM3 / "baselines_dms")) | |
| import video_io as VIO # noqa: E402 | |
| SUSPECTS = CWM3 / "results/targets_kin_causal_routes_v2/driver_box_suspect_routes.json" | |
| KIN = CWM3 / "results/targets_kin_causal_routes_v2" | |
| FPS = 20.0 | |
| EVENT_CN = {"hand_leave": "手离开方向盘(hand_leave)", "hand_return": "手回到方向盘(hand_return)", "head_turn_onset": "头开始转向(head_turn_onset)"} | |
| def grab(cap, frame_idx: int): | |
| import cv2 | |
| cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(frame_idx))) | |
| ok, fr = cap.read() | |
| return fr if ok else None | |
| def draw(fr, box, label: str, out_path: Path, width: int = 640): | |
| import cv2 | |
| H, W = fr.shape[:2] | |
| if box is not None and np.all(np.isfinite(box)): | |
| x0, y0, x1, y1 = [int(round(float(v))) for v in box] | |
| cv2.rectangle(fr, (x0, y0), (x1, y1), (0, 0, 255), 4) | |
| s = width / W | |
| fr = cv2.resize(fr, (width, int(round(H * s))), interpolation=cv2.INTER_AREA) | |
| cv2.putText(fr, label, (8, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 3, cv2.LINE_AA) | |
| cv2.putText(fr, label, (8, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1, cv2.LINE_AA) | |
| cv2.imwrite(str(out_path), fr, [cv2.IMWRITE_JPEG_QUALITY, 82]) | |
| def nearest_pose_row(boxes, t: float) -> int | None: | |
| fi = int(round(t * FPS)) | |
| j = int(np.searchsorted(boxes["frame_idx"], fi)) | |
| j = min(max(j, 0), len(boxes["frame_idx"]) - 1) | |
| if abs(int(boxes["frame_idx"][j]) - fi) > 4: | |
| return None | |
| return j | |
| def task_a_items(out_img: Path, log): | |
| import cv2 | |
| sus = json.load(open(SUSPECTS)) | |
| per_uid = {} | |
| for e in sus["flagged"]: | |
| per_uid.setdefault(e["uid"], []).append(e) | |
| items = [] | |
| for i, (uid, ents) in enumerate(sorted(per_uid.items())): | |
| vp = VIO.video_path(uid) | |
| boxes = VIO.load_driver_boxes(uid) | |
| meta = {"uid": uid, "car": uid.split("__")[0], "regimes": sorted({e["reg"] for e in ents}), "splits": sorted({e["sp"] for e in ents}), | |
| "r_ht": ents[0]["r_ht"], "hands_agr": ents[0]["hands_agr"], "co_frac": ents[0].get("co_frac"), "dside_source": ents[0]["src"], | |
| "n_anchors": int(sum(e["n_anchors"] for e in ents))} | |
| item = {"id": f"A{i:03d}", "task": "A", "meta": meta, "frames": [], "video_local": vp.exists(), "pose": boxes is not None} | |
| if not vp.exists() or boxes is None: | |
| log(f"A {uid}: video_local={vp.exists()} pose={boxes is not None} -> no frames") | |
| items.append(item) | |
| continue | |
| det = np.where(boxes["driver_det"])[0] | |
| if len(det) == 0: | |
| det = np.arange(len(boxes["t"])) | |
| picks = det[np.linspace(0, len(det) - 1, 6).round().astype(int)] | |
| cap = cv2.VideoCapture(str(vp)) | |
| try: | |
| with np.load(VIO.pose_path(uid), allow_pickle=True) as z: | |
| n_persons = z["n_persons"] if "n_persons" in z.files else None | |
| for k, j in enumerate(picks): | |
| fi = int(boxes["frame_idx"][j]) | |
| fr = grab(cap, fi) | |
| if fr is None: | |
| continue | |
| npers = int(n_persons[j]) if n_persons is not None else -1 | |
| lab = f"{uid[:26]} t={boxes['t'][j]:.1f}s persons={npers}" | |
| fn = f"{item['id']}_{k}.jpg" | |
| draw(fr, boxes["box"][j], lab, out_img / fn) | |
| item["frames"].append({"file": f"img/{fn}", "t": float(boxes["t"][j]), "n_persons": npers}) | |
| finally: | |
| cap.release() | |
| items.append(item) | |
| log(f"A {i+1}/{len(per_uid)} {uid[:40]} frames={len(item['frames'])}") | |
| return items | |
| def task_b_items(out_img: Path, n_total: int, seed: int, log): | |
| import cv2 | |
| rng = np.random.default_rng(seed) | |
| items = [] | |
| per_regime = n_total // 2 | |
| for reg in ("manual", "engaged"): | |
| z = np.load(KIN / f"kin_causal_{reg}_test.npz", allow_pickle=True) | |
| ev_names = [str(s) for s in z["event_names"]] | |
| E, EM, ok = z["E_c"], z["EM_c"], z["row_ok"] | |
| uid, t = z["uid"], z["t"] | |
| cand = {} | |
| for e_idx, e_name in enumerate(ev_names): | |
| rows = np.where(ok & (E[:, 0, e_idx] == 1) & EM[:, 0, e_idx])[0] | |
| cand[e_name] = rows | |
| # quota: hand_leave 34 %, hand_return 33 %, head_turn 33 % ; at most 2 per route ; video must be local | |
| quota = {ev_names[0]: per_regime - 2 * (per_regime // 3), ev_names[1]: per_regime // 3, ev_names[2]: per_regime // 3} | |
| chosen = [] | |
| for e_name, rows in cand.items(): | |
| rng.shuffle(rows) | |
| taken_route = {} | |
| for r in rows: | |
| if sum(1 for c in chosen if c[0] == e_name) >= quota[e_name]: | |
| break | |
| u = str(uid[r]) | |
| if taken_route.get(u, 0) >= 2 or not VIO.has_video(u): | |
| continue | |
| taken_route[u] = taken_route.get(u, 0) + 1 | |
| chosen.append((e_name, int(r))) | |
| log(f"B {reg}: candidates {{k: len(v) for k, v in cand.items()}} chosen {len(chosen)}") | |
| for e_name, r in chosen: | |
| u, ta = str(uid[r]), float(t[r]) | |
| te = ta + 0.2 | |
| boxes = VIO.load_driver_boxes(u) | |
| item = {"id": f"B{len(items):03d}", "task": "B", "meta": {"uid": u, "car": u.split("__")[0], "regime": reg, "event": e_name, "event_cn": EVENT_CN.get(e_name, e_name), | |
| "t_anchor": ta, "t_event": te, "state0": [float(v) for v in z["S0_c"][r]], "state_names": [str(s) for s in z["state_names"]]}, "frames": []} | |
| if boxes is None: | |
| items.append(item) | |
| continue | |
| cap = cv2.VideoCapture(str(VIO.video_path(u))) | |
| try: | |
| for k, dt in enumerate([-1.0, -0.5, 0.0, 0.5, 1.0]): | |
| tt = te + dt | |
| j = nearest_pose_row(boxes, tt) | |
| fi = int(round(tt * FPS)) | |
| fr = grab(cap, fi) | |
| if fr is None: | |
| continue | |
| box = boxes["box"][j] if j is not None else None | |
| lab = f"{'EVENT ' if dt == 0 else ''}t={tt:.1f}s ({dt:+.1f}s)" | |
| fn = f"{item['id']}_{k}.jpg" | |
| draw(fr, box, lab, out_img / fn) | |
| item["frames"].append({"file": f"img/{fn}", "t": tt, "dt": dt}) | |
| finally: | |
| cap.release() | |
| items.append(item) | |
| if len(items) % 20 == 0: | |
| log(f"B {len(items)} items") | |
| return items | |
| HTML = r"""<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>CockpitWM 人工核验</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:16px;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.primary{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:6px;flex-wrap:wrap}.frames img{width:min(30vw,420px);border:1px solid #ccc;border-radius:4px} | |
| .q{margin:8px 0 4px;font-weight:600}.opts label{margin-right:14px;cursor:pointer}.meta{font-size:12px;color:#555;word-break:break-all} | |
| input[type=text]{width:60%;padding:4px}.prog{font-size:13px;color:#333}.hint{font-size:12px;color:#666} | |
| </style></head><body> | |
| <header> | |
| <b>CockpitWM 人工核验</b> | |
| <span class="tab active" data-tab="A" onclick="setTab('A')">A 司机/乘客核验</span> | |
| <span class="tab" data-tab="B" onclick="setTab('B')">B 自动标签验证</span> | |
| <span class="prog" id="prog"></span> | |
| <label>标注者 <input type="text" id="rater" style="width:120px" placeholder="姓名/代号" oninput="saveRater()"></label> | |
| <button onclick="jump(-1)">◀ 上一条 (p)</button><button onclick="jump(1)">下一条 ▶ (n)</button> | |
| <button onclick="exportJSON()">导出 JSON</button><button onclick="exportCSV()">导出 CSV</button> | |
| <label class="hint">导入 JSON <input type="file" id="imp" accept=".json" onchange="importJSON(this)"></label> | |
| <span class="hint" id="sync">离线模式</span> | |
| <span class="hint">快捷键:1–5 选择答案,n/p 翻页;每次选择自动保存(服务器 + 本浏览器)。</span> | |
| </header> | |
| <main id="main"></main> | |
| <script> | |
| const ITEMS = __ITEMS__; | |
| const KEY = 'cwm3_review_v1'; | |
| const OPTS = {A:[['driver','司机(红框是驾驶员)'],['passenger','乘客/非驾驶员'],['unsure','不确定'],['none','无人/看不清/无框']], | |
| B:[['correct','正确:事件发生在中间帧附近'],['wrong','错误:没有该事件'],['timing','时间偏差 >0.5 s'],['unsure','不确定/看不清']]}; | |
| let store = JSON.parse(localStorage.getItem(KEY) || '{}'); | |
| let tab = 'A', cur = 0; | |
| const SERVER = location.protocol.startsWith('http'); | |
| function setSync(msg, ok){ const e=document.getElementById('sync'); if(e){ 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:store._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; Object.entries(o).forEach(([id,r])=>{ if(r.label && !(store[id]&&store[id].label)){ store[id]=Object.assign(store[id]||{}, {label:r.label, note:r.note, rater:r.rater, ts:r.ts}); n++; } if(r.rater && !store._rater) store._rater=r.rater; }); save(); setSync(`已从服务器同步 ${Object.keys(o).length} 条`, true); if(n) render(); }catch(e){ setSync('无法连接服务器(离线模式)', false);} } | |
| function save(){ localStorage.setItem(KEY, JSON.stringify(store)); renderProg(); } | |
| function saveRater(){ store._rater = document.getElementById('rater').value; save(); } | |
| function setTab(t){ tab = t; cur = 0; document.querySelectorAll('.tab').forEach(e=>e.classList.toggle('active', e.dataset.tab===t)); render(); } | |
| function items(){ return ITEMS.filter(i=>i.task===tab); } | |
| function renderProg(){ 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 = `任务 ${tab}: ${done}/${its.length} 已标 · 总计 ${all}/${ITEMS.length}`; } | |
| function render(){ const m=document.getElementById('main'); m.innerHTML=''; document.getElementById('rater').value = store._rater||''; | |
| items().forEach((it,idx)=>{ const d=document.createElement('div'); d.className='item'+(store[it.id]&&store[it.id].label?' done':'')+(idx===cur?' cur':''); d.id='it_'+idx; | |
| const q = it.task==='A' ? '红框内的人是驾驭员(司机)吗?' : `自动标签:<b>${it.meta.event_cn}</b>,标注时刻 = 中间帧(EVENT,t=${it.meta.t_event.toFixed(1)} s,${it.meta.regime})。这个事件是否真实发生?`; | |
| const meta = it.task==='A' ? `${it.meta.uid} · 疑似依据: r(head_turn)=${it.meta.r_ht}, hands 一致率=${it.meta.hands_agr}, 同乘者比例=${it.meta.co_frac} · 框来源=${it.meta.dside_source} · 影响锚点=${it.meta.n_anchors}` | |
| : `${it.meta.uid} · 锚点 t=${it.meta.t_anchor.toFixed(1)} s · 当前状态 [${it.meta.state_names.join(', ')}] = [${it.meta.state0.map(v=>v.toFixed(0)).join(', ')}]`; | |
| const frames = it.frames.length ? it.frames.map(f=>`<img loading="lazy" src="${f.file}" title="t=${f.t.toFixed(1)}s">`).join('') : '<i>(无本地视频或无姿态框,无法出图)</i>'; | |
| const opts = OPTS[it.task].map(([v,l],k)=>`<label><input type="radio" name="${it.id}" value="${v}" ${store[it.id]&&store[it.id].label===v?'checked':''} onchange="setLabel('${it.id}','${v}',${idx})"> ${k+1}. ${l}</label>`).join(''); | |
| d.innerHTML = `<div class="meta">${it.id} · ${meta}</div><div class="frames">${frames}</div><div class="q">${q}</div><div class="opts">${opts}</div> | |
| <div><input type="text" placeholder="备注(可选)" value="${(store[it.id]&&store[it.id].note)||''}" oninput="setNote('${it.id}',this.value)"></div>`; | |
| d.onclick=()=>{cur=idx; highlight();}; m.appendChild(d); }); | |
| renderProg(); highlight(); } | |
| function highlight(){ document.querySelectorAll('.item').forEach((e,i)=>e.classList.toggle('cur',i===cur)); } | |
| function setLabel(id,v,idx){ store[id]=Object.assign(store[id]||{}, {label:v, ts:new Date().toISOString(), rater:store._rater||''}); save(); | |
| document.getElementById('it_'+idx).classList.add('done'); pushLabel(id); } | |
| function setNote(id,v){ store[id]=Object.assign(store[id]||{}, {note:v}); save(); clearTimeout(window._nt); window._nt=setTimeout(()=>pushLabel(id), 800); } | |
| function jump(d){ const its=items(); cur=Math.min(Math.max(cur+d,0),its.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(/^[1-5]$/.test(e.key)){ const it=items()[cur]; const o=OPTS[it.task][parseInt(e.key)-1]; if(o){ setLabel(it.id,o[0],cur); document.querySelector(`input[name="${it.id}"][value="${o[0]}"]`).checked=true; jump(1);} } }); | |
| function rows(){ return ITEMS.map(it=>({id:it.id, task:it.task, uid:it.meta.uid, regime:(it.meta.regime||(it.meta.regimes||[]).join('|')), event:it.meta.event||'', t_event:it.meta.t_event||'', label:(store[it.id]||{}).label||'', note:(store[it.id]||{}).note||'', rater:(store[it.id]||{}).rater||store._rater||'', ts:(store[it.id]||{}).ts||''})); } | |
| function dl(name, text, type){ const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([text],{type})); a.download=name; a.click(); } | |
| function exportJSON(){ dl('cwm3_review_labels.json', JSON.stringify({exported:new Date().toISOString(), rater:store._rater||'', items:rows()}, null, 1), 'application/json'); } | |
| function exportCSV(){ const r=rows(); const h=Object.keys(r[0]); const esc=v=>'"'+String(v).replace(/"/g,'""')+'"'; dl('cwm3_review_labels.csv', [h.join(',')].concat(r.map(o=>h.map(k=>esc(o[k])).join(','))).join('\n'), 'text/csv'); } | |
| function importJSON(inp){ const f=inp.files[0]; if(!f) return; f.text().then(t=>{ const o=JSON.parse(t); (o.items||[]).forEach(r=>{ if(r.label) store[r.id]=Object.assign(store[r.id]||{}, {label:r.label, note:r.note, rater:r.rater, ts:r.ts}); }); if(o.rater) store._rater=o.rater; save(); render(); }); } | |
| render(); pullLabels(); | |
| </script></body></html> | |
| """ | |
| def main(argv=None) -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--out", default=str(CWM3 / "results/review_tool")) | |
| ap.add_argument("--n-b", type=int, default=200) | |
| ap.add_argument("--seed", type=int, default=0) | |
| ap.add_argument("--only", choices=["A", "B"], default=None) | |
| ap.add_argument("--html-only", action="store_true", help="rewrite index.html from the existing items.json (no frame extraction)") | |
| a = ap.parse_args(argv) | |
| out = Path(a.out) | |
| (out / "img").mkdir(parents=True, exist_ok=True) | |
| if a.html_only: | |
| items = json.load(open(out / "items.json"))["items"] | |
| (out / "index.html").write_text(HTML.replace("__ITEMS__", json.dumps(items, ensure_ascii=False)), encoding="utf-8") | |
| print(f"index.html rewritten from items.json ({len(items)} items)") | |
| return 0 | |
| t0 = time.time() | |
| logf = open(out / "build.log", "a", encoding="utf-8") | |
| def log(s): | |
| print(s, flush=True) | |
| logf.write(f"{time.strftime('%H:%M:%S')} {s}\n"); logf.flush() | |
| items = [] | |
| if a.only in (None, "A"): | |
| items += task_a_items(out / "img", log) | |
| if a.only in (None, "B"): | |
| items += task_b_items(out / "img", a.n_b, a.seed, log) | |
| json.dump({"built_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "seed": a.seed, "n_items": len(items), "items": items}, | |
| open(out / "items.json", "w"), indent=1) | |
| (out / "index.html").write_text(HTML.replace("__ITEMS__", json.dumps(items, ensure_ascii=False)), encoding="utf-8") | |
| log(f"done: {len(items)} items, {sum(len(i['frames']) for i in items)} frames, {time.time()-t0:.0f}s -> {out/'index.html'}") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |
Xet Storage Details
- Size:
- 18.3 kB
- Xet hash:
- 35402e13eed82415041ec39ebfab6b01c151e45a97e744f3af140dec8e995a66
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.