File size: 15,192 Bytes
46f8ebc
 
 
fbeda03
46f8ebc
 
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
fbeda03
 
 
 
 
46f8ebc
 
 
72dca15
46f8ebc
 
 
 
 
 
fbeda03
46f8ebc
 
72dca15
46f8ebc
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
fbeda03
46f8ebc
 
fbeda03
 
 
46f8ebc
 
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
 
fbeda03
46f8ebc
 
fbeda03
 
 
 
 
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbeda03
72dca15
fbeda03
 
 
46f8ebc
 
 
 
 
 
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
 
fbeda03
 
46f8ebc
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
5d257ae
 
 
 
 
fbeda03
5d257ae
fbeda03
 
5d257ae
fbeda03
 
 
 
 
 
 
 
46f8ebc
fbeda03
46f8ebc
5d257ae
46f8ebc
 
 
5d257ae
 
 
 
 
 
 
 
 
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
 
 
fbeda03
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d257ae
46f8ebc
 
5d257ae
 
46f8ebc
 
5d257ae
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
72dca15
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d257ae
 
 
 
 
 
 
 
f7a96ab
 
 
5d257ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46f8ebc
 
 
 
 
fbeda03
46f8ebc
 
fbeda03
 
 
 
46f8ebc
 
 
 
72dca15
46f8ebc
 
fbeda03
 
 
 
 
46f8ebc
 
 
 
 
 
 
 
 
 
 
fbeda03
46f8ebc
fbeda03
 
 
 
 
 
 
 
 
 
 
 
 
 
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
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
"""
Play tracker parent module.

Coordinates the NormalPlayTracker, SpecialPlayTracker, and FlagTracker sub-trackers,
managing handoffs between them and maintaining the master list of all plays.

Architecture:
- PlayTracker (this class): Coordinates sub-trackers, holds all plays
- NormalPlayTracker: Handles standard plays (clock reset to 40, countdown)
- SpecialPlayTracker: Handles 40β†’25 transitions (timeouts, punts, FGs, XPs)
- FlagTracker: Independently tracks FLAG (penalty) events

Control flow:
1. Normal mode: NormalPlayTracker processes clock readings
2. When 40β†’25 detected: NormalPlayTracker signals handoff
3. Special mode: SpecialPlayTracker classifies the transition
4. After resolution: Control returns to NormalPlayTracker

FLAG tracking runs in parallel:
- FlagTracker is updated on EVERY frame regardless of mode
- FLAG plays are tracked independently and merged at the end
- FLAG plays have absolute priority - they are ALWAYS included
"""

import logging
from typing import Any, Dict, List, Optional

from detection import ScorebugDetection
from readers import PlayClockReading

from .models import (
    ClockResetStats,
    FlagInfo,
    PlayEvent,
    PlayState,
    SpecialPlayHandoff,
    TimeoutInfo,
    TrackerMode,
    TrackPlayStateConfig,
)
from .flag_tracker import FlagTracker
from .normal_play_tracker import NormalPlayTracker
from .special_play_tracker import SpecialPlayTracker

logger = logging.getLogger(__name__)


