Spaces:
Paused
Paused
Download sensorflow/bevfusion/evaluate.py from whassan/SensorflowStudio: direct link, hf CLI and curl.
- Browser
- Download file 18.4 kB
-
https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/sensorflow/bevfusion/evaluate.py
- Command line
-
hf download hf://spaces/whassan/SensorflowStudio/sensorflow/bevfusion/evaluate.py
-
curl -L -o evaluate.py https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/sensorflow/bevfusion/evaluate.py
18.4 kB
| """Self-evaluation: baseline vs BEV-fused labeler on identical scenes. | |
| Generates N deterministic sequences with known ground truth, runs both | |
| engines on the *same* sensor detection streams, evaluates both against ground | |
| truth with the platform's existing metrics (BEV IoU from | |
| sensorflow.metrics.perception_3d; ID-switch and fragmentation rates from | |
| sensorflow.metrics.temporal_mot; plus nuScenes-style center-distance matching, | |
| per-cohort recall, position error and IDF1 computed here), and produces a | |
| comparison report mirroring the shape of the megaeval compare machinery | |
| (headline_deltas / per-cohort deltas / policy / blockers / PROMOTE | |
| recommendation). Every number is computed at runtime from the engine outputs. | |
| Matching is center-distance based (2.0 m, nuScenes convention) rather than | |
| IoU-thresholded, so the baseline's monocular depth error degrades recall and | |
| position error smoothly instead of zeroing everything out; BEV IoU is still | |
| reported for matched pairs. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Dict, List, Optional | |
| from uuid import uuid4 | |
| import numpy as np | |
| from scipy.optimize import linear_sum_assignment | |
| from sensorflow.bevfusion.engines import ( | |
| BASELINE_ENGINE, FUSED_ENGINE, run_baseline, run_fused, | |
| ) | |
| from sensorflow.bevfusion.masklet import tracks_to_dicts | |
| from sensorflow.bevfusion.scenes import ( | |
| SAFETY_CLASSES, SceneSequence, generate_sequences, gt_tracks, | |
| ) | |
| from sensorflow.metrics.perception_3d import bev_iou | |
| from sensorflow.metrics.temporal_mot import ( | |
| compute_id_swap_rate, compute_track_fragmentation_rate, | |
| ) | |
| RUNS_DIR = Path("runs/bevfusion") | |
| MATCH_DIST_M = 2.0 | |
| DEFAULT_POLICY = { | |
| "max_recall_drop": 0.010, | |
| "max_precision_drop": 0.015, | |
| "max_safety_recall_drop": 0.005, | |
| "max_cohort_recall_drop": 0.050, | |
| "min_cohort_support": 20, | |
| } | |
| COHORT_EXPLANATIONS = { | |
| "overall": "Fused existence evidence (noisy-OR of camera and LiDAR) plus " | |
| "inverse-variance geometry gives higher recall and near-LiDAR " | |
| "position accuracy wherever either sensor fires.", | |
| "day": "Both sensors healthy: fusion mainly sharpens positions " | |
| "(LiDAR-dominated inverse-variance weighting) and suppresses " | |
| "single-modality ghosts at the decode threshold.", | |
| "night": "The camera misses heavily in low light; LiDAR is unaffected by " | |
| "darkness, so LiDAR existence evidence in the BEV grid recovers " | |
| "the missed objects and the class comes from LiDAR geometry " | |
| "templates or temporal continuity.", | |
| "clear": "Nominal conditions; improvement is dominated by monocular depth " | |
| "error being replaced by LiDAR geometry.", | |
| "rain": "Rain degrades LiDAR at range (spray attenuation) and the camera " | |
| "mildly; each modality covers part of the other's losses, so the " | |
| "fused map keeps existence evidence where either survives.", | |
| "occluded": "Camera line of sight is blocked; LiDAR (mounted higher, " | |
| "sensing 3D structure) keeps partial returns, and masklet " | |
| "propagation carries identity and a predicted box through the " | |
| "remaining gap frames.", | |
| "long_range": "Beyond ~45 m LiDAR thins out and monocular depth error " | |
| "exceeds the match gate; fusion helps where LiDAR still " | |
| "fires, and camera bearing keeps existence alive elsewhere.", | |
| } | |
| # ------------------------------------------------------------------ matching | |
| def _match_frame(pred_boxes: List[Dict], gt_boxes: List, dist_m: float = MATCH_DIST_M): | |
| """Greedy center-distance matching. Returns (pairs, unmatched_pred_idx).""" | |
| cands = [] | |
| for pi, p in enumerate(pred_boxes): | |
| for gi, g in enumerate(gt_boxes): | |
| d = math.hypot(p["bbox_3d"][0] - g.bbox_3d[0], p["bbox_3d"][1] - g.bbox_3d[1]) | |
| if d <= dist_m: | |
| cands.append((d, pi, gi)) | |
| cands.sort() | |
| used_p, used_g, pairs = set(), set(), [] | |
| for d, pi, gi in cands: | |
| if pi in used_p or gi in used_g: | |
| continue | |
| pairs.append((pi, gi, d)) | |
| used_p.add(pi) | |
| used_g.add(gi) | |
| unmatched_pred = [i for i in range(len(pred_boxes)) if i not in used_p] | |
| return pairs, unmatched_pred | |
| def _gt_cohorts(gt, seq: SceneSequence) -> List[str]: | |
| cohorts = ["overall", seq.time_of_day, seq.weather] | |
| if gt.occluded: | |
| cohorts.append("occluded") | |
| if gt.distance > 40.0: | |
| cohorts.append("long_range") | |
| return cohorts | |
| # ------------------------------------------------------------------ IDF1 | |
| def compute_idf1(pred_tracks: List[Dict], gt_tracks_list: List[Dict], | |
| dist_m: float = MATCH_DIST_M) -> float: | |
| """IDF1 via optimal 1:1 track assignment (Hungarian on frame overlaps).""" | |
| if not pred_tracks or not gt_tracks_list: | |
| return 0.0 | |
| n_pred = sum(len(t["frames"]) for t in pred_tracks) | |
| n_gt = sum(len(t["frames"]) for t in gt_tracks_list) | |
| overlap = np.zeros((len(pred_tracks), len(gt_tracks_list))) | |
| for i, pt in enumerate(pred_tracks): | |
| pframes = {f["frame_id"]: f["bbox_3d"] for f in pt["frames"]} | |
| for j, gt in enumerate(gt_tracks_list): | |
| n = 0 | |
| for f in gt["frames"]: | |
| pb = pframes.get(f["frame_id"]) | |
| if pb is not None and math.hypot(pb[0] - f["bbox_3d"][0], | |
| pb[1] - f["bbox_3d"][1]) <= dist_m: | |
| n += 1 | |
| overlap[i, j] = n | |
| rows, cols = linear_sum_assignment(-overlap) | |
| idtp = float(overlap[rows, cols].sum()) | |
| return round(2.0 * idtp / max(n_pred + n_gt, 1), 4) | |
| # ------------------------------------------------------------------ engine evaluation | |
| def evaluate_engine(sequences: List[SceneSequence], | |
| preds_by_seq: Dict[str, Dict[str, List[Dict]]]) -> Dict: | |
| """Evaluate one engine's per-frame labels against ground truth.""" | |
| cohort_stats: Dict[str, Dict] = {} | |
| class_stats: Dict[str, Dict] = {} | |
| tp = fp = fn = 0 | |
| ious: List[float] = [] | |
| pos_errors: List[float] = [] | |
| class_correct = class_total = 0 | |
| safety_tp = safety_fn = 0 | |
| def _coh(name): | |
| return cohort_stats.setdefault(name, {"n_gt": 0, "matched": 0, | |
| "pos_errors": [], "ious": [], "fp": 0}) | |
| for seq in sequences: | |
| preds = preds_by_seq[seq.sequence_id] | |
| for frame in seq.frames: | |
| pred_boxes = preds.get(frame.frame_id, []) | |
| pairs, unmatched_pred = _match_frame(pred_boxes, frame.gt) | |
| matched_gt = {gi for _, gi, _ in pairs} | |
| for pi, gi, d in pairs: | |
| p, g = pred_boxes[pi], frame.gt[gi] | |
| tp += 1 | |
| iou = bev_iou(p["bbox_3d"], g.bbox_3d) | |
| ious.append(iou) | |
| pos_errors.append(d) | |
| class_total += 1 | |
| if p["class_name"] == g.class_name: | |
| class_correct += 1 | |
| if g.class_name in SAFETY_CLASSES: | |
| safety_tp += 1 | |
| cs = class_stats.setdefault(g.class_name, {"n_gt": 0, "matched": 0}) | |
| cs["n_gt"] += 1 | |
| cs["matched"] += 1 | |
| for name in _gt_cohorts(g, seq): | |
| c = _coh(name) | |
| c["n_gt"] += 1 | |
| c["matched"] += 1 | |
| c["pos_errors"].append(d) | |
| c["ious"].append(iou) | |
| for gi, g in enumerate(frame.gt): | |
| if gi in matched_gt: | |
| continue | |
| fn += 1 | |
| if g.class_name in SAFETY_CLASSES: | |
| safety_fn += 1 | |
| class_stats.setdefault(g.class_name, {"n_gt": 0, "matched": 0})["n_gt"] += 1 | |
| for name in _gt_cohorts(g, seq): | |
| _coh(name)["n_gt"] += 1 | |
| for pi in unmatched_pred: | |
| fp += 1 | |
| _coh("overall")["fp"] += 1 | |
| _coh(seq.time_of_day)["fp"] += 1 | |
| _coh(seq.weather)["fp"] += 1 | |
| precision = tp / (tp + fp) if tp + fp else 0.0 | |
| recall = tp / (tp + fn) if tp + fn else 0.0 | |
| f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 | |
| # Tracking metrics via the platform's temporal MOT module + IDF1. | |
| # Track ids restart per sequence (one tracker per sequence), so namespace | |
| # them with the sequence id before pooling. | |
| pred_tracks: List[Dict] = [] | |
| for seq in sequences: | |
| for t in tracks_to_dicts(preds_by_seq[seq.sequence_id]): | |
| t["track_id"] = f"{seq.sequence_id}:{t['track_id']}" | |
| pred_tracks.append(t) | |
| gts = gt_tracks(sequences) | |
| id_switch_rate = compute_id_swap_rate(pred_tracks, gts) | |
| frag_rate = compute_track_fragmentation_rate(pred_tracks, gts) | |
| idf1 = compute_idf1(pred_tracks, gts) | |
| cohorts = {} | |
| for name, c in cohort_stats.items(): | |
| cohorts[name] = { | |
| "n_gt": c["n_gt"], | |
| "recall": round(c["matched"] / c["n_gt"], 4) if c["n_gt"] else None, | |
| "position_error_m": round(float(np.mean(c["pos_errors"])), 4) if c["pos_errors"] else None, | |
| "mean_iou": round(float(np.mean(c["ious"])), 4) if c["ious"] else None, | |
| "false_positives": c["fp"], | |
| } | |
| per_class = { | |
| cls: {"n_gt": s["n_gt"], | |
| "recall": round(s["matched"] / s["n_gt"], 4) if s["n_gt"] else None} | |
| for cls, s in sorted(class_stats.items()) | |
| } | |
| return { | |
| "headline": { | |
| "precision": round(precision, 4), | |
| "recall": round(recall, 4), | |
| "f1": round(f1, 4), | |
| "mean_iou": round(float(np.mean(ious)), 4) if ious else 0.0, | |
| "position_error_m": round(float(np.mean(pos_errors)), 4) if pos_errors else None, | |
| "class_accuracy": round(class_correct / class_total, 4) if class_total else None, | |
| "safety_recall": round(safety_tp / (safety_tp + safety_fn), 4) | |
| if safety_tp + safety_fn else None, | |
| "id_switch_rate": round(id_switch_rate, 4), | |
| "fragmentation_rate": round(frag_rate, 4), | |
| "idf1": idf1, | |
| }, | |
| "counts": {"tp": tp, "fp": fp, "fn": fn, | |
| "pred_tracks": len(pred_tracks), "gt_tracks": len(gts)}, | |
| "cohorts": cohorts, | |
| "per_class": per_class, | |
| } | |
| # ------------------------------------------------------------------ comparison report | |
| # Metrics where lower is better. | |
| _LOWER_IS_BETTER = {"position_error_m", "id_switch_rate", "fragmentation_rate"} | |
| _HEADLINE_ORDER = ["precision", "recall", "f1", "mean_iou", "position_error_m", | |
| "class_accuracy", "safety_recall", "id_switch_rate", | |
| "fragmentation_rate", "idf1"] | |
| def build_comparison(base_eval: Dict, cand_eval: Dict, | |
| policy: Optional[Dict] = None) -> Dict: | |
| """Comparison report mirroring the megaeval compare shape | |
| (headline_deltas / per-cohort / policy / blockers / recommendation).""" | |
| policy = {**DEFAULT_POLICY, **(policy or {})} | |
| bh, ch = base_eval["headline"], cand_eval["headline"] | |
| headline_deltas = [] | |
| for m in _HEADLINE_ORDER: | |
| b, c = bh.get(m), ch.get(m) | |
| if b is None or c is None: | |
| continue | |
| delta = round(c - b, 4) | |
| headline_deltas.append({ | |
| "metric": m, "baseline": b, "candidate": c, "delta": delta, | |
| "improved": (delta < 0) if m in _LOWER_IS_BETTER else (delta > 0), | |
| }) | |
| per_cohort = [] | |
| for name, b in base_eval["cohorts"].items(): | |
| c = cand_eval["cohorts"].get(name) | |
| if not c or b["recall"] is None or c["recall"] is None: | |
| continue | |
| per_cohort.append({ | |
| "cohort": name, | |
| "n_gt": c["n_gt"], | |
| "recall_baseline": b["recall"], "recall_candidate": c["recall"], | |
| "recall_delta": round(c["recall"] - b["recall"], 4), | |
| "position_error_baseline_m": b["position_error_m"], | |
| "position_error_candidate_m": c["position_error_m"], | |
| "mean_iou_baseline": b["mean_iou"], "mean_iou_candidate": c["mean_iou"], | |
| "explanation": COHORT_EXPLANATIONS.get(name, ""), | |
| }) | |
| per_cohort.sort(key=lambda x: x["recall_delta"], reverse=True) | |
| per_class = [] | |
| for cls, b in base_eval["per_class"].items(): | |
| c = cand_eval["per_class"].get(cls) | |
| if not c or b["recall"] is None or c["recall"] is None: | |
| continue | |
| per_class.append({"class": cls, "n_gt": c["n_gt"], | |
| "recall_baseline": b["recall"], | |
| "recall_candidate": c["recall"], | |
| "recall_delta": round(c["recall"] - b["recall"], 4)}) | |
| hd = {h["metric"]: h["delta"] for h in headline_deltas} | |
| blockers: List[str] = [] | |
| if hd.get("recall", 0) < -policy["max_recall_drop"]: | |
| blockers.append(f"headline recall dropped {hd['recall']:+.4f}") | |
| if hd.get("precision", 0) < -policy["max_precision_drop"]: | |
| blockers.append(f"headline precision dropped {hd['precision']:+.4f}") | |
| if hd.get("safety_recall", 0) < -policy["max_safety_recall_drop"]: | |
| blockers.append(f"safety recall dropped {hd['safety_recall']:+.4f}") | |
| for c in per_cohort: | |
| if c["n_gt"] >= policy["min_cohort_support"] and \ | |
| c["recall_delta"] < -policy["max_cohort_recall_drop"]: | |
| blockers.append( | |
| f"REGRESSION: cohort {c['cohort']} recall " | |
| f"{c['recall_baseline']*100:.1f}% -> {c['recall_candidate']*100:.1f}%") | |
| improvements = [f"{h['metric']}: {h['baseline']} -> {h['candidate']} " | |
| f"({h['delta']:+.4f})" for h in headline_deltas if h["improved"]] | |
| return { | |
| "headline_deltas": headline_deltas, | |
| "per_cohort": per_cohort, | |
| "per_class": per_class, | |
| "policy": policy, | |
| "blockers": blockers, | |
| "improvements": improvements, | |
| "recommendation": "DO_NOT_PROMOTE" if blockers else "PROMOTE", | |
| } | |
| # ------------------------------------------------------------------ orchestration | |
| def run_comparison(n_sequences: int = 6, frames_per_sequence: int = 24, | |
| seed: int = 7, out_dir: Optional[Path] = None, | |
| persist: bool = True) -> Dict: | |
| """Generate scenes, run both engines, evaluate both, build + persist report.""" | |
| sequences = generate_sequences(n_sequences=n_sequences, | |
| frames_per_sequence=frames_per_sequence, seed=seed) | |
| baseline_preds: Dict[str, Dict[str, List[Dict]]] = {} | |
| fused_preds: Dict[str, Dict[str, List[Dict]]] = {} | |
| fusion_stats = {"camera_detections": 0, "lidar_detections": 0, "fused_boxes": 0} | |
| for qi, seq in enumerate(sequences): | |
| baseline_preds[seq.sequence_id] = run_baseline(seq, seed, qi) | |
| fused, stats = run_fused(seq, seed, qi) | |
| fused_preds[seq.sequence_id] = fused | |
| for k in fusion_stats: | |
| fusion_stats[k] += stats[k] | |
| base_eval = evaluate_engine(sequences, baseline_preds) | |
| cand_eval = evaluate_engine(sequences, fused_preds) | |
| comparison = build_comparison(base_eval, cand_eval) | |
| n_frames = sum(len(s.frames) for s in sequences) | |
| n_gt = sum(len(f.gt) for s in sequences for f in s.frames) | |
| process_units = _record_process_units(fusion_stats, n_frames, n_gt) | |
| report = { | |
| "run_id": f"bevrun-{uuid4().hex[:10]}", | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| "params": {"n_sequences": n_sequences, | |
| "frames_per_sequence": frames_per_sequence, "seed": seed}, | |
| "engines": {"baseline": BASELINE_ENGINE, "candidate": FUSED_ENGINE}, | |
| "scale": {"frames": n_frames, "gt_boxes": n_gt, **fusion_stats}, | |
| "baseline": base_eval, | |
| "candidate": cand_eval, | |
| **comparison, | |
| "process_units": process_units, | |
| "notes": ( | |
| "Honest simulation: no learned features; detections are sampled from " | |
| "ground truth with modality-specific failure models (camera: occlusion/" | |
| "night misses + monocular depth ambiguity, good semantics; LiDAR: " | |
| "accurate geometry, range/rain degradation, weak semantics). The " | |
| "improvement arises at runtime from geometric+probabilistic fusion in " | |
| "the BEV grid and masklet propagation — sensor complementarity is real." | |
| ), | |
| } | |
| if persist: | |
| out = Path(out_dir) if out_dir else RUNS_DIR | |
| out.mkdir(parents=True, exist_ok=True) | |
| with open(out / f"{report['run_id']}.json", "w") as f: | |
| json.dump(report, f, indent=2) | |
| with open(out / "latest.json", "w") as f: | |
| json.dump(report, f, indent=2) | |
| return report | |
| def _record_process_units(fusion_stats: Dict, n_frames: int, n_gt: int) -> Dict: | |
| """Record fusion-stage process units via the platform's accounting | |
| (best effort: skipped silently if the evaluation store is unavailable).""" | |
| units = {} | |
| try: | |
| from sensorflow.evaluation.process_units import ProcessMeter | |
| from sensorflow.evaluation.records import get_store | |
| meter = ProcessMeter(get_store(), run_id="bevfusion") | |
| units["bev_camera_projection"] = meter.record( | |
| "bev_camera_projection", fusion_stats["camera_detections"], factor=0.2) | |
| units["bev_lidar_rasterization"] = meter.record( | |
| "bev_lidar_rasterization", fusion_stats["lidar_detections"], factor=0.2) | |
| units["bev_fusion"] = meter.record("bev_fusion", n_frames, factor=0.8) | |
| units["masklet_tracking"] = meter.record( | |
| "masklet_tracking", fusion_stats["fused_boxes"], factor=0.3) | |
| units["label_evaluation"] = meter.record("label_evaluation", n_gt, factor=0.5) | |
| units["total"] = sum(units.values()) | |
| except Exception: | |
| units = {"total": 0, "note": "process accounting unavailable"} | |
| return units | |
| def latest_report(out_dir: Optional[Path] = None) -> Optional[Dict]: | |
| path = (Path(out_dir) if out_dir else RUNS_DIR) / "latest.json" | |
| if not path.exists(): | |
| return None | |
| with open(path) as f: | |
| return json.load(f) | |