""" 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__) @dataclass 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 @property 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 @dataclass 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 # ========================================================================= @property def flag_active(self) -> bool: """Whether a FLAG is currently being tracked.""" return self._state.flag_active @property 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, }