diff --git a/scripts/speed_limit_vision/build_track_proposal_detector_dataset.py b/scripts/speed_limit_vision/build_track_proposal_detector_dataset.py new file mode 100644 index 000000000..3edd9e99b --- /dev/null +++ b/scripts/speed_limit_vision/build_track_proposal_detector_dataset.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import shutil + +from collections import Counter +from pathlib import Path + +import cv2 +import yaml + +if __package__ in (None, ""): + import sys + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from import_manual_review_queue import merged_review_rows, parse_speed # type: ignore +else: + from .import_manual_review_queue import merged_review_rows, parse_speed + + +POSITIVE_STATUSES = frozenset(("accepted", "corrected")) +IMPORTANT_SPEEDS = frozenset(range(30, 70, 5)) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Add clean reviewed sign tracks to the single-class runtime proposal detector.") + parser.add_argument("--base-yaml", type=Path, required=True, help="Existing single-class detector dataset YAML to preserve.") + parser.add_argument("--queue", type=Path, required=True, help="Reviewed manual_review_queue.csv used to create the tracks.") + parser.add_argument("--labels", type=Path, help="Defaults to manual_review_labels.csv beside the queue.") + parser.add_argument("--track-samples", type=Path, required=True, help="track_samples.csv from mine_reviewed_sign_tracks.py.") + parser.add_argument("--output", type=Path, required=True, help="Output directory for added images, labels, and dataset.yaml.") + parser.add_argument("--train-ratio", type=float, default=0.85, help="Route-level train split ratio.") + parser.add_argument("--min-growth", type=float, default=1.0, help="Minimum tracked box area relative to its reviewed anchor.") + parser.add_argument("--min-exact-confidence", type=float, default=0.80) + parser.add_argument("--min-detector-confidence", type=float, default=0.30) + parser.add_argument("--max-track-rank", type=int, default=4) + parser.add_argument("--important-repeat", type=int, default=2, help="Train repeats for accepted 30-65 mph samples.") + parser.add_argument("--other-repeat", type=int, default=1, help="Train repeats for other accepted speed samples.") + return parser.parse_args() + + +def parse_bbox(value: str) -> tuple[int, int, int, int] | None: + try: + values = tuple(int(round(float(part.strip()))) for part in value.split(",")) + except ValueError: + return None + if len(values) != 4: + return None + x1, y1, x2, y2 = values + return values if x2 > x1 and y2 > y1 else None + + +def split_for_key(key: str, train_ratio: float) -> str: + fraction = int(hashlib.sha1(key.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF + return "train" if fraction < train_ratio else "val" + + +def link_or_copy(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + return + try: + os.link(source, destination) + except OSError: + shutil.copy2(source, destination) + + +def yolo_label(bbox: tuple[int, int, int, int], image_path: Path) -> str | None: + image = cv2.imread(str(image_path)) + if image is None: + return None + height, width = image.shape[:2] + x1, y1, x2, y2 = bbox + x1 = max(min(x1, width - 1), 0) + y1 = max(min(y1, height - 1), 0) + x2 = max(min(x2, width), 0) + y2 = max(min(y2, height), 0) + if x2 <= x1 or y2 <= y1: + return None + values = ( + (x1 + x2) / (2 * width), + (y1 + y2) / (2 * height), + (x2 - x1) / width, + (y2 - y1) / height, + ) + return f"0 {values[0]:.8f} {values[1]:.8f} {values[2]:.8f} {values[3]:.8f}\n" + + +def reviewed_positive_rows(queue_path: Path, labels_path: Path) -> dict[str, dict[str, str]]: + return { + row.get("record_key", ""): row + for row in merged_review_rows(queue_path, labels_path) + if row.get("record_key") and row.get("review_status") in POSITIVE_STATUSES and parse_speed(row.get("review_speed_limit_mph", "")) + } + + +def trusted_track_row(row: dict[str, str], reviewed_row: dict[str, str], args: argparse.Namespace) -> bool: + original_bbox = parse_bbox(reviewed_row.get("bbox", "")) + corrected_bbox = parse_bbox(reviewed_row.get("review_bbox", "")) + if corrected_bbox is not None and corrected_bbox != original_bbox: + # Existing tracks predate manual box correction, so their propagated boxes are stale. + return False + try: + expected = int(row.get("expected_speed_limit_mph", "")) + reviewed_speed = int(parse_speed(reviewed_row.get("review_speed_limit_mph", "")) or 0) + predicted = int(row.get("predicted_speed_limit_mph", "") or 0) + read_confidence = float(row.get("read_confidence", "") or 0.0) + detector_confidence = float(row.get("detector_confidence", "") or 0.0) + growth = float(row.get("area_ratio_to_anchor", "") or 0.0) + rank = int(row.get("rank", "") or 999) + except ValueError: + return False + exact_read = predicted == expected and read_confidence >= args.min_exact_confidence + detector_snap = detector_confidence >= args.min_detector_confidence + return expected == reviewed_speed and growth >= args.min_growth and rank <= args.max_track_rank and (exact_read or detector_snap) + + +def add_sample( + output: Path, + split: str, + stem: str, + image_path: Path, + bbox: tuple[int, int, int, int], + repeats: int, +) -> int: + label = yolo_label(bbox, image_path) + if label is None: + return 0 + created = 0 + for repeat in range(max(repeats, 1) if split == "train" else 1): + suffix = f"_r{repeat:02d}" if split == "train" and repeats > 1 else "" + destination_stem = f"{stem}{suffix}" + destination_image = output / "images" / split / f"{destination_stem}{image_path.suffix.lower()}" + destination_label = output / "labels" / split / f"{destination_stem}.txt" + link_or_copy(image_path, destination_image) + destination_label.parent.mkdir(parents=True, exist_ok=True) + destination_label.write_text(label, encoding="ascii") + created += 1 + return created + + +def add_reviewed_anchors( + reviewed_rows: dict[str, dict[str, str]], + output: Path, + args: argparse.Namespace, +) -> Counter[str]: + counts: Counter[str] = Counter() + for record_key, row in reviewed_rows.items(): + speed = int(parse_speed(row.get("review_speed_limit_mph", "")) or 0) + bbox = parse_bbox(row.get("review_bbox") or row.get("bbox", "")) + image_path = Path(row.get("frame_path", "")).expanduser().resolve() + if bbox is None or not image_path.is_file(): + counts["anchor_rejected"] += 1 + continue + split = split_for_key(row.get("route") or record_key, args.train_ratio) + repeats = args.important_repeat if speed in IMPORTANT_SPEEDS else args.other_repeat + created = add_sample(output, split, f"anchor_{record_key}", image_path, bbox, repeats) + counts[f"anchor_{split}"] += created + counts[f"speed_{speed}"] += created + return counts + + +def add_track_samples( + reviewed_rows: dict[str, dict[str, str]], + output: Path, + args: argparse.Namespace, +) -> Counter[str]: + counts: Counter[str] = Counter() + with args.track_samples.expanduser().resolve().open(encoding="utf-8", newline="") as input_file: + for row in csv.DictReader(input_file): + reviewed_row = reviewed_rows.get(row.get("track_key", "")) + if reviewed_row is None or not trusted_track_row(row, reviewed_row, args): + counts["track_rejected"] += 1 + continue + bbox = parse_bbox(row.get("bbox", "")) + image_path = Path(row.get("frame_path", "")).expanduser().resolve() + if bbox is None or not image_path.is_file(): + counts["track_rejected"] += 1 + continue + speed = int(row["expected_speed_limit_mph"]) + split = split_for_key(row.get("route") or row.get("track_key", ""), args.train_ratio) + repeats = args.important_repeat if speed in IMPORTANT_SPEEDS else args.other_repeat + stem = f"track_{row.get('track_key', '')}_{row.get('rank', '')}_{row.get('frame_time_s', '').replace('.', 'p')}" + created = add_sample(output, split, stem, image_path, bbox, repeats) + counts[f"track_{split}"] += created + counts[f"speed_{speed}"] += created + return counts + + +def resolved_dataset_paths(base_yaml: Path, key: str) -> list[str]: + data = yaml.safe_load(base_yaml.read_text(encoding="utf-8")) or {} + base_root = Path(data.get("path", base_yaml.parent)).expanduser() + if not base_root.is_absolute(): + base_root = (base_yaml.parent / base_root).resolve() + entries = data.get(key, []) + if isinstance(entries, str): + entries = [entries] + return [str((base_root / entry).resolve()) if not Path(entry).is_absolute() else str(Path(entry).resolve()) for entry in entries] + + +def write_dataset_yaml(base_yaml: Path, output: Path) -> Path: + train_paths = (*resolved_dataset_paths(base_yaml, "train"), str((output / "images" / "train").resolve())) + val_paths = (*resolved_dataset_paths(base_yaml, "val"), str((output / "images" / "val").resolve())) + lines = ["train:", *(f" - {path}" for path in train_paths), "val:", *(f" - {path}" for path in val_paths), "names:", " 0: speed_limit_sign"] + dataset_yaml = output / "dataset.yaml" + dataset_yaml.write_text("\n".join(lines) + "\n", encoding="ascii") + return dataset_yaml + + +def main() -> int: + args = parse_args() + if not 0.0 < args.train_ratio < 1.0: + raise ValueError("--train-ratio must be between zero and one") + queue_path = args.queue.expanduser().resolve() + labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv") + output = args.output.expanduser().resolve() + for split in ("train", "val"): + (output / "images" / split).mkdir(parents=True, exist_ok=True) + (output / "labels" / split).mkdir(parents=True, exist_ok=True) + + reviewed_rows = reviewed_positive_rows(queue_path, labels_path) + counts = add_reviewed_anchors(reviewed_rows, output, args) + counts.update(add_track_samples(reviewed_rows, output, args)) + dataset_yaml = write_dataset_yaml(args.base_yaml.expanduser().resolve(), output) + summary = { + "accepted_source_records": len(reviewed_rows), + "base_yaml": str(args.base_yaml.expanduser().resolve()), + "dataset_yaml": str(dataset_yaml), + "output": str(output), + "counts": dict(sorted(counts.items())), + } + (output / "track_proposal_dataset_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="ascii") + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/speed_limit_vision/evaluate_route_ground_truth.py b/scripts/speed_limit_vision/evaluate_route_ground_truth.py new file mode 100644 index 000000000..9d921bda4 --- /dev/null +++ b/scripts/speed_limit_vision/evaluate_route_ground_truth.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import json + +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path + +import starpilot.system.speed_limit_vision as slv + +if __package__ in (None, ""): + import sys + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from replay_route_runtime import ( # type: ignore + build_runtime_context, + configure_models, + qlog_paths, + replay_route, + route_log_id, + segment_paths, + ) +else: + from .replay_route_runtime import build_runtime_context, configure_models, qlog_paths, replay_route, route_log_id, segment_paths + + +@dataclass(frozen=True) +class GroundTruthEvent: + route: str + time_s: float + speed_limit_mph: int + sign_type: str + initial_speed_limit_mph: int + notes: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Score continuous route replay against independently labeled speed-sign events.") + parser.add_argument("--ground-truth", type=Path, required=True, help="CSV with route,time_s,speed_limit_mph and optional metadata.") + parser.add_argument("--clip-root", type=Path, default=Path("/Volumes/T5/starpilot_speed_limit/realdata")) + parser.add_argument("--models-dir", type=Path, default=Path("starpilot/assets/vision_models")) + parser.add_argument("--output-csv", type=Path, required=True) + parser.add_argument("--window-before", type=float, default=2.0) + parser.add_argument("--window-after", type=float, default=4.0) + parser.add_argument("--event-dedupe-seconds", type=float, default=2.0) + parser.add_argument("--qlog-context", action="store_true") + parser.add_argument("--measured-base-inference-seconds", type=float, default=0.44) + parser.add_argument("--measured-classifier-forward-seconds", type=float, default=0.066) + parser.add_argument("--crop-ocr", action="store_true") + return parser.parse_args() + + +def int_value(text: str, default: int = 0) -> int: + try: + return int(float(text)) + except (TypeError, ValueError): + return default + + +def load_ground_truth(path: Path) -> list[GroundTruthEvent]: + events: list[GroundTruthEvent] = [] + with path.open(encoding="utf-8", newline="") as input_file: + for row in csv.DictReader(input_file): + route = route_log_id(row.get("route", "")) + try: + time_s = float(row.get("time_s", "")) + except ValueError: + continue + speed = int_value(row.get("speed_limit_mph", "")) + applicable = row.get("applicable", "true").strip().lower() not in ("0", "false", "no") + if not route or time_s < 0 or speed <= 0 or not applicable: + continue + events.append(GroundTruthEvent( + route=route, + time_s=time_s, + speed_limit_mph=speed, + sign_type=row.get("sign_type", "regulatory").strip() or "regulatory", + initial_speed_limit_mph=int_value(row.get("initial_speed_limit_mph", "")), + notes=row.get("notes", "").strip(), + )) + return sorted(events, key=lambda event: (event.route, event.time_s)) + + +def runtime_value(event: dict[str, str]) -> int: + key = "candidateSpeedLimitMph" if event.get("event") == "candidate" else "speedLimitMph" + return int_value(event.get(key, "")) + + +def clustered_runtime_events(events: list[dict[str, str]], event_type: str, dedupe_seconds: float) -> list[dict[str, str]]: + clusters: list[dict[str, str]] = [] + for event in events: + if event.get("event") != event_type or runtime_value(event) <= 0: + continue + if ( + clusters and + runtime_value(clusters[-1]) == runtime_value(event) and + float(event["time_s"]) - float(clusters[-1]["time_s"]) <= dedupe_seconds + ): + continue + clusters.append(event) + return clusters + + +def events_in_window(events: list[dict[str, str]], truth: GroundTruthEvent, before: float, after: float) -> list[dict[str, str]]: + return [event for event in events if truth.time_s - before <= float(event["time_s"]) <= truth.time_s + after] + + +def first_exact(events: list[dict[str, str]], speed: int) -> dict[str, str] | None: + return next((event for event in events if runtime_value(event) == speed), None) + + +def score_route( + truth_events: list[GroundTruthEvent], + runtime_events: list[dict[str, str]], + window_before: float, + window_after: float, + dedupe_seconds: float, +) -> tuple[list[dict[str, object]], Counter[str]]: + candidates = clustered_runtime_events(runtime_events, "candidate", dedupe_seconds) + publishes = clustered_runtime_events(runtime_events, "publish", dedupe_seconds) + rows: list[dict[str, object]] = [] + totals: Counter[str] = Counter(total=len(truth_events)) + + for truth in truth_events: + candidate_window = events_in_window(candidates, truth, window_before, window_after) + publish_window = events_in_window(publishes, truth, window_before, window_after) + exact_candidate = first_exact(candidate_window, truth.speed_limit_mph) + exact_publish = first_exact(publish_window, truth.speed_limit_mph) + wrong_candidates = sorted({runtime_value(event) for event in candidate_window if runtime_value(event) != truth.speed_limit_mph}) + wrong_publishes = sorted({runtime_value(event) for event in publish_window if runtime_value(event) != truth.speed_limit_mph}) + totals.update( + candidate_hit=int(exact_candidate is not None), + publish_hit=int(exact_publish is not None), + wrong_candidate=int(bool(wrong_candidates)), + wrong_publish=int(bool(wrong_publishes)), + ) + rows.append({ + "route": truth.route, + "time_s": f"{truth.time_s:.3f}", + "speed_limit_mph": truth.speed_limit_mph, + "sign_type": truth.sign_type, + "candidate_hit": exact_candidate is not None, + "publish_hit": exact_publish is not None, + "candidate_latency_s": f"{float(exact_candidate['time_s']) - truth.time_s:.3f}" if exact_candidate else "", + "publish_latency_s": f"{float(exact_publish['time_s']) - truth.time_s:.3f}" if exact_publish else "", + "candidate_values": "|".join(str(runtime_value(event)) for event in candidate_window), + "publish_values": "|".join(str(runtime_value(event)) for event in publish_window), + "wrong_candidate_values": "|".join(map(str, wrong_candidates)), + "wrong_publish_values": "|".join(map(str, wrong_publishes)), + "notes": truth.notes, + }) + + unmatched_publishes = [ + event for event in publishes + if not any( + truth.time_s - window_before <= float(event["time_s"]) <= truth.time_s + window_after and + runtime_value(event) == truth.speed_limit_mph + for truth in truth_events + ) + ] + totals["publish_bursts"] = len(publishes) + totals["unmatched_publish_bursts"] = len(unmatched_publishes) + return rows, totals + + +def main() -> int: + args = parse_args() + configure_models(args.models_dir) + slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = args.crop_ocr + clip_root = args.clip_root.expanduser().resolve() + truth_by_route: dict[str, list[GroundTruthEvent]] = defaultdict(list) + for event in load_ground_truth(args.ground_truth.expanduser().resolve()): + truth_by_route[event.route].append(event) + if not truth_by_route: + raise ValueError("Ground-truth manifest contains no applicable speed-sign events") + + output_rows: list[dict[str, object]] = [] + aggregate: Counter[str] = Counter() + by_speed: dict[int, Counter[str]] = defaultdict(Counter) + route_summaries: dict[str, dict[str, int]] = {} + for route, truth_events in truth_by_route.items(): + segments = segment_paths(clip_root, route) + if not segments: + raise FileNotFoundError(f"No camera segments for {route} under {clip_root}") + runtime_context = None + if args.qlog_context: + qlogs = qlog_paths(clip_root, route) + runtime_context = build_runtime_context(qlogs) if qlogs else None + initial_speed = next((event.initial_speed_limit_mph for event in truth_events if event.initial_speed_limit_mph > 0), 0) + _summary, runtime_events = replay_route( + route, + segments, + runtime_context, + 0.0, + None, + False, + False, + 0.0, + args.measured_base_inference_seconds, + args.measured_classifier_forward_seconds, + initial_speed, + ) + rows, totals = score_route(truth_events, runtime_events, args.window_before, args.window_after, args.event_dedupe_seconds) + output_rows.extend(rows) + aggregate.update(totals) + route_summaries[route] = dict(totals) + for row in rows: + speed_counts = by_speed[int(row["speed_limit_mph"])] + speed_counts.update( + total=1, + candidate_hit=int(bool(row["candidate_hit"])), + publish_hit=int(bool(row["publish_hit"])), + wrong_publish=int(bool(row["wrong_publish_values"])), + ) + + output_path = args.output_csv.expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8", newline="") as output_file: + writer = csv.DictWriter(output_file, fieldnames=list(output_rows[0])) + writer.writeheader() + writer.writerows(output_rows) + summary = { + "models_dir": str(args.models_dir.expanduser().resolve()), + "ground_truth": str(args.ground_truth.expanduser().resolve()), + "continuous_route_replay": True, + "detector_selected_inputs": False, + "measured_base_inference_seconds": args.measured_base_inference_seconds, + "measured_classifier_forward_seconds": args.measured_classifier_forward_seconds, + "totals": dict(aggregate), + "by_speed": {str(speed): dict(counts) for speed, counts in sorted(by_speed.items())}, + "by_route": route_summaries, + } + output_path.with_suffix(".json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="ascii") + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/speed_limit_vision/test_route_ground_truth.py b/scripts/speed_limit_vision/test_route_ground_truth.py new file mode 100644 index 000000000..029f78112 --- /dev/null +++ b/scripts/speed_limit_vision/test_route_ground_truth.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import importlib.util +import sys + +from pathlib import Path + + +def load_local_module(name: str): + path = Path(__file__).resolve().with_name(f"{name}.py") + spec = importlib.util.spec_from_file_location(f"test_local_{name}", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +route_eval = load_local_module("evaluate_route_ground_truth") +GroundTruthEvent = route_eval.GroundTruthEvent + + +def test_clustered_runtime_events_collapses_same_value_burst(): + events = [ + {"event": "publish", "time_s": "10.0", "speedLimitMph": "35"}, + {"event": "publish", "time_s": "10.5", "speedLimitMph": "35"}, + {"event": "publish", "time_s": "11.0", "speedLimitMph": "45"}, + ] + clusters = route_eval.clustered_runtime_events(events, "publish", 2.0) + assert [route_eval.runtime_value(event) for event in clusters] == [35, 45] + + +def test_score_route_separates_candidate_publish_and_wrong_value(): + truth = [GroundTruthEvent("route", 10.0, 35, "regulatory", 40, "")] + events = [ + {"event": "candidate", "time_s": "10.2", "candidateSpeedLimitMph": "35"}, + {"event": "publish", "time_s": "10.4", "speedLimitMph": "45"}, + ] + rows, totals = route_eval.score_route(truth, events, 1.0, 2.0, 0.5) + assert totals["candidate_hit"] == 1 + assert totals["publish_hit"] == 0 + assert totals["wrong_publish"] == 1 + assert totals["unmatched_publish_bursts"] == 1 + assert rows[0]["wrong_publish_values"] == "45" diff --git a/scripts/speed_limit_vision/test_track_proposal_detector_dataset.py b/scripts/speed_limit_vision/test_track_proposal_detector_dataset.py new file mode 100644 index 000000000..c33d0a587 --- /dev/null +++ b/scripts/speed_limit_vision/test_track_proposal_detector_dataset.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import importlib.util + +from argparse import Namespace +from pathlib import Path + + +def load_local_module(name: str): + path = Path(__file__).resolve().with_name(f"{name}.py") + spec = importlib.util.spec_from_file_location(f"test_local_{name}", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +track_dataset = load_local_module("build_track_proposal_detector_dataset") +split_for_key = track_dataset.split_for_key +trusted_track_row = track_dataset.trusted_track_row + + +def args() -> Namespace: + return Namespace(min_exact_confidence=0.8, min_detector_confidence=0.3, min_growth=1.0, max_track_rank=4) + + +def test_split_for_key_keeps_route_samples_together(): + assert split_for_key("route-a", 0.85) == split_for_key("route-a", 0.85) + + +def test_trusted_track_requires_current_review_speed_to_match(): + track = { + "expected_speed_limit_mph": "45", + "predicted_speed_limit_mph": "45", + "read_confidence": "0.99", + "detector_confidence": "0.0", + "area_ratio_to_anchor": "1.2", + "rank": "1", + } + assert trusted_track_row(track, {"review_speed_limit_mph": "45"}, args()) + assert not trusted_track_row(track, {"review_speed_limit_mph": "55"}, args()) + + +def test_trusted_track_accepts_detector_snap_without_classifier_read(): + track = { + "expected_speed_limit_mph": "35", + "predicted_speed_limit_mph": "", + "read_confidence": "", + "detector_confidence": "0.7", + "area_ratio_to_anchor": "1.0", + "rank": "4", + } + assert trusted_track_row(track, {"review_speed_limit_mph": "35"}, args()) + + +def test_trusted_track_rejects_low_growth_and_rank(): + track = { + "expected_speed_limit_mph": "35", + "predicted_speed_limit_mph": "35", + "read_confidence": "0.99", + "detector_confidence": "0.7", + "area_ratio_to_anchor": "0.9", + "rank": "5", + } + assert not trusted_track_row(track, {"review_speed_limit_mph": "35"}, args()) + + +def test_trusted_track_rejects_tracks_from_redrawn_anchor(): + track = { + "expected_speed_limit_mph": "35", + "predicted_speed_limit_mph": "35", + "read_confidence": "0.99", + "detector_confidence": "0.7", + "area_ratio_to_anchor": "1.2", + "rank": "1", + } + review = { + "review_speed_limit_mph": "35", + "bbox": "10,10,40,50", + "review_bbox": "12,12,35,45", + } + assert not trusted_track_row(track, review, args()) diff --git a/starpilot/assets/vision_models/speed_limit_us_detector.onnx b/starpilot/assets/vision_models/speed_limit_us_detector.onnx index 275c2c27b..2f1f1cc24 100755 Binary files a/starpilot/assets/vision_models/speed_limit_us_detector.onnx and b/starpilot/assets/vision_models/speed_limit_us_detector.onnx differ