whassan commited on
Commit
93efd9d
·
verified ·
1 Parent(s): f271df0

Deploy Sensorflow Studio

Browse files
.gitignore CHANGED
@@ -65,7 +65,6 @@ temp/
65
  # Generated runtime/test artifacts
66
  runs/labeleval/
67
  runs/megaeval/
68
- runs/platform/
69
  runs/pipeline/frame_test/
70
  runs/pipeline/qg_test/
71
  runs/pipeline/roundtrip/
 
65
  # Generated runtime/test artifacts
66
  runs/labeleval/
67
  runs/megaeval/
 
68
  runs/pipeline/frame_test/
69
  runs/pipeline/qg_test/
70
  runs/pipeline/roundtrip/
app_backend.py CHANGED
@@ -1467,9 +1467,9 @@ app.include_router(labeleval_router)
1467
  from sensorflow.megaeval.api import router as megaeval_router
1468
  app.include_router(megaeval_router)
1469
 
1470
- # Population-scale foundation (container quality, multi-model compare, multi-gate, evidence).
1471
- from sensorflow.platform.api import router as platform_router
1472
- app.include_router(platform_router)
1473
 
1474
  # Mount static folder
1475
  static_dir = Path(__file__).parent / "static"
 
1467
  from sensorflow.megaeval.api import router as megaeval_router
1468
  app.include_router(megaeval_router)
1469
 
1470
+ # BEV-Fusion perception engine (camera+LiDAR fusion, masklet tracking, self-eval).
1471
+ from sensorflow.bevfusion.api import router as bevfusion_router
1472
+ app.include_router(bevfusion_router)
1473
 
1474
  # Mount static folder
1475
  static_dir = Path(__file__).parent / "static"
