| """Run the pipeline over a labelled dataset and compute metrics. |
| |
| Ground truth is image-level: each sample lists the violation *types* truly present |
| (plus an optional ground-truth plate). We report violation-level Precision/Recall/F1 |
| two ways — **rule-only** (every rule candidate) vs **rule+VLM routed** (only auto/VLM |
| confirmed) — which is the ablation that shows what the VLM verification buys. |
| |
| Detection mAP needs bbox-level annotations and is out of scope for this image-level |
| harness; use the base detector's COCO mAP for object-detection mAP. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import time |
| from dataclasses import dataclass |
|
|
| from core.pipeline import process |
| from core.schemas import Route |
| from eval.metrics import PRF, char_accuracy, confusion, macro_f1 |
|
|
| CONFIRMED = {Route.auto_confirmed.value, Route.vlm_confirmed.value} |
|
|
| ALL_TYPES = [ |
| "HELMET_NON_COMPLIANCE", |
| "TRIPLE_RIDING", |
| "SEATBELT_NON_COMPLIANCE", |
| "STOP_LINE_VIOLATION", |
| "RED_LIGHT_VIOLATION", |
| "ILLEGAL_PARKING", |
| "WRONG_SIDE_DRIVING", |
| ] |
|
|
|
|
| @dataclass |
| class Sample: |
| image: str |
| expected: set[str] |
| camera_id: str | None = None |
| plate: str | None = None |
|
|
|
|
| @dataclass |
| class EvalReport: |
| n: int |
| rule_only: dict[str, PRF] |
| routed: dict[str, PRF] |
| macro_f1_rule_only: float |
| macro_f1_routed: float |
| dispositions: dict[str, int] |
| mean_latency_s: float |
| plate_whole_accuracy: float | None |
| plate_char_accuracy: float | None |
|
|
|
|
| def load_dataset(path: str) -> list[Sample]: |
| with open(path, encoding="utf-8") as fh: |
| data = json.load(fh) |
| return [ |
| Sample( |
| image=s["image"], |
| expected=set(s.get("expected", [])), |
| camera_id=s.get("camera_id"), |
| plate=s.get("plate"), |
| ) |
| for s in data["samples"] |
| ] |
|
|
|
|
| def evaluate( |
| dataset: list[Sample], *, labels: list[str] | None = None, **pipeline_kwargs |
| ) -> EvalReport: |
| labels = labels or ALL_TYPES |
| expected: list[set[str]] = [] |
| pred_rule_only: list[set[str]] = [] |
| pred_routed: list[set[str]] = [] |
| dispositions: dict[str, int] = {} |
| latencies: list[float] = [] |
| plate_whole: list[float] = [] |
| plate_char: list[float] = [] |
|
|
| for s in dataset: |
| t0 = time.perf_counter() |
| violations, graph = process( |
| "eval", s.image, camera_id=s.camera_id, **pipeline_kwargs |
| ) |
| latencies.append(time.perf_counter() - t0) |
|
|
| expected.append(s.expected) |
| pred_rule_only.append({v.type for v in violations}) |
| pred_routed.append({v.type for v in violations if v.route.value in CONFIRMED}) |
| for v in violations: |
| dispositions[v.route.value] = dispositions.get(v.route.value, 0) + 1 |
|
|
| if s.plate: |
| reads = [p.text for p in graph.plates if p.text] |
| pred_plate = reads[0] if reads else "" |
| plate_whole.append(1.0 if pred_plate == s.plate else 0.0) |
| plate_char.append(char_accuracy(pred_plate, s.plate)) |
|
|
| rule_only = confusion(expected, pred_rule_only, labels) |
| routed = confusion(expected, pred_routed, labels) |
| return EvalReport( |
| n=len(dataset), |
| rule_only=rule_only, |
| routed=routed, |
| macro_f1_rule_only=macro_f1(rule_only), |
| macro_f1_routed=macro_f1(routed), |
| dispositions=dispositions, |
| mean_latency_s=(sum(latencies) / len(latencies)) if latencies else 0.0, |
| plate_whole_accuracy=(sum(plate_whole) / len(plate_whole)) |
| if plate_whole |
| else None, |
| plate_char_accuracy=(sum(plate_char) / len(plate_char)) if plate_char else None, |
| ) |
|
|