Spaces:
Paused
Paused
Download sensorflow/safety/odd.py from whassan/SensorflowStudio: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/sensorflow/safety/odd.py
- Command line
-
hf download hf://spaces/whassan/SensorflowStudio/sensorflow/safety/odd.py
-
curl -L -o odd.py https://huggingface.co/spaces/whassan/SensorflowStudio/resolve/main/sensorflow/safety/odd.py
17.8 kB
| """ODD coverage analysis (ISO 34503 / ASAM OpenODD-inspired). | |
| Industry concept mapping | |
| ------------------------ | |
| ISO 34503 defines an Operational Design Domain taxonomy in three top-level | |
| categories — scenery, environmental conditions, dynamic elements. ASAM OpenODD | |
| standardizes machine-readable ODD definitions. This module: | |
| 1. exposes an ODD taxonomy built on the megaeval population dimensions (so | |
| coverage is computed against the same vocabulary the metric cube uses), | |
| 2. computes *combinatorial coverage* of the ODD space for an evaluation run: | |
| which cells have data, how much, and with what quality (recall + Wilson CI), | |
| 3. identifies statistically under-covered gaps (too few samples, or a recall | |
| CI too wide to support any claim), | |
| 4. ranks gaps by risk = production-like frequency x performance deficit, | |
| 5. emits synthetic gap-filling scenario REQUESTS and can actually fulfil them | |
| through the existing synthetic generator (sensorflow.evaluation.synthetic), | |
| folding the generated objects back into coverage as a clearly-marked | |
| synthetic supplement. | |
| Honest markers: the "production-like distribution" is the product of the | |
| population's per-dimension marginals (independence assumption, documented in | |
| the response); gap filling uses the deterministic synthetic generator, and the | |
| generator does not model road geometry — road_type on a supplement is a | |
| recorded request attribute, not simulated physics. | |
| """ | |
| from __future__ import annotations | |
| import itertools | |
| import math | |
| from datetime import datetime, timezone | |
| from typing import Dict, List, Optional, Tuple | |
| from sensorflow.megaeval import cube as cube_mod | |
| from sensorflow.megaeval import population as pop_mod | |
| from sensorflow.megaeval.population import DIMENSIONS, DIM_NAMES | |
| from sensorflow.megaeval.sampling import wilson_interval | |
| from sensorflow.safety.store import read_json, write_json | |
| DEFAULT_COVERAGE_DIMS = ["weather", "lighting", "road_type", "class"] | |
| DEFAULT_MIN_SAMPLES = 50 | |
| DEFAULT_MAX_CI_WIDTH = 0.25 | |
| # ISO 34503-style categorization of the dimensions we can instrument. | |
| ISO34503_CATEGORY = { | |
| "road_type": "scenery", | |
| "weather": "environmental_conditions", | |
| "lighting": "environmental_conditions", | |
| "occlusion": "environmental_conditions", | |
| "class": "dynamic_elements", | |
| "speed_band": "dynamic_elements", | |
| "scenario": "dynamic_elements", | |
| "distance_band": "dynamic_elements", | |
| "sensor": "sensor_configuration", | |
| } | |
| # megaeval class vocabulary -> labeleval synthetic-generator class vocabulary. | |
| _CLASS_TO_GENERATOR = { | |
| "vehicle": "vehicle", | |
| "pedestrian": "pedestrian", | |
| "cyclist": "cyclist", | |
| "motorcycle": "motorcycle", | |
| "truck": "truck", | |
| "bus": "truck", # generator has no bus prior; truck is the closest proxy | |
| } | |
| def _now() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def taxonomy() -> Dict: | |
| """Machine-readable ODD taxonomy (ISO 34503 categories, ASAM OpenODD-ish).""" | |
| dims = {} | |
| for name in DIM_NAMES: | |
| dims[name] = { | |
| "values": DIMENSIONS[name], | |
| "iso34503_category": ISO34503_CATEGORY.get(name, "dynamic_elements"), | |
| "instrumented": True, | |
| "source": "megaeval population dimension", | |
| } | |
| # Declared but not instrumented in the current synthetic population. | |
| dims["geography"] = { | |
| "values": ["urban_california", "suburban_california", "highway_california", | |
| "rural_california"], | |
| "iso34503_category": "scenery", | |
| "instrumented": False, | |
| "source": "placeholder — the current synthetic population does not carry " | |
| "a geography attribute; excluded from coverage computation", | |
| } | |
| return { | |
| "standard_basis": "ISO 34503 ODD taxonomy structure (scenery / " | |
| "environmental conditions / dynamic elements), " | |
| "ASAM OpenODD-style machine-readable attributes", | |
| "dimensions": dims, | |
| "default_coverage_dims": DEFAULT_COVERAGE_DIMS, | |
| "notes": [ | |
| "Coverage cells are the cartesian product of the selected dimensions.", | |
| "Only instrumented dimensions may be used for coverage computation.", | |
| ], | |
| } | |
| # ------------------------------------------------------------------ core math | |
| def _marginals(dim_counts: Optional[Dict], cube_df, dims: List[str]) -> Dict[str, Dict[str, float]]: | |
| """Per-dimension marginal probabilities of the production-like distribution. | |
| Prefers the population dataset card (exact counts); falls back to the | |
| cube's own n if no card is available (pure-cube unit tests). | |
| """ | |
| out: Dict[str, Dict[str, float]] = {} | |
| for dim in dims: | |
| if dim_counts and dim in dim_counts: | |
| counts = {v: float(c) for v, c in dim_counts[dim].items()} | |
| else: | |
| grp = cube_df.groupby(dim, observed=True)["n"].sum() | |
| counts = {DIMENSIONS[dim][int(code)]: float(n) for code, n in grp.items()} | |
| total = sum(counts.values()) or 1.0 | |
| out[dim] = {v: counts.get(v, 0.0) / total for v in DIMENSIONS[dim]} | |
| return out | |
| def cell_id(cell: Dict[str, str], dims: List[str]) -> str: | |
| return "|".join(f"{d}={cell[d]}" for d in dims) | |
| def compute_coverage( | |
| cube_df, | |
| dims: Optional[List[str]] = None, | |
| dim_counts: Optional[Dict] = None, | |
| min_samples: int = DEFAULT_MIN_SAMPLES, | |
| max_ci_width: float = DEFAULT_MAX_CI_WIDTH, | |
| target_recall: Optional[float] = None, | |
| supplements: Optional[List[Dict]] = None, | |
| max_gaps: int = 50, | |
| ) -> Dict: | |
| """Combinatorial ODD coverage from a metric cube (pure function, exact math). | |
| Per cell: n (ground-truth objects), tp/fn, recall with Wilson CI, | |
| adequacy (n >= min_samples AND CI width <= max_ci_width), production share | |
| (product of marginals — independence assumption), performance deficit | |
| (target_recall - Wilson lower bound, floored at 0) and | |
| risk = production_share x deficit. | |
| """ | |
| dims = [d for d in (dims or DEFAULT_COVERAGE_DIMS) if d in DIM_NAMES] | |
| if not dims: | |
| raise ValueError("no valid ODD dimensions selected") | |
| n_cells = math.prod(len(DIMENSIONS[d]) for d in dims) | |
| if n_cells > 2000: | |
| raise ValueError(f"selected dimensions produce {n_cells} cells (max 2000); " | |
| "choose fewer dimensions") | |
| rows, _ = cube_mod.aggregate(cube_df, None, dims, ["n", "tp", "fn", "recall", "mean_iou"], | |
| limit=2000) | |
| observed = {tuple(r[d] for d in dims): r for r in rows} | |
| overall_rows, _ = cube_mod.aggregate(cube_df, None, None, ["recall"], 1) | |
| overall_recall = overall_rows[0].get("recall") if overall_rows else None | |
| if target_recall is None: | |
| target_recall = overall_recall if overall_recall is not None else 0.9 | |
| marg = _marginals(dim_counts, cube_df, dims) | |
| # Index synthetic supplements by cell id (only those matching these dims). | |
| supp_by_cell: Dict[str, Dict] = {} | |
| for s in supplements or []: | |
| c = s.get("cell") or {} | |
| if set(c.keys()) == set(dims): | |
| cid = cell_id(c, dims) | |
| agg = supp_by_cell.setdefault(cid, {"n_added": 0, "tp_added": 0, "fn_added": 0, | |
| "datasets": []}) | |
| agg["n_added"] += int(s.get("n_added", 0)) | |
| agg["tp_added"] += int(s.get("tp_added", 0)) | |
| agg["fn_added"] += int(s.get("fn_added", 0)) | |
| if s.get("dataset_id"): | |
| agg["datasets"].append(s["dataset_id"]) | |
| cells: List[Dict] = [] | |
| for combo in itertools.product(*(DIMENSIONS[d] for d in dims)): | |
| cell = dict(zip(dims, combo)) | |
| cid = cell_id(cell, dims) | |
| r = observed.get(combo, {}) | |
| n = int(r.get("n", 0)) | |
| tp = int(r.get("tp", 0)) | |
| fn = int(r.get("fn", 0)) | |
| supp = supp_by_cell.get(cid) | |
| if supp: | |
| n += supp["n_added"] | |
| tp += supp["tp_added"] | |
| fn += supp["fn_added"] | |
| gt_n = tp + fn | |
| recall = (tp / gt_n) if gt_n > 0 else None | |
| lo, hi = wilson_interval(tp, gt_n) | |
| ci_width = hi - lo | |
| share = math.prod(marg[d].get(cell[d], 0.0) for d in dims) | |
| reasons = [] | |
| if n < min_samples: | |
| reasons.append("insufficient_samples") | |
| if ci_width > max_ci_width: | |
| reasons.append("ci_too_wide") | |
| adequate = not reasons | |
| deficit = max(0.0, float(target_recall) - lo) | |
| risk = share * deficit | |
| rec = { | |
| "cell_id": cid, | |
| "cell": cell, | |
| "n": n, | |
| "tp": tp, | |
| "fn": fn, | |
| "recall": None if recall is None else round(recall, 4), | |
| "mean_iou": r.get("mean_iou"), | |
| "wilson_ci": [round(lo, 4), round(hi, 4)], | |
| "ci_width": round(ci_width, 4), | |
| "production_share": round(share, 6), | |
| "adequate": adequate, | |
| "is_gap": not adequate, | |
| "gap_reasons": reasons, | |
| "performance_deficit": round(deficit, 4), | |
| "risk": round(risk, 6), | |
| } | |
| if supp: | |
| rec["synthetic_supplement"] = supp | |
| cells.append(rec) | |
| populated = [c for c in cells if c["n"] > 0] | |
| adequate_cells = [c for c in cells if c["adequate"]] | |
| gaps = sorted((c for c in cells if c["is_gap"]), key=lambda c: -c["risk"]) | |
| total_share = sum(c["production_share"] for c in cells) or 1.0 | |
| weighted_cov = sum(c["production_share"] for c in adequate_cells) / total_share | |
| fill_requests = [gap_fill_request(g, min_samples) for g in gaps[:max_gaps]] | |
| return { | |
| "dims": dims, | |
| "thresholds": {"min_samples": min_samples, "max_ci_width": max_ci_width, | |
| "target_recall": round(float(target_recall), 4)}, | |
| "summary": { | |
| "total_cells": len(cells), | |
| "populated_cells": len(populated), | |
| "empty_cells": len(cells) - len(populated), | |
| "adequate_cells": len(adequate_cells), | |
| "gap_cells": len(gaps), | |
| "coverage_rate": round(len(adequate_cells) / len(cells), 4), | |
| "production_weighted_coverage": round(weighted_cov, 4), | |
| "overall_recall": overall_recall, | |
| }, | |
| "gaps": gaps[:max_gaps], | |
| "fill_requests": fill_requests, | |
| "cells": cells, | |
| "method": "combinatorial cell coverage over the metric cube; recall CI = " | |
| "Wilson; production share = product of population marginals " | |
| "(independence assumption); risk = share x (target_recall - " | |
| "Wilson recall lower bound)", | |
| } | |
| def gap_fill_request(gap: Dict, min_samples: int) -> Dict: | |
| """A scenario REQUEST the synthetic generator can fulfil for one gap cell.""" | |
| cell = gap["cell"] | |
| needed = max(0, min_samples - gap["n"]) | |
| cls = cell.get("class") | |
| return { | |
| "cell_id": gap["cell_id"], | |
| "cell": cell, | |
| "needed_samples": needed, | |
| "risk": gap["risk"], | |
| "generator": "sensorflow.evaluation.synthetic.generate_dataset", | |
| "request": { | |
| "weather": cell.get("weather"), | |
| "time_of_day": ("night" if cell.get("lighting") == "night" | |
| else cell.get("lighting", "day")), | |
| "target_class": _CLASS_TO_GENERATOR.get(cls) if cls else None, | |
| "target_class_note": ("bus mapped to truck proxy" if cls == "bus" else None), | |
| "road_type": cell.get("road_type"), | |
| "road_type_note": "recorded request attribute; the synthetic generator " | |
| "does not model road geometry", | |
| "num_sequences": 2, | |
| "frames_per_sequence": max(10, min(40, needed)), | |
| }, | |
| } | |
| # ------------------------------------------------------------------ run plumbing | |
| def _supplements_key(run_id: str) -> Tuple[str, str]: | |
| return ("odd", f"{run_id}.json") | |
| def load_supplements(run_id: str) -> List[Dict]: | |
| return read_json(*_supplements_key(run_id)) or [] | |
| def coverage_for_run(store, run, dims: Optional[List[str]] = None, | |
| min_samples: int = DEFAULT_MIN_SAMPLES, | |
| max_ci_width: float = DEFAULT_MAX_CI_WIDTH, | |
| target_recall: Optional[float] = None, | |
| include_cells: bool = False, | |
| max_gaps: int = 50) -> Dict: | |
| """ODD coverage for a published megaeval run (uses cube + dataset card).""" | |
| art = store.artifacts(run.run_id) | |
| meta = pop_mod.load_meta(run.population_id) or {} | |
| result = compute_coverage( | |
| art["cube"], dims=dims, dim_counts=meta.get("dim_counts"), | |
| min_samples=min_samples, max_ci_width=max_ci_width, | |
| target_recall=target_recall, supplements=load_supplements(run.run_id), | |
| max_gaps=max_gaps) | |
| result["run_id"] = run.run_id | |
| result["population_id"] = run.population_id | |
| result["model_version"] = run.model_version | |
| if not include_cells: | |
| result.pop("cells") | |
| return result | |
| def fill_gap(store, run, cell: Dict[str, str], num_sequences: int = 2, | |
| frames_per_sequence: int = 20, seed: Optional[int] = None) -> Dict: | |
| """Generate targeted synthetic data for a gap cell and re-compute coverage. | |
| Actually wired: calls the existing synthetic generator + label generator on | |
| the labeleval store, retargets the generated frames' conditions to the gap | |
| cell, measures how many ground-truth objects of the target class were added | |
| and how many the simulated model labeled, persists the supplement, and | |
| returns before/after coverage for that cell. | |
| """ | |
| from sensorflow.evaluation import synthetic | |
| from sensorflow.evaluation.process_units import ProcessMeter | |
| from sensorflow.evaluation.records import get_store as get_legacy_store | |
| dims = [d for d in DIM_NAMES if d in cell] | |
| if not dims: | |
| raise ValueError("cell must specify at least one known ODD dimension") | |
| for d in dims: | |
| if cell[d] not in DIMENSIONS[d]: | |
| raise ValueError(f"unknown value {cell[d]!r} for dimension {d}") | |
| cell = {d: cell[d] for d in dims} | |
| before = coverage_for_run(store, run, dims=dims, include_cells=True) | |
| cid = cell_id(cell, dims) | |
| before_cell = next(c for c in before["cells"] if c["cell_id"] == cid) | |
| legacy = get_legacy_store() | |
| seed = seed if seed is not None else (run.seed + len(load_supplements(run.run_id))) % (2**31) | |
| dataset = synthetic.generate_dataset( | |
| legacy, name=f"odd-gap-{cid}", num_sequences=num_sequences, | |
| frames_per_sequence=frames_per_sequence, seed=seed, | |
| version="gap-fill", generated_from_model=run.model_version) | |
| # Retarget conditions to the requested cell (deterministic post-adjustment; | |
| # the generator itself does not take weather/lighting parameters). | |
| weather = cell.get("weather") | |
| lighting = cell.get("lighting") | |
| for frame in legacy.where("frames", dataset_id=dataset.dataset_id): | |
| if weather: | |
| frame.weather = weather | |
| if weather != "clear" and "adverse_weather" not in frame.scenario_tags: | |
| frame.scenario_tags.append("adverse_weather") | |
| if lighting: | |
| frame.time_of_day = "night" if lighting == "night" else lighting | |
| if lighting == "night" and "night_glare" not in frame.scenario_tags: | |
| frame.scenario_tags.append("night_glare") | |
| if "odd_gap_fill" not in frame.scenario_tags: | |
| frame.scenario_tags.append("odd_gap_fill") | |
| legacy.put("frames", frame) | |
| annotations = synthetic.generate_labels(legacy, dataset, | |
| model_version=run.model_version) | |
| labeled_gt = {a.matched_gt_id for a in annotations if a.matched_gt_id} | |
| target_class = _CLASS_TO_GENERATOR.get(cell.get("class", ""), None) | |
| n_added = tp_added = 0 | |
| for frame in legacy.where("frames", dataset_id=dataset.dataset_id): | |
| for gt in frame.gt_boxes: | |
| if target_class is not None and gt.class_name != target_class: | |
| continue | |
| n_added += 1 | |
| if gt.gt_id in labeled_gt: | |
| tp_added += 1 | |
| fn_added = n_added - tp_added | |
| supplement = { | |
| "cell_id": cid, | |
| "cell": cell, | |
| "dataset_id": dataset.dataset_id, | |
| "n_added": n_added, | |
| "tp_added": tp_added, | |
| "fn_added": fn_added, | |
| "seed": seed, | |
| "source": "synthetic", | |
| "created_at": _now(), | |
| "note": "synthetic supplement generated by sensorflow.evaluation.synthetic; " | |
| "counted separately from the immutable evaluation population", | |
| } | |
| supplements = load_supplements(run.run_id) | |
| supplements.append(supplement) | |
| write_json(supplements, *_supplements_key(run.run_id)) | |
| ProcessMeter(legacy, run_id=run.run_id).record("odd_gap_fill", n_added, factor=1.2) | |
| legacy.audit("odd_gap_filled", "Dataset", dataset.dataset_id, | |
| f"cell={cid} n_added={n_added}") | |
| legacy.save() | |
| # Register the generated scenario in the scenario database. | |
| try: | |
| from sensorflow.safety import scenario_db | |
| scenario_db.get_db().add_gap_fill(run.run_id, cell, dataset.dataset_id, n_added) | |
| except Exception: | |
| pass | |
| after = coverage_for_run(store, run, dims=dims, include_cells=True) | |
| after_cell = next(c for c in after["cells"] if c["cell_id"] == cid) | |
| after.pop("cells") | |
| return { | |
| "run_id": run.run_id, | |
| "cell_id": cid, | |
| "cell": cell, | |
| "generated_dataset_id": dataset.dataset_id, | |
| "objects_added": n_added, | |
| "labeled_added": tp_added, | |
| "supplement": supplement, | |
| "cell_before": before_cell, | |
| "cell_after": after_cell, | |
| "coverage_after": after, | |
| } | |