Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |