Files
StarPilot/scripts/speed_limit_vision/evaluate_reviewed_route_events.py
T
firestar5683 e4dfbd1a60 Warbler
2026-07-12 21:48:37 -05:00

343 lines
17 KiB
Python

#!/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 cv2
import starpilot.system.speed_limit_vision as slv
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
from replay_route_runtime import RouteReplayDaemon, configure_models # type: ignore
else:
from .import_manual_review_queue import merged_review_rows, parse_speed
from .replay_route_runtime import RouteReplayDaemon, configure_models
POSITIVE_STATUSES = frozenset(("accepted", "corrected"))
@dataclass(frozen=True)
class ReviewedCase:
record_key: str
route: str
segment: int
frame_time_s: float
source_video_path: Path
expected_speed_limit_mph: int
negative: bool
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Evaluate reviewed sign events through realistic runtime cadence and publish logic.")
parser.add_argument("--queue", type=Path, required=True, help="Reviewed manual_review_queue.csv.")
parser.add_argument("--labels", type=Path, help="Defaults to manual_review_labels.csv beside the queue.")
parser.add_argument("--models-dir", type=Path, default=Path("starpilot/assets/vision_models"), help="Candidate ONNX model directory.")
parser.add_argument("--output-csv", type=Path, required=True, help="Per-event evaluation output.")
parser.add_argument("--window-before", type=float, default=4.0, help="Seconds replayed before the reviewed frame.")
parser.add_argument("--window-after", type=float, default=3.0, help="Seconds replayed after the reviewed frame.")
parser.add_argument("--dedupe-seconds", type=float, default=3.0, help="Collapse nearby reviewed rows with the same expected value.")
parser.add_argument("--measured-base-inference-seconds", type=float, default=0.44, help="Measured no-proposal comma inference cost.")
parser.add_argument("--measured-classifier-forward-seconds", type=float, default=0.066, help="Measured comma cost per classifier forward.")
parser.add_argument("--crop-ocr", action="store_true", help="Evaluate with crop OCR confirmation enabled.")
parser.add_argument("--classifier-min-confidence", type=float, help="Override the value classifier confidence threshold.")
parser.add_argument("--trusted-model-min-confidence", type=float, help="Override tiny-box trusted model confidence.")
parser.add_argument("--classifier-expansion-limit", type=int, help="Evaluate only the first N detector crop expansions.")
parser.add_argument("--classifier-expansion-indices", help="Comma-separated detector crop expansion indices to evaluate.")
parser.add_argument("--strong-rescue-min-proposal-confidence", type=float, help="Override single-frame tiny-sign proposal confidence.")
parser.add_argument("--strong-rescue-min-read-confidence", type=float, help="Override single-frame tiny-sign classifier confidence.")
parser.add_argument("--low-speed-change-consistent-detections", type=int, help="Override reads required to change from 30+ mph to below 30 mph.")
parser.add_argument(
"--allow-low-speed-strong-consensus",
action="store_true",
help="Permit a strong multi-crop consensus to publish a low-speed change from one frame.",
)
parser.add_argument(
"--enable-strong-model-consensus",
action="store_true",
help="Mark three agreeing high-confidence regulatory model crops as strong consensus.",
)
parser.add_argument(
"--strong-model-min-proposal-confidence",
type=float,
help="Override proposal confidence required for a crop to contribute to strong model consensus.",
)
parser.add_argument(
"--strong-model-consensus-min-read-confidence",
type=float,
help="Override classifier confidence required for a crop to contribute to strong model consensus.",
)
parser.add_argument(
"--strong-model-consensus-min-support",
type=int,
help="Override the number of agreeing classifier crops required for strong model consensus.",
)
parser.add_argument("--initial-speed-limit", type=int, default=0, help="Seed each replay window with a currently published speed limit.")
parser.add_argument("--positive-only", action="store_true", help="Replay only reviewed speed signs, omitting ignored-crop windows.")
parser.add_argument("--negative-only", action="store_true", help="Replay only ignored not-speed-limit windows.")
parser.add_argument("--route-file", type=Path, help="Only replay routes listed one per line in this file.")
parser.add_argument("--strong-detection-confidence", type=float, help="Override one-frame publication confidence.")
parser.add_argument("--consistent-detections", type=int, help="Override matching reads required for an initial publication.")
parser.add_argument("--change-consistent-detections", type=int, help="Override matching reads required to change a publication.")
parser.add_argument("--max-cases", type=int, default=0, help="Optional evaluation cap after deduplication.")
return parser.parse_args()
def load_cases(queue_path: Path, labels_path: Path, dedupe_seconds: float) -> list[ReviewedCase]:
rows = merged_review_rows(queue_path, labels_path)
cases: list[ReviewedCase] = []
seen_buckets: set[tuple[str, int, int, int, bool]] = set()
for row in rows:
status = row.get("review_status", "")
positive = status in POSITIVE_STATUSES
negative = status == "ignore" and row.get("review_sign_type") == "not_speed_limit"
if not positive and not negative:
continue
speed = parse_speed(row.get("review_speed_limit_mph", "")) if positive else 0
if positive and not speed:
continue
try:
segment = int(row.get("segment", ""))
frame_time_s = float(row.get("frame_time_s", ""))
except ValueError:
continue
source_video = Path(row.get("source_video_path", "")).expanduser()
if not source_video.is_file():
continue
bucket = int(frame_time_s / max(dedupe_seconds, 0.1))
dedupe_key = (row.get("route", ""), segment, bucket, speed, negative)
if dedupe_key in seen_buckets:
continue
seen_buckets.add(dedupe_key)
cases.append(ReviewedCase(
record_key=row.get("record_key", ""),
route=row.get("route", ""),
segment=segment,
frame_time_s=frame_time_s,
source_video_path=source_video.resolve(),
expected_speed_limit_mph=speed,
negative=negative,
))
return cases
def replay_video_cases(cases: list[ReviewedCase], args: argparse.Namespace) -> dict[str, tuple[list[dict[str, str]], int]]:
daemons = {
case.record_key: RouteReplayDaemon(
runtime_context=None,
measured_inference_seconds=0.0,
measured_base_inference_seconds=args.measured_base_inference_seconds,
measured_classifier_forward_seconds=args.measured_classifier_forward_seconds,
)
for case in cases
}
for daemon in daemons.values():
daemon.published_speed_limit_mph = args.initial_speed_limit
daemon.last_published_support_at = 0.0
capture = cv2.VideoCapture(str(cases[0].source_video_path))
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
duration_s = frame_count / fps if frame_count > 0 else 60.0
windows = {
case.record_key: (
max(case.frame_time_s - args.window_before, 0.0),
min(case.frame_time_s + args.window_after, duration_s),
)
for case in cases
}
first_frame = max(int(min(window[0] for window in windows.values()) * fps), 0)
end_frame = max(int(max(window[1] for window in windows.values()) * fps), first_frame)
# Raw comma HEVC streams do not contain a usable random-access index.
# OpenCV accepts CAP_PROP_POS_FRAMES but still returns frame zero.
frame_index = 0
while frame_index < first_frame:
if not capture.grab():
break
frame_index += 1
while frame_index <= end_frame:
ok, frame_bgr = capture.read()
if not ok:
break
frame_time_s = frame_index / fps
for case in cases:
start_s, end_s = windows[case.record_key]
if start_s <= frame_time_s <= end_s:
daemons[case.record_key].process_frame(frame_time_s - start_s, frame_bgr)
frame_index += 1
capture.release()
results = {}
for case in cases:
daemon = daemons[case.record_key]
results[case.record_key] = daemon.events, daemon.inference_frames
return results
def main() -> int:
args = parse_args()
queue_path = args.queue.expanduser().resolve()
labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv")
configure_models(args.models_dir)
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = args.crop_ocr
if args.classifier_min_confidence is not None:
slv.US_CLASSIFIER_MIN_CONFIDENCE = args.classifier_min_confidence
if args.trusted_model_min_confidence is not None:
slv.DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = args.trusted_model_min_confidence
if args.classifier_expansion_indices:
indices = tuple(int(value) for value in args.classifier_expansion_indices.split(","))
if not indices or min(indices) < 0 or max(indices) >= len(slv.DETECTOR_CLASSIFIER_EXPANSIONS):
raise ValueError("--classifier-expansion-indices contains an invalid index")
slv.DETECTOR_CLASSIFIER_EXPANSIONS = tuple(slv.DETECTOR_CLASSIFIER_EXPANSIONS[index] for index in indices)
elif args.classifier_expansion_limit is not None:
if args.classifier_expansion_limit < 1:
raise ValueError("--classifier-expansion-limit must be at least 1")
slv.DETECTOR_CLASSIFIER_EXPANSIONS = slv.DETECTOR_CLASSIFIER_EXPANSIONS[:args.classifier_expansion_limit]
if args.strong_rescue_min_proposal_confidence is not None:
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE = args.strong_rescue_min_proposal_confidence
if args.strong_rescue_min_read_confidence is not None:
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_READ_CONFIDENCE = args.strong_rescue_min_read_confidence
if args.low_speed_change_consistent_detections is not None:
slv.LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS = args.low_speed_change_consistent_detections
if args.allow_low_speed_strong_consensus:
slv.LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS = True
if args.enable_strong_model_consensus:
slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED = True
if args.strong_model_min_proposal_confidence is not None:
slv.DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE = args.strong_model_min_proposal_confidence
if args.strong_model_consensus_min_read_confidence is not None:
slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_READ_CONFIDENCE = args.strong_model_consensus_min_read_confidence
if args.strong_model_consensus_min_support is not None:
if args.strong_model_consensus_min_support < 1:
raise ValueError("--strong-model-consensus-min-support must be at least 1")
slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_SUPPORT = args.strong_model_consensus_min_support
if args.strong_detection_confidence is not None:
slv.STRONG_DETECTION_CONFIDENCE = args.strong_detection_confidence
if args.consistent_detections is not None:
slv.CONSISTENT_DETECTIONS = args.consistent_detections
if args.change_consistent_detections is not None:
slv.CHANGE_CONSISTENT_DETECTIONS = args.change_consistent_detections
cases = load_cases(queue_path, labels_path, args.dedupe_seconds)
if args.route_file:
selected_routes = {
line.strip() for line in args.route_file.expanduser().resolve().read_text(encoding="utf-8").splitlines() if line.strip()
}
cases = [case for case in cases if case.route in selected_routes]
if args.positive_only:
cases = [case for case in cases if not case.negative]
if args.negative_only:
cases = [case for case in cases if case.negative]
if args.positive_only and args.negative_only:
raise ValueError("--positive-only and --negative-only are mutually exclusive")
if args.max_cases > 0:
cases = cases[:args.max_cases]
output_rows: list[dict[str, object]] = []
positive_by_speed: dict[int, Counter[str]] = defaultdict(Counter)
negative_counts: Counter[str] = Counter()
results: dict[str, tuple[list[dict[str, str]], int]] = {}
cases_by_video: dict[Path, list[ReviewedCase]] = defaultdict(list)
for case in cases:
cases_by_video[case.source_video_path].append(case)
for index, video_cases in enumerate(cases_by_video.values(), start=1):
results.update(replay_video_cases(video_cases, args))
if index % 10 == 0:
print(f"Replayed {index}/{len(cases_by_video)} video segments", flush=True)
for case in cases:
events, inference_frames = results.get(case.record_key, ([], 0))
candidate_events = [event for event in events if event["event"] == "candidate"]
publish_events = [event for event in events if event["event"] == "publish"]
candidates = [int(event["candidateSpeedLimitMph"]) for event in candidate_events]
publishes = [int(event["speedLimitMph"]) for event in publish_events]
candidate_hit = case.expected_speed_limit_mph in candidates if not case.negative else False
publish_hit = case.expected_speed_limit_mph in publishes if not case.negative else False
wrong_candidate_values = [] if case.negative else [value for value in candidates if value != case.expected_speed_limit_mph]
wrong_publish_values = [] if case.negative else [value for value in publishes if value != case.expected_speed_limit_mph]
false_candidate = bool(candidates) if case.negative else False
false_publish = bool(publishes) if case.negative else False
if case.negative:
negative_counts.update(total=1, candidate_fp=int(false_candidate), publish_fp=int(false_publish))
else:
positive_by_speed[case.expected_speed_limit_mph].update(
total=1,
candidate_hit=int(candidate_hit),
publish_hit=int(publish_hit),
wrong_candidate=int(bool(wrong_candidate_values)),
wrong_publish=int(bool(wrong_publish_values)),
)
output_rows.append({
"record_key": case.record_key,
"route": case.route,
"segment": case.segment,
"frame_time_s": f"{case.frame_time_s:.3f}",
"expected_speed_limit_mph": "" if case.negative else case.expected_speed_limit_mph,
"negative": case.negative,
"candidate_values": "|".join(str(value) for value in candidates),
"publish_values": "|".join(str(value) for value in publishes),
"candidate_confidences": "|".join(event.get("candidateConfidence", "") for event in candidate_events),
"candidate_strong_consensus": "|".join(event.get("candidateStrongConsensus", "") for event in candidate_events),
"publish_confidences": "|".join(event.get("confidence", "") for event in publish_events),
"candidate_hit": candidate_hit,
"publish_hit": publish_hit,
"wrong_candidate_values": "|".join(str(value) for value in wrong_candidate_values),
"wrong_publish_values": "|".join(str(value) for value in wrong_publish_values),
"wrong_candidate": bool(wrong_candidate_values),
"wrong_publish": bool(wrong_publish_values),
"false_candidate": false_candidate,
"false_publish": false_publish,
"inference_frames": inference_frames,
"source_video_path": str(case.source_video_path),
})
output_path = args.output_csv.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = list(output_rows[0]) if output_rows else []
with output_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(output_rows)
totals = Counter()
for counts in positive_by_speed.values():
totals.update(counts)
negative_scope = " ".join((
"Reviewed negative labels apply to the proposed crop.",
"Candidate/publish counts replay the full video window and are an upper bound, not confirmed false positives,",
"because another valid sign may be visible in that window.",
))
summary = {
"models_dir": str(args.models_dir.expanduser().resolve()),
"crop_ocr": args.crop_ocr,
"classifier_min_confidence": slv.US_CLASSIFIER_MIN_CONFIDENCE,
"measured_base_inference_seconds": args.measured_base_inference_seconds,
"measured_classifier_forward_seconds": args.measured_classifier_forward_seconds,
"initial_speed_limit_mph": args.initial_speed_limit,
"low_speed_change_consistent_detections": slv.LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS,
"low_speed_change_allow_strong_consensus": slv.LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS,
"strong_model_consensus_enabled": slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED,
"strong_model_min_proposal_confidence": slv.DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE,
"strong_model_consensus_min_read_confidence": slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_READ_CONFIDENCE,
"strong_model_consensus_min_support": slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_SUPPORT,
"positive": dict(totals),
"positive_by_speed": {str(speed): dict(counts) for speed, counts in sorted(positive_by_speed.items())},
"negative": dict(negative_counts),
"negative_scope": negative_scope,
}
summary_path = output_path.with_suffix(".json")
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())