Spaces:
Paused
Paused
File size: 10,489 Bytes
9762da0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | """Auto-labeling discrepancy mining (continuous-learning loop).
Industry concept: production AV stacks run a weaker real-time ("online")
perception pass in the vehicle and a stronger non-causal ("offline") auto-label
pass over the same logs. Diffing the two is one of the highest-yield scenario
mining signals — anything the online stack missed but the offline stack found
is a candidate training example / regression scenario.
This module simulates the online pass deterministically over an existing
labeleval dataset (the stored auto-labels play the role of the strong offline
pass), diffs the two, and converts discrepancies into queryable scenario-mining
records:
- MISSED_ONLINE: offline labeled the object, online did not detect it
- CLASS_MISMATCH: both detected it, classes differ
- GEOMETRY_DELTA: both detected it, geometry disagrees (center offset / IoU)
- OFFLINE_MISS: online detected an object the offline pass dropped (rare)
High-severity discrepancies feed the existing rare-event store; all of them
feed the scenario database (source="discrepancy") and cohort summaries.
SIMULATED: the online pass is a seeded degradation model over ground truth
(higher miss rates, class confusion, geometry noise), not a real detector.
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import Dict, List, Optional
import numpy as np
from sensorflow.evaluation.records import (
SAFETY_CRITICAL_CLASSES,
EvalStore,
RareEvent,
new_id,
)
from sensorflow.metrics.perception_3d import bev_iou
from sensorflow.safety.store import read_json, write_json
DEFAULT_ONLINE_PROFILE = {
"extra_miss_rate": 0.12, # online misses this fraction on top of offline
"miss_rate_by_class": {"pedestrian": 0.08, "cyclist": 0.08}, # additional VRU misses
"class_confusion_rate": 0.05,
"position_noise_m": 0.55,
"yaw_noise_deg": 6.0,
"night_miss_multiplier": 1.6, # online degrades harder at night/rain
"rain_miss_multiplier": 1.4,
}
GEOMETRY_CENTER_THRESHOLD_M = 0.75
GEOMETRY_IOU_THRESHOLD = 0.5
_CONFUSABLE = {
"vehicle": "truck", "truck": "vehicle", "pedestrian": "cyclist",
"cyclist": "pedestrian", "motorcycle": "cyclist",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def simulate_online_pass(store: EvalStore, dataset_id: str,
profile: Optional[Dict] = None, seed: int = 101) -> Dict[str, Dict]:
"""Deterministic weaker 'online' perception pass over the dataset's GT.
Returns {gt_id: detection dict} for every ground-truth object the online
stack detected. SIMULATED degradation model, clearly marked.
"""
p = {**DEFAULT_ONLINE_PROFILE, **(profile or {})}
rng = np.random.default_rng(seed)
detections: Dict[str, Dict] = {}
frames = sorted(store.where("frames", dataset_id=dataset_id), key=lambda f: f.index)
for frame in frames:
cond_mult = 1.0
if frame.time_of_day == "night":
cond_mult *= p["night_miss_multiplier"]
if frame.weather not in ("clear", ""):
cond_mult *= p["rain_miss_multiplier"]
for gt in frame.gt_boxes:
miss = p["extra_miss_rate"] + p["miss_rate_by_class"].get(gt.class_name, 0.0)
miss = min(1.0, miss * cond_mult) if miss < 1.0 else 1.0
if rng.random() < miss:
continue # online miss
x, y, z, l, w, h, yaw = gt.bbox_3d
x += float(rng.normal(0, p["position_noise_m"]))
y += float(rng.normal(0, p["position_noise_m"]))
yaw += float(rng.normal(0, math.radians(p["yaw_noise_deg"])))
cls = gt.class_name
if rng.random() < p["class_confusion_rate"]:
cls = _CONFUSABLE.get(cls, cls)
detections[gt.gt_id] = {
"gt_id": gt.gt_id, "frame_id": frame.frame_id, "class_name": cls,
"bbox_3d": [round(v, 4) for v in (x, y, z, l, w, h, yaw)],
"confidence": round(float(rng.uniform(0.35, 0.85)), 3),
}
return detections
def mine(store: EvalStore, dataset_id: str, profile: Optional[Dict] = None,
seed: int = 101, persist: bool = True) -> Dict:
"""Diff the simulated online pass against the stored offline auto-labels."""
dataset = store.get("datasets", dataset_id)
if dataset is None:
raise KeyError(f"Unknown dataset {dataset_id}")
online = simulate_online_pass(store, dataset_id, profile, seed)
frames = {f.frame_id: f for f in store.where("frames", dataset_id=dataset_id)}
offline_by_gt = {a.matched_gt_id: a
for a in store.where("annotations", dataset_id=dataset_id)
if a.matched_gt_id}
discrepancies: List[Dict] = []
cohort_totals: Dict[str, int] = {}
cohort_hits: Dict[str, int] = {}
for frame in frames.values():
for gt in frame.gt_boxes:
cohort = f"{gt.class_name}/{frame.weather}/{frame.time_of_day}"
cohort_totals[cohort] = cohort_totals.get(cohort, 0) + 1
off = offline_by_gt.get(gt.gt_id)
on = online.get(gt.gt_id)
dtype = details = None
if off is not None and on is None:
dtype = "MISSED_ONLINE"
details = {"offline_confidence": off.confidence}
elif off is not None and on is not None:
if on["class_name"] != off.class_name:
dtype = "CLASS_MISMATCH"
details = {"offline_class": off.class_name,
"online_class": on["class_name"]}
elif off.bbox_3d:
center_d = math.hypot(on["bbox_3d"][0] - off.bbox_3d[0],
on["bbox_3d"][1] - off.bbox_3d[1])
iou = bev_iou(on["bbox_3d"], off.bbox_3d)
if center_d > GEOMETRY_CENTER_THRESHOLD_M or iou < GEOMETRY_IOU_THRESHOLD:
dtype = "GEOMETRY_DELTA"
details = {"center_delta_m": round(center_d, 3),
"bev_iou": round(iou, 4)}
elif off is None and on is not None:
dtype = "OFFLINE_MISS"
details = {"online_class": on["class_name"]}
if dtype is None:
continue
cohort_hits[cohort] = cohort_hits.get(cohort, 0) + 1
safety = gt.class_name in SAFETY_CRITICAL_CLASSES
severity = ("critical" if dtype == "MISSED_ONLINE" and safety
else "high" if dtype == "MISSED_ONLINE" or safety
else "medium")
discrepancies.append({
"discrepancy_id": new_id("disc"),
"dataset_id": dataset_id,
"frame_id": frame.frame_id,
"gt_id": gt.gt_id,
"type": dtype,
"class_name": gt.class_name,
"weather": frame.weather,
"time_of_day": frame.time_of_day,
"safety_critical": safety,
"severity": severity,
"details": details,
})
by_type: Dict[str, int] = {}
for d in discrepancies:
by_type[d["type"]] = by_type.get(d["type"], 0) + 1
cohorts = []
for cohort, total in sorted(cohort_totals.items()):
hits = cohort_hits.get(cohort, 0)
cls, weather, tod = cohort.split("/")
cohorts.append({"cohort": cohort, "class": cls, "weather": weather,
"time_of_day": tod, "objects": total, "discrepancies": hits,
"discrepancy_rate": round(hits / total, 4)})
cohorts.sort(key=lambda c: -c["discrepancy_rate"])
total_objects = sum(cohort_totals.values())
report = {
"dataset_id": dataset_id,
"seed": seed,
"created_at": _now(),
"online_profile": {**DEFAULT_ONLINE_PROFILE, **(profile or {})},
"totals": {
"objects": total_objects,
"online_detections": len(online),
"offline_annotations": len(offline_by_gt),
"discrepancies": len(discrepancies),
"discrepancy_rate": round(len(discrepancies) / max(total_objects, 1), 4),
},
"by_type": by_type,
"cohorts": cohorts,
"discrepancies": discrepancies,
"simulated": True,
"method": "deterministic simulated online pass vs stored offline auto-labels; "
"diff over shared ground-truth ids",
}
if persist:
write_json(report, "discrepancy", f"{dataset_id}.json")
_feed_stores(store, report)
return report
def _feed_stores(store: EvalStore, report: Dict) -> None:
"""Feed high-severity discrepancies into the rare-event store, all of them
into the scenario database, and record process units (best-effort)."""
critical = [d for d in report["discrepancies"] if d["severity"] == "critical"]
for d in critical[:40]:
store.put("rare_events", RareEvent(
event_id=new_id("evt"),
dataset_id=d["dataset_id"],
scenario_type="online_perception_miss",
severity="critical",
rarity_score=0.85,
anomaly_score=0.0,
confidence=0.9,
evidence_frames=[d["frame_id"]],
sensor_evidence={"discrepancy": f"{d['type']} on {d['class_name']} "
f"({d['weather']}/{d['time_of_day']})"},
description=f"Online stack missed a {d['class_name']} the offline "
f"auto-label pass found (simulated online pass)",
))
try:
from sensorflow.evaluation.process_units import ProcessMeter
ProcessMeter(store, run_id=report["dataset_id"]).record(
"discrepancy_mining", report["totals"]["objects"], factor=0.3)
except Exception:
pass
store.save()
try:
from sensorflow.safety import scenario_db
scenario_db.get_db().add_from_discrepancies(report)
except Exception:
pass
def latest_summary(dataset_id: str) -> Optional[Dict]:
"""Persisted mining report without the raw per-object rows."""
report = read_json("discrepancy", f"{dataset_id}.json")
if report is None:
return None
slim = {k: v for k, v in report.items() if k != "discrepancies"}
slim["sample_discrepancies"] = report["discrepancies"][:25]
return slim
|