This commit is contained in:
firestar5683
2026-07-01 11:32:48 -05:00
parent 5234a121f9
commit 9d2149d495
2 changed files with 1019 additions and 0 deletions
@@ -0,0 +1,607 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import math
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 common import ensure_dir, preferred_clip_root, resolve_workspace # type: ignore
from localize_bookmark_signs import configure_models # type: ignore
from mine_route_training_samples import ( # type: ignore
MapContext,
completed_marker_routes,
fmt_read,
iter_frames_at_times,
load_segment_map_context,
nearest_context,
parse_route_id,
read_frame_at,
route_segments,
safe_key,
sample_times,
transition_times,
)
else:
from .common import ensure_dir, preferred_clip_root, resolve_workspace
from .localize_bookmark_signs import configure_models
from .mine_route_training_samples import (
MapContext,
completed_marker_routes,
fmt_read,
iter_frames_at_times,
load_segment_map_context,
nearest_context,
parse_route_id,
read_frame_at,
route_segments,
safe_key,
sample_times,
transition_times,
)
DEFAULT_WORKSPACE = Path("/Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean")
DEFAULT_ROUTE_BUNDLE_STATE_DIR = Path("/Volumes/T5/starpilot_speed_limit/analysis/route_bundles/state")
DEFAULT_OUTPUT_NAME = "manual_review_queue_v1"
PRIORITY_SPEED_VALUES = frozenset((30, 35, 40, 45, 50, 55, 60, 65))
FIELDNAMES = [
"record_key",
"route",
"dongle_id",
"log_id",
"segment",
"frame_time_s",
"frame_path",
"crop_path",
"source_video_path",
"bbox",
"crop_bbox",
"class_id",
"detector_class",
"proposal_confidence",
"candidate_speed_limit_mph",
"candidate_confidence",
"model_read",
"ocr_read",
"full_detection",
"read_sources",
"read_support_count",
"is_regulatory",
"map_current_speed_limit_mph",
"map_next_speed_limit_mph",
"map_next_speed_limit_distance_m",
"map_relation",
"review_priority",
"review_reasons",
"review_status",
"review_speed_limit_mph",
"review_sign_type",
"review_bbox",
"review_ignore_reason",
"review_notes",
]
@dataclass(frozen=True)
class ReadVote:
speed_limit_mph: int
confidence: float
source: str
expansion_index: int
crop_box: tuple[int, int, int, int]
is_regulatory: bool
weight: float
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build a broad manual-review queue from comma route footage.")
parser.add_argument("routes", nargs="*", help="Optional route ids like 'dongle/logid'. Defaults to extracted route bundle markers.")
parser.add_argument("--routes-file", type=Path, help="Optional text file with one route id per line.")
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
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("--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-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.")
parser.add_argument("--no-read-min-proposal-confidence", type=float, default=0.12, help="Keep no-value detector boxes above this confidence.")
parser.add_argument("--school-zone-min-proposal-confidence", type=float, default=0.02, help="Loose floor for school-zone detector candidates.")
parser.add_argument("--min-width", type=int, default=12, help="Minimum detector bbox width.")
parser.add_argument("--min-height", type=int, default=16, help="Minimum detector bbox height.")
parser.add_argument("--dedupe-seconds", type=float, default=2.5, help="Approximate time bucket used to dedupe adjacent frames.")
parser.add_argument("--limit-routes", type=int, default=0, help="Optional maximum routes to mine.")
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("--dry-run", action="store_true", help="Score frames and print counts without writing images/CSV.")
return parser.parse_args()
def read_routes(args: argparse.Namespace) -> list[str]:
routes = list(args.routes)
if args.routes_file:
routes.extend(
line.strip()
for line in args.routes_file.expanduser().read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
)
if not routes:
routes = completed_marker_routes(args.bundle_state_dir.expanduser().resolve())
deduped = []
seen = set()
for route in routes:
normalized, _, _ = parse_route_id(route)
if normalized in seen:
continue
seen.add(normalized)
deduped.append(normalized)
if args.limit_routes > 0:
deduped = deduped[:args.limit_routes]
return deduped
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
x1 = max(min(int(x1), frame_width), 0)
x2 = max(min(int(x2), frame_width), 0)
y1 = max(min(int(y1), frame_height), 0)
y2 = max(min(int(y2), frame_height), 0)
if x2 <= x1 or y2 <= y1:
return None
return x1, y1, x2, y2
def expanded_box(
box: tuple[int, int, int, int],
frame_shape: tuple[int, int, int],
expand_left: float,
expand_top: float,
expand_right: float,
expand_bottom: float,
) -> tuple[int, int, int, int] | None:
x1, y1, x2, y2 = box
width = x2 - x1
height = y2 - y1
return clamp_box((
int(x1 - width * expand_left),
int(y1 - height * expand_top),
int(x2 + width * expand_right),
int(y2 + height * expand_bottom),
), frame_shape)
def add_vote(votes: list[ReadVote], result, source: str, expansion_index: int, crop_box: tuple[int, int, int, int], is_regulatory: bool, weight: float) -> None:
if result is None:
return
speed_limit_mph, confidence = result
if int(speed_limit_mph) not in slv.US_CLASSIFIER_SPEED_VALUES:
return
votes.append(ReadVote(int(speed_limit_mph), float(confidence), source, expansion_index, crop_box, is_regulatory, weight))
def best_source_read(votes: list[ReadVote], source: str):
source_votes = [vote for vote in votes if vote.source == source]
if not source_votes:
return None
vote = max(source_votes, key=lambda item: item.confidence)
return vote.speed_limit_mph, vote.confidence
def choose_vote(votes: list[ReadVote]) -> tuple[ReadVote | None, int]:
if not votes:
return None, 0
support: dict[int, int] = {}
best_by_speed: dict[int, ReadVote] = {}
score_by_speed: dict[int, float] = {}
for vote in votes:
support[vote.speed_limit_mph] = support.get(vote.speed_limit_mph, 0) + 1
if vote.speed_limit_mph not in best_by_speed or vote.confidence > best_by_speed[vote.speed_limit_mph].confidence:
best_by_speed[vote.speed_limit_mph] = vote
score_by_speed[vote.speed_limit_mph] = score_by_speed.get(vote.speed_limit_mph, 0.0) + vote.confidence * vote.weight
speed_limit_mph = max(
score_by_speed,
key=lambda speed: (
score_by_speed[speed] + max(support[speed] - 1, 0) * slv.DETECTOR_CLASSIFIER_SUPPORT_BONUS,
best_by_speed[speed].confidence,
),
)
return best_by_speed[speed_limit_mph], support[speed_limit_mph]
def classify_map_relation(speed_limit_mph: int, context: MapContext, next_limit_distance_m: float = 180.0) -> str:
if speed_limit_mph <= 0:
if context.current_speed_limit_mph or context.next_speed_limit_mph:
return "map_present_no_read"
return "no_map_no_read"
if context.current_speed_limit_mph == speed_limit_mph:
return "agree_current"
if context.next_speed_limit_mph == speed_limit_mph and 0.0 < context.next_speed_limit_distance_m <= next_limit_distance_m:
return "agree_next"
if context.current_speed_limit_mph or context.next_speed_limit_mph:
return "map_disagreement"
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:
score = proposal_confidence * 2.0
if chosen_vote is not None:
score += chosen_vote.confidence * 2.0
score += min(support_count, 4) * 0.18
if chosen_vote.speed_limit_mph in PRIORITY_SPEED_VALUES:
score += 2.0
if chosen_vote.speed_limit_mph in slv.SCHOOL_ZONE_SPEED_VALUES:
score += 0.8
if class_id == 2:
score += 2.2
if "map_disagreement" in map_relation:
score += 1.2
if "read_disagreement" in reasons:
score += 1.0
if "no_value_read" in reasons:
score += 0.5
if "low_detector_confidence" in reasons:
score += 0.25
return score
def summarize_votes(votes: list[ReadVote]) -> str:
if not votes:
return ""
compact = []
for vote in sorted(votes, key=lambda item: (-item.confidence, item.source, item.expansion_index))[:8]:
compact.append(f"{vote.source}{vote.expansion_index}:{vote.speed_limit_mph}@{vote.confidence:.3f}")
return "|".join(compact)
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
box = clamp_box(raw_box, frame_bgr.shape)
if box is None:
return None
x1, y1, x2, y2 = box
width = x2 - x1
height = y2 - y1
if width < args.min_width or height < args.min_height:
return None
if class_id == 2:
if proposal_confidence < args.school_zone_min_proposal_confidence:
return None
elif proposal_confidence < args.min_proposal_confidence:
return None
votes: list[ReadVote] = []
any_regulatory = False
for expansion_index, (expand_left, expand_top, expand_right, expand_bottom, weight) in enumerate(slv.DETECTOR_CLASSIFIER_EXPANSIONS):
crop_box = expanded_box(box, frame_bgr.shape, expand_left, expand_top, expand_right, expand_bottom)
if crop_box is None:
continue
crop = frame_bgr[crop_box[1]:crop_box[3], crop_box[0]:crop_box[2]]
if crop.size == 0:
continue
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)
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:
return None
model_read = best_source_read(votes, "model")
ocr_read = best_source_read(votes, "ocr")
candidate_speed = chosen_vote.speed_limit_mph if chosen_vote is not None else 0
map_relation = classify_map_relation(candidate_speed, context)
reasons: set[str] = set()
if class_id == 2:
reasons.add("school_zone_candidate")
if class_id == 1:
reasons.add("advisory_candidate")
if chosen_vote is None:
reasons.add("no_value_read")
elif chosen_vote.speed_limit_mph in PRIORITY_SPEED_VALUES:
reasons.add("priority_30_65")
if proposal_confidence < 0.12:
reasons.add("low_detector_confidence")
if model_read is not None and ocr_read is not None and int(model_read[0]) != int(ocr_read[0]):
reasons.add("read_disagreement")
vote_values = {vote.speed_limit_mph for vote in votes}
if len(vote_values) > 1:
reasons.add("multi_value_votes")
if "disagreement" in map_relation:
reasons.add("map_disagreement")
elif map_relation.startswith("agree"):
reasons.add("map_agreement")
elif map_relation.startswith("map_present"):
reasons.add("map_context")
crop_box = chosen_vote.crop_box if chosen_vote is not None else box
priority = score_review_priority(class_id, proposal_confidence, chosen_vote, support_count, map_relation, reasons)
return {
"bbox": box,
"crop_bbox": crop_box,
"class_id": class_id,
"detector_class": slv.US_DETECTOR_CLASSES.get(class_id, str(class_id)),
"proposal_confidence": proposal_confidence,
"candidate_speed_limit_mph": "" if chosen_vote is None else chosen_vote.speed_limit_mph,
"candidate_confidence": "" if chosen_vote is None else f"{chosen_vote.confidence:.6f}",
"model_read": fmt_read(model_read),
"ocr_read": fmt_read(ocr_read),
"full_detection": fmt_read(full_detection),
"read_sources": summarize_votes(votes),
"read_support_count": support_count,
"is_regulatory": any_regulatory,
"map_relation": map_relation,
"review_priority": priority,
"review_reasons": "|".join(sorted(reasons)),
}
def write_image(path: Path, image, quality: int, overwrite: bool) -> None:
if path.exists() and not overwrite:
return
ensure_dir(path.parent)
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)
time_bucket = int(math.floor(time_s / max(dedupe_seconds, 0.1)))
grid_x = int(center_x * 12)
grid_y = int(center_y * 8)
value = candidate["candidate_speed_limit_mph"] or "none"
return f"{route_id}|{segment}|{time_bucket}|{candidate['class_id']}|{value}|{grid_x}|{grid_y}"
def candidate_record_key(route_key: str, segment: int, time_s: float, index: int) -> str:
sample_index = f"s{segment:04d}_t{time_s:07.3f}_c{index:02d}".replace(".", "p")
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]]:
route_id, dongle_id, log_id = parse_route_id(route_id)
route_key = safe_key(route_id)
clip_root = args.clip_root.expanduser().resolve()
segments = route_segments(clip_root, log_id)
if not segments:
return [], {"route": route_id, "status": "missing_segments", "frames": 0, "candidates": 0, "negatives": 0}
frame_dir = output_dir / "frames"
crop_dir = output_dir / "crops"
rows_by_cluster: dict[str, dict[str, object]] = {}
route_candidates = 0
negatives = 0
frames_scored = 0
for segment in segments:
if frames_scored >= args.max_frames_per_route or route_candidates >= args.max_candidates_per_route:
break
contexts = load_segment_map_context(segment.path)
capture = cv2.VideoCapture(str(segment.video_path))
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
frame_count = capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0
duration_s = frame_count / fps if frame_count > 0 else 60.0
times = sample_times(duration_s, args.sample_every, transition_times(contexts), args.transition_radius, args.transition_step)
if args.seek_sampling:
frame_iter = ((time_s, read_frame_at(capture, fps, time_s)) for time_s in times)
else:
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:
break
if frame_bgr is None:
continue
frames_scored += 1
context = nearest_context(contexts, time_s)
full_detection = daemon._detect_sign(frame_bgr) if args.include_full_detection else None
proposals = daemon._collect_detector_classifier_proposals(frame_bgr)
candidate_index = 0
kept_any = False
for proposal in proposals:
candidate = analyze_proposal(daemon, frame_bgr, proposal, full_detection, context, args)
if candidate is None:
continue
candidate_index += 1
record_key = candidate_record_key(route_key, segment.segment, time_s, candidate_index)
frame_path = frame_dir / f"{record_key}.jpg"
crop_path = crop_dir / f"{record_key}_crop.jpg"
x1, y1, x2, y2 = candidate["crop_bbox"]
crop = frame_bgr[y1:y2, x1:x2]
row = {
"record_key": record_key,
"route": route_id,
"dongle_id": dongle_id,
"log_id": log_id,
"segment": segment.segment,
"frame_time_s": f"{time_s:.3f}",
"frame_path": str(frame_path),
"crop_path": str(crop_path),
"source_video_path": str(segment.video_path),
"bbox": ",".join(str(value) for value in candidate["bbox"]),
"crop_bbox": ",".join(str(value) for value in candidate["crop_bbox"]),
"class_id": candidate["class_id"],
"detector_class": candidate["detector_class"],
"proposal_confidence": f"{candidate['proposal_confidence']:.6f}",
"candidate_speed_limit_mph": candidate["candidate_speed_limit_mph"],
"candidate_confidence": candidate["candidate_confidence"],
"model_read": candidate["model_read"],
"ocr_read": candidate["ocr_read"],
"full_detection": candidate["full_detection"],
"read_sources": candidate["read_sources"],
"read_support_count": candidate["read_support_count"],
"is_regulatory": str(bool(candidate["is_regulatory"])),
"map_current_speed_limit_mph": context.current_speed_limit_mph,
"map_next_speed_limit_mph": context.next_speed_limit_mph,
"map_next_speed_limit_distance_m": f"{context.next_speed_limit_distance_m:.1f}",
"map_relation": candidate["map_relation"],
"review_priority": f"{candidate['review_priority']:.4f}",
"review_reasons": candidate["review_reasons"],
"review_status": "",
"review_speed_limit_mph": "",
"review_sign_type": "",
"review_bbox": "",
"review_ignore_reason": "",
"review_notes": "",
}
key = cluster_key(route_id, segment.segment, time_s, frame_bgr.shape, 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:
write_image(frame_path, frame_bgr, quality=88, overwrite=args.overwrite_images)
write_image(crop_path, crop, quality=92, overwrite=args.overwrite_images)
rows_by_cluster[key] = row
kept_any = True
if kept_any:
route_candidates = len(rows_by_cluster)
elif negatives < args.max_negatives_per_route:
record_key = candidate_record_key(route_key, segment.segment, time_s, 0)
frame_path = frame_dir / f"{record_key}.jpg"
row = {
"record_key": record_key,
"route": route_id,
"dongle_id": dongle_id,
"log_id": log_id,
"segment": segment.segment,
"frame_time_s": f"{time_s:.3f}",
"frame_path": str(frame_path),
"crop_path": "",
"source_video_path": str(segment.video_path),
"bbox": "",
"crop_bbox": "",
"class_id": "",
"detector_class": "negative_empty",
"proposal_confidence": "",
"candidate_speed_limit_mph": "",
"candidate_confidence": "",
"model_read": "",
"ocr_read": "",
"full_detection": fmt_read(full_detection),
"read_sources": "",
"read_support_count": "",
"is_regulatory": "",
"map_current_speed_limit_mph": context.current_speed_limit_mph,
"map_next_speed_limit_mph": context.next_speed_limit_mph,
"map_next_speed_limit_distance_m": f"{context.next_speed_limit_distance_m:.1f}",
"map_relation": classify_map_relation(0, context),
"review_priority": "0.1000",
"review_reasons": "negative_empty",
"review_status": "",
"review_speed_limit_mph": "",
"review_sign_type": "",
"review_bbox": "",
"review_ignore_reason": "",
"review_notes": "",
}
rows_by_cluster[f"{route_id}|negative|{segment.segment}|{negatives}"] = row
negatives += 1
if not args.dry_run:
write_image(frame_path, frame_bgr, quality=82, overwrite=args.overwrite_images)
capture.release()
rows = sorted(rows_by_cluster.values(), key=lambda row: (-float(row["review_priority"]), str(row["record_key"])))
if args.max_candidates_per_route > 0:
positives = [row for row in rows if row["detector_class"] != "negative_empty"][:args.max_candidates_per_route]
negative_rows = [row for row in rows if row["detector_class"] == "negative_empty"][:args.max_negatives_per_route]
rows = sorted(positives + negative_rows, key=lambda row: (-float(row["review_priority"]), str(row["record_key"])))
return rows, {
"route": route_id,
"status": "mined",
"frames": frames_scored,
"candidates": sum(1 for row in rows if row["detector_class"] != "negative_empty"),
"negatives": sum(1 for row in rows if row["detector_class"] == "negative_empty"),
}
def write_manifest(path: Path, rows: list[dict[str, object]]) -> None:
ensure_dir(path.parent)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=FIELDNAMES, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def write_summary(path: Path, manifest_path: Path, rows: list[dict[str, object]], summaries: list[dict[str, object]]) -> None:
path.write_text(json.dumps({
"routes": summaries,
"manifest": str(manifest_path),
"rows": len(rows),
"candidates": sum(1 for row in rows if row["detector_class"] != "negative_empty"),
"negatives": sum(1 for row in rows if row["detector_class"] == "negative_empty"),
}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def main() -> int:
try:
cv2.setLogLevel(1)
except Exception:
pass
args = parse_args()
configure_models(args.models_dir)
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"
routes = read_routes(args)
if not routes:
raise SystemExit("No routes to mine. Pass route ids, --routes-file, or extracted route bundle markers.")
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
all_rows: list[dict[str, object]] = []
summaries = []
summary_path = output_dir / "manual_review_summary.json"
for index, route_id in enumerate(routes, start=1):
rows, summary = mine_route(route_id, daemon, args, output_dir)
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']}"
)
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)
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)
print(f"Wrote {len(all_rows)} review rows to {manifest_path}")
print(f"Summary: {summary_path}")
else:
print(f"Dry run rows={len(all_rows)} candidates={sum(1 for row in all_rows if row['detector_class'] != 'negative_empty')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
QUEUE_REVIEW_FIELDS = [
"review_status",
"review_speed_limit_mph",
"review_sign_type",
"review_bbox",
"review_ignore_reason",
"review_notes",
]
LABEL_FIELDNAMES = [
"record_key",
"review_status",
"review_speed_limit_mph",
"review_sign_type",
"review_bbox",
"review_ignore_reason",
"review_notes",
"reviewed_at_unix",
]
HTML = r"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Speed Limit Review</title>
<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; }
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; }
button.primary { background: #285f9f; border-color: #3f7ec8; }
button.warn { background: #6b3b18; border-color: #9a5a25; }
main { display: grid; grid-template-columns: minmax(360px, 1fr) 420px; gap: 12px; padding: 12px; }
.images { display: grid; gap: 12px; align-content: start; }
.panel { background: #181818; border: 1px solid #303030; border-radius: 8px; padding: 10px; }
.imageWrap { display: grid; place-items: center; background: #050505; border-radius: 6px; min-height: 160px; overflow: hidden; }
img { max-width: 100%; max-height: 52vh; object-fit: contain; }
.crop img { image-rendering: auto; max-height: 28vh; }
.meta { display: grid; gap: 5px; font-size: 13px; color: #ddd; }
.meta code { color: #cfe5ff; overflow-wrap: anywhere; }
.buttons { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.buttons button.active { outline: 2px solid #ddd; background: #345; }
textarea { width: 100%; min-height: 70px; box-sizing: border-box; }
.speed button { min-width: 42px; }
.muted { color: #aaa; }
.status { white-space: nowrap; }
@media (max-width: 980px) { main { grid-template-columns: 1fr; } img { max-height: 45vh; } }
</style>
</head>
<body>
<header>
<button id="prevBtn">Prev</button>
<button id="nextBtn" class="primary">Next</button>
<select id="filter">
<option value="unreviewed">Unreviewed</option>
<option value="all">All</option>
<option value="school">School Zone</option>
<option value="priority">Priority 30-65</option>
<option value="disagreement">Disagreement</option>
<option value="negative">Negatives</option>
</select>
<span class="status" id="status"></span>
<span class="muted">Keys: j/k next/prev, 0 ignore, s school, r regulatory, a advisory</span>
</header>
<main>
<section class="images">
<div class="panel crop">
<div class="muted">Crop</div>
<div class="imageWrap"><img id="cropImg"></div>
</div>
<div class="panel">
<div class="muted">Frame</div>
<div class="imageWrap"><img id="frameImg"></div>
</div>
</section>
<aside class="panel">
<div class="meta" id="meta"></div>
<h3>Speed</h3>
<div class="buttons speed" id="speedButtons"></div>
<h3>Type</h3>
<div class="buttons" id="typeButtons">
<button data-type="regulatory">Regulatory</button>
<button data-type="school_zone">School Zone</button>
<button data-type="advisory">Advisory</button>
<button data-type="construction">Construction</button>
<button data-type="not_speed_limit">Not Speed Limit</button>
</div>
<h3>Status</h3>
<div class="buttons" id="statusButtons">
<button data-status="accepted" class="primary">Accept</button>
<button data-status="corrected">Corrected</button>
<button data-status="ignore" class="warn">Ignore</button>
<button data-status="needs_later">Needs Later</button>
</div>
<label>Ignore reason</label>
<input id="ignoreReason" placeholder="false_positive, blurry, side_road, duplicate">
<label>Notes</label>
<textarea id="notes"></textarea>
<div class="buttons">
<button id="saveBtn" class="primary">Save</button>
<button id="acceptPredBtn">Accept Prediction</button>
</div>
</aside>
</main>
<script>
const speeds = [15,20,25,30,35,40,45,50,55,60,65,70,75];
let rows = [];
let index = 0;
let current = null;
let draft = {};
function qs(sel) { return document.querySelector(sel); }
function qsa(sel) { return Array.from(document.querySelectorAll(sel)); }
async function loadQueue() {
const filter = qs("#filter").value;
const res = await fetch(`/api/queue?filter=${encodeURIComponent(filter)}`);
const data = await res.json();
rows = data.rows;
index = 0;
render();
}
function setActive(selector, attr, value) {
qsa(selector).forEach(btn => btn.classList.toggle("active", btn.dataset[attr] === String(value)));
}
function renderSpeedButtons() {
const root = qs("#speedButtons");
root.innerHTML = "";
for (const speed of speeds) {
const btn = document.createElement("button");
btn.textContent = speed;
btn.dataset.speed = speed;
btn.onclick = () => {
draft.review_speed_limit_mph = String(speed);
setActive("#speedButtons button", "speed", speed);
};
root.appendChild(btn);
}
}
function render() {
current = rows[index] || null;
if (!current) {
qs("#status").textContent = "No rows";
qs("#meta").innerHTML = "";
qs("#cropImg").removeAttribute("src");
qs("#frameImg").removeAttribute("src");
return;
}
draft = {
review_status: current.review_status || "",
review_speed_limit_mph: current.review_speed_limit_mph || "",
review_sign_type: current.review_sign_type || "",
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}`;
qs("#cropImg").src = current.crop_path ? `/media/${current.record_key}/crop` : "";
qs("#frameImg").src = `/media/${current.record_key}/frame`;
qs("#ignoreReason").value = draft.review_ignore_reason;
qs("#notes").value = draft.review_notes;
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("#meta").innerHTML = [
["record", current.record_key],
["candidate", `${current.candidate_speed_limit_mph || "none"} @ ${current.candidate_confidence || ""}`],
["class", `${current.detector_class} (${current.proposal_confidence})`],
["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}`],
["reads", current.read_sources],
["route", current.route],
["time", `seg ${current.segment} @ ${current.frame_time_s}s`],
].map(([k,v]) => `<div><span class="muted">${k}:</span> <code>${String(v || "")}</code></div>`).join("");
}
async function save(moveNext = true) {
if (!current) return;
draft.review_ignore_reason = qs("#ignoreReason").value;
draft.review_notes = qs("#notes").value;
const payload = {record_key: current.record_key, ...draft};
const res = await fetch("/api/review", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload)});
if (!res.ok) {
alert(await res.text());
return;
}
Object.assign(current, payload);
if (moveNext) next();
else render();
}
function next() { if (index < rows.length - 1) { index += 1; render(); } }
function prev() { if (index > 0) { index -= 1; render(); } }
renderSpeedButtons();
qs("#filter").onchange = loadQueue;
qs("#nextBtn").onclick = next;
qs("#prevBtn").onclick = prev;
qs("#saveBtn").onclick = () => save(true);
qs("#acceptPredBtn").onclick = () => {
if (!current) return;
draft.review_status = "accepted";
draft.review_speed_limit_mph = current.candidate_speed_limit_mph || "";
draft.review_sign_type = current.detector_class === "school_zone_speed_limit" ? "school_zone" :
current.detector_class === "advisory_speed_limit" ? "advisory" :
current.detector_class === "negative_empty" ? "not_speed_limit" : "regulatory";
save(true);
};
qsa("#typeButtons button").forEach(btn => btn.onclick = () => {
draft.review_sign_type = btn.dataset.type;
setActive("#typeButtons button", "type", draft.review_sign_type);
});
qsa("#statusButtons button").forEach(btn => btn.onclick = () => {
draft.review_status = btn.dataset.status;
setActive("#statusButtons button", "status", draft.review_status);
});
document.addEventListener("keydown", ev => {
if (ev.target.tagName === "TEXTAREA" || ev.target.tagName === "INPUT") return;
if (ev.key === "j") next();
if (ev.key === "k") prev();
if (ev.key === "s") { draft.review_sign_type = "school_zone"; setActive("#typeButtons button", "type", "school_zone"); }
if (ev.key === "r") { draft.review_sign_type = "regulatory"; setActive("#typeButtons button", "type", "regulatory"); }
if (ev.key === "a") { draft.review_sign_type = "advisory"; setActive("#typeButtons button", "type", "advisory"); }
if (ev.key === "0") { draft.review_status = "ignore"; draft.review_sign_type = "not_speed_limit"; setActive("#statusButtons button", "status", "ignore"); setActive("#typeButtons button", "type", "not_speed_limit"); }
if (ev.key === "Enter") save(true);
});
loadQueue();
</script>
</body>
</html>
"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Serve a small browser UI for manually reviewing speed-limit queue rows.")
parser.add_argument("--manifest", type=Path, required=True, help="manual_review_queue.csv from build_manual_review_queue.py")
parser.add_argument("--labels-out", type=Path, help="Defaults to <manifest_dir>/manual_review_labels.csv")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
return parser.parse_args()
def load_csv(path: Path) -> list[dict[str, str]]:
with path.open("r", encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle))
def load_labels(path: Path) -> dict[str, dict[str, str]]:
if not path.is_file():
return {}
rows = load_csv(path)
return {row["record_key"]: row for row in rows if row.get("record_key")}
def write_labels(path: Path, labels: dict[str, dict[str, str]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=LABEL_FIELDNAMES, extrasaction="ignore")
writer.writeheader()
for key in sorted(labels):
writer.writerow(labels[key])
def merged_rows(rows: list[dict[str, str]], labels: dict[str, dict[str, str]]) -> list[dict[str, str]]:
merged = []
for row in rows:
item = dict(row)
label = labels.get(row.get("record_key", ""))
if label:
item.update({field: label.get(field, "") for field in QUEUE_REVIEW_FIELDS})
merged.append(item)
return merged
def filter_rows(rows: list[dict[str, str]], filter_name: str) -> list[dict[str, str]]:
if filter_name == "all":
return rows
if filter_name == "school":
return [row for row in rows if "school_zone" in row.get("detector_class", "") or "school_zone_candidate" in row.get("review_reasons", "")]
if filter_name == "priority":
return [row for row in rows if "priority_30_65" in row.get("review_reasons", "")]
if filter_name == "disagreement":
return [row for row in rows if "disagreement" in row.get("review_reasons", "") or "multi_value_votes" in row.get("review_reasons", "")]
if filter_name == "negative":
return [row for row in rows if row.get("detector_class") == "negative_empty"]
return [row for row in rows if not row.get("review_status")]
class ReviewServer(ThreadingHTTPServer):
def __init__(self, server_address, handler_class, manifest_path: Path, labels_path: Path):
super().__init__(server_address, handler_class)
self.manifest_path = manifest_path
self.labels_path = labels_path
self.rows = load_csv(manifest_path)
self.row_by_key = {row["record_key"]: row for row in self.rows}
self.labels = load_labels(labels_path)
class Handler(BaseHTTPRequestHandler):
server: ReviewServer
def log_message(self, format, *args): # noqa: A003
return
def send_json(self, data, status=HTTPStatus.OK):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_text(self, text: str, status=HTTPStatus.OK, content_type="text/html; charset=utf-8"):
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self): # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_text(HTML)
return
if parsed.path == "/api/queue":
params = parse_qs(parsed.query)
filter_name = params.get("filter", ["unreviewed"])[0]
rows = filter_rows(merged_rows(self.server.rows, self.server.labels), filter_name)
self.send_json({"rows": rows, "count": len(rows), "reviewed": len(self.server.labels)})
return
if parsed.path.startswith("/media/"):
parts = parsed.path.strip("/").split("/")
if len(parts) != 3:
self.send_error(HTTPStatus.NOT_FOUND)
return
_, record_key, kind = parts
row = self.server.row_by_key.get(record_key)
if row is None:
self.send_error(HTTPStatus.NOT_FOUND)
return
image_path = Path(row.get("crop_path" if kind == "crop" else "frame_path", ""))
if not image_path.is_file():
self.send_error(HTTPStatus.NOT_FOUND)
return
body = image_path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self): # noqa: N802
if urlparse(self.path).path != "/api/review":
self.send_error(HTTPStatus.NOT_FOUND)
return
length = int(self.headers.get("Content-Length", "0"))
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
except Exception:
self.send_error(HTTPStatus.BAD_REQUEST, "Invalid JSON")
return
record_key = str(payload.get("record_key") or "")
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}"}
for field in QUEUE_REVIEW_FIELDS:
label[field] = str(payload.get(field) or "")
self.server.labels[record_key] = label
write_labels(self.server.labels_path, self.server.labels)
self.send_json({"ok": True, "reviewed": len(self.server.labels)})
def main() -> int:
args = parse_args()
manifest_path = args.manifest.expanduser().resolve()
labels_path = args.labels_out.expanduser().resolve() if args.labels_out else manifest_path.with_name("manual_review_labels.csv")
server = ReviewServer((args.host, args.port), Handler, manifest_path, labels_path)
print(f"Review UI: http://{args.host}:{args.port}")
print(f"Manifest: {manifest_path}")
print(f"Labels: {labels_path}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())