#!/usr/bin/env python3 from __future__ import annotations import argparse import bz2 import csv import hashlib import json import math from dataclasses import dataclass from pathlib import Path import cv2 import zstandard as zstd from cereal import log 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 VALUE_LABEL_FIELDS, ensure_dir, preferred_clip_root, resolve_workspace # type: ignore from localize_bookmark_signs import configure_models, score_frame # type: ignore else: from .common import VALUE_LABEL_FIELDS, ensure_dir, preferred_clip_root, resolve_workspace from .localize_bookmark_signs import configure_models, score_frame 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", "segment", "frame_time_s", "split", "sample_type", "dataset_image", "dataset_label", "speed_limit_mph", "class_id", "bbox", "score", "proposal_confidence", "consistent_read_count", "model_read", "ocr_read", "full_detection", "map_current_speed_limit_mph", "map_next_speed_limit_mph", "map_next_speed_limit_distance_m", "map_relation", "source_video_path", ] @dataclass(frozen=True) class MapContext: time_s: float current_speed_limit_mph: int next_speed_limit_mph: int next_speed_limit_distance_m: float @dataclass(frozen=True) class SegmentInfo: segment: int path: Path video_path: Path def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Mine completed comma route clips into weakly labeled speed-limit detector/classifier samples.") parser.add_argument("routes", nargs="*", help="Optional route ids like 'dongle/logid'. Defaults to completed 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("--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 /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.") parser.add_argument("--transition-radius", type=float, default=10.0, help="Extra seconds around map speed transitions to sample densely.") parser.add_argument("--transition-step", type=float, default=1.0, help="Seconds between transition-window samples.") parser.add_argument("--max-frames-per-route", type=int, default=360, help="Maximum frames to score per route.") parser.add_argument("--max-positives-per-route", type=int, default=90, help="Maximum positive training images to add per route.") parser.add_argument("--max-negatives-per-route", type=int, default=160, help="Maximum empty-label negatives to add per route.") parser.add_argument("--positive-min-score", type=float, default=1.35, help="Minimum localization score for map-agreeing positives.") parser.add_argument("--no-map-min-score", type=float, default=1.65, help="Minimum score for positives without map agreement.") parser.add_argument("--min-proposal-confidence", type=float, default=0.18, help="Minimum detector proposal confidence for positives.") parser.add_argument("--min-width", type=int, default=24, help="Minimum mined bbox width in pixels.") parser.add_argument("--min-height", type=int, default=36, help="Minimum mined bbox height in pixels.") parser.add_argument("--next-limit-distance", type=float, default=180.0, help="Treat map next-speed as agreeing only within this many meters.") parser.add_argument("--val-route-modulo", type=int, default=5, help="Use route hash modulo N to choose validation routes. 0 disables.") parser.add_argument("--val-route-remainder", type=int, default=0, help="Validation route hash remainder.") parser.add_argument("--limit-routes", type=int, default=0, help="Optional maximum routes to mine.") parser.add_argument("--force", action="store_true", help="Re-mine routes even if the route mining marker exists.") parser.add_argument("--overwrite", action="store_true", help="Overwrite existing mined image/label files.") parser.add_argument("--dry-run", action="store_true", help="Score frames and print counts without writing dataset files.") return parser.parse_args() 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: raise ValueError(f"Route id must be dongle/logid: {text}") dongle_id, log_id = normalized.split("/", 1) return f"{dongle_id}/{log_id}", dongle_id, log_id def route_split(route_id: str, val_modulo: int, val_remainder: int) -> str: if val_modulo <= 0: return "train" digest = hashlib.sha1(route_id.encode("utf-8")).hexdigest() return "val" if int(digest[:8], 16) % val_modulo == val_remainder else "train" def completed_marker_routes(state_dir: Path) -> list[str]: routes: list[str] = [] if not state_dir.is_dir(): return routes for marker in sorted(path for path in state_dir.glob("*.json") if not path.name.startswith("._")): try: data = json.loads(marker.read_text(encoding="utf-8")) except Exception: continue if data.get("status") == "extracted" and data.get("route_id"): routes.append(str(data["route_id"])) return routes 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 segment_number(path: Path) -> int: try: return int(path.name.rsplit("--", 1)[-1]) except ValueError: return -1 def route_segments(clip_root: Path, log_id: str) -> list[SegmentInfo]: segments = [] for segment_dir in sorted(clip_root.glob(f"{log_id}--*"), key=segment_number): video_path = segment_dir / "fcamera.hevc" if video_path.is_file(): segments.append(SegmentInfo(segment_number(segment_dir), segment_dir, video_path)) return segments def read_log_bytes(path: Path) -> bytes: if path.suffix == ".zst": with path.open("rb") as handle: return zstd.ZstdDecompressor().stream_reader(handle).read() if path.suffix == ".bz2": return bz2.decompress(path.read_bytes()) return path.read_bytes() def round_mph_from_ms(speed_ms: float) -> int: if speed_ms <= 0.0: return 0 mph = speed_ms * MPH_PER_MS rounded = int(round(mph / 5.0) * 5) return rounded if rounded in VALID_WEAK_LABEL_VALUES else 0 def load_segment_map_context(segment_dir: Path) -> list[MapContext]: log_path = segment_dir / "qlog.zst" if not log_path.exists(): log_path = segment_dir / "qlog.bz2" if not log_path.exists(): log_path = segment_dir / "rlog.zst" if not log_path.exists(): log_path = segment_dir / "rlog.bz2" if not log_path.exists(): return [] try: events = list(log.Event.read_multiple_bytes(read_log_bytes(log_path))) except Exception: return [] if not events: return [] start_mono = events[0].logMonoTime rows: list[MapContext] = [] last_current = 0 last_next = 0 last_next_distance = 0.0 for event in events: event_type = event.which() current = 0 next_speed = 0 next_distance = 0.0 if event_type == "mapdOut": mapd = event.mapdOut current = round_mph_from_ms(float(mapd.speedLimit or 0.0)) next_speed = round_mph_from_ms(float(mapd.nextSpeedLimit or 0.0)) next_distance = float(mapd.nextSpeedLimitDistance or 0.0) elif event_type == "starpilotPlan": plan = event.starpilotPlan current = round_mph_from_ms(float(plan.slcMapSpeedLimit or 0.0)) next_speed = round_mph_from_ms(float(plan.slcNextSpeedLimit or 0.0)) next_distance = last_next_distance else: continue if current == 0: current = last_current if next_speed == 0: next_speed = last_next if next_distance <= 0.0: next_distance = last_next_distance if current == 0 and next_speed == 0: continue last_current = current last_next = next_speed last_next_distance = next_distance rows.append(MapContext((event.logMonoTime - start_mono) / 1e9, current, next_speed, next_distance)) return rows def nearest_context(rows: list[MapContext], time_s: float, max_delta_s: float = 2.5) -> MapContext: if not rows: return MapContext(time_s, 0, 0, 0.0) best = min(rows, key=lambda row: abs(row.time_s - time_s)) if abs(best.time_s - time_s) > max_delta_s: return MapContext(time_s, 0, 0, 0.0) return best def transition_times(rows: list[MapContext]) -> list[float]: times = [] previous = 0 for row in rows: current = row.current_speed_limit_mph if current > 0 and previous > 0 and current != previous: times.append(row.time_s) if current > 0: previous = current return times def sample_times(duration_s: float, regular_step_s: float, transition_centers: list[float], transition_radius_s: float, transition_step_s: float) -> list[float]: times = set() if regular_step_s > 0.0: sample_count = max(int(math.floor(duration_s / regular_step_s)), 0) for index in range(sample_count + 1): value = min(index * regular_step_s, max(duration_s - 0.05, 0.0)) times.add(round(value, 3)) if transition_radius_s > 0.0 and transition_step_s > 0.0: for center in transition_centers: offset = -transition_radius_s while offset <= transition_radius_s + 1e-6: value = center + offset if 0.0 <= value < duration_s: times.add(round(value, 3)) offset += transition_step_s return sorted(times) def iter_frames_at_times(capture: cv2.VideoCapture, fps: float, times: list[float]): targets: list[tuple[int, float]] = [] previous_frame_index = -1 for time_s in times: frame_index = max(int(round(time_s * fps)), 0) if frame_index == previous_frame_index: continue targets.append((frame_index, time_s)) previous_frame_index = frame_index current_frame_index = -1 for target_frame_index, time_s in targets: while current_frame_index + 1 < target_frame_index: if not capture.grab(): return current_frame_index += 1 ok, frame_bgr = capture.read() current_frame_index += 1 if not ok or frame_bgr is None: return yield time_s, frame_bgr def read_frame_at(capture: cv2.VideoCapture, fps: float, target_time_s: float): frame_index = max(int(round(target_time_s * fps)), 0) capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index) ok, frame_bgr = capture.read() if not ok or frame_bgr is None: return None return frame_bgr def detector_label_line(detector_class: int, x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int]) -> str: image_h, image_w = image_shape[:2] x_center = ((x1 + x2) / 2) / image_w y_center = ((y1 + y2) / 2) / image_h width = (x2 - x1) / image_w height = (y2 - y1) / image_h return f"{detector_class} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n" def fmt_read(result) -> str: if result is None: return "" if hasattr(result, "speed_limit_mph"): return f"{result.speed_limit_mph}@{result.confidence:.3f}" return f"{result[0]}@{result[1]:.3f}" def dominant_read(scored: dict) -> tuple[int, int]: counts: dict[int, int] = {} for key in ("model_read", "ocr_read"): result = scored.get(key) if result is not None: counts[int(result[0])] = counts.get(int(result[0]), 0) + 1 full_detection = scored.get("full_detection") if full_detection is not None: counts[int(full_detection.speed_limit_mph)] = counts.get(int(full_detection.speed_limit_mph), 0) + 1 if not counts: read_result = scored.get("read_result") if read_result is None: return 0, 0 return int(read_result[0]), 1 value, count = max(counts.items(), key=lambda item: (item[1], item[0])) return value, count def map_relation(speed_limit_mph: int, context: MapContext, next_limit_distance_m: float) -> str: if speed_limit_mph <= 0: return "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 should_keep_positive(scored: dict, speed_limit_mph: int, consistent_count: int, relation: str, args: argparse.Namespace) -> bool: if speed_limit_mph not in VALID_WEAK_LABEL_VALUES: return False if scored.get("class_id") == 1: return False if not scored.get("is_regulatory") and scored.get("class_id") != 2: return False x1, y1, x2, y2 = scored["box"] if x2 - x1 < args.min_width or y2 - y1 < args.min_height: 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 def load_csv_by_key(path: Path, key_field: str) -> dict[str, dict[str, str]]: if not path.is_file(): return {} with path.open("r", encoding="utf-8", newline="") as handle: reader = csv.DictReader(handle) rows = {} for row in reader: key = row.get(key_field, "") if key: rows[key] = row return rows def write_csv(path: Path, fieldnames: list[str], 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() for row in rows: writer.writerow(row) def merge_review_rows(path: Path, new_rows: list[dict[str, object]]) -> None: existing = load_csv_by_key(path, "record_key") for row in new_rows: existing[str(row["record_key"])] = {key: str(value) for key, value in row.items()} write_csv(path, POSITIVE_FIELDNAMES, [existing[key] for key in sorted(existing)]) def merge_value_labels(path: Path, new_rows: list[dict[str, object]]) -> None: existing = load_csv_by_key(path, "image_path") for row in new_rows: existing[str(row["image_path"])] = {key: str(value) for key, value in row.items()} write_csv(path, VALUE_LABEL_FIELDS, [existing[key] for key in sorted(existing)]) def write_sample(frame_bgr, image_path: Path, label_path: Path, label_text: str, overwrite: bool, dry_run: bool) -> bool: if dry_run: return True if image_path.exists() and label_path.exists() and not overwrite: return False ensure_dir(image_path.parent) ensure_dir(label_path.parent) cv2.imwrite(str(image_path), frame_bgr, [cv2.IMWRITE_JPEG_QUALITY, 92]) label_path.write_text(label_text, encoding="utf-8") return True 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(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} route_rows: list[dict[str, object]] = [] value_rows: list[dict[str, object]] = [] positives = 0 negatives = 0 scored_frames = 0 for segment in segments: if scored_frames >= args.max_frames_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 scored_frames >= args.max_frames_per_route: break if positives >= args.max_positives_per_route and negatives >= args.max_negatives_per_route: break if frame_bgr is None: continue scored_frames += 1 scored = score_frame(daemon, frame_bgr, use_ocr=not args.model_only) context = nearest_context(contexts, time_s) if scored is None: if negatives >= args.max_negatives_per_route: continue sample_index = f"s{segment.segment:04d}_t{time_s:06.2f}".replace(".", "p") record_key = f"real_route_negative_{route_key}_{sample_index}" image_path = image_dir / f"{record_key}.jpg" label_path = label_dir / f"{record_key}.txt" if write_sample(frame_bgr, image_path, label_path, "", args.overwrite, args.dry_run): 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, "segment": segment.segment, "frame_time_s": f"{time_s:.3f}", "split": split, "sample_type": "negative_empty", "dataset_image": str(image_path), "dataset_label": str(label_path), "speed_limit_mph": "", "class_id": "", "bbox": "", "score": "", "proposal_confidence": "", "consistent_read_count": "", "model_read": "", "ocr_read": "", "full_detection": "", "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": "no_candidate", "source_video_path": str(segment.video_path), }) continue speed_limit_mph, consistent_count = dominant_read(scored) relation = map_relation(speed_limit_mph, context, args.next_limit_distance) if not should_keep_positive(scored, speed_limit_mph, consistent_count, relation, args): continue if positives >= args.max_positives_per_route: continue sample_index = f"s{segment.segment:04d}_t{time_s:06.2f}".replace(".", "p") record_key = f"real_route_positive_{route_key}_{sample_index}" image_path = image_dir / f"{record_key}.jpg" label_path = label_dir / f"{record_key}.txt" x1, y1, x2, y2 = scored["box"] detector_class = 2 if int(scored["class_id"]) == 2 else 0 label_text = detector_label_line(detector_class, x1, y1, x2, y2, frame_bgr.shape) if not write_sample(frame_bgr, image_path, label_path, label_text, args.overwrite, args.dry_run): continue positives += 1 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, "segment": segment.segment, "frame_time_s": f"{time_s:.3f}", "split": split, "sample_type": "positive_weak_map" if relation in ("agree_current", "agree_next") else "positive_weak_nomap", "dataset_image": str(image_path), "dataset_label": str(label_path), "speed_limit_mph": speed_limit_mph, "class_id": detector_class, "bbox": bbox, "score": f"{scored['score']:.4f}", "proposal_confidence": f"{scored['proposal_confidence']:.4f}", "consistent_read_count": consistent_count, "model_read": fmt_read(scored.get("model_read")), "ocr_read": fmt_read(scored.get("ocr_read")), "full_detection": fmt_read(scored.get("full_detection")), "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": relation, "source_video_path": str(segment.video_path), }) value_rows.append({ "image_path": str(image_path), "split": split, "speed_limit_mph": speed_limit_mph, "bbox_index": 0, "padding": 0.12, "label_path": str(label_path), }) capture.release() if not args.dry_run: merge_review_rows(manifest_path, route_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, "scored": scored_frames, "segments": len(segments), }, indent=2, sort_keys=True) + "\n", encoding="utf-8") return { "route": route_id, "status": "mined", "positives": positives, "negatives": negatives, "scored": scored_frames, } def main() -> int: try: cv2.setLogLevel(1) except Exception: pass args = parse_args() workspace = resolve_workspace(args.workspace) clip_root = args.clip_root.expanduser().resolve() 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, 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)) print( f"[{index}/{len(routes)}] {result['route']}: {result['status']} " f"positives={result['positives']} negatives={result['negatives']} scored={result['scored']}", flush=True, ) print( f"Route mining complete: routes={len(routes)} positives={total_positive} negatives={total_negative} scored={total_scored}", 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 if __name__ == "__main__": raise SystemExit(main())