Spaces:
Paused
Paused
Deploy Sensorflow Studio
Browse files- .gitignore +0 -1
- app_backend.py +3 -3
- sensorflow/bevfusion/__init__.py +53 -0
- sensorflow/bevfusion/api.py +69 -0
- sensorflow/bevfusion/engines.py +120 -0
- sensorflow/bevfusion/evaluate.py +422 -0
- sensorflow/bevfusion/fusion.py +275 -0
- sensorflow/bevfusion/geometry.py +108 -0
- sensorflow/bevfusion/masklet.py +150 -0
- sensorflow/bevfusion/scenes.py +132 -0
- sensorflow/bevfusion/sensors.py +225 -0
- src/pages/CommandCenterPage.tsx +8 -33
- src/pages/EvaluationPage.tsx +0 -2
- src/pages/QualityEnginePage.tsx +0 -2
- tests/test_bevfusion/__init__.py +0 -0
- tests/test_bevfusion/test_e2e.py +110 -0
- tests/test_bevfusion/test_fusion.py +76 -0
- tests/test_bevfusion/test_geometry.py +57 -0
- tests/test_bevfusion/test_masklet.py +81 -0
.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 |
-
#
|
| 1471 |
-
from sensorflow.
|
| 1472 |
-
app.include_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 |
-
<
|
| 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 |
-
<
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 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 |
-
<
|
| 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
|