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