Spaces:
Sleeping
Sleeping
File size: 17,268 Bytes
fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 72dca15 fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf fbeda03 137c6cf 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 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 | """
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,
}
|