cfb40 / src /tracking /models.py
andytaylor-smg's picture
fixing the small things
bb03a73
Raw History Blame Contribute Delete
15.1 kB
"""
Pydantic models for play tracking.
These models represent detected plays, their temporal boundaries,
and the state machine configuration/state for play detection.
Architecture:
- PlayTracker (parent): Coordinates sub-trackers, holds all plays
- NormalPlayTracker: Handles standard plays (clock reset to 40, countdown)
- SpecialPlayTracker: Handles 40→25 transitions (timeouts, punts, FGs, XPs)
"""
from enum import Enum
from typing import Optional, List, Tuple
from pydantic import BaseModel, Field
# =============================================================================
# Utility Functions
# =============================================================================
def determine_timeout_team(home_change: int, away_change: int) -> Optional[str]:
"""
Determine which team called a timeout based on timeout count changes.
Validates that EXACTLY one team's count decreased by 1 while the other
team's count stayed the same.
Args:
home_change: Change in home team timeouts (before - after, positive = decrease)
away_change: Change in away team timeouts (before - after, positive = decrease)
Returns:
"home" or "away" if a valid timeout pattern detected, None otherwise
"""
if home_change == 1 and away_change == 0:
return "home"
if away_change == 1 and home_change == 0:
return "away"
return None
# =============================================================================
# Enums
# =============================================================================
class TrackerMode(Enum):
"""Which sub-tracker is currently active."""
NORMAL = "normal" # Normal play tracker is active
SPECIAL = "special" # Special play tracker is handling 40→25 transition
class PlayState(Enum):
"""Current state of play detection (used by NormalPlayTracker)."""
IDLE = "idle" # No scorebug detected, waiting
PRE_SNAP = "pre_snap" # Scorebug visible, clock ticking down before snap
PLAY_IN_PROGRESS = "play_in_progress" # Ball snapped, play is live
POST_PLAY = "post_play" # Play ended, waiting for next play setup
NO_SCOREBUG = "no_scorebug" # Scorebug lost during/after play (e.g., replay)
class SpecialPlayPhase(Enum):
"""
State machine phases for SpecialPlayTracker when handling 40→25 transitions.
Flow:
1. CHECKING_COUNTDOWN: Check if 25 immediately counts down (weird clock)
2. MONITORING_TIMEOUT: Wait 4-7s for timeout indicator change
3. WAITING_FOR_END: Track as special play until 7s or scorebug disappears
4. RESOLVED: Classification complete, ready to hand back control
"""
CHECKING_COUNTDOWN = "checking_countdown" # First 2s: checking if 25 counts down
MONITORING_TIMEOUT = "monitoring_timeout" # 4-7s: watching for timeout indicator change
WAITING_FOR_END = "waiting_for_end" # Tracking special play until end condition
RESOLVED = "resolved" # Classification complete
class PlayEvent(BaseModel):
"""Represents a detected play with start and end times."""
play_number: int = Field(..., description="Sequential play number")
start_time: float = Field(..., description="Video timestamp (seconds) when play started")
end_time: float = Field(..., description="Video timestamp (seconds) when play ended - from backward counting")
confidence: float = Field(..., description="Overall confidence score")
start_method: str = Field(..., description="How start was detected: 'clock_reset', 'clock_reset_25', 'clock_freeze'")
end_method: str = Field(..., description="How end was detected: 'backward_calc' (primary), 'direct_detect' (secondary)")
direct_end_time: Optional[float] = Field(None, description="End time from direct detection (for comparison)")
start_clock_value: Optional[int] = Field(None, description="Clock value at start detection")
end_clock_value: Optional[int] = Field(None, description="Clock value used for backward calculation")
play_type: str = Field("normal", description="Type of play: 'normal', 'special' (punt/fg/xp after 25-second reset)")
has_flag: bool = Field(False, description="Whether a penalty FLAG was detected during this play")
class TrackPlayStateConfig(BaseModel):
"""Configuration settings for play state tracking.
These values control the detection thresholds and timing parameters
used by the state machine to identify play boundaries.
"""
clock_stable_frames: int = Field(3, description="Frames with same clock value to consider it 'stable'")
max_play_duration: float = Field(15.0, description="Maximum expected play duration in seconds")
scorebug_lost_timeout: float = Field(30.0, description="Seconds before resetting state when scorebug lost")
required_countdown_ticks: int = Field(3, description="Number of consecutive descending ticks required to confirm play end")
min_clock_jump_for_reset: int = Field(5, description="Minimum jump in clock value to consider it a valid reset (40 from X where X <= 40 - this value)")
# FLAG detection settings (penalty flag indicator)
flag_extension_timeout: float = Field(15.0, description="Max seconds to extend play capture after FLAG first detected")
capture_flag_plays: bool = Field(True, description="Whether to capture plays with FLAGS (bypasses quiet time filter)")
# Opening kickoff detection settings
opening_kickoff_min_consecutive_frames: int = Field(3, description="Required consecutive frames with valid clock readings to confirm kickoff start")
max_opening_kickoff_duration: float = Field(90.0, description="Maximum valid kickoff duration (seconds). If exceeded, reject and keep searching.")
class TimeoutInfo(BaseModel):
"""Timeout information for a frame."""
home_timeouts: Optional[int] = Field(None, description="Number of home team timeouts remaining")
away_timeouts: Optional[int] = Field(None, description="Number of away team timeouts remaining")
confidence: float = Field(0.0, description="Confidence of the timeout reading")
class FlagInfo(BaseModel):
"""FLAG indicator information for a frame."""
detected: bool = Field(False, description="Whether FLAG is detected (yellow present AND valid hue)")
yellow_ratio: float = Field(0.0, description="Ratio of yellow pixels in FLAG region")
mean_hue: float = Field(0.0, description="Mean hue of yellow pixels (helps distinguish orange)")
scorebug_verified: bool = Field(True, description="Whether scorebug was verified present via template matching")
is_valid_yellow: bool = Field(False, description="True if mean_hue >= threshold (not orange)")
class ClockResetStats(BaseModel):
"""Statistics about clock reset classifications."""
total: int = Field(0, description="Total 40→25 resets detected")
weird_clock: int = Field(0, description="Class A: 25 counts down immediately (rejected)")
timeout: int = Field(0, description="Class B: Timeout indicator changed")
special: int = Field(0, description="Class C: Special play (injury/punt/FG/XP)")
# =============================================================================
# State models for restructured tracker architecture
# =============================================================================
class SpecialPlayHandoff(BaseModel):
"""
Data passed from NormalPlayTracker to SpecialPlayTracker when a 40→25 transition is detected.
Contains all context needed for the SpecialPlayTracker to classify and handle the transition.
"""
transition_timestamp: float = Field(..., description="When the 40→25 transition occurred")
home_timeouts_at_40: Optional[int] = Field(None, description="Home team timeouts before transition")
away_timeouts_at_40: Optional[int] = Field(None, description="Away team timeouts before transition")
timeout_confidence_at_40: float = Field(0.0, description="Confidence of timeout reading at 40")
was_in_play: bool = Field(False, description="Whether a play was in progress when transition detected")
play_start_time: Optional[float] = Field(None, description="Start time of play in progress (if any)")
time_at_40: float = Field(0.0, description="How long clock was at 40 before transitioning to 25")
from_freeze_detection: bool = Field(False, description="Whether this handoff came from clock freeze→25 detection (always treat as special play)")
class NormalTrackerState(BaseModel):
"""
State for NormalPlayTracker.
Tracks clock behavior and play boundaries for normal plays.
"""
# Current detection state
state: PlayState = Field(PlayState.IDLE, description="Current state of the normal play tracker")
# Clock tracking
last_clock_value: Optional[int] = Field(None, description="Last observed play clock value")
last_clock_timestamp: Optional[float] = Field(None, description="Timestamp of last clock reading")
clock_stable_count: int = Field(0, description="Number of consecutive frames with same clock value")
last_scorebug_timestamp: Optional[float] = Field(None, description="Timestamp of last scorebug detection")
# Current play tracking
current_play_start_time: Optional[float] = Field(None, description="Start time of the current play")
current_play_start_method: Optional[str] = Field(None, description="Method used to detect play start")
current_play_start_clock: Optional[int] = Field(None, description="Clock value when play started")
current_play_clock_base: int = Field(40, description="Clock base for current play (40 or 25)")
current_play_type: str = Field("normal", description="Type of current play: 'normal' or 'special'")
first_40_timestamp: Optional[float] = Field(None, description="When we first saw 40 in current play")
countdown_history: List[Tuple[float, int]] = Field(default_factory=list, description="(timestamp, clock_value) pairs")
direct_end_time: Optional[float] = Field(None, description="Direct end time observation")
# Timeout tracking
last_home_timeouts: Optional[int] = Field(None, description="Last observed home team timeouts")
last_away_timeouts: Optional[int] = Field(None, description="Last observed away team timeouts")
last_timeout_confidence: float = Field(0.0, description="Confidence of last timeout reading")
# FLAG tracking (penalty flag indicator)
flag_detected_at: Optional[float] = Field(None, description="When FLAG first appeared in current FLAG event")
flag_last_seen_at: Optional[float] = Field(None, description="Last time FLAG was visible")
flag_active: bool = Field(False, description="Whether FLAG is currently active (extends play capture)")
current_play_has_flag: bool = Field(False, description="Whether FLAG was detected during the current play")
# Clock freeze tracking (for special play detection)
clock_freeze_start_timestamp: Optional[float] = Field(None, description="When clock first reached current frozen value")
clock_freeze_value: Optional[int] = Field(None, description="The clock value that has been frozen/stable")
# Last play info (for continuation detection)
last_play_end_time: Optional[float] = Field(None, description="End time of the last completed play")
# Handoff signaling
request_special_handoff: bool = Field(False, description="Flag to request handoff to SpecialPlayTracker")
pending_handoff: Optional[SpecialPlayHandoff] = Field(None, description="Data for pending handoff to special tracker")
# Opening kickoff tracking
# The opening kickoff is special because the scorebug appears mid-play
# We detect it when scorebug first appears and end it on first clock reset to 40
# Requires k consecutive valid clock readings to confirm kickoff (filters out pre-game noise)
opening_kickoff_active: bool = Field(False, description="Whether we're currently tracking the opening kickoff")
opening_kickoff_complete: bool = Field(False, description="Whether opening kickoff has been recorded (only happens once)")
first_scorebug_timestamp: Optional[float] = Field(None, description="When scorebug first appeared in the video (verified by template matching)")
first_clock_reading_timestamp: Optional[float] = Field(None, description="When we first got a valid clock reading")
opening_kickoff_consecutive_readings: int = Field(0, description="Count of consecutive frames with valid clock readings for kickoff detection")
opening_kickoff_candidate_timestamp: Optional[float] = Field(None, description="Timestamp when consecutive clock readings started (candidate kickoff start)")
class SpecialTrackerState(BaseModel):
"""
State for SpecialPlayTracker.
Manages the 40→25 transition classification state machine.
"""
# Current phase of special play handling
phase: SpecialPlayPhase = Field(SpecialPlayPhase.RESOLVED, description="Current phase of special play handling")
# Transition context (from handoff)
transition_timestamp: Optional[float] = Field(None, description="When the 40→25 transition occurred")
home_timeouts_at_40: Optional[int] = Field(None, description="Home team timeouts before transition")
away_timeouts_at_40: Optional[int] = Field(None, description="Away team timeouts before transition")
timeout_confidence_at_40: float = Field(0.0, description="Confidence of timeout reading at 40")
was_in_play: bool = Field(False, description="Whether a play was in progress when transition detected")
play_start_time: Optional[float] = Field(None, description="Start time of play that led to this transition")
time_at_40: float = Field(0.0, description="How long clock was at 40 before transitioning")
from_freeze_detection: bool = Field(False, description="Whether this came from clock freeze→25 detection (always special play)")
# Classification tracking
lowest_clock_seen: int = Field(25, description="Lowest clock value seen since 25 (for countdown detection)")
last_scorebug_timestamp: Optional[float] = Field(None, description="Last time scorebug was visible")
# Scorebug disappearance tracking (debounce for noisy detection)
consecutive_scorebug_absent: int = Field(0, description="Number of consecutive frames without scorebug detection")
# Timeout confirmation tracking (require k consecutive confirmations)
pending_timeout_team: Optional[str] = Field(None, description="Team with pending timeout detection")
consecutive_timeout_confirmations: int = Field(0, description="Number of consecutive frames confirming timeout")
saw_timeout_increase: bool = Field(False, description="Whether we saw any timeout count INCREASE (invalidates timeout detection)")
# Resolution
resolution_complete: bool = Field(False, description="Whether classification is complete")
classification: Optional[str] = Field(None, description="Final classification: 'weird_clock', 'timeout', 'special'")
timeout_team: Optional[str] = Field(None, description="Which team called timeout: 'home' or 'away'")