#!/usr/bin/env python """ Test script for FLAG region selection (Step 1 of FLAG detection implementation). This script allows interactive selection of the FLAG indicator region on the scorebug. The FLAG region is where "1st & 10" / "FLAG" text appears on the scorebug. Usage: # Use default Tennessee video python scripts/test_flag_region_selection.py # Use a specific video python scripts/test_flag_region_selection.py --video "full_videos/OSU vs Texas 01.10.25.mkv" The script will: 1. Load sample frames from the specified video 2. Display the frame with the existing scorebug region highlighted 3. Allow user to click/drag to select the FLAG region 4. Save the selected region to output/{video_basename}_flag_config.json 5. Optionally update the session config if it exists 6. Display a cropped preview of the selected region """ import json import logging import sys from pathlib import Path from typing import Any, Optional, Tuple import cv2 import numpy as np # Add src to path for imports sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from ui.models import BBox from ui.selector import RegionSelector from video.frame_extractor import extract_sample_frames logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) # Project paths PROJECT_ROOT = Path(__file__).parent.parent DATA_CONFIG_DIR = PROJECT_ROOT / "data" / "config" OUTPUT_DIR = PROJECT_ROOT / "output" FULL_VIDEOS_DIR = PROJECT_ROOT / "full_videos" # Default test video DEFAULT_VIDEO = FULL_VIDEOS_DIR / "OSU vs Tenn 12.21.24.mkv" DEFAULT_START_TIME = 38 * 60 + 40 # 38:40 - where test segment starts class FlagRegionSelectionSession: """Interactive session for selecting the FLAG indicator region with padding around scorebug.""" def __init__( self, frame: np.ndarray[Any, Any], scorebug_bbox: BBox, scale_factor: int = 3, padding: int = 100, ): """ Initialize FLAG region selection session. Args: frame: Full video frame (BGR) scorebug_bbox: Bounding box of the scorebug in the frame scale_factor: How much to scale up the region for easier selection padding: Pixels of padding around the scorebug to include in the view """ self.full_frame = frame self.scorebug_bbox = scorebug_bbox self.scale_factor = scale_factor self.padding = padding self.window_name = "Select FLAG Region (1st & 10 location)" # Calculate padded region bounds (with boundary checks) sb = scorebug_bbox self.pad_x1 = max(0, sb.x - padding) self.pad_y1 = max(0, sb.y - padding) self.pad_x2 = min(frame.shape[1], sb.x2 + padding) self.pad_y2 = min(frame.shape[0], sb.y2 + padding) # Scorebug position within the padded region (for drawing reference outline) self.scorebug_in_padded = BBox( x=sb.x - self.pad_x1, y=sb.y - self.pad_y1, width=sb.width, height=sb.height, ) # Extract the padded region from the frame self.padded_region = frame[self.pad_y1 : self.pad_y2, self.pad_x1 : self.pad_x2].copy() padded_w, padded_h = self.padded_region.shape[1], self.padded_region.shape[0] # Scale up the padded region for easier selection self.scaled_region = cv2.resize( self.padded_region, (padded_w * scale_factor, padded_h * scale_factor), interpolation=cv2.INTER_LINEAR, ) # Setup selector self.selector = RegionSelector(self.window_name, mode="two_click") # State self.should_quit = False self.should_confirm = False def _render_frame(self, display_frame: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]: """Add overlays and instructions to the display frame.""" # Draw scorebug boundary for reference (gray dashed appearance) sb_scaled = self.scorebug_in_padded.scaled(self.scale_factor) cv2.rectangle( display_frame, (sb_scaled.x, sb_scaled.y), (sb_scaled.x2, sb_scaled.y2), (128, 128, 128), 2, ) # Label the scorebug region cv2.putText( display_frame, "Scorebug", (sb_scaled.x, sb_scaled.y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (128, 128, 128), 1, ) # Draw clicked points for i, point in enumerate(self.selector.points): color = (0, 255, 0) if i == 0 else (0, 0, 255) # Green for first, red for second cv2.circle(display_frame, point, 5, color, -1) label = "Top-Left" if i == 0 else "Bottom-Right" cv2.putText(display_frame, label, (point[0] + 10, point[1]), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2) # Draw preview rectangle when dragging if len(self.selector.points) == 1 and self.selector.current_point: cv2.rectangle(display_frame, self.selector.points[0], self.selector.current_point, (0, 255, 255), 2) # Draw final selection rectangle if self.selector.selection_complete: bbox = self.selector.get_bbox() if bbox: # Yellow highlight for FLAG region cv2.rectangle(display_frame, (bbox.x, bbox.y), (bbox.x2, bbox.y2), (0, 255, 255), 3) # Instructions at top if len(self.selector.points) == 0: instruction = "Step 1: Click TOP-LEFT corner of FLAG/down-distance area" color = (0, 255, 255) elif len(self.selector.points) == 1: instruction = "Step 2: Click BOTTOM-RIGHT corner of FLAG/down-distance area" color = (0, 255, 255) else: instruction = "Press ENTER to confirm, 'r' to reset, 'q' to quit" color = (0, 255, 0) cv2.putText(display_frame, instruction, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) return display_frame def run(self) -> Optional[BBox]: """ Run the interactive selection loop. Returns: BBox in scaled coordinates, or None if cancelled """ cv2.namedWindow(self.window_name, cv2.WINDOW_NORMAL) cv2.resizeWindow( self.window_name, self.scaled_region.shape[1], self.scaled_region.shape[0] + 50, ) cv2.setMouseCallback(self.window_name, self.selector.mouse_callback) while True: # Get display frame display_frame = self.scaled_region.copy() display_frame = self._render_frame(display_frame) cv2.imshow(self.window_name, display_frame) key = cv2.waitKey(30) & 0xFF # Handle key presses if key == ord("q"): self.should_quit = True elif key == ord("r"): self.selector.reset() elif key == 13: # Enter key if self.selector.selection_complete: self.should_confirm = True # Check exit conditions if self.should_quit: cv2.destroyAllWindows() return None if self.should_confirm: bbox = self.selector.get_bbox() cv2.destroyAllWindows() return bbox return None def get_scorebug_relative_bbox(self) -> Optional[BBox]: """ Get the selection converted to coordinates relative to the scorebug. The selection is made in the scaled padded region. This method: 1. Unscales to original pixel coordinates 2. Converts from padded-region coordinates to scorebug-relative coordinates Returns: BBox with x_offset, y_offset relative to the scorebug top-left corner """ bbox = self.selector.get_bbox() if bbox is None: return None # Unscale from the zoomed view unscaled = bbox.unscaled(self.scale_factor) # Convert from padded-region coords to scorebug-relative coords # The scorebug starts at self.scorebug_in_padded within the padded region scorebug_relative = BBox( x=unscaled.x - self.scorebug_in_padded.x, y=unscaled.y - self.scorebug_in_padded.y, width=unscaled.width, height=unscaled.height, ) return scorebug_relative def get_absolute_bbox(self) -> Optional[BBox]: """ Get the selection in absolute frame coordinates. Returns: BBox in absolute frame coordinates """ bbox = self.selector.get_bbox() if bbox is None: return None # Unscale from the zoomed view unscaled = bbox.unscaled(self.scale_factor) # Add the padded region offset to get absolute coordinates absolute = BBox( x=unscaled.x + self.pad_x1, y=unscaled.y + self.pad_y1, width=unscaled.width, height=unscaled.height, ) return absolute def load_saved_scorebug_config() -> Optional[Tuple[BBox, str]]: """ Load the scorebug region from an existing config file. Returns: Tuple of (scorebug_bbox, template_path) or None if not found """ # Look for any existing config file in output/ config_files = list(OUTPUT_DIR.glob("*_config.json")) # Filter out playclock and timeout configs main_configs = [f for f in config_files if "playclock" not in f.name and "timeout" not in f.name] if not main_configs: logger.warning("No existing config files found in output/") return None # Use the most recent one config_path = max(main_configs, key=lambda p: p.stat().st_mtime) logger.info("Loading scorebug config from: %s", config_path) with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) scorebug_bbox = BBox( x=config["scorebug_x"], y=config["scorebug_y"], width=config["scorebug_width"], height=config["scorebug_height"], ) template_path = config.get("template_path", "") return scorebug_bbox, template_path def show_preview(frame: np.ndarray[Any, Any], flag_region_bbox: BBox, scorebug_bbox: BBox) -> None: """ Display a preview of the selected FLAG region. Args: frame: Full video frame flag_region_bbox: Selected FLAG region (relative to scorebug) scorebug_bbox: Scorebug bounding box """ # Calculate absolute coordinates abs_x = scorebug_bbox.x + flag_region_bbox.x abs_y = scorebug_bbox.y + flag_region_bbox.y abs_x2 = abs_x + flag_region_bbox.width abs_y2 = abs_y + flag_region_bbox.height # Extract the FLAG region flag_roi = frame[abs_y:abs_y2, abs_x:abs_x2].copy() # Scale up for visibility scale = 4 preview = cv2.resize(flag_roi, (flag_roi.shape[1] * scale, flag_roi.shape[0] * scale), interpolation=cv2.INTER_LINEAR) # Also show the full frame with the region highlighted frame_with_highlight = frame.copy() # Draw scorebug outline (gray) cv2.rectangle( frame_with_highlight, (scorebug_bbox.x, scorebug_bbox.y), (scorebug_bbox.x2, scorebug_bbox.y2), (128, 128, 128), 2, ) # Draw FLAG region outline (yellow) cv2.rectangle( frame_with_highlight, (abs_x, abs_y), (abs_x2, abs_y2), (0, 255, 255), 3, ) # Add labels cv2.putText(frame_with_highlight, "Scorebug", (scorebug_bbox.x, scorebug_bbox.y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (128, 128, 128), 2) cv2.putText(frame_with_highlight, "FLAG Region", (abs_x, abs_y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2) # Show both windows cv2.namedWindow("FLAG Region Preview (4x scaled)", cv2.WINDOW_NORMAL) cv2.resizeWindow("FLAG Region Preview (4x scaled)", preview.shape[1], preview.shape[0]) cv2.imshow("FLAG Region Preview (4x scaled)", preview) cv2.namedWindow("Full Frame with FLAG Region", cv2.WINDOW_NORMAL) cv2.resizeWindow("Full Frame with FLAG Region", 1280, 720) cv2.imshow("Full Frame with FLAG Region", frame_with_highlight) print("\n" + "=" * 50) print("FLAG Region Preview") print("=" * 50) print(" Relative to scorebug:") print(f" x_offset: {flag_region_bbox.x}") print(f" y_offset: {flag_region_bbox.y}") print(f" width: {flag_region_bbox.width}") print(f" height: {flag_region_bbox.height}") print(" Absolute in frame:") print(f" x: {abs_x}, y: {abs_y}") print(f" width: {flag_region_bbox.width}, height: {flag_region_bbox.height}") print("=" * 50) print("Press any key to close preview...") cv2.waitKey(0) cv2.destroyAllWindows() def get_video_basename(video_path: str) -> str: """Get a clean basename from video path for config naming.""" basename = Path(video_path).stem for char in [" ", ".", "-"]: basename = basename.replace(char, "_") while "__" in basename: basename = basename.replace("__", "_") return basename.strip("_") def save_flag_region(flag_bbox: BBox, source_video: str, scorebug_template: str, video_path: str) -> Path: """ Save the FLAG region configuration to video-specific JSON file. Also updates the session config if it exists. Args: flag_bbox: FLAG region bounding box (relative to scorebug) source_video: Name of the source video scorebug_template: Name of the scorebug template file video_path: Full path to the video (used for naming) Returns: Path to the saved config file """ video_basename = get_video_basename(video_path) config = { "flag_region": { "x_offset": flag_bbox.x, "y_offset": flag_bbox.y, "width": flag_bbox.width, "height": flag_bbox.height, }, "source_video": source_video, "scorebug_template": scorebug_template, } # Save to video-specific file in output directory output_path = OUTPUT_DIR / f"{video_basename}_flag_config.json" OUTPUT_DIR.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) logger.info("Saved FLAG region config to: %s", output_path) # Also update session config if it exists session_config_path = OUTPUT_DIR / f"{video_basename}_config.json" if session_config_path.exists(): try: with open(session_config_path, "r", encoding="utf-8") as f: session_config = json.load(f) session_config["flag_x_offset"] = flag_bbox.x session_config["flag_y_offset"] = flag_bbox.y session_config["flag_width"] = flag_bbox.width session_config["flag_height"] = flag_bbox.height with open(session_config_path, "w", encoding="utf-8") as f: json.dump(session_config, f, indent=2) logger.info("Updated session config: %s", session_config_path) except Exception as e: logger.warning("Could not update session config: %s", e) return output_path def print_banner() -> None: """Print the script banner and introduction.""" print("\n" + "=" * 60) print(" FLAG Region Selection Test") print("=" * 60) print("\nThis script helps select the FLAG/down-distance region on the scorebug.") print("The FLAG indicator appears where '1st & 10' normally shows.") print("-" * 60) def print_instructions() -> None: """Print selection instructions and wait for user.""" print("\n" + "-" * 60) print("INSTRUCTIONS:") print(" 1. The scorebug region will be shown (scaled 3x)") print(" 2. Click the TOP-LEFT corner of the down/distance area") print(" (where '1st & 10' or 'FLAG' appears)") print(" 3. Click the BOTTOM-RIGHT corner") print(" 4. Press ENTER to confirm, 'r' to reset, 'q' to quit") print("-" * 60) input("\nPress Enter to start selection...") def parse_args(): """Parse command line arguments.""" import argparse parser = argparse.ArgumentParser(description="Select FLAG region for a video") parser.add_argument( "--video", type=str, default=str(DEFAULT_VIDEO), help="Path to video file (default: OSU vs Tenn 12.21.24.mkv)", ) parser.add_argument( "--start-time", type=float, default=DEFAULT_START_TIME, help="Start time in seconds for frame extraction (default: 38:40)", ) return parser.parse_args() def load_scorebug_config_for_video(video_path: str) -> Optional[Tuple[BBox, str]]: """Load scorebug config for a specific video.""" video_basename = get_video_basename(video_path) session_config_path = OUTPUT_DIR / f"{video_basename}_config.json" if session_config_path.exists(): with open(session_config_path, "r", encoding="utf-8") as f: config = json.load(f) scorebug_bbox = BBox( x=config["scorebug_x"], y=config["scorebug_y"], width=config["scorebug_width"], height=config["scorebug_height"], ) template_path = config.get("template_path", "") logger.info("Loaded session config from: %s", session_config_path) return scorebug_bbox, template_path # Fall back to generic config logger.warning("No session config found for video, trying generic config...") return load_saved_scorebug_config() def main() -> int: """Main entry point for FLAG region selection test.""" args = parse_args() print_banner() # Determine video path video_path = Path(args.video) if not video_path.exists(): # Try relative to project root video_path = PROJECT_ROOT / args.video if not video_path.exists(): print(f"\nERROR: Video not found: {args.video}") return 1 print(f"Using video: {video_path.name}") video_basename = get_video_basename(str(video_path)) print(f"Config basename: {video_basename}") # Load scorebug config for this video result = load_scorebug_config_for_video(str(video_path)) if result is None: print("\nERROR: No existing scorebug config found.") print("Please run main.py first to set up the scorebug region for this video.") return 1 scorebug_bbox, template_path = result print(f"\nLoaded scorebug region: {scorebug_bbox.to_tuple()}") start_time = args.start_time print(f"Starting at: {int(start_time) // 60}:{int(start_time) % 60:02d}") # Extract sample frames print("\nExtracting sample frames...") frames = extract_sample_frames(str(video_path), start_time, num_frames=1, interval=0.0) if not frames: print("ERROR: Failed to extract frames from video") return 1 timestamp, frame = frames[0] print(f"Loaded frame at {timestamp:.1f}s") # Show instructions and start selection print_instructions() # Run selection session (with 100px padding around scorebug) session = FlagRegionSelectionSession(frame, scorebug_bbox, scale_factor=3, padding=100) scaled_bbox = session.run() if scaled_bbox is None: print("\nSelection cancelled.") return 0 # Get bbox relative to scorebug (for config) and absolute (for preview) flag_bbox = session.get_scorebug_relative_bbox() if flag_bbox is None: print("\nNo valid selection made.") return 0 print(f"\nSelected FLAG region (relative to scorebug): {flag_bbox.to_tuple()}") # Show preview show_preview(frame, flag_bbox, scorebug_bbox) # Ask to save print("\nSave this FLAG region? (y/n)") response = input("> ").strip().lower() if response == "y": # Extract video name and template name for metadata video_name = video_path.name template_name = Path(template_path).name if template_path else "unknown" save_path = save_flag_region(flag_bbox, video_name, template_name, str(video_path)) print(f"\n✓ FLAG region saved to: {save_path}") else: print("\nSelection not saved.") return 0 if __name__ == "__main__": sys.exit(main())