cfb40 / src /config /session.py
andytaylor-smg's picture
updating docstrings
5c3033e
"""
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)