File size: 29,490 Bytes
30191d6
 
 
 
 
 
46f8ebc
 
 
 
30191d6
 
 
 
 
719b8f7
30191d6
 
 
 
46f8ebc
30191d6
aa2c8ff
30191d6
 
21c8945
30191d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21c8945
30191d6
 
 
21c8945
30191d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719b8f7
30191d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719b8f7
30191d6
 
 
 
 
 
 
 
 
 
 
 
 
719b8f7
 
 
 
30191d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719b8f7
30191d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719b8f7
30191d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72dca15
46f8ebc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
"""
Timeout tracker detector module.

This module provides functions to detect timeout indicator changes on the scorebug.
Each team has 3 timeout indicators (white ovals when available, dark when used).
Detecting when an oval changes from white to dark indicates a timeout was called.

Two detection modes are supported:
1. Legacy mode (DetectTimeouts): Divides region into 3 equal parts
2. Calibrated mode (CalibratedTimeoutDetector): Uses blob-detected oval positions
"""

import json
import logging
from pathlib import Path
from typing import Any, Optional, Tuple, List

import cv2
import numpy as np

from .models import CalibratedTimeoutRegion, OvalLocation, TimeoutRegionConfig, TimeoutReading

logger = logging.getLogger(__name__)


class DetectTimeouts:
    """
    Tracks timeout indicators on the scorebug.

    Each team has 3 timeout indicators displayed as ovals:
    - White oval = timeout available
    - Dark oval = timeout used

    The tracker monitors these indicators and detects when a timeout is called
    (white oval becomes dark).
    """

    # Threshold for determining if a pixel is "bright" (part of white oval)
    BRIGHT_PIXEL_THRESHOLD = 200  # On 0-255 scale

    # Minimum percentage of bright pixels for an oval to be considered "white" (available)
    # Based on analysis: available ovals have 0.15-0.19 ratio, used ovals have ~0.00 ratio
    BRIGHT_PIXEL_RATIO_THRESHOLD = 0.10  # 10% of pixels must be bright for available timeout

    # Minimum confidence for a valid reading
    MIN_CONFIDENCE = 0.5

    def __init__(
        self,
        home_region: Optional[TimeoutRegionConfig] = None,
        away_region: Optional[TimeoutRegionConfig] = None,
        config_path: Optional[str] = None,
    ):
        """
        Initialize the timeout tracker.

        Args:
            home_region: Configuration for home team's timeout indicators
            away_region: Configuration for away team's timeout indicators
            config_path: Path to JSON config file with regions (alternative to direct config)
        """
        self.home_region = home_region
        self.away_region = away_region
        self._configured = home_region is not None and away_region is not None

        # Previous reading for change detection
        self._prev_reading: Optional[TimeoutReading] = None

        # Load from config if provided
        if config_path and not self._configured:
            self._load_config(config_path)

        if self._configured:
            logger.info("DetectTimeouts initialized with regions")
            logger.info("  Home region: %s", self.home_region.bbox if self.home_region else None)
            logger.info("  Away region: %s", self.away_region.bbox if self.away_region else None)
        else:
            logger.info("DetectTimeouts initialized (not configured - call configure_regions first)")

    def _load_config(self, config_path: str) -> None:
        """Load timeout regions from a JSON config file."""
        path = Path(config_path)
        if not path.exists():
            logger.warning("Timeout tracker config not found: %s", config_path)
            return

        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)

        if "home_timeout_region" in data:
            self.home_region = TimeoutRegionConfig.from_dict(data["home_timeout_region"])
        if "away_timeout_region" in data:
            self.away_region = TimeoutRegionConfig.from_dict(data["away_timeout_region"])

        self._configured = self.home_region is not None and self.away_region is not None
        if self._configured:
            logger.info("Loaded timeout tracker config from: %s", config_path)

    def save_config(self, config_path: str) -> None:
        """Save timeout regions to a JSON config file."""
        if not self._configured:
            logger.warning("Cannot save config - tracker not configured")
            return

        data = {}
        if self.home_region:
            data["home_timeout_region"] = self.home_region.to_dict()
        if self.away_region:
            data["away_timeout_region"] = self.away_region.to_dict()

        path = Path(config_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)

        logger.info("Saved timeout tracker config to: %s", config_path)

    def is_configured(self) -> bool:
        """Check if the tracker is configured with regions."""
        return self._configured

    def set_regions(self, home_region: TimeoutRegionConfig, away_region: TimeoutRegionConfig) -> None:
        """
        Set the timeout indicator regions.

        Args:
            home_region: Configuration for home team's timeout indicators
            away_region: Configuration for away team's timeout indicators
        """
        self.home_region = home_region
        self.away_region = away_region
        self._configured = True
        logger.info("Timeout regions set: home=%s, away=%s", home_region.bbox, away_region.bbox)

    def _extract_oval_bright_ratios(self, frame: np.ndarray[Any, Any], region: TimeoutRegionConfig) -> List[float]:
        """
        Extract the ratio of bright pixels for each oval in a region.

        Divides the region into 3 equal VERTICAL parts (one per oval) since
        timeout indicators are stacked vertically (3 horizontal bars).

        Args:
            frame: Input frame (BGR format)
            region: Region configuration

        Returns:
            List of 3 bright pixel ratios (0.0-1.0), one per oval
        """
        x, y, w, h = region.bbox

        # Validate bounds
        frame_h, frame_w = frame.shape[:2]
        if x < 0 or y < 0 or x + w > frame_w or y + h > frame_h:
            logger.warning("Timeout region out of bounds: %s", region.bbox)
            return [0.0, 0.0, 0.0]

        # Extract the region
        roi = frame[y : y + h, x : x + w]

        # Convert to grayscale for brightness analysis
        gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

        # Divide into 3 equal VERTICAL sections (one per oval) - they're stacked
        oval_height = h // 3
        bright_ratios = []

        for i in range(3):
            start_y = i * oval_height
            end_y = start_y + oval_height if i < 2 else h  # Last oval gets remaining height

            oval_region = gray[start_y:end_y, :]

            # Count pixels above brightness threshold
            total_pixels = oval_region.size
            bright_pixels = np.sum(oval_region >= self.BRIGHT_PIXEL_THRESHOLD)
            bright_ratio = bright_pixels / total_pixels if total_pixels > 0 else 0.0

            bright_ratios.append(float(bright_ratio))

        return bright_ratios

    def _classify_ovals(self, bright_ratios: List[float]) -> List[bool]:
        """
        Classify each oval as white (available) or dark (used).

        An oval is considered "white" (timeout available) if it has enough
        bright pixels above the threshold.

        Args:
            bright_ratios: List of bright pixel ratios for each oval

        Returns:
            List of booleans: True = white/available, False = dark/used
        """
        return [ratio >= self.BRIGHT_PIXEL_RATIO_THRESHOLD for ratio in bright_ratios]

    def _count_available_timeouts(self, oval_states: List[bool]) -> int:
        """Count how many timeouts are available (white ovals)."""
        return sum(1 for state in oval_states if state)

    def read_timeouts(self, frame: np.ndarray[Any, Any]) -> TimeoutReading:
        """
        Read the current timeout count for each team.

        Args:
            frame: Input frame (BGR format)

        Returns:
            TimeoutReading with current timeout counts
        """
        if not self._configured:
            logger.warning("Timeout tracker not configured")
            return TimeoutReading(home_timeouts=3, away_timeouts=3, confidence=0.0)

        # Asserts: _configured guarantees regions are set
        assert self.home_region is not None
        assert self.away_region is not None

        # Read home team timeouts using bright pixel ratio
        home_bright_ratios = self._extract_oval_bright_ratios(frame, self.home_region)
        home_states = self._classify_ovals(home_bright_ratios)
        home_count = self._count_available_timeouts(home_states)

        # Read away team timeouts using bright pixel ratio
        away_bright_ratios = self._extract_oval_bright_ratios(frame, self.away_region)
        away_states = self._classify_ovals(away_bright_ratios)
        away_count = self._count_available_timeouts(away_states)

        # Calculate confidence based on how distinct the readings are
        all_ratios = home_bright_ratios + away_bright_ratios
        confidence = self._calculate_confidence(all_ratios)

        reading = TimeoutReading(
            home_timeouts=home_count,
            away_timeouts=away_count,
            confidence=confidence,
            home_oval_states=home_states,
            away_oval_states=away_states,
        )

        logger.debug(
            "Timeout reading: home=%d (states=%s, ratios=%s), away=%d (states=%s, ratios=%s), conf=%.2f",
            home_count,
            home_states,
            [f"{r:.2f}" for r in home_bright_ratios],
            away_count,
            away_states,
            [f"{r:.2f}" for r in away_bright_ratios],
            confidence,
        )

        return reading

    def _calculate_confidence(self, bright_ratios: List[float]) -> float:
        """
        Calculate confidence based on how distinct the bright pixel ratios are.

        High confidence when ratios are clearly above or below threshold.
        Low confidence when ratios are near the threshold.
        """
        if not bright_ratios:
            return 0.0

        # Calculate distance from threshold for each ratio
        distances = [abs(r - self.BRIGHT_PIXEL_RATIO_THRESHOLD) for r in bright_ratios]

        # Average distance, normalized (threshold is 0.15, so max meaningful distance is ~0.85)
        avg_distance = sum(distances) / len(distances)
        confidence = min(1.0, avg_distance / 0.1)  # 0.1 ratio distance = full confidence

        return confidence

    def detect_timeout_change(self, curr_reading: TimeoutReading) -> Optional[str]:
        """
        Detect if a timeout was just called by comparing with previous reading.

        Args:
            curr_reading: Current timeout reading

        Returns:
            "home" if home team called timeout, "away" if away team did, None otherwise
        """
        if self._prev_reading is None:
            self._prev_reading = curr_reading
            return None

        # Check for timeout decrement
        home_change = self._prev_reading.home_timeouts - curr_reading.home_timeouts
        away_change = self._prev_reading.away_timeouts - curr_reading.away_timeouts

        result = None
        if home_change > 0:
            logger.info("HOME team timeout detected: %d -> %d", self._prev_reading.home_timeouts, curr_reading.home_timeouts)
            result = "home"
        elif away_change > 0:
            logger.info("AWAY team timeout detected: %d -> %d", self._prev_reading.away_timeouts, curr_reading.away_timeouts)
            result = "away"

        self._prev_reading = curr_reading
        return result

    def update(self, frame: np.ndarray[Any, Any]) -> Tuple[TimeoutReading, Optional[str]]:
        """
        Read timeouts and detect any change in one call.

        Args:
            frame: Input frame

        Returns:
            Tuple of (current reading, team that called timeout or None)
        """
        reading = self.read_timeouts(frame)
        change = self.detect_timeout_change(reading)
        return reading, change

    def reset_tracking(self) -> None:
        """Reset the previous reading for fresh tracking."""
        self._prev_reading = None
        logger.debug("Timeout tracking reset")

    def visualize(self, frame: np.ndarray[Any, Any], reading: Optional[TimeoutReading] = None) -> np.ndarray[Any, Any]:
        """
        Draw timeout regions and states on frame for visualization.

        Args:
            frame: Input frame
            reading: Optional reading to display (if None, will read from frame)

        Returns:
            Frame with visualization overlay
        """
        vis_frame = frame.copy()

        if not self._configured:
            cv2.putText(vis_frame, "Timeout tracker not configured", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
            return vis_frame

        if reading is None:
            reading = self.read_timeouts(frame)

        # Draw home team region
        if self.home_region:
            x, y, w, h = self.home_region.bbox
            cv2.rectangle(vis_frame, (x, y), (x + w, y + h), (255, 0, 0), 2)  # Blue
            cv2.putText(vis_frame, f"HOME: {reading.home_timeouts}", (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 1)

            # Draw individual oval states
            if reading.home_oval_states:
                oval_width = w // 3
                for i, state in enumerate(reading.home_oval_states):
                    color = (0, 255, 0) if state else (0, 0, 255)  # Green if available, red if used
                    cx = x + i * oval_width + oval_width // 2
                    cy = y + h + 10
                    cv2.circle(vis_frame, (cx, cy), 5, color, -1)

        # Draw away team region
        if self.away_region:
            x, y, w, h = self.away_region.bbox
            cv2.rectangle(vis_frame, (x, y), (x + w, y + h), (0, 165, 255), 2)  # Orange
            cv2.putText(vis_frame, f"AWAY: {reading.away_timeouts}", (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1)

            # Draw individual oval states
            if reading.away_oval_states:
                oval_width = w // 3
                for i, state in enumerate(reading.away_oval_states):
                    color = (0, 255, 0) if state else (0, 0, 255)
                    cx = x + i * oval_width + oval_width // 2
                    cy = y + h + 10
                    cv2.circle(vis_frame, (cx, cy), 5, color, -1)

        # Add confidence
        cv2.putText(vis_frame, f"Conf: {reading.confidence:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)

        return vis_frame


class CalibratedTimeoutDetector:
    """
    Timeout detector using calibrated oval positions.

    This detector uses precise oval locations discovered during calibration,
    rather than dividing the region into equal parts. This provides more
    accurate timeout detection by checking brightness at the exact oval locations.
    """

    # Brightness threshold ratio - oval is "dark" if below this fraction of baseline
    BRIGHTNESS_THRESHOLD_RATIO = 0.5

    # Minimum brightness for an oval to be considered valid (scorebug visible)
    # If the brightest oval is below this, we consider the scorebug not visible
    # Calibrated ovals have baseline brightness ~185-195, so we need at least 120
    # to confidently say the scorebug is visible with readable ovals
    MIN_VALID_BRIGHTNESS = 120

    def __init__(
        self,
        home_region: Optional[CalibratedTimeoutRegion] = None,
        away_region: Optional[CalibratedTimeoutRegion] = None,
        config_path: Optional[str] = None,
    ):
        """
        Initialize the calibrated timeout detector.

        Args:
            home_region: Calibrated region for home team's timeout indicators
            away_region: Calibrated region for away team's timeout indicators
            config_path: Path to JSON config file with calibrated regions
        """
        self.home_region = home_region
        self.away_region = away_region
        self._configured = home_region is not None and away_region is not None

        # Previous reading for change detection
        self._prev_reading: Optional[TimeoutReading] = None

        # Load from config if provided
        if config_path and not self._configured:
            self._load_config(config_path)

        if self._configured:
            home_ovals = len(self.home_region.ovals) if self.home_region else 0
            away_ovals = len(self.away_region.ovals) if self.away_region else 0
            logger.info("CalibratedTimeoutDetector initialized: home=%d ovals, away=%d ovals", home_ovals, away_ovals)
        else:
            logger.info("CalibratedTimeoutDetector initialized (not configured - call calibrate first)")

    def _load_config(self, config_path: str) -> None:
        """Load calibrated timeout regions from a JSON config file."""
        path = Path(config_path)
        if not path.exists():
            logger.warning("Calibrated timeout config not found: %s", config_path)
            return

        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)

        # Check for calibrated regions (with ovals)
        if "home_timeout_region" in data and "ovals" in data["home_timeout_region"]:
            self.home_region = CalibratedTimeoutRegion.from_dict(data["home_timeout_region"])
        if "away_timeout_region" in data and "ovals" in data["away_timeout_region"]:
            self.away_region = CalibratedTimeoutRegion.from_dict(data["away_timeout_region"])

        self._configured = self.home_region is not None and self.away_region is not None
        if self._configured:
            logger.info("Loaded calibrated timeout config from: %s", config_path)

    def save_config(self, config_path: str) -> None:
        """Save calibrated timeout regions to a JSON config file."""
        if not self._configured:
            logger.warning("Cannot save config - detector not calibrated")
            return

        data = {}
        if self.home_region:
            data["home_timeout_region"] = self.home_region.to_dict()
        if self.away_region:
            data["away_timeout_region"] = self.away_region.to_dict()

        path = Path(config_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)

        logger.info("Saved calibrated timeout config to: %s", config_path)

    def is_configured(self) -> bool:
        """Check if the detector is configured with calibrated regions."""
        return self._configured

    def set_regions(self, home_region: CalibratedTimeoutRegion, away_region: CalibratedTimeoutRegion) -> None:
        """
        Set the calibrated timeout regions.

        Args:
            home_region: Calibrated region for home team
            away_region: Calibrated region for away team
        """
        self.home_region = home_region
        self.away_region = away_region
        self._configured = True
        logger.info(
            "Calibrated regions set: home=%d ovals, away=%d ovals",
            len(home_region.ovals),
            len(away_region.ovals),
        )

    def _check_oval_brightness(self, frame: np.ndarray[Any, Any], region: CalibratedTimeoutRegion, oval: OvalLocation) -> Tuple[bool, float]:
        """
        Check if a specific oval is still bright (timeout available).

        Args:
            frame: Full video frame (BGR format)
            region: Calibrated region containing the oval
            oval: OvalLocation to check

        Returns:
            Tuple of (is_bright, current_brightness)
        """
        rx, ry, _, _ = region.bbox

        # Calculate absolute position in frame
        abs_x = rx + oval.x
        abs_y = ry + oval.y

        # Validate bounds
        frame_h, frame_w = frame.shape[:2]
        if abs_x < 0 or abs_y < 0 or abs_x + oval.width > frame_w or abs_y + oval.height > frame_h:
            logger.warning("Oval position out of bounds: (%d, %d)", abs_x, abs_y)
            return False, 0.0

        # Extract the oval region
        oval_roi = frame[abs_y : abs_y + oval.height, abs_x : abs_x + oval.width]

        # Convert to grayscale and calculate mean brightness
        gray = cv2.cvtColor(oval_roi, cv2.COLOR_BGR2GRAY)
        current_brightness = float(np.mean(np.asarray(gray)))

        # Compare to baseline
        threshold = oval.baseline_brightness * self.BRIGHTNESS_THRESHOLD_RATIO
        is_bright = current_brightness >= threshold

        return is_bright, current_brightness

    def _read_team_timeouts(self, frame: np.ndarray[Any, Any], region: CalibratedTimeoutRegion) -> Tuple[int, List[bool], float]:
        """
        Read timeout count for a single team.

        Args:
            frame: Full video frame (BGR format)
            region: Calibrated region for the team

        Returns:
            Tuple of (timeout_count, oval_states, max_brightness)
            - timeout_count: Number of available timeouts (bright ovals)
            - oval_states: List of booleans for each oval
            - max_brightness: Maximum brightness across all ovals (for validity check)
        """
        oval_states = []
        max_brightness = 0.0

        for oval in region.ovals:
            is_bright, brightness = self._check_oval_brightness(frame, region, oval)
            oval_states.append(is_bright)
            max_brightness = max(max_brightness, brightness)

        timeout_count = sum(1 for state in oval_states if state)
        return timeout_count, oval_states, max_brightness

    def read_timeouts(self, frame: np.ndarray[Any, Any]) -> TimeoutReading:
        """
        Read the current timeout count for each team.

        Args:
            frame: Input frame (BGR format)

        Returns:
            TimeoutReading with current timeout counts
            Note: confidence=0.0 if scorebug appears not visible (all ovals too dark)
        """
        if not self._configured:
            logger.warning("Calibrated timeout detector not configured")
            return TimeoutReading(home_timeouts=3, away_timeouts=3, confidence=0.0)

        assert self.home_region is not None
        assert self.away_region is not None

        # Read home team timeouts
        home_count, home_states, home_max_brightness = self._read_team_timeouts(frame, self.home_region)

        # Read away team timeouts
        away_count, away_states, away_max_brightness = self._read_team_timeouts(frame, self.away_region)

        # Check if scorebug is likely not visible
        # At least ONE region must have valid brightness to indicate scorebug is visible
        # A region with 0 timeouts will have all dark ovals (low brightness), which is valid
        # but a region showing end zone/players will have different patterns
        home_visible = home_max_brightness >= self.MIN_VALID_BRIGHTNESS
        away_visible = away_max_brightness >= self.MIN_VALID_BRIGHTNESS

        # Scorebug is visible if at least one team has bright ovals
        # (a team with 0 timeouts will have dark ovals but other team should be bright)
        scorebug_visible = home_visible or away_visible

        # Additional check: if BOTH regions are dark, scorebug is definitely not visible
        # But if only ONE is dark and the other is bright, the dark one likely has 0 timeouts
        if not scorebug_visible:
            logger.debug(
                "Scorebug appears not visible (home_max=%.1f, away_max=%.1f, threshold=%.1f)",
                home_max_brightness,
                away_max_brightness,
                self.MIN_VALID_BRIGHTNESS,
            )
            confidence = 0.0
        else:
            confidence = 1.0 if (len(home_states) == 3 and len(away_states) == 3) else 0.8

        reading = TimeoutReading(
            home_timeouts=home_count,
            away_timeouts=away_count,
            confidence=confidence,
            home_oval_states=home_states,
            away_oval_states=away_states,
        )

        logger.debug(
            "Calibrated timeout reading: home=%d (states=%s), away=%d (states=%s)",
            home_count,
            home_states,
            away_count,
            away_states,
        )

        return reading

    def detect_timeout_change(self, curr_reading: TimeoutReading) -> Optional[str]:
        """
        Detect if a timeout was just called by comparing with previous reading.

        Args:
            curr_reading: Current timeout reading

        Returns:
            "home" if home team called timeout, "away" if away team did, None otherwise
        """
        # Skip low-confidence readings (scorebug not visible)
        if curr_reading.confidence < 0.5:
            logger.debug("Skipping timeout change detection - low confidence reading (%.2f)", curr_reading.confidence)
            return None

        if self._prev_reading is None:
            self._prev_reading = curr_reading
            return None

        # Skip if previous reading was low confidence
        if self._prev_reading.confidence < 0.5:
            self._prev_reading = curr_reading
            return None

        # Check for timeout decrement - must be exactly 1 decrease for one team
        home_change = self._prev_reading.home_timeouts - curr_reading.home_timeouts
        away_change = self._prev_reading.away_timeouts - curr_reading.away_timeouts

        result = None

        # Valid timeout: exactly one team decreases by exactly 1, other stays same
        if home_change == 1 and away_change == 0:
            logger.info("HOME team timeout detected: %d -> %d", self._prev_reading.home_timeouts, curr_reading.home_timeouts)
            result = "home"
        elif away_change == 1 and home_change == 0:
            logger.info("AWAY team timeout detected: %d -> %d", self._prev_reading.away_timeouts, curr_reading.away_timeouts)
            result = "away"
        elif home_change != 0 or away_change != 0:
            # Invalid pattern - both changed or changed by more than 1
            logger.debug(
                "Invalid timeout pattern: home %d->%d (Δ%d), away %d->%d (Δ%d)",
                self._prev_reading.home_timeouts,
                curr_reading.home_timeouts,
                home_change,
                self._prev_reading.away_timeouts,
                curr_reading.away_timeouts,
                away_change,
            )

        self._prev_reading = curr_reading
        return result

    def update(self, frame: np.ndarray[Any, Any]) -> Tuple[TimeoutReading, Optional[str]]:
        """
        Read timeouts and detect any change in one call.

        Args:
            frame: Input frame

        Returns:
            Tuple of (current reading, team that called timeout or None)
        """
        reading = self.read_timeouts(frame)
        change = self.detect_timeout_change(reading)
        return reading, change

    def reset_tracking(self) -> None:
        """Reset the previous reading for fresh tracking."""
        self._prev_reading = None
        logger.debug("Calibrated timeout tracking reset")

    def visualize(self, frame: np.ndarray[Any, Any], reading: Optional[TimeoutReading] = None) -> np.ndarray[Any, Any]:
        """
        Draw calibrated oval positions and states on frame for visualization.

        Args:
            frame: Input frame
            reading: Optional reading to display (if None, will read from frame)

        Returns:
            Frame with visualization overlay
        """
        vis_frame = frame.copy()

        if not self._configured:
            cv2.putText(vis_frame, "Calibrated timeout detector not configured", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
            return vis_frame

        if reading is None:
            reading = self.read_timeouts(frame)

        # Draw home team region and ovals
        if self.home_region:
            rx, ry, rw, rh = self.home_region.bbox
            cv2.rectangle(vis_frame, (rx, ry), (rx + rw, ry + rh), (255, 0, 0), 2)
            cv2.putText(vis_frame, f"HOME: {reading.home_timeouts}", (rx, ry - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 1)

            # Draw each calibrated oval
            for i, oval in enumerate(self.home_region.ovals):
                abs_x = rx + oval.x
                abs_y = ry + oval.y
                state = reading.home_oval_states[i] if reading.home_oval_states and i < len(reading.home_oval_states) else True
                color = (0, 255, 0) if state else (0, 0, 255)
                cv2.rectangle(vis_frame, (abs_x, abs_y), (abs_x + oval.width, abs_y + oval.height), color, 2)

        # Draw away team region and ovals
        if self.away_region:
            rx, ry, rw, rh = self.away_region.bbox
            cv2.rectangle(vis_frame, (rx, ry), (rx + rw, ry + rh), (0, 165, 255), 2)
            cv2.putText(vis_frame, f"AWAY: {reading.away_timeouts}", (rx, ry - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1)

            # Draw each calibrated oval
            for i, oval in enumerate(self.away_region.ovals):
                abs_x = rx + oval.x
                abs_y = ry + oval.y
                state = reading.away_oval_states[i] if reading.away_oval_states and i < len(reading.away_oval_states) else True
                color = (0, 255, 0) if state else (0, 0, 255)
                cv2.rectangle(vis_frame, (abs_x, abs_y), (abs_x + oval.width, abs_y + oval.height), color, 2)

        cv2.putText(vis_frame, f"Conf: {reading.confidence:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)

        return vis_frame