andytaylor-smg commited on
Commit
eecfaf7
·
1 Parent(s): bbd3e23

timeout works now

Browse files
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
- # Get timeout counts before reset (look back up to 20 frames)
 
 
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
- if frame.get("home_timeouts") is not None:
183
- before_home = frame.get("home_timeouts", 3)
184
- before_away = frame.get("away_timeouts", 3)
 
 
185
  break
186
 
187
- if before_home is None:
188
  return None
189
 
190
- # Look forward for timeout change (up to 15 seconds)
191
- frame_interval = frame_data[1]["timestamp"] - frame_data[0]["timestamp"] if len(frame_data) > 1 else 0.5
192
- max_frames_forward = int(15.0 / frame_interval) if frame_interval > 0 else 30
193
 
194
- for j in range(frame_idx, min(len(frame_data), frame_idx + max_frames_forward)):
 
 
 
 
195
  frame = frame_data[j]
196
- if frame.get("home_timeouts") is not None:
197
- after_home = frame.get("home_timeouts", 3)
198
- after_away = frame.get("away_timeouts", 3)
 
 
 
 
 
 
199
 
200
- if after_home < before_home:
201
- return "home"
202
- if after_away < before_away:
203
- return "away"
 
 
 
 
 
204
 
205
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) -> None:
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 changed → tracked as timeout play
249
- - Class C (special): Neither A nor B → special play (punt/FG/XP)
 
 
 
250
 
251
- For now, we check for timeout change. If no timeout, treat as special play.
252
- The weird_clock case will naturally be filtered when 25 starts counting down.
 
 
 
253
 
254
  Args:
255
  timestamp: Current timestamp
@@ -257,20 +412,28 @@ class PlayIdentificationChecks:
257
  """
258
  self._state.clock_reset_stats.total += 1
259
 
260
- # Check for timeout change (Class B)
261
- timeout_team = self.check_timeout_change(timeout_info)
262
-
263
- if timeout_team:
264
- # Class B: Team timeout identified
265
- self._state.clock_reset_stats.timeout += 1
266
- logger.info("Clock reset 40→25 at %.1fs classified as TIMEOUT (%s team)", timestamp, timeout_team)
267
- self._state.current_play_clock_base = 25
268
- self._state.current_play_type = "timeout"
269
- self.lifecycle.start_play(timestamp, f"timeout_{timeout_team}", 40)
 
 
 
 
 
270
  else:
271
- # Class C: Special play (punt/FG/XP) - or potentially Class A (will be filtered later)
272
- self._state.clock_reset_stats.special += 1
273
- logger.info("Clock reset 40→25 at %.1fs classified as SPECIAL play", timestamp)
274
- self._state.current_play_clock_base = 25
275
- self._state.current_play_type = "special"
276
- self.lifecycle.start_play(timestamp, "clock_reset_special", 40)
 
 
 
 
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 > special > timeout (normal plays are most reliable)
99
- type_priority = {"normal": 3, "special": 2, "timeout": 1}
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 special/timeout 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
  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
- if play.start_time < last_normal_end + self.quiet_time and play.play_type != "normal":
135
- # This non-normal play starts during quiet time - filter it out
 
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 # 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,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
- # Note: This function only updates state, never returns a PlayEvent
207
- self.identification.check_possession_change(timestamp, clock_value)
 
 
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)