class PlayTracker:
    """
    Parent tracker that coordinates NormalPlayTracker, SpecialPlayTracker, and FlagTracker.

    Responsibilities:
    - Maintain the master list of all detected plays
    - Route updates to the appropriate sub-tracker based on current mode
    - Handle handoffs between sub-trackers when 40β†’25 transitions occur
    - Independently track FLAG events (always included in output)
    - Synchronize play counts between sub-trackers
    """

    def __init__(self, config: Optional[TrackPlayStateConfig] = None):
        """
        Initialize the play tracker.

        Args:
            config: Configuration settings. Uses defaults if not provided.
        """
        self.config = config or TrackPlayStateConfig()

        # Master list of all plays (normal + special, FLAGS handled separately)
        self._plays: List[PlayEvent] = []

        # FLAG plays tracked independently
        self._flag_plays: List[PlayEvent] = []

        # Current active tracker mode
        self._active_mode: TrackerMode = TrackerMode.NORMAL

        # Sub-trackers
        self._normal_tracker = NormalPlayTracker(self.config)
        self._special_tracker = SpecialPlayTracker()
        self._flag_tracker = FlagTracker()

    # =========================================================================
    # Public properties
    # =========================================================================

    @property
    def plays(self) -> List[PlayEvent]:
        """List of all detected plays (normal + special, not FLAG)."""
        return self._plays

    @property
    def flag_plays(self) -> List[PlayEvent]:
        """List of all FLAG plays (tracked independently)."""
        return self._flag_plays

    @property
    def state(self) -> PlayState:
        """Current state of the normal play tracker (for backward compatibility)."""
        return self._normal_tracker.state

    @property
    def active_mode(self) -> TrackerMode:
        """Which sub-tracker is currently active."""
        return self._active_mode

    @property
    def clock_reset_stats(self) -> ClockResetStats:
        """Statistics about 40β†’25 clock reset classifications."""
        return self._special_tracker.stats

    @property
    def flag_tracker_stats(self) -> Dict[str, Any]:
        """Statistics about FLAG tracking."""
        return self._flag_tracker.get_stats()

    # =========================================================================
    # Main update method
    # =========================================================================

    def update(
        self,
        timestamp: float,
        scorebug: ScorebugDetection,
        clock: PlayClockReading,
        timeout_info: Optional[TimeoutInfo] = None,
        flag_info: Optional[FlagInfo] = None,
    ) -> Optional[PlayEvent]:
        """
        Update the tracker with new frame data.

        Routes the update to the appropriate sub-tracker based on current mode,
        handles handoffs, and maintains the master play list.

        FLAG tracking runs in parallel on EVERY frame, regardless of mode.

        Args:
            timestamp: Current video timestamp in seconds
            scorebug: Scorebug detection result
            clock: Play clock reading result
            timeout_info: Optional timeout indicator information
            flag_info: Optional FLAG indicator information for penalty detection

        Returns:
            PlayEvent if a play just ended, None otherwise
        """
        completed_play = None

        # Determine actual scorebug visibility (for disappearance detection and flag filtering)
        # In fixed coords mode: detected=True (assumed), template_matched=actual visibility
        # In standard mode: template_matched is None, use detected
        scorebug_actually_visible = scorebug.template_matched if scorebug.template_matched is not None else scorebug.detected

        # FLAG tracking runs in parallel, regardless of normal/special mode
        # Uses actual visibility (template_matched) to filter false positives during commercials
        flag_play = self._flag_tracker.update(
            timestamp=timestamp,
            scorebug_detected=scorebug_actually_visible,
            flag_info=flag_info,
        )
        if flag_play is not None:
            # Add FLAG play to separate list (will be merged later)
            self._flag_plays.append(flag_play)
            logger.debug("FLAG Play #%d added (%.1fs - %.1fs)", len(self._flag_plays), flag_play.start_time, flag_play.end_time)

        # Normal/Special play tracking
        if self._active_mode == TrackerMode.NORMAL:
            completed_play = self._update_normal_mode(timestamp, scorebug, clock, timeout_info, flag_info)
        else:  # TrackerMode.SPECIAL
            completed_play = self._update_special_mode(timestamp, scorebug, clock, timeout_info, scorebug_actually_visible)

        # Add completed play to master list
        if completed_play is not None:
            # Check if opening kickoff is still active - if so, end it first
            # This handles cases where special plays are created before a normal play
            if self._normal_tracker._state.opening_kickoff_active:
                kickoff_play = self._end_opening_kickoff_from_parent(completed_play.start_time)
                if kickoff_play is not None:
                    kickoff_play = kickoff_play.model_copy(update={"play_number": len(self._plays) + 1})
                    self._plays.append(kickoff_play)
                    logger.debug("Opening kickoff Play #%d added before first detected play", kickoff_play.play_number)

            # Update play number to be sequential in master list
            completed_play = completed_play.model_copy(update={"play_number": len(self._plays) + 1})
            self._plays.append(completed_play)
            logger.debug("Play #%d added to master list (mode=%s)", completed_play.play_number, self._active_mode.value)

        return completed_play

    def _update_normal_mode(
        self,
        timestamp: float,
        scorebug: ScorebugDetection,
        clock: PlayClockReading,
        timeout_info: Optional[TimeoutInfo],
        flag_info: Optional[FlagInfo] = None,
    ) -> Optional[PlayEvent]:
        """Process update in normal mode."""
        # Update the normal tracker
        completed_play = self._normal_tracker.update(
            timestamp=timestamp,
            scorebug_detected=scorebug.detected,
            clock_value=clock.value if clock.detected else None,
            timeout_info=timeout_info,
            flag_info=flag_info,
        )

        # Check if normal tracker is requesting handoff to special tracker
        if self._normal_tracker.request_special_handoff:
            handoff = self._normal_tracker.pending_handoff
            if handoff is not None:
                self._activate_special_mode(handoff)

        return completed_play

    def _update_special_mode(
        self,
        timestamp: float,
        scorebug: ScorebugDetection,
        clock: PlayClockReading,
        timeout_info: Optional[TimeoutInfo],
        scorebug_actually_visible: bool = True,
    ) -> Optional[PlayEvent]:
        """Process update in special mode."""
        # Update the special tracker with actual scorebug visibility
        # (for disappearance detection in special play end logic)
        completed_play = self._special_tracker.update(
            timestamp=timestamp,
            scorebug_detected=scorebug_actually_visible,
            clock_value=clock.value if clock.detected else None,
            timeout_info=timeout_info,
        )

        # Check if special tracker has completed resolution
        if self._special_tracker.resolution_complete:
            self._return_to_normal_mode()

        return completed_play

    # =========================================================================
    # Mode transitions
    # =========================================================================

    def _activate_special_mode(self, handoff: SpecialPlayHandoff) -> None:
        """
        Activate special mode to handle a 40β†’25 transition.

        Args:
            handoff: SpecialPlayHandoff data from NormalPlayTracker
        """
        logger.info(
            "Activating SPECIAL mode at %.1fs (was_in_play=%s)",
            handoff.transition_timestamp,
            handoff.was_in_play,
        )

        # Clear the handoff request from normal tracker
        self._normal_tracker.clear_handoff_request()

        # Synchronize play count to special tracker
        self._special_tracker.set_play_count(len(self._plays))

        # Activate special tracker with handoff data
        self._special_tracker.activate(handoff)

        # Switch to special mode
        self._active_mode = TrackerMode.SPECIAL

    def _return_to_normal_mode(self) -> None:
        """Return to normal mode after special tracker completes."""
        classification = self._special_tracker.classification
        last_clock = self._special_tracker.get_last_clock_value()

        logger.info(
            "Returning to NORMAL mode (classification=%s, last_clock=%d)",
            classification,
            last_clock,
        )

        # Reset special tracker
        self._special_tracker.reset()

        # Resume normal tracker with last known clock value
        self._normal_tracker.resume_after_special(clock_value=last_clock)

        # Switch back to normal mode
        self._active_mode = TrackerMode.NORMAL

    def _end_opening_kickoff_from_parent(self, first_play_start: float) -> Optional[PlayEvent]:
        """
        End the opening kickoff when the first play is detected.

        This is called from the parent PlayTracker when any play (normal or special)
        is completed, ensuring the opening kickoff is captured even if special plays
        are created before normal play detection kicks in.

        Note: Kickoff plays that are too long will be filtered out by PlayMerger
        using max_kickoff_duration filter.

        Args:
            first_play_start: Start time of the first detected play

        Returns:
            PlayEvent for the opening kickoff, or None if not applicable
        """
        state = self._normal_tracker._state

        # Get the first clock reading timestamp as kickoff start
        start_time = state.first_clock_reading_timestamp
        if start_time is None:
            return None

        # Kickoff ends just before the first play starts
        end_time = first_play_start - 0.5
        if end_time <= start_time:
            end_time = start_time + 2.0  # Minimum 2 second duration

        # Create the kickoff play
        play = PlayEvent(
            play_number=0,  # Will be updated by caller
            start_time=start_time,
            end_time=end_time,
            confidence=0.75,
            start_method="opening_kickoff",
            end_method="first_play_detected",
            direct_end_time=first_play_start,
            start_clock_value=None,
            end_clock_value=None,
            play_type="kickoff",
        )

        # Mark opening kickoff as complete in normal tracker
        state.opening_kickoff_active = False
        state.opening_kickoff_complete = True

        logger.info(
            "Opening kickoff: %.1fs - %.1fs (duration: %.1fs)",
            play.start_time,
            play.end_time,
            play.end_time - play.start_time,
        )

        return play

    # =========================================================================
    # Public API methods
    # =========================================================================

    def get_plays(self) -> List[PlayEvent]:
        """Get all tracked plays (normal + special, not FLAG)."""
        return self._plays.copy()

    def get_flag_plays(self) -> List[PlayEvent]:
        """Get all FLAG plays (tracked independently)."""
        return self._flag_plays.copy()

    def get_state(self) -> PlayState:
        """Get current state of normal tracker."""
        return self._normal_tracker.state

    def get_stats(self) -> Dict[str, Any]:
        """Get statistics about tracked plays."""
        if not self._plays:
            return {
                "total_plays": 0,
                "clock_reset_events": self._special_tracker.stats.model_dump(),
                "flag_stats": self._flag_tracker.get_stats(),
            }

        durations = [p.end_time - p.start_time for p in self._plays]
        return {
            "total_plays": len(self._plays),
            "avg_duration": sum(durations) / len(durations),
            "min_duration": min(durations),
            "max_duration": max(durations),
            "start_methods": {m: sum(1 for p in self._plays if p.start_method == m) for m in set(p.start_method for p in self._plays)},
            "end_methods": {m: sum(1 for p in self._plays if p.end_method == m) for m in set(p.end_method for p in self._plays)},
            "play_types": {t: sum(1 for p in self._plays if p.play_type == t) for t in set(p.play_type for p in self._plays)},
            "clock_reset_events": self._special_tracker.stats.model_dump(),
            "flag_stats": self._flag_tracker.get_stats(),
        }

    def finalize(self, timestamp: float) -> None:
        """
        Finalize tracking at end of video.

        This ensures any active FLAG event is properly closed.

        Args:
            timestamp: Final video timestamp
        """
        flag_play = self._flag_tracker.finalize(timestamp)
        if flag_play is not None:
            self._flag_plays.append(flag_play)
            logger.info("Finalized active FLAG event at end of video")