This commit is contained in:
firestar5683
2026-07-12 17:53:20 -05:00
parent 7b0c0784ed
commit e577502f4b
34 changed files with 3456 additions and 171 deletions
@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
import csv
import hashlib
import json
import math
@@ -16,7 +17,7 @@ import starpilot.system.speed_limit_vision as slv
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import ensure_dir, preferred_clip_root, resolve_workspace # type: ignore
from common import ensure_dir, preferred_clip_root, resolve_workspace # type: ignore # noqa: TID251
from localize_bookmark_signs import configure_models # type: ignore
from mine_route_training_samples import ( # type: ignore
MapContext,
@@ -25,6 +26,7 @@ if __package__ in (None, ""):
iter_frames_at_times,
load_segment_map_context,
nearest_context,
model_bundle_fingerprint,
parse_route_id,
read_frame_at,
route_segments,
@@ -42,6 +44,7 @@ else:
iter_frames_at_times,
load_segment_map_context,
nearest_context,
model_bundle_fingerprint,
parse_route_id,
read_frame_at,
route_segments,
@@ -57,6 +60,8 @@ DEFAULT_OUTPUT_NAME = "manual_review_queue_v1"
PRIORITY_SPEED_VALUES = frozenset((30, 35, 40, 45, 50, 55, 60, 65))
FIELDNAMES = [
"record_key",
"mining_fingerprint",
"model_fingerprint",
"route",
"dongle_id",
"log_id",
@@ -112,14 +117,15 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--clip-root", type=Path, default=preferred_clip_root(), help="Route realdata root.")
parser.add_argument("--bundle-state-dir", type=Path, default=DEFAULT_ROUTE_BUNDLE_STATE_DIR, help="Completed extraction marker directory.")
parser.add_argument("--models-dir", type=Path, help="Optional model directory for mining with non-repo ONNXs.")
parser.add_argument("--model-only", action="store_true", help="Do not run crop OCR while discovering review candidates.")
parser.add_argument("--output-dir", type=Path, help=f"Defaults to <workspace>/review/{DEFAULT_OUTPUT_NAME}.")
parser.add_argument("--manifest-out", type=Path, help="Defaults to <output-dir>/manual_review_queue.csv.")
parser.add_argument("--sample-every", type=float, default=2.0, help="Seconds between regular video samples.")
parser.add_argument("--seek-sampling", action="store_true", help="Seek directly to sampled frames instead of sequential grabbing.")
parser.add_argument("--transition-radius", type=float, default=18.0, help="Extra seconds around map speed transitions to sample densely.")
parser.add_argument("--transition-step", type=float, default=0.75, help="Seconds between transition-window samples.")
parser.add_argument("--max-frames-per-route", type=int, default=1200, help="Maximum frames to score per route.")
parser.add_argument("--max-candidates-per-route", type=int, default=500, help="Maximum review candidates to keep per route.")
parser.add_argument("--max-frames-per-route", type=int, default=1200, help="Maximum frames to score per route. 0 scans the full route.")
parser.add_argument("--max-candidates-per-route", type=int, default=500, help="Maximum review candidates to keep per route. 0 keeps all.")
parser.add_argument("--max-candidates-per-frame", type=int, default=1, help="Maximum detector candidates to keep from a single video frame. 0 keeps all.")
parser.add_argument("--max-negatives-per-route", type=int, default=60, help="Maximum empty/no-candidate frames to keep per route.")
parser.add_argument("--min-proposal-confidence", type=float, default=0.025, help="Loose detector confidence floor for review candidates.")
@@ -132,6 +138,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--include-advisory", action=argparse.BooleanOptionalAction, default=True, help="Include advisory-speed detector class candidates.")
parser.add_argument("--include-full-detection", action="store_true", help="Also run the full runtime detector on each frame for extra context. Slower.")
parser.add_argument("--overwrite-images", action="store_true", help="Rewrite existing review images.")
parser.add_argument("--resume", action=argparse.BooleanOptionalAction, default=True, help="Resume a matching fingerprinted queue.")
parser.add_argument("--dry-run", action="store_true", help="Score frames and print counts without writing images/CSV.")
return parser.parse_args()
@@ -160,6 +167,33 @@ def read_routes(args: argparse.Namespace) -> list[str]:
return deduped
def review_mining_fingerprint(args: argparse.Namespace, model_fingerprint: str) -> str:
config = {
"schema_version": 1,
"model_fingerprint": model_fingerprint,
"model_only": args.model_only,
"sample_every": args.sample_every,
"transition_radius": args.transition_radius,
"transition_step": args.transition_step,
"max_frames_per_route": args.max_frames_per_route,
"max_candidates_per_route": args.max_candidates_per_route,
"max_candidates_per_frame": args.max_candidates_per_frame,
"max_negatives_per_route": args.max_negatives_per_route,
"min_proposal_confidence": args.min_proposal_confidence,
"no_read_min_proposal_confidence": args.no_read_min_proposal_confidence,
"school_zone_min_proposal_confidence": args.school_zone_min_proposal_confidence,
"min_width": args.min_width,
"min_height": args.min_height,
"dedupe_seconds": args.dedupe_seconds,
"include_advisory": args.include_advisory,
"include_full_detection": args.include_full_detection,
}
digest = hashlib.sha256(json.dumps(config, sort_keys=True).encode("utf-8"))
for source_path in (Path(__file__), Path(slv.__file__)):
digest.update(source_path.resolve().read_bytes())
return digest.hexdigest()
def clamp_box(box: tuple[int, int, int, int], frame_shape: tuple[int, int, int]) -> tuple[int, int, int, int] | None:
frame_height, frame_width = frame_shape[:2]
x1, y1, x2, y2 = box
@@ -245,7 +279,14 @@ def classify_map_relation(speed_limit_mph: int, context: MapContext, next_limit_
return "no_map"
def score_review_priority(class_id: int, proposal_confidence: float, chosen_vote: ReadVote | None, support_count: int, map_relation: str, reasons: set[str]) -> float:
def score_review_priority(
class_id: int,
proposal_confidence: float,
chosen_vote: ReadVote | None,
support_count: int,
map_relation: str,
reasons: set[str],
) -> float:
score = proposal_confidence * 2.0
if chosen_vote is not None:
score += chosen_vote.confidence * 2.0
@@ -276,7 +317,14 @@ def summarize_votes(votes: list[ReadVote]) -> str:
return "|".join(compact)
def analyze_proposal(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, proposal, full_detection, context: MapContext, args: argparse.Namespace):
def analyze_proposal(
daemon: slv.SpeedLimitVisionDaemon,
frame_bgr,
proposal,
full_detection,
context: MapContext,
args: argparse.Namespace,
):
proposal_confidence, class_id, raw_box = proposal
if class_id == 1 and not args.include_advisory:
return None
@@ -306,7 +354,8 @@ def analyze_proposal(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, proposal, fu
is_regulatory = daemon._is_regulatory_speed_sign(crop) or class_id == 2
any_regulatory = any_regulatory or is_regulatory
add_vote(votes, daemon._classify_speed_limit_from_model(crop), "model", expansion_index, crop_box, is_regulatory, weight)
add_vote(votes, daemon._read_speed_limit_from_crop(crop), "ocr", expansion_index, crop_box, is_regulatory, weight)
if not args.model_only:
add_vote(votes, daemon._read_speed_limit_from_crop(crop), "ocr", expansion_index, crop_box, is_regulatory, weight)
chosen_vote, support_count = choose_vote(votes)
if chosen_vote is None and proposal_confidence < args.no_read_min_proposal_confidence and class_id != 2:
@@ -368,15 +417,10 @@ def write_image(path: Path, image, quality: int, overwrite: bool) -> None:
cv2.imwrite(str(path), image, [cv2.IMWRITE_JPEG_QUALITY, quality])
def cluster_key(route_id: str, segment: int, time_s: float, frame_shape: tuple[int, int, int], candidate: dict, dedupe_seconds: float) -> str:
x1, y1, x2, y2 = candidate["bbox"]
frame_height, frame_width = frame_shape[:2]
center_x = ((x1 + x2) / 2) / max(frame_width, 1)
center_y = ((y1 + y2) / 2) / max(frame_height, 1)
def cluster_key(route_id: str, segment: int, time_s: float, candidate: dict, dedupe_seconds: float) -> str:
time_bucket = int(math.floor(time_s / max(dedupe_seconds, 0.1)))
grid_x = int(center_x * 12)
grid_y = int(center_y * 8)
return f"{route_id}|{segment}|{time_bucket}|{candidate['class_id']}|{grid_x}|{grid_y}"
candidate_speed = candidate.get("candidate_speed_limit_mph") or "none"
return f"{route_id}|{segment}|{time_bucket}|{candidate['class_id']}|{candidate_speed}"
def candidate_record_key(route_key: str, segment: int, time_s: float, index: int) -> str:
@@ -384,7 +428,14 @@ def candidate_record_key(route_key: str, segment: int, time_s: float, index: int
return f"manual_review_{route_key}_{sample_index}"
def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse.Namespace, output_dir: Path) -> tuple[list[dict[str, object]], dict[str, object]]:
def mine_route(
route_id: str,
daemon: slv.SpeedLimitVisionDaemon,
args: argparse.Namespace,
output_dir: Path,
mining_fingerprint: str,
model_fingerprint: str,
) -> tuple[list[dict[str, object]], dict[str, object]]:
route_id, dongle_id, log_id = parse_route_id(route_id)
route_key = safe_key(route_id)
clip_root = args.clip_root.expanduser().resolve()
@@ -400,7 +451,10 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
frames_scored = 0
for segment in segments:
if frames_scored >= args.max_frames_per_route or route_candidates >= args.max_candidates_per_route:
if (
(args.max_frames_per_route > 0 and frames_scored >= args.max_frames_per_route) or
(args.max_candidates_per_route > 0 and route_candidates >= args.max_candidates_per_route)
):
break
contexts = load_segment_map_context(segment.path)
capture = cv2.VideoCapture(str(segment.video_path))
@@ -414,7 +468,10 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
frame_iter = iter_frames_at_times(capture, fps, times)
for time_s, frame_bgr in frame_iter:
if frames_scored >= args.max_frames_per_route or route_candidates >= args.max_candidates_per_route:
if (
(args.max_frames_per_route > 0 and frames_scored >= args.max_frames_per_route) or
(args.max_candidates_per_route > 0 and route_candidates >= args.max_candidates_per_route)
):
break
if frame_bgr is None:
continue
@@ -444,6 +501,8 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
crop_path = crop_dir / f"{record_key}_crop.jpg"
row = {
"record_key": record_key,
"mining_fingerprint": mining_fingerprint,
"model_fingerprint": model_fingerprint,
"route": route_id,
"dongle_id": dongle_id,
"log_id": log_id,
@@ -478,7 +537,7 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
"review_ignore_reason": "",
"review_notes": "",
}
key = cluster_key(route_id, segment.segment, time_s, frame_bgr.shape, candidate, args.dedupe_seconds)
key = cluster_key(route_id, segment.segment, time_s, candidate, args.dedupe_seconds)
existing = rows_by_cluster.get(key)
if existing is None or float(row["review_priority"]) > float(existing["review_priority"]):
if not args.dry_run:
@@ -494,6 +553,8 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
frame_path = frame_dir / f"{record_key}.jpg"
row = {
"record_key": record_key,
"mining_fingerprint": mining_fingerprint,
"model_fingerprint": model_fingerprint,
"route": route_id,
"dongle_id": dongle_id,
"log_id": log_id,
@@ -557,8 +618,17 @@ def write_manifest(path: Path, rows: list[dict[str, object]]) -> None:
writer.writerows(rows)
def write_summary(path: Path, manifest_path: Path, rows: list[dict[str, object]], summaries: list[dict[str, object]]) -> None:
def write_summary(
path: Path,
manifest_path: Path,
rows: list[dict[str, object]],
summaries: list[dict[str, object]],
mining_fingerprint: str,
model_fingerprint: str,
) -> None:
path.write_text(json.dumps({
"mining_fingerprint": mining_fingerprint,
"model_fingerprint": model_fingerprint,
"routes": summaries,
"manifest": str(manifest_path),
"rows": len(rows),
@@ -575,6 +645,9 @@ def main() -> int:
args = parse_args()
configure_models(args.models_dir)
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = not args.model_only
model_fingerprint = model_bundle_fingerprint()
mining_fingerprint = review_mining_fingerprint(args, model_fingerprint)
workspace = resolve_workspace(args.workspace)
output_dir = args.output_dir.expanduser().resolve() if args.output_dir else ensure_dir(workspace / "review" / DEFAULT_OUTPUT_NAME)
manifest_path = args.manifest_out.expanduser().resolve() if args.manifest_out else output_dir / "manual_review_queue.csv"
@@ -586,25 +659,43 @@ def main() -> int:
all_rows: list[dict[str, object]] = []
summaries = []
summary_path = output_dir / "manual_review_summary.json"
completed_routes: set[str] = set()
if args.resume and summary_path.is_file():
prior_summary = json.loads(summary_path.read_text(encoding="utf-8"))
if prior_summary.get("mining_fingerprint") != mining_fingerprint:
raise RuntimeError("Existing review queue fingerprint does not match this run. Use a new --output-dir or --no-resume.")
if manifest_path.is_file():
with manifest_path.open("r", encoding="utf-8", newline="") as handle:
all_rows = list(csv.DictReader(handle))
summaries = list(prior_summary.get("routes", []))
completed_routes = {str(summary["route"]) for summary in summaries if summary.get("status") in ("mined", "missing_segments")}
elif args.resume and (manifest_path.exists() or summary_path.exists()):
raise RuntimeError("Existing review queue is missing fingerprinted resume state. Use a new --output-dir or --no-resume.")
for index, route_id in enumerate(routes, start=1):
rows, summary = mine_route(route_id, daemon, args, output_dir)
normalized_route, _, _ = parse_route_id(route_id)
if normalized_route in completed_routes:
print(f"[{index}/{len(routes)}] {normalized_route}: skipped (already mined)")
continue
rows, summary = mine_route(route_id, daemon, args, output_dir, mining_fingerprint, model_fingerprint)
all_rows.extend(rows)
summaries.append(summary)
print(
f"[{index}/{len(routes)}] {summary['route']}: {summary['status']} "
f"frames={summary['frames']} candidates={summary['candidates']} negatives={summary['negatives']}"
)
progress = f"[{index}/{len(routes)}] {summary['route']}: {summary['status']}"
counts = f"frames={summary['frames']} candidates={summary['candidates']} negatives={summary['negatives']}"
print(f"{progress} {counts}")
if not args.dry_run:
all_rows.sort(key=lambda row: (-float(row["review_priority"]), str(row["record_key"])))
write_manifest(manifest_path, all_rows)
write_summary(summary_path, manifest_path, all_rows, summaries)
write_summary(summary_path, manifest_path, all_rows, summaries, mining_fingerprint, model_fingerprint)
all_rows.sort(key=lambda row: (-float(row["review_priority"]), str(row["record_key"])))
if not args.dry_run:
write_manifest(manifest_path, all_rows)
write_summary(summary_path, manifest_path, all_rows, summaries)
write_summary(summary_path, manifest_path, all_rows, summaries, mining_fingerprint, model_fingerprint)
print(f"Wrote {len(all_rows)} review rows to {manifest_path}")
print(f"Summary: {summary_path}")
print(f"Model fingerprint: {model_fingerprint}")
print(f"Mining fingerprint: {mining_fingerprint}")
else:
print(f"Dry run rows={len(all_rows)} candidates={sum(1 for row in all_rows if row['detector_class'] != 'negative_empty')}")
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import shutil
from collections import Counter
from pathlib import Path
VALID_SPEEDS = frozenset((15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build an isolated classifier dataset from a base corpus and reviewed crops.")
parser.add_argument("--base", type=Path, required=True, help="Existing Ultralytics classification dataset root.")
parser.add_argument("--output", type=Path, required=True, help="New isolated dataset root.")
parser.add_argument("--positive-manifest", type=Path, action="append", default=[], help="Reviewed positive crop manifest. Repeat as needed.")
parser.add_argument("--reject-manifest", type=Path, action="append", default=[], help="Reviewed classifier reject manifest. Repeat as needed.")
parser.add_argument(
"--advisory-as-reject",
action="store_true",
help="Stage reviewed advisory-speed crops in the reject class instead of omitting them.",
)
parser.add_argument(
"--include-advisory-positives",
action="store_true",
help="Train reviewed advisory crops as their numeric speed classes for recall-first models.",
)
parser.add_argument(
"--advisory-reject-fraction",
type=float,
default=1.0,
help="Deterministic fraction of training advisories staged as reject; validation advisories are always retained.",
)
return parser.parse_args()
def read_rows(paths: list[Path]):
for path in paths:
resolved = path.expanduser().resolve()
with resolved.open("r", encoding="utf-8", newline="") as handle:
yield from csv.DictReader(handle)
def remove_appledouble_files(root: Path) -> int:
removed = 0
for path in root.rglob("._*"):
if path.is_file():
path.unlink()
removed += 1
return removed
def parse_speed(text: str) -> int:
try:
value = int(float((text or "").strip()))
except ValueError:
return 0
return value if value in VALID_SPEEDS else 0
def is_advisory(row: dict[str, str]) -> bool:
return row.get("review_sign_type", "").strip().lower() == "advisory"
def keep_advisory_reject(row: dict[str, str], fraction: float) -> bool:
if row.get("split") == "val" or fraction >= 1.0:
return True
if fraction <= 0.0:
return False
digest = hashlib.sha256(row.get("record_key", "").encode("utf-8")).digest()
return int.from_bytes(digest[:8], "big") / 2**64 < fraction
def stage_crop(source: Path, destination_dir: Path, record_key: str) -> bool:
if not source.is_file():
return False
digest = hashlib.sha256(source.read_bytes()).hexdigest()[:16]
suffix = source.suffix.lower() if source.suffix.lower() in (".jpg", ".jpeg", ".png") else ".jpg"
safe_key = "".join(char if char.isalnum() or char in "._-" else "_" for char in record_key)[:100]
destination_dir.mkdir(parents=True, exist_ok=True)
destination = destination_dir / f"review_{safe_key}_{digest}{suffix}"
if not destination.exists():
shutil.copyfile(source, destination)
return True
def main() -> int:
args = parse_args()
if args.advisory_as_reject and args.include_advisory_positives:
raise ValueError("--advisory-as-reject and --include-advisory-positives are mutually exclusive")
if not 0.0 <= args.advisory_reject_fraction <= 1.0:
raise ValueError("--advisory-reject-fraction must be between 0 and 1")
base = args.base.expanduser().resolve()
output = args.output.expanduser().resolve()
if not base.is_dir():
raise FileNotFoundError(base)
if output.exists():
raise FileExistsError(f"Output dataset already exists: {output}")
shutil.copytree(base, output, copy_function=shutil.copyfile)
appledouble_removed = remove_appledouble_files(output)
positive_counts: Counter[str] = Counter()
reject_counts: Counter[str] = Counter()
skipped = 0
for row in read_rows(args.positive_manifest):
if is_advisory(row):
if args.include_advisory_positives:
pass
elif args.advisory_as_reject and keep_advisory_reject(row, args.advisory_reject_fraction):
split = row.get("split", "")
source = Path(row.get("crop_path", "")).expanduser()
if split in ("train", "val") and stage_crop(source, output / split / "reject", row.get("record_key", "advisory")):
reject_counts[f"advisory_{split}"] += 1
else:
skipped += 1
continue
else:
continue
split = row.get("split", "")
speed = parse_speed(row.get("speed_limit_mph", ""))
source = Path(row.get("crop_path", "")).expanduser()
if split not in ("train", "val") or not speed or not stage_crop(source, output / split / str(speed), row.get("record_key", "positive")):
skipped += 1
continue
positive_counts[f"{split}/{speed}"] += 1
for row in read_rows(args.reject_manifest):
split = row.get("split", "")
source = Path(row.get("crop_path", "")).expanduser()
if split not in ("train", "val") or not stage_crop(source, output / split / "reject", row.get("record_key", "reject")):
skipped += 1
continue
reject_counts[split] += 1
appledouble_removed += remove_appledouble_files(output)
for split in ("train", "val"):
cache_path = output / f"{split}.cache"
if cache_path.is_file():
cache_path.unlink()
summary = {
"base": str(base),
"output": str(output),
"positive_counts": dict(sorted(positive_counts.items())),
"reject_counts": dict(sorted(reject_counts.items())),
"skipped": skipped,
"appledouble_removed": appledouble_removed,
}
summary_path = output / "review_dataset_summary.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())
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from collections import Counter
from pathlib import Path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Compare two fingerprinted manual-review queues by stable record key.")
parser.add_argument("--before", type=Path, required=True, help="Baseline manual_review_queue.csv.")
parser.add_argument("--after", type=Path, required=True, help="Candidate manual_review_queue.csv.")
parser.add_argument("--output-csv", type=Path, required=True, help="Changed-row output CSV.")
parser.add_argument("--review-output", type=Path, help="Optional review-compatible manifest containing the changed source rows.")
parser.add_argument("--confidence-delta", type=float, default=0.05, help="Minimum confidence-only change to report.")
return parser.parse_args()
def read_rows(path: Path) -> dict[str, dict[str, str]]:
with path.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
return {row["record_key"]: row for row in csv.DictReader(handle) if row.get("record_key")}
def parse_float(text: str) -> float:
try:
return float(text)
except (TypeError, ValueError):
return 0.0
def classify_change(before: dict[str, str] | None, after: dict[str, str] | None, confidence_delta: float) -> str:
if before is None:
return "added_proposal"
if after is None:
return "removed_proposal"
before_speed = before.get("candidate_speed_limit_mph", "")
after_speed = after.get("candidate_speed_limit_mph", "")
if not before_speed and after_speed:
return "gained_read"
if before_speed and not after_speed:
return "lost_read"
if before_speed != after_speed:
return "value_changed"
confidence_change = abs(
parse_float(after.get("candidate_confidence", "")) - parse_float(before.get("candidate_confidence", ""))
)
if confidence_change >= confidence_delta:
return "confidence_changed"
return ""
def comparison_row(record_key: str, change: str, before: dict[str, str] | None, after: dict[str, str] | None) -> dict[str, str]:
source = after or before or {}
return {
"record_key": record_key,
"change": change,
"route": source.get("route", ""),
"segment": source.get("segment", ""),
"frame_time_s": source.get("frame_time_s", ""),
"detector_class": source.get("detector_class", ""),
"proposal_confidence": source.get("proposal_confidence", ""),
"before_speed_limit_mph": (before or {}).get("candidate_speed_limit_mph", ""),
"before_confidence": (before or {}).get("candidate_confidence", ""),
"after_speed_limit_mph": (after or {}).get("candidate_speed_limit_mph", ""),
"after_confidence": (after or {}).get("candidate_confidence", ""),
"before_support": (before or {}).get("read_support_count", ""),
"after_support": (after or {}).get("read_support_count", ""),
"frame_path": source.get("frame_path", ""),
"crop_path": source.get("crop_path", ""),
"source_video_path": source.get("source_video_path", ""),
}
def main() -> int:
args = parse_args()
before = read_rows(args.before)
after = read_rows(args.after)
rows: list[dict[str, str]] = []
review_rows: list[dict[str, str]] = []
change_counts: Counter[str] = Counter()
transition_counts: Counter[str] = Counter()
for record_key in sorted(before.keys() | after.keys()):
before_row = before.get(record_key)
after_row = after.get(record_key)
change = classify_change(before_row, after_row, args.confidence_delta)
if not change:
continue
row = comparison_row(record_key, change, before_row, after_row)
rows.append(row)
source_row = dict(after_row or before_row or {})
source_row.update({
"comparison_change": change,
"before_speed_limit_mph": row["before_speed_limit_mph"],
"before_confidence": row["before_confidence"],
})
review_rows.append(source_row)
change_counts[change] += 1
before_speed = row["before_speed_limit_mph"] or "none"
after_speed = row["after_speed_limit_mph"] or "none"
transition_counts[f"{before_speed}->{after_speed}"] += 1
output_path = args.output_csv.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = list(rows[0]) if rows else list(comparison_row("", "", None, None))
with output_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
if args.review_output:
review_output = args.review_output.expanduser().resolve()
review_output.parent.mkdir(parents=True, exist_ok=True)
review_fieldnames = list(review_rows[0]) if review_rows else []
with review_output.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=review_fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(review_rows)
summary = {
"before": str(args.before.expanduser().resolve()),
"after": str(args.after.expanduser().resolve()),
"before_rows": len(before),
"after_rows": len(after),
"changed_rows": len(rows),
"review_output": str(args.review_output.expanduser().resolve()) if args.review_output else "",
"changes": dict(sorted(change_counts.items())),
"transitions": dict(sorted(transition_counts.items(), key=lambda item: (-item[1], item[0]))),
}
output_path.with_suffix(".json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
from collections import Counter
from pathlib import Path
import cv2
import starpilot.system.speed_limit_vision as slv
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Evaluate the integrated value/reject classifier on reviewed crops.")
parser.add_argument("--models-dir", type=Path, default=Path("starpilot/assets/vision_models"))
parser.add_argument("--positive-manifest", type=Path, required=True)
parser.add_argument("--reject-manifest", type=Path, required=True)
parser.add_argument("--split", choices=("train", "val"), help="Optional source split filter.")
parser.add_argument("--output-csv", type=Path)
return parser.parse_args()
def read_rows(path: Path) -> list[dict[str, str]]:
with path.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle))
def parse_speed(text: str) -> int | None:
try:
return int(float((text or "").strip()))
except ValueError:
return None
def main() -> int:
args = parse_args()
models_dir = args.models_dir.expanduser().resolve()
slv.US_DETECTOR_MODEL_PATH = models_dir / "speed_limit_us_detector.onnx"
slv.US_CLASSIFIER_MODEL_PATH = models_dir / "speed_limit_us_value_classifier.onnx"
reject_path = models_dir / "speed_limit_us_reject_classifier.onnx"
if reject_path.is_file():
slv.US_REJECT_CLASSIFIER_MODEL_PATH = reject_path
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
cases: list[tuple[str, dict[str, str], int | None]] = []
for row in read_rows(args.positive_manifest):
if args.split and row.get("split") != args.split:
continue
kind = "advisory" if row.get("review_sign_type", "").strip().lower() == "advisory" else "regulatory"
cases.append((kind, row, parse_speed(row.get("speed_limit_mph", "")) if kind == "regulatory" else None))
for row in read_rows(args.reject_manifest):
if not args.split or row.get("split") == args.split:
cases.append(("hard_negative", row, None))
counts: Counter[str] = Counter()
output_rows = []
for kind, row, expected in cases:
crop_path = Path(row.get("crop_path", "")).expanduser()
crop = cv2.imread(str(crop_path))
if crop is None:
counts[f"{kind}_unreadable"] += 1
continue
result = daemon._classify_speed_limit_from_model(crop)
predicted = result[0] if result is not None else None
confidence = result[1] if result is not None else None
counts[f"{kind}_total"] += 1
if kind == "regulatory":
counts[f"regulatory_speed_{expected}_total"] += 1
if predicted is not None:
counts["regulatory_any"] += 1
if predicted == expected:
counts["regulatory_exact"] += 1
counts[f"regulatory_speed_{expected}_exact"] += 1
elif predicted is not None:
counts["regulatory_wrong"] += 1
elif predicted is None:
counts[f"{kind}_rejected"] += 1
else:
counts[f"{kind}_false_read"] += 1
output_rows.append({
"record_key": row.get("record_key", ""),
"split": row.get("split", ""),
"kind": kind,
"crop_path": str(crop_path),
"expected_speed_limit_mph": "" if expected is None else expected,
"predicted_speed_limit_mph": "" if predicted is None else predicted,
"confidence": "" if confidence is None else f"{confidence:.6f}",
})
regulatory_total = counts["regulatory_total"]
advisory_total = counts["advisory_total"]
hard_negative_total = counts["hard_negative_total"]
exact_rate = counts["regulatory_exact"] / regulatory_total if regulatory_total else 0.0
advisory_reject_rate = counts["advisory_rejected"] / advisory_total if advisory_total else 0.0
hard_negative_reject_rate = counts["hard_negative_rejected"] / hard_negative_total if hard_negative_total else 0.0
regulatory_summary = f"Regulatory exact: {counts['regulatory_exact']}/{regulatory_total} ({exact_rate:.3f})"
print(f"{regulatory_summary}; wrong reads: {counts['regulatory_wrong']}")
print(f"Advisory rejected: {counts['advisory_rejected']}/{advisory_total} ({advisory_reject_rate:.3f})")
hard_negative_summary = f"Hard negatives rejected: {counts['hard_negative_rejected']}/{hard_negative_total}"
print(f"{hard_negative_summary} ({hard_negative_reject_rate:.3f})")
speed_parts = []
for speed in slv.US_CLASSIFIER_SPEED_VALUES:
total = counts[f"regulatory_speed_{speed}_total"]
if total:
speed_parts.append(f"{speed}:{counts[f'regulatory_speed_{speed}_exact']}/{total}")
print("Exact by speed: " + " ".join(speed_parts))
if args.output_csv:
output = args.output_csv.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=tuple(output_rows[0]) if output_rows else ("record_key",))
writer.writeheader()
writer.writerows(output_rows)
print(f"Wrote {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,267 @@
#!/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("--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("--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("--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[int], list[int], 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)
capture.set(cv2.CAP_PROP_POS_FRAMES, first_frame)
frame_index = first_frame
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]
candidates = [int(event["candidateSpeedLimitMph"]) for event in daemon.events if event["event"] == "candidate"]
publishes = [int(event["speedLimitMph"]) for event in daemon.events if event["event"] == "publish"]
results[case.record_key] = candidates, publishes, 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.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
cases = load_cases(queue_path, labels_path, args.dedupe_seconds)
if args.positive_only:
cases = [case for case in cases if not case.negative]
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[int], list[int], 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:
candidates, publishes, inference_frames = results.get(case.record_key, ([], [], 0))
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
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),
)
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_hit": candidate_hit,
"publish_hit": publish_hit,
"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,
"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())
@@ -20,7 +20,7 @@ def parse_args() -> argparse.Namespace:
default=Path("starpilot/assets/vision_models"),
help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.",
)
parser.add_argument("--manifest", type=Path, required=True, help="CSV manifest with dataset_image/frame_path and labels.")
parser.add_argument("--manifest", type=Path, required=True, help="CSV manifest with dataset_image/frame_path/image_path and labels.")
parser.add_argument("--split", action="append", help="Optional split filter. Repeat for multiple splits.")
parser.add_argument("--max-rows", type=int, default=0, help="Optional cap after filtering.")
parser.add_argument("--seed", type=int, default=0, help="Sampling seed used with --max-rows.")
@@ -28,6 +28,9 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--detector-min-confidence", type=float, help="Override runtime US detector confidence threshold.")
parser.add_argument("--classifier-min-confidence", type=float, help="Override runtime US classifier confidence threshold.")
parser.add_argument("--classifier-reject-min-confidence", type=float, help="Override runtime reject-class confidence threshold.")
parser.add_argument("--trusted-model-min-confidence", type=float, help="Override tiny-box trusted model confidence for evaluation.")
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(
"--detector-region-mode",
choices=("full", "right_roi", "full_and_right_roi"),
@@ -36,7 +39,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--right-roi-bounds", help="Override the right ROI as left,top,right,bottom ratios, for example 0.45,0,1,0.82.")
parser.add_argument("--right-roi-min-confidence", type=float, help="Override the right ROI detector minimum confidence.")
parser.add_argument("--full-frame-ocr", action="store_true", help="Enable the expensive full-frame OCR fallback during eval.")
crop_ocr_group = parser.add_mutually_exclusive_group()
crop_ocr_group.add_argument("--crop-ocr", action="store_true", dest="crop_ocr", default=None, help="Enable crop OCR confirmation.")
crop_ocr_group.add_argument("--no-crop-ocr", action="store_false", dest="crop_ocr", help="Evaluate the model-only detector/classifier path.")
parser.add_argument("--separate-reject-classifier", action="store_true", help="Enable the optional second-stage reject classifier during eval.")
parser.add_argument("--include-uncertain", action="store_true", help="Include uncertain_positive review rows in positive metrics.")
parser.add_argument("--advisory-positive", action="store_true", help="Score reviewed advisory rows as readable speed positives.")
parser.add_argument("--strict-positive-recall", type=float, help="Exit non-zero if positive exact recall is below this value.")
parser.add_argument("--strict-negative-fpr", type=float, help="Exit non-zero if negative false-positive rate is above this value.")
return parser.parse_args()
@@ -48,6 +56,10 @@ def configure_runtime_options(args: argparse.Namespace) -> None:
if args.full_frame_ocr:
slv.FULL_FRAME_OCR_FALLBACK_ENABLED = True
if args.crop_ocr is not None:
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = args.crop_ocr
if args.separate_reject_classifier:
slv.SEPARATE_REJECT_CLASSIFIER_ENABLED = True
if args.right_roi_bounds:
parts = [float(part.strip()) for part in args.right_roi_bounds.split(",")]
@@ -81,7 +93,7 @@ def first_present(row: dict[str, str], keys: tuple[str, ...]) -> str:
def expected_value(row: dict[str, str]) -> int | None:
value_text = first_present(row, ("speed_limit_mph", "dominant_value"))
value_text = first_present(row, ("expected_speed_limit_mph", "speed_limit_mph", "dominant_value"))
if value_text:
try:
return int(float(value_text))
@@ -151,6 +163,12 @@ def main() -> int:
if args.classifier_reject_min_confidence is not None:
slv.US_CLASSIFIER_REJECT_MIN_CONFIDENCE = args.classifier_reject_min_confidence
slv.US_REJECT_CLASSIFIER_MIN_CONFIDENCE = args.classifier_reject_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.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
configure_runtime_options(args)
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
@@ -163,7 +181,7 @@ def main() -> int:
unreadable_count = 0
for row in rows:
image_text = first_present(row, ("dataset_image", "frame_path", "source_frame"))
image_text = first_present(row, ("dataset_image", "frame_path", "source_frame", "image_path"))
if not image_text:
unreadable_count += 1
continue
@@ -178,7 +196,8 @@ def main() -> int:
predicted_value = detection.speed_limit_mph if detection is not None else None
confidence = detection.confidence if detection is not None else None
expected = expected_value(row)
negative = is_negative(row)
advisory_positive = args.advisory_positive and row.get("review_sign_type", "").strip().lower() == "advisory"
negative = False if advisory_positive else is_negative(row)
if negative:
negative_count += 1
@@ -211,10 +230,9 @@ def main() -> int:
if uncertain_count and not args.include_uncertain:
print(f"Skipped uncertain rows: {uncertain_count}")
print(f"Unreadable rows: {unreadable_count}")
print(
f"Positive exact: {positive_exact}/{positive_count} "
f"({positive_exact_recall:.3f}); any detection: {positive_detected}/{positive_count} ({positive_any_recall:.3f})"
)
positive_summary = f"Positive exact: {positive_exact}/{positive_count} ({positive_exact_recall:.3f})"
detection_summary = f"any detection: {positive_detected}/{positive_count} ({positive_any_recall:.3f})"
print(f"{positive_summary}; {detection_summary}")
print(f"Negative false positives: {negative_false_positive}/{negative_count} ({negative_fpr:.3f})")
if args.output_csv:
@@ -14,13 +14,16 @@ import cv2
if __package__ in (None, ""):
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore # noqa: TID251
else:
from .common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
CLASSIFIER_FIELDNAMES = [
"record_key",
"route",
"log_id",
"segment",
"split",
"speed_limit_mph",
"review_sign_type",
@@ -36,6 +39,9 @@ CLASSIFIER_FIELDNAMES = [
RUNTIME_FIELDNAMES = [
"record_key",
"route",
"log_id",
"segment",
"split",
"sample_type",
"dataset_image",
@@ -49,6 +55,9 @@ RUNTIME_FIELDNAMES = [
DETECTOR_MANIFEST_FIELDNAMES = [
"record_key",
"route",
"log_id",
"segment",
"split",
"sample_type",
"speed_limit_mph",
@@ -62,6 +71,23 @@ DETECTOR_MANIFEST_FIELDNAMES = [
"detector_class",
]
REJECT_FIELDNAMES = [
"record_key",
"route",
"log_id",
"segment",
"split",
"crop_path",
"frame_path",
"bbox",
"crop_bbox",
"candidate_speed_limit_mph",
"candidate_confidence",
"detector_class",
"review_ignore_reason",
"review_notes",
]
POSITIVE_STATUSES = {"accepted", "corrected"}
UNCERTAIN_STATUS = "uncertain"
NEGATIVE_STATUS = "ignore"
@@ -81,6 +107,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--classifier-manifest-out", type=Path, help="Positive crop manifest for import_manifest_classifier_masks.py.")
parser.add_argument("--runtime-manifest-out", type=Path, help="Full-frame eval manifest including positives and true negatives.")
parser.add_argument("--detector-manifest-out", type=Path, help="Manifest of imported detector examples.")
parser.add_argument("--reject-manifest-out", type=Path, help="Reviewed proposal crops that should train the classifier reject class.")
parser.add_argument("--source-name", default="manual_review", help="Filename prefix for detector dataset imports.")
parser.add_argument("--mode", choices=("symlink", "copy"), default="symlink", help="How to place detector images.")
parser.add_argument("--val-modulo", type=int, default=5, help="Hash modulo for validation split. 0 sends everything to train.")
@@ -110,6 +137,10 @@ def split_for_key(key: str, val_modulo: int, val_remainder: int) -> str:
return "val" if int(digest[:8], 16) % val_modulo == val_remainder else "train"
def split_group_key(row: dict[str, str]) -> str:
return row.get("route") or row.get("log_id") or row["record_key"]
def parse_speed(text: str) -> int:
text = (text or "").strip()
if not text:
@@ -227,6 +258,10 @@ def is_positive(row: dict[str, str]) -> bool:
return Path(row.get("crop_path", "")).is_file() and Path(row.get("frame_path", "")).is_file()
def is_advisory_positive(row: dict[str, str]) -> bool:
return is_positive(row) and effective_sign_type(row) == "advisory"
def is_uncertain_positive(row: dict[str, str]) -> bool:
if row.get("review_status") != UNCERTAIN_STATUS:
return False
@@ -243,10 +278,40 @@ def is_true_negative(row: dict[str, str]) -> bool:
return Path(row.get("frame_path", "")).is_file()
def is_classifier_reject(row: dict[str, str]) -> bool:
if row.get("review_status") != NEGATIVE_STATUS or row.get("detector_class") == "negative_empty":
return False
if row.get("review_sign_type") != "not_speed_limit":
return False
return Path(row.get("crop_path", "")).is_file()
def classifier_reject_row(row: dict[str, str], split: str) -> dict[str, object]:
return {
"record_key": row["record_key"],
"route": row.get("route", ""),
"log_id": row.get("log_id", ""),
"segment": row.get("segment", ""),
"split": split,
"crop_path": row.get("crop_path", ""),
"frame_path": row.get("frame_path", ""),
"bbox": row.get("bbox", ""),
"crop_bbox": row.get("crop_bbox", ""),
"candidate_speed_limit_mph": row.get("candidate_speed_limit_mph", ""),
"candidate_confidence": row.get("candidate_confidence", ""),
"detector_class": row.get("detector_class", ""),
"review_ignore_reason": row.get("review_ignore_reason", ""),
"review_notes": row.get("review_notes", ""),
}
def positive_classifier_row(row: dict[str, str], split: str) -> dict[str, object]:
speed = parse_speed(row.get("review_speed_limit_mph", ""))
return {
"record_key": row["record_key"],
"route": row.get("route", ""),
"log_id": row.get("log_id", ""),
"segment": row.get("segment", ""),
"split": split,
"speed_limit_mph": speed,
"review_sign_type": effective_sign_type(row),
@@ -262,9 +327,13 @@ def positive_classifier_row(row: dict[str, str], split: str) -> dict[str, object
def runtime_row(row: dict[str, str], split: str, sample_type: str) -> dict[str, object]:
speed = parse_speed(row.get("review_speed_limit_mph", "")) if sample_type in ("positive", "uncertain_positive") else 0
positive_sample_types = ("positive", "uncertain_positive", "advisory_negative")
speed = parse_speed(row.get("review_speed_limit_mph", "")) if sample_type in positive_sample_types else 0
return {
"record_key": row["record_key"],
"route": row.get("route", ""),
"log_id": row.get("log_id", ""),
"segment": row.get("segment", ""),
"split": split,
"sample_type": sample_type,
"dataset_image": row.get("frame_path", ""),
@@ -314,6 +383,9 @@ def import_detector_example(
return {
"record_key": row["record_key"],
"route": row.get("route", ""),
"log_id": row.get("log_id", ""),
"segment": row.get("segment", ""),
"split": split,
"sample_type": sample_type,
"speed_limit_mph": parse_speed(row.get("review_speed_limit_mph", "")) if sample_type == "positive" else "",
@@ -334,67 +406,94 @@ def main() -> int:
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_dir = queue_path.parent
classifier_manifest = args.classifier_manifest_out.expanduser().resolve() if args.classifier_manifest_out else output_dir / "manual_review_classifier_manifest.csv"
runtime_manifest = args.runtime_manifest_out.expanduser().resolve() if args.runtime_manifest_out else output_dir / "manual_review_runtime_eval_manifest.csv"
detector_manifest = args.detector_manifest_out.expanduser().resolve() if args.detector_manifest_out else output_dir / "manual_review_detector_import_manifest.csv"
classifier_manifest = (
args.classifier_manifest_out.expanduser().resolve()
if args.classifier_manifest_out else output_dir / "manual_review_classifier_manifest.csv"
)
runtime_manifest = (
args.runtime_manifest_out.expanduser().resolve()
if args.runtime_manifest_out else output_dir / "manual_review_runtime_eval_manifest.csv"
)
detector_manifest = (
args.detector_manifest_out.expanduser().resolve()
if args.detector_manifest_out else output_dir / "manual_review_detector_import_manifest.csv"
)
reject_manifest = (
args.reject_manifest_out.expanduser().resolve()
if args.reject_manifest_out else output_dir / "manual_review_classifier_reject_manifest.csv"
)
rows = merged_review_rows(queue_path, labels_path)
positive_rows = [row for row in rows if is_positive(row)]
advisory_positive_rows = [row for row in positive_rows if is_advisory_positive(row)]
uncertain_positive_rows = [row for row in rows if is_uncertain_positive(row)]
true_negative_rows = [row for row in rows if is_true_negative(row)]
classifier_reject_rows = [row for row in rows if is_classifier_reject(row)]
if args.max_detector_negatives > 0:
true_negative_rows = true_negative_rows[:args.max_detector_negatives]
classifier_rows: list[dict[str, object]] = []
runtime_rows: list[dict[str, object]] = []
detector_rows: list[dict[str, object]] = []
reject_rows: list[dict[str, object]] = []
for row in positive_rows:
split = split_for_key(row["record_key"], args.val_modulo, args.val_remainder)
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
classifier_rows.append(positive_classifier_row(row, split))
runtime_rows.append(runtime_row(row, split, "positive"))
sample_type = "advisory_negative" if is_advisory_positive(row) else "positive"
runtime_rows.append(runtime_row(row, split, sample_type))
detector_row = import_detector_example(workspace, row, split, args.source_name, "positive", args.mode, args.overwrite)
if detector_row is not None:
detector_rows.append(detector_row)
for row in uncertain_positive_rows:
split = split_for_key(row["record_key"], args.val_modulo, args.val_remainder)
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
runtime_rows.append(runtime_row(row, split, "uncertain_positive"))
for row in true_negative_rows:
split = split_for_key(row["record_key"], args.val_modulo, args.val_remainder)
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
runtime_rows.append(runtime_row(row, split, "negative_empty"))
detector_row = import_detector_example(workspace, row, split, args.source_name, "negative_empty", args.mode, args.overwrite)
if detector_row is not None:
detector_rows.append(detector_row)
for row in classifier_reject_rows:
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
reject_rows.append(classifier_reject_row(row, split))
write_csv(classifier_manifest, CLASSIFIER_FIELDNAMES, classifier_rows)
write_csv(runtime_manifest, RUNTIME_FIELDNAMES, runtime_rows)
write_csv(detector_manifest, DETECTOR_MANIFEST_FIELDNAMES, detector_rows)
write_csv(reject_manifest, REJECT_FIELDNAMES, reject_rows)
summary = {
"queue": str(queue_path),
"labels": str(labels_path),
"reviewed_rows": len(rows),
"positive_rows": len(positive_rows),
"advisory_positive_rows": len(advisory_positive_rows),
"uncertain_positive_rows": len(uncertain_positive_rows),
"true_negative_rows": len(true_negative_rows),
"classifier_reject_rows": len(reject_rows),
"classifier_manifest": str(classifier_manifest),
"runtime_manifest": str(runtime_manifest),
"detector_manifest": str(detector_manifest),
"detector_imported": len(detector_rows),
"reject_manifest": str(reject_manifest),
}
summary_path = output_dir / "manual_review_import_summary.json"
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(
"Imported manual review queue: "
f"reviewed={len(rows)} positives={len(positive_rows)} uncertain_positives={len(uncertain_positive_rows)} true_negatives={len(true_negative_rows)} "
f"detector_imported={len(detector_rows)}"
)
review_counts = " ".join((
f"reviewed={len(rows)} positives={len(positive_rows)}",
f"uncertain_positives={len(uncertain_positive_rows)} true_negatives={len(true_negative_rows)}",
))
import_counts = f"classifier_rejects={len(reject_rows)} detector_imported={len(detector_rows)}"
print(f"Imported manual review queue: {review_counts} {import_counts}")
print(f"Classifier manifest: {classifier_manifest}")
print(f"Runtime eval manifest: {runtime_manifest}")
print(f"Detector import manifest: {detector_manifest}")
print(f"Classifier reject manifest: {reject_manifest}")
print(f"Summary: {summary_path}")
return 0
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import copy
from pathlib import Path
import torch
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Linearly interpolate compatible Ultralytics checkpoints.")
parser.add_argument("--base", type=Path, required=True, help="Baseline checkpoint used at alpha=0.")
parser.add_argument("--candidate", type=Path, required=True, help="Candidate checkpoint used at alpha=1.")
parser.add_argument("--alpha", type=float, required=True, help="Candidate weight in [0, 1].")
parser.add_argument("--output", type=Path, required=True, help="Interpolated checkpoint path.")
return parser.parse_args()
def checkpoint_model(checkpoint: dict):
model = checkpoint.get("ema") or checkpoint.get("model")
if model is None:
raise ValueError("Checkpoint contains neither model nor ema weights")
return model.float()
def main() -> int:
args = parse_args()
if not 0.0 <= args.alpha <= 1.0:
raise ValueError("--alpha must be between 0 and 1")
base_checkpoint = torch.load(args.base.expanduser().resolve(), map_location="cpu", weights_only=False)
candidate_checkpoint = torch.load(args.candidate.expanduser().resolve(), map_location="cpu", weights_only=False)
base_model = checkpoint_model(base_checkpoint)
candidate_model = checkpoint_model(candidate_checkpoint)
base_state = base_model.state_dict()
candidate_state = candidate_model.state_dict()
if base_state.keys() != candidate_state.keys():
raise ValueError("Checkpoint model state keys differ")
interpolated_state = {}
for key, base_value in base_state.items():
candidate_value = candidate_state[key]
if base_value.shape != candidate_value.shape:
raise ValueError(f"Checkpoint tensor shape differs for {key}")
if torch.is_floating_point(base_value):
interpolated_state[key] = base_value * (1.0 - args.alpha) + candidate_value * args.alpha
else:
interpolated_state[key] = candidate_value if args.alpha >= 0.5 else base_value
interpolated_model = copy.deepcopy(base_model)
interpolated_model.load_state_dict(interpolated_state)
output_checkpoint = dict(base_checkpoint)
output_checkpoint.update({
"model": interpolated_model,
"ema": None,
"optimizer": None,
"epoch": -1,
"best_fitness": None,
})
output = args.output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
torch.save(output_checkpoint, output)
print(f"Wrote alpha={args.alpha:.4f} interpolated checkpoint to {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -100,7 +100,15 @@ def iter_context_frames(clip_root: Path, window: ebl.BookmarkWindow, search_befo
yield relative_time_s, clip_path, source_time_s, frame_bgr
def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, class_id: int, proposal_confidence: float, box, full_detection):
def _score_expanded_candidate(
daemon: slv.SpeedLimitVisionDaemon,
frame_bgr,
class_id: int,
proposal_confidence: float,
box,
full_detection,
use_ocr: bool = True,
):
frame_height, frame_width = frame_bgr.shape[:2]
x1, y1, x2, y2 = box
box_width = x2 - x1
@@ -123,7 +131,7 @@ def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, cla
is_regulatory = daemon._is_regulatory_speed_sign(sign_crop) or class_id == 2
model_read = daemon._classify_speed_limit_from_model(sign_crop)
ocr_read = daemon._read_speed_limit_from_crop(sign_crop)
ocr_read = daemon._read_speed_limit_from_crop(sign_crop) if use_ocr else None
if model_read is None and ocr_read is None:
continue
@@ -132,9 +140,14 @@ def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, cla
if read_result is None or read_result[0] not in slv.SCHOOL_ZONE_SPEED_VALUES:
continue
elif not is_regulatory:
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
if use_ocr:
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
continue
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
elif model_read is None or model_read[1] < slv.DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE:
continue
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
else:
read_result = model_read
else:
if model_read is not None and ocr_read is not None and model_read[0] == ocr_read[0]:
read_result = (model_read[0], max(model_read[1], ocr_read[1]))
@@ -169,7 +182,7 @@ def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, cla
return best
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, use_ocr: bool = True):
full_detection = daemon._detect_sign(frame_bgr)
best = None
@@ -177,7 +190,15 @@ def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
if class_id == 1:
continue
candidate = _score_expanded_candidate(daemon, frame_bgr, class_id, proposal_confidence, (x1, y1, x2, y2), full_detection)
candidate = _score_expanded_candidate(
daemon,
frame_bgr,
class_id,
proposal_confidence,
(x1, y1, x2, y2),
full_detection,
use_ocr=use_ocr,
)
if candidate is None:
continue
if best is None or candidate["score"] > best["score"]:
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from pathlib import Path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Merge fingerprint-compatible speed-limit manual review queues.")
parser.add_argument("inputs", type=Path, nargs="+", help="Queue directories or manual_review_queue.csv files.")
parser.add_argument("--output-dir", type=Path, required=True, help="Destination queue directory.")
return parser.parse_args()
def queue_paths(input_path: Path) -> tuple[Path, Path]:
resolved = input_path.expanduser().resolve()
if resolved.is_dir():
return resolved / "manual_review_queue.csv", resolved / "manual_review_summary.json"
return resolved, resolved.with_name("manual_review_summary.json")
def main() -> int:
args = parse_args()
rows_by_key: dict[str, dict[str, str]] = {}
summaries_by_route: dict[str, dict[str, object]] = {}
fieldnames: list[str] | None = None
mining_fingerprint = ""
model_fingerprint = ""
for input_path in args.inputs:
queue_path, summary_path = queue_paths(input_path)
if not queue_path.is_file() or not summary_path.is_file():
raise FileNotFoundError(f"Queue or summary missing for {input_path}")
summary = json.loads(summary_path.read_text(encoding="utf-8"))
current_mining = str(summary.get("mining_fingerprint", ""))
current_model = str(summary.get("model_fingerprint", ""))
if not current_mining or not current_model:
raise RuntimeError(f"Queue is not fingerprinted: {input_path}")
if mining_fingerprint and current_mining != mining_fingerprint:
raise RuntimeError(f"Mining fingerprint mismatch: {input_path}")
if model_fingerprint and current_model != model_fingerprint:
raise RuntimeError(f"Model fingerprint mismatch: {input_path}")
mining_fingerprint = current_mining
model_fingerprint = current_model
with queue_path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
current_fields = list(reader.fieldnames or [])
if fieldnames is not None and current_fields != fieldnames:
raise RuntimeError(f"Queue fields differ: {input_path}")
fieldnames = current_fields
for row in reader:
key = row.get("record_key", "")
if key:
rows_by_key[key] = row
for route_summary in summary.get("routes", []):
route = str(route_summary.get("route", ""))
if route:
summaries_by_route[route] = route_summary
rows = sorted(rows_by_key.values(), key=lambda row: (-float(row.get("review_priority") or 0.0), row["record_key"]))
route_summaries = sorted(summaries_by_route.values(), key=lambda item: str(item["route"]))
output_dir = args.output_dir.expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
queue_output = output_dir / "manual_review_queue.csv"
with queue_output.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames or [])
writer.writeheader()
writer.writerows(rows)
summary_output = output_dir / "manual_review_summary.json"
summary_output.write_text(json.dumps({
"mining_fingerprint": mining_fingerprint,
"model_fingerprint": model_fingerprint,
"manifest": str(queue_output),
"rows": len(rows),
"candidates": sum(row.get("detector_class") != "negative_empty" for row in rows),
"negatives": sum(row.get("detector_class") == "negative_empty" for row in rows),
"routes": route_summaries,
}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"Merged {len(rows)} rows from {len(route_summaries)} routes into {queue_output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import hashlib
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 localize_bookmark_signs import configure_models # type: ignore
else:
from .localize_bookmark_signs import configure_models
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Mine model-confusing negative crops into an integrated classifier reject class.")
parser.add_argument("--manifest", type=Path, action="append", required=True, help="Reviewed runtime manifest. Repeat for multiple sets.")
parser.add_argument("--models-dir", type=Path, required=True, help="Detector/classifier ONNX bundle used to mine hard negatives.")
parser.add_argument("--dataset", type=Path, required=True, help="Classifier dataset containing train/reject and val/reject.")
parser.add_argument("--split", choices=("train", "val"), default="train", help="Destination dataset split.")
parser.add_argument("--classifier-min-confidence", type=float, default=0.55, help="Minimum wrong speed confidence to mine.")
parser.add_argument("--max-crops", type=int, default=2000, help="Maximum reject crops to add.")
parser.add_argument("--overwrite", action="store_true", help="Overwrite an existing crop with the same content hash.")
return parser.parse_args()
def first_present(row: dict[str, str], keys: tuple[str, ...]) -> str:
for key in keys:
value = row.get(key, "").strip()
if value:
return value
return ""
def expected_value(row: dict[str, str]) -> int | None:
value = first_present(row, ("expected_speed_limit_mph", "speed_limit_mph", "dominant_value"))
if not value:
return None
try:
return int(float(value))
except ValueError:
return None
def is_reviewed_negative(row: dict[str, str]) -> bool:
sample_type = row.get("sample_type", "").lower()
review_status = row.get("review_status", "").lower()
explicit_negative = row.get("negative", "").strip().lower() in ("1", "true", "yes")
return explicit_negative or "negative" in sample_type or review_status in ("negative", "reject")
def iter_negative_images(manifests: list[Path]):
seen: set[Path] = set()
for manifest in manifests:
with manifest.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle):
if not is_reviewed_negative(row):
continue
image_text = first_present(row, ("dataset_image", "frame_path", "source_frame", "image_path"))
if not image_text:
continue
image_path = Path(image_text).expanduser().resolve()
if image_path in seen or not image_path.is_file():
continue
seen.add(image_path)
yield image_path
def crop_hash(crop) -> str:
ok, encoded = cv2.imencode(".jpg", crop, [cv2.IMWRITE_JPEG_QUALITY, 94])
if not ok:
return ""
return hashlib.sha256(encoded.tobytes()).hexdigest()[:20]
def remove_appledouble_files(root: Path) -> int:
removed = 0
for path in root.rglob("._*"):
if path.is_file():
path.unlink()
removed += 1
return removed
def main() -> int:
args = parse_args()
configure_models(args.models_dir)
slv.US_CLASSIFIER_MIN_CONFIDENCE = args.classifier_min_confidence
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
dataset_root = args.dataset.expanduser().resolve()
appledouble_removed = remove_appledouble_files(dataset_root)
output_dir = dataset_root / args.split / "reject"
output_dir.mkdir(parents=True, exist_ok=True)
added = 0
frames = 0
proposals = 0
for image_path in iter_negative_images(args.manifest):
if added >= args.max_crops:
break
frame_bgr = cv2.imread(str(image_path))
if frame_bgr is None:
continue
frames += 1
frame_height, frame_width = frame_bgr.shape[:2]
for _proposal_confidence, class_id, (x1, y1, x2, y2) in daemon._collect_detector_classifier_proposals(frame_bgr):
if class_id == 1:
continue
proposals += 1
box_width = x2 - x1
box_height = y2 - y1
if box_width <= 0 or box_height <= 0:
continue
for expansion_index, (left, top, right, bottom, _weight) in enumerate(slv.DETECTOR_CLASSIFIER_EXPANSIONS):
crop_x1 = max(int(x1 - box_width * left), 0)
crop_y1 = max(int(y1 - box_height * top), 0)
crop_x2 = min(int(x2 + box_width * right), frame_width)
crop_y2 = min(int(y2 + box_height * bottom), frame_height)
crop = frame_bgr[crop_y1:crop_y2, crop_x1:crop_x2]
if crop.size == 0 or daemon._classify_speed_limit_from_model(crop) is None:
continue
digest = crop_hash(crop)
if not digest:
continue
output_path = output_dir / f"hardneg_{digest}_e{expansion_index}.jpg"
if output_path.exists() and not args.overwrite:
continue
cv2.imwrite(str(output_path), crop, [cv2.IMWRITE_JPEG_QUALITY, 94])
added += 1
if added >= args.max_crops:
break
if added >= args.max_crops:
break
if added:
cache_path = dataset_root / f"{args.split}.cache"
if cache_path.is_file():
cache_path.unlink()
summary = f"Hard-negative mining complete: frames={frames} proposals={proposals} added={added}"
summary += f" appledouble_removed={appledouble_removed}"
print(summary)
print(f"Reject dataset: {output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -30,10 +30,14 @@ else:
DEFAULT_ROUTE_BUNDLE_STATE_DIR = Path("/Volumes/T5/starpilot_speed_limit/analysis/route_bundles/state")
DEFAULT_WORKSPACE = Path("/Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean")
DEFAULT_REVIEW_MANIFEST_NAME = "route_training_samples.csv"
MINING_RUN_SCHEMA_VERSION = 2
MPH_PER_MS = 2.2369362920544
VALID_WEAK_LABEL_VALUES = set(slv.US_CLASSIFIER_SPEED_VALUES)
POSITIVE_FIELDNAMES = [
"record_key",
"mining_run_id",
"mining_fingerprint",
"model_fingerprint",
"route",
"dongle_id",
"log_id",
@@ -83,6 +87,9 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--clip-root", type=Path, default=preferred_clip_root(), help="Route realdata root.")
parser.add_argument("--bundle-state-dir", type=Path, default=DEFAULT_ROUTE_BUNDLE_STATE_DIR, help="Completed extraction marker directory.")
parser.add_argument("--models-dir", type=Path, help="Optional model directory for mining with non-repo ONNXs.")
parser.add_argument("--model-only", action="store_true", help="Use detector/classifier output without OCR when weak-labeling signs.")
parser.add_argument("--run-id", help="Version this mining pass. Use 'auto' to derive an id from the ONNX bundle.")
parser.add_argument("--output-root", type=Path, help="Output root for this pass. Defaults to a versioned staging directory when --run-id is set.")
parser.add_argument("--manifest-out", type=Path, help=f"Review manifest path. Defaults to <workspace>/review/{DEFAULT_REVIEW_MANIFEST_NAME}.")
parser.add_argument("--sample-every", type=float, default=4.0, help="Seconds between regular video samples.")
parser.add_argument("--seek-sampling", action="store_true", help="Seek directly to sampled frames instead of sequentially grabbing through each segment.")
@@ -110,6 +117,54 @@ def safe_key(text: str) -> str:
return text.replace("/", "_").replace("|", "_").replace(":", "_")
def model_bundle_fingerprint() -> str:
digest = hashlib.sha256()
for path in (slv.US_DETECTOR_MODEL_PATH, slv.US_CLASSIFIER_MODEL_PATH):
resolved = Path(path).expanduser().resolve()
digest.update(resolved.name.encode("utf-8"))
with resolved.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def mining_configuration_fingerprint(args: argparse.Namespace, model_fingerprint: str) -> str:
config = {
"schema_version": MINING_RUN_SCHEMA_VERSION,
"model_fingerprint": model_fingerprint,
"model_only": args.model_only,
"sample_every": args.sample_every,
"transition_radius": args.transition_radius,
"transition_step": args.transition_step,
"max_frames_per_route": args.max_frames_per_route,
"max_positives_per_route": args.max_positives_per_route,
"max_negatives_per_route": args.max_negatives_per_route,
"positive_min_score": args.positive_min_score,
"no_map_min_score": args.no_map_min_score,
"min_proposal_confidence": args.min_proposal_confidence,
"min_width": args.min_width,
"min_height": args.min_height,
"next_limit_distance": args.next_limit_distance,
}
digest = hashlib.sha256(json.dumps(config, sort_keys=True).encode("utf-8"))
for source_path in (Path(__file__), Path(score_frame.__code__.co_filename), Path(slv.__file__)):
digest.update(source_path.resolve().read_bytes())
return digest.hexdigest()
def resolve_run_id(requested: str | None, model_fingerprint: str, mining_fingerprint: str) -> str:
if not requested:
return ""
run_id = (
f"model_{model_fingerprint[:12]}_run_{mining_fingerprint[:12]}"
if requested == "auto"
else safe_key(requested.strip())
)
if not run_id:
raise ValueError("--run-id must not be empty")
return run_id
def parse_route_id(text: str) -> tuple[str, str, str]:
normalized = text.strip().replace("|", "/")
if "/" not in normalized:
@@ -390,6 +445,8 @@ def should_keep_positive(scored: dict, speed_limit_mph: int, consistent_count: i
return False
if float(scored["proposal_confidence"]) < args.min_proposal_confidence:
return False
if args.model_only and relation not in ("agree_current", "agree_next"):
return False
if relation in ("agree_current", "agree_next"):
return float(scored["score"]) >= args.positive_min_score and consistent_count >= 1
return float(scored["score"]) >= args.no_map_min_score and consistent_count >= 2
@@ -443,16 +500,33 @@ def write_sample(frame_bgr, image_path: Path, label_path: Path, label_text: str,
return True
def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse.Namespace, workspace: Path, clip_root: Path, manifest_path: Path, route_state_dir: Path) -> dict[str, int | str | float]:
def mine_route(
route_id: str,
daemon: slv.SpeedLimitVisionDaemon,
args: argparse.Namespace,
output_root: Path,
clip_root: Path,
manifest_path: Path,
route_state_dir: Path,
run_id: str,
mining_fingerprint: str,
model_fingerprint: str,
) -> dict[str, int | str | float]:
route_id, dongle_id, log_id = parse_route_id(route_id)
route_key = safe_key(route_id)
state_path = route_state_dir / f"{route_key}.json"
if run_id and state_path.exists():
state = json.loads(state_path.read_text(encoding="utf-8"))
if state.get("model_fingerprint") != model_fingerprint or state.get("mining_fingerprint") != mining_fingerprint:
raise RuntimeError(
f"Mining state fingerprint mismatch for {route_id}. Use a new --run-id or output root instead of mixing runs."
)
if state_path.exists() and not args.force:
return {"route": route_id, "status": "skipped", "positives": 0, "negatives": 0, "scored": 0}
split = route_split(route_id, args.val_route_modulo, args.val_route_remainder)
image_dir = ensure_dir(workspace / "detector" / "images" / split)
label_dir = ensure_dir(workspace / "detector" / "labels" / split)
image_dir = ensure_dir(output_root / "detector" / "images" / split)
label_dir = ensure_dir(output_root / "detector" / "labels" / split)
segments = route_segments(clip_root, log_id)
if not segments:
return {"route": route_id, "status": "missing_segments", "positives": 0, "negatives": 0, "scored": 0}
@@ -487,7 +561,7 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
continue
scored_frames += 1
scored = score_frame(daemon, frame_bgr)
scored = score_frame(daemon, frame_bgr, use_ocr=not args.model_only)
context = nearest_context(contexts, time_s)
if scored is None:
@@ -501,6 +575,9 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
negatives += 1
route_rows.append({
"record_key": record_key,
"mining_run_id": run_id,
"mining_fingerprint": mining_fingerprint,
"model_fingerprint": model_fingerprint,
"route": route_id,
"dongle_id": dongle_id,
"log_id": log_id,
@@ -548,6 +625,9 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
bbox = ",".join(str(value) for value in scored["box"])
route_rows.append({
"record_key": record_key,
"mining_run_id": run_id,
"mining_fingerprint": mining_fingerprint,
"model_fingerprint": model_fingerprint,
"route": route_id,
"dongle_id": dongle_id,
"log_id": log_id,
@@ -585,9 +665,13 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
if not args.dry_run:
merge_review_rows(manifest_path, route_rows)
merge_value_labels(workspace / "classifier" / "value_labels.csv", value_rows)
merge_value_labels(output_root / "classifier" / "value_labels.csv", value_rows)
state_path.write_text(json.dumps({
"route": route_id,
"mining_run_id": run_id,
"mining_fingerprint": mining_fingerprint,
"model_fingerprint": model_fingerprint,
"model_only": args.model_only,
"status": "mined",
"positives": positives,
"negatives": negatives,
@@ -613,20 +697,41 @@ def main() -> int:
args = parse_args()
workspace = resolve_workspace(args.workspace)
clip_root = args.clip_root.expanduser().resolve()
manifest_path = args.manifest_out.expanduser().resolve() if args.manifest_out else (ensure_dir(workspace / "review") / DEFAULT_REVIEW_MANIFEST_NAME)
route_state_dir = ensure_dir(workspace / "review" / "route_training_samples_state")
routes = read_routes(args)
if not routes:
raise SystemExit("No routes to mine. Pass route ids, --routes-file, or completed bundle markers.")
configure_models(args.models_dir)
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = not args.model_only
model_fingerprint = model_bundle_fingerprint()
mining_fingerprint = mining_configuration_fingerprint(args, model_fingerprint)
run_id = resolve_run_id(args.run_id, model_fingerprint, mining_fingerprint)
if args.output_root:
output_root = args.output_root.expanduser().resolve()
elif run_id:
output_root = workspace / "staging" / "route_mining" / run_id
else:
output_root = workspace
manifest_path = args.manifest_out.expanduser().resolve() if args.manifest_out else (ensure_dir(output_root / "review") / DEFAULT_REVIEW_MANIFEST_NAME)
route_state_dir = ensure_dir(output_root / "review" / "route_training_samples_state")
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
total_positive = 0
total_negative = 0
total_scored = 0
for index, route in enumerate(routes, start=1):
result = mine_route(route, daemon, args, workspace, clip_root, manifest_path, route_state_dir)
result = mine_route(
route,
daemon,
args,
output_root,
clip_root,
manifest_path,
route_state_dir,
run_id,
mining_fingerprint,
model_fingerprint,
)
total_positive += int(result.get("positives", 0))
total_negative += int(result.get("negatives", 0))
total_scored += int(result.get("scored", 0))
@@ -641,6 +746,8 @@ def main() -> int:
flush=True,
)
print(f"Review manifest: {manifest_path}", flush=True)
print(f"Model fingerprint: {model_fingerprint}", flush=True)
print(f"Mining fingerprint: {mining_fingerprint}", flush=True)
return 0
@@ -193,6 +193,21 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--right-roi-min-confidence", type=float, help="Override the right ROI detector minimum confidence.")
parser.add_argument("--classifier-min-confidence", type=float, help="Override the value classifier confidence threshold.")
parser.add_argument("--full-frame-ocr", action="store_true", help="Enable the expensive full-frame OCR fallback during replay.")
crop_ocr_group = parser.add_mutually_exclusive_group()
crop_ocr_group.add_argument("--crop-ocr", action="store_true", dest="crop_ocr", default=None, help="Enable crop OCR confirmation during replay.")
crop_ocr_group.add_argument("--no-crop-ocr", action="store_false", dest="crop_ocr", help="Replay the model-only detector/classifier path.")
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("--initial-speed-limit", type=int, default=0, help="Seed route replay with a currently published speed limit.")
return parser.parse_args()
@@ -299,6 +314,14 @@ def configure_runtime_options(args: argparse.Namespace) -> None:
if args.full_frame_ocr:
slv.FULL_FRAME_OCR_FALLBACK_ENABLED = True
if args.crop_ocr is not None:
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = args.crop_ocr
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.right_roi_bounds:
parts = [float(part.strip()) for part in args.right_roi_bounds.split(",")]
@@ -347,6 +370,7 @@ def replay_route(
measured_inference_seconds: float,
measured_base_inference_seconds: float | None = None,
measured_classifier_forward_seconds: float = 0.0,
initial_speed_limit_mph: int = 0,
) -> tuple[RouteSummary, list[dict[str, str]]]:
daemon = RouteReplayDaemon(
runtime_context,
@@ -354,6 +378,7 @@ def replay_route(
measured_base_inference_seconds,
measured_classifier_forward_seconds,
)
daemon.published_speed_limit_mph = initial_speed_limit_mph
for segment_path in segments:
segment = segment_index(segment_path)
capture = cv2.VideoCapture(str(segment_path))
@@ -397,11 +422,8 @@ def replay_route(
capture.release()
if progress:
print(
f" seg {segment:02d}: sampled={daemon.sampled_frames} inference={daemon.inference_frames} "
f"events={len(daemon.events)}",
flush=True,
)
counts = f"sampled={daemon.sampled_frames} inference={daemon.inference_frames} events={len(daemon.events)}"
print(f" seg {segment:02d}: {counts}", flush=True)
return summarize(log_id, len(segments), runtime_context is not None, daemon), daemon.events
@@ -490,6 +512,7 @@ def main() -> int:
args.measured_inference_seconds,
args.measured_base_inference_seconds,
args.measured_classifier_forward_seconds,
args.initial_speed_limit,
)
all_events.extend((log_id, event) for event in events)
summary_line = "".join((
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from collections import Counter
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 compare_manual_review_queues import classify_change # type: ignore
from mine_route_training_samples import model_bundle_fingerprint # type: ignore
from replay_route_runtime import configure_models # type: ignore
else:
from .compare_manual_review_queues import classify_change
from .mine_route_training_samples import model_bundle_fingerprint
from .replay_route_runtime import configure_models
EXTRA_FIELDS = (
"comparison_change",
"before_speed_limit_mph",
"before_confidence",
"rescore_status",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Rescore stored manual-review crops with a new value classifier.")
parser.add_argument("--input", type=Path, required=True, help="Baseline manual_review_queue.csv.")
parser.add_argument("--models-dir", type=Path, required=True, help="Candidate detector/classifier ONNX directory.")
parser.add_argument("--output", type=Path, required=True, help="Rescored review-compatible output CSV.")
parser.add_argument("--confidence-delta", type=float, default=0.05, help="Minimum confidence-only change to report.")
parser.add_argument("--shard-count", type=int, default=1, help="Number of deterministic row shards.")
parser.add_argument("--shard-index", type=int, default=0, help="Zero-based row shard processed by this invocation.")
return parser.parse_args()
def rescore_row(
row: dict[str, str],
daemon: slv.SpeedLimitVisionDaemon,
model_fingerprint: str,
confidence_delta: float,
) -> dict[str, str]:
output = dict(row)
before_speed = row.get("candidate_speed_limit_mph", "")
before_confidence = row.get("candidate_confidence", "")
output["before_speed_limit_mph"] = before_speed
output["before_confidence"] = before_confidence
output["model_fingerprint"] = model_fingerprint
crop_path = Path(row.get("crop_path", "")).expanduser()
crop = cv2.imread(str(crop_path)) if crop_path.is_file() else None
if crop is None:
output["comparison_change"] = "unreadable"
output["rescore_status"] = "unreadable"
return output
result = daemon._classify_speed_limit_from_model(crop)
if result is None:
output["candidate_speed_limit_mph"] = ""
output["candidate_confidence"] = ""
output["model_read"] = ""
else:
speed_limit_mph, confidence = result
output["candidate_speed_limit_mph"] = str(int(speed_limit_mph))
output["candidate_confidence"] = f"{float(confidence):.8f}"
output["model_read"] = f"{int(speed_limit_mph)}@{float(confidence):.3f}"
change = classify_change(row, output, confidence_delta)
output["comparison_change"] = change or "unchanged"
output["rescore_status"] = "rescored_crop"
return output
def main() -> int:
args = parse_args()
if args.shard_count <= 0 or not 0 <= args.shard_index < args.shard_count:
raise ValueError("--shard-index must be within --shard-count")
configure_models(args.models_dir)
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = False
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
fingerprint = model_bundle_fingerprint()
input_path = args.input.expanduser().resolve()
with input_path.open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
input_fields = list(reader.fieldnames or [])
rows = [row for index, row in enumerate(reader) if index % args.shard_count == args.shard_index]
output_rows: list[dict[str, str]] = []
changes: Counter[str] = Counter()
transitions: Counter[str] = Counter()
for index, row in enumerate(rows, start=1):
rescored = rescore_row(row, daemon, fingerprint, args.confidence_delta)
output_rows.append(rescored)
change = rescored["comparison_change"]
changes[change] += 1
before_speed = rescored["before_speed_limit_mph"] or "none"
after_speed = rescored.get("candidate_speed_limit_mph", "") or "none"
if change != "unchanged":
transitions[f"{before_speed}->{after_speed}"] += 1
if index % 1000 == 0:
print(f"Rescored {index}/{len(rows)} crops", flush=True)
output_path = args.output.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [*input_fields, *(field for field in EXTRA_FIELDS if field not in input_fields)]
with output_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(output_rows)
summary = {
"input": str(input_path),
"output": str(output_path),
"models_dir": str(args.models_dir.expanduser().resolve()),
"model_fingerprint": fingerprint,
"shard_count": args.shard_count,
"shard_index": args.shard_index,
"rows": len(output_rows),
"changes": dict(sorted(changes.items())),
"transitions": dict(sorted(transitions.items(), key=lambda item: (-item[1], item[0]))),
}
output_path.with_suffix(".json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
from collections import Counter, defaultdict, deque
from pathlib import Path
PRIORITY_SPEED_ORDER = (60, 65, 55, 50, 45, 40, 35, 30, 25, 20, 70, 15, 75)
COMPARISON_PRIORITY_BONUS = {
"value_changed": 4.0,
"gained_read": 3.0,
"lost_read": 3.0,
"added_proposal": 1.0,
"removed_proposal": 1.0,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Select a diverse, high-value subset from a raw speed-limit review queue.")
parser.add_argument("--input", type=Path, required=True, help="Raw manual_review_queue.csv.")
parser.add_argument("--output", type=Path, required=True, help="Selected manual_review_queue.csv.")
parser.add_argument("--max-rows", type=int, default=1000, help="Maximum selected rows.")
parser.add_argument("--max-per-route", type=int, default=30, help="Maximum selected rows from one route.")
parser.add_argument("--max-per-speed", type=int, default=140, help="Maximum rows for each predicted speed.")
parser.add_argument(
"--max-primary-speed",
type=int,
default=0,
help="Optional per-speed cap for the primary 30-65 mph range; defaults to --max-per-speed.",
)
parser.add_argument(
"--max-speed-20",
type=int,
default=0,
help="Optional cap for 20 mph rows; defaults to --max-per-speed.",
)
parser.add_argument("--max-no-read", type=int, default=220, help="Maximum detector proposals without a value read.")
parser.add_argument("--max-school", type=int, default=100, help="Maximum school-zone candidates.")
parser.add_argument("--max-advisory", type=int, default=100, help="Maximum advisory candidates.")
parser.add_argument(
"--min-seconds-per-route-speed",
type=float,
default=3.0,
help="Minimum spacing between selected rows from the same route, segment, and predicted-speed bucket.",
)
return parser.parse_args()
def read_rows(path: Path) -> tuple[list[str], list[dict[str, str]]]:
with path.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
return list(reader.fieldnames or []), list(reader)
def predicted_speed(row: dict[str, str]) -> int:
text = (row.get("candidate_speed_limit_mph") or "").strip()
if not text and row.get("comparison_change") == "lost_read":
text = (row.get("before_speed_limit_mph") or "").strip()
try:
return int(float(text)) if text else 0
except ValueError:
return 0
def bucket_name(row: dict[str, str]) -> str:
detector_class = row.get("detector_class", "")
if detector_class == "school_zone_speed_limit":
return "school"
if detector_class == "advisory_speed_limit":
return "advisory"
speed = predicted_speed(row)
return f"speed_{speed}" if speed else "no_read"
def priority(row: dict[str, str]) -> tuple[float, float, float, str]:
review_priority = (
float(row.get("review_priority") or 0.0) +
COMPARISON_PRIORITY_BONUS.get(row.get("comparison_change", ""), 0.0)
)
proposal_confidence = float(row.get("proposal_confidence") or 0.0)
candidate_confidence = float(row.get("candidate_confidence") or 0.0)
return review_priority, proposal_confidence, candidate_confidence, row.get("record_key", "")
def temporal_key(row: dict[str, str]) -> tuple[str, str, str, float] | None:
try:
frame_time_s = float(row.get("frame_time_s", ""))
except ValueError:
return None
return row.get("route", ""), row.get("segment", ""), bucket_name(row), frame_time_s
def round_robin_routes(rows: list[dict[str, str]]):
by_route: dict[str, deque[dict[str, str]]] = defaultdict(deque)
for row in sorted(rows, key=priority, reverse=True):
by_route[row.get("route", "")].append(row)
route_order = deque(sorted(by_route, key=lambda route: priority(by_route[route][0]), reverse=True))
while route_order:
route = route_order.popleft()
yield by_route[route].popleft()
if by_route[route]:
route_order.append(route)
def select_rows(rows: list[dict[str, str]], args: argparse.Namespace) -> list[dict[str, str]]:
buckets: dict[str, list[dict[str, str]]] = defaultdict(list)
for row in rows:
if row.get("detector_class") == "negative_empty":
continue
buckets[bucket_name(row)].append(row)
max_primary_speed = int(getattr(args, "max_primary_speed", 0))
max_speed_20 = int(getattr(args, "max_speed_20", 0))
primary_limit = max_primary_speed if max_primary_speed > 0 else args.max_per_speed
speed_20_limit = max_speed_20 if max_speed_20 > 0 else args.max_per_speed
limits = {
f"speed_{speed}": (
primary_limit if 30 <= speed <= 65 else speed_20_limit if speed == 20 else args.max_per_speed
)
for speed in PRIORITY_SPEED_ORDER
}
limits.update({"school": args.max_school, "advisory": args.max_advisory, "no_read": args.max_no_read})
ordered_buckets = [f"speed_{speed}" for speed in PRIORITY_SPEED_ORDER] + ["school", "advisory", "no_read"]
ordered_buckets.extend(sorted(set(buckets) - set(ordered_buckets)))
selected: list[dict[str, str]] = []
selected_keys: set[str] = set()
route_counts: Counter[str] = Counter()
bucket_counts: Counter[str] = Counter()
selected_times: dict[tuple[str, str, str], list[float]] = defaultdict(list)
min_spacing = max(float(getattr(args, "min_seconds_per_route_speed", 0.0)), 0.0)
def try_add(row: dict[str, str], bucket: str) -> bool:
key = row.get("record_key", "")
route = row.get("route", "")
if not key or key in selected_keys or route_counts[route] >= args.max_per_route:
return False
if bucket_counts[bucket] >= limits.get(bucket, args.max_per_speed):
return False
time_key = temporal_key(row)
if time_key is not None and min_spacing > 0.0:
route_key = time_key[:3]
if any(abs(time_key[3] - selected_time) < min_spacing for selected_time in selected_times[route_key]):
return False
selected.append(row)
selected_keys.add(key)
route_counts[route] += 1
bucket_counts[bucket] += 1
if time_key is not None:
selected_times[time_key[:3]].append(time_key[3])
return True
iterators = {bucket: iter(round_robin_routes(buckets[bucket])) for bucket in ordered_buckets if buckets.get(bucket)}
active = deque(bucket for bucket in ordered_buckets if bucket in iterators)
while active and len(selected) < args.max_rows:
bucket = active.popleft()
iterator = iterators[bucket]
added = False
for row in iterator:
if try_add(row, bucket):
added = True
break
if added and bucket_counts[bucket] < limits.get(bucket, args.max_per_speed):
active.append(bucket)
if len(selected) < args.max_rows:
for row in sorted(rows, key=priority, reverse=True):
if len(selected) >= args.max_rows:
break
try_add(row, bucket_name(row))
return sorted(selected, key=priority, reverse=True)
def main() -> int:
args = parse_args()
fieldnames, rows = read_rows(args.input)
selected = select_rows(rows, args)
output = args.output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(selected)
summary = {
"input": str(args.input.expanduser().resolve()),
"output": str(output),
"input_rows": len(rows),
"selected_rows": len(selected),
"routes": len({row.get("route", "") for row in selected}),
"buckets": dict(sorted(Counter(bucket_name(row) for row in selected).items())),
"min_seconds_per_route_speed": args.min_seconds_per_route_speed,
}
summary_path = output.with_name("manual_review_selection_summary.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())
@@ -4,8 +4,8 @@ from __future__ import annotations
import argparse
import csv
import json
import time
from datetime import UTC, datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -40,7 +40,8 @@ HTML = r"""<!doctype html>
<style>
:root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; background: #111; color: #eee; }
header { display: flex; align-items: center; gap: 16px; padding: 10px 14px; background: #1b1b1b; border-bottom: 1px solid #333; position: sticky; top: 0; z-index: 2; }
header { display: flex; align-items: center; gap: 16px; padding: 10px 14px; background: #1b1b1b; }
header { border-bottom: 1px solid #333; position: sticky; top: 0; z-index: 2; }
button, select, input, textarea { background: #222; color: #eee; border: 1px solid #555; border-radius: 6px; padding: 7px 9px; font: inherit; }
button { cursor: pointer; }
button:hover { background: #333; }
@@ -63,6 +64,7 @@ HTML = r"""<!doctype html>
.speed button { min-width: 42px; }
.muted { color: #aaa; }
.status { white-space: nowrap; }
.hint { margin-bottom: 10px; padding: 8px; border-left: 3px solid #3f7ec8; background: #20252b; color: #dbeaff; }
@media (max-width: 980px) { main { grid-template-columns: 1fr; } img { max-height: 45vh; } }
</style>
</head>
@@ -79,7 +81,8 @@ HTML = r"""<!doctype html>
<option value="negative">Negatives</option>
</select>
<span class="status" id="status"></span>
<span class="muted">Keys: Space/p accept model, type speed to correct, u uncertain, i/x ignore, Enter save correction, j/k next/prev, s school, r regulatory, a advisory</span>
<span class="muted">Keys: Space/p accept model, type speed to correct, u uncertain, i/x ignore,
Enter save correction, j/k next/prev, s school, r regulatory, a advisory</span>
</header>
<main>
<section class="images">
@@ -93,6 +96,7 @@ HTML = r"""<!doctype html>
</div>
</section>
<aside class="panel">
<div class="hint" id="taskHint" hidden></div>
<div class="meta" id="meta"></div>
<h3>Speed</h3>
<div class="buttons speed" id="speedButtons"></div>
@@ -270,9 +274,26 @@ function inferredType(row) {
return "regulatory";
}
function auditHint(row) {
if (!row) return "";
const previous = row.before_speed_limit_mph || "no value";
const candidate = row.candidate_speed_limit_mph || "no value";
if (row.comparison_change === "advisory_type_reaudit") {
return `Recheck sign type for reviewed ${candidate}: Space = regulatory; A then Space = advisory; S then Space = school zone.`;
}
if (row.comparison_change === "lost_read") {
return `Current model rejected previous ${previous}. Type the speed if readable; press i if it is not a speed sign.`;
}
if (row.comparison_change === "gained_read") return `Current model gained ${candidate}. Press Space if correct, or type the correct speed.`;
if (row.comparison_change === "value_changed") {
return `Model changed ${previous} to ${candidate}. Press Space for the current value, or type the correct speed.`;
}
return "";
}
function ensureSpeedSignType() {
if (draft.review_sign_type === "not_speed_limit") {
draft.review_sign_type = inferredType(current);
draft.review_sign_type = "regulatory";
setActive("#typeButtons button", "type", draft.review_sign_type);
}
}
@@ -329,12 +350,15 @@ function render() {
draft = {
review_status: current.review_status || "",
review_speed_limit_mph: current.review_speed_limit_mph || current.candidate_speed_limit_mph || "",
review_sign_type: current.review_sign_type || inferredType(current),
review_sign_type: current.review_sign_type || "regulatory",
review_bbox: current.review_bbox || current.bbox || "",
review_ignore_reason: current.review_ignore_reason || "",
review_notes: current.review_notes || "",
};
qs("#status").textContent = `${index + 1}/${rows.length}`;
const hint = auditHint(current);
qs("#taskHint").textContent = hint;
qs("#taskHint").hidden = !hint;
qs("#cropImg").src = current.crop_path ? `/media/${current.record_key}/crop` : "";
qs("#frameImg").src = `/media/${current.record_key}/frame`;
setBBox(draft.review_bbox, false);
@@ -343,13 +367,18 @@ function render() {
setActive("#speedButtons button", "speed", draft.review_speed_limit_mph);
setActive("#typeButtons button", "type", draft.review_sign_type);
setActive("#statusButtons button", "status", draft.review_status);
qs("#acceptPredBtn").disabled = !current.candidate_speed_limit_mph;
const mapSummary = `${current.map_relation} current=${current.map_current_speed_limit_mph}`;
const nextMapSummary = `next=${current.map_next_speed_limit_mph} dist=${current.map_next_speed_limit_distance_m}`;
qs("#meta").innerHTML = [
["record", current.record_key],
["change", current.comparison_change],
["previous", `${current.before_speed_limit_mph || "none"} @ ${current.before_confidence || ""}`],
["candidate", `${current.candidate_speed_limit_mph || "none"} @ ${current.candidate_confidence || ""}`],
["class", `${current.detector_class} (${current.proposal_confidence})`],
["bbox", draft.review_bbox],
["reasons", current.review_reasons],
["map", `${current.map_relation} current=${current.map_current_speed_limit_mph} next=${current.map_next_speed_limit_mph} dist=${current.map_next_speed_limit_distance_m}`],
["map", `${mapSummary} ${nextMapSummary}`],
["reads", current.read_sources],
["route", current.route],
["time", `seg ${current.segment} @ ${current.frame_time_s}s`],
@@ -390,7 +419,6 @@ qs("#clearBBoxBtn").onclick = () => setBBox("");
qs("#acceptPredBtn").onclick = () => {
if (!current) return;
draft.review_speed_limit_mph = current.candidate_speed_limit_mph || "";
draft.review_sign_type = inferredType(current);
save(true, "accepted");
};
qsa("#typeButtons button").forEach(btn => btn.onclick = () => {
@@ -529,7 +557,7 @@ class ReviewServer(ThreadingHTTPServer):
class Handler(BaseHTTPRequestHandler):
server: ReviewServer
def log_message(self, format, *args): # noqa: A003
def log_message(self, _format, *args):
return
def send_json(self, data, status=HTTPStatus.OK):
@@ -548,7 +576,7 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def do_GET(self): # noqa: N802
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_text(HTML)
@@ -582,7 +610,7 @@ class Handler(BaseHTTPRequestHandler):
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self): # noqa: N802
def do_POST(self):
if urlparse(self.path).path != "/api/review":
self.send_error(HTTPStatus.NOT_FOUND)
return
@@ -596,7 +624,7 @@ class Handler(BaseHTTPRequestHandler):
if record_key not in self.server.row_by_key:
self.send_error(HTTPStatus.BAD_REQUEST, "Unknown record_key")
return
label = {"record_key": record_key, "reviewed_at_unix": f"{time.time():.3f}"}
label = {"record_key": record_key, "reviewed_at_unix": f"{datetime.now(UTC).timestamp():.3f}"}
for field in QUEUE_REVIEW_FIELDS:
label[field] = str(payload.get(field) or "")
self.server.labels[record_key] = label
@@ -0,0 +1,193 @@
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
import_queue = load_local_module("import_manual_review_queue")
build_review_classifier = load_local_module("build_review_classifier_dataset")
select_queue = load_local_module("select_manual_review_queue")
compare_queues = load_local_module("compare_manual_review_queues")
rescore_queue = load_local_module("rescore_manual_review_queue")
is_classifier_reject = import_queue.is_classifier_reject
split_for_key = import_queue.split_for_key
split_group_key = import_queue.split_group_key
select_rows = select_queue.select_rows
def review_row(key: str, route: str, speed: int, priority: float) -> dict[str, str]:
return {
"record_key": key,
"route": route,
"detector_class": "regulatory_speed_limit",
"candidate_speed_limit_mph": str(speed),
"review_priority": str(priority),
"proposal_confidence": "0.8",
"candidate_confidence": "0.99",
}
def test_review_selection_balances_routes_and_speeds():
rows = [
*(review_row(f"a-{index}", "route-a", 30, 10 - index) for index in range(5)),
*(review_row(f"b-{index}", "route-b", 65, 10 - index) for index in range(5)),
]
args = Namespace(
max_rows=4,
max_per_route=2,
max_per_speed=2,
max_no_read=2,
max_school=2,
max_advisory=2,
)
selected = select_rows(rows, args)
assert len(selected) == 4
assert sum(row["route"] == "route-a" for row in selected) == 2
assert sum(row["route"] == "route-b" for row in selected) == 2
def test_review_selection_prioritizes_model_disagreements():
unchanged = review_row("unchanged", "route-a", 30, 5.0)
changed = {**review_row("changed", "route-a", 30, 2.0), "comparison_change": "value_changed"}
args = Namespace(max_rows=1, max_per_route=2, max_per_speed=2, max_no_read=2, max_school=2, max_advisory=2)
assert select_rows([unchanged, changed], args)[0]["record_key"] == "changed"
def test_review_selection_deduplicates_adjacent_same_speed_frames():
first = {**review_row("first", "route-a", 40, 5.0), "segment": "1", "frame_time_s": "10.0"}
duplicate = {**review_row("duplicate", "route-a", 40, 4.0), "segment": "1", "frame_time_s": "11.0"}
different_speed = {**review_row("different", "route-a", 45, 3.0), "segment": "1", "frame_time_s": "11.0"}
args = Namespace(
max_rows=3,
max_per_route=3,
max_per_speed=3,
max_no_read=3,
max_school=3,
max_advisory=3,
min_seconds_per_route_speed=3.0,
)
selected_keys = {row["record_key"] for row in select_rows([first, duplicate, different_speed], args)}
assert selected_keys == {"first", "different"}
def test_lost_reads_remain_balanced_by_previous_speed():
row = {
**review_row("lost", "route-a", 0, 5.0),
"candidate_speed_limit_mph": "",
"before_speed_limit_mph": "55",
"comparison_change": "lost_read",
}
assert select_queue.predicted_speed(row) == 55
assert select_queue.bucket_name(row) == "speed_55"
def test_primary_speed_limits_override_general_limit():
rows = [
*(review_row(f"primary-{index}", f"route-p-{index}", 40, 10 - index) for index in range(3)),
*(review_row(f"low-{index}", f"route-l-{index}", 15, 10 - index) for index in range(3)),
]
args = Namespace(
max_rows=6,
max_per_route=1,
max_per_speed=1,
max_primary_speed=3,
max_speed_20=2,
max_no_read=1,
max_school=1,
max_advisory=1,
min_seconds_per_route_speed=0.0,
)
selected = select_rows(rows, args)
assert sum(select_queue.predicted_speed(row) == 40 for row in selected) == 3
assert sum(select_queue.predicted_speed(row) == 15 for row in selected) == 1
def test_manual_import_splits_adjacent_frames_by_route():
rows = [{"record_key": f"frame-{index}", "route": "dongle/route"} for index in range(8)]
splits = {split_for_key(split_group_key(row), 5, 0) for row in rows}
assert len(splits) == 1
def test_only_reviewed_proposal_crops_become_classifier_rejects(tmp_path):
crop_path = tmp_path / "crop.jpg"
crop_path.write_bytes(b"crop")
row = {
"review_status": "ignore",
"review_sign_type": "not_speed_limit",
"detector_class": "regulatory_speed_limit",
"crop_path": str(crop_path),
}
assert is_classifier_reject(row)
assert not is_classifier_reject({**row, "detector_class": "negative_empty"})
assert not is_classifier_reject({**row, "review_status": "uncertain"})
def test_advisory_positive_is_a_runtime_negative(tmp_path):
crop_path = tmp_path / "crop.jpg"
frame_path = tmp_path / "frame.jpg"
crop_path.write_bytes(b"crop")
frame_path.write_bytes(b"frame")
row = {
"record_key": "advisory",
"review_status": "corrected",
"review_sign_type": "advisory",
"review_speed_limit_mph": "40",
"crop_path": str(crop_path),
"frame_path": str(frame_path),
}
assert import_queue.is_advisory_positive(row)
runtime_row = import_queue.runtime_row(row, "val", "advisory_negative")
assert runtime_row["sample_type"] == "advisory_negative"
assert runtime_row["speed_limit_mph"] == 40
assert build_review_classifier.is_advisory(row)
assert build_review_classifier.keep_advisory_reject({**row, "split": "val"}, 0.0)
assert not build_review_classifier.keep_advisory_reject({**row, "split": "train"}, 0.0)
def test_queue_comparison_distinguishes_gained_lost_and_changed_reads():
no_read = {"candidate_speed_limit_mph": "", "candidate_confidence": ""}
speed_20 = {"candidate_speed_limit_mph": "20", "candidate_confidence": "0.99"}
speed_30 = {"candidate_speed_limit_mph": "30", "candidate_confidence": "0.98"}
assert compare_queues.classify_change(no_read, speed_20, 0.05) == "gained_read"
assert compare_queues.classify_change(speed_20, no_read, 0.05) == "lost_read"
assert compare_queues.classify_change(speed_20, speed_30, 0.05) == "value_changed"
assert compare_queues.classify_change(speed_20, {**speed_20, "candidate_confidence": "0.97"}, 0.05) == ""
def test_rescore_row_preserves_before_values_and_marks_gained_read(tmp_path):
crop_path = tmp_path / "crop.jpg"
import cv2
import numpy as np
cv2.imwrite(str(crop_path), np.zeros((32, 32, 3), dtype=np.uint8))
daemon = type("Daemon", (), {"_classify_speed_limit_from_model": lambda self, crop: (20, 0.99)})()
row = {
"record_key": "candidate",
"crop_path": str(crop_path),
"candidate_speed_limit_mph": "",
"candidate_confidence": "",
}
rescored = rescore_queue.rescore_row(row, daemon, "model", 0.05)
assert rescored["before_speed_limit_mph"] == ""
assert rescored["candidate_speed_limit_mph"] == "20"
assert rescored["comparison_change"] == "gained_read"