cfb40 / src /tracking /flag_tracker.py
andytaylor-smg's picture
some decent progress generalizing
137c6cf
"""
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,
}