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,
        }