File size: 15,080 Bytes
6c65498
d12d00d
6c65498
251faa9
 
46f8ebc
 
 
 
 
6c65498
 
251faa9
 
6c65498
d12d00d
6c65498
fbeda03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46f8ebc
 
 
 
 
 
 
251faa9
46f8ebc
251faa9
 
 
 
 
 
 
 
46f8ebc
 
 
 
 
 
bb03a73
 
46f8ebc
 
 
 
bb03a73
46f8ebc
 
 
 
d12d00d
6c65498
 
d12d00d
 
 
 
 
 
 
 
 
 
fbeda03
251faa9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbeda03
 
 
 
f7a96ab
 
 
 
251faa9
 
 
 
 
 
eecfaf7
251faa9
 
fbeda03
 
 
 
 
 
137c6cf
fbeda03
 
 
251faa9
 
 
 
 
 
 
 
 
46f8ebc
47d79b8
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d257ae
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbeda03
 
 
 
 
 
5d257ae
 
 
 
 
 
 
46f8ebc
 
 
 
5d257ae
 
 
f7a96ab
5d257ae
 
 
 
f7a96ab
 
5d257ae
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d257ae
46f8ebc
 
 
 
 
5d257ae
 
 
46f8ebc
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
"""
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'")