AsianPlayer/TriDrive / code /tools /review_tool /build_review_tool.py
AsianPlayer's picture
download
raw
18.3 kB
#!/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.