sensorflow/bevfusion/__init__.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BEV-Fusion perception engine (inspired by BEVFusion) with masklet-style
2
+ temporal propagation (inspired by SAM 3).
3
+
4
+ What this package does
5
+ ----------------------
6
+ Fuses camera and LiDAR evidence in a unified bird's-eye-view (BEV) plane to
7
+ produce better auto-labels than a single-sensor (camera-primary) labeler, and
8
+ demonstrates the improvement with the platform's own evaluation machinery on
9
+ synthetic scenes with known ground truth.
10
+
11
+ Concept mapping (paper -> this implementation)
12
+ ----------------------------------------------
13
+ BEVFusion:
14
+ - "Lift camera features to BEV via calibrated geometry"
15
+ -> :mod:`geometry`: pinhole projection with a 6-DoF camera-to-ego
16
+ extrinsic; monocular detections are back-projected along the viewing
17
+ ray into the BEV plane with an anisotropic (along-ray-elongated)
18
+ position covariance.
19
+ - "Rasterize LiDAR features into a BEV grid"
20
+ -> :mod:`fusion`: LiDAR detections are splatted into a metric BEV
21
+ occupancy/feature grid with tight isotropic covariance.
22
+ - "Fuse the modality feature maps in the shared BEV space"
23
+ -> :mod:`fusion.fuse_maps`: per-cell fusion of existence evidence
24
+ (noisy-OR), class evidence (reliability-weighted histograms; camera
25
+ weighted higher), and geometry evidence (inverse-variance weighted
26
+ accumulators; LiDAR dominates position where present).
27
+ - "Decode fused BEV features into 3D boxes"
28
+ -> :mod:`fusion.decode_bev`: peak extraction + ray-aware NMS +
29
+ continuous weighted decoding of center/dims/yaw/class.
30
+
31
+ SAM 3 masklets:
32
+ - "Propagate each object's identity through time, surviving brief
33
+ disappearances without re-identification"
34
+ -> :mod:`masklet.BEVMaskletTracker`: association in the stable BEV
35
+ plane (Hungarian on BEV distance + class + size, extending
36
+ :mod:`sensorflow.temporal_tracker`), with Kalman-predicted BEV
37
+ position carrying identity across short dropouts and emitting
38
+ propagated ("masklet") boxes during occlusion gaps.
39
+
40
+ What is simulated vs. real
41
+ --------------------------
42
+ There are NO learned features and NO neural networks here (the platform is
43
+ numpy-only by design). Camera/LiDAR "detections" are sampled from ground truth
44
+ with realistic, modality-specific failure modes (camera: occlusion/night
45
+ misses and depth ambiguity but good class discrimination; LiDAR: accurate
46
+ geometry, degraded at long range and in rain, weak class discrimination).
47
+ The fusion itself is honest geometric + probabilistic math, and the measured
48
+ improvement arises from real sensor complementarity in that math: LiDAR
49
+ existence evidence recovers camera misses, camera class evidence flows along
50
+ the viewing ray to LiDAR-anchored cells, and inverse-variance weighting gives
51
+ fused boxes near-LiDAR position accuracy. Nothing in the comparison report is
52
+ hardcoded; every number is computed at runtime from the fused outputs.
53
+ """
sensorflow/bevfusion/api.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI router for the BEV-Fusion perception engine (included by app_backend).
2
+
3
+ Endpoints:
4
+ POST /api/bevfusion/run generate scenes, run both engines, evaluate, persist
5
+ GET /api/bevfusion/report latest comparison report (per-cohort deltas + explanations)
6
+ GET /api/bevfusion/status run inventory + engine identifiers
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Optional
13
+
14
+ from fastapi import APIRouter, HTTPException
15
+ from pydantic import BaseModel, Field
16
+
17
+ from sensorflow.bevfusion import evaluate as evaluate_mod
18
+ from sensorflow.bevfusion.engines import BASELINE_ENGINE, FUSED_ENGINE
19
+
20
+ router = APIRouter(prefix="/api/bevfusion")
21
+
22
+
23
+ class RunRequest(BaseModel):
24
+ n_sequences: int = Field(6, ge=1, le=24)
25
+ frames_per_sequence: int = Field(24, ge=8, le=60)
26
+ seed: int = 7
27
+
28
+
29
+ @router.post("/run")
30
+ def run(req: RunRequest):
31
+ report = evaluate_mod.run_comparison(
32
+ n_sequences=req.n_sequences,
33
+ frames_per_sequence=req.frames_per_sequence,
34
+ seed=req.seed,
35
+ )
36
+ return report
37
+
38
+
39
+ @router.get("/report")
40
+ def report():
41
+ rep = evaluate_mod.latest_report()
42
+ if rep is None:
43
+ raise HTTPException(404, "No BEV-fusion comparison has been run yet; "
44
+ "POST /api/bevfusion/run first")
45
+ return rep
46
+
47
+
48
+ @router.get("/status")
49
+ def status():
50
+ runs_dir = evaluate_mod.RUNS_DIR
51
+ runs = []
52
+ if runs_dir.exists():
53
+ for p in sorted(runs_dir.glob("bevrun-*.json")):
54
+ try:
55
+ with open(p) as f:
56
+ r = json.load(f)
57
+ runs.append({"run_id": r["run_id"], "created_at": r["created_at"],
58
+ "params": r["params"],
59
+ "recommendation": r.get("recommendation")})
60
+ except Exception:
61
+ continue
62
+ latest: Optional[dict] = runs[-1] if runs else None
63
+ return {
64
+ "engines": {"baseline": BASELINE_ENGINE, "candidate": FUSED_ENGINE},
65
+ "n_runs": len(runs),
66
+ "runs": runs,
67
+ "latest": latest,
68
+ "ready": latest is not None,
69
+ }
sensorflow/bevfusion/engines.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The two auto-label engines under comparison.
2
+
3
+ perception-v1-camera (baseline)
4
+ Camera-primary label generation: monocular detections are lifted to 3D
5
+ directly (noisy range along the ray, ground-plane z) and associated
6
+ frame-to-frame with a greedy nearest-neighbour matcher — no motion model,
7
+ no gap tolerance. Expected weaknesses (all measured, none hardcoded):
8
+ misses under occlusion and at night, position error growing with range,
9
+ ID switches/fragmentation whenever detection drops for even one frame or
10
+ the depth estimate jumps.
11
+
12
+ perception-v3-bevfusion (candidate)
13
+ Camera + LiDAR fused in the shared BEV grid (fusion.py), decoded to boxes,
14
+ then tracked with BEV Hungarian association + masklet propagation
15
+ (masklet.py). The improvement comes from the fusion math: LiDAR existence
16
+ recovers camera misses, camera semantics label LiDAR geometry, and
17
+ inverse-variance weighting gives near-LiDAR position accuracy.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import Dict, List
24
+
25
+ from sensorflow.bevfusion.geometry import BEVGrid
26
+ from sensorflow.bevfusion.fusion import build_modality_map, decode_bev, fuse_maps
27
+ from sensorflow.bevfusion.masklet import BEVMaskletTracker
28
+ from sensorflow.bevfusion.scenes import SceneSequence
29
+ from sensorflow.bevfusion.sensors import (
30
+ Detection, camera_rng, lidar_rng, simulate_camera, simulate_lidar,
31
+ )
32
+ from sensorflow.schemas.unified_frame import Object3D
33
+
34
+ BASELINE_ENGINE = "perception-v1-camera"
35
+ FUSED_ENGINE = "perception-v3-bevfusion"
36
+
37
+
38
+ class FrameToFrameTracker:
39
+ """Deliberately naive baseline association: greedy NN against the previous
40
+ frame only. Any dropout or large position jitter spawns a new identity."""
41
+
42
+ def __init__(self, gate: float = 3.0):
43
+ self.gate = gate
44
+ self.prev: List[Dict] = [] # [{track_id, x, y}]
45
+ self.next_id = 1
46
+
47
+ def update(self, boxes: List[Dict]) -> List[Dict]:
48
+ used = set()
49
+ for box in sorted(boxes, key=lambda b: -b.get("confidence", 0.0)):
50
+ best, best_d = None, self.gate
51
+ for i, p in enumerate(self.prev):
52
+ if i in used:
53
+ continue
54
+ d = math.hypot(box["bbox_3d"][0] - p["x"], box["bbox_3d"][1] - p["y"])
55
+ if d < best_d:
56
+ best, best_d = i, d
57
+ if best is not None:
58
+ used.add(best)
59
+ box["track_id"] = self.prev[best]["track_id"]
60
+ else:
61
+ box["track_id"] = self.next_id
62
+ self.next_id += 1
63
+ self.prev = [{"track_id": b["track_id"], "x": b["bbox_3d"][0], "y": b["bbox_3d"][1]}
64
+ for b in boxes]
65
+ return boxes
66
+
67
+
68
+ def _det_to_box(det: Detection) -> Dict:
69
+ return {
70
+ "bbox_3d": [det.x, det.y, det.z, det.dims[0], det.dims[1], det.dims[2], det.yaw],
71
+ "class_name": det.class_name,
72
+ "class_confidence": det.class_confidence,
73
+ "confidence": det.confidence,
74
+ "propagated": False,
75
+ }
76
+
77
+
78
+ def run_baseline(seq: SceneSequence, seed: int, seq_index: int) -> Dict[str, List[Dict]]:
79
+ """Camera-only labels per frame. Uses the same camera detections (same rng
80
+ stream) as the fused engine, so the comparison isolates the fusion."""
81
+ rng = camera_rng(seed, seq_index)
82
+ tracker = FrameToFrameTracker(gate=3.0)
83
+ out: Dict[str, List[Dict]] = {}
84
+ for frame in seq.frames:
85
+ boxes = [_det_to_box(d) for d in simulate_camera(frame, seq, rng)]
86
+ out[frame.frame_id] = tracker.update(boxes)
87
+ return out
88
+
89
+
90
+ def run_fused(seq: SceneSequence, seed: int, seq_index: int,
91
+ grid: BEVGrid = None):
92
+ """BEV-fused labels per frame, tracked with masklet propagation."""
93
+ grid = grid or BEVGrid()
94
+ cam_gen = camera_rng(seed, seq_index)
95
+ lid_gen = lidar_rng(seed, seq_index)
96
+ tracker = BEVMaskletTracker(bounds=(grid.x_min, grid.x_max, grid.y_min, grid.y_max))
97
+ out: Dict[str, List[Dict]] = {}
98
+ stats = {"camera_detections": 0, "lidar_detections": 0, "fused_boxes": 0}
99
+
100
+ for frame in seq.frames:
101
+ cam_dets = simulate_camera(frame, seq, cam_gen)
102
+ lid_dets = simulate_lidar(frame, seq, lid_gen)
103
+ stats["camera_detections"] += len(cam_dets)
104
+ stats["lidar_detections"] += len(lid_dets)
105
+
106
+ cam_map = build_modality_map(cam_dets, grid, "camera")
107
+ lid_map = build_modality_map(lid_dets, grid, "lidar")
108
+ decoded = decode_bev(fuse_maps(cam_map, lid_map))
109
+ stats["fused_boxes"] += len(decoded)
110
+
111
+ proposals = [Object3D(bbox_3d=b["bbox_3d"], class_name=b["class_name"],
112
+ confidence=b["confidence"]) for b in decoded]
113
+ tracked = tracker.update(frame.frame_id, proposals)
114
+ propagated_ids = getattr(tracker, "last_propagated", set())
115
+ out[frame.frame_id] = [{
116
+ "bbox_3d": t.bbox_3d, "class_name": t.class_name,
117
+ "confidence": t.confidence, "track_id": t.track_id,
118
+ "propagated": t.track_id in propagated_ids,
119
+ } for t in tracked]
120
+ return out, stats
sensorflow/bevfusion/evaluate.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-evaluation: baseline vs BEV-fused labeler on identical scenes.
2
+
3
+ Generates N deterministic sequences with known ground truth, runs both
4
+ engines on the *same* sensor detection streams, evaluates both against ground
5
+ truth with the platform's existing metrics (BEV IoU from
6
+ sensorflow.metrics.perception_3d; ID-switch and fragmentation rates from
7
+ sensorflow.metrics.temporal_mot; plus nuScenes-style center-distance matching,
8
+ per-cohort recall, position error and IDF1 computed here), and produces a
9
+ comparison report mirroring the shape of the megaeval compare machinery
10
+ (headline_deltas / per-cohort deltas / policy / blockers / PROMOTE
11
+ recommendation). Every number is computed at runtime from the engine outputs.
12
+
13
+ Matching is center-distance based (2.0 m, nuScenes convention) rather than
14
+ IoU-thresholded, so the baseline's monocular depth error degrades recall and
15
+ position error smoothly instead of zeroing everything out; BEV IoU is still
16
+ reported for matched pairs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import math
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Dict, List, Optional
26
+ from uuid import uuid4
27
+
28
+ import numpy as np
29
+ from scipy.optimize import linear_sum_assignment
30
+
31
+ from sensorflow.bevfusion.engines import (
32
+ BASELINE_ENGINE, FUSED_ENGINE, run_baseline, run_fused,
33
+ )
34
+ from sensorflow.bevfusion.masklet import tracks_to_dicts
35
+ from sensorflow.bevfusion.scenes import (
36
+ SAFETY_CLASSES, SceneSequence, generate_sequences, gt_tracks,
37
+ )
38
+ from sensorflow.metrics.perception_3d import bev_iou
39
+ from sensorflow.metrics.temporal_mot import (
40
+ compute_id_swap_rate, compute_track_fragmentation_rate,
41
+ )
42
+
43
+ RUNS_DIR = Path("runs/bevfusion")
44
+ MATCH_DIST_M = 2.0
45
+
46
+ DEFAULT_POLICY = {
47
+ "max_recall_drop": 0.010,
48
+ "max_precision_drop": 0.015,
49
+ "max_safety_recall_drop": 0.005,
50
+ "max_cohort_recall_drop": 0.050,
51
+ "min_cohort_support": 20,
52
+ }
53
+
54
+ COHORT_EXPLANATIONS = {
55
+ "overall": "Fused existence evidence (noisy-OR of camera and LiDAR) plus "
56
+ "inverse-variance geometry gives higher recall and near-LiDAR "
57
+ "position accuracy wherever either sensor fires.",
58
+ "day": "Both sensors healthy: fusion mainly sharpens positions "
59
+ "(LiDAR-dominated inverse-variance weighting) and suppresses "
60
+ "single-modality ghosts at the decode threshold.",
61
+ "night": "The camera misses heavily in low light; LiDAR is unaffected by "
62
+ "darkness, so LiDAR existence evidence in the BEV grid recovers "
63
+ "the missed objects and the class comes from LiDAR geometry "
64
+ "templates or temporal continuity.",
65
+ "clear": "Nominal conditions; improvement is dominated by monocular depth "
66
+ "error being replaced by LiDAR geometry.",
67
+ "rain": "Rain degrades LiDAR at range (spray attenuation) and the camera "
68
+ "mildly; each modality covers part of the other's losses, so the "
69
+ "fused map keeps existence evidence where either survives.",
70
+ "occluded": "Camera line of sight is blocked; LiDAR (mounted higher, "
71
+ "sensing 3D structure) keeps partial returns, and masklet "
72
+ "propagation carries identity and a predicted box through the "
73
+ "remaining gap frames.",
74
+ "long_range": "Beyond ~45 m LiDAR thins out and monocular depth error "
75
+ "exceeds the match gate; fusion helps where LiDAR still "
76
+ "fires, and camera bearing keeps existence alive elsewhere.",
77
+ }
78
+
79
+
80
+ # ------------------------------------------------------------------ matching
81
+
82
+
83
+ def _match_frame(pred_boxes: List[Dict], gt_boxes: List, dist_m: float = MATCH_DIST_M):
84
+ """Greedy center-distance matching. Returns (pairs, unmatched_pred_idx)."""
85
+ cands = []
86
+ for pi, p in enumerate(pred_boxes):
87
+ for gi, g in enumerate(gt_boxes):
88
+ d = math.hypot(p["bbox_3d"][0] - g.bbox_3d[0], p["bbox_3d"][1] - g.bbox_3d[1])
89
+ if d <= dist_m:
90
+ cands.append((d, pi, gi))
91
+ cands.sort()
92
+ used_p, used_g, pairs = set(), set(), []
93
+ for d, pi, gi in cands:
94
+ if pi in used_p or gi in used_g:
95
+ continue
96
+ pairs.append((pi, gi, d))
97
+ used_p.add(pi)
98
+ used_g.add(gi)
99
+ unmatched_pred = [i for i in range(len(pred_boxes)) if i not in used_p]
100
+ return pairs, unmatched_pred
101
+
102
+
103
+ def _gt_cohorts(gt, seq: SceneSequence) -> List[str]:
104
+ cohorts = ["overall", seq.time_of_day, seq.weather]
105
+ if gt.occluded:
106
+ cohorts.append("occluded")
107
+ if gt.distance > 40.0:
108
+ cohorts.append("long_range")
109
+ return cohorts
110
+
111
+
112
+ # ------------------------------------------------------------------ IDF1
113
+
114
+
115
+ def compute_idf1(pred_tracks: List[Dict], gt_tracks_list: List[Dict],
116
+ dist_m: float = MATCH_DIST_M) -> float:
117
+ """IDF1 via optimal 1:1 track assignment (Hungarian on frame overlaps)."""
118
+ if not pred_tracks or not gt_tracks_list:
119
+ return 0.0
120
+ n_pred = sum(len(t["frames"]) for t in pred_tracks)
121
+ n_gt = sum(len(t["frames"]) for t in gt_tracks_list)
122
+ overlap = np.zeros((len(pred_tracks), len(gt_tracks_list)))
123
+ for i, pt in enumerate(pred_tracks):
124
+ pframes = {f["frame_id"]: f["bbox_3d"] for f in pt["frames"]}
125
+ for j, gt in enumerate(gt_tracks_list):
126
+ n = 0
127
+ for f in gt["frames"]:
128
+ pb = pframes.get(f["frame_id"])
129
+ if pb is not None and math.hypot(pb[0] - f["bbox_3d"][0],
130
+ pb[1] - f["bbox_3d"][1]) <= dist_m:
131
+ n += 1
132
+ overlap[i, j] = n
133
+ rows, cols = linear_sum_assignment(-overlap)
134
+ idtp = float(overlap[rows, cols].sum())
135
+ return round(2.0 * idtp / max(n_pred + n_gt, 1), 4)
136
+
137
+
138
+ # ------------------------------------------------------------------ engine evaluation
139
+
140
+
141
+ def evaluate_engine(sequences: List[SceneSequence],
142
+ preds_by_seq: Dict[str, Dict[str, List[Dict]]]) -> Dict:
143
+ """Evaluate one engine's per-frame labels against ground truth."""
144
+ cohort_stats: Dict[str, Dict] = {}
145
+ class_stats: Dict[str, Dict] = {}
146
+ tp = fp = fn = 0
147
+ ious: List[float] = []
148
+ pos_errors: List[float] = []
149
+ class_correct = class_total = 0
150
+ safety_tp = safety_fn = 0
151
+
152
+ def _coh(name):
153
+ return cohort_stats.setdefault(name, {"n_gt": 0, "matched": 0,
154
+ "pos_errors": [], "ious": [], "fp": 0})
155
+
156
+ for seq in sequences:
157
+ preds = preds_by_seq[seq.sequence_id]
158
+ for frame in seq.frames:
159
+ pred_boxes = preds.get(frame.frame_id, [])
160
+ pairs, unmatched_pred = _match_frame(pred_boxes, frame.gt)
161
+ matched_gt = {gi for _, gi, _ in pairs}
162
+
163
+ for pi, gi, d in pairs:
164
+ p, g = pred_boxes[pi], frame.gt[gi]
165
+ tp += 1
166
+ iou = bev_iou(p["bbox_3d"], g.bbox_3d)
167
+ ious.append(iou)
168
+ pos_errors.append(d)
169
+ class_total += 1
170
+ if p["class_name"] == g.class_name:
171
+ class_correct += 1
172
+ if g.class_name in SAFETY_CLASSES:
173
+ safety_tp += 1
174
+ cs = class_stats.setdefault(g.class_name, {"n_gt": 0, "matched": 0})
175
+ cs["n_gt"] += 1
176
+ cs["matched"] += 1
177
+ for name in _gt_cohorts(g, seq):
178
+ c = _coh(name)
179
+ c["n_gt"] += 1
180
+ c["matched"] += 1
181
+ c["pos_errors"].append(d)
182
+ c["ious"].append(iou)
183
+
184
+ for gi, g in enumerate(frame.gt):
185
+ if gi in matched_gt:
186
+ continue
187
+ fn += 1
188
+ if g.class_name in SAFETY_CLASSES:
189
+ safety_fn += 1
190
+ class_stats.setdefault(g.class_name, {"n_gt": 0, "matched": 0})["n_gt"] += 1
191
+ for name in _gt_cohorts(g, seq):
192
+ _coh(name)["n_gt"] += 1
193
+
194
+ for pi in unmatched_pred:
195
+ fp += 1
196
+ _coh("overall")["fp"] += 1
197
+ _coh(seq.time_of_day)["fp"] += 1
198
+ _coh(seq.weather)["fp"] += 1
199
+
200
+ precision = tp / (tp + fp) if tp + fp else 0.0
201
+ recall = tp / (tp + fn) if tp + fn else 0.0
202
+ f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
203
+
204
+ # Tracking metrics via the platform's temporal MOT module + IDF1.
205
+ # Track ids restart per sequence (one tracker per sequence), so namespace
206
+ # them with the sequence id before pooling.
207
+ pred_tracks: List[Dict] = []
208
+ for seq in sequences:
209
+ for t in tracks_to_dicts(preds_by_seq[seq.sequence_id]):
210
+ t["track_id"] = f"{seq.sequence_id}:{t['track_id']}"
211
+ pred_tracks.append(t)
212
+ gts = gt_tracks(sequences)
213
+ id_switch_rate = compute_id_swap_rate(pred_tracks, gts)
214
+ frag_rate = compute_track_fragmentation_rate(pred_tracks, gts)
215
+ idf1 = compute_idf1(pred_tracks, gts)
216
+
217
+ cohorts = {}
218
+ for name, c in cohort_stats.items():
219
+ cohorts[name] = {
220
+ "n_gt": c["n_gt"],
221
+ "recall": round(c["matched"] / c["n_gt"], 4) if c["n_gt"] else None,
222
+ "position_error_m": round(float(np.mean(c["pos_errors"])), 4) if c["pos_errors"] else None,
223
+ "mean_iou": round(float(np.mean(c["ious"])), 4) if c["ious"] else None,
224
+ "false_positives": c["fp"],
225
+ }
226
+ per_class = {
227
+ cls: {"n_gt": s["n_gt"],
228
+ "recall": round(s["matched"] / s["n_gt"], 4) if s["n_gt"] else None}
229
+ for cls, s in sorted(class_stats.items())
230
+ }
231
+
232
+ return {
233
+ "headline": {
234
+ "precision": round(precision, 4),
235
+ "recall": round(recall, 4),
236
+ "f1": round(f1, 4),
237
+ "mean_iou": round(float(np.mean(ious)), 4) if ious else 0.0,
238
+ "position_error_m": round(float(np.mean(pos_errors)), 4) if pos_errors else None,
239
+ "class_accuracy": round(class_correct / class_total, 4) if class_total else None,
240
+ "safety_recall": round(safety_tp / (safety_tp + safety_fn), 4)
241
+ if safety_tp + safety_fn else None,
242
+ "id_switch_rate": round(id_switch_rate, 4),
243
+ "fragmentation_rate": round(frag_rate, 4),
244
+ "idf1": idf1,
245
+ },
246
+ "counts": {"tp": tp, "fp": fp, "fn": fn,
247
+ "pred_tracks": len(pred_tracks), "gt_tracks": len(gts)},
248
+ "cohorts": cohorts,
249
+ "per_class": per_class,
250
+ }
251
+
252
+
253
+ # ------------------------------------------------------------------ comparison report
254
+
255
+ # Metrics where lower is better.
256
+ _LOWER_IS_BETTER = {"position_error_m", "id_switch_rate", "fragmentation_rate"}
257
+ _HEADLINE_ORDER = ["precision", "recall", "f1", "mean_iou", "position_error_m",
258
+ "class_accuracy", "safety_recall", "id_switch_rate",
259
+ "fragmentation_rate", "idf1"]
260
+
261
+
262
+ def build_comparison(base_eval: Dict, cand_eval: Dict,
263
+ policy: Optional[Dict] = None) -> Dict:
264
+ """Comparison report mirroring the megaeval compare shape
265
+ (headline_deltas / per-cohort / policy / blockers / recommendation)."""
266
+ policy = {**DEFAULT_POLICY, **(policy or {})}
267
+ bh, ch = base_eval["headline"], cand_eval["headline"]
268
+
269
+ headline_deltas = []
270
+ for m in _HEADLINE_ORDER:
271
+ b, c = bh.get(m), ch.get(m)
272
+ if b is None or c is None:
273
+ continue
274
+ delta = round(c - b, 4)
275
+ headline_deltas.append({
276
+ "metric": m, "baseline": b, "candidate": c, "delta": delta,
277
+ "improved": (delta < 0) if m in _LOWER_IS_BETTER else (delta > 0),
278
+ })
279
+
280
+ per_cohort = []
281
+ for name, b in base_eval["cohorts"].items():
282
+ c = cand_eval["cohorts"].get(name)
283
+ if not c or b["recall"] is None or c["recall"] is None:
284
+ continue
285
+ per_cohort.append({
286
+ "cohort": name,
287
+ "n_gt": c["n_gt"],
288
+ "recall_baseline": b["recall"], "recall_candidate": c["recall"],
289
+ "recall_delta": round(c["recall"] - b["recall"], 4),
290
+ "position_error_baseline_m": b["position_error_m"],
291
+ "position_error_candidate_m": c["position_error_m"],
292
+ "mean_iou_baseline": b["mean_iou"], "mean_iou_candidate": c["mean_iou"],
293
+ "explanation": COHORT_EXPLANATIONS.get(name, ""),
294
+ })
295
+ per_cohort.sort(key=lambda x: x["recall_delta"], reverse=True)
296
+
297
+ per_class = []
298
+ for cls, b in base_eval["per_class"].items():
299
+ c = cand_eval["per_class"].get(cls)
300
+ if not c or b["recall"] is None or c["recall"] is None:
301
+ continue
302
+ per_class.append({"class": cls, "n_gt": c["n_gt"],
303
+ "recall_baseline": b["recall"],
304
+ "recall_candidate": c["recall"],
305
+ "recall_delta": round(c["recall"] - b["recall"], 4)})
306
+
307
+ hd = {h["metric"]: h["delta"] for h in headline_deltas}
308
+ blockers: List[str] = []
309
+ if hd.get("recall", 0) < -policy["max_recall_drop"]:
310
+ blockers.append(f"headline recall dropped {hd['recall']:+.4f}")
311
+ if hd.get("precision", 0) < -policy["max_precision_drop"]:
312
+ blockers.append(f"headline precision dropped {hd['precision']:+.4f}")
313
+ if hd.get("safety_recall", 0) < -policy["max_safety_recall_drop"]:
314
+ blockers.append(f"safety recall dropped {hd['safety_recall']:+.4f}")
315
+ for c in per_cohort:
316
+ if c["n_gt"] >= policy["min_cohort_support"] and \
317
+ c["recall_delta"] < -policy["max_cohort_recall_drop"]:
318
+ blockers.append(
319
+ f"REGRESSION: cohort {c['cohort']} recall "
320
+ f"{c['recall_baseline']*100:.1f}% -> {c['recall_candidate']*100:.1f}%")
321
+
322
+ improvements = [f"{h['metric']}: {h['baseline']} -> {h['candidate']} "
323
+ f"({h['delta']:+.4f})" for h in headline_deltas if h["improved"]]
324
+
325
+ return {
326
+ "headline_deltas": headline_deltas,
327
+ "per_cohort": per_cohort,
328
+ "per_class": per_class,
329
+ "policy": policy,
330
+ "blockers": blockers,
331
+ "improvements": improvements,
332
+ "recommendation": "DO_NOT_PROMOTE" if blockers else "PROMOTE",
333
+ }
334
+
335
+
336
+ # ------------------------------------------------------------------ orchestration
337
+
338
+
339
+ def run_comparison(n_sequences: int = 6, frames_per_sequence: int = 24,
340
+ seed: int = 7, out_dir: Optional[Path] = None,
341
+ persist: bool = True) -> Dict:
342
+ """Generate scenes, run both engines, evaluate both, build + persist report."""
343
+ sequences = generate_sequences(n_sequences=n_sequences,
344
+ frames_per_sequence=frames_per_sequence, seed=seed)
345
+
346
+ baseline_preds: Dict[str, Dict[str, List[Dict]]] = {}
347
+ fused_preds: Dict[str, Dict[str, List[Dict]]] = {}
348
+ fusion_stats = {"camera_detections": 0, "lidar_detections": 0, "fused_boxes": 0}
349
+ for qi, seq in enumerate(sequences):
350
+ baseline_preds[seq.sequence_id] = run_baseline(seq, seed, qi)
351
+ fused, stats = run_fused(seq, seed, qi)
352
+ fused_preds[seq.sequence_id] = fused
353
+ for k in fusion_stats:
354
+ fusion_stats[k] += stats[k]
355
+
356
+ base_eval = evaluate_engine(sequences, baseline_preds)
357
+ cand_eval = evaluate_engine(sequences, fused_preds)
358
+ comparison = build_comparison(base_eval, cand_eval)
359
+
360
+ n_frames = sum(len(s.frames) for s in sequences)
361
+ n_gt = sum(len(f.gt) for s in sequences for f in s.frames)
362
+ process_units = _record_process_units(fusion_stats, n_frames, n_gt)
363
+
364
+ report = {
365
+ "run_id": f"bevrun-{uuid4().hex[:10]}",
366
+ "created_at": datetime.now(timezone.utc).isoformat(),
367
+ "params": {"n_sequences": n_sequences,
368
+ "frames_per_sequence": frames_per_sequence, "seed": seed},
369
+ "engines": {"baseline": BASELINE_ENGINE, "candidate": FUSED_ENGINE},
370
+ "scale": {"frames": n_frames, "gt_boxes": n_gt, **fusion_stats},
371
+ "baseline": base_eval,
372
+ "candidate": cand_eval,
373
+ **comparison,
374
+ "process_units": process_units,
375
+ "notes": (
376
+ "Honest simulation: no learned features; detections are sampled from "
377
+ "ground truth with modality-specific failure models (camera: occlusion/"
378
+ "night misses + monocular depth ambiguity, good semantics; LiDAR: "
379
+ "accurate geometry, range/rain degradation, weak semantics). The "
380
+ "improvement arises at runtime from geometric+probabilistic fusion in "
381
+ "the BEV grid and masklet propagation — sensor complementarity is real."
382
+ ),
383
+ }
384
+
385
+ if persist:
386
+ out = Path(out_dir) if out_dir else RUNS_DIR
387
+ out.mkdir(parents=True, exist_ok=True)
388
+ with open(out / f"{report['run_id']}.json", "w") as f:
389
+ json.dump(report, f, indent=2)
390
+ with open(out / "latest.json", "w") as f:
391
+ json.dump(report, f, indent=2)
392
+ return report
393
+
394
+
395
+ def _record_process_units(fusion_stats: Dict, n_frames: int, n_gt: int) -> Dict:
396
+ """Record fusion-stage process units via the platform's accounting
397
+ (best effort: skipped silently if the evaluation store is unavailable)."""
398
+ units = {}
399
+ try:
400
+ from sensorflow.evaluation.process_units import ProcessMeter
401
+ from sensorflow.evaluation.records import get_store
402
+ meter = ProcessMeter(get_store(), run_id="bevfusion")
403
+ units["bev_camera_projection"] = meter.record(
404
+ "bev_camera_projection", fusion_stats["camera_detections"], factor=0.2)
405
+ units["bev_lidar_rasterization"] = meter.record(
406
+ "bev_lidar_rasterization", fusion_stats["lidar_detections"], factor=0.2)
407
+ units["bev_fusion"] = meter.record("bev_fusion", n_frames, factor=0.8)
408
+ units["masklet_tracking"] = meter.record(
409
+ "masklet_tracking", fusion_stats["fused_boxes"], factor=0.3)
410
+ units["label_evaluation"] = meter.record("label_evaluation", n_gt, factor=0.5)
411
+ units["total"] = sum(units.values())
412
+ except Exception:
413
+ units = {"total": 0, "note": "process accounting unavailable"}
414
+ return units
415
+
416
+
417
+ def latest_report(out_dir: Optional[Path] = None) -> Optional[Dict]:
418
+ path = (Path(out_dir) if out_dir else RUNS_DIR) / "latest.json"
419
+ if not path.exists():
420
+ return None
421
+ with open(path) as f:
422
+ return json.load(f)
sensorflow/bevfusion/fusion.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BEV rasterization, per-cell modality fusion, and box decoding.
2
+
3
+ This is the numpy analogue of BEVFusion's shared-BEV-space fusion:
4
+
5
+ 1. Each modality's detections are splatted into a metric BEV feature map
6
+ (:class:`ModalityBEV`) as Gaussian evidence. The splat covariance is the
7
+ detection's position covariance (floored at the cell size for existence so
8
+ tight LiDAR kernels are not lost between cell centers). Three kinds of
9
+ evidence are accumulated per cell:
10
+
11
+ - existence: noisy-OR of (confidence x kernel) — "is something here?";
12
+ - geometry: inverse-variance weighted votes for the detection's continuous
13
+ mean/dims/yaw (weight = confidence x kernel / worst-axis variance), so
14
+ LiDAR dominates position wherever it has evidence and there is no grid
15
+ quantization in the decoded centers;
16
+ - class: reliability-weighted class histograms. Camera class evidence is
17
+ splatted with an inflated along-ray kernel: monocular semantics is
18
+ positionally vague along the ray, which is precisely what lets a camera
19
+ label flow to the LiDAR-anchored cell of the same object even when the
20
+ camera's depth estimate is metres off.
21
+
22
+ 2. :func:`fuse_maps` fuses per cell: noisy-OR for existence, summed
23
+ inverse-variance geometry accumulators, summed weighted class histograms
24
+ (camera weight 1.0 vs LiDAR 0.35 — camera is the semantics expert).
25
+
26
+ 3. :func:`decode_bev` extracts local existence maxima above a threshold,
27
+ applies euclidean NMS plus ray-aware NMS (two peaks on the same bearing
28
+ within the monocular depth-ambiguity band are one object — the fused,
29
+ LiDAR-anchored peak wins over the camera's displaced ghost), then decodes
30
+ each surviving peak's neighbourhood into a 3D box with class + confidence.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import math
36
+ from dataclasses import dataclass, field
37
+ from typing import Dict, List, Optional
38
+
39
+ import numpy as np
40
+ from scipy.ndimage import maximum_filter
41
+
42
+ from sensorflow.bevfusion.geometry import BEVGrid
43
+ from sensorflow.bevfusion.scenes import CLASSES
44
+ from sensorflow.bevfusion.sensors import Detection
45
+
46
+ CLASS_INDEX = {c: i for i, c in enumerate(CLASSES)}
47
+
48
+ # Modality reliability weights for class evidence (camera >> lidar).
49
+ CLASS_WEIGHT = {"camera": 1.0, "lidar": 0.35}
50
+ # Camera class evidence is smeared further along the ray (see module docstring).
51
+ CAMERA_CLASS_SIGMA_SCALE = 2.0
52
+
53
+
54
+ @dataclass
55
+ class ModalityBEV:
56
+ """Per-modality BEV feature map built by splatting detections."""
57
+
58
+ grid: BEVGrid
59
+ modality: str = "camera"
60
+ miss: np.ndarray = field(default=None) # prod(1 - c*g): existence complement
61
+ sum_w: np.ndarray = field(default=None)
62
+ sum_wx: np.ndarray = field(default=None)
63
+ sum_wy: np.ndarray = field(default=None)
64
+ sum_wz: np.ndarray = field(default=None)
65
+ sum_wl: np.ndarray = field(default=None)
66
+ sum_ww: np.ndarray = field(default=None)
67
+ sum_wh: np.ndarray = field(default=None)
68
+ sum_wsin: np.ndarray = field(default=None)
69
+ sum_wcos: np.ndarray = field(default=None)
70
+ class_hist: np.ndarray = field(default=None) # (nx, ny, n_classes)
71
+
72
+ def __post_init__(self):
73
+ nx, ny = self.grid.nx, self.grid.ny
74
+ self.miss = np.ones((nx, ny))
75
+ for name in ("sum_w", "sum_wx", "sum_wy", "sum_wz", "sum_wl", "sum_ww",
76
+ "sum_wh", "sum_wsin", "sum_wcos"):
77
+ setattr(self, name, np.zeros((nx, ny)))
78
+ self.class_hist = np.zeros((nx, ny, len(CLASSES)))
79
+
80
+ @property
81
+ def existence(self) -> np.ndarray:
82
+ return 1.0 - self.miss
83
+
84
+ # -- splatting
85
+
86
+ def _window(self, mx: float, my: float, ex: float, ey: float):
87
+ g = self.grid
88
+ ix0 = max(0, int((mx - ex - g.x_min) / g.cell))
89
+ ix1 = min(g.nx, int((mx + ex - g.x_min) / g.cell) + 1)
90
+ iy0 = max(0, int((my - ey - g.y_min) / g.cell))
91
+ iy1 = min(g.ny, int((my + ey - g.y_min) / g.cell) + 1)
92
+ if ix0 >= ix1 or iy0 >= iy1:
93
+ return None
94
+ xs = g.x_min + (np.arange(ix0, ix1) + 0.5) * g.cell
95
+ ys = g.y_min + (np.arange(iy0, iy1) + 0.5) * g.cell
96
+ X, Y = np.meshgrid(xs, ys, indexing="ij")
97
+ return (slice(ix0, ix1), slice(iy0, iy1)), X - mx, Y - my
98
+
99
+ @staticmethod
100
+ def _kernel(dx: np.ndarray, dy: np.ndarray, cov: np.ndarray) -> np.ndarray:
101
+ inv = np.linalg.inv(cov)
102
+ q = inv[0, 0] * dx * dx + (inv[0, 1] + inv[1, 0]) * dx * dy + inv[1, 1] * dy * dy
103
+ return np.exp(-0.5 * q)
104
+
105
+ def splat(self, det: Detection) -> None:
106
+ cov = np.array(det.cov, dtype=float)
107
+ # Floor the existence/geometry kernel at ~the cell size so tight LiDAR
108
+ # Gaussians are not lost between cell centers (grid smoothing only:
109
+ # geometry votes still use the true covariance for their weights).
110
+ floor = (0.8 * self.grid.cell) ** 2
111
+ cov_splat = cov + np.eye(2) * floor
112
+ ex = 3.0 * math.sqrt(cov_splat[0, 0])
113
+ ey = 3.0 * math.sqrt(cov_splat[1, 1])
114
+ win = self._window(det.x, det.y, ex, ey)
115
+ if win is None:
116
+ return
117
+ sl, dx, dy = win
118
+ g = self._kernel(dx, dy, cov_splat)
119
+
120
+ # Existence: noisy-OR accumulation.
121
+ self.miss[sl] *= (1.0 - det.confidence * g)
122
+
123
+ # Geometry: inverse-variance weighted votes for the *continuous* mean.
124
+ # The kernel is normalized to unit mass so each detection contributes
125
+ # exactly one measurement of total weight conf/sigma_eff^2 no matter
126
+ # how widely its kernel is spread — proper inverse-variance fusion.
127
+ eigvals = np.linalg.eigvalsh(cov)
128
+ sigma_eff_sq = float(max(eigvals[-1], 1e-4)) # worst-axis variance
129
+ g_mass = float(g.sum())
130
+ if g_mass <= 1e-12:
131
+ return
132
+ w = det.confidence * (g / g_mass) / sigma_eff_sq
133
+ self.sum_w[sl] += w
134
+ self.sum_wx[sl] += w * det.x
135
+ self.sum_wy[sl] += w * det.y
136
+ self.sum_wz[sl] += w * det.z
137
+ self.sum_wl[sl] += w * det.dims[0]
138
+ self.sum_ww[sl] += w * det.dims[1]
139
+ self.sum_wh[sl] += w * det.dims[2]
140
+ self.sum_wsin[sl] += w * math.sin(det.yaw)
141
+ self.sum_wcos[sl] += w * math.cos(det.yaw)
142
+
143
+ # Class: reliability-weighted histogram; camera semantics smeared
144
+ # further along the ray so labels reach the LiDAR-anchored cell.
145
+ cls_idx = CLASS_INDEX.get(det.class_name)
146
+ if cls_idx is None:
147
+ return
148
+ if self.modality == "camera":
149
+ cov_cls = cov * (CAMERA_CLASS_SIGMA_SCALE ** 2) + np.eye(2) * floor
150
+ else:
151
+ cov_cls = cov * 4.0 + np.eye(2) * floor
152
+ exc = 3.0 * math.sqrt(cov_cls[0, 0])
153
+ eyc = 3.0 * math.sqrt(cov_cls[1, 1])
154
+ winc = self._window(det.x, det.y, exc, eyc)
155
+ if winc is None:
156
+ return
157
+ slc, dxc, dyc = winc
158
+ gc = self._kernel(dxc, dyc, cov_cls)
159
+ wc = det.confidence * det.class_confidence * CLASS_WEIGHT.get(self.modality, 0.5)
160
+ self.class_hist[slc[0], slc[1], cls_idx] += wc * gc
161
+
162
+
163
+ def build_modality_map(dets: List[Detection], grid: BEVGrid, modality: str) -> ModalityBEV:
164
+ bev = ModalityBEV(grid=grid, modality=modality)
165
+ for det in dets:
166
+ if det.modality == modality:
167
+ bev.splat(det)
168
+ return bev
169
+
170
+
171
+ @dataclass
172
+ class FusedBEV:
173
+ grid: BEVGrid
174
+ existence: np.ndarray
175
+ sum_w: np.ndarray
176
+ sum_wx: np.ndarray
177
+ sum_wy: np.ndarray
178
+ sum_wz: np.ndarray
179
+ sum_wl: np.ndarray
180
+ sum_ww: np.ndarray
181
+ sum_wh: np.ndarray
182
+ sum_wsin: np.ndarray
183
+ sum_wcos: np.ndarray
184
+ class_hist: np.ndarray
185
+
186
+
187
+ def fuse_maps(cam: ModalityBEV, lidar: ModalityBEV) -> FusedBEV:
188
+ """Per-cell confidence-weighted fusion of the two modality feature maps."""
189
+ return FusedBEV(
190
+ grid=cam.grid,
191
+ existence=1.0 - cam.miss * lidar.miss, # noisy-OR across modalities
192
+ sum_w=cam.sum_w + lidar.sum_w,
193
+ sum_wx=cam.sum_wx + lidar.sum_wx,
194
+ sum_wy=cam.sum_wy + lidar.sum_wy,
195
+ sum_wz=cam.sum_wz + lidar.sum_wz,
196
+ sum_wl=cam.sum_wl + lidar.sum_wl,
197
+ sum_ww=cam.sum_ww + lidar.sum_ww,
198
+ sum_wh=cam.sum_wh + lidar.sum_wh,
199
+ sum_wsin=cam.sum_wsin + lidar.sum_wsin,
200
+ sum_wcos=cam.sum_wcos + lidar.sum_wcos,
201
+ class_hist=cam.class_hist + lidar.class_hist,
202
+ )
203
+
204
+
205
+ def decode_bev(fused: FusedBEV, threshold: float = 0.30,
206
+ nms_radius_m: float = 2.0, gather_radius_m: float = 2.0,
207
+ ray_nms_bearing_deg: float = 1.5,
208
+ ray_nms_range_m: float = 6.0) -> List[Dict]:
209
+ """Decode fused BEV cells into 3D boxes with class + confidence."""
210
+ grid = fused.grid
211
+ e = fused.existence
212
+ local_max = (e >= maximum_filter(e, size=5)) & (e >= threshold)
213
+ ixs, iys = np.nonzero(local_max)
214
+ if ixs.size == 0:
215
+ return []
216
+ order = np.argsort(-e[ixs, iys])
217
+ peaks = [(int(ixs[i]), int(iys[i])) for i in order]
218
+
219
+ accepted: List[Dict] = []
220
+ gather_cells = max(1, int(round(gather_radius_m / grid.cell)))
221
+ for ix, iy in peaks:
222
+ cx, cy = grid.center(ix, iy)
223
+ suppress = False
224
+ for box in accepted:
225
+ bx, by = box["_peak_xy"]
226
+ if math.hypot(cx - bx, cy - by) < nms_radius_m:
227
+ suppress = True
228
+ break
229
+ # Ray-aware NMS: same bearing, within the monocular depth
230
+ # ambiguity band -> the weaker peak is the camera's range ghost.
231
+ db = abs(math.atan2(cy, cx) - math.atan2(by, bx))
232
+ db = min(db, 2 * math.pi - db)
233
+ if math.degrees(db) < ray_nms_bearing_deg and \
234
+ abs(math.hypot(cx, cy) - math.hypot(bx, by)) < ray_nms_range_m:
235
+ suppress = True
236
+ break
237
+ if suppress:
238
+ continue
239
+
240
+ x0, x1 = max(0, ix - gather_cells), min(grid.nx, ix + gather_cells + 1)
241
+ y0, y1 = max(0, iy - gather_cells), min(grid.ny, iy + gather_cells + 1)
242
+ sl = (slice(x0, x1), slice(y0, y1))
243
+ w = float(fused.sum_w[sl].sum())
244
+ if w <= 1e-9:
245
+ continue
246
+ x = float(fused.sum_wx[sl].sum() / w)
247
+ y = float(fused.sum_wy[sl].sum() / w)
248
+ z = float(fused.sum_wz[sl].sum() / w)
249
+ l = float(fused.sum_wl[sl].sum() / w)
250
+ wd = float(fused.sum_ww[sl].sum() / w)
251
+ h = float(fused.sum_wh[sl].sum() / w)
252
+ yaw = float(math.atan2(fused.sum_wsin[sl].sum(), fused.sum_wcos[sl].sum()))
253
+ hist = fused.class_hist[sl].sum(axis=(0, 1))
254
+ hist_total = float(hist.sum())
255
+ cls_idx = int(np.argmax(hist)) if hist_total > 0 else 0
256
+ accepted.append({
257
+ "bbox_3d": [x, y, z, l, wd, h, yaw],
258
+ "class_name": CLASSES[cls_idx],
259
+ "class_confidence": round(float(hist[cls_idx] / hist_total), 4) if hist_total > 0 else 0.0,
260
+ "confidence": round(float(e[ix, iy]), 4),
261
+ "_peak_xy": (cx, cy),
262
+ })
263
+
264
+ for box in accepted:
265
+ box.pop("_peak_xy", None)
266
+ return accepted
267
+
268
+
269
+ def fuse_frame(camera_dets: List[Detection], lidar_dets: List[Detection],
270
+ grid: Optional[BEVGrid] = None, threshold: float = 0.30) -> List[Dict]:
271
+ """Convenience: full rasterize -> fuse -> decode for one frame."""
272
+ grid = grid or BEVGrid()
273
+ cam_map = build_modality_map(camera_dets, grid, "camera")
274
+ lidar_map = build_modality_map(lidar_dets, grid, "lidar")
275
+ return decode_bev(fuse_maps(cam_map, lidar_map), threshold=threshold)
sensorflow/bevfusion/geometry.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibrated camera geometry + metric BEV grid.
2
+
3
+ Coordinate conventions (matching the rest of the platform, e.g.
4
+ sensorflow/evaluation/synthetic.py): ego frame has x forward (m), y left (m),
5
+ z up (m), ground plane at z=0.
6
+
7
+ The camera uses the standard optical convention (z forward, x right, y down);
8
+ `make_camera_extrinsic` builds the 6-DoF camera-to-ego transform, mirroring
9
+ the Calibration.camera_to_ego 4x4 matrices in
10
+ sensorflow/schemas/unified_frame.py.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from dataclasses import dataclass
17
+ from typing import Optional, Tuple
18
+
19
+ import numpy as np
20
+
21
+ # Default intrinsics, consistent with the existing synthetic generator
22
+ # (sensorflow/evaluation/synthetic.py: CAM_W, CAM_H, CAM_F).
23
+ CAM_W, CAM_H, CAM_F = 800, 450, 500.0
24
+
25
+
26
+ def make_intrinsics(fx: float = CAM_F, fy: float = CAM_F,
27
+ cx: float = CAM_W / 2, cy: float = CAM_H / 2) -> np.ndarray:
28
+ return np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]])
29
+
30
+
31
+ def make_camera_extrinsic(tx: float = 0.2, ty: float = 0.0, tz: float = 1.5,
32
+ yaw: float = 0.0) -> np.ndarray:
33
+ """4x4 camera-to-ego transform for a forward-looking camera.
34
+
35
+ Camera axes expressed in the ego frame (before yaw): optical axis
36
+ z_cam -> +x_ego, x_cam (image right) -> -y_ego, y_cam (image down) -> -z_ego.
37
+ """
38
+ base = np.array([
39
+ [0.0, 0.0, 1.0], # ego x row: from cam z
40
+ [-1.0, 0.0, 0.0], # ego y row: from -cam x
41
+ [0.0, -1.0, 0.0], # ego z row: from -cam y
42
+ ])
43
+ c, s = math.cos(yaw), math.sin(yaw)
44
+ rz = np.array([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]])
45
+ T = np.eye(4)
46
+ T[:3, :3] = rz @ base
47
+ T[:3, 3] = [tx, ty, tz]
48
+ return T
49
+
50
+
51
+ def project_to_image(point_ego: np.ndarray, cam_to_ego: np.ndarray,
52
+ K: np.ndarray) -> Optional[Tuple[float, float, float]]:
53
+ """Ego-frame point -> (u, v, range). None if behind the camera."""
54
+ R, t = cam_to_ego[:3, :3], cam_to_ego[:3, 3]
55
+ p_cam = R.T @ (np.asarray(point_ego, dtype=float) - t)
56
+ if p_cam[2] < 0.5: # behind or too close to the optical center
57
+ return None
58
+ u = K[0, 0] * p_cam[0] / p_cam[2] + K[0, 2]
59
+ v = K[1, 1] * p_cam[1] / p_cam[2] + K[1, 2]
60
+ rng = float(np.linalg.norm(p_cam))
61
+ return float(u), float(v), rng
62
+
63
+
64
+ def backproject(u: float, v: float, range_est: float, cam_to_ego: np.ndarray,
65
+ K: np.ndarray) -> np.ndarray:
66
+ """Pixel + estimated range along the viewing ray -> ego-frame 3D point.
67
+
68
+ This is the monocular "lift" step: the bearing (pixel) is precise, the
69
+ range is whatever the depth estimate says — hence the along-ray
70
+ uncertainty that the BEV fusion exploits.
71
+ """
72
+ d_cam = np.array([(u - K[0, 2]) / K[0, 0], (v - K[1, 2]) / K[1, 1], 1.0])
73
+ d_cam /= np.linalg.norm(d_cam)
74
+ R, t = cam_to_ego[:3, :3], cam_to_ego[:3, 3]
75
+ return R @ (d_cam * range_est) + t
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class BEVGrid:
80
+ """Metric BEV grid over the ego frame (x forward, y left)."""
81
+
82
+ x_min: float = 0.0
83
+ x_max: float = 80.0
84
+ y_min: float = -25.0
85
+ y_max: float = 25.0
86
+ cell: float = 0.5
87
+
88
+ @property
89
+ def nx(self) -> int:
90
+ return int(round((self.x_max - self.x_min) / self.cell))
91
+
92
+ @property
93
+ def ny(self) -> int:
94
+ return int(round((self.y_max - self.y_min) / self.cell))
95
+
96
+ def index(self, x: float, y: float) -> Optional[Tuple[int, int]]:
97
+ ix = int((x - self.x_min) / self.cell)
98
+ iy = int((y - self.y_min) / self.cell)
99
+ if 0 <= ix < self.nx and 0 <= iy < self.ny:
100
+ return ix, iy
101
+ return None
102
+
103
+ def center(self, ix: int, iy: int) -> Tuple[float, float]:
104
+ return (self.x_min + (ix + 0.5) * self.cell,
105
+ self.y_min + (iy + 0.5) * self.cell)
106
+
107
+ def contains(self, x: float, y: float) -> bool:
108
+ return self.x_min <= x < self.x_max and self.y_min <= y < self.y_max
sensorflow/bevfusion/masklet.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Masklet-style temporal propagation with BEV association.
2
+
3
+ Extends the platform's :class:`sensorflow.temporal_tracker.TemporalTracker`
4
+ (Kalman + Hungarian) with the two ideas this feature needs, mapping to SAM 3's
5
+ masklet propagation:
6
+
7
+ - association happens in the stable metric BEV plane with a richer cost:
8
+ BEV center distance + class-mismatch penalty + size-difference penalty
9
+ (fused boxes have reliable LiDAR geometry, so size/class gating is safe);
10
+ - masklet propagation: a track whose object briefly disappears (e.g. a 2-3
11
+ frame camera occlusion that LiDAR also partially loses) keeps its identity
12
+ alive via the Kalman-predicted BEV position and *emits propagated boxes*
13
+ for the gap frames (confidence-decayed, flagged ``propagated``), so brief
14
+ dropouts are bridged without spawning new IDs and without fragmenting the
15
+ track — the direct analogue of a masklet being carried through frames where
16
+ the object is not re-detected.
17
+
18
+ The baseline camera labeler deliberately uses frame-to-frame greedy
19
+ association with no motion model and no gap tolerance (see engines.py), which
20
+ is what makes ID switches and fragmentation measurable.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ from typing import Dict, List, Tuple
27
+
28
+ import numpy as np
29
+
30
+ from sensorflow.schemas.unified_frame import Object3D
31
+ from sensorflow.temporal_tracker import TemporalTracker
32
+
33
+
34
+ class BEVMaskletTracker(TemporalTracker):
35
+ """BEV Hungarian association + masklet propagation across dropouts."""
36
+
37
+ def __init__(self, max_age: int = 6, distance_gate: float = 4.0,
38
+ propagate_max: int = 3, min_hits_to_propagate: int = 2,
39
+ class_penalty: float = 1.5, size_penalty: float = 0.3,
40
+ dt: float = 0.1,
41
+ bounds: Tuple[float, float, float, float] = (0.0, 80.0, -25.0, 25.0)):
42
+ super().__init__(max_age=max_age, distance_gate=distance_gate)
43
+ self.dt = dt
44
+ self.propagate_max = propagate_max
45
+ self.min_hits_to_propagate = min_hits_to_propagate
46
+ self.class_penalty = class_penalty
47
+ self.size_penalty = size_penalty
48
+ self.bounds = bounds
49
+
50
+ # -- association: BEV distance + class + size (replaces the parent cost)
51
+
52
+ def _associate(self, proposals: List[Object3D]) -> List[Tuple[int, int]]:
53
+ from scipy.optimize import linear_sum_assignment
54
+
55
+ track_ids = list(self.tracks.keys())
56
+ cost = np.full((len(track_ids), len(proposals)), 1e6)
57
+ for ti, tid in enumerate(track_ids):
58
+ track = self.tracks[tid]
59
+ last_dims = track.history[-1]["bbox_3d"][3:6] if track.history else [4.5, 1.9, 1.6]
60
+ for pi, prop in enumerate(proposals):
61
+ dist = math.hypot(track.state[0] - prop.bbox_3d[0],
62
+ track.state[1] - prop.bbox_3d[1])
63
+ if dist >= self.distance_gate:
64
+ continue
65
+ c = dist
66
+ if track.class_name != prop.class_name:
67
+ c += self.class_penalty
68
+ c += self.size_penalty * (abs(last_dims[0] - prop.bbox_3d[3])
69
+ + abs(last_dims[1] - prop.bbox_3d[4]))
70
+ cost[ti, pi] = c
71
+
72
+ row_ind, col_ind = linear_sum_assignment(cost)
73
+ return [(track_ids[r], c) for r, c in zip(row_ind, col_ind)
74
+ if cost[r, c] < 1e6]
75
+
76
+ # -- update with masklet propagation
77
+
78
+ def update(self, frame_id: str, proposals: List[Object3D]) -> List[Object3D]:
79
+ out = super().update(frame_id, proposals)
80
+ self.last_propagated: set = set()
81
+
82
+ # The parent Kalman observes position only, so its velocity estimate
83
+ # converges too slowly to bridge gaps for fast objects. Blend in a
84
+ # finite-difference velocity from the last two *real* measurements
85
+ # (SORT-style), which the constant-velocity prediction then carries
86
+ # through occlusion gaps.
87
+ for track in self.tracks.values():
88
+ if track.missed_frames != 0:
89
+ continue # not matched this frame
90
+ real_idx = [i for i, h in enumerate(track.history) if not h.get("propagated")]
91
+ if len(real_idx) >= 2:
92
+ i1, i0 = real_idx[-1], real_idx[-2]
93
+ span = max(i1 - i0, 1) # frames elapsed (propagated entries fill gaps)
94
+ dx = track.history[i1]["bbox_3d"][0] - track.history[i0]["bbox_3d"][0]
95
+ dy = track.history[i1]["bbox_3d"][1] - track.history[i0]["bbox_3d"][1]
96
+ v_meas = np.array([dx, dy]) / (span * self.dt)
97
+ track.state[2:4] = 0.5 * track.state[2:4] + 0.5 * v_meas
98
+
99
+ # Track-level class voting: identity persistence lets high-confidence
100
+ # camera semantics (or the modal LiDAR template guess) stabilize the
101
+ # per-frame class of every box the track emits.
102
+ if not hasattr(self, "_class_votes"):
103
+ self._class_votes: Dict[int, Dict[str, float]] = {}
104
+ for prop in out:
105
+ votes = self._class_votes.setdefault(prop.track_id, {})
106
+ votes[prop.class_name] = votes.get(prop.class_name, 0.0) + prop.confidence
107
+ best = max(votes, key=votes.get)
108
+ prop.class_name = best
109
+ if prop.track_id in self.tracks:
110
+ self.tracks[prop.track_id].class_name = best
111
+
112
+ for track in self.tracks.values():
113
+ if track.missed_frames == 0 or not track.history:
114
+ continue # matched (or spawned) this frame
115
+ if not (1 <= track.missed_frames <= self.propagate_max):
116
+ continue
117
+ real_hits = sum(1 for h in track.history if not h.get("propagated"))
118
+ if real_hits < self.min_hits_to_propagate:
119
+ continue
120
+ px, py = float(track.state[0]), float(track.state[1])
121
+ x0, x1, y0, y1 = self.bounds
122
+ if not (x0 <= px < x1 and y0 <= py < y1):
123
+ continue # object likely left the field of view: do not hallucinate
124
+ last_box = track.history[-1]["bbox_3d"]
125
+ bbox = [px, py, last_box[2], last_box[3], last_box[4], last_box[5], last_box[6]]
126
+ conf = round(track.confidence * (0.8 ** track.missed_frames), 4)
127
+ track.history.append({
128
+ "frame_id": frame_id, "bbox_3d": bbox,
129
+ "confidence": conf, "propagated": True,
130
+ })
131
+ prop = Object3D(bbox_3d=bbox, class_name=track.class_name,
132
+ confidence=conf, track_id=track.track_id)
133
+ self.last_propagated.add(track.track_id)
134
+ out.append(prop)
135
+ return out
136
+
137
+
138
+ def tracks_to_dicts(per_frame: Dict[str, List[Dict]]) -> List[Dict]:
139
+ """Group per-frame labeled boxes (with track_id) into MOT-shaped tracks."""
140
+ tracks: Dict[object, Dict] = {}
141
+ for frame_id in sorted(per_frame.keys()):
142
+ for box in per_frame[frame_id]:
143
+ tid = box.get("track_id")
144
+ if tid is None:
145
+ continue
146
+ entry = tracks.setdefault(tid, {"track_id": tid,
147
+ "class_name": box.get("class_name", "unknown"),
148
+ "frames": []})
149
+ entry["frames"].append({"frame_id": frame_id, "bbox_3d": box["bbox_3d"]})
150
+ return list(tracks.values())
sensorflow/bevfusion/scenes.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic synthetic multi-sensor scenes with known 3D ground truth.
2
+
3
+ Mirrors the conventions of the existing evaluation generator
4
+ (sensorflow/evaluation/synthetic.py: ego drives +x at 10 m/s, objects have
5
+ world-frame kinematics, class dimensions from CLASS_DIMS) but is purpose-built
6
+ for the sensor-simulation study: every ground-truth box carries a per-frame
7
+ camera-occlusion flag (planted contiguous occlusion windows) so that both
8
+ sensor simulators and the cohort evaluation can reason about occlusion.
9
+
10
+ Conditions cycle deterministically across sequences (day/clear, night/clear,
11
+ day/rain) so every cohort is well represented at any n_sequences >= 3.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import List
18
+
19
+ import numpy as np
20
+ from pydantic import BaseModel, Field
21
+
22
+ # Reuse the platform's class dimension priors rather than duplicating them.
23
+ from sensorflow.evaluation.synthetic import CLASS_DIMS, CLASS_POOL
24
+
25
+ CLASSES = list(CLASS_DIMS.keys())
26
+ SAFETY_CLASSES = {"pedestrian", "cyclist", "motorcycle"}
27
+
28
+ DT = 0.1
29
+ EGO_SPEED = 10.0
30
+
31
+
32
+ class GTBox(BaseModel):
33
+ instance_id: str
34
+ class_name: str
35
+ bbox_3d: List[float] # [x, y, z, l, w, h, yaw] ego frame
36
+ occluded: bool = False # camera line-of-sight blocked this frame
37
+ distance: float = 0.0
38
+
39
+
40
+ class SceneFrame(BaseModel):
41
+ frame_id: str
42
+ index: int
43
+ gt: List[GTBox] = Field(default_factory=list)
44
+
45
+
46
+ class SceneSequence(BaseModel):
47
+ sequence_id: str
48
+ time_of_day: str = "day" # day | night
49
+ weather: str = "clear" # clear | rain
50
+ frames: List[SceneFrame] = Field(default_factory=list)
51
+
52
+
53
+ def generate_sequences(n_sequences: int = 6, frames_per_sequence: int = 24,
54
+ seed: int = 7) -> List[SceneSequence]:
55
+ """Generate deterministic sequences with planted camera-occlusion windows."""
56
+ sequences: List[SceneSequence] = []
57
+ for qi in range(n_sequences):
58
+ rng = np.random.default_rng(seed * 100003 + qi * 7919)
59
+ time_of_day = "night" if qi % 3 == 1 else "day"
60
+ weather = "rain" if qi % 3 == 2 else "clear"
61
+ seq = SceneSequence(sequence_id=f"bev-seq-{seed}-{qi}",
62
+ time_of_day=time_of_day, weather=weather)
63
+
64
+ num_objects = int(rng.integers(7, 11))
65
+ objects = []
66
+ for oi in range(num_objects):
67
+ cls = CLASS_POOL[int(rng.integers(0, len(CLASS_POOL)))]
68
+ l, w, h = CLASS_DIMS[cls]
69
+ # Rejection-sample spawn positions so objects start >= 5 m apart
70
+ # (keeps BEV peaks separable; documented decode limitation).
71
+ for _ in range(30):
72
+ x0 = float(rng.uniform(10, 68))
73
+ y0 = float(rng.uniform(-13, 13))
74
+ if all(math.hypot(x0 - o["x"], y0 - o["y"]) >= 5.0 for o in objects):
75
+ break
76
+ heading = (float(rng.uniform(-math.pi, math.pi)) if cls == "pedestrian"
77
+ else float(rng.choice([0.0, math.pi, math.pi / 2, -math.pi / 2])))
78
+ speed = {"pedestrian": 1.4, "cyclist": 4.5, "motorcycle": 9.0,
79
+ "vehicle": 8.0, "truck": 7.0}[cls] * float(rng.uniform(0.6, 1.2))
80
+ objects.append({
81
+ "iid": f"{seq.sequence_id}-obj-{oi}", "cls": cls, "dims": (l, w, h),
82
+ "x": x0, "y": y0,
83
+ "vx": speed * math.cos(heading), "vy": speed * math.sin(heading),
84
+ "yaw": heading,
85
+ })
86
+
87
+ # Plant camera-occlusion windows: 2 objects per sequence lose camera
88
+ # line of sight for a contiguous 2-3 frame window mid-sequence.
89
+ occlusion = {}
90
+ if objects and frames_per_sequence >= 10:
91
+ occluded_idx = rng.choice(len(objects), size=min(2, len(objects)), replace=False)
92
+ for k, oi in enumerate(occluded_idx):
93
+ start = int(rng.integers(4, max(5, frames_per_sequence - 6)))
94
+ length = int(rng.integers(2, 4))
95
+ occlusion[objects[int(oi)]["iid"]] = (start, start + length)
96
+
97
+ for fi in range(frames_per_sequence):
98
+ ego_x = EGO_SPEED * DT * fi
99
+ frame = SceneFrame(frame_id=f"{seq.sequence_id}-f{fi:04d}", index=fi)
100
+ for obj in objects:
101
+ wx = obj["x"] + obj["vx"] * DT * fi
102
+ wy = obj["y"] + obj["vy"] * DT * fi
103
+ rel_x, rel_y = wx - ego_x, wy
104
+ if rel_x < 3.0 or rel_x > 78.0 or abs(rel_y) > 22.0:
105
+ continue
106
+ l, w, h = obj["dims"]
107
+ win = occlusion.get(obj["iid"])
108
+ frame.gt.append(GTBox(
109
+ instance_id=obj["iid"],
110
+ class_name=obj["cls"],
111
+ bbox_3d=[round(rel_x, 3), round(rel_y, 3), round(h / 2, 3),
112
+ l, w, h, round(obj["yaw"], 4)],
113
+ occluded=bool(win and win[0] <= fi < win[1]),
114
+ distance=round(math.hypot(rel_x, rel_y), 3),
115
+ ))
116
+ seq.frames.append(frame)
117
+ sequences.append(seq)
118
+ return sequences
119
+
120
+
121
+ def gt_tracks(sequences: List[SceneSequence]) -> List[dict]:
122
+ """Ground-truth tracks in the shape expected by sensorflow.metrics.temporal_mot."""
123
+ by_instance: dict = {}
124
+ for seq in sequences:
125
+ for frame in seq.frames:
126
+ for gt in frame.gt:
127
+ entry = by_instance.setdefault(
128
+ gt.instance_id,
129
+ {"instance_id": gt.instance_id, "track_id": gt.instance_id,
130
+ "class_name": gt.class_name, "frames": []})
131
+ entry["frames"].append({"frame_id": frame.frame_id, "bbox_3d": gt.bbox_3d})
132
+ return list(by_instance.values())
sensorflow/bevfusion/sensors.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Camera and LiDAR detection simulators with modality-specific failure modes.
2
+
3
+ These stand in for the learned detection backbones of a real BEVFusion stack.
4
+ Detections are sampled from ground truth with honest, physically motivated
5
+ error models — the complementarity that the fusion exploits is real:
6
+
7
+ Camera ("perception-v1-camera" front end):
8
+ - misses under occlusion (line of sight blocked), at night (with a
9
+ distance ramp: dark + small pixels), lightly in rain;
10
+ - monocular depth ambiguity: range estimate noisy (~7% of range), bearing
11
+ precise -> anisotropic BEV covariance elongated along the viewing ray;
12
+ - good class discrimination (94% correct, confusions within size groups);
13
+ - occasional false positives (glare phantoms, more at night).
14
+
15
+ LiDAR:
16
+ - accurate range/geometry (~0.1-0.2 m position, ~3.5% dims);
17
+ - degraded at long distance (sparse returns beyond ~45 m) and in heavy
18
+ rain (spray attenuation growing with distance); mild degradation under
19
+ partial occlusion (fewer returns, but the sensor sits higher and sees
20
+ in 3D, so much less than the camera);
21
+ - weak class discrimination: geometry-template guess with low confidence
22
+ (semantics is the camera's job);
23
+ - unaffected by night — this is the key complementarity for night recall.
24
+
25
+ All randomness is seeded per (seed, sequence, modality): deterministic.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import List, Optional
32
+
33
+ import numpy as np
34
+ from pydantic import BaseModel, Field
35
+
36
+ from sensorflow.bevfusion.geometry import (
37
+ backproject, make_camera_extrinsic, make_intrinsics, project_to_image,
38
+ CAM_W, CAM_H,
39
+ )
40
+ from sensorflow.bevfusion.scenes import CLASS_DIMS, CLASSES, SceneFrame, SceneSequence
41
+
42
+ # Class confusion groups (similar appearance / similar footprint).
43
+ _CONFUSION = {
44
+ "vehicle": ["truck"],
45
+ "truck": ["vehicle"],
46
+ "pedestrian": ["cyclist"],
47
+ "cyclist": ["pedestrian", "motorcycle"],
48
+ "motorcycle": ["cyclist"],
49
+ }
50
+
51
+ DEFAULT_K = make_intrinsics()
52
+ DEFAULT_CAM_TO_EGO = make_camera_extrinsic(tx=0.2, ty=0.0, tz=1.5)
53
+
54
+
55
+ class Detection(BaseModel):
56
+ """A single-modality detection expressed in the ego/BEV frame."""
57
+
58
+ modality: str # "camera" | "lidar"
59
+ x: float
60
+ y: float
61
+ z: float = 0.0
62
+ cov: List[List[float]] = Field(default_factory=lambda: [[0.04, 0.0], [0.0, 0.04]])
63
+ dims: List[float] = Field(default_factory=lambda: [4.5, 1.9, 1.6]) # l, w, h
64
+ yaw: float = 0.0
65
+ class_name: str = "vehicle"
66
+ class_confidence: float = 0.5
67
+ confidence: float = 0.5
68
+ source_instance_id: Optional[str] = None # debug/tests only; engines never read it
69
+
70
+
71
+ def _ray_cov(x: float, y: float, sigma_along: float, sigma_cross: float,
72
+ origin_x: float = 0.2, origin_y: float = 0.0) -> List[List[float]]:
73
+ """BEV covariance elongated along the sensor->object ray."""
74
+ theta = math.atan2(y - origin_y, x - origin_x)
75
+ c, s = math.cos(theta), math.sin(theta)
76
+ rot = np.array([[c, -s], [s, c]])
77
+ cov = rot @ np.diag([sigma_along ** 2, sigma_cross ** 2]) @ rot.T
78
+ return cov.tolist()
79
+
80
+
81
+ # ------------------------------------------------------------------ camera
82
+
83
+
84
+ def simulate_camera(frame: SceneFrame, seq: SceneSequence, rng: np.random.Generator,
85
+ cam_to_ego: np.ndarray = DEFAULT_CAM_TO_EGO,
86
+ K: np.ndarray = DEFAULT_K) -> List[Detection]:
87
+ night = seq.time_of_day == "night"
88
+ rain = seq.weather == "rain"
89
+ dets: List[Detection] = []
90
+
91
+ for gt in frame.gt:
92
+ gx, gy, gz, l, w, h, yaw = gt.bbox_3d
93
+ d = gt.distance
94
+ p_miss = 0.04 + 0.10 * min(1.0, d / 70.0)
95
+ if gt.occluded:
96
+ p_miss += 0.82
97
+ if night:
98
+ p_miss += 0.35 + 0.25 * min(1.0, d / 60.0)
99
+ if rain:
100
+ p_miss += 0.06
101
+ if rng.random() < min(p_miss, 0.97):
102
+ continue
103
+
104
+ proj = project_to_image(np.array([gx, gy, gz]), cam_to_ego, K)
105
+ if proj is None:
106
+ continue
107
+ u, v, r_true = proj
108
+ u += float(rng.normal(0, 0.4))
109
+ v += float(rng.normal(0, 0.4))
110
+ if not (0 <= u <= CAM_W and 0 <= v <= CAM_H):
111
+ continue
112
+
113
+ # Monocular depth: multiplicative range noise along the precise ray.
114
+ r_est = r_true * float(1.0 + rng.normal(0, 0.07))
115
+ p_ego = backproject(u, v, max(r_est, 1.0), cam_to_ego, K)
116
+ dims = [max(0.3, dim * float(1.0 + rng.normal(0, 0.10))) for dim in (l, w, h)]
117
+
118
+ if rng.random() < 0.94:
119
+ cls = gt.class_name
120
+ else:
121
+ cls = str(rng.choice(_CONFUSION[gt.class_name]))
122
+ sigma_along = max(0.35, 0.07 * d)
123
+ sigma_cross = max(0.12, 0.004 * d)
124
+ dets.append(Detection(
125
+ modality="camera",
126
+ x=float(p_ego[0]), y=float(p_ego[1]), z=dims[2] / 2, # ground-plane assumption
127
+ cov=_ray_cov(float(p_ego[0]), float(p_ego[1]), sigma_along, sigma_cross),
128
+ dims=dims,
129
+ yaw=float(yaw + rng.normal(0, math.radians(8))),
130
+ class_name=cls,
131
+ class_confidence=float(np.clip(rng.normal(0.88, 0.05), 0.5, 0.99)),
132
+ confidence=float(np.clip(0.88 - d / 250.0 + rng.normal(0, 0.05), 0.30, 0.99)),
133
+ source_instance_id=gt.instance_id,
134
+ ))
135
+
136
+ # False positives: glare/texture phantoms, more frequent at night.
137
+ if rng.random() < (0.10 if night else 0.05):
138
+ fx = float(rng.uniform(8, 70))
139
+ fy = float(rng.uniform(-15, 15))
140
+ cls = str(rng.choice(CLASSES))
141
+ l, w, h = CLASS_DIMS[cls]
142
+ d = math.hypot(fx, fy)
143
+ dets.append(Detection(
144
+ modality="camera", x=fx, y=fy, z=h / 2,
145
+ cov=_ray_cov(fx, fy, max(0.35, 0.07 * d), max(0.12, 0.004 * d)),
146
+ dims=[l, w, h], yaw=float(rng.uniform(-math.pi, math.pi)),
147
+ class_name=cls, class_confidence=0.55,
148
+ confidence=float(rng.uniform(0.35, 0.55)),
149
+ ))
150
+ return dets
151
+
152
+
153
+ # ------------------------------------------------------------------ lidar
154
+
155
+
156
+ def _template_class(dims: List[float], rng: np.random.Generator) -> str:
157
+ """Weak geometric classification: nearest size template, often confused.
158
+
159
+ Real LiDAR-only detectors get geometry right but semantics wrong; we model
160
+ that as a 55% template guess / 45% confusion-group draw.
161
+ """
162
+ best, best_err = "vehicle", float("inf")
163
+ for cls, (tl, tw, th) in CLASS_DIMS.items():
164
+ err = (math.log(dims[0] / tl) ** 2 + math.log(dims[1] / tw) ** 2
165
+ + math.log(dims[2] / th) ** 2)
166
+ if err < best_err:
167
+ best, best_err = cls, err
168
+ if rng.random() < 0.55:
169
+ return best
170
+ return str(rng.choice(_CONFUSION[best] + [best]))
171
+
172
+
173
+ def simulate_lidar(frame: SceneFrame, seq: SceneSequence,
174
+ rng: np.random.Generator) -> List[Detection]:
175
+ rain = seq.weather == "rain"
176
+ dets: List[Detection] = []
177
+
178
+ for gt in frame.gt:
179
+ gx, gy, gz, l, w, h, yaw = gt.bbox_3d
180
+ d = gt.distance
181
+ p_miss = 0.02 + 0.60 * max(0.0, (d - 45.0) / 35.0)
182
+ if rain:
183
+ p_miss += 0.03 + 0.25 * max(0.0, (d - 25.0) / 50.0)
184
+ if gt.occluded:
185
+ p_miss += 0.15 # partial returns: far milder than the camera's 0.82
186
+ if rng.random() < min(p_miss, 0.97):
187
+ continue
188
+
189
+ sigma = 0.10 + 0.0015 * d
190
+ dims = [max(0.3, dim * float(1.0 + rng.normal(0, 0.035))) for dim in (l, w, h)]
191
+ dets.append(Detection(
192
+ modality="lidar",
193
+ x=float(gx + rng.normal(0, sigma)),
194
+ y=float(gy + rng.normal(0, sigma)),
195
+ z=float(gz + rng.normal(0, sigma)),
196
+ cov=[[sigma ** 2, 0.0], [0.0, sigma ** 2]],
197
+ dims=dims,
198
+ yaw=float(yaw + rng.normal(0, math.radians(3))),
199
+ class_name=_template_class(dims, rng),
200
+ class_confidence=float(np.clip(rng.normal(0.40, 0.05), 0.2, 0.6)),
201
+ confidence=float(np.clip(0.93 - 0.5 * max(0.0, (d - 40.0) / 40.0)
202
+ - (0.08 if rain else 0.0) + rng.normal(0, 0.03),
203
+ 0.30, 0.99)),
204
+ source_instance_id=gt.instance_id,
205
+ ))
206
+
207
+ # Rain clutter (spray) false positives: weak, usually below decode threshold.
208
+ if rain and rng.random() < 0.06:
209
+ fx, fy = float(rng.uniform(5, 40)), float(rng.uniform(-12, 12))
210
+ dets.append(Detection(
211
+ modality="lidar", x=fx, y=fy, z=0.5,
212
+ cov=[[0.04, 0.0], [0.0, 0.04]],
213
+ dims=[0.8, 0.8, 1.0], yaw=0.0,
214
+ class_name="pedestrian", class_confidence=0.25,
215
+ confidence=float(rng.uniform(0.30, 0.45)),
216
+ ))
217
+ return dets
218
+
219
+
220
+ def camera_rng(seed: int, seq_index: int) -> np.random.Generator:
221
+ return np.random.default_rng(seed * 100003 + seq_index * 7919 + 1)
222
+
223
+
224
+ def lidar_rng(seed: int, seq_index: int) -> np.random.Generator:
225
+ return np.random.default_rng(seed * 100003 + seq_index * 7919 + 2)
src/pages/CommandCenterPage.tsx CHANGED
@@ -40,9 +40,6 @@ import CompareTab from '../components/megaeval/CompareTab';
40
  import ReviewTab from '../components/megaeval/ReviewTab';
41
  import ShiftTab from '../components/megaeval/ShiftTab';
42
  import LineageCard from '../components/megaeval/LineageCard';
43
- import ContainerQualityPanel from '../components/platform/ContainerQualityPanel';
44
- import ModelComparePanel from '../components/platform/ModelComparePanel';
45
- import GateStatusPanel from '../components/platform/GateStatusPanel';
46
 
47
  const ACTIVE_STATUSES: RunStatus[] = ['created', 'queued', 'running', 'reducing', 'materializing'];
48
 
@@ -52,7 +49,6 @@ type TabId =
52
  | 'containers'
53
  | 'investigation'
54
  | 'compare'
55
- | 'gates'
56
  | 'review'
57
  | 'shift'
58
  | 'lineage';
@@ -294,49 +290,28 @@ export default function CommandCenterPage() {
294
  <Tab label="Containers" value="containers" />
295
  <Tab label="Investigation" value="investigation" />
296
  <Tab label="Compare" value="compare" />
297
- <Tab label="Gates" value="gates" />
298
  <Tab label="Review" value="review" />
299
  <Tab label="Shift" value="shift" />
300
  <Tab label="Lineage" value="lineage" />
301
  </Tabs>
302
 
303
  {tab === 'quality' ? (
304
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
305
- <QualityTab runId={selectedRun.run_id} runs={runs} refreshKey={publishTick} />
306
- <ContainerQualityPanel runId={selectedRun.run_id} refreshKey={publishTick} />
307
- </Box>
308
  ) : null}
309
  {tab === 'cohorts' ? <CohortTab runId={selectedRun.run_id} refreshKey={publishTick} /> : null}
310
  {tab === 'containers' ? (
311
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
312
- <ContainerQualityPanel runId={selectedRun.run_id} refreshKey={publishTick} />
313
- <ContainersTab
314
- runId={selectedRun.run_id}
315
- refreshKey={publishTick}
316
- drillContainerId={drillContainerId}
317
- onDrill={setDrillContainerId}
318
- />
319
- </Box>
320
  ) : null}
321
  {tab === 'investigation' ? (
322
  <InvestigationTab runId={selectedRun.run_id} onOpenContainer={openContainer} />
323
  ) : null}
324
  {tab === 'compare' ? (
325
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
326
- <ModelComparePanel
327
- runId={selectedRun.run_id}
328
- baselineRunId={baselineRunId}
329
- refreshKey={publishTick}
330
- />
331
- <CompareTab runId={selectedRun.run_id} baselineRunId={baselineRunId} refreshKey={publishTick} />
332
- </Box>
333
- ) : null}
334
- {tab === 'gates' ? (
335
- <GateStatusPanel
336
- candidateRunId={selectedRun.run_id}
337
- baselineRunId={baselineRunId}
338
- refreshKey={publishTick}
339
- />
340
  ) : null}
341
  {tab === 'review' ? (
342
  <ReviewTab runId={selectedRun.run_id} review={review} onReview={setReview} />
 
40
  import ReviewTab from '../components/megaeval/ReviewTab';
41
  import ShiftTab from '../components/megaeval/ShiftTab';
42
  import LineageCard from '../components/megaeval/LineageCard';
 
 
 
43
 
44
  const ACTIVE_STATUSES: RunStatus[] = ['created', 'queued', 'running', 'reducing', 'materializing'];
45
 
 
49
  | 'containers'
50
  | 'investigation'
51
  | 'compare'
 
52
  | 'review'
53
  | 'shift'
54
  | 'lineage';
 
290
  <Tab label="Containers" value="containers" />
291
  <Tab label="Investigation" value="investigation" />
292
  <Tab label="Compare" value="compare" />
 
293
  <Tab label="Review" value="review" />
294
  <Tab label="Shift" value="shift" />
295
  <Tab label="Lineage" value="lineage" />
296
  </Tabs>
297
 
298
  {tab === 'quality' ? (
299
+ <QualityTab runId={selectedRun.run_id} runs={runs} refreshKey={publishTick} />
 
 
 
300
  ) : null}
301
  {tab === 'cohorts' ? <CohortTab runId={selectedRun.run_id} refreshKey={publishTick} /> : null}
302
  {tab === 'containers' ? (
303
+ <ContainersTab
304
+ runId={selectedRun.run_id}
305
+ refreshKey={publishTick}
306
+ drillContainerId={drillContainerId}
307
+ onDrill={setDrillContainerId}
308
+ />
 
 
 
309
  ) : null}
310
  {tab === 'investigation' ? (
311
  <InvestigationTab runId={selectedRun.run_id} onOpenContainer={openContainer} />
312
  ) : null}
313
  {tab === 'compare' ? (
314
+ <CompareTab runId={selectedRun.run_id} baselineRunId={baselineRunId} refreshKey={publishTick} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  ) : null}
316
  {tab === 'review' ? (
317
  <ReviewTab runId={selectedRun.run_id} review={review} onReview={setReview} />
src/pages/EvaluationPage.tsx CHANGED
@@ -20,7 +20,6 @@ import {
20
  import { useLabelEval } from '../context/LabelEvalContext';
21
  import EvidencePanel from '../components/labeleval/EvidencePanel';
22
  import CopilotDrawer from '../components/labeleval/CopilotDrawer';
23
- import GateStatusPanel from '../components/platform/GateStatusPanel';
24
  import { SectionCard, StatusChip, LoadingBox, ErrorNote, fmtNum, fmtPct } from '../components/labeleval/shared';
25
 
26
  const PAGE_SIZE = 15;
@@ -91,7 +90,6 @@ export default function EvaluationPage() {
91
 
92
  return (
93
  <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
94
- <GateStatusPanel candidateRunId={null} baselineRunId={null} />
95
  <SectionCard title="Evaluation Record Explorer">
96
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
97
  <Typography variant="body2" sx={{ color: '#8a949e' }}>
 
20
  import { useLabelEval } from '../context/LabelEvalContext';
21
  import EvidencePanel from '../components/labeleval/EvidencePanel';
22
  import CopilotDrawer from '../components/labeleval/CopilotDrawer';
 
23
  import { SectionCard, StatusChip, LoadingBox, ErrorNote, fmtNum, fmtPct } from '../components/labeleval/shared';
24
 
25
  const PAGE_SIZE = 15;
 
90
 
91
  return (
92
  <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
 
93
  <SectionCard title="Evaluation Record Explorer">
94
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
95
  <Typography variant="body2" sx={{ color: '#8a949e' }}>
src/pages/QualityEnginePage.tsx CHANGED
@@ -5,7 +5,6 @@ import LabelEvaluationEngine from '../components/labeleval/LabelEvaluationEngine
5
  import StrictQualityValidation from '../components/labeleval/StrictQualityValidation';
6
  import GroundTruthComparison from '../components/labeleval/GroundTruthComparison';
7
  import GraderDisagreement from '../components/labeleval/GraderDisagreement';
8
- import GateStatusPanel from '../components/platform/GateStatusPanel';
9
  import { LoadingBox, ErrorNote } from '../components/labeleval/shared';
10
 
11
  export default function QualityEnginePage() {
@@ -21,7 +20,6 @@ export default function QualityEnginePage() {
21
  {metrics.error && !metrics.data ? <ErrorNote error={metrics.error} /> : null}
22
 
23
  <LabelEvaluationEngine pipeline={pipeline ?? null} />
24
- <GateStatusPanel candidateRunId={null} baselineRunId={null} />
25
  <StrictQualityValidation metrics={metrics.data} />
26
  <GroundTruthComparison metrics={metrics.data} />
27
  <GraderDisagreement metrics={metrics.data} />
 
5
  import StrictQualityValidation from '../components/labeleval/StrictQualityValidation';
6
  import GroundTruthComparison from '../components/labeleval/GroundTruthComparison';
7
  import GraderDisagreement from '../components/labeleval/GraderDisagreement';
 
8
  import { LoadingBox, ErrorNote } from '../components/labeleval/shared';
9
 
10
  export default function QualityEnginePage() {
 
20
  {metrics.error && !metrics.data ? <ErrorNote error={metrics.error} /> : null}
21
 
22
  <LabelEvaluationEngine pipeline={pipeline ?? null} />
 
23
  <StrictQualityValidation metrics={metrics.data} />
24
  <GroundTruthComparison metrics={metrics.data} />
25
  <GraderDisagreement metrics={metrics.data} />
tests/test_bevfusion/__init__.py ADDED
File without changes
tests/test_bevfusion/test_e2e.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end self-evaluation: fused engine must measurably beat the baseline
2
+ on identical scenes, deterministically — plus the API surface."""
3
+
4
+ import pytest
5
+ from fastapi import FastAPI
6
+ from fastapi.testclient import TestClient
7
+
8
+ from sensorflow.bevfusion import evaluate as evaluate_mod
9
+ from sensorflow.bevfusion.api import router as bevfusion_router
10
+ from sensorflow.bevfusion.evaluate import run_comparison
11
+
12
+
13
+ @pytest.fixture(scope="module")
14
+ def report():
15
+ return run_comparison(n_sequences=6, frames_per_sequence=24, seed=7, persist=False)
16
+
17
+
18
+ def _headline(report, metric, who):
19
+ row = next(h for h in report["headline_deltas"] if h["metric"] == metric)
20
+ return row[who]
21
+
22
+
23
+ def _cohort(report, name):
24
+ return next(c for c in report["per_cohort"] if c["cohort"] == name)
25
+
26
+
27
+ def test_fused_beats_baseline_on_recall_and_geometry(report):
28
+ assert _headline(report, "recall", "delta") >= 0.20
29
+ assert _headline(report, "mean_iou", "delta") >= 0.10
30
+ # Fused position error must be at most 60% of the monocular baseline.
31
+ assert _headline(report, "position_error_m", "candidate") <= \
32
+ 0.6 * _headline(report, "position_error_m", "baseline")
33
+ assert _headline(report, "safety_recall", "delta") >= 0.10
34
+
35
+
36
+ def test_night_and_occluded_cohorts_recover_substantially(report):
37
+ night = _cohort(report, "night")
38
+ assert night["recall_delta"] >= 0.30, night
39
+ occluded = _cohort(report, "occluded")
40
+ assert occluded["recall_delta"] >= 0.30, occluded
41
+ assert night["explanation"] and occluded["explanation"]
42
+
43
+
44
+ def test_tracking_id_switches_drop(report):
45
+ base = _headline(report, "id_switch_rate", "baseline")
46
+ cand = _headline(report, "id_switch_rate", "candidate")
47
+ assert cand <= 0.5 * base, (base, cand)
48
+ assert _headline(report, "idf1", "delta") >= 0.20
49
+ assert _headline(report, "fragmentation_rate", "candidate") <= \
50
+ _headline(report, "fragmentation_rate", "baseline")
51
+
52
+
53
+ def test_recommendation_is_promote_with_no_blockers(report):
54
+ assert report["recommendation"] == "PROMOTE"
55
+ assert report["blockers"] == []
56
+ assert len(report["improvements"]) >= 5
57
+ assert report["engines"] == {"baseline": "perception-v1-camera",
58
+ "candidate": "perception-v3-bevfusion"}
59
+
60
+
61
+ def test_comparison_is_deterministic(report):
62
+ again = run_comparison(n_sequences=6, frames_per_sequence=24, seed=7, persist=False)
63
+ assert again["headline_deltas"] == report["headline_deltas"]
64
+ assert again["per_cohort"] == report["per_cohort"]
65
+
66
+
67
+ def test_persistence_writes_latest_report(tmp_path):
68
+ rep = run_comparison(n_sequences=3, frames_per_sequence=12, seed=3,
69
+ out_dir=tmp_path, persist=True)
70
+ assert (tmp_path / "latest.json").exists()
71
+ assert (tmp_path / f"{rep['run_id']}.json").exists()
72
+
73
+
74
+ # ------------------------------------------------------------------ API
75
+
76
+
77
+ @pytest.fixture()
78
+ def client(tmp_path, monkeypatch):
79
+ monkeypatch.setattr(evaluate_mod, "RUNS_DIR", tmp_path)
80
+ app = FastAPI()
81
+ app.include_router(bevfusion_router)
82
+ return TestClient(app)
83
+
84
+
85
+ def test_api_run_report_status_lifecycle(client):
86
+ r = client.get("/api/bevfusion/report")
87
+ assert r.status_code == 404
88
+
89
+ r = client.post("/api/bevfusion/run",
90
+ json={"n_sequences": 3, "frames_per_sequence": 12, "seed": 11})
91
+ assert r.status_code == 200
92
+ body = r.json()
93
+ assert body["recommendation"] in ("PROMOTE", "DO_NOT_PROMOTE")
94
+ assert body["headline_deltas"] and body["per_cohort"]
95
+
96
+ r = client.get("/api/bevfusion/report")
97
+ assert r.status_code == 200
98
+ assert r.json()["run_id"] == body["run_id"]
99
+
100
+ r = client.get("/api/bevfusion/status")
101
+ assert r.status_code == 200
102
+ status = r.json()
103
+ assert status["ready"] is True
104
+ assert status["n_runs"] == 1
105
+ assert status["engines"]["candidate"] == "perception-v3-bevfusion"
106
+
107
+
108
+ def test_api_rejects_out_of_range_params(client):
109
+ r = client.post("/api/bevfusion/run", json={"n_sequences": 0})
110
+ assert r.status_code == 422
tests/test_bevfusion/test_fusion.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BEV fusion behavior: complementarity mechanics with hand-planted evidence."""
2
+
3
+ import math
4
+
5
+ from sensorflow.bevfusion.fusion import fuse_frame
6
+ from sensorflow.bevfusion.sensors import Detection
7
+
8
+
9
+ def _lidar(x, y, cls="cyclist", cls_conf=0.4, conf=0.9, dims=(0.7, 0.7, 1.75)):
10
+ return Detection(modality="lidar", x=x, y=y, z=dims[2] / 2,
11
+ cov=[[0.0144, 0.0], [0.0, 0.0144]], dims=list(dims), yaw=0.3,
12
+ class_name=cls, class_confidence=cls_conf, confidence=conf)
13
+
14
+
15
+ def _camera(x, y, cls="pedestrian", cls_conf=0.9, conf=0.85, dims=(0.7, 0.7, 1.75),
16
+ sigma_along=1.4, sigma_cross=0.12):
17
+ theta = math.atan2(y, x)
18
+ c, s = math.cos(theta), math.sin(theta)
19
+ sa2, sc2 = sigma_along**2, sigma_cross**2
20
+ cov = [[c*c*sa2 + s*s*sc2, c*s*(sa2 - sc2)],
21
+ [c*s*(sa2 - sc2), s*s*sa2 + c*c*sc2]]
22
+ return Detection(modality="camera", x=x, y=y, z=dims[2] / 2, cov=cov,
23
+ dims=list(dims), yaw=0.3, class_name=cls,
24
+ class_confidence=cls_conf, confidence=conf)
25
+
26
+
27
+ def test_fusion_recovers_planted_camera_miss_from_lidar_evidence():
28
+ """Camera saw nothing (night/occlusion); LiDAR evidence alone must decode."""
29
+ boxes = fuse_frame(camera_dets=[], lidar_dets=[_lidar(30.0, 2.0)])
30
+ assert len(boxes) == 1
31
+ box = boxes[0]
32
+ assert math.hypot(box["bbox_3d"][0] - 30.0, box["bbox_3d"][1] - 2.0) < 0.5
33
+ assert box["confidence"] >= 0.30
34
+
35
+
36
+ def test_camera_class_flows_to_lidar_anchored_detection():
37
+ """Camera depth is 3 m off but its class evidence smears along the ray and
38
+ labels the LiDAR-anchored cell; geometry snaps to LiDAR (inverse-variance)."""
39
+ true_x, true_y = 20.0, 0.0
40
+ cam = _camera(23.0, 0.0, cls="pedestrian", cls_conf=0.9) # +3 m range error
41
+ lid = _lidar(true_x, true_y, cls="cyclist", cls_conf=0.4) # weak wrong class
42
+ boxes = fuse_frame([cam], [lid])
43
+ # Ray-aware NMS must collapse the camera's range ghost into one object.
44
+ assert len(boxes) == 1
45
+ box = boxes[0]
46
+ assert math.hypot(box["bbox_3d"][0] - true_x, box["bbox_3d"][1] - true_y) < 0.5, \
47
+ "fused position must be LiDAR-anchored, not the camera's depth estimate"
48
+ assert box["class_name"] == "pedestrian", \
49
+ "camera semantics must win over the weak LiDAR template class"
50
+
51
+
52
+ def test_camera_only_detection_survives_when_lidar_missing():
53
+ """Long-range/sparse LiDAR: the camera's evidence alone keeps existence."""
54
+ cam = _camera(55.0, -4.0, conf=0.8, sigma_along=3.85, sigma_cross=0.22)
55
+ boxes = fuse_frame([cam], [])
56
+ assert len(boxes) == 1
57
+ assert math.hypot(boxes[0]["bbox_3d"][0] - 55.0, boxes[0]["bbox_3d"][1] + 4.0) < 1.0
58
+
59
+
60
+ def test_weak_single_modality_clutter_is_suppressed_at_decode():
61
+ """Low-confidence rain clutter stays below the fused decode threshold."""
62
+ clutter = _lidar(20.4, 3.4, conf=0.25, cls_conf=0.25)
63
+ assert fuse_frame([], [clutter]) == []
64
+
65
+
66
+ def test_both_modalities_beat_camera_alone_on_position():
67
+ true_x, true_y = 40.0, 5.0
68
+ cam = _camera(43.0, 5.375, sigma_along=2.8, sigma_cross=0.16) # ~3 m range error
69
+ lid = _lidar(40.1, 5.05)
70
+ fused = fuse_frame([cam], [lid])
71
+ cam_only = fuse_frame([cam], [])
72
+ assert len(fused) == 1 and len(cam_only) == 1
73
+ err_fused = math.hypot(fused[0]["bbox_3d"][0] - true_x, fused[0]["bbox_3d"][1] - true_y)
74
+ err_cam = math.hypot(cam_only[0]["bbox_3d"][0] - true_x, cam_only[0]["bbox_3d"][1] - true_y)
75
+ assert err_fused < 0.4
76
+ assert err_fused < err_cam / 3
tests/test_bevfusion/test_geometry.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BEV projection geometry: known extrinsic -> known pixel / known BEV cell."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+ from sensorflow.bevfusion.geometry import (
8
+ BEVGrid, backproject, make_camera_extrinsic, make_intrinsics, project_to_image,
9
+ )
10
+
11
+
12
+ def test_known_extrinsic_projects_optical_axis_to_principal_point():
13
+ K = make_intrinsics() # fx=fy=500, cx=400, cy=225
14
+ T = make_camera_extrinsic(tx=0.2, ty=0.0, tz=1.5)
15
+ # A point straight down the optical axis maps exactly to the principal point.
16
+ u, v, rng = project_to_image(np.array([20.2, 0.0, 1.5]), T, K)
17
+ assert abs(u - 400.0) < 1e-9
18
+ assert abs(v - 225.0) < 1e-9
19
+ assert abs(rng - 20.0) < 1e-9
20
+
21
+
22
+ def test_known_extrinsic_off_axis_pixel_matches_analytic_formula():
23
+ K = make_intrinsics()
24
+ T = make_camera_extrinsic(tx=0.2, ty=0.0, tz=1.5)
25
+ px, py, pz = 10.2, 2.0, 0.8
26
+ u, v, rng = project_to_image(np.array([px, py, pz]), T, K)
27
+ # For this extrinsic: x_cam = -(y-ty), y_cam = -(z-tz), z_cam = x-tx.
28
+ x_c, y_c, z_c = -(py - 0.0), -(pz - 1.5), px - 0.2
29
+ assert abs(u - (500.0 * x_c / z_c + 400.0)) < 1e-9
30
+ assert abs(v - (500.0 * y_c / z_c + 225.0)) < 1e-9
31
+ assert abs(rng - math.sqrt(x_c**2 + y_c**2 + z_c**2)) < 1e-9
32
+
33
+
34
+ def test_backprojection_round_trip_recovers_ego_point():
35
+ K = make_intrinsics()
36
+ T = make_camera_extrinsic(tx=0.2, ty=0.0, tz=1.5, yaw=0.05)
37
+ point = np.array([34.0, -6.5, 1.1])
38
+ u, v, rng = project_to_image(point, T, K)
39
+ recovered = backproject(u, v, rng, T, K)
40
+ np.testing.assert_allclose(recovered, point, atol=1e-9)
41
+
42
+
43
+ def test_point_behind_camera_is_rejected():
44
+ K = make_intrinsics()
45
+ T = make_camera_extrinsic()
46
+ assert project_to_image(np.array([-5.0, 0.0, 1.5]), T, K) is None
47
+
48
+
49
+ def test_known_bev_cell_and_center():
50
+ grid = BEVGrid(x_min=0.0, x_max=80.0, y_min=-25.0, y_max=25.0, cell=0.5)
51
+ assert grid.nx == 160 and grid.ny == 100
52
+ # (30.3, 2.1): ix = 30.3/0.5 = 60, iy = (2.1+25)/0.5 = 54
53
+ assert grid.index(30.3, 2.1) == (60, 54)
54
+ cx, cy = grid.center(60, 54)
55
+ assert abs(cx - 30.25) < 1e-9 and abs(cy - 2.25) < 1e-9
56
+ assert grid.index(90.0, 0.0) is None
57
+ assert grid.index(10.0, -30.0) is None
tests/test_bevfusion/test_masklet.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Masklet propagation: identity bridges planted occlusion gaps in BEV."""
2
+
3
+ from sensorflow.bevfusion.engines import FrameToFrameTracker
4
+ from sensorflow.bevfusion.masklet import BEVMaskletTracker, tracks_to_dicts
5
+ from sensorflow.schemas.unified_frame import Object3D
6
+
7
+
8
+ def _det(x, y, cls="vehicle", conf=0.9):
9
+ return Object3D(bbox_3d=[x, y, 0.8, 4.5, 1.9, 1.6, 0.0],
10
+ class_name=cls, confidence=conf)
11
+
12
+
13
+ def _stream_with_gap(gap_frames=(5, 6), n=11):
14
+ """Object moving +1.2 m/frame in x; detections missing during the gap."""
15
+ stream = []
16
+ for fi in range(n):
17
+ dets = [] if fi in gap_frames else [_det(10.0 + 1.2 * fi, 3.0)]
18
+ stream.append((f"f{fi:03d}", dets))
19
+ return stream
20
+
21
+
22
+ def test_masklet_bridges_two_frame_occlusion_without_id_switch():
23
+ tracker = BEVMaskletTracker()
24
+ ids_seen = set()
25
+ propagated_frames = []
26
+ for frame_id, dets in _stream_with_gap():
27
+ out = tracker.update(frame_id, dets)
28
+ assert len(out) == 1, f"expected exactly one box at {frame_id}"
29
+ ids_seen.add(out[0].track_id)
30
+ if out[0].track_id in tracker.last_propagated:
31
+ propagated_frames.append(frame_id)
32
+ assert len(ids_seen) == 1, "identity must be carried through the gap"
33
+ assert propagated_frames == ["f005", "f006"], \
34
+ "gap frames must be filled by propagated masklet boxes"
35
+
36
+
37
+ def test_propagated_box_follows_predicted_bev_position():
38
+ tracker = BEVMaskletTracker()
39
+ last_real_x = None
40
+ for frame_id, dets in _stream_with_gap(gap_frames=(5, 6), n=8):
41
+ out = tracker.update(frame_id, dets)
42
+ if frame_id == "f004":
43
+ last_real_x = out[0].bbox_3d[0]
44
+ if frame_id == "f006":
45
+ # The Kalman-predicted position must keep moving forward, not
46
+ # freeze at the last observed position.
47
+ assert out[0].bbox_3d[0] > last_real_x + 0.5
48
+
49
+
50
+ def test_baseline_frame_to_frame_tracker_switches_id_after_gap():
51
+ tracker = FrameToFrameTracker(gate=3.0)
52
+ ids_seen = set()
53
+ for frame_id, dets in _stream_with_gap():
54
+ boxes = [{"bbox_3d": d.bbox_3d, "class_name": d.class_name,
55
+ "confidence": d.confidence} for d in dets]
56
+ for b in tracker.update(boxes):
57
+ ids_seen.add(b["track_id"])
58
+ assert len(ids_seen) >= 2, "the naive baseline must fragment across the gap"
59
+
60
+
61
+ def test_track_class_voting_stabilizes_semantics():
62
+ """One noisy LiDAR-template class flip must not change the track's label."""
63
+ tracker = BEVMaskletTracker()
64
+ classes = ["pedestrian", "pedestrian", "cyclist", "pedestrian"]
65
+ emitted = []
66
+ for fi, cls in enumerate(classes):
67
+ out = tracker.update(f"f{fi:03d}", [_det(10.0 + fi, 0.0, cls=cls)])
68
+ emitted.append(out[0].class_name)
69
+ assert emitted[2] == "pedestrian", "majority vote must override the flip"
70
+ assert emitted[-1] == "pedestrian"
71
+
72
+
73
+ def test_tracks_to_dicts_groups_by_track_id():
74
+ per_frame = {
75
+ "f000": [{"track_id": 1, "class_name": "vehicle", "bbox_3d": [1, 0, 0, 4, 2, 1.5, 0]}],
76
+ "f001": [{"track_id": 1, "class_name": "vehicle", "bbox_3d": [2, 0, 0, 4, 2, 1.5, 0]},
77
+ {"track_id": 2, "class_name": "pedestrian", "bbox_3d": [8, 3, 0, 0.7, 0.7, 1.7, 0]}],
78
+ }
79
+ tracks = {t["track_id"]: t for t in tracks_to_dicts(per_frame)}
80
+ assert len(tracks[1]["frames"]) == 2
81
+ assert len(tracks[2]["frames"]) == 1