#!/usr/bin/env python3 from __future__ import annotations import argparse import csv import json import math from pathlib import Path import cv2 import numpy as np if __package__ in (None, ""): import sys sys.path.insert(0, str(Path(__file__).resolve().parent)) from common import ( # type: ignore # noqa: TID251 BOOKMARK_LEADIN_MANIFEST_FIELDS, DEFAULT_WORKSPACE, ensure_dir, preferred_clip_root, resolve_workspace, source_video_fps, write_csv_header, ) else: from .common import ( BOOKMARK_LEADIN_MANIFEST_FIELDS, DEFAULT_WORKSPACE, ensure_dir, preferred_clip_root, resolve_workspace, source_video_fps, write_csv_header, ) DEFAULT_REPORT_JSON = Path(".tmp/live_route_clips/bookmark_windows_report.json") DEFAULT_CLIP_ROOT = preferred_clip_root() def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Import sampled 5-second pre-bookmark route windows into the speed-limit training workspace.") parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.") parser.add_argument("--report-json", type=Path, default=DEFAULT_REPORT_JSON, help="JSON output from evaluate_bookmark_leadins.py.") parser.add_argument("--clip-root", type=Path, default=DEFAULT_CLIP_ROOT, help="Copied route clip root used by evaluate_bookmark_leadins.py.") parser.add_argument("--source-name", default="comma_bookmark", help="Logical source name for imported bookmark windows.") parser.add_argument("--source-region", default="", help="Optional region or market label, e.g. us_midwest.") parser.add_argument("--source-device", default="", help="Optional device identifier or platform label.") parser.add_argument("--source-driver", default="", help="Optional contributor/driver identifier.") parser.add_argument("--mode", choices=("misses", "hits", "all"), default="misses", help="Which bookmark windows to sample.") parser.add_argument("--sample-every", type=float, default=0.5, help="Seconds between sampled review frames.") parser.add_argument("--max-samples", type=int, default=12, help="Optional cap on sampled frames per bookmark window.") parser.add_argument("--overwrite", action="store_true", help="Overwrite existing extracted review frames and contact sheets.") return parser.parse_args() def load_existing_rows(manifest_path: Path) -> dict[str, dict[str, str]]: if not manifest_path.exists(): return {} with manifest_path.open("r", encoding="utf-8", newline="") as manifest_file: reader = csv.DictReader(manifest_file) return {row["record_key"]: row for row in reader if row.get("record_key")} def read_frames_at(video_path: Path, target_times_s: list[float]): capture = cv2.VideoCapture(str(video_path)) fps = source_video_fps(video_path, capture.get(cv2.CAP_PROP_FPS)) targets = sorted((max(int(round(target_time_s * fps)), 0), target_time_s) for target_time_s in target_times_s) results = {} frame_index = 0 last_frame_index = -1 last_frame_bgr = None for target_frame_index, target_time_s in targets: if target_frame_index == last_frame_index and last_frame_bgr is not None: results[target_time_s] = (last_frame_index / fps, last_frame_bgr.copy()) continue while frame_index < target_frame_index: if not capture.grab(): capture.release() return results frame_index += 1 ok, frame_bgr = capture.read() if not ok or frame_bgr is None: break last_frame_index = frame_index last_frame_bgr = frame_bgr results[target_time_s] = (frame_index / fps, frame_bgr) frame_index += 1 capture.release() return results def sample_offsets(leadin_start_s: float, segment_offset_s: float, sample_every: float, max_samples: int): if sample_every <= 0: raise ValueError("sample_every must be positive") duration_s = max(segment_offset_s - leadin_start_s, 0.0) sample_count = int(math.floor(duration_s / sample_every)) + 1 if max_samples > 0: sample_count = min(sample_count, max_samples) if sample_count <= 1: return [segment_offset_s] if max_samples > 0 and sample_count == max_samples: return [leadin_start_s + duration_s * index / (sample_count - 1) for index in range(sample_count)] offsets = [] sample_offset_s = leadin_start_s while sample_offset_s <= segment_offset_s + 1e-6: offsets.append(sample_offset_s) sample_offset_s += sample_every return offsets[:sample_count] def extract_window_frames(row: dict, clip_root: Path, sample_every: float, max_samples: int): route = row["route"] segment = int(row["segment"]) segment_offset_s = float(row["segmentOffsetS"]) leadin_start_s = float(row["leadinStartS"]) spans_previous_segment = bool(row.get("spansPreviousSegment")) requests = [] previous_clip = clip_root / f"{route}--{segment - 1}" / "fcamera.hevc" current_clip = clip_root / f"{route}--{segment}" / "fcamera.hevc" for relative_offset_s in sample_offsets(leadin_start_s, segment_offset_s, sample_every, max_samples): if relative_offset_s < 0.0 and spans_previous_segment and segment > 0: source_video = previous_clip source_time_s = 60.0 + relative_offset_s else: source_video = current_clip source_time_s = max(relative_offset_s, 0.0) if not source_video.is_file(): continue requests.append((relative_offset_s, source_video, source_time_s)) decoded_by_video = {} for source_video in {request[1] for request in requests}: target_times_s = [request[2] for request in requests if request[1] == source_video] decoded_by_video[source_video] = read_frames_at(source_video, target_times_s) sampled = [] for relative_offset_s, source_video, source_time_s in requests: frame_info = decoded_by_video[source_video].get(source_time_s) if frame_info is None: continue actual_time_s, frame_bgr = frame_info sampled.append({ "relative_offset_s": relative_offset_s, "actual_time_s": actual_time_s, "source_video": source_video, "frame_bgr": frame_bgr, }) return sampled def write_contact_sheet(output_path: Path, frames: list[np.ndarray], labels: list[str], overwrite: bool): if output_path.exists() and not overwrite: return ensure_dir(output_path.parent) columns = 4 rows = max(int(math.ceil(len(frames) / columns)), 1) tile_height, tile_width = 256, 456 header_height = 26 sheet = np.full((rows * (tile_height + header_height), columns * tile_width, 3), 24, dtype=np.uint8) for index, (frame_bgr, label) in enumerate(zip(frames, labels, strict=False)): row_index = index // columns column_index = index % columns y = row_index * (tile_height + header_height) x = column_index * tile_width resized = cv2.resize(frame_bgr, (tile_width, tile_height), interpolation=cv2.INTER_AREA) sheet[y + header_height:y + header_height + tile_height, x:x + tile_width] = resized cv2.putText(sheet, label, (x + 8, y + 18), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (235, 235, 235), 1, cv2.LINE_AA) cv2.imwrite(str(output_path), sheet, [cv2.IMWRITE_JPEG_QUALITY, 88]) def include_row(row: dict, mode: str): hit = bool(row.get("hit")) if mode == "all": return "segment" in row if mode == "hits": return "segment" in row and hit return "segment" in row and not hit def main() -> int: args = parse_args() workspace = resolve_workspace(args.workspace) report_rows = json.loads(args.report_json.expanduser().resolve().read_text(encoding="utf-8")) clip_root = args.clip_root.expanduser().resolve() frame_dir = ensure_dir(workspace / "review" / "leadins" / "frames") contact_sheet_dir = ensure_dir(workspace / "review" / "leadins" / "contact_sheets") manifest_path = workspace / "review" / "bookmark_leadins.csv" write_csv_header(manifest_path, BOOKMARK_LEADIN_MANIFEST_FIELDS) manifest_rows = load_existing_rows(manifest_path) imported_windows = 0 imported_frames = 0 for row in report_rows: if not include_row(row, args.mode): continue sampled_frames = extract_window_frames(row, clip_root, args.sample_every, args.max_samples) if not sampled_frames: continue imported_windows += 1 window_result = "hit" if row.get("hit") else "miss" session_id = row["sessionId"] bookmark_number = int(row["bookmarkNumber"]) route = row["route"] segment = int(row["segment"]) published_values = ",".join(str(value) for value in row.get("publishedValues", [])) candidate_values = ",".join(str(value) for value in row.get("candidateValues", [])) contact_sheet_name = f"{session_id}_bookmark_{bookmark_number:03d}_{window_result}.jpg" contact_sheet_path = contact_sheet_dir / contact_sheet_name contact_sheet_labels = [] contact_sheet_frames = [] for sample_index, sample in enumerate(sampled_frames, start=1): frame_name = f"{session_id}_bookmark_{bookmark_number:03d}_sample_{sample_index:02d}.jpg" frame_path = frame_dir / frame_name if args.overwrite or not frame_path.exists(): cv2.imwrite(str(frame_path), sample["frame_bgr"], [cv2.IMWRITE_JPEG_QUALITY, 90]) imported_frames += 1 contact_sheet_frames.append(sample["frame_bgr"]) contact_sheet_labels.append(f"t={sample['relative_offset_s']:+.2f}s") record_key = f"{session_id}:bookmark:{bookmark_number}:sample:{sample_index}" manifest_rows[record_key] = { "record_key": record_key, "source_name": args.source_name, "source_region": args.source_region, "source_device": args.source_device, "source_driver": args.source_driver, "session_id": session_id, "bookmark_number": str(bookmark_number), "route": route, "segment": str(segment), "segment_offset_s": str(row["segmentOffsetS"]), "leadin_start_s": str(row["leadinStartS"]), "sample_offset_s": f"{sample['relative_offset_s']:.3f}", "window_result": window_result, "published_values": published_values, "candidate_values": candidate_values, "frame_path": str(frame_path.relative_to(workspace)), "contact_sheet_path": str(contact_sheet_path.relative_to(workspace)), "source_video_path": str(sample["source_video"]), } write_contact_sheet(contact_sheet_path, contact_sheet_frames, contact_sheet_labels, args.overwrite) with manifest_path.open("w", encoding="utf-8", newline="") as manifest_file: writer = csv.DictWriter(manifest_file, fieldnames=BOOKMARK_LEADIN_MANIFEST_FIELDS) writer.writeheader() for row in sorted(manifest_rows.values(), key=lambda entry: entry["record_key"]): writer.writerow(row) print(f"Imported {imported_windows} lead-in window(s) and {imported_frames} sampled frame(s) into {manifest_path}") return 0 if __name__ == "__main__": raise SystemExit(main())