distilled-moonstone v2

This commit is contained in:
firestar5683
2026-07-13 09:49:45 -05:00
parent c306fc7cfd
commit 1d982da86e
5 changed files with 609 additions and 0 deletions
@@ -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())
@@ -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())
@@ -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"
@@ -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())