Spaces:
Sleeping
Sleeping
Commit ·
eecfaf7
1
Parent(s): bbd3e23
timeout works now
Browse files- docs/timeout_ground_truth.md +206 -0
- scripts/cache_playclock_readings.py +267 -0
- scripts/test_timeout_at_transitions.py +303 -0
- src/pipeline/play_extractor.py +3 -0
- src/tracking/clock_reset_identifier.py +72 -17
- src/tracking/models.py +10 -0
- src/tracking/play_identification_checks.py +186 -23
- src/tracking/play_merger.py +9 -5
- src/tracking/play_state.py +5 -1
- src/tracking/state_handlers.py +7 -4
docs/timeout_ground_truth.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Timeout Ground Truth
|
| 2 |
+
|
| 3 |
+
## Video: OSU vs Tenn 12.21.24.mkv
|
| 4 |
+
|
| 5 |
+
### Timeout Events (Chronological)
|
| 6 |
+
|
| 7 |
+
| Timestamp | Seconds | Team | Notes |
|
| 8 |
+
|-----------|---------|------|-------|
|
| 9 |
+
| 4:25 | 265 | HOME | First timeout of the game |
|
| 10 |
+
| 1:07:30 | 4050 | AWAY | |
|
| 11 |
+
| 1:09:40 | 4180 | AWAY | |
|
| 12 |
+
| 1:14:07 | 4447 | HOME | |
|
| 13 |
+
| 1:16:06 | 4566 | HOME | |
|
| 14 |
+
| 1:17:32 | - | - | **Halftime** - All timeouts hidden |
|
| 15 |
+
| 1:20:53 | - | - | Scorebug reappears, timeouts reset to 3 each |
|
| 16 |
+
| 1:44:54 | 6294 | AWAY | |
|
| 17 |
+
| 2:22:30 | - | - | **Game Over** - All timeouts hidden |
|
| 18 |
+
|
| 19 |
+
### Summary by Half
|
| 20 |
+
|
| 21 |
+
**First Half:**
|
| 22 |
+
- HOME: 3 timeouts used (4:25, 1:14:07, 1:16:06)
|
| 23 |
+
- AWAY: 2 timeouts used (1:07:30, 1:09:40)
|
| 24 |
+
|
| 25 |
+
**Second Half:**
|
| 26 |
+
- AWAY: 1 timeout used (1:44:54)
|
| 27 |
+
|
| 28 |
+
### Total Timeouts to Detect: 6
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## v4 Baseline Timeout Tracker Performance
|
| 33 |
+
|
| 34 |
+
### Detected Timeouts (17 total from v4_baseline.json)
|
| 35 |
+
|
| 36 |
+
| Play # | Timestamp | Seconds | Team | Ground Truth Match |
|
| 37 |
+
|--------|-----------|---------|------|-------------------|
|
| 38 |
+
| 4 | 4:26 | 266 | HOME | ✓ Matches 4:25 |
|
| 39 |
+
| 10 | 9:19 | 559 | HOME | ✗ False positive |
|
| 40 |
+
| 15 | 12:58 | 778 | HOME | ✗ False positive |
|
| 41 |
+
| 21 | 17:37 | 1057 | HOME | ✗ False positive |
|
| 42 |
+
| 35 | 29:05 | 1745 | HOME | ✗ False positive |
|
| 43 |
+
| 60 | 48:21 | 2901 | HOME | ✗ False positive |
|
| 44 |
+
| 68 | 55:04 | 3304 | HOME | ✗ False positive |
|
| 45 |
+
| 79 | 1:02:24 | 3744 | HOME | ✗ False positive |
|
| 46 |
+
| 91 | 1:11:54 | 4314 | HOME | ✗ False positive |
|
| 47 |
+
| 102 | 1:23:48 | 5028 | AWAY | ✗ False positive |
|
| 48 |
+
| 104 | 1:25:40 | 5140 | HOME | ✗ False positive |
|
| 49 |
+
| 111 | 1:30:35 | 5435 | HOME | ✗ False positive |
|
| 50 |
+
| 131 | 1:44:48 | 6288 | AWAY | ✓ Matches 1:44:54 |
|
| 51 |
+
| 146 | 1:57:54 | 7074 | HOME | ✗ False positive |
|
| 52 |
+
| 151 | 2:01:50 | 7310 | HOME | ✗ False positive |
|
| 53 |
+
| 155 | 2:04:52 | 7492 | HOME | ✗ False positive |
|
| 54 |
+
| 175 | 2:18:51 | 8331 | AWAY | ✗ False positive |
|
| 55 |
+
|
| 56 |
+
### Ground Truth Comparison
|
| 57 |
+
|
| 58 |
+
| Ground Truth | Detected? | Notes |
|
| 59 |
+
|--------------|-----------|-------|
|
| 60 |
+
| 4:25 (HOME) | ✓ Play 4 at 4:26 | 1 second off |
|
| 61 |
+
| 1:07:30 (AWAY) | ✗ MISSED | No detection near this time |
|
| 62 |
+
| 1:09:40 (AWAY) | ✗ MISSED | No detection near this time |
|
| 63 |
+
| 1:14:07 (HOME) | ✗ MISSED | No detection near this time |
|
| 64 |
+
| 1:16:06 (HOME) | ✗ MISSED | No detection near this time |
|
| 65 |
+
| 1:44:54 (AWAY) | ✓ Play 131 at 1:44:48 | 6 seconds off |
|
| 66 |
+
|
| 67 |
+
### Performance Summary
|
| 68 |
+
|
| 69 |
+
- **True Positives**: 2 (detected correctly)
|
| 70 |
+
- **False Negatives**: 4 (missed real timeouts)
|
| 71 |
+
- **False Positives**: 15 (incorrectly flagged as timeout)
|
| 72 |
+
- **Recall**: 2/6 = **33%**
|
| 73 |
+
- **Precision**: 2/17 = **12%**
|
| 74 |
+
|
| 75 |
+
### Key Observations
|
| 76 |
+
|
| 77 |
+
1. **Most first-half timeouts missed**: 4 out of 5 first-half timeouts not detected
|
| 78 |
+
2. **High false positive rate**: 15 false positives, mostly flagged as HOME
|
| 79 |
+
3. **Timeout indicator region may be misconfigured**: The detector appears to trigger on 40->25 play clock transitions that aren't actual timeouts
|
| 80 |
+
4. **Second half better**: The one second-half timeout (1:44:54) was detected
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## Root Cause Analysis
|
| 85 |
+
|
| 86 |
+
### How Timeout Detection Works
|
| 87 |
+
|
| 88 |
+
1. **Trigger**: Timeout detection is only triggered when a 40→25 play clock transition is detected
|
| 89 |
+
2. **Classification**: When 40→25 occurs, `classify_40_to_25_reset()` is called which:
|
| 90 |
+
- Compares current timeout counts with last known values via `check_timeout_change()`
|
| 91 |
+
- If `timeout_info.home_timeouts < last_home_timeouts` → HOME timeout
|
| 92 |
+
- If `timeout_info.away_timeouts < last_away_timeouts` → AWAY timeout
|
| 93 |
+
- Otherwise, classified as "special play" (punt/FG/XP)
|
| 94 |
+
|
| 95 |
+
### Why False Positives Occur
|
| 96 |
+
|
| 97 |
+
The `DetectTimeouts` class reads timeout indicators via bright pixel analysis in configured regions:
|
| 98 |
+
- **Config file**: `data/config/timeout_tracker_region.json`
|
| 99 |
+
- **Home region**: (1231, 972, 30, 49) - 30x49 pixel box
|
| 100 |
+
- **Away region**: (661, 973, 31, 47) - 31x47 pixel box
|
| 101 |
+
|
| 102 |
+
**Problems observed in isolation testing:**
|
| 103 |
+
- At 0:30 (start): Reads [False, False, False] for both teams (0 timeouts) - should be 3 each
|
| 104 |
+
- At 4:30: Reads [True, True, True] for both teams (3 timeouts) - wildly inconsistent
|
| 105 |
+
- Many "resets" and spurious transitions detected
|
| 106 |
+
|
| 107 |
+
**Likely causes:**
|
| 108 |
+
1. Region coordinates may be slightly off or need adjustment for this video
|
| 109 |
+
2. Brightness threshold (200) or ratio threshold (10%) may not be optimal
|
| 110 |
+
3. The timeout indicator ovals may have a different appearance than expected
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
## Updated Analysis (2026-01-09)
|
| 115 |
+
|
| 116 |
+
### Test Methodology Improvement
|
| 117 |
+
|
| 118 |
+
The initial v4 baseline test was reading timeout indicators **immediately at the 40→25 transition**. However, the timeout indicator on the scorebug updates with a **delay of 4-6 seconds** after the clock resets.
|
| 119 |
+
|
| 120 |
+
Updated test methodology:
|
| 121 |
+
1. Cached all 17,555 play clock readings at 2 fps
|
| 122 |
+
2. Identified 55 total 40→25 transitions
|
| 123 |
+
3. Compared timeout readings from 2s BEFORE to 2-6s AFTER each transition
|
| 124 |
+
|
| 125 |
+
### Validation Rules Implemented
|
| 126 |
+
|
| 127 |
+
A valid timeout must satisfy:
|
| 128 |
+
- **Exactly one team** decreases by **exactly 1**
|
| 129 |
+
- **Other team stays the same**
|
| 130 |
+
- **Confidence threshold**: Both before and after readings must have confidence >= 0.5
|
| 131 |
+
|
| 132 |
+
This filters out scorebug visibility issues where both teams' readings change:
|
| 133 |
+
- #5 at 9:19: (2,3)→(1,1) - both changed → REJECTED
|
| 134 |
+
- #11 at 29:04: (2,3)→(0,0) - both changed → REJECTED
|
| 135 |
+
- #35 at 1:30:35: (3,3)→(3,1) - away changed by 2 → REJECTED
|
| 136 |
+
- #45 at 1:57:55: (3,2)→(0,2) - home changed by 3 → REJECTED
|
| 137 |
+
- #32 at 1:23:48: (3,3)→(3,2) with low confidence (0.487) → REJECTED
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## ✅ IMPLEMENTED FIX (2026-01-09)
|
| 142 |
+
|
| 143 |
+
### Final Results (Pipeline Implementation)
|
| 144 |
+
|
| 145 |
+
| Metric | Value |
|
| 146 |
+
|--------|-------|
|
| 147 |
+
| **True Positives** | 6 |
|
| 148 |
+
| **False Positives** | 0 |
|
| 149 |
+
| **False Negatives** | 0 |
|
| 150 |
+
| **Recall** | **100%** |
|
| 151 |
+
| **Precision** | **100%** |
|
| 152 |
+
|
| 153 |
+
### Detected Timeouts (All Correct)
|
| 154 |
+
|
| 155 |
+
| Timestamp | Seconds | Team | Ground Truth |
|
| 156 |
+
|-----------|---------|------|--------------|
|
| 157 |
+
| 4:24 | 265 | HOME | ✓ Matches 4:25 |
|
| 158 |
+
| 67:23 | 4044 | AWAY | ✓ Matches 67:30 |
|
| 159 |
+
| 69:38 | 4178 | AWAY | ✓ Matches 69:40 |
|
| 160 |
+
| 74:04 | 4444 | HOME | ✓ Matches 74:07 |
|
| 161 |
+
| 76:03 | 4563 | HOME | ✓ Matches 76:06 |
|
| 162 |
+
| 104:45 | 6285 | AWAY | ✓ Matches 104:54 |
|
| 163 |
+
|
| 164 |
+
### Implementation Details
|
| 165 |
+
|
| 166 |
+
#### Key Changes Made:
|
| 167 |
+
|
| 168 |
+
1. **Delayed timeout check** (`TIMEOUT_CHECK_DELAY = 5.5s`):
|
| 169 |
+
- Store timeout reading when 40→25 transition detected
|
| 170 |
+
- Schedule check 5.5 seconds later
|
| 171 |
+
- Compare readings to detect change
|
| 172 |
+
|
| 173 |
+
2. **Validation logic**:
|
| 174 |
+
- Require exactly ONE team to decrease by exactly 1
|
| 175 |
+
- Other team's count must stay the same
|
| 176 |
+
- Both readings must have confidence >= 0.5
|
| 177 |
+
|
| 178 |
+
3. **Multiple entry points**:
|
| 179 |
+
- `classify_40_to_25_reset()` - handles 40→25 in PRE_SNAP state
|
| 180 |
+
- `check_possession_change()` - handles 40→25 during PLAY_IN_PROGRESS state
|
| 181 |
+
- Both now schedule delayed timeout checks
|
| 182 |
+
|
| 183 |
+
4. **Merger priority fix**:
|
| 184 |
+
- Changed from `normal > special > timeout` to `normal > timeout > special`
|
| 185 |
+
- Timeout plays now have higher priority than special plays
|
| 186 |
+
|
| 187 |
+
5. **Quiet time filter fix**:
|
| 188 |
+
- Only filter "special" plays in quiet time after normal plays
|
| 189 |
+
- Timeout plays can occur immediately after normal plays end
|
| 190 |
+
|
| 191 |
+
#### Files Modified:
|
| 192 |
+
|
| 193 |
+
- `src/tracking/play_identification_checks.py`
|
| 194 |
+
- `src/tracking/state_handlers.py`
|
| 195 |
+
- `src/tracking/play_state.py`
|
| 196 |
+
- `src/tracking/models.py`
|
| 197 |
+
- `src/tracking/play_merger.py`
|
| 198 |
+
- `src/tracking/clock_reset_identifier.py`
|
| 199 |
+
|
| 200 |
+
### Key Learnings
|
| 201 |
+
|
| 202 |
+
1. **Scorebug timeout indicator delay**: The indicator updates 4-6 seconds AFTER the play clock resets from 40→25, not immediately
|
| 203 |
+
2. **Confidence thresholds matter**: Low-confidence readings can cause false positives
|
| 204 |
+
3. **Validation rules essential**: Must verify exactly one team changes by exactly 1
|
| 205 |
+
4. **Multiple code paths**: 40→25 transitions can occur in both PRE_SNAP and PLAY_IN_PROGRESS states
|
| 206 |
+
|
scripts/cache_playclock_readings.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cache all play clock readings for the OSU vs Tenn video.
|
| 3 |
+
|
| 4 |
+
This script:
|
| 5 |
+
1. Loads the template library
|
| 6 |
+
2. Reads play clock values from fixed regions for every frame (sampled at 0.5s)
|
| 7 |
+
3. Caches the results as JSON
|
| 8 |
+
4. Identifies all 40→25 transitions for timeout tracker testing
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python scripts/cache_playclock_readings.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 20 |
+
|
| 21 |
+
import cv2
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
# Add src to path
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
| 26 |
+
|
| 27 |
+
from readers import ReadPlayClock
|
| 28 |
+
from setup import DigitTemplateLibrary
|
| 29 |
+
|
| 30 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def seconds_to_timestamp(seconds: float) -> str:
|
| 35 |
+
"""Convert seconds to timestamp string (H:MM:SS)."""
|
| 36 |
+
hours = int(seconds // 3600)
|
| 37 |
+
minutes = int((seconds % 3600) // 60)
|
| 38 |
+
secs = int(seconds % 60)
|
| 39 |
+
if hours > 0:
|
| 40 |
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
| 41 |
+
return f"{minutes}:{secs:02d}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def cache_playclock_readings(
|
| 45 |
+
video_path: str,
|
| 46 |
+
template_dir: str,
|
| 47 |
+
output_path: str,
|
| 48 |
+
playclock_coords: Tuple[int, int, int, int],
|
| 49 |
+
sample_interval: float = 0.5,
|
| 50 |
+
start_time: float = 0.0,
|
| 51 |
+
end_time: Optional[float] = None,
|
| 52 |
+
) -> Dict[str, Any]:
|
| 53 |
+
"""
|
| 54 |
+
Cache all play clock readings for a video.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
video_path: Path to video file
|
| 58 |
+
template_dir: Path to digit templates directory
|
| 59 |
+
output_path: Path to save cached readings
|
| 60 |
+
playclock_coords: (x, y, width, height) absolute coordinates
|
| 61 |
+
sample_interval: Seconds between samples (default 0.5)
|
| 62 |
+
start_time: Start time in seconds
|
| 63 |
+
end_time: End time in seconds (None for full video)
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Dictionary with cached readings and transitions
|
| 67 |
+
"""
|
| 68 |
+
# Load template library
|
| 69 |
+
logger.info("Loading template library from %s", template_dir)
|
| 70 |
+
library = DigitTemplateLibrary()
|
| 71 |
+
if not library.load(template_dir):
|
| 72 |
+
raise RuntimeError(f"Failed to load templates from {template_dir}")
|
| 73 |
+
|
| 74 |
+
coverage = library.get_coverage_status()
|
| 75 |
+
logger.info("Template coverage: %d/%d (complete: %s)", coverage["total_have"], coverage["total_needed"], coverage["is_complete"])
|
| 76 |
+
|
| 77 |
+
# Create play clock reader
|
| 78 |
+
reader = ReadPlayClock(library, region_width=playclock_coords[2], region_height=playclock_coords[3])
|
| 79 |
+
|
| 80 |
+
# Open video
|
| 81 |
+
cap = cv2.VideoCapture(video_path)
|
| 82 |
+
if not cap.isOpened():
|
| 83 |
+
raise RuntimeError(f"Failed to open video: {video_path}")
|
| 84 |
+
|
| 85 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 86 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 87 |
+
duration = total_frames / fps
|
| 88 |
+
|
| 89 |
+
if end_time is None:
|
| 90 |
+
end_time = duration
|
| 91 |
+
|
| 92 |
+
logger.info("Video: %s", video_path)
|
| 93 |
+
logger.info(" FPS: %.2f, Total frames: %d, Duration: %s", fps, total_frames, seconds_to_timestamp(duration))
|
| 94 |
+
logger.info(" Processing: %s to %s", seconds_to_timestamp(start_time), seconds_to_timestamp(end_time))
|
| 95 |
+
logger.info(" Play clock region: (%d, %d, %d, %d)", *playclock_coords)
|
| 96 |
+
|
| 97 |
+
# Cache readings
|
| 98 |
+
readings: List[Dict[str, Any]] = []
|
| 99 |
+
current_time = start_time
|
| 100 |
+
frames_processed = 0
|
| 101 |
+
t_start = time.perf_counter()
|
| 102 |
+
|
| 103 |
+
while current_time <= end_time:
|
| 104 |
+
frame_num = int(current_time * fps)
|
| 105 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
|
| 106 |
+
ret, frame = cap.read()
|
| 107 |
+
|
| 108 |
+
if not ret:
|
| 109 |
+
current_time += sample_interval
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
# Read play clock using fixed coordinates
|
| 113 |
+
result = reader.read_from_fixed_location(frame, playclock_coords)
|
| 114 |
+
|
| 115 |
+
reading_entry = {
|
| 116 |
+
"timestamp": current_time,
|
| 117 |
+
"frame_num": frame_num,
|
| 118 |
+
"detected": result.detected,
|
| 119 |
+
"value": result.value,
|
| 120 |
+
"confidence": result.confidence,
|
| 121 |
+
"method": result.method,
|
| 122 |
+
}
|
| 123 |
+
readings.append(reading_entry)
|
| 124 |
+
|
| 125 |
+
frames_processed += 1
|
| 126 |
+
current_time += sample_interval
|
| 127 |
+
|
| 128 |
+
# Progress log every 5 minutes of video
|
| 129 |
+
if frames_processed % int(300 / sample_interval) == 0:
|
| 130 |
+
elapsed = time.perf_counter() - t_start
|
| 131 |
+
video_minutes = current_time / 60
|
| 132 |
+
logger.info(" Processed %d frames (%.1f min), elapsed: %.1fs", frames_processed, video_minutes, elapsed)
|
| 133 |
+
|
| 134 |
+
cap.release()
|
| 135 |
+
|
| 136 |
+
elapsed = time.perf_counter() - t_start
|
| 137 |
+
logger.info("Processed %d frames in %.1fs (%.1f fps)", frames_processed, elapsed, frames_processed / elapsed)
|
| 138 |
+
|
| 139 |
+
# Identify 40→25 transitions
|
| 140 |
+
transitions = find_40_to_25_transitions(readings)
|
| 141 |
+
logger.info("Found %d potential 40→25 transitions", len(transitions))
|
| 142 |
+
|
| 143 |
+
# Build result
|
| 144 |
+
result = {
|
| 145 |
+
"video": Path(video_path).name,
|
| 146 |
+
"config": {
|
| 147 |
+
"playclock_coords": list(playclock_coords),
|
| 148 |
+
"sample_interval": sample_interval,
|
| 149 |
+
"start_time": start_time,
|
| 150 |
+
"end_time": end_time,
|
| 151 |
+
},
|
| 152 |
+
"stats": {
|
| 153 |
+
"total_readings": len(readings),
|
| 154 |
+
"detected_readings": sum(1 for r in readings if r["detected"]),
|
| 155 |
+
"processing_time": elapsed,
|
| 156 |
+
},
|
| 157 |
+
"readings": readings,
|
| 158 |
+
"transitions_40_to_25": transitions,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
# Save to file
|
| 162 |
+
output_file = Path(output_path)
|
| 163 |
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
with open(output_file, "w", encoding="utf-8") as f:
|
| 165 |
+
json.dump(result, f, indent=2)
|
| 166 |
+
|
| 167 |
+
logger.info("Saved cache to %s", output_path)
|
| 168 |
+
|
| 169 |
+
return result
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def find_40_to_25_transitions(readings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 173 |
+
"""
|
| 174 |
+
Find all 40→25 clock transitions in the readings.
|
| 175 |
+
|
| 176 |
+
A transition is identified when:
|
| 177 |
+
1. Previous reading was 40 (or no previous reading)
|
| 178 |
+
2. Current reading is 25
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
readings: List of clock readings
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
List of transition events with timestamps and context
|
| 185 |
+
"""
|
| 186 |
+
transitions = []
|
| 187 |
+
prev_value = None
|
| 188 |
+
prev_timestamp = None
|
| 189 |
+
|
| 190 |
+
for i, reading in enumerate(readings):
|
| 191 |
+
if not reading["detected"] or reading["value"] is None:
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
curr_value = reading["value"]
|
| 195 |
+
curr_timestamp = reading["timestamp"]
|
| 196 |
+
|
| 197 |
+
# Check for 40→25 transition
|
| 198 |
+
if prev_value == 40 and curr_value == 25:
|
| 199 |
+
transition = {
|
| 200 |
+
"index": i,
|
| 201 |
+
"timestamp": curr_timestamp,
|
| 202 |
+
"timestamp_str": seconds_to_timestamp(curr_timestamp),
|
| 203 |
+
"prev_timestamp": prev_timestamp,
|
| 204 |
+
"prev_value": prev_value,
|
| 205 |
+
"curr_value": curr_value,
|
| 206 |
+
}
|
| 207 |
+
transitions.append(transition)
|
| 208 |
+
logger.debug("Found 40→25 transition at %s (index %d)", seconds_to_timestamp(curr_timestamp), i)
|
| 209 |
+
|
| 210 |
+
prev_value = curr_value
|
| 211 |
+
prev_timestamp = curr_timestamp
|
| 212 |
+
|
| 213 |
+
return transitions
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def print_transitions_summary(cache_file: str) -> None:
|
| 217 |
+
"""Print a summary of transitions from a cache file."""
|
| 218 |
+
with open(cache_file, "r", encoding="utf-8") as f:
|
| 219 |
+
data = json.load(f)
|
| 220 |
+
|
| 221 |
+
transitions = data.get("transitions_40_to_25", [])
|
| 222 |
+
|
| 223 |
+
print("\n" + "=" * 80)
|
| 224 |
+
print("40→25 CLOCK TRANSITIONS")
|
| 225 |
+
print("=" * 80)
|
| 226 |
+
print(f"Total transitions found: {len(transitions)}")
|
| 227 |
+
print("-" * 80)
|
| 228 |
+
print(f"{'#':<4} {'Timestamp':<12} {'Prev→Curr':<12} {'Index':<8}")
|
| 229 |
+
print("-" * 80)
|
| 230 |
+
|
| 231 |
+
for i, t in enumerate(transitions, 1):
|
| 232 |
+
print(f"{i:<4} {t['timestamp_str']:<12} {t['prev_value']}→{t['curr_value']:<8} {t['index']:<8}")
|
| 233 |
+
|
| 234 |
+
print("=" * 80)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def main():
|
| 238 |
+
"""Main function to cache play clock readings."""
|
| 239 |
+
# Configuration
|
| 240 |
+
video_path = "full_videos/OSU vs Tenn 12.21.24.mkv"
|
| 241 |
+
template_dir = "output/debug/digit_templates"
|
| 242 |
+
output_path = "output/cache/playclock_readings_full.json"
|
| 243 |
+
|
| 244 |
+
# Play clock absolute coordinates (scorebug_x + offset_x, scorebug_y + offset_y, width, height)
|
| 245 |
+
# From config: scorebug (131, 972), offset (899, 18), size (51, 28)
|
| 246 |
+
playclock_coords = (131 + 899, 972 + 18, 51, 28)
|
| 247 |
+
|
| 248 |
+
# Process full video
|
| 249 |
+
result = cache_playclock_readings(
|
| 250 |
+
video_path=video_path,
|
| 251 |
+
template_dir=template_dir,
|
| 252 |
+
output_path=output_path,
|
| 253 |
+
playclock_coords=playclock_coords,
|
| 254 |
+
sample_interval=0.5, # Match pipeline interval
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Print summary
|
| 258 |
+
print_transitions_summary(output_path)
|
| 259 |
+
|
| 260 |
+
# Also print detection rate
|
| 261 |
+
stats = result["stats"]
|
| 262 |
+
detection_rate = stats["detected_readings"] / stats["total_readings"] * 100
|
| 263 |
+
print(f"\nDetection rate: {stats['detected_readings']}/{stats['total_readings']} ({detection_rate:.1f}%)")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
if __name__ == "__main__":
|
| 267 |
+
main()
|
scripts/test_timeout_at_transitions.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test the timeout tracker at each 40→25 transition.
|
| 3 |
+
|
| 4 |
+
This script:
|
| 5 |
+
1. Loads the cached play clock readings with identified transitions
|
| 6 |
+
2. For each 40→25 transition, reads the timeout indicators BEFORE and AFTER
|
| 7 |
+
3. Compares the change in timeouts to determine if this was a timeout event
|
| 8 |
+
4. Compares results against ground truth
|
| 9 |
+
|
| 10 |
+
Ground truth timeouts:
|
| 11 |
+
- 4:25 (HOME) -> transition at ~4:26
|
| 12 |
+
- 1:07:30 (AWAY) -> transition at ~1:07:24
|
| 13 |
+
- 1:09:40 (AWAY) -> transition at ~1:09:38
|
| 14 |
+
- 1:14:07 (HOME) -> transition at ~1:14:05
|
| 15 |
+
- 1:16:06 (HOME) -> transition at ~1:16:03
|
| 16 |
+
- 1:44:54 (AWAY) -> transition at ~1:44:48
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 24 |
+
|
| 25 |
+
import cv2
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
# Add src to path
|
| 29 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
| 30 |
+
|
| 31 |
+
from detection.timeouts import DetectTimeouts
|
| 32 |
+
|
| 33 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# Ground truth timeouts (timestamp in seconds, team)
|
| 38 |
+
GROUND_TRUTH_TIMEOUTS = [
|
| 39 |
+
(4 * 60 + 25, "HOME"), # 4:25
|
| 40 |
+
(67 * 60 + 30, "AWAY"), # 1:07:30
|
| 41 |
+
(69 * 60 + 40, "AWAY"), # 1:09:40
|
| 42 |
+
(74 * 60 + 7, "HOME"), # 1:14:07
|
| 43 |
+
(76 * 60 + 6, "HOME"), # 1:16:06
|
| 44 |
+
(104 * 60 + 54, "AWAY"), # 1:44:54
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def seconds_to_timestamp(seconds: float) -> str:
|
| 49 |
+
"""Convert seconds to timestamp string (H:MM:SS)."""
|
| 50 |
+
hours = int(seconds // 3600)
|
| 51 |
+
minutes = int((seconds % 3600) // 60)
|
| 52 |
+
secs = int(seconds % 60)
|
| 53 |
+
if hours > 0:
|
| 54 |
+
return f"{hours}:{minutes:02d}:{secs:02d}"
|
| 55 |
+
return f"{minutes}:{secs:02d}"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def is_ground_truth_timeout(timestamp: float, tolerance: float = 10.0) -> Optional[str]:
|
| 59 |
+
"""
|
| 60 |
+
Check if a timestamp corresponds to a ground truth timeout.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
timestamp: Timestamp to check
|
| 64 |
+
tolerance: Seconds tolerance for matching
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
Team name ("HOME" or "AWAY") if this is a ground truth timeout, None otherwise
|
| 68 |
+
"""
|
| 69 |
+
for gt_time, team in GROUND_TRUTH_TIMEOUTS:
|
| 70 |
+
if abs(timestamp - gt_time) <= tolerance:
|
| 71 |
+
return team
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_timeout_at_transitions():
|
| 76 |
+
"""Test timeout detection at each 40→25 transition."""
|
| 77 |
+
# Load cached transitions
|
| 78 |
+
cache_path = Path("output/cache/playclock_readings_full.json")
|
| 79 |
+
if not cache_path.exists():
|
| 80 |
+
logger.error("Cache file not found: %s", cache_path)
|
| 81 |
+
logger.error("Run cache_playclock_readings.py first")
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
with open(cache_path, "r", encoding="utf-8") as f:
|
| 85 |
+
cache = json.load(f)
|
| 86 |
+
|
| 87 |
+
transitions = cache["transitions_40_to_25"]
|
| 88 |
+
logger.info("Loaded %d transitions from cache", len(transitions))
|
| 89 |
+
|
| 90 |
+
# Load timeout tracker config
|
| 91 |
+
config_path = Path("data/config/timeout_tracker_region.json")
|
| 92 |
+
if not config_path.exists():
|
| 93 |
+
logger.error("Timeout config not found: %s", config_path)
|
| 94 |
+
return
|
| 95 |
+
|
| 96 |
+
# Initialize timeout tracker
|
| 97 |
+
tracker = DetectTimeouts(config_path=str(config_path))
|
| 98 |
+
if not tracker.is_configured():
|
| 99 |
+
logger.error("Timeout tracker not configured")
|
| 100 |
+
return
|
| 101 |
+
|
| 102 |
+
# Open video
|
| 103 |
+
video_path = "full_videos/OSU vs Tenn 12.21.24.mkv"
|
| 104 |
+
cap = cv2.VideoCapture(video_path)
|
| 105 |
+
if not cap.isOpened():
|
| 106 |
+
logger.error("Could not open video: %s", video_path)
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 110 |
+
logger.info("Video FPS: %.2f", fps)
|
| 111 |
+
|
| 112 |
+
# Test each transition
|
| 113 |
+
results: List[Dict[str, Any]] = []
|
| 114 |
+
|
| 115 |
+
# Read timeout state at video start for baseline
|
| 116 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, int(30 * fps)) # 30 seconds in
|
| 117 |
+
ret, frame = cap.read()
|
| 118 |
+
if ret:
|
| 119 |
+
baseline = tracker.read_timeouts(frame)
|
| 120 |
+
logger.info("Baseline timeouts at 30s: HOME=%d, AWAY=%d, conf=%.2f", baseline.home_timeouts, baseline.away_timeouts, baseline.confidence)
|
| 121 |
+
|
| 122 |
+
print("\n" + "=" * 100)
|
| 123 |
+
print("TIMEOUT DETECTION AT 40→25 TRANSITIONS")
|
| 124 |
+
print("=" * 100)
|
| 125 |
+
print(f"{'#':<4} {'Timestamp':<12} {'Before (H,A)':<14} {'After (H,A)':<14} {'Change':<12} {'GT Team':<10} {'Detected':<10} {'Status':<10}")
|
| 126 |
+
print("-" * 100)
|
| 127 |
+
|
| 128 |
+
for i, transition in enumerate(transitions, 1):
|
| 129 |
+
timestamp = transition["timestamp"]
|
| 130 |
+
prev_timestamp = transition.get("prev_timestamp", timestamp - 1.0)
|
| 131 |
+
|
| 132 |
+
# Read frame BEFORE transition (when clock was 40) - 2 seconds before
|
| 133 |
+
before_time = prev_timestamp - 2.0
|
| 134 |
+
frame_before = int(before_time * fps)
|
| 135 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_before)
|
| 136 |
+
ret, frame = cap.read()
|
| 137 |
+
if not ret:
|
| 138 |
+
continue
|
| 139 |
+
reading_before = tracker.read_timeouts(frame)
|
| 140 |
+
|
| 141 |
+
# Read frame AFTER transition - try multiple times to catch delayed update
|
| 142 |
+
# Check at 2s, 4s, and 6s after the transition
|
| 143 |
+
reading_after = None
|
| 144 |
+
for delay in [2.0, 4.0, 6.0]:
|
| 145 |
+
after_time = timestamp + delay
|
| 146 |
+
frame_after = int(after_time * fps)
|
| 147 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_after)
|
| 148 |
+
ret, frame = cap.read()
|
| 149 |
+
if not ret:
|
| 150 |
+
continue
|
| 151 |
+
reading_after = tracker.read_timeouts(frame)
|
| 152 |
+
# If we detected a change, use this reading
|
| 153 |
+
home_diff = reading_before.home_timeouts - reading_after.home_timeouts
|
| 154 |
+
away_diff = reading_before.away_timeouts - reading_after.away_timeouts
|
| 155 |
+
if home_diff > 0 or away_diff > 0:
|
| 156 |
+
break # Found the change
|
| 157 |
+
|
| 158 |
+
if reading_after is None:
|
| 159 |
+
continue
|
| 160 |
+
|
| 161 |
+
# Determine change
|
| 162 |
+
home_change = reading_before.home_timeouts - reading_after.home_timeouts
|
| 163 |
+
away_change = reading_before.away_timeouts - reading_after.away_timeouts
|
| 164 |
+
|
| 165 |
+
# Validate: A valid timeout should show EXACTLY ONE team decreasing by 1
|
| 166 |
+
# while the other team stays the same. If both teams change, the scorebug
|
| 167 |
+
# likely disappeared (e.g., replay/commercial) and this is not a real timeout.
|
| 168 |
+
detected_team = None
|
| 169 |
+
is_valid_timeout = False
|
| 170 |
+
|
| 171 |
+
# Minimum confidence threshold for reliable readings
|
| 172 |
+
MIN_CONFIDENCE = 0.5
|
| 173 |
+
|
| 174 |
+
# Check for valid timeout pattern: one team -1, other team unchanged
|
| 175 |
+
# Also require high confidence on both readings
|
| 176 |
+
if home_change == 1 and away_change == 0:
|
| 177 |
+
if reading_before.confidence >= MIN_CONFIDENCE and reading_after.confidence >= MIN_CONFIDENCE:
|
| 178 |
+
detected_team = "HOME"
|
| 179 |
+
is_valid_timeout = True
|
| 180 |
+
elif away_change == 1 and home_change == 0:
|
| 181 |
+
if reading_before.confidence >= MIN_CONFIDENCE and reading_after.confidence >= MIN_CONFIDENCE:
|
| 182 |
+
detected_team = "AWAY"
|
| 183 |
+
is_valid_timeout = True
|
| 184 |
+
elif home_change > 0 or away_change > 0:
|
| 185 |
+
# Both teams changed, or change > 1 - likely scorebug visibility issue
|
| 186 |
+
# Log but don't detect as timeout
|
| 187 |
+
pass
|
| 188 |
+
|
| 189 |
+
# Check ground truth
|
| 190 |
+
gt_team = is_ground_truth_timeout(timestamp)
|
| 191 |
+
|
| 192 |
+
# Determine status
|
| 193 |
+
if gt_team is not None:
|
| 194 |
+
if detected_team == gt_team:
|
| 195 |
+
status = "✓ TP" # True positive
|
| 196 |
+
elif detected_team is not None:
|
| 197 |
+
status = "✗ WRONG" # Wrong team
|
| 198 |
+
else:
|
| 199 |
+
status = "✗ FN" # False negative
|
| 200 |
+
else:
|
| 201 |
+
if detected_team is not None:
|
| 202 |
+
status = "✗ FP" # False positive
|
| 203 |
+
else:
|
| 204 |
+
status = "✓ TN" # True negative (correctly not detected)
|
| 205 |
+
|
| 206 |
+
before_str = f"({reading_before.home_timeouts},{reading_before.away_timeouts})"
|
| 207 |
+
after_str = f"({reading_after.home_timeouts},{reading_after.away_timeouts})"
|
| 208 |
+
change_str = f"H:{home_change:+d} A:{away_change:+d}"
|
| 209 |
+
|
| 210 |
+
print(f"{i:<4} {transition['timestamp_str']:<12} {before_str:<14} {after_str:<14} {change_str:<12} {gt_team or '-':<10} {detected_team or '-':<10} {status:<10}")
|
| 211 |
+
|
| 212 |
+
results.append(
|
| 213 |
+
{
|
| 214 |
+
"index": i,
|
| 215 |
+
"timestamp": timestamp,
|
| 216 |
+
"timestamp_str": transition["timestamp_str"],
|
| 217 |
+
"before_home": reading_before.home_timeouts,
|
| 218 |
+
"before_away": reading_before.away_timeouts,
|
| 219 |
+
"before_conf": reading_before.confidence,
|
| 220 |
+
"after_home": reading_after.home_timeouts,
|
| 221 |
+
"after_away": reading_after.away_timeouts,
|
| 222 |
+
"after_conf": reading_after.confidence,
|
| 223 |
+
"home_change": home_change,
|
| 224 |
+
"away_change": away_change,
|
| 225 |
+
"detected_team": detected_team,
|
| 226 |
+
"ground_truth_team": gt_team,
|
| 227 |
+
"status": status,
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
cap.release()
|
| 232 |
+
|
| 233 |
+
# Summary statistics
|
| 234 |
+
print("=" * 100)
|
| 235 |
+
|
| 236 |
+
tp = sum(1 for r in results if "TP" in r["status"])
|
| 237 |
+
fp = sum(1 for r in results if "FP" in r["status"])
|
| 238 |
+
fn = sum(1 for r in results if "FN" in r["status"])
|
| 239 |
+
tn = sum(1 for r in results if "TN" in r["status"])
|
| 240 |
+
wrong = sum(1 for r in results if "WRONG" in r["status"])
|
| 241 |
+
|
| 242 |
+
print(f"\nSUMMARY:")
|
| 243 |
+
print(f" True Positives (correct timeouts): {tp}")
|
| 244 |
+
print(f" False Positives (spurious detections): {fp}")
|
| 245 |
+
print(f" False Negatives (missed timeouts): {fn}")
|
| 246 |
+
print(f" Wrong Team: {wrong}")
|
| 247 |
+
print(f" True Negatives (correct non-timeout): {tn}")
|
| 248 |
+
|
| 249 |
+
if tp + fn > 0:
|
| 250 |
+
recall = tp / (tp + fn)
|
| 251 |
+
print(f"\n Recall (of ground truth timeouts): {recall:.1%} ({tp}/{tp + fn})")
|
| 252 |
+
if tp + fp > 0:
|
| 253 |
+
precision = tp / (tp + fp)
|
| 254 |
+
print(f" Precision (of detected timeouts): {precision:.1%} ({tp}/{tp + fp})")
|
| 255 |
+
|
| 256 |
+
# Save detailed results
|
| 257 |
+
output_path = Path("output/cache/timeout_tracker_evaluation.json")
|
| 258 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 259 |
+
json.dump(
|
| 260 |
+
{
|
| 261 |
+
"summary": {"tp": tp, "fp": fp, "fn": fn, "tn": tn, "wrong": wrong},
|
| 262 |
+
"results": results,
|
| 263 |
+
},
|
| 264 |
+
f,
|
| 265 |
+
indent=2,
|
| 266 |
+
)
|
| 267 |
+
print(f"\nDetailed results saved to: {output_path}")
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def visualize_timeout_regions(timestamp: float):
|
| 271 |
+
"""Visualize timeout regions at a specific timestamp for debugging."""
|
| 272 |
+
config_path = Path("data/config/timeout_tracker_region.json")
|
| 273 |
+
tracker = DetectTimeouts(config_path=str(config_path))
|
| 274 |
+
|
| 275 |
+
video_path = "full_videos/OSU vs Tenn 12.21.24.mkv"
|
| 276 |
+
cap = cv2.VideoCapture(video_path)
|
| 277 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 278 |
+
|
| 279 |
+
frame_num = int(timestamp * fps)
|
| 280 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
|
| 281 |
+
ret, frame = cap.read()
|
| 282 |
+
cap.release()
|
| 283 |
+
|
| 284 |
+
if not ret:
|
| 285 |
+
logger.error("Could not read frame at %.1fs", timestamp)
|
| 286 |
+
return
|
| 287 |
+
|
| 288 |
+
reading = tracker.read_timeouts(frame)
|
| 289 |
+
vis_frame = tracker.visualize(frame, reading)
|
| 290 |
+
|
| 291 |
+
output_path = f"output/debug/timeout_tracker/vis_{int(timestamp)}.png"
|
| 292 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 293 |
+
cv2.imwrite(output_path, vis_frame)
|
| 294 |
+
logger.info("Saved visualization to %s", output_path)
|
| 295 |
+
logger.info(" HOME: %d, AWAY: %d, conf: %.2f", reading.home_timeouts, reading.away_timeouts, reading.confidence)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
if __name__ == "__main__":
|
| 299 |
+
test_timeout_at_transitions()
|
| 300 |
+
|
| 301 |
+
# Optionally visualize at specific timestamps
|
| 302 |
+
# visualize_timeout_regions(265) # Before first timeout at 4:25
|
| 303 |
+
# visualize_timeout_regions(270) # After first timeout
|
src/pipeline/play_extractor.py
CHANGED
|
@@ -405,10 +405,12 @@ class PlayExtractor:
|
|
| 405 |
timeout_reading = self.timeout_tracker.read_timeouts(frame)
|
| 406 |
frame_result["home_timeouts"] = timeout_reading.home_timeouts
|
| 407 |
frame_result["away_timeouts"] = timeout_reading.away_timeouts
|
|
|
|
| 408 |
# Create TimeoutInfo for state machine clock reset classification
|
| 409 |
timeout_info = TimeoutInfo(
|
| 410 |
home_timeouts=timeout_reading.home_timeouts,
|
| 411 |
away_timeouts=timeout_reading.away_timeouts,
|
|
|
|
| 412 |
)
|
| 413 |
|
| 414 |
# Extract play clock region and run template matching immediately
|
|
@@ -724,6 +726,7 @@ class PlayExtractor:
|
|
| 724 |
timeout_info = TimeoutInfo(
|
| 725 |
home_timeouts=frame.get("home_timeouts"),
|
| 726 |
away_timeouts=frame.get("away_timeouts"),
|
|
|
|
| 727 |
)
|
| 728 |
self.state_machine.update(frame["timestamp"], scorebug, clock_reading, timeout_info)
|
| 729 |
timing["state_machine"] = time.perf_counter() - t_sm_start
|
|
|
|
| 405 |
timeout_reading = self.timeout_tracker.read_timeouts(frame)
|
| 406 |
frame_result["home_timeouts"] = timeout_reading.home_timeouts
|
| 407 |
frame_result["away_timeouts"] = timeout_reading.away_timeouts
|
| 408 |
+
frame_result["timeout_confidence"] = timeout_reading.confidence
|
| 409 |
# Create TimeoutInfo for state machine clock reset classification
|
| 410 |
timeout_info = TimeoutInfo(
|
| 411 |
home_timeouts=timeout_reading.home_timeouts,
|
| 412 |
away_timeouts=timeout_reading.away_timeouts,
|
| 413 |
+
confidence=timeout_reading.confidence,
|
| 414 |
)
|
| 415 |
|
| 416 |
# Extract play clock region and run template matching immediately
|
|
|
|
| 726 |
timeout_info = TimeoutInfo(
|
| 727 |
home_timeouts=frame.get("home_timeouts"),
|
| 728 |
away_timeouts=frame.get("away_timeouts"),
|
| 729 |
+
confidence=frame.get("timeout_confidence", 0.0),
|
| 730 |
)
|
| 731 |
self.state_machine.update(frame["timestamp"], scorebug, clock_reading, timeout_info)
|
| 732 |
timing["state_machine"] = time.perf_counter() - t_sm_start
|
src/tracking/clock_reset_identifier.py
CHANGED
|
@@ -19,6 +19,7 @@ from .models import PlayEvent
|
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
|
|
| 22 |
# pylint: disable=too-few-public-methods
|
| 23 |
class ClockResetIdentifier:
|
| 24 |
"""
|
|
@@ -159,6 +160,13 @@ class ClockResetIdentifier:
|
|
| 159 |
|
| 160 |
return False
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
def _check_timeout_change(self, frame_data: List[Dict[str, Any]], frame_idx: int) -> Optional[str]:
|
| 163 |
"""
|
| 164 |
Check if a timeout indicator changed around the reset (Class B check).
|
|
@@ -166,6 +174,11 @@ class ClockResetIdentifier:
|
|
| 166 |
Compares timeout counts before and after the reset to determine
|
| 167 |
if a team timeout was called.
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
Args:
|
| 170 |
frame_data: Frame data list
|
| 171 |
frame_idx: Index of frame where 40→25 reset occurred
|
|
@@ -173,36 +186,78 @@ class ClockResetIdentifier:
|
|
| 173 |
Returns:
|
| 174 |
"home" or "away" if timeout was used, None otherwise
|
| 175 |
"""
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
before_home: Optional[int] = None
|
| 178 |
before_away: Optional[int] = None
|
|
|
|
| 179 |
|
| 180 |
for j in range(frame_idx - 1, max(0, frame_idx - 20), -1):
|
| 181 |
frame = frame_data[j]
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
break
|
| 186 |
|
| 187 |
-
if before_home is None:
|
| 188 |
return None
|
| 189 |
|
| 190 |
-
# Look forward for timeout change
|
| 191 |
-
|
| 192 |
-
|
| 193 |
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
frame = frame_data[j]
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
def _find_play_end(self, frame_data: List[Dict[str, Any]], frame_idx: int, max_duration: float) -> float:
|
| 208 |
"""
|
|
|
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
+
|
| 23 |
# pylint: disable=too-few-public-methods
|
| 24 |
class ClockResetIdentifier:
|
| 25 |
"""
|
|
|
|
| 160 |
|
| 161 |
return False
|
| 162 |
|
| 163 |
+
# Minimum confidence threshold for reliable timeout readings
|
| 164 |
+
MIN_TIMEOUT_CONFIDENCE = 0.5
|
| 165 |
+
|
| 166 |
+
# Delay (seconds) after 40→25 before checking timeout indicators
|
| 167 |
+
# Must be long enough for the scorebug timeout indicator to update (typically 4-6 seconds)
|
| 168 |
+
TIMEOUT_CHECK_DELAY = 5.5
|
| 169 |
+
|
| 170 |
def _check_timeout_change(self, frame_data: List[Dict[str, Any]], frame_idx: int) -> Optional[str]:
|
| 171 |
"""
|
| 172 |
Check if a timeout indicator changed around the reset (Class B check).
|
|
|
|
| 174 |
Compares timeout counts before and after the reset to determine
|
| 175 |
if a team timeout was called.
|
| 176 |
|
| 177 |
+
Validation rules for a valid timeout:
|
| 178 |
+
1. Exactly ONE team's count decreased by exactly 1
|
| 179 |
+
2. Other team's count stayed the same
|
| 180 |
+
3. Both before and after readings have confidence >= MIN_TIMEOUT_CONFIDENCE
|
| 181 |
+
|
| 182 |
Args:
|
| 183 |
frame_data: Frame data list
|
| 184 |
frame_idx: Index of frame where 40→25 reset occurred
|
|
|
|
| 186 |
Returns:
|
| 187 |
"home" or "away" if timeout was used, None otherwise
|
| 188 |
"""
|
| 189 |
+
reset_timestamp: float = frame_data[frame_idx]["timestamp"]
|
| 190 |
+
|
| 191 |
+
# Get timeout counts BEFORE reset (look back for high-confidence reading)
|
| 192 |
before_home: Optional[int] = None
|
| 193 |
before_away: Optional[int] = None
|
| 194 |
+
before_conf: float = 0.0
|
| 195 |
|
| 196 |
for j in range(frame_idx - 1, max(0, frame_idx - 20), -1):
|
| 197 |
frame = frame_data[j]
|
| 198 |
+
conf = frame.get("timeout_confidence", 0.0)
|
| 199 |
+
if frame.get("home_timeouts") is not None and conf >= self.MIN_TIMEOUT_CONFIDENCE:
|
| 200 |
+
before_home = frame.get("home_timeouts")
|
| 201 |
+
before_away = frame.get("away_timeouts")
|
| 202 |
+
before_conf = conf
|
| 203 |
break
|
| 204 |
|
| 205 |
+
if before_home is None or before_away is None:
|
| 206 |
return None
|
| 207 |
|
| 208 |
+
# Look forward for timeout change AFTER DELAY (5.5-6 seconds after reset)
|
| 209 |
+
target_time = reset_timestamp + self.TIMEOUT_CHECK_DELAY
|
| 210 |
+
max_time = reset_timestamp + 6.0 # Search window 3-6 seconds after reset
|
| 211 |
|
| 212 |
+
after_home: Optional[int] = None
|
| 213 |
+
after_away: Optional[int] = None
|
| 214 |
+
after_conf: float = 0.0
|
| 215 |
+
|
| 216 |
+
for j in range(frame_idx + 1, len(frame_data)):
|
| 217 |
frame = frame_data[j]
|
| 218 |
+
timestamp: float = frame["timestamp"]
|
| 219 |
+
|
| 220 |
+
# Only check after the delay period
|
| 221 |
+
if timestamp < target_time:
|
| 222 |
+
continue
|
| 223 |
+
|
| 224 |
+
# Stop if we've gone past the search window
|
| 225 |
+
if timestamp > max_time:
|
| 226 |
+
break
|
| 227 |
|
| 228 |
+
conf = frame.get("timeout_confidence", 0.0)
|
| 229 |
+
if frame.get("home_timeouts") is not None and conf >= self.MIN_TIMEOUT_CONFIDENCE:
|
| 230 |
+
after_home = frame.get("home_timeouts")
|
| 231 |
+
after_away = frame.get("away_timeouts")
|
| 232 |
+
after_conf = conf
|
| 233 |
+
break
|
| 234 |
+
|
| 235 |
+
if after_home is None or after_away is None:
|
| 236 |
+
return None
|
| 237 |
|
| 238 |
+
# Validate timeout pattern: EXACTLY one team -1, other unchanged
|
| 239 |
+
home_change = before_home - after_home # positive = decrease
|
| 240 |
+
away_change = before_away - after_away # positive = decrease
|
| 241 |
+
|
| 242 |
+
timeout_team = None
|
| 243 |
+
if home_change == 1 and away_change == 0:
|
| 244 |
+
timeout_team = "home"
|
| 245 |
+
elif away_change == 1 and home_change == 0:
|
| 246 |
+
timeout_team = "away"
|
| 247 |
+
|
| 248 |
+
if timeout_team:
|
| 249 |
+
logger.debug(
|
| 250 |
+
"Timeout detected: %s team (before=(%d,%d) conf=%.2f, after=(%d,%d) conf=%.2f)",
|
| 251 |
+
timeout_team,
|
| 252 |
+
before_home,
|
| 253 |
+
before_away,
|
| 254 |
+
before_conf,
|
| 255 |
+
after_home,
|
| 256 |
+
after_away,
|
| 257 |
+
after_conf,
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
return timeout_team
|
| 261 |
|
| 262 |
def _find_play_end(self, frame_data: List[Dict[str, Any]], frame_idx: int, max_duration: float) -> float:
|
| 263 |
"""
|
src/tracking/models.py
CHANGED
|
@@ -55,6 +55,7 @@ class TimeoutInfo(BaseModel):
|
|
| 55 |
|
| 56 |
home_timeouts: Optional[int] = Field(None, description="Number of home team timeouts remaining")
|
| 57 |
away_timeouts: Optional[int] = Field(None, description="Number of away team timeouts remaining")
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
class ClockResetStats(BaseModel):
|
|
@@ -97,4 +98,13 @@ class PlayTrackingState(BaseModel):
|
|
| 97 |
# Timeout tracking for clock reset classification
|
| 98 |
last_home_timeouts: Optional[int] = Field(None, description="Last observed home team timeouts")
|
| 99 |
last_away_timeouts: Optional[int] = Field(None, description="Last observed away team timeouts")
|
|
|
|
| 100 |
clock_reset_stats: ClockResetStats = Field(default_factory=ClockResetStats, description="Statistics about clock reset classifications")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
home_timeouts: Optional[int] = Field(None, description="Number of home team timeouts remaining")
|
| 57 |
away_timeouts: Optional[int] = Field(None, description="Number of away team timeouts remaining")
|
| 58 |
+
confidence: float = Field(0.0, description="Confidence of the timeout reading")
|
| 59 |
|
| 60 |
|
| 61 |
class ClockResetStats(BaseModel):
|
|
|
|
| 98 |
# Timeout tracking for clock reset classification
|
| 99 |
last_home_timeouts: Optional[int] = Field(None, description="Last observed home team timeouts")
|
| 100 |
last_away_timeouts: Optional[int] = Field(None, description="Last observed away team timeouts")
|
| 101 |
+
last_timeout_confidence: float = Field(0.0, description="Confidence of last timeout reading")
|
| 102 |
clock_reset_stats: ClockResetStats = Field(default_factory=ClockResetStats, description="Statistics about clock reset classifications")
|
| 103 |
+
|
| 104 |
+
# Pending timeout resolution (for delayed timeout detection)
|
| 105 |
+
# When 40→25 detected, we store the "before" state and check again after delay
|
| 106 |
+
pending_timeout_check_time: Optional[float] = Field(None, description="Timestamp when we should resolve pending timeout check")
|
| 107 |
+
pending_timeout_transition_time: Optional[float] = Field(None, description="Timestamp when the 40→25 transition occurred")
|
| 108 |
+
timeout_home_at_40: Optional[int] = Field(None, description="Home timeouts when clock was at 40 (before 40→25)")
|
| 109 |
+
timeout_away_at_40: Optional[int] = Field(None, description="Away timeouts when clock was at 40 (before 40→25)")
|
| 110 |
+
timeout_conf_at_40: float = Field(0.0, description="Timeout confidence when clock was at 40")
|
src/tracking/play_identification_checks.py
CHANGED
|
@@ -46,10 +46,20 @@ class PlayIdentificationChecks:
|
|
| 46 |
self.config = config
|
| 47 |
self.lifecycle = lifecycle
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def check_timeout_change(self, timeout_info: Optional[TimeoutInfo] = None) -> Optional[str]:
|
| 50 |
"""
|
| 51 |
Check if a timeout indicator changed, indicating a team timeout.
|
| 52 |
|
|
|
|
|
|
|
|
|
|
| 53 |
Args:
|
| 54 |
timeout_info: Current timeout information
|
| 55 |
|
|
@@ -70,6 +80,113 @@ class PlayIdentificationChecks:
|
|
| 70 |
|
| 71 |
return None
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def check_play_timeout(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
|
| 74 |
"""Check if play duration has exceeded maximum allowed time.
|
| 75 |
|
|
@@ -115,18 +232,25 @@ class PlayIdentificationChecks:
|
|
| 115 |
)
|
| 116 |
self._state.countdown_history = []
|
| 117 |
|
| 118 |
-
def check_possession_change(self, timestamp: float, clock_value: int) ->
|
| 119 |
"""Check for rapid 40→25 transition indicating possession change during play.
|
| 120 |
|
| 121 |
This happens during punts, kickoffs, and after XPs/FGs. The play continues
|
| 122 |
(e.g., punt return in progress) - we don't end the play here.
|
| 123 |
|
|
|
|
|
|
|
|
|
|
| 124 |
Args:
|
| 125 |
timestamp: Current video timestamp
|
| 126 |
clock_value: Current clock value
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
"""
|
| 128 |
if clock_value != 25 or self._state.first_40_timestamp is None:
|
| 129 |
-
return # Not a 40→25 transition
|
| 130 |
|
| 131 |
time_at_40 = timestamp - self._state.first_40_timestamp
|
| 132 |
max_time_for_possession_change = 5.0
|
|
@@ -144,6 +268,26 @@ class PlayIdentificationChecks:
|
|
| 144 |
self._state.first_40_timestamp = None
|
| 145 |
self._state.countdown_history = []
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def check_abnormal_clock_drop(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
|
| 148 |
"""Check for abnormal clock drop on first reading after 40.
|
| 149 |
|
|
@@ -161,6 +305,11 @@ class PlayIdentificationChecks:
|
|
| 161 |
if len(self._state.countdown_history) != 0:
|
| 162 |
return None # Not the first reading after 40
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
clock_drop = 40 - clock_value
|
| 165 |
max_normal_drop = 5
|
| 166 |
if clock_drop <= max_normal_drop:
|
|
@@ -242,14 +391,20 @@ class PlayIdentificationChecks:
|
|
| 242 |
"""
|
| 243 |
Classify a 40→25 clock reset and handle appropriately.
|
| 244 |
|
| 245 |
-
Classification:
|
| 246 |
- Class A (weird_clock): Will be handled by checking if 25 counts down immediately
|
| 247 |
(identified later via abnormal clock drop check)
|
| 248 |
-
- Class B (timeout): Timeout indicator
|
| 249 |
-
- Class C (special):
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
Args:
|
| 255 |
timestamp: Current timestamp
|
|
@@ -257,20 +412,28 @@ class PlayIdentificationChecks:
|
|
| 257 |
"""
|
| 258 |
self._state.clock_reset_stats.total += 1
|
| 259 |
|
| 260 |
-
#
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
self._state.
|
| 268 |
-
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
else:
|
| 271 |
-
#
|
| 272 |
-
self._state.
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
self.config = config
|
| 47 |
self.lifecycle = lifecycle
|
| 48 |
|
| 49 |
+
# Minimum confidence for timeout readings to be considered valid
|
| 50 |
+
MIN_TIMEOUT_CONFIDENCE = 0.5
|
| 51 |
+
|
| 52 |
+
# Delay (seconds) after 40→25 transition before checking timeout indicators
|
| 53 |
+
# Must be long enough for the scorebug timeout indicator to update (typically 4-6 seconds)
|
| 54 |
+
TIMEOUT_CHECK_DELAY = 5.5
|
| 55 |
+
|
| 56 |
def check_timeout_change(self, timeout_info: Optional[TimeoutInfo] = None) -> Optional[str]:
|
| 57 |
"""
|
| 58 |
Check if a timeout indicator changed, indicating a team timeout.
|
| 59 |
|
| 60 |
+
DEPRECATED: This immediate check is no longer used for 40→25 classification.
|
| 61 |
+
Use check_pending_timeout_resolution() for delayed timeout detection.
|
| 62 |
+
|
| 63 |
Args:
|
| 64 |
timeout_info: Current timeout information
|
| 65 |
|
|
|
|
| 80 |
|
| 81 |
return None
|
| 82 |
|
| 83 |
+
def check_pending_timeout_resolution(self, timestamp: float, timeout_info: TimeoutInfo) -> None:
|
| 84 |
+
"""
|
| 85 |
+
Check if it's time to resolve a pending timeout classification.
|
| 86 |
+
|
| 87 |
+
Called every frame after 40→25 transition to check if enough time has passed
|
| 88 |
+
and if the timeout indicators have changed (indicating a real timeout vs special play).
|
| 89 |
+
|
| 90 |
+
Validation rules for a valid timeout:
|
| 91 |
+
1. Exactly ONE team's count decreased by exactly 1
|
| 92 |
+
2. Other team's count stayed the same
|
| 93 |
+
3. Both before and after readings have confidence >= MIN_TIMEOUT_CONFIDENCE
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
timestamp: Current video timestamp
|
| 97 |
+
timeout_info: Current timeout indicator information
|
| 98 |
+
"""
|
| 99 |
+
# Check if we have a pending timeout to resolve
|
| 100 |
+
if self._state.pending_timeout_check_time is None:
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
# Not yet time to check
|
| 104 |
+
if timestamp < self._state.pending_timeout_check_time:
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
# Time to resolve - compare current reading with "at 40" reading
|
| 108 |
+
before_home = self._state.timeout_home_at_40
|
| 109 |
+
before_away = self._state.timeout_away_at_40
|
| 110 |
+
before_conf = self._state.timeout_conf_at_40
|
| 111 |
+
after_home = timeout_info.home_timeouts
|
| 112 |
+
after_away = timeout_info.away_timeouts
|
| 113 |
+
after_conf = timeout_info.confidence
|
| 114 |
+
|
| 115 |
+
# Get transition time before clearing (needed for play lookup)
|
| 116 |
+
transition_time = self._state.pending_timeout_transition_time
|
| 117 |
+
|
| 118 |
+
# Clear pending state
|
| 119 |
+
self._state.pending_timeout_check_time = None
|
| 120 |
+
self._state.timeout_home_at_40 = None
|
| 121 |
+
self._state.timeout_away_at_40 = None
|
| 122 |
+
self._state.timeout_conf_at_40 = 0.0
|
| 123 |
+
# Note: pending_timeout_transition_time is cleared in the confirmation logic below
|
| 124 |
+
|
| 125 |
+
# Validate we have all needed values
|
| 126 |
+
if before_home is None or before_away is None or after_home is None or after_away is None:
|
| 127 |
+
logger.debug("Pending timeout check skipped: missing timeout values")
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
# Check confidence threshold
|
| 131 |
+
if before_conf < self.MIN_TIMEOUT_CONFIDENCE or after_conf < self.MIN_TIMEOUT_CONFIDENCE:
|
| 132 |
+
logger.debug(
|
| 133 |
+
"Pending timeout check: low confidence (before=%.2f, after=%.2f), keeping as special play",
|
| 134 |
+
before_conf,
|
| 135 |
+
after_conf,
|
| 136 |
+
)
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
# Calculate changes
|
| 140 |
+
home_change = before_home - after_home
|
| 141 |
+
away_change = before_away - after_away
|
| 142 |
+
|
| 143 |
+
# Validate timeout pattern: exactly one team -1, other unchanged
|
| 144 |
+
timeout_team = None
|
| 145 |
+
if home_change == 1 and away_change == 0:
|
| 146 |
+
timeout_team = "home"
|
| 147 |
+
elif away_change == 1 and home_change == 0:
|
| 148 |
+
timeout_team = "away"
|
| 149 |
+
|
| 150 |
+
# Clear transition time state
|
| 151 |
+
self._state.pending_timeout_transition_time = None
|
| 152 |
+
|
| 153 |
+
if timeout_team:
|
| 154 |
+
# Valid timeout detected - reclassify the play associated with this timeout
|
| 155 |
+
logger.info(
|
| 156 |
+
"Delayed timeout detection: %s team timeout confirmed at %.1fs (before=(%d,%d) after=(%d,%d))",
|
| 157 |
+
timeout_team.upper(),
|
| 158 |
+
timestamp,
|
| 159 |
+
before_home,
|
| 160 |
+
before_away,
|
| 161 |
+
after_home,
|
| 162 |
+
after_away,
|
| 163 |
+
)
|
| 164 |
+
# Update the current play's classification
|
| 165 |
+
self._state.current_play_type = "timeout"
|
| 166 |
+
|
| 167 |
+
# Find the play that corresponds to this timeout by matching transition timestamp
|
| 168 |
+
if self._state.plays and transition_time is not None:
|
| 169 |
+
# Find play whose start or end time is close to the transition
|
| 170 |
+
for i, play in enumerate(self._state.plays):
|
| 171 |
+
# Check if this play's timing matches the transition
|
| 172 |
+
if play.play_type == "special" and abs(play.start_time - transition_time) < 10:
|
| 173 |
+
# Update this play to be a timeout
|
| 174 |
+
self._state.plays[i] = play.model_copy(update={"start_method": f"timeout_{timeout_team}", "play_type": "timeout"})
|
| 175 |
+
logger.debug("Reclassified play #%d at %.1fs as %s timeout", play.play_number, play.start_time, timeout_team.upper())
|
| 176 |
+
break
|
| 177 |
+
elif self._state.current_play_start_time is not None:
|
| 178 |
+
# If we're currently in a play that started as special, update it
|
| 179 |
+
pass # Will be classified correctly when play ends
|
| 180 |
+
|
| 181 |
+
self._state.clock_reset_stats.timeout += 1
|
| 182 |
+
self._state.clock_reset_stats.special -= 1 # Undo the special count
|
| 183 |
+
else:
|
| 184 |
+
logger.debug(
|
| 185 |
+
"Pending timeout check: no valid timeout pattern (home_change=%d, away_change=%d), keeping as special play",
|
| 186 |
+
home_change,
|
| 187 |
+
away_change,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
def check_play_timeout(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
|
| 191 |
"""Check if play duration has exceeded maximum allowed time.
|
| 192 |
|
|
|
|
| 232 |
)
|
| 233 |
self._state.countdown_history = []
|
| 234 |
|
| 235 |
+
def check_possession_change(self, timestamp: float, clock_value: int, timeout_info: Optional[TimeoutInfo] = None) -> bool:
|
| 236 |
"""Check for rapid 40→25 transition indicating possession change during play.
|
| 237 |
|
| 238 |
This happens during punts, kickoffs, and after XPs/FGs. The play continues
|
| 239 |
(e.g., punt return in progress) - we don't end the play here.
|
| 240 |
|
| 241 |
+
Also schedules a delayed timeout check, as timeouts can trigger 40→25
|
| 242 |
+
transitions during plays (e.g., timeout called during an ongoing play).
|
| 243 |
+
|
| 244 |
Args:
|
| 245 |
timestamp: Current video timestamp
|
| 246 |
clock_value: Current clock value
|
| 247 |
+
timeout_info: Optional timeout indicator information for delayed timeout check
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
True if transition was handled, False otherwise (caller should continue checking)
|
| 251 |
"""
|
| 252 |
if clock_value != 25 or self._state.first_40_timestamp is None:
|
| 253 |
+
return False # Not a 40→25 transition
|
| 254 |
|
| 255 |
time_at_40 = timestamp - self._state.first_40_timestamp
|
| 256 |
max_time_for_possession_change = 5.0
|
|
|
|
| 268 |
self._state.first_40_timestamp = None
|
| 269 |
self._state.countdown_history = []
|
| 270 |
|
| 271 |
+
# Schedule delayed timeout check (same as classify_40_to_25_reset)
|
| 272 |
+
# This allows us to detect timeouts called during mid-play transitions
|
| 273 |
+
if timeout_info is not None:
|
| 274 |
+
self._state.timeout_home_at_40 = timeout_info.home_timeouts
|
| 275 |
+
self._state.timeout_away_at_40 = timeout_info.away_timeouts
|
| 276 |
+
self._state.timeout_conf_at_40 = timeout_info.confidence
|
| 277 |
+
self._state.pending_timeout_check_time = timestamp + self.TIMEOUT_CHECK_DELAY
|
| 278 |
+
self._state.pending_timeout_transition_time = timestamp # Store when the 40→25 occurred
|
| 279 |
+
self._state.clock_reset_stats.total += 1
|
| 280 |
+
self._state.clock_reset_stats.special += 1 # Initially classified as special
|
| 281 |
+
logger.debug(
|
| 282 |
+
"Scheduled timeout check at %.1fs for mid-play 40→25 (timeouts: H=%s, A=%s)",
|
| 283 |
+
self._state.pending_timeout_check_time,
|
| 284 |
+
timeout_info.home_timeouts,
|
| 285 |
+
timeout_info.away_timeouts,
|
| 286 |
+
)
|
| 287 |
+
return True # Transition was handled, skip subsequent checks
|
| 288 |
+
|
| 289 |
+
return False # Not a valid possession change, continue with other checks
|
| 290 |
+
|
| 291 |
def check_abnormal_clock_drop(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
|
| 292 |
"""Check for abnormal clock drop on first reading after 40.
|
| 293 |
|
|
|
|
| 305 |
if len(self._state.countdown_history) != 0:
|
| 306 |
return None # Not the first reading after 40
|
| 307 |
|
| 308 |
+
# If already in special play mode (clock base = 25), skip this check
|
| 309 |
+
# This prevents false triggers after check_possession_change handles a 40→25 transition
|
| 310 |
+
if self._state.current_play_clock_base == 25:
|
| 311 |
+
return None
|
| 312 |
+
|
| 313 |
clock_drop = 40 - clock_value
|
| 314 |
max_normal_drop = 5
|
| 315 |
if clock_drop <= max_normal_drop:
|
|
|
|
| 391 |
"""
|
| 392 |
Classify a 40→25 clock reset and handle appropriately.
|
| 393 |
|
| 394 |
+
Classification (with delayed timeout detection):
|
| 395 |
- Class A (weird_clock): Will be handled by checking if 25 counts down immediately
|
| 396 |
(identified later via abnormal clock drop check)
|
| 397 |
+
- Class B (timeout): Timeout indicator changes AFTER 3-4 seconds → tracked as timeout play
|
| 398 |
+
- Class C (special): No timeout change → special play (punt/FG/XP)
|
| 399 |
+
|
| 400 |
+
The timeout check is DELAYED because the scorebug's timeout indicator updates
|
| 401 |
+
2-6 seconds after the clock resets, not immediately.
|
| 402 |
|
| 403 |
+
Strategy:
|
| 404 |
+
1. Store current timeout reading as "at 40" reference
|
| 405 |
+
2. Start play as "special" initially
|
| 406 |
+
3. Set pending check time (timestamp + TIMEOUT_CHECK_DELAY)
|
| 407 |
+
4. check_pending_timeout_resolution() will reclassify if timeout detected
|
| 408 |
|
| 409 |
Args:
|
| 410 |
timestamp: Current timestamp
|
|
|
|
| 412 |
"""
|
| 413 |
self._state.clock_reset_stats.total += 1
|
| 414 |
|
| 415 |
+
# Store current timeout state as "before" reference for delayed check
|
| 416 |
+
if timeout_info is not None:
|
| 417 |
+
self._state.timeout_home_at_40 = timeout_info.home_timeouts
|
| 418 |
+
self._state.timeout_away_at_40 = timeout_info.away_timeouts
|
| 419 |
+
self._state.timeout_conf_at_40 = timeout_info.confidence
|
| 420 |
+
# Schedule delayed timeout check
|
| 421 |
+
self._state.pending_timeout_check_time = timestamp + self.TIMEOUT_CHECK_DELAY
|
| 422 |
+
self._state.pending_timeout_transition_time = timestamp # Store when the 40→25 occurred
|
| 423 |
+
logger.debug(
|
| 424 |
+
"Scheduled timeout check at %.1fs (current timeouts: H=%s, A=%s, conf=%.2f)",
|
| 425 |
+
self._state.pending_timeout_check_time,
|
| 426 |
+
timeout_info.home_timeouts,
|
| 427 |
+
timeout_info.away_timeouts,
|
| 428 |
+
timeout_info.confidence,
|
| 429 |
+
)
|
| 430 |
else:
|
| 431 |
+
# No timeout info available, can't do delayed check
|
| 432 |
+
self._state.pending_timeout_check_time = None
|
| 433 |
+
|
| 434 |
+
# Start as special play initially - may be reclassified as timeout later
|
| 435 |
+
self._state.clock_reset_stats.special += 1
|
| 436 |
+
logger.info("Clock reset 40→25 at %.1fs classified as SPECIAL play (pending timeout check)", timestamp)
|
| 437 |
+
self._state.current_play_clock_base = 25
|
| 438 |
+
self._state.current_play_type = "special"
|
| 439 |
+
self.lifecycle.start_play(timestamp, "clock_reset_special", 40)
|
src/tracking/play_merger.py
CHANGED
|
@@ -95,8 +95,8 @@ class PlayMerger: # pylint: disable=too-few-public-methods
|
|
| 95 |
|
| 96 |
if is_overlapping or is_close:
|
| 97 |
# Same event detected twice - keep the better one
|
| 98 |
-
# Priority: normal >
|
| 99 |
-
type_priority = {"normal": 3, "
|
| 100 |
last_priority = type_priority.get(last.play_type, 0)
|
| 101 |
play_priority = type_priority.get(play.play_type, 0)
|
| 102 |
|
|
@@ -114,9 +114,12 @@ class PlayMerger: # pylint: disable=too-few-public-methods
|
|
| 114 |
"""
|
| 115 |
Apply quiet time filter after normal plays.
|
| 116 |
|
| 117 |
-
After a normal play ends, no new
|
| 118 |
This filters out false positives from penalties during plays (false starts, delay of game, etc.).
|
| 119 |
|
|
|
|
|
|
|
|
|
|
| 120 |
Args:
|
| 121 |
plays: List of plays sorted by start time
|
| 122 |
|
|
@@ -131,8 +134,9 @@ class PlayMerger: # pylint: disable=too-few-public-methods
|
|
| 131 |
|
| 132 |
for play in plays:
|
| 133 |
# Check if this play starts during quiet time after a normal play
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
time_since_normal = play.start_time - last_normal_end
|
| 137 |
logger.debug(
|
| 138 |
"Quiet time filter: Removing %s play at %.1fs (%.1fs after normal play ended)",
|
|
|
|
| 95 |
|
| 96 |
if is_overlapping or is_close:
|
| 97 |
# Same event detected twice - keep the better one
|
| 98 |
+
# Priority: normal > timeout > special (timeout is a more specific classification than special)
|
| 99 |
+
type_priority = {"normal": 3, "timeout": 2, "special": 1}
|
| 100 |
last_priority = type_priority.get(last.play_type, 0)
|
| 101 |
play_priority = type_priority.get(play.play_type, 0)
|
| 102 |
|
|
|
|
| 114 |
"""
|
| 115 |
Apply quiet time filter after normal plays.
|
| 116 |
|
| 117 |
+
After a normal play ends, no new SPECIAL plays can start for quiet_time seconds.
|
| 118 |
This filters out false positives from penalties during plays (false starts, delay of game, etc.).
|
| 119 |
|
| 120 |
+
NOTE: Timeout plays are NOT filtered because timeouts can legitimately be called
|
| 121 |
+
immediately after a play ends.
|
| 122 |
+
|
| 123 |
Args:
|
| 124 |
plays: List of plays sorted by start time
|
| 125 |
|
|
|
|
| 134 |
|
| 135 |
for play in plays:
|
| 136 |
# Check if this play starts during quiet time after a normal play
|
| 137 |
+
# Only filter "special" plays, NOT timeout plays (timeouts can occur right after plays)
|
| 138 |
+
if play.start_time < last_normal_end + self.quiet_time and play.play_type == "special":
|
| 139 |
+
# This special play starts during quiet time - filter it out
|
| 140 |
time_since_normal = play.start_time - last_normal_end
|
| 141 |
logger.debug(
|
| 142 |
"Quiet time filter: Removing %s play at %.1fs (%.1fs after normal play ended)",
|
src/tracking/play_state.py
CHANGED
|
@@ -108,6 +108,10 @@ class TrackPlayState:
|
|
| 108 |
self._state.last_home_timeouts = timeout_info.home_timeouts
|
| 109 |
if timeout_info.away_timeouts is not None:
|
| 110 |
self._state.last_away_timeouts = timeout_info.away_timeouts
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
# Handle scorebug presence/absence
|
| 113 |
if not scorebug.detected:
|
|
@@ -152,7 +156,7 @@ class TrackPlayState:
|
|
| 152 |
|
| 153 |
elif self._state.state == PlayState.PLAY_IN_PROGRESS:
|
| 154 |
# Play is live, watching for it to end (clock restarts)
|
| 155 |
-
completed_play = self._handlers.handle_play_in_progress(timestamp, clock_value)
|
| 156 |
|
| 157 |
elif self._state.state == PlayState.POST_PLAY:
|
| 158 |
# Play ended, transitioning back to PRE_SNAP
|
|
|
|
| 108 |
self._state.last_home_timeouts = timeout_info.home_timeouts
|
| 109 |
if timeout_info.away_timeouts is not None:
|
| 110 |
self._state.last_away_timeouts = timeout_info.away_timeouts
|
| 111 |
+
self._state.last_timeout_confidence = timeout_info.confidence
|
| 112 |
+
|
| 113 |
+
# Check for pending timeout resolution (delayed timeout detection)
|
| 114 |
+
self._identification.check_pending_timeout_resolution(timestamp, timeout_info)
|
| 115 |
|
| 116 |
# Handle scorebug presence/absence
|
| 117 |
if not scorebug.detected:
|
|
|
|
| 156 |
|
| 157 |
elif self._state.state == PlayState.PLAY_IN_PROGRESS:
|
| 158 |
# Play is live, watching for it to end (clock restarts)
|
| 159 |
+
completed_play = self._handlers.handle_play_in_progress(timestamp, clock_value, timeout_info)
|
| 160 |
|
| 161 |
elif self._state.state == PlayState.POST_PLAY:
|
| 162 |
# Play ended, transitioning back to PRE_SNAP
|
src/tracking/state_handlers.py
CHANGED
|
@@ -121,7 +121,7 @@ class StateHandlers:
|
|
| 121 |
if self._state.last_clock_value is None:
|
| 122 |
self._state.last_clock_value = clock_value
|
| 123 |
self._state.clock_stable_count = 1
|
| 124 |
-
return None
|
| 125 |
|
| 126 |
# Check for clock reset to 40 (indicates ball was snapped for normal play)
|
| 127 |
# Require a significant jump in clock value to avoid false positives from OCR noise
|
|
@@ -177,7 +177,7 @@ class StateHandlers:
|
|
| 177 |
# The clock_reset identification (going to 40 or 25) is the reliable method
|
| 178 |
return None
|
| 179 |
|
| 180 |
-
def handle_play_in_progress(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
|
| 181 |
"""Handle clock reading during PLAY_IN_PROGRESS state.
|
| 182 |
|
| 183 |
Delegates to identification checks for each scenario.
|
|
@@ -185,6 +185,7 @@ class StateHandlers:
|
|
| 185 |
Args:
|
| 186 |
timestamp: Current video timestamp
|
| 187 |
clock_value: Current clock value
|
|
|
|
| 188 |
|
| 189 |
Returns:
|
| 190 |
PlayEvent if play ended, None otherwise
|
|
@@ -203,8 +204,10 @@ class StateHandlers:
|
|
| 203 |
return None
|
| 204 |
|
| 205 |
# Check for possession change (40→25 transition)
|
| 206 |
-
#
|
| 207 |
-
|
|
|
|
|
|
|
| 208 |
|
| 209 |
# Check for abnormal clock drop (first reading after 40)
|
| 210 |
result = self.identification.check_abnormal_clock_drop(timestamp, clock_value)
|
|
|
|
| 121 |
if self._state.last_clock_value is None:
|
| 122 |
self._state.last_clock_value = clock_value
|
| 123 |
self._state.clock_stable_count = 1
|
| 124 |
+
return None # exit early if no last clock value
|
| 125 |
|
| 126 |
# Check for clock reset to 40 (indicates ball was snapped for normal play)
|
| 127 |
# Require a significant jump in clock value to avoid false positives from OCR noise
|
|
|
|
| 177 |
# The clock_reset identification (going to 40 or 25) is the reliable method
|
| 178 |
return None
|
| 179 |
|
| 180 |
+
def handle_play_in_progress(self, timestamp: float, clock_value: int, timeout_info: Optional[TimeoutInfo] = None) -> Optional[PlayEvent]:
|
| 181 |
"""Handle clock reading during PLAY_IN_PROGRESS state.
|
| 182 |
|
| 183 |
Delegates to identification checks for each scenario.
|
|
|
|
| 185 |
Args:
|
| 186 |
timestamp: Current video timestamp
|
| 187 |
clock_value: Current clock value
|
| 188 |
+
timeout_info: Optional timeout indicator information
|
| 189 |
|
| 190 |
Returns:
|
| 191 |
PlayEvent if play ended, None otherwise
|
|
|
|
| 204 |
return None
|
| 205 |
|
| 206 |
# Check for possession change (40→25 transition)
|
| 207 |
+
# Also schedules delayed timeout check if 40→25 detected
|
| 208 |
+
# Returns True if transition was handled (skip subsequent checks)
|
| 209 |
+
if self.identification.check_possession_change(timestamp, clock_value, timeout_info):
|
| 210 |
+
return None # Transition handled, don't run abnormal clock drop check
|
| 211 |
|
| 212 |
# Check for abnormal clock drop (first reading after 40)
|
| 213 |
result = self.identification.check_abnormal_clock_drop(timestamp, clock_value)
|