Spaces:
Sleeping
Sleeping
| """ | |
| Flag tracker module. | |
| Independently tracks FLAG (penalty flag) events in the video. | |
| FLAG events are tracked separately from normal/special plays and are | |
| ALWAYS included in the final output, regardless of what else is happening. | |
| Key differences from normal/special play tracking: | |
| - FLAG events are not subject to duration limits (no 15s max) | |
| - FLAG events bypass quiet time filters | |
| - FLAG events are tracked even when no play is in progress | |
| - FLAG detection has absolute priority - if FLAG is visible, we capture it | |
| This module uses the same FlagInfo input from the FlagReader but manages | |
| its own state independently of the NormalPlayTracker. | |
| """ | |
| import logging | |
| from dataclasses import dataclass, field | |
| from typing import Any, List, Optional | |
| from .models import FlagInfo, PlayEvent | |
| logger = logging.getLogger(__name__) | |
| class FlagEventData: | |
| """Data for a FLAG event being tracked.""" | |
| start_time: float | |
| end_time: Optional[float] = None | |
| peak_yellow_ratio: float = 0.0 | |
| avg_yellow_ratio: float = 0.0 | |
| avg_hue: float = 0.0 # Mean hue of yellow pixels (distinguishes yellow from orange) | |
| frame_count: int = 0 | |
| yellow_sum: float = 0.0 | |
| hue_sum: float = 0.0 | |
| scorebug_frames: int = 0 # Frames where scorebug was detected | |
| def update(self, yellow_ratio: float, mean_hue: float, scorebug_verified: bool = True) -> None: | |
| """Update running statistics with a new frame. | |
| Args: | |
| yellow_ratio: Yellow pixel ratio for this frame | |
| mean_hue: Mean hue of yellow pixels | |
| scorebug_verified: Whether scorebug was verified present via template matching | |
| """ | |
| self.frame_count += 1 | |
| self.yellow_sum += yellow_ratio | |
| self.hue_sum += mean_hue | |
| self.peak_yellow_ratio = max(self.peak_yellow_ratio, yellow_ratio) | |
| self.avg_yellow_ratio = self.yellow_sum / self.frame_count | |
| self.avg_hue = self.hue_sum / self.frame_count | |
| if scorebug_verified: | |
| self.scorebug_frames += 1 | |
| def scorebug_ratio(self) -> float: | |
| """Ratio of frames where scorebug was detected.""" | |
| return self.scorebug_frames / self.frame_count if self.frame_count > 0 else 0.0 | |
| class FlagTrackerState: | |
| """State for the FlagTracker.""" | |
| # Current FLAG event being tracked (None if no FLAG active) | |
| current_flag: Optional[FlagEventData] = None | |
| # Whether FLAG is currently active | |
| flag_active: bool = False | |
| # Completed FLAG events | |
| completed_flags: List[FlagEventData] = field(default_factory=list) | |
| # Statistics | |
| total_flags_detected: int = 0 | |
| class FlagTracker: | |
| """ | |
| Independently tracks FLAG (penalty flag) events. | |
| This tracker runs in parallel with NormalPlayTracker and SpecialPlayTracker, | |
| monitoring for FLAG indicator presence and creating FLAG plays when detected. | |
| FLAG events are: | |
| - Started when FLAG yellow is first detected (valid yellow with proper hue) | |
| - Extended while FLAG remains visible (tolerates brief gaps during replays) | |
| - Ended when FLAG disappears with scorebug visible (confirming FLAG cleared) | |
| To be recorded as a FLAG play, an event must meet these criteria: | |
| - Duration >= min_flag_duration (filters brief flashes) | |
| - Peak yellow ratio >= min_peak_yellow (real FLAGS are 80%+ yellow) | |
| - Average yellow ratio >= min_avg_yellow (sustained high yellow, not brief spikes) | |
| Configuration: | |
| - min_flag_duration: Minimum FLAG duration to record (filters brief flashes) | |
| - gap_tolerance: Maximum gap (seconds) to bridge between FLAG sightings | |
| - min_peak_yellow: Minimum peak yellow ratio required (filters low-yellow events) | |
| - min_avg_yellow: Minimum average yellow ratio required (filters brief spikes) | |
| """ | |
| # Configuration constants (validated against ground truth) | |
| MIN_FLAG_DURATION = 3.0 # Minimum seconds for a FLAG event to be recorded | |
| GAP_TOLERANCE = 12.0 # Maximum gap (seconds) between FLAG sightings to consider same event | |
| MIN_PEAK_YELLOW = 0.70 # Real FLAGs peak at 80%+, false positives at 30-40% | |
| MIN_AVG_YELLOW = 0.60 # Real FLAGs average 70%+, false positives much lower | |
| # Hue filtering: Real FLAG yellow has hue ~26-30, orange ~16-17, other graphics ~24-25 | |
| MIN_MEAN_HUE = 25.0 # Ground truth FLAGs have hue >= 25 (lowered from 28) | |
| MAX_MEAN_HUE = 31.0 # Reject lime-green graphics (hue > 31) | |
| MIN_SCOREBUG_RATIO = 0.50 # Require scorebug in at least 50% of FLAG frames (filters replays/commercials) | |
| def __init__( | |
| self, | |
| min_flag_duration: float = MIN_FLAG_DURATION, | |
| gap_tolerance: float = GAP_TOLERANCE, | |
| min_peak_yellow: float = MIN_PEAK_YELLOW, | |
| min_avg_yellow: float = MIN_AVG_YELLOW, | |
| min_mean_hue: float = MIN_MEAN_HUE, | |
| max_mean_hue: float = MAX_MEAN_HUE, | |
| min_scorebug_ratio: float = MIN_SCOREBUG_RATIO, | |
| ): | |
| """ | |
| Initialize the FLAG tracker. | |
| Args: | |
| min_flag_duration: Minimum duration (seconds) for FLAG events to be recorded | |
| gap_tolerance: Maximum gap (seconds) to bridge between FLAG sightings | |
| min_peak_yellow: Minimum peak yellow ratio required | |
| min_avg_yellow: Minimum average yellow ratio required | |
| min_mean_hue: Minimum mean hue required (rejects orange/other yellow graphics) | |
| max_mean_hue: Maximum mean hue allowed (rejects lime-green graphics) | |
| min_scorebug_ratio: Minimum ratio of frames with scorebug present (filters replays/commercials) | |
| """ | |
| self.min_flag_duration = min_flag_duration | |
| self.gap_tolerance = gap_tolerance | |
| self.min_peak_yellow = min_peak_yellow | |
| self.min_avg_yellow = min_avg_yellow | |
| self.min_mean_hue = min_mean_hue | |
| self.max_mean_hue = max_mean_hue | |
| self.min_scorebug_ratio = min_scorebug_ratio | |
| self._state = FlagTrackerState() | |
| self._play_count = 0 # Running count for play numbering | |
| self._last_flag_seen_at: Optional[float] = None # For gap tolerance | |
| # ========================================================================= | |
| # Public properties | |
| # ========================================================================= | |
| def flag_active(self) -> bool: | |
| """Whether a FLAG is currently being tracked.""" | |
| return self._state.flag_active | |
| def completed_flags(self) -> List[FlagEventData]: | |
| """List of completed FLAG events.""" | |
| return self._state.completed_flags | |
| # ========================================================================= | |
| # Main update method | |
| # ========================================================================= | |
| def update( | |
| self, | |
| timestamp: float, | |
| scorebug_detected: bool, | |
| flag_info: Optional[FlagInfo] = None, | |
| ) -> Optional[PlayEvent]: | |
| """ | |
| Update the FLAG tracker with new frame data. | |
| Args: | |
| timestamp: Current video timestamp in seconds | |
| scorebug_detected: Whether scorebug is visible | |
| flag_info: FLAG indicator information (from FlagReader) | |
| Returns: | |
| PlayEvent if a FLAG event just ended, None otherwise | |
| """ | |
| # Handle no FLAG info | |
| if flag_info is None: | |
| return self._handle_no_flag_info(timestamp, scorebug_detected) | |
| # FLAG detected (valid yellow with proper hue) | |
| if flag_info.detected: | |
| return self._handle_flag_detected(timestamp, flag_info) | |
| # FLAG not detected but scorebug visible - FLAG has cleared | |
| if scorebug_detected: | |
| return self._handle_flag_cleared(timestamp) | |
| # No scorebug (e.g., replay) - use gap tolerance | |
| return self._handle_no_scorebug(timestamp) | |
| def _handle_flag_detected(self, timestamp: float, flag_info: FlagInfo) -> Optional[PlayEvent]: # pylint: disable=useless-return | |
| """Handle FLAG being detected.""" | |
| self._last_flag_seen_at = timestamp | |
| if not self._state.flag_active: | |
| # Start new FLAG event | |
| self._state.flag_active = True | |
| self._state.current_flag = FlagEventData(start_time=timestamp) | |
| self._state.total_flags_detected += 1 | |
| logger.info( | |
| "FLAG EVENT started at %.1fs (yellow=%.0f%%, hue=%.1f)", | |
| timestamp, | |
| flag_info.yellow_ratio * 100, | |
| flag_info.mean_hue, | |
| ) | |
| # Update current FLAG statistics (including scorebug verification from FlagInfo) | |
| if self._state.current_flag is not None: | |
| self._state.current_flag.update(flag_info.yellow_ratio, flag_info.mean_hue, flag_info.scorebug_verified) | |
| return None | |
| def _handle_flag_cleared(self, timestamp: float) -> Optional[PlayEvent]: | |
| """Handle FLAG being cleared (scorebug visible, no FLAG).""" | |
| if not self._state.flag_active: | |
| return None | |
| # Check gap tolerance - maybe FLAG will reappear (replay, etc.) | |
| if self._last_flag_seen_at is not None: | |
| gap = timestamp - self._last_flag_seen_at | |
| if gap <= self.gap_tolerance: | |
| # Still within gap tolerance, don't end yet | |
| return None | |
| # FLAG has been cleared - end the event | |
| return self._end_flag_event(timestamp) | |
| def _handle_no_scorebug(self, timestamp: float) -> Optional[PlayEvent]: | |
| """Handle no scorebug being visible (likely replay).""" | |
| # During replays, we keep the FLAG event open | |
| # Only end if gap tolerance exceeded | |
| if not self._state.flag_active: | |
| return None | |
| if self._last_flag_seen_at is not None: | |
| gap = timestamp - self._last_flag_seen_at | |
| if gap > self.gap_tolerance: | |
| # Gap too long, end the event | |
| return self._end_flag_event(timestamp) | |
| return None | |
| def _handle_no_flag_info(self, timestamp: float, scorebug_detected: bool) -> Optional[PlayEvent]: | |
| """Handle case when no FLAG info is provided.""" | |
| # Same logic as FLAG cleared if scorebug is visible | |
| if scorebug_detected: | |
| return self._handle_flag_cleared(timestamp) | |
| return self._handle_no_scorebug(timestamp) | |
| def _end_flag_event(self, timestamp: float) -> Optional[PlayEvent]: | |
| """End the current FLAG event and potentially create a PlayEvent.""" | |
| if self._state.current_flag is None: | |
| self._state.flag_active = False | |
| return None | |
| # Set end time | |
| self._state.current_flag.end_time = self._last_flag_seen_at or timestamp | |
| # Calculate duration | |
| duration = self._state.current_flag.end_time - self._state.current_flag.start_time | |
| peak_yellow = self._state.current_flag.peak_yellow_ratio | |
| avg_yellow = self._state.current_flag.avg_yellow_ratio | |
| avg_hue = self._state.current_flag.avg_hue | |
| # Store completed flag data | |
| self._state.completed_flags.append(self._state.current_flag) | |
| scorebug_ratio = self._state.current_flag.scorebug_ratio | |
| logger.info( | |
| "FLAG EVENT ended at %.1fs (duration=%.1fs, peak=%.0f%%, avg=%.0f%%, hue=%.1f, scorebug=%.0f%%)", | |
| self._state.current_flag.end_time, | |
| duration, | |
| peak_yellow * 100, | |
| avg_yellow * 100, | |
| avg_hue, | |
| scorebug_ratio * 100, | |
| ) | |
| # Check if FLAG event meets all criteria to become a FLAG PLAY | |
| play_event = None | |
| reject_reasons = [] | |
| if duration < self.min_flag_duration: | |
| reject_reasons.append(f"duration {duration:.1f}s < {self.min_flag_duration}s") | |
| if peak_yellow < self.min_peak_yellow: | |
| reject_reasons.append(f"peak {peak_yellow:.0%} < {self.min_peak_yellow:.0%}") | |
| if avg_yellow < self.min_avg_yellow: | |
| reject_reasons.append(f"avg {avg_yellow:.0%} < {self.min_avg_yellow:.0%}") | |
| if avg_hue < self.min_mean_hue: | |
| reject_reasons.append(f"hue {avg_hue:.1f} < {self.min_mean_hue:.1f} (not FLAG yellow)") | |
| if avg_hue > self.max_mean_hue: | |
| reject_reasons.append(f"hue {avg_hue:.1f} > {self.max_mean_hue:.1f} (lime-green, not yellow)") | |
| if scorebug_ratio < self.min_scorebug_ratio: | |
| reject_reasons.append(f"scorebug {scorebug_ratio:.0%} < {self.min_scorebug_ratio:.0%} (likely replay/commercial)") | |
| if reject_reasons: | |
| logger.debug( | |
| "FLAG event rejected: %s", | |
| ", ".join(reject_reasons), | |
| ) | |
| else: | |
| # Passed all filters - create FLAG PLAY | |
| play_event = self._create_flag_play(self._state.current_flag) | |
| logger.info( | |
| "FLAG PLAY #%d created: %.1fs - %.1fs (duration=%.1fs, peak=%.0f%%, avg=%.0f%%, hue=%.1f)", | |
| play_event.play_number, | |
| play_event.start_time, | |
| play_event.end_time, | |
| duration, | |
| peak_yellow * 100, | |
| avg_yellow * 100, | |
| avg_hue, | |
| ) | |
| # Reset state | |
| self._state.flag_active = False | |
| self._state.current_flag = None | |
| return play_event | |
| def _create_flag_play(self, flag_data: FlagEventData) -> PlayEvent: | |
| """Create a PlayEvent from FLAG event data.""" | |
| self._play_count += 1 | |
| return PlayEvent( | |
| play_number=self._play_count, | |
| start_time=flag_data.start_time, | |
| end_time=flag_data.end_time or flag_data.start_time, | |
| confidence=0.95, # High confidence for FLAG events | |
| start_method="flag_detected", | |
| end_method="flag_cleared", | |
| play_type="flag", | |
| has_flag=True, | |
| ) | |
| # ========================================================================= | |
| # Public API methods | |
| # ========================================================================= | |
| def get_plays(self) -> List[PlayEvent]: | |
| """ | |
| Get all FLAG plays detected so far. | |
| Note: This only returns completed FLAG events that passed all filters. | |
| If a FLAG is currently active, it will be included once it ends | |
| (if it passes the filters). | |
| """ | |
| plays = [] | |
| for flag_data in self._state.completed_flags: | |
| if flag_data.end_time is not None: | |
| duration = flag_data.end_time - flag_data.start_time | |
| # Apply the same filters used in _end_flag_event | |
| passes_duration = duration >= self.min_flag_duration | |
| passes_peak = flag_data.peak_yellow_ratio >= self.min_peak_yellow | |
| passes_avg = flag_data.avg_yellow_ratio >= self.min_avg_yellow | |
| passes_min_hue = flag_data.avg_hue >= self.min_mean_hue | |
| passes_max_hue = flag_data.avg_hue <= self.max_mean_hue | |
| if passes_duration and passes_peak and passes_avg and passes_min_hue and passes_max_hue: | |
| play = PlayEvent( | |
| play_number=0, # Will be renumbered by PlayMerger | |
| start_time=flag_data.start_time, | |
| end_time=flag_data.end_time, | |
| confidence=0.95, | |
| start_method="flag_detected", | |
| end_method="flag_cleared", | |
| play_type="flag", | |
| has_flag=True, | |
| ) | |
| plays.append(play) | |
| return plays | |
| def finalize(self, timestamp: float) -> Optional[PlayEvent]: | |
| """ | |
| Finalize any active FLAG event at end of video. | |
| Args: | |
| timestamp: Final video timestamp | |
| Returns: | |
| PlayEvent if a FLAG was active, None otherwise | |
| """ | |
| if self._state.flag_active and self._state.current_flag is not None: | |
| # Force-end the current FLAG event | |
| return self._end_flag_event(timestamp) | |
| return None | |
| def set_play_count(self, count: int) -> None: | |
| """Set the play count (used for coordination with parent tracker).""" | |
| self._play_count = count | |
| def get_play_count(self) -> int: | |
| """Get the current play count.""" | |
| return self._play_count | |
| def get_stats(self) -> dict[str, Any]: | |
| """Get statistics about FLAG tracking.""" | |
| completed_durations = [] | |
| for flag_data in self._state.completed_flags: | |
| if flag_data.end_time is not None: | |
| completed_durations.append(flag_data.end_time - flag_data.start_time) | |
| valid_durations = [d for d in completed_durations if d >= self.min_flag_duration] | |
| return { | |
| "total_flag_events": self._state.total_flags_detected, | |
| "valid_flag_plays": len(valid_durations), | |
| "rejected_short_flags": len(completed_durations) - len(valid_durations), | |
| "avg_flag_duration": sum(valid_durations) / len(valid_durations) if valid_durations else 0, | |
| "min_flag_duration": min(valid_durations) if valid_durations else 0, | |
| "max_flag_duration": max(valid_durations) if valid_durations else 0, | |
| } | |