AsianPlayer/TriDrive / code /tools /vlm_handson /evaluate_vlm_handson.py
AsianPlayer's picture
download
raw
22 kB
#!/usr/bin/env python3
"""tools/vlm_handson/evaluate_vlm_handson.py — metrics + acceptance verdict of the VLM hands-on candidates on the owner-labelled validation
frames (pre-registration docs/decisions.md 2026-09-16T23:26Z). NEW files only.
Per candidate x input variant: frame accuracy at P >= 0.5, AUROC, per-class recall (on / off), per regime (engaged / manual), per-item
agreement (majority of the item's frames correct), and for each clicked-return item the detected off->on time — the first ON frame after the
LAST OFF run, (i) plain threshold 0.5 and (ii) hysteresis (off -> on at P >= 0.6, on -> off at P <= 0.4; initial state from P >= 0.5) — vs the
owner's click (|delta| <= 0.5 s counts). Acceptance (pre-registered): accuracy >= 0.90 AND AUROC >= 0.95 AND >= 3 of the 4 clicked returns
within +-0.5 s (hysteresis detector is the primary one; the plain one is reported). Candidates are judged in the FIXED order
qwen3vl2b -> qwen25vl3b -> qwen3vl4b; the first that passes (on either clean variant) is 'accepted'; every scored candidate is reported.
Writes results/tables/vlm_handson_validation.md and results/vlm_handson/metrics.json.
python tools/vlm_handson/evaluate_vlm_handson.py
"""
from __future__ import annotations
import json, sys, time
from pathlib import Path
import numpy as np
CWM3 = Path(__file__).resolve().parents[2]
OUT = CWM3 / "results/vlm_handson"
TABLE = CWM3 / "results/tables/vlm_handson_validation.md"
ORDER = ["qwen3vl2b", "qwen25vl3b", "qwen3vl4b"]
NAMES = {"qwen3vl2b": "Qwen/Qwen3-VL-2B-Instruct", "qwen25vl3b": "Qwen/Qwen2.5-VL-3B-Instruct", "qwen3vl4b": "Qwen/Qwen3-VL-4B-Instruct"}
CLEAN_VARIANTS = ("crop", "full896")
ACC_MIN, AUROC_MIN, CLICK_MIN, CLICK_TOL = 0.90, 0.95, 3, 0.5
JOB_FRAMES = 190_000 # engaged 25 h + manual 28 h at 1 Hz
def auroc(y, p):
from sklearn.metrics import roc_auc_score
return float(roc_auc_score(y, p)) if len(set(y.tolist())) == 2 else float("nan")
def detect_return(ts, P, hyst: bool):
"""-> (t_detected | None, reason, state string). State per frame; detected = first ON frame after the LAST OFF run."""
if hyst:
st = []; s = int(P[0] >= 0.5)
for p in P:
if s == 1 and p <= 0.4:
s = 0
elif s == 0 and p >= 0.6:
s = 1
st.append(s)
st = np.array(st)
else:
st = (P >= 0.5).astype(int)
if st.min() == 1:
return None, "never_off", "".join(map(str, st))
if st.max() == 0:
return None, "never_on", "".join(map(str, st))
last_off = int(np.where(st == 0)[0].max())
if last_off == len(st) - 1:
return None, "ends_off", "".join(map(str, st))
return float(ts[last_off + 1]), "ok", "".join(map(str, st))
def evaluate(val, sc, variant):
d = sc["variants"][variant]
P = np.array(d["p_yes"]); fr = sc["frames"]
y = np.array([f["label"] for f in fr]); reg = np.array([f["regime"] for f in fr]); item = np.array([f["item"] for f in fr])
pred = (P >= 0.5).astype(int)
m = {"n": int(len(y)), "acc": float((pred == y).mean()), "auroc": auroc(y, P), "recall_on": float((pred[y == 1] == 1).mean()), "recall_off": float((pred[y == 0] == 0).mean()),
"p_mean_on": float(P[y == 1].mean()), "p_mean_off": float(P[y == 0].mean())}
for r in ("engaged", "manual"):
k = reg == r
m[f"{r}_n"] = int(k.sum()); m[f"{r}_acc"] = float((pred[k] == y[k]).mean()); m[f"{r}_auroc"] = auroc(y[k], P[k])
# best-threshold diagnostic (NOT the pre-registered operating point)
ths = np.unique(P); accs = [((P >= t).astype(int) == y).mean() for t in ths]
j = int(np.argmax(accs)); m["diag_best_thr"] = float(ths[j]); m["diag_best_acc"] = float(accs[j])
items = {it["id"]: it for it in val["items"]}
agree = []; per_item = []
for iid in items:
k = item == iid
frac = float((pred[k] == y[k]).mean()); agree.append(frac > 0.5)
per_item.append({"id": iid, "owner": items[iid]["owner_label"], "regime": items[iid]["regime"], "frac_correct": round(frac, 3), "p_mean": round(float(P[k].mean()), 3)})
m["item_agree"] = int(sum(agree)); m["item_n"] = len(agree); m["per_item"] = per_item
clicks = []
for it in val["items"]:
if it["click_dt"] is None:
continue
k = item == it["id"]; ts = np.array([f["t"] for f in fr])[k]; Pk = P[k]; o = np.argsort(ts); ts, Pk = ts[o], Pk[o]
t_click = it["t_event"] + it["click_dt"]
rec = {"id": it["id"], "regime": it["regime"], "t_click": round(t_click, 2), "click_dt": it["click_dt"], "p_trace": [round(float(v), 2) for v in Pk]}
for tag, hy in (("plain", False), ("hyst", True)):
td, why, st = detect_return(ts, Pk, hy)
rec[tag] = {"t_det": None if td is None else round(td, 2), "delta_s": None if td is None else round(td - t_click, 2), "reason": why, "states": st,
"within": bool(td is not None and abs(td - t_click) <= CLICK_TOL + 1e-9)}
clicks.append(rec)
m["clicks"] = clicks; m["clicks_within_hyst"] = int(sum(c["hyst"]["within"] for c in clicks)); m["clicks_within_plain"] = int(sum(c["plain"]["within"] for c in clicks)); m["clicks_n"] = len(clicks)
m["pass"] = bool(m["acc"] >= ACC_MIN and m["auroc"] >= AUROC_MIN and m["clicks_within_hyst"] >= CLICK_MIN)
m["timing"] = {k: d[k] for k in ("wall_s", "model_s", "preproc_s", "frames_per_s_wall", "frames_per_s_model", "peak_vram_alloc_gb", "peak_vram_reserved_gb", "seq_len_max", "image_tokens_max", "top1_in_yes_no", "n_full_fallback") if k in d}
m["timing"]["yes_no_mass_median"] = float(np.median(d["yes_no_mass"]))
return m
def overlay_effect(sc):
if "overlay" not in sc["variants"] or "full896" not in sc["variants"]:
return None
a = np.array(sc["variants"]["full896"]["p_yes"]); b = np.array(sc["variants"]["overlay"]["p_yes"])
return {"mean_abs_dP": float(np.abs(a - b).mean()), "flip_rate_at_0.5": float(((a >= 0.5) != (b >= 0.5)).mean()), "mean_P_clean": float(a.mean()), "mean_P_overlay": float(b.mean()),
"corr": float(np.corrcoef(a, b)[0, 1])}
def f(x, nd=3):
return "—" if x is None or (isinstance(x, float) and np.isnan(x)) else (f"{x:.{nd}f}" if isinstance(x, float) else str(x))
def main() -> int:
val = json.load(open(OUT / "validation_frames.json"))
scored = {c: json.load(open(OUT / f"scores_{c}.json")) for c in ORDER if (OUT / f"scores_{c}.json").exists()}
metrics = {"built_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "acceptance": {"acc_min": ACC_MIN, "auroc_min": AUROC_MIN, "clicks_min": CLICK_MIN, "click_tol_s": CLICK_TOL, "primary_detector": "hysteresis 0.4/0.6"},
"validation_summary": val["summary"], "candidates": {}, "accepted": None, "not_evaluated": []}
for c in ORDER:
if c not in scored:
metrics["not_evaluated"].append(c); continue
sc = scored[c]
metrics["candidates"][c] = {"model_id": sc["meta"]["model_id"], "n_params_M": sc["meta"]["n_params_M"], "batch": sc["meta"]["batch"], "load_s": sc["load_s"], "vram_after_load_gb": sc["vram_after_load_gb"], "gate": sc["gate"],
"yes_tokens": sc["meta"]["yes_tokens"], "no_tokens": sc["meta"]["no_tokens"], "variants": {v: evaluate(val, sc, v) for v in sc["variants"]}, "overlay_effect": overlay_effect(sc)}
metrics["candidates"][c]["pass_any_clean"] = any(metrics["candidates"][c]["variants"][v]["pass"] for v in CLEAN_VARIANTS if v in sc["variants"])
metrics["candidates"][c]["verdict"] = "PASS" if metrics["candidates"][c]["pass_any_clean"] else "FAIL"
if metrics["accepted"] is None and metrics["candidates"][c]["pass_any_clean"]:
best = max((v for v in CLEAN_VARIANTS if v in sc["variants"] and metrics["candidates"][c]["variants"][v]["pass"]), key=lambda v: metrics["candidates"][c]["variants"][v]["auroc"])
metrics["accepted"] = {"cand": c, "variant": best}
(OUT / "metrics.json").write_text(json.dumps(metrics, indent=1))
# ------------------------------------------------------------------ table
S = val["summary"]; L = []
L.append("# VLM hands-on gold — validation against the owner's frame-level clicks\n")
L.append(f"Built {metrics['built_utc']} by `tools/vlm_handson/evaluate_vlm_handson.py` from `results/vlm_handson/scores_<cand>.json` (scorer `tools/vlm_handson/score_vlm_handson.py`) "
f"and `results/vlm_handson/validation_frames.json` (`tools/vlm_handson/build_validation_frames.py`). Pre-registration: docs/decisions.md 2026-09-16T23:26Z "
"\"vision-based hands-on gold\". Evaluation-only artefact; no frozen file or result was modified; frames never left the machine (local HF models).\n")
L.append("## Validation set\n")
L.append(f"* {S['n_items']} owner-labelled items → **{S['n_frames']} frames** ({S['n_pos']} hands-on = 1, {S['n_neg']} hands-off = 0); pass-4 items {S['by_source']['pass4']['items']} × 21 frames "
f"(dt −1.0 … +1.0 s), v3-page items {S['by_source']['v3']['items']} × 31 frames (dt −2.0 … +1.0 s); regimes: engaged {S['by_regime']['engaged']['items']} items / {S['by_regime']['engaged']['frames']} frames, "
f"manual {S['by_regime']['manual']['items']} items / {S['by_regime']['manual']['frames']} frames. Owner labels: never_on {S['by_owner_label'].get('never_on', 0)}, always_on {S['by_owner_label'].get('always_on', 0)}, "
f"clicked returns (ret_dt) {S['by_owner_label'].get('ret_dt', 0)} ({', '.join(c['id'] + ' ' + c['regime'] + ' click ' + str(c['click_dt']) + ' s' for c in S['clicked_returns'])}). "
f"Skipped: {len(val['skipped_items'])} items (5 pass-4 'unsure', 90 unlabelled V3 items).")
L.append("* Label rules: never_on → all frames 0; always_on → all frames 1; ret_dt=c → 0 for dt < c, 1 for dt ≥ c. Labels source `results/review_tool/labels_latest.json`; items "
"`items_relabel_hr_engaged_r4.json` (ids `<orig>__r4`) and `items_relabel_v3.json` (ids `V3_*`).")
L.append(f"* Frames RE-EXTRACTED CLEAN from the local videos with the review tool's own convention (plain cv2 seek at frame_idx = round(20 t)); each clean frame was checked against the owner's overlay jpg: "
f"mean |diff| outside the box outline / text band median {S['overlay_check_mad']['median']}, p95 {S['overlay_check_mad']['p95']}, max {S['overlay_check_mad']['max']} (JPEG-noise level → identical frames). "
f"Driver box defined on {S['n_frames'] - S['n_box_undefined']}/{S['n_frames']} frames. Driver side = route-median box centre (right of the image on 40/41 items; B108__r4 has a known-bad box, cx = 0.28).")
L.append("* Inputs: **crop** = causal driver box expanded ×1.6 horizontally toward the wheel side (+0.5 w outward, +0.1 w inward) and ×1.3 vertically (+0.25 h down, +0.05 h up), long side ≤ 896 px; "
"**full896** = whole frame resized to 896 px wide; **overlay** = the owner's 640-px review jpg with the red box and text (fallback check only). Prompt exactly: "
"\"Look at the driver. Is at least one of the driver's hands touching the steering wheel? Answer with yes or no.\" P(yes) = softmax over the first-token logits of the single-token "
"case variants of yes vs no (summed). bf16, sdpa, batch 8, left padding, ≤ 4 CPU threads; GPU shared with the running MotionBERT trainer.\n")
L.append(f"## Acceptance (pre-registered)\n\nframe accuracy at P ≥ 0.5 **≥ {ACC_MIN}** AND AUROC **≥ {AUROC_MIN}** AND **≥ {CLICK_MIN} of the 4** clicked returns detected as off→on within ±{CLICK_TOL} s "
"(detector: first ON frame after the last OFF run; hysteresis 0.4/0.6 primary, plain 0.5 also shown). Candidates in fixed order; the first passing on a clean variant is used; all evaluated are reported.\n")
L.append("## Summary\n")
L.append("| candidate | variant | n | acc@0.5 | AUROC | recall on | recall off | engaged acc / AUROC (n) | manual acc / AUROC (n) | items agree | clicks ±0.5 s hyst / plain | verdict |")
L.append("|---|---|---|---|---|---|---|---|---|---|---|---|")
for c, C in metrics["candidates"].items():
for v, m in C["variants"].items():
verdict = ("**PASS**" if m["pass"] else "fail") if v in CLEAN_VARIANTS else ("pass" if m["pass"] else "fail") + " (overlay check)"
L.append(f"| {NAMES[c]} | {v} | {m['n']} | {f(m['acc'])} | {f(m['auroc'])} | {f(m['recall_on'])} | {f(m['recall_off'])} | {f(m['engaged_acc'])} / {f(m['engaged_auroc'])} ({m['engaged_n']}) | "
f"{f(m['manual_acc'])} / {f(m['manual_auroc'])} ({m['manual_n']}) | {m['item_agree']}/{m['item_n']} | {m['clicks_within_hyst']}/{m['clicks_n']} / {m['clicks_within_plain']}/{m['clicks_n']} | {verdict} |")
for c in metrics["not_evaluated"]:
L.append(f"| {NAMES[c]} | — | — | — | — | — | — | — | — | — | — | not evaluated (an earlier candidate passed) |" if metrics["accepted"] else
f"| {NAMES[c]} | — | — | — | — | — | — | — | — | — | — | not evaluated |")
acc = metrics["accepted"]
L.append("")
L.append(f"**Accepted candidate: {NAMES[acc['cand']] + ' (' + acc['variant'] + ')' if acc else 'NONE — every evaluated candidate fails the pre-registered acceptance'}.**\n")
for c, C in metrics["candidates"].items():
L.append(f"## {NAMES[c]} — verdict {C['verdict']}\n")
L.append(f"* {C['n_params_M']} M params; load {C['load_s']} s; VRAM after load {C['vram_after_load_gb']} GB; gate at start: free VRAM {C['gate']['free_vram_gb']} GB, MemAvailable {C['gate']['mem_available_gb']} GB. "
f"yes tokens {C['yes_tokens']}; no tokens {C['no_tokens']}.")
L.append("\n| variant | wall s | frames/s wall | frames/s model-only | preproc s | peak VRAM alloc / reserved GB | seq len max | image tokens max (batch) | top-1 ∈ {yes,no} | yes+no mass median | full-frame fallbacks | diag best thr → acc |")
L.append("|---|---|---|---|---|---|---|---|---|---|---|---|")
for v, m in C["variants"].items():
t = m["timing"]
L.append(f"| {v} | {t['wall_s']} | {t['frames_per_s_wall']} | {t['frames_per_s_model']} | {t['preproc_s']} | {t['peak_vram_alloc_gb']} / {t['peak_vram_reserved_gb']} | {t['seq_len_max']} | {t['image_tokens_max']} | "
f"{f(t['top1_in_yes_no'])} | {f(t['yes_no_mass_median'])} | {t.get('n_full_fallback', 0)} | {f(m['diag_best_thr'])} → {f(m['diag_best_acc'])} |")
L.append("\nMean P(yes) on hands-on vs hands-off frames: " + "; ".join(f"{v}: {f(m['p_mean_on'])} vs {f(m['p_mean_off'])}" for v, m in C["variants"].items()) + ".")
oe = C["overlay_effect"]
if oe:
L.append(f"\nOverlay check (owner's jpg vs clean full frame, same frames): mean |ΔP| {f(oe['mean_abs_dP'])}, decision flips at 0.5 {f(oe['flip_rate_at_0.5'])}, mean P clean {f(oe['mean_P_clean'])} vs overlay {f(oe['mean_P_overlay'])}, corr {f(oe['corr'])}. "
"(The overlay jpg is 640 px wide with a red box and a text band; it was not used for the verdict.)")
L.append("\n### Clicked returns (owner's click = first frame with a hand on the wheel)\n")
L.append("| variant | item | regime | click dt | t_click | hyst: t_det / Δ s / reason | within ±0.5 | plain: t_det / Δ s / reason | within ±0.5 | P(yes) trace (dt −2.0 … +1.0, 0.1 s) |")
L.append("|---|---|---|---|---|---|---|---|---|---|")
for v, m in C["variants"].items():
for ck in m["clicks"]:
h, p = ck["hyst"], ck["plain"]
L.append(f"| {v} | {ck['id']} | {ck['regime']} | {ck['click_dt']:+.1f} | {ck['t_click']} | {f(h['t_det'], 2)} / {f(h['delta_s'], 2)} / {h['reason']} | {'yes' if h['within'] else 'no'} | "
f"{f(p['t_det'], 2)} / {f(p['delta_s'], 2)} / {p['reason']} | {'yes' if p['within'] else 'no'} | {' '.join(f'{x:.2f}' for x in ck['p_trace'])} |")
L.append("\n### Per item (fraction of frames correct at 0.5; mean P(yes))\n")
vs = list(C["variants"])
L.append("| item | regime | owner label | " + " | ".join(f"{v} frac / mean P" for v in vs) + " |")
L.append("|---|---|---|" + "---|" * len(vs))
rows = {r["id"]: {} for r in C["variants"][vs[0]]["per_item"]}
for v in vs:
for r in C["variants"][v]["per_item"]:
rows[r["id"]][v] = r
for iid, rr in rows.items():
r0 = rr[vs[0]]
L.append(f"| {iid} | {r0['regime']} | {r0['owner']} | " + " | ".join(f"{rr[v]['frac_correct']:.2f} / {rr[v]['p_mean']:.2f}" for v in vs) + " |")
L.append("")
# throughput for the labelling job
L.append("## Throughput estimate for the test-split labelling job (≈ 190 k frames at 1 Hz: engaged 25 h + manual 28 h)\n")
L.append("| candidate | variant | batch | frames/s wall (2B/3B: GPU shared with the ~20 GB MotionBERT trainer; 4B: idle GPU) | frames/s model-only | hours for 190 k frames (wall) | hours (model-only) |")
L.append("|---|---|---|---|---|---|---|")
for c, C in metrics["candidates"].items():
for v, m in C["variants"].items():
if v == "overlay":
continue
t = m["timing"]
L.append(f"| {NAMES[c]} | {v} | {C['batch']} | {t['frames_per_s_wall']} | {t['frames_per_s_model']} | {JOB_FRAMES / t['frames_per_s_wall'] / 3600:.1f} | {JOB_FRAMES / t['frames_per_s_model'] / 3600:.1f} |")
L.append("\n## Notes / failure analysis\n")
L.append("* Qwen3-VL-2B answers 'yes' almost regardless of the image: median P(yes) on hands-OFF frames 0.95 (crop) / 0.88 (full896); 22/25 never_on items have mean P ≥ 0.5 (crop), 9/25 for the 3B. The never_on item "
"scored highest (B103__r4, TOYOTA_PRIUS, night IR, P = 0.99) shows the driver holding a phone with both hands directly over the wheel rim — hands hovering at the wheel, not touching: the "
"distinction the keypoint-geometry labels v2–v4b also failed on (decisions 23:26Z). Its AUROC (0.81 / 0.76) shows some ranking signal but far below 0.95, and no threshold reaches 0.90 accuracy "
"(best-threshold diagnostic 0.81 at P ≥ 0.996).")
L.append("* Qwen2.5-VL-3B is better calibrated (mean P on / off 0.68 / 0.44, crop) but separates weakly: acc 0.67, AUROC 0.80 (crop); 0.65 / 0.73 (full896). With hysteresis it places 3/4 clicked "
"returns within ±0.5 s on the crop (V3_000 −0.1 s, V3_004 −0.3 s, V3_009 +0.3 s; V3_001 fires 2.1 s early), 0/4 on the full frame — the click criterion alone would pass on the crop, the "
"accuracy / AUROC criteria fail by a wide margin.")
L.append("* The route KIA_NIRO_EV__driver_47__00000138 (items B108__r4 never_on, B110__r4 always_on) has a wrong causal driver box (passenger head-rest, cx = 0.28; the frozen pose cache is "
"used as given), so its crop never contains the wheel — B110 is the lowest-scored always_on item for every model (2B 0.06, 3B 0.46). This affects 42 of 981 frames and does not change any verdict.")
L.append("* P(yes) is computed from bf16 logits, so the probabilities are quantised (a few hundred distinct levels over 981 frames); ties slightly depress AUROC but cannot explain the gap to 0.95.")
L.append("* The overlay jpg (red box + text) shifts the 2B scores little (mean |ΔP| 0.09, 4 % decision flips) and the 3B scores substantially (mean |ΔP| 0.15, 29 % flips, lower AUROC 0.66 vs 0.73) — "
"the clean re-extraction was the right input; all verdicts use clean frames only.")
L.append("* Qwen3-VL-4B (best of the three: AUROC 0.85 on both clean variants, acc 0.70 crop / 0.64 full896, best-threshold diagnostic 0.80–0.81) still says 'yes' throughout the two clicked "
"returns V3_000 and V3_001 (never off in the 2 s before the owner's click) and passes only 1/4 (crop) or 2/4 (full896) clicks; 10/25 never_on items keep mean P ≥ 0.5 on the crop. "
"It was run at batch 4 (not 8) so that its 8.9 GB of bf16 weights plus activations stay inside the ≤ 10 GB coexistence budget (peak alloc 9.8 GB); by the time its gate opened the "
"MotionBERT run had finished, so its throughput was measured on an otherwise idle GPU, whereas the 2B and 3B shared the GPU with the ~20 GB trainer." if "qwen3vl4b" in metrics["candidates"]
else "* Qwen3-VL-4B: see the summary row for whether it was evaluated.")
L.append("* Conclusion: at this camera view (wide-angle cabin camera, wheel seen edge-on from above, hands often resting at or hovering over the rim) a 2–4 B local VLM asked the yes/no question "
"per frame does not resolve 'touching' from 'resting just off the wheel' either — the same mechanism that defeated the keypoint-geometry labels v3/v4/v4b. The pre-registered fallback applies: "
"the owner decides between a manual gold set and dropping the engaged hand cells.")
L.append("\nThe wall figure includes JPEG decoding + PIL resizing in the main thread on clean frames already on disk; the labelling job must additionally decode video "
"(index-exact `baselines_dms/video_io.py` decode, ~1 frame per second of video at 1 Hz, i.e. a full decode pass over 53 h of video is the floor) — plan for the wall figure "
"or worse, and for the 5 Hz densification around state changes on top. Measured while the MotionBERT trainer (~20 GB) shared the GPU; an idle GPU is faster.\n")
TABLE.parent.mkdir(parents=True, exist_ok=True)
TABLE.write_text("\n".join(L))
print("\n".join(L[:40]))
print(f"TABLE {TABLE}\nMETRICS {OUT / 'metrics.json'}\nACCEPTED {acc}")
return 0
if __name__ == "__main__":
sys.exit(main())

Xet Storage Details

Size:
22 kB
·
Xet hash:
5ababd740ca9a79fc377f0ff887f0a829a6019ba62061147212956a1dbde2fb9

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.