""" Session configuration management for CFB40. This module provides functions for managing session configuration, including region selections, video paths, and project constants. """ import json import logging from pathlib import Path from typing import Any, Optional, Tuple import cv2 import numpy as np from config.models import SessionConfig logger = logging.getLogger(__name__) # ============================================================================= # Project Constants # ============================================================================= # Project root is two levels up from this file (src/config/session.py -> project root) PROJECT_ROOT = Path(__file__).parent.parent.parent DEFAULT_VIDEO_PATH = PROJECT_ROOT / "full_videos" / "OSU vs Tenn 12.21.24.mkv" OUTPUT_DIR = PROJECT_ROOT / "output" # Test segment bounds (per AGENTS.md) TESTING_START_TIME = 38 * 60 + 40 # 38:40 TESTING_END_TIME = 48 * 60 + 40 # 48:40 (10 minutes, ~12 plays) EXPECTED_PLAYS_TESTING = 12 # Minimum play duration to filter out false positives (e.g., clock operator errors) MIN_PLAY_DURATION = 3.0 # seconds # ============================================================================= # Utility Functions # ============================================================================= def get_video_basename(video_path: str, testing_mode: bool = False) -> str: """ Get a clean basename from the video path for use in output filenames. Args: video_path: Path to video file. testing_mode: If True, return "testing" instead of video name. Returns: Clean basename without extension (e.g., "OSU_vs_Tenn_12_21_24"). """ if testing_mode: return "testing" # Get filename without extension and replace spaces/special chars with underscores basename = Path(video_path).stem # Replace common problematic characters for char in [" ", ".", "-"]: basename = basename.replace(char, "_") # Remove consecutive underscores while "__" in basename: basename = basename.replace("__", "_") return basename.strip("_") def parse_time_string(time_str: str) -> float: """ Parse time string in MM:SS or HH:MM:SS or seconds format. Args: time_str: Time string like "38:40", "1:30:00", or "2320". Returns: Time in seconds. """ if ":" in time_str: parts = time_str.split(":") if len(parts) == 2: return int(parts[0]) * 60 + float(parts[1]) if len(parts) == 3: return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) return float(time_str) def format_time(seconds: float) -> str: """ Format seconds as MM:SS. Args: seconds: Time in seconds. Returns: Formatted time string. """ mins = int(seconds // 60) secs = seconds % 60 return f"{mins}:{secs:05.2f}" # ============================================================================= # Save/Load Helper Functions # ============================================================================= def _save_config_json(config: SessionConfig, output_dir: Path, basename: str) -> Path: """ Save the main session configuration as JSON. Args: config: Session configuration to save. output_dir: Output directory. basename: Base name for the output file. Returns: Path to the saved config file. """ config_path = output_dir / f"{basename}_config.json" config_dict = config.model_dump() with open(config_path, "w", encoding="utf-8") as f: json.dump(config_dict, f, indent=2) logger.info("Config saved to: %s", config_path) return config_path def _extract_frame_from_video(video_path: str, timestamp: float) -> np.ndarray[Any, Any] | None: """ Extract a single frame from a video at the given timestamp. Args: video_path: Path to the video file. timestamp: Time in seconds to extract the frame. Returns: The extracted frame as a numpy array, or None if extraction failed. """ cap = cv2.VideoCapture(video_path) frame = None if cap.isOpened(): fps = cap.get(cv2.CAP_PROP_FPS) frame_number = int(timestamp * fps) cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) ret, frame = cap.read() if not ret: frame = None cap.release() return frame def _save_template_image(config: SessionConfig, output_dir: Path, basename: str, frame: np.ndarray[Any, Any]) -> Path: """ Extract the scorebug region from a frame and save it as the template image. Args: config: Session configuration with scorebug coordinates. output_dir: Output directory. basename: Base name for the output file. frame: The frame to extract the template from. Returns: Path to the saved template image. """ template_path = output_dir / f"{basename}_template.png" sb_x, sb_y = config.scorebug_x, config.scorebug_y sb_w, sb_h = config.scorebug_width, config.scorebug_height template = frame[sb_y : sb_y + sb_h, sb_x : sb_x + sb_w] cv2.imwrite(str(template_path), template) logger.info("Template saved to: %s", template_path) return template_path def _save_playclock_config(config: SessionConfig, output_dir: Path, basename: str) -> Path: """ Save the play clock region configuration in the format expected by ReadPlayClock. Args: config: Session configuration with play clock coordinates. output_dir: Output directory. basename: Base name for the output file. Returns: Path to the saved playclock config file. """ playclock_config_path = output_dir / f"{basename}_playclock_config.json" playclock_config = { "x_offset": config.playclock_x_offset, "y_offset": config.playclock_y_offset, "width": config.playclock_width, "height": config.playclock_height, "source_video": Path(config.video_path).name, "scorebug_template": f"{basename}_template.png", "samples_used": 1, } with open(playclock_config_path, "w", encoding="utf-8") as f: json.dump(playclock_config, f, indent=2) logger.info("Playclock config saved to: %s", playclock_config_path) return playclock_config_path def _save_timeout_config(config: SessionConfig, output_dir: Path, basename: str) -> Path | None: """ Save the timeout tracker configuration in the format expected by DetectTimeouts. Only saves if both home and away timeout regions are configured. Args: config: Session configuration with timeout region coordinates. output_dir: Output directory. basename: Base name for the output file. Returns: Path to the saved timeout config file, or None if not configured. """ # Only save if timeout regions are configured if config.home_timeout_width <= 0 or config.away_timeout_width <= 0: return None timeout_config_path = output_dir / f"{basename}_timeout_config.json" timeout_config = { "home_timeout_region": { "team_name": "home", "bbox": { "x": config.home_timeout_x, "y": config.home_timeout_y, "width": config.home_timeout_width, "height": config.home_timeout_height, }, }, "away_timeout_region": { "team_name": "away", "bbox": { "x": config.away_timeout_x, "y": config.away_timeout_y, "width": config.away_timeout_width, "height": config.away_timeout_height, }, }, "source_video": Path(config.video_path).name, } with open(timeout_config_path, "w", encoding="utf-8") as f: json.dump(timeout_config, f, indent=2) logger.info("Timeout tracker config saved to: %s", timeout_config_path) return timeout_config_path # ============================================================================= # Save/Load Functions # ============================================================================= def save_session_config( config: SessionConfig, output_dir: Path, selected_frame: Tuple[float, np.ndarray[Any, Any]] | None = None, ) -> Tuple[str, str]: """ Save session configuration and generate template image. Args: config: Session configuration to save. output_dir: Output directory. selected_frame: The specific (timestamp, frame) tuple selected during region selection. Used to generate template image. Returns: Tuple of (config_path, template_path). """ output_dir.mkdir(parents=True, exist_ok=True) basename = config.video_basename # Save the main session config JSON config_path = _save_config_json(config, output_dir, basename) # Get frame for template generation frame: Optional[np.ndarray[Any, Any]] = None if selected_frame is not None: timestamp, frame = selected_frame logger.info("Using selected frame @ %.1fs for template generation", timestamp) else: # Fallback: extract from video at start_time frame = _extract_frame_from_video(config.video_path, config.start_time) # Save template image from the frame template_path = output_dir / f"{basename}_template.png" if frame is not None: template_path = _save_template_image(config, output_dir, basename, frame) # Save auxiliary config files _save_playclock_config(config, output_dir, basename) _save_timeout_config(config, output_dir, basename) return str(config_path), str(template_path) def load_session_config(config_path: str) -> SessionConfig: """ Load session configuration from a JSON file. Args: config_path: Path to configuration JSON file. Returns: SessionConfig object. Raises: FileNotFoundError: If config file doesn't exist. json.JSONDecodeError: If config file is invalid JSON. """ with open(config_path, "r", encoding="utf-8") as f: config_dict = json.load(f) return SessionConfig(**config_dict)