andytaylor-smg commited on
Commit
137c6cf
·
1 Parent(s): aee009f

some decent progress generalizing

Browse files
docs/texas_video_vfr_issue.md CHANGED
@@ -32,36 +32,53 @@ This means when we request sequential timestamps, we get frames that are **out o
32
 
33
  ## Solution Options
34
 
35
- ### Option 1: Re-encode to CFR (Recommended for simplicity)
36
  ```bash
37
  ffmpeg -i "OSU vs Texas 01.10.25.mkv" -vsync cfr -r 29.97 -c:v libx264 -preset fast -crf 18 -c:a copy "OSU_vs_Texas_CFR.mkv"
38
  ```
39
  - Pros: One-time fix, no code changes needed
40
- - Cons: Requires re-encoding (takes time, slight quality loss)
41
 
42
- ### Option 2: Use FFmpeg for frame extraction
43
- Replace OpenCV seeking with ffmpeg-based extraction for accurate timestamps.
44
- - Pros: Works with original file, accurate timestamps
45
- - Cons: Requires code changes, potentially slower
46
 
47
  ### Option 3: Use OpenCV's timestamp-based API with compensation
48
  Use `CAP_PROP_POS_MSEC` instead of `CAP_PROP_POS_FRAMES`, and track actual timestamps rather than calculated ones.
49
  - Pros: Minimal code changes
50
  - Cons: Still unreliable for this video (both methods showed similar errors)
51
 
52
- ## Recommended Approach
 
 
 
 
 
 
 
 
 
 
53
 
54
- For the Texas video specifically, **Option 1 (re-encoding to CFR)** is the cleanest solution since:
55
- 1. The video only needs to be processed once
56
- 2. All existing code continues to work correctly
57
- 3. No risk of introducing new bugs in frame extraction
 
58
 
59
- For a more general solution (to handle any VFR video), implement Option 2 in the frame extraction pipeline.
 
 
 
 
60
 
61
  ## Diagnostic Scripts
62
 
63
  - `scripts/diagnose_video_timestamps.py` - Basic timestamp analysis
64
  - `scripts/diagnose_vfr_issue.py` - Detailed VFR investigation
 
 
65
 
66
  ## Debug Output
67
 
 
32
 
33
  ## Solution Options
34
 
35
+ ### Option 1: Re-encode to CFR
36
  ```bash
37
  ffmpeg -i "OSU vs Texas 01.10.25.mkv" -vsync cfr -r 29.97 -c:v libx264 -preset fast -crf 18 -c:a copy "OSU_vs_Texas_CFR.mkv"
38
  ```
39
  - Pros: One-time fix, no code changes needed
40
+ - Cons: Requires re-encoding (takes time, slight quality loss), not video-agnostic
41
 
42
+ ### Option 2: Use FFmpeg Pipe for frame extraction (RECOMMENDED)
43
+ Replace OpenCV seeking with ffmpeg piping raw frames to OpenCV.
44
+ - Pros: Works with any video (VFR or CFR), accurate timestamps, **36x faster than current method!**
45
+ - Cons: Requires code changes in pipeline
46
 
47
  ### Option 3: Use OpenCV's timestamp-based API with compensation
48
  Use `CAP_PROP_POS_MSEC` instead of `CAP_PROP_POS_FRAMES`, and track actual timestamps rather than calculated ones.
49
  - Pros: Minimal code changes
50
  - Cons: Still unreliable for this video (both methods showed similar errors)
51
 
52
+ ## Performance Benchmark Results
53
+
54
+ Tested extracting 300 frames from a 60-second segment:
55
+
56
+ | Method | Time per Frame | vs Current |
57
+ |--------|----------------|------------|
58
+ | **FFmpeg Pipe to OpenCV** | 0.0023s | **36.55x FASTER** |
59
+ | OpenCV Sequential Read | 0.0037s | 22.49x faster |
60
+ | OpenCV Frame Seeking (current) | 0.0840s | baseline |
61
+ | OpenCV Time Seeking | 0.0851s | 0.99x |
62
+ | FFmpeg Single Frame | 0.2156s | 0.39x (slower) |
63
 
64
+ The FFmpeg pipe method is both:
65
+ 1. **Accurate** - properly handles VFR timestamps
66
+ 2. **Fast** - 36x faster than current OpenCV seeking
67
+
68
+ ## Recommended Approach
69
 
70
+ **Use FFmpeg Pipe to OpenCV** (Option 2) because:
71
+ 1. Works with any video format (video-agnostic)
72
+ 2. Correctly handles VFR videos
73
+ 3. Significantly faster than current implementation
74
+ 4. No need to re-encode videos
75
 
76
  ## Diagnostic Scripts
77
 
78
  - `scripts/diagnose_video_timestamps.py` - Basic timestamp analysis
79
  - `scripts/diagnose_vfr_issue.py` - Detailed VFR investigation
80
+ - `scripts/test_ffmpeg_frame_extraction.py` - Verify ffmpeg produces correct frame order
81
+ - `scripts/benchmark_extraction_methods.py` - Performance comparison of extraction methods
82
 
83
  ## Debug Output
84
 
scripts/benchmark_extraction_methods.py ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Benchmark different frame extraction methods to assess performance impact.
3
+
4
+ Compares:
5
+ 1. OpenCV frame-based seeking (CAP_PROP_POS_FRAMES) - current method
6
+ 2. OpenCV time-based seeking (CAP_PROP_POS_MSEC)
7
+ 3. FFmpeg single-frame extraction (one call per frame)
8
+ 4. FFmpeg batch extraction (one call for multiple frames)
9
+ 5. OpenCV sequential read with skip
10
+
11
+ Usage:
12
+ python scripts/benchmark_extraction_methods.py
13
+ """
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ import cv2
26
+ import numpy as np
27
+
28
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def load_texas_config() -> Dict[str, Any]:
33
+ """Load the saved config for Texas video."""
34
+ config_path = Path("output/OSU_vs_Texas_01_10_25_config.json")
35
+ with open(config_path, "r") as f:
36
+ return json.load(f)
37
+
38
+
39
+ # =============================================================================
40
+ # Method 1: OpenCV Frame-Based Seeking (Current Method)
41
+ # =============================================================================
42
+
43
+
44
+ def benchmark_opencv_frame_seeking(video_path: str, timestamps: List[float]) -> Dict[str, Any]:
45
+ """
46
+ Benchmark OpenCV's CAP_PROP_POS_FRAMES seeking.
47
+ This is the current method used in the pipeline.
48
+ """
49
+ cap = cv2.VideoCapture(video_path)
50
+ if not cap.isOpened():
51
+ return {"error": "Failed to open video"}
52
+
53
+ fps = cap.get(cv2.CAP_PROP_FPS)
54
+ frames_extracted = 0
55
+
56
+ t_start = time.perf_counter()
57
+
58
+ for ts in timestamps:
59
+ frame_num = int(ts * fps)
60
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
61
+ ret, frame = cap.read()
62
+ if ret:
63
+ frames_extracted += 1
64
+
65
+ t_elapsed = time.perf_counter() - t_start
66
+ cap.release()
67
+
68
+ return {
69
+ "method": "OpenCV Frame Seeking",
70
+ "frames_requested": len(timestamps),
71
+ "frames_extracted": frames_extracted,
72
+ "total_time": t_elapsed,
73
+ "time_per_frame": t_elapsed / len(timestamps),
74
+ "fps": len(timestamps) / t_elapsed,
75
+ }
76
+
77
+
78
+ # =============================================================================
79
+ # Method 2: OpenCV Time-Based Seeking
80
+ # =============================================================================
81
+
82
+
83
+ def benchmark_opencv_time_seeking(video_path: str, timestamps: List[float]) -> Dict[str, Any]:
84
+ """
85
+ Benchmark OpenCV's CAP_PROP_POS_MSEC seeking.
86
+ """
87
+ cap = cv2.VideoCapture(video_path)
88
+ if not cap.isOpened():
89
+ return {"error": "Failed to open video"}
90
+
91
+ frames_extracted = 0
92
+
93
+ t_start = time.perf_counter()
94
+
95
+ for ts in timestamps:
96
+ cap.set(cv2.CAP_PROP_POS_MSEC, ts * 1000.0)
97
+ ret, frame = cap.read()
98
+ if ret:
99
+ frames_extracted += 1
100
+
101
+ t_elapsed = time.perf_counter() - t_start
102
+ cap.release()
103
+
104
+ return {
105
+ "method": "OpenCV Time Seeking",
106
+ "frames_requested": len(timestamps),
107
+ "frames_extracted": frames_extracted,
108
+ "total_time": t_elapsed,
109
+ "time_per_frame": t_elapsed / len(timestamps),
110
+ "fps": len(timestamps) / t_elapsed,
111
+ }
112
+
113
+
114
+ # =============================================================================
115
+ # Method 3: FFmpeg Single Frame Extraction
116
+ # =============================================================================
117
+
118
+
119
+ def benchmark_ffmpeg_single_frame(video_path: str, timestamps: List[float]) -> Dict[str, Any]:
120
+ """
121
+ Benchmark FFmpeg extraction, one frame at a time.
122
+ This is the slowest FFmpeg approach but most straightforward.
123
+ """
124
+ frames_extracted = 0
125
+
126
+ t_start = time.perf_counter()
127
+
128
+ for ts in timestamps:
129
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
130
+ tmp_path = tmp.name
131
+
132
+ try:
133
+ cmd = [
134
+ "ffmpeg",
135
+ "-ss",
136
+ str(ts),
137
+ "-i",
138
+ str(video_path),
139
+ "-frames:v",
140
+ "1",
141
+ "-q:v",
142
+ "2",
143
+ "-loglevel",
144
+ "error",
145
+ tmp_path,
146
+ "-y",
147
+ ]
148
+
149
+ result = subprocess.run(cmd, capture_output=True, timeout=30)
150
+ if result.returncode == 0:
151
+ frame = cv2.imread(tmp_path)
152
+ if frame is not None:
153
+ frames_extracted += 1
154
+ finally:
155
+ if os.path.exists(tmp_path):
156
+ os.remove(tmp_path)
157
+
158
+ t_elapsed = time.perf_counter() - t_start
159
+
160
+ return {
161
+ "method": "FFmpeg Single Frame",
162
+ "frames_requested": len(timestamps),
163
+ "frames_extracted": frames_extracted,
164
+ "total_time": t_elapsed,
165
+ "time_per_frame": t_elapsed / len(timestamps),
166
+ "fps": len(timestamps) / t_elapsed,
167
+ }
168
+
169
+
170
+ # =============================================================================
171
+ # Method 4: FFmpeg Batch Extraction (select filter)
172
+ # =============================================================================
173
+
174
+
175
+ def benchmark_ffmpeg_batch_select(video_path: str, timestamps: List[float]) -> Dict[str, Any]:
176
+ """
177
+ Benchmark FFmpeg batch extraction using select filter.
178
+ Extracts all frames in a single ffmpeg call using timestamp expressions.
179
+ """
180
+ with tempfile.TemporaryDirectory() as tmp_dir:
181
+ t_start = time.perf_counter()
182
+
183
+ # Build select filter expression for all timestamps
184
+ # Use 'between' to select frames near each timestamp (within 0.02s = ~1 frame at 60fps)
185
+ tolerance = 0.02
186
+ conditions = [f"between(t,{ts-tolerance},{ts+tolerance})" for ts in timestamps]
187
+ select_expr = "+".join(conditions)
188
+
189
+ cmd = [
190
+ "ffmpeg",
191
+ "-i",
192
+ str(video_path),
193
+ "-vf",
194
+ f"select='{select_expr}',setpts=N/TB",
195
+ "-vsync",
196
+ "vfr",
197
+ "-q:v",
198
+ "2",
199
+ "-loglevel",
200
+ "error",
201
+ f"{tmp_dir}/frame_%04d.png",
202
+ "-y",
203
+ ]
204
+
205
+ result = subprocess.run(cmd, capture_output=True, timeout=120)
206
+
207
+ t_elapsed = time.perf_counter() - t_start
208
+
209
+ # Count extracted frames
210
+ frames_extracted = len(list(Path(tmp_dir).glob("frame_*.png")))
211
+
212
+ return {
213
+ "method": "FFmpeg Batch Select",
214
+ "frames_requested": len(timestamps),
215
+ "frames_extracted": frames_extracted,
216
+ "total_time": t_elapsed,
217
+ "time_per_frame": t_elapsed / len(timestamps),
218
+ "fps": len(timestamps) / t_elapsed,
219
+ "note": "Single ffmpeg call with select filter",
220
+ }
221
+
222
+
223
+ # =============================================================================
224
+ # Method 5: FFmpeg Segment + Sequential Read
225
+ # =============================================================================
226
+
227
+
228
+ def benchmark_ffmpeg_segment_opencv_read(video_path: str, timestamps: List[float], interval: float) -> Dict[str, Any]:
229
+ """
230
+ Benchmark: Extract a video segment with ffmpeg, then read sequentially with OpenCV.
231
+ This is a hybrid approach that might give best accuracy with good speed.
232
+ """
233
+ if not timestamps:
234
+ return {"error": "No timestamps provided"}
235
+
236
+ start_ts = min(timestamps) - 1.0 # 1 second buffer
237
+ end_ts = max(timestamps) + 1.0
238
+
239
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
240
+ tmp_path = tmp.name
241
+
242
+ try:
243
+ t_start = time.perf_counter()
244
+
245
+ # Extract segment with ffmpeg (accurate seeking)
246
+ cmd = [
247
+ "ffmpeg",
248
+ "-ss",
249
+ str(start_ts),
250
+ "-i",
251
+ str(video_path),
252
+ "-t",
253
+ str(end_ts - start_ts),
254
+ "-c:v",
255
+ "libx264",
256
+ "-preset",
257
+ "ultrafast",
258
+ "-crf",
259
+ "18",
260
+ "-an", # No audio
261
+ "-loglevel",
262
+ "error",
263
+ tmp_path,
264
+ "-y",
265
+ ]
266
+
267
+ result = subprocess.run(cmd, capture_output=True, timeout=120)
268
+ if result.returncode != 0:
269
+ return {"error": "FFmpeg segment extraction failed"}
270
+
271
+ t_extract = time.perf_counter() - t_start
272
+
273
+ # Now read sequentially from the segment
274
+ cap = cv2.VideoCapture(tmp_path)
275
+ if not cap.isOpened():
276
+ return {"error": "Failed to open extracted segment"}
277
+
278
+ fps = cap.get(cv2.CAP_PROP_FPS)
279
+ frames_extracted = 0
280
+
281
+ # Read frames at the target interval
282
+ t_read_start = time.perf_counter()
283
+ frame_skip = max(1, int(interval * fps))
284
+
285
+ current_time = 0.0
286
+ frame_idx = 0
287
+ while current_time < (end_ts - start_ts):
288
+ ret, frame = cap.read()
289
+ if not ret:
290
+ break
291
+
292
+ # Check if this frame is near any of our target timestamps
293
+ actual_video_time = start_ts + current_time
294
+ for ts in timestamps:
295
+ if abs(actual_video_time - ts) < interval / 2:
296
+ frames_extracted += 1
297
+ break
298
+
299
+ # Skip frames
300
+ for _ in range(frame_skip - 1):
301
+ cap.grab()
302
+
303
+ current_time += interval
304
+ frame_idx += 1
305
+
306
+ cap.release()
307
+ t_read = time.perf_counter() - t_read_start
308
+
309
+ t_elapsed = time.perf_counter() - t_start
310
+
311
+ finally:
312
+ if os.path.exists(tmp_path):
313
+ os.remove(tmp_path)
314
+
315
+ return {
316
+ "method": "FFmpeg Segment + OpenCV Read",
317
+ "frames_requested": len(timestamps),
318
+ "frames_extracted": frames_extracted,
319
+ "total_time": t_elapsed,
320
+ "extraction_time": t_extract,
321
+ "read_time": t_read,
322
+ "time_per_frame": t_elapsed / len(timestamps),
323
+ "fps": len(timestamps) / t_elapsed,
324
+ }
325
+
326
+
327
+ # =============================================================================
328
+ # Method 6: OpenCV Sequential Read with Skip (Baseline)
329
+ # =============================================================================
330
+
331
+
332
+ def benchmark_opencv_sequential(video_path: str, start_time: float, num_frames: int, interval: float) -> Dict[str, Any]:
333
+ """
334
+ Benchmark OpenCV sequential reading with frame skipping.
335
+ This avoids seeking entirely but requires reading from the start of a range.
336
+ """
337
+ cap = cv2.VideoCapture(video_path)
338
+ if not cap.isOpened():
339
+ return {"error": "Failed to open video"}
340
+
341
+ fps = cap.get(cv2.CAP_PROP_FPS)
342
+ frame_skip = max(1, int(interval * fps))
343
+
344
+ t_start = time.perf_counter()
345
+
346
+ # Seek to start position once
347
+ cap.set(cv2.CAP_PROP_POS_MSEC, start_time * 1000.0)
348
+
349
+ frames_extracted = 0
350
+ for _ in range(num_frames):
351
+ ret, frame = cap.read()
352
+ if not ret:
353
+ break
354
+ frames_extracted += 1
355
+
356
+ # Skip frames
357
+ for _ in range(frame_skip - 1):
358
+ cap.grab()
359
+
360
+ t_elapsed = time.perf_counter() - t_start
361
+ cap.release()
362
+
363
+ return {
364
+ "method": "OpenCV Sequential Read",
365
+ "frames_requested": num_frames,
366
+ "frames_extracted": frames_extracted,
367
+ "total_time": t_elapsed,
368
+ "time_per_frame": t_elapsed / num_frames,
369
+ "fps": num_frames / t_elapsed,
370
+ "note": "Single seek + sequential read with skip",
371
+ }
372
+
373
+
374
+ # =============================================================================
375
+ # Method 7: FFmpeg pipe to OpenCV (no temp files)
376
+ # =============================================================================
377
+
378
+
379
+ def benchmark_ffmpeg_pipe(video_path: str, start_time: float, duration: float, interval: float) -> Dict[str, Any]:
380
+ """
381
+ Benchmark FFmpeg piping raw frames to OpenCV.
382
+ This avoids temp files and gives accurate timestamps.
383
+ """
384
+ # Get video dimensions first
385
+ cap = cv2.VideoCapture(video_path)
386
+ if not cap.isOpened():
387
+ return {"error": "Failed to open video"}
388
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
389
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
390
+ cap.release()
391
+
392
+ # Calculate output fps based on interval
393
+ output_fps = 1.0 / interval
394
+
395
+ t_start = time.perf_counter()
396
+
397
+ cmd = [
398
+ "ffmpeg",
399
+ "-ss",
400
+ str(start_time),
401
+ "-i",
402
+ str(video_path),
403
+ "-t",
404
+ str(duration),
405
+ "-vf",
406
+ f"fps={output_fps}",
407
+ "-f",
408
+ "rawvideo",
409
+ "-pix_fmt",
410
+ "bgr24",
411
+ "-loglevel",
412
+ "error",
413
+ "-",
414
+ ]
415
+
416
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
417
+
418
+ frame_size = width * height * 3
419
+ frames_extracted = 0
420
+
421
+ while True:
422
+ raw_frame = process.stdout.read(frame_size)
423
+ if len(raw_frame) != frame_size:
424
+ break
425
+ frame = np.frombuffer(raw_frame, dtype=np.uint8).reshape((height, width, 3))
426
+ frames_extracted += 1
427
+
428
+ process.wait()
429
+ t_elapsed = time.perf_counter() - t_start
430
+
431
+ expected_frames = int(duration / interval)
432
+
433
+ return {
434
+ "method": "FFmpeg Pipe to OpenCV",
435
+ "frames_requested": expected_frames,
436
+ "frames_extracted": frames_extracted,
437
+ "total_time": t_elapsed,
438
+ "time_per_frame": t_elapsed / max(1, frames_extracted),
439
+ "fps": frames_extracted / t_elapsed if t_elapsed > 0 else 0,
440
+ "note": "FFmpeg pipes raw frames, no temp files",
441
+ }
442
+
443
+
444
+ def main():
445
+ """Run all benchmarks and compare."""
446
+ config = load_texas_config()
447
+ video_path = config["video_path"]
448
+
449
+ logger.info("=" * 80)
450
+ logger.info("FRAME EXTRACTION METHOD BENCHMARK")
451
+ logger.info("=" * 80)
452
+ logger.info("Video: %s", video_path)
453
+ logger.info("")
454
+
455
+ # Test parameters
456
+ # Simulate typical pipeline: extract frames every 0.2s over a 60-second segment
457
+ interval = 0.2 # seconds between frames
458
+ segment_duration = 60.0 # seconds
459
+ start_time = 5900.0 # Start in the problem area
460
+
461
+ num_frames = int(segment_duration / interval)
462
+ timestamps = [start_time + (i * interval) for i in range(num_frames)]
463
+
464
+ logger.info("Test parameters:")
465
+ logger.info(" Segment: %.1fs to %.1fs (%.1fs duration)", start_time, start_time + segment_duration, segment_duration)
466
+ logger.info(" Interval: %.2fs", interval)
467
+ logger.info(" Frames to extract: %d", num_frames)
468
+ logger.info("")
469
+
470
+ results = []
471
+
472
+ # Benchmark each method
473
+ logger.info("Running benchmarks...")
474
+ logger.info("-" * 40)
475
+
476
+ # 1. Current method: OpenCV frame seeking
477
+ logger.info(" Testing OpenCV Frame Seeking...")
478
+ r1 = benchmark_opencv_frame_seeking(video_path, timestamps)
479
+ results.append(r1)
480
+ logger.info(" Done: %.2fs total, %.3fs/frame", r1["total_time"], r1["time_per_frame"])
481
+
482
+ # 2. OpenCV time seeking
483
+ logger.info(" Testing OpenCV Time Seeking...")
484
+ r2 = benchmark_opencv_time_seeking(video_path, timestamps)
485
+ results.append(r2)
486
+ logger.info(" Done: %.2fs total, %.3fs/frame", r2["total_time"], r2["time_per_frame"])
487
+
488
+ # 3. FFmpeg single frame (only test subset - it's slow)
489
+ subset_timestamps = timestamps[:20] # Only test 20 frames
490
+ logger.info(" Testing FFmpeg Single Frame (20 frames only)...")
491
+ r3 = benchmark_ffmpeg_single_frame(video_path, subset_timestamps)
492
+ results.append(r3)
493
+ logger.info(" Done: %.2fs total, %.3fs/frame", r3["total_time"], r3["time_per_frame"])
494
+
495
+ # 4. OpenCV sequential read
496
+ logger.info(" Testing OpenCV Sequential Read...")
497
+ r4 = benchmark_opencv_sequential(video_path, start_time, num_frames, interval)
498
+ results.append(r4)
499
+ logger.info(" Done: %.2fs total, %.3fs/frame", r4["total_time"], r4["time_per_frame"])
500
+
501
+ # 5. FFmpeg pipe
502
+ logger.info(" Testing FFmpeg Pipe to OpenCV...")
503
+ r5 = benchmark_ffmpeg_pipe(video_path, start_time, segment_duration, interval)
504
+ results.append(r5)
505
+ logger.info(" Done: %.2fs total, %.3fs/frame", r5["total_time"], r5["time_per_frame"])
506
+
507
+ logger.info("")
508
+ logger.info("=" * 80)
509
+ logger.info("RESULTS SUMMARY")
510
+ logger.info("=" * 80)
511
+ logger.info("")
512
+
513
+ # Sort by time per frame
514
+ results_sorted = sorted(results, key=lambda x: x.get("time_per_frame", float("inf")))
515
+
516
+ # Find baseline (current method)
517
+ baseline_time = r1["time_per_frame"]
518
+
519
+ logger.info("%-30s %10s %10s %10s %10s", "Method", "Total(s)", "Per Frame", "FPS", "vs Current")
520
+ logger.info("-" * 80)
521
+
522
+ for r in results_sorted:
523
+ if "error" in r:
524
+ logger.info("%-30s ERROR: %s", r.get("method", "Unknown"), r["error"])
525
+ continue
526
+
527
+ speedup = baseline_time / r["time_per_frame"] if r["time_per_frame"] > 0 else 0
528
+ speedup_str = f"{speedup:.2f}x" if speedup != 1.0 else "baseline"
529
+
530
+ logger.info(
531
+ "%-30s %10.2f %10.4f %10.1f %10s",
532
+ r["method"],
533
+ r["total_time"],
534
+ r["time_per_frame"],
535
+ r["fps"],
536
+ speedup_str,
537
+ )
538
+
539
+ logger.info("")
540
+ logger.info("NOTES:")
541
+ logger.info(" - 'FFmpeg Single Frame' tested with only 20 frames (would be %.1fs for %d frames)", r3["time_per_frame"] * num_frames, num_frames)
542
+ logger.info(" - 'FFmpeg Pipe' gives accurate timestamps AND good performance")
543
+ logger.info(" - 'OpenCV Sequential Read' is fastest but requires contiguous segments")
544
+ logger.info("")
545
+
546
+ # Recommendation
547
+ fastest_accurate = None
548
+ for r in results_sorted:
549
+ if r["method"] in ["FFmpeg Pipe to OpenCV", "FFmpeg Segment + OpenCV Read"]:
550
+ fastest_accurate = r
551
+ break
552
+
553
+ if fastest_accurate:
554
+ speedup = baseline_time / fastest_accurate["time_per_frame"]
555
+ logger.info("RECOMMENDATION:")
556
+ logger.info(" Use '%s' for accurate VFR handling", fastest_accurate["method"])
557
+ logger.info(" Performance: %.2fx %s than current method", speedup, "faster" if speedup > 1 else "slower")
558
+
559
+
560
+ if __name__ == "__main__":
561
+ main()
scripts/compare_tennessee_plays.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Compare Tennessee benchmark vs current FFmpeg output to identify missing plays."""
3
+
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def load_json(path: str) -> dict:
13
+ """Load a JSON file."""
14
+ with open(path, "r") as f:
15
+ return json.load(f)
16
+
17
+
18
+ def find_matching_play(play: dict, play_list: list, tolerance: float = 2.0) -> dict | None:
19
+ """Find a matching play in the list based on start_time proximity."""
20
+ for p in play_list:
21
+ if abs(p["start_time"] - play["start_time"]) <= tolerance:
22
+ return p
23
+ return None
24
+
25
+
26
+ def main():
27
+ # Load both outputs
28
+ benchmark_path = Path("output/benchmarks/v6_baseline.json")
29
+ current_path = Path("output/OSU_vs_Tenn_12_21_24_plays.json")
30
+
31
+ benchmark = load_json(benchmark_path)
32
+ current = load_json(current_path)
33
+
34
+ benchmark_plays = benchmark["plays"]
35
+ current_plays = current["plays"]
36
+
37
+ logger.info("=" * 80)
38
+ logger.info("TENNESSEE PLAY COMPARISON: Benchmark vs FFmpeg")
39
+ logger.info("=" * 80)
40
+ logger.info(f"\nBenchmark plays: {len(benchmark_plays)}")
41
+ logger.info(f"Current plays: {len(current_plays)}")
42
+ logger.info(f"Difference: {len(benchmark_plays) - len(current_plays)}")
43
+
44
+ # Compare play type distributions
45
+ logger.info("\n" + "-" * 40)
46
+ logger.info("PLAY TYPE COMPARISON")
47
+ logger.info("-" * 40)
48
+
49
+ benchmark_types = benchmark["stats"]["play_types"]
50
+ current_types = current["stats"]["play_types"]
51
+
52
+ logger.info(f"{'Type':<15} {'Benchmark':<12} {'Current':<12} {'Diff':<10}")
53
+ for ptype in set(list(benchmark_types.keys()) + list(current_types.keys())):
54
+ b_count = benchmark_types.get(ptype, 0)
55
+ c_count = current_types.get(ptype, 0)
56
+ diff = b_count - c_count
57
+ logger.info(f"{ptype:<15} {b_count:<12} {c_count:<12} {diff:+d}")
58
+
59
+ # Find plays in benchmark that are missing from current
60
+ logger.info("\n" + "-" * 40)
61
+ logger.info("PLAYS IN BENCHMARK BUT MISSING FROM CURRENT")
62
+ logger.info("-" * 40)
63
+
64
+ missing_plays = []
65
+ for bp in benchmark_plays:
66
+ match = find_matching_play(bp, current_plays, tolerance=3.0)
67
+ if match is None:
68
+ missing_plays.append(bp)
69
+
70
+ logger.info(f"\nFound {len(missing_plays)} missing plays:\n")
71
+ for p in missing_plays:
72
+ logger.info(f" Play #{p['play_number']:3d}: {p['start_time']:7.1f}s - {p['end_time']:7.1f}s")
73
+ logger.info(f" Type: {p['play_type']}, Method: {p['start_method']}")
74
+ logger.info(f" Duration: {p['duration']:.1f}s, Clock: {p.get('start_clock_value')} -> {p.get('end_clock_value')}")
75
+ logger.info("")
76
+
77
+ # Group missing plays by type
78
+ logger.info("-" * 40)
79
+ logger.info("MISSING PLAYS BY TYPE")
80
+ logger.info("-" * 40)
81
+ missing_by_type = {}
82
+ for p in missing_plays:
83
+ ptype = p["play_type"]
84
+ if ptype not in missing_by_type:
85
+ missing_by_type[ptype] = []
86
+ missing_by_type[ptype].append(p)
87
+
88
+ for ptype, plays in sorted(missing_by_type.items()):
89
+ logger.info(f"\n{ptype.upper()} plays missing: {len(plays)}")
90
+ for p in plays:
91
+ logger.info(f" - #{p['play_number']}: {p['start_time']:.1f}s (method: {p['start_method']})")
92
+
93
+ # Find plays in current that don't exist in benchmark (extra plays)
94
+ logger.info("\n" + "-" * 40)
95
+ logger.info("PLAYS IN CURRENT BUT NOT IN BENCHMARK (extras)")
96
+ logger.info("-" * 40)
97
+
98
+ extra_plays = []
99
+ for cp in current_plays:
100
+ match = find_matching_play(cp, benchmark_plays, tolerance=3.0)
101
+ if match is None:
102
+ extra_plays.append(cp)
103
+
104
+ if extra_plays:
105
+ logger.info(f"\nFound {len(extra_plays)} extra plays:\n")
106
+ for p in extra_plays:
107
+ logger.info(f" Play #{p['play_number']:3d}: {p['start_time']:7.1f}s - {p['end_time']:7.1f}s")
108
+ logger.info(f" Type: {p['play_type']}, Method: {p['start_method']}")
109
+ else:
110
+ logger.info("\nNo extra plays found (current is a subset of benchmark)")
111
+
112
+ # Check first play difference - benchmark starts at ~121s, current starts at ~180s
113
+ logger.info("\n" + "-" * 40)
114
+ logger.info("FIRST FEW PLAYS COMPARISON")
115
+ logger.info("-" * 40)
116
+ logger.info("\nBenchmark first 5 plays:")
117
+ for p in benchmark_plays[:5]:
118
+ logger.info(f" #{p['play_number']}: {p['start_time']:.1f}s - {p['play_type']} ({p['start_method']})")
119
+
120
+ logger.info("\nCurrent first 5 plays:")
121
+ for p in current_plays[:5]:
122
+ logger.info(f" #{p['play_number']}: {p['start_time']:.1f}s - {p['play_type']} ({p['start_method']})")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
scripts/diagnose_tennessee_regression.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Diagnose Tennessee regression - check what's happening with frames at key timestamps."""
3
+
4
+ import sys
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent.parent))
9
+
10
+ import cv2
11
+ import numpy as np
12
+ from src.video.ffmpeg_reader import FFmpegFrameReader
13
+ from src.readers.flags import FlagReader
14
+
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def check_frames_at_timestamp(video_path: str, start_time: float, duration: float = 10.0):
20
+ """Check what frames are extracted at a given timestamp range."""
21
+ logger.info(f"\n{'='*60}")
22
+ logger.info(f"Checking frames from {start_time}s to {start_time + duration}s")
23
+ logger.info("=" * 60)
24
+
25
+ frames = []
26
+ with FFmpegFrameReader(video_path, start_time, start_time + duration, 0.5) as reader:
27
+ for timestamp, frame in reader:
28
+ frames.append((timestamp, frame.copy()))
29
+ logger.info(f" Frame at t={timestamp:.2f}s, shape={frame.shape}")
30
+
31
+ logger.info(f" Total frames extracted: {len(frames)}")
32
+ return frames
33
+
34
+
35
+ def check_flag_detection(video_path: str, start_time: float, duration: float = 30.0):
36
+ """Check flag detection at a timestamp range using direct yellow detection."""
37
+ import json
38
+
39
+ # Load saved session config (this is what main.py uses)
40
+ session_config_path = Path("output/OSU_vs_Tenn_12_21_24_config.json")
41
+ with open(session_config_path, "r") as f:
42
+ session_config = json.load(f)
43
+
44
+ # Extract flag region from session config (what main.py actually uses)
45
+ flag_x_offset = session_config["flag_x_offset"]
46
+ flag_y_offset = session_config["flag_y_offset"]
47
+ flag_width = session_config["flag_width"]
48
+ flag_height = session_config["flag_height"]
49
+
50
+ # Scorebug location from session config
51
+ scorebug_x = session_config["scorebug_x"]
52
+ scorebug_y = session_config["scorebug_y"]
53
+
54
+ # Calculate absolute flag region
55
+ x = scorebug_x + flag_x_offset
56
+ y = scorebug_y + flag_y_offset
57
+ w = flag_width
58
+ h = flag_height
59
+
60
+ logger.info(f"Flag region from SESSION config: x={x}, y={y}, w={w}, h={h}")
61
+ logger.info(f" (offsets: {flag_x_offset}, {flag_y_offset}, size: {flag_width}x{flag_height})")
62
+
63
+ # Use the FlagReader with actual session config values
64
+ flag_reader = FlagReader(
65
+ flag_x_offset=flag_x_offset,
66
+ flag_y_offset=flag_y_offset,
67
+ flag_width=flag_width,
68
+ flag_height=flag_height,
69
+ )
70
+
71
+ logger.info(f"\n{'='*60}")
72
+ logger.info(f"Flag detection from {start_time}s to {start_time + duration}s")
73
+ logger.info("=" * 60)
74
+
75
+ flag_events = []
76
+ with FFmpegFrameReader(video_path, start_time, start_time + duration, 0.5) as reader:
77
+ for timestamp, frame in reader:
78
+ # Check for flag using fixed location
79
+ result = flag_reader.read_from_fixed_location(frame, (x, y, w, h))
80
+ yellow_pct = result.yellow_ratio * 100
81
+ if result.detected:
82
+ logger.info(f" t={timestamp:7.1f}s: FLAG! yellow={yellow_pct:.1f}%, hue={result.mean_hue:.1f}")
83
+ flag_events.append(timestamp)
84
+ elif yellow_pct > 10:
85
+ logger.info(f" t={timestamp:7.1f}s: some yellow={yellow_pct:.1f}%, hue={result.mean_hue:.1f}")
86
+
87
+ logger.info(f"\nTotal FLAG frames detected: {len(flag_events)}")
88
+
89
+
90
+ def main():
91
+ video_path = "/Users/andytaylor/Documents/Personal/cfb40/full_videos/OSU vs Tenn 12.21.24.mkv"
92
+
93
+ # Check the very start of the video - the missing first play starts at ~121.9s
94
+ logger.info("\n" + "=" * 80)
95
+ logger.info("INVESTIGATING MISSING FIRST PLAY (~121.9s)")
96
+ logger.info("=" * 80)
97
+ # Just check what frames we get around the first play
98
+ check_frames_at_timestamp(video_path, 115.0, 20.0)
99
+
100
+ # Check one of the missing flag plays - the first one at ~340.6s
101
+ logger.info("\n" + "=" * 80)
102
+ logger.info("INVESTIGATING MISSING FLAG PLAY (~340.6s)")
103
+ logger.info("=" * 80)
104
+ check_flag_detection(video_path, 335.0, 40.0)
105
+
106
+ # Check another missing flag play at ~422.9s
107
+ logger.info("\n" + "=" * 80)
108
+ logger.info("INVESTIGATING MISSING FLAG PLAY (~422.9s)")
109
+ logger.info("=" * 80)
110
+ check_flag_detection(video_path, 418.0, 30.0)
111
+
112
+ # Check the ONE flag play that WAS detected (at 3552.5s)
113
+ logger.info("\n" + "=" * 80)
114
+ logger.info("INVESTIGATING DETECTED FLAG PLAY (~3552.5s)")
115
+ logger.info("=" * 80)
116
+ check_flag_detection(video_path, 3548.0, 20.0)
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
scripts/find_flag_ground_truth.py CHANGED
@@ -178,28 +178,82 @@ class ScanResult:
178
  }
179
 
180
 
181
- def load_flag_region_config() -> Optional[Dict[str, Any]]:
182
- """Load the FLAG region configuration."""
183
- config_path = DATA_CONFIG_DIR / "flag_region.json"
184
- if not config_path.exists():
185
- logger.error("FLAG region config not found: %s", config_path)
186
- logger.error("Please run test_flag_region_selection.py first")
187
- return None
 
188
 
189
- with open(config_path, "r", encoding="utf-8") as f:
190
- return json.load(f)
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
- def load_scorebug_config() -> Optional[Tuple[BBox, str]]:
194
- """Load the scorebug region from config."""
195
- config_files = list(OUTPUT_DIR.glob("*_config.json"))
196
- main_configs = [f for f in config_files if "playclock" not in f.name and "timeout" not in f.name]
197
 
198
- if not main_configs:
199
- logger.error("No scorebug config found in output/")
200
- return None
201
 
202
- config_path = max(main_configs, key=lambda p: p.stat().st_mtime)
 
 
 
203
 
204
  with open(config_path, "r", encoding="utf-8") as f:
205
  config = json.load(f)
@@ -706,23 +760,27 @@ def main() -> int:
706
  print(" FLAG Ground Truth Scanner")
707
  print("=" * 60)
708
 
709
- # Load configs
710
- flag_config = load_flag_region_config()
 
 
 
 
 
 
 
 
 
 
711
  if flag_config is None:
712
  return 1
713
 
714
- scorebug_result = load_scorebug_config()
715
  if scorebug_result is None:
716
  return 1
717
 
718
  scorebug_bbox, _ = scorebug_result
719
 
720
- # Determine video path
721
- video_path = args.video if args.video else str(DEFAULT_VIDEO)
722
- if not Path(video_path).exists():
723
- logger.error("Video not found: %s", video_path)
724
- return 1
725
-
726
  # Run scan
727
  result = scan_video_for_flags(
728
  video_path=video_path,
@@ -737,9 +795,9 @@ def main() -> int:
737
  # Print results
738
  print_results(result)
739
 
740
- # Save results
741
  OUTPUT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
742
- output_path = OUTPUT_CACHE_DIR / "flag_candidates.json"
743
 
744
  with open(output_path, "w", encoding="utf-8") as f:
745
  json.dump(result.to_dict(), f, indent=2)
 
178
  }
179
 
180
 
181
+ def get_video_basename(video_path: str) -> str:
182
+ """Get a clean basename from video path for config naming."""
183
+ basename = Path(video_path).stem
184
+ for char in [" ", ".", "-"]:
185
+ basename = basename.replace(char, "_")
186
+ while "__" in basename:
187
+ basename = basename.replace("__", "_")
188
+ return basename.strip("_")
189
 
 
 
190
 
191
+ def load_flag_region_config(video_path: str) -> Optional[Dict[str, Any]]:
192
+ """
193
+ Load the FLAG region configuration for a specific video.
194
+
195
+ Tries video-specific config first, then falls back to generic.
196
+ """
197
+ video_basename = get_video_basename(video_path)
198
+
199
+ # Try video-specific flag config first
200
+ video_flag_config_path = OUTPUT_DIR / f"{video_basename}_flag_config.json"
201
+ if video_flag_config_path.exists():
202
+ logger.info("Loading flag config from: %s", video_flag_config_path)
203
+ with open(video_flag_config_path, "r", encoding="utf-8") as f:
204
+ return json.load(f)
205
+
206
+ # Try session config (has flag_x_offset, etc.)
207
+ session_config_path = OUTPUT_DIR / f"{video_basename}_config.json"
208
+ if session_config_path.exists():
209
+ logger.info("Loading flag config from session: %s", session_config_path)
210
+ with open(session_config_path, "r", encoding="utf-8") as f:
211
+ session_config = json.load(f)
212
+
213
+ # Convert session config format to flag config format
214
+ if session_config.get("flag_x_offset") is not None:
215
+ return {
216
+ "flag_region": {
217
+ "x_offset": session_config["flag_x_offset"],
218
+ "y_offset": session_config["flag_y_offset"],
219
+ "width": session_config["flag_width"],
220
+ "height": session_config["flag_height"],
221
+ },
222
+ "source_video": Path(video_path).name,
223
+ }
224
+
225
+ # Fall back to generic config (legacy)
226
+ generic_config_path = DATA_CONFIG_DIR / "flag_region.json"
227
+ if generic_config_path.exists():
228
+ logger.warning("Using generic flag config (may not match video): %s", generic_config_path)
229
+ with open(generic_config_path, "r", encoding="utf-8") as f:
230
+ return json.load(f)
231
+
232
+ logger.error("No FLAG region config found for video: %s", video_basename)
233
+ logger.error("Please run test_flag_region_selection.py --video '%s' first", video_path)
234
+ return None
235
+
236
+
237
+ def load_scorebug_config(video_path: str) -> Optional[Tuple[BBox, str]]:
238
+ """Load the scorebug region from config for a specific video."""
239
+ video_basename = get_video_basename(video_path)
240
+
241
+ # Try video-specific config first
242
+ config_path = OUTPUT_DIR / f"{video_basename}_config.json"
243
 
244
+ if not config_path.exists():
245
+ # Fall back to most recently modified config (legacy behavior)
246
+ config_files = list(OUTPUT_DIR.glob("*_config.json"))
247
+ main_configs = [f for f in config_files if "playclock" not in f.name and "timeout" not in f.name and "flag" not in f.name]
248
 
249
+ if not main_configs:
250
+ logger.error("No scorebug config found in output/")
251
+ return None
252
 
253
+ config_path = max(main_configs, key=lambda p: p.stat().st_mtime)
254
+ logger.warning("Using fallback config (may not match video): %s", config_path)
255
+
256
+ logger.info("Loading scorebug config from: %s", config_path)
257
 
258
  with open(config_path, "r", encoding="utf-8") as f:
259
  config = json.load(f)
 
760
  print(" FLAG Ground Truth Scanner")
761
  print("=" * 60)
762
 
763
+ # Determine video path first (needed for config loading)
764
+ video_path = args.video if args.video else str(DEFAULT_VIDEO)
765
+ if not Path(video_path).exists():
766
+ logger.error("Video not found: %s", video_path)
767
+ return 1
768
+
769
+ video_basename = get_video_basename(video_path)
770
+ print(f"\nVideo: {Path(video_path).name}")
771
+ print(f"Config basename: {video_basename}")
772
+
773
+ # Load configs for specific video
774
+ flag_config = load_flag_region_config(video_path)
775
  if flag_config is None:
776
  return 1
777
 
778
+ scorebug_result = load_scorebug_config(video_path)
779
  if scorebug_result is None:
780
  return 1
781
 
782
  scorebug_bbox, _ = scorebug_result
783
 
 
 
 
 
 
 
784
  # Run scan
785
  result = scan_video_for_flags(
786
  video_path=video_path,
 
795
  # Print results
796
  print_results(result)
797
 
798
+ # Save results to video-specific file
799
  OUTPUT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
800
+ output_path = OUTPUT_CACHE_DIR / f"{video_basename}_flag_candidates.json"
801
 
802
  with open(output_path, "w", encoding="utf-8") as f:
803
  json.dump(result.to_dict(), f, indent=2)
scripts/test_flag_region_selection.py CHANGED
@@ -6,14 +6,19 @@ This script allows interactive selection of the FLAG indicator region on the sco
6
  The FLAG region is where "1st & 10" / "FLAG" text appears on the scorebug.
7
 
8
  Usage:
 
9
  python scripts/test_flag_region_selection.py
10
 
 
 
 
11
  The script will:
12
- 1. Load sample frames from the test video
13
  2. Display the frame with the existing scorebug region highlighted
14
  3. Allow user to click/drag to select the FLAG region
15
- 4. Save the selected region to data/config/flag_region.json
16
- 5. Display a cropped preview of the selected region
 
17
  """
18
 
19
  import json
@@ -365,18 +370,33 @@ def show_preview(frame: np.ndarray[Any, Any], flag_region_bbox: BBox, scorebug_b
365
  cv2.destroyAllWindows()
366
 
367
 
368
- def save_flag_region(flag_bbox: BBox, source_video: str, scorebug_template: str) -> Path:
 
 
 
 
 
 
 
 
 
 
369
  """
370
- Save the FLAG region configuration to JSON.
 
 
371
 
372
  Args:
373
  flag_bbox: FLAG region bounding box (relative to scorebug)
374
  source_video: Name of the source video
375
  scorebug_template: Name of the scorebug template file
 
376
 
377
  Returns:
378
  Path to the saved config file
379
  """
 
 
380
  config = {
381
  "flag_region": {
382
  "x_offset": flag_bbox.x,
@@ -388,13 +408,34 @@ def save_flag_region(flag_bbox: BBox, source_video: str, scorebug_template: str)
388
  "scorebug_template": scorebug_template,
389
  }
390
 
391
- output_path = DATA_CONFIG_DIR / "flag_region.json"
392
- DATA_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
 
393
 
394
  with open(output_path, "w", encoding="utf-8") as f:
395
  json.dump(config, f, indent=2)
396
 
397
  logger.info("Saved FLAG region config to: %s", output_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  return output_path
399
 
400
 
@@ -421,32 +462,84 @@ def print_instructions() -> None:
421
  input("\nPress Enter to start selection...")
422
 
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  def main() -> int:
425
  """Main entry point for FLAG region selection test."""
 
426
  print_banner()
427
 
428
- # Load existing scorebug config
429
- result = load_saved_scorebug_config()
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  if result is None:
431
  print("\nERROR: No existing scorebug config found.")
432
- print("Please run the main pipeline first to set up the scorebug region.")
433
  return 1
434
 
435
  scorebug_bbox, template_path = result
436
  print(f"\nLoaded scorebug region: {scorebug_bbox.to_tuple()}")
437
 
438
- # Determine video path
439
- video_path = DEFAULT_VIDEO
440
- if not video_path.exists():
441
- print(f"\nERROR: Default video not found: {video_path}")
442
- return 1
443
-
444
- print(f"Using video: {video_path.name}")
445
- print(f"Starting at: {DEFAULT_START_TIME // 60}:{DEFAULT_START_TIME % 60:02d}")
446
 
447
  # Extract sample frames
448
  print("\nExtracting sample frames...")
449
- frames = extract_sample_frames(str(video_path), DEFAULT_START_TIME, num_frames=1, interval=0.0)
450
 
451
  if not frames:
452
  print("ERROR: Failed to extract frames from video")
@@ -486,7 +579,7 @@ def main() -> int:
486
  video_name = video_path.name
487
  template_name = Path(template_path).name if template_path else "unknown"
488
 
489
- save_path = save_flag_region(flag_bbox, video_name, template_name)
490
  print(f"\n✓ FLAG region saved to: {save_path}")
491
  else:
492
  print("\nSelection not saved.")
 
6
  The FLAG region is where "1st & 10" / "FLAG" text appears on the scorebug.
7
 
8
  Usage:
9
+ # Use default Tennessee video
10
  python scripts/test_flag_region_selection.py
11
 
12
+ # Use a specific video
13
+ python scripts/test_flag_region_selection.py --video "full_videos/OSU vs Texas 01.10.25.mkv"
14
+
15
  The script will:
16
+ 1. Load sample frames from the specified video
17
  2. Display the frame with the existing scorebug region highlighted
18
  3. Allow user to click/drag to select the FLAG region
19
+ 4. Save the selected region to output/{video_basename}_flag_config.json
20
+ 5. Optionally update the session config if it exists
21
+ 6. Display a cropped preview of the selected region
22
  """
23
 
24
  import json
 
370
  cv2.destroyAllWindows()
371
 
372
 
373
+ def get_video_basename(video_path: str) -> str:
374
+ """Get a clean basename from video path for config naming."""
375
+ basename = Path(video_path).stem
376
+ for char in [" ", ".", "-"]:
377
+ basename = basename.replace(char, "_")
378
+ while "__" in basename:
379
+ basename = basename.replace("__", "_")
380
+ return basename.strip("_")
381
+
382
+
383
+ def save_flag_region(flag_bbox: BBox, source_video: str, scorebug_template: str, video_path: str) -> Path:
384
  """
385
+ Save the FLAG region configuration to video-specific JSON file.
386
+
387
+ Also updates the session config if it exists.
388
 
389
  Args:
390
  flag_bbox: FLAG region bounding box (relative to scorebug)
391
  source_video: Name of the source video
392
  scorebug_template: Name of the scorebug template file
393
+ video_path: Full path to the video (used for naming)
394
 
395
  Returns:
396
  Path to the saved config file
397
  """
398
+ video_basename = get_video_basename(video_path)
399
+
400
  config = {
401
  "flag_region": {
402
  "x_offset": flag_bbox.x,
 
408
  "scorebug_template": scorebug_template,
409
  }
410
 
411
+ # Save to video-specific file in output directory
412
+ output_path = OUTPUT_DIR / f"{video_basename}_flag_config.json"
413
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
414
 
415
  with open(output_path, "w", encoding="utf-8") as f:
416
  json.dump(config, f, indent=2)
417
 
418
  logger.info("Saved FLAG region config to: %s", output_path)
419
+
420
+ # Also update session config if it exists
421
+ session_config_path = OUTPUT_DIR / f"{video_basename}_config.json"
422
+ if session_config_path.exists():
423
+ try:
424
+ with open(session_config_path, "r", encoding="utf-8") as f:
425
+ session_config = json.load(f)
426
+
427
+ session_config["flag_x_offset"] = flag_bbox.x
428
+ session_config["flag_y_offset"] = flag_bbox.y
429
+ session_config["flag_width"] = flag_bbox.width
430
+ session_config["flag_height"] = flag_bbox.height
431
+
432
+ with open(session_config_path, "w", encoding="utf-8") as f:
433
+ json.dump(session_config, f, indent=2)
434
+
435
+ logger.info("Updated session config: %s", session_config_path)
436
+ except Exception as e:
437
+ logger.warning("Could not update session config: %s", e)
438
+
439
  return output_path
440
 
441
 
 
462
  input("\nPress Enter to start selection...")
463
 
464
 
465
+ def parse_args():
466
+ """Parse command line arguments."""
467
+ import argparse
468
+
469
+ parser = argparse.ArgumentParser(description="Select FLAG region for a video")
470
+ parser.add_argument(
471
+ "--video",
472
+ type=str,
473
+ default=str(DEFAULT_VIDEO),
474
+ help="Path to video file (default: OSU vs Tenn 12.21.24.mkv)",
475
+ )
476
+ parser.add_argument(
477
+ "--start-time",
478
+ type=float,
479
+ default=DEFAULT_START_TIME,
480
+ help="Start time in seconds for frame extraction (default: 38:40)",
481
+ )
482
+ return parser.parse_args()
483
+
484
+
485
+ def load_scorebug_config_for_video(video_path: str) -> Optional[Tuple[BBox, str]]:
486
+ """Load scorebug config for a specific video."""
487
+ video_basename = get_video_basename(video_path)
488
+ session_config_path = OUTPUT_DIR / f"{video_basename}_config.json"
489
+
490
+ if session_config_path.exists():
491
+ with open(session_config_path, "r", encoding="utf-8") as f:
492
+ config = json.load(f)
493
+
494
+ scorebug_bbox = BBox(
495
+ x=config["scorebug_x"],
496
+ y=config["scorebug_y"],
497
+ width=config["scorebug_width"],
498
+ height=config["scorebug_height"],
499
+ )
500
+ template_path = config.get("template_path", "")
501
+ logger.info("Loaded session config from: %s", session_config_path)
502
+ return scorebug_bbox, template_path
503
+
504
+ # Fall back to generic config
505
+ logger.warning("No session config found for video, trying generic config...")
506
+ return load_saved_scorebug_config()
507
+
508
+
509
  def main() -> int:
510
  """Main entry point for FLAG region selection test."""
511
+ args = parse_args()
512
  print_banner()
513
 
514
+ # Determine video path
515
+ video_path = Path(args.video)
516
+ if not video_path.exists():
517
+ # Try relative to project root
518
+ video_path = PROJECT_ROOT / args.video
519
+ if not video_path.exists():
520
+ print(f"\nERROR: Video not found: {args.video}")
521
+ return 1
522
+
523
+ print(f"Using video: {video_path.name}")
524
+ video_basename = get_video_basename(str(video_path))
525
+ print(f"Config basename: {video_basename}")
526
+
527
+ # Load scorebug config for this video
528
+ result = load_scorebug_config_for_video(str(video_path))
529
  if result is None:
530
  print("\nERROR: No existing scorebug config found.")
531
+ print("Please run main.py first to set up the scorebug region for this video.")
532
  return 1
533
 
534
  scorebug_bbox, template_path = result
535
  print(f"\nLoaded scorebug region: {scorebug_bbox.to_tuple()}")
536
 
537
+ start_time = args.start_time
538
+ print(f"Starting at: {int(start_time) // 60}:{int(start_time) % 60:02d}")
 
 
 
 
 
 
539
 
540
  # Extract sample frames
541
  print("\nExtracting sample frames...")
542
+ frames = extract_sample_frames(str(video_path), start_time, num_frames=1, interval=0.0)
543
 
544
  if not frames:
545
  print("ERROR: Failed to extract frames from video")
 
579
  video_name = video_path.name
580
  template_name = Path(template_path).name if template_path else "unknown"
581
 
582
+ save_path = save_flag_region(flag_bbox, video_name, template_name, str(video_path))
583
  print(f"\n✓ FLAG region saved to: {save_path}")
584
  else:
585
  print("\nSelection not saved.")
scripts/test_frame_alignment.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Test frame alignment between different FFmpeg extraction methods."""
3
+
4
+ import sys
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent.parent))
9
+
10
+ import json
11
+ import numpy as np
12
+ from src.video.ffmpeg_reader import FFmpegFrameReader
13
+ from src.readers.flags import FlagReader
14
+
15
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def test_frame_at_timestamp(video_path: str, target_time: float, flag_reader: FlagReader, scorebug_x: int, scorebug_y: int):
20
+ """Extract and test a specific frame."""
21
+ # Calculate absolute flag region
22
+ x = scorebug_x + flag_reader.flag_x_offset
23
+ y = scorebug_y + flag_reader.flag_y_offset
24
+ w = flag_reader.flag_width
25
+ h = flag_reader.flag_height
26
+
27
+ with FFmpegFrameReader(video_path, target_time, target_time + 0.5, 0.5) as reader:
28
+ for timestamp, frame in reader:
29
+ result = flag_reader.read_from_fixed_location(frame, (x, y, w, h))
30
+ yellow_pct = result.yellow_ratio * 100
31
+ logger.info(f" t={timestamp:.1f}s: yellow={yellow_pct:.1f}%, hue={result.mean_hue:.1f}, detected={result.detected}")
32
+ return timestamp, yellow_pct, result.mean_hue
33
+
34
+ return None, 0, 0
35
+
36
+
37
+ def main():
38
+ video_path = "/Users/andytaylor/Documents/Personal/cfb40/full_videos/OSU vs Tenn 12.21.24.mkv"
39
+
40
+ # Load session config
41
+ with open("output/OSU_vs_Tenn_12_21_24_config.json", "r") as f:
42
+ config = json.load(f)
43
+
44
+ # Create flag reader
45
+ flag_reader = FlagReader(
46
+ flag_x_offset=config["flag_x_offset"],
47
+ flag_y_offset=config["flag_y_offset"],
48
+ flag_width=config["flag_width"],
49
+ flag_height=config["flag_height"],
50
+ )
51
+
52
+ scorebug_x = config["scorebug_x"]
53
+ scorebug_y = config["scorebug_y"]
54
+
55
+ logger.info("=" * 80)
56
+ logger.info("FRAME ALIGNMENT TEST")
57
+ logger.info(f"Flag region: offset=({flag_reader.flag_x_offset}, {flag_reader.flag_y_offset})")
58
+ logger.info("=" * 80)
59
+
60
+ # Test 1: Extract frame at 340.5s directly (like diagnostic)
61
+ logger.info("\nTest 1: Extract frame at 340.5s directly (start_time=340.5)")
62
+ test_frame_at_timestamp(video_path, 340.5, flag_reader, scorebug_x, scorebug_y)
63
+
64
+ # Test 2: Extract frame 681 from start (like pipeline chunk 0)
65
+ logger.info("\nTest 2: Extract from start and iterate to frame 681 (340.5s)")
66
+ frame_count = 0
67
+ target_frame = 681 # 340.5 / 0.5 = 681
68
+
69
+ # Only extract 5 frames around the target to save time
70
+ start_time = (target_frame - 2) * 0.5 # 339.5s
71
+ end_time = (target_frame + 3) * 0.5 # 342.0s
72
+
73
+ x = scorebug_x + flag_reader.flag_x_offset
74
+ y = scorebug_y + flag_reader.flag_y_offset
75
+ w = flag_reader.flag_width
76
+ h = flag_reader.flag_height
77
+
78
+ with FFmpegFrameReader(video_path, start_time, end_time, 0.5) as reader:
79
+ for timestamp, frame in reader:
80
+ result = flag_reader.read_from_fixed_location(frame, (x, y, w, h))
81
+ yellow_pct = result.yellow_ratio * 100
82
+ logger.info(f" t={timestamp:.1f}s: yellow={yellow_pct:.1f}%, hue={result.mean_hue:.1f}, detected={result.detected}")
83
+
84
+ # Test 3: Extract from time 0 and iterate to 340.5s (exactly like pipeline)
85
+ # This would take too long, so let's just test the first few frames to see if there's a pattern
86
+ logger.info("\nTest 3: Extract first 10 frames from time 0 (like pipeline chunk 0)")
87
+ with FFmpegFrameReader(video_path, 0.0, 5.0, 0.5) as reader:
88
+ for timestamp, frame in reader:
89
+ # Just print frame info, no flag detection (not expected at start of video)
90
+ logger.info(f" t={timestamp:.1f}s: frame shape={frame.shape}")
91
+
92
+ logger.info("\n" + "=" * 80)
93
+ logger.info("CONCLUSION: If Test 1 and Test 2 show same results, frame alignment is correct")
94
+ logger.info("=" * 80)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
scripts/test_timeout_at_transitions.py CHANGED
@@ -7,15 +7,23 @@ This script:
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
@@ -33,6 +41,19 @@ from detection.timeouts import DetectTimeouts
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 = [
@@ -87,12 +108,27 @@ def test_timeout_at_transitions():
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():
@@ -100,7 +136,6 @@ def test_timeout_at_transitions():
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)
 
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 (Tennessee video):
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
+ Usage:
19
+ # Default Tennessee video
20
+ python scripts/test_timeout_at_transitions.py
21
+
22
+ # Specific video
23
+ python scripts/test_timeout_at_transitions.py --video "full_videos/OSU vs Texas 01.10.25.mkv"
24
  """
25
 
26
+ import argparse
27
  import json
28
  import logging
29
  import sys
 
41
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
42
  logger = logging.getLogger(__name__)
43
 
44
+ PROJECT_ROOT = Path(__file__).parent.parent
45
+ OUTPUT_DIR = PROJECT_ROOT / "output"
46
+
47
+
48
+ def get_video_basename(video_path: str) -> str:
49
+ """Get a clean basename from video path for config naming."""
50
+ basename = Path(video_path).stem
51
+ for char in [" ", ".", "-"]:
52
+ basename = basename.replace(char, "_")
53
+ while "__" in basename:
54
+ basename = basename.replace("__", "_")
55
+ return basename.strip("_")
56
+
57
 
58
  # Ground truth timeouts (timestamp in seconds, team)
59
  GROUND_TRUTH_TIMEOUTS = [
 
108
  transitions = cache["transitions_40_to_25"]
109
  logger.info("Loaded %d transitions from cache", len(transitions))
110
 
111
+ # Parse arguments
112
+ parser = argparse.ArgumentParser(description="Test timeout tracking at transitions")
113
+ parser.add_argument("--video", type=str, default="full_videos/OSU vs Tenn 12.21.24.mkv", help="Path to video file")
114
+ args = parser.parse_args()
115
+
116
+ video_path = args.video
117
+ video_basename = get_video_basename(video_path)
118
+
119
+ # Try video-specific timeout config first
120
+ config_path = OUTPUT_DIR / f"{video_basename}_timeout_config.json"
121
+ if not config_path.exists():
122
+ # Fall back to generic config
123
+ config_path = Path("data/config/timeout_tracker_region.json")
124
+
125
  if not config_path.exists():
126
  logger.error("Timeout config not found: %s", config_path)
127
+ logger.error("Try running main.py first to generate timeout config for this video")
128
  return
129
 
130
+ logger.info("Using timeout config: %s", config_path)
131
+
132
  # Initialize timeout tracker
133
  tracker = DetectTimeouts(config_path=str(config_path))
134
  if not tracker.is_configured():
 
136
  return
137
 
138
  # Open video
 
139
  cap = cv2.VideoCapture(video_path)
140
  if not cap.isOpened():
141
  logger.error("Could not open video: %s", video_path)
scripts/verify_config_loading.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Verify what configuration is being loaded for each video."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ OUTPUT_DIR = Path("output")
8
+ DATA_CONFIG_DIR = Path("data/config")
9
+
10
+
11
+ def get_video_basename(video_name: str) -> str:
12
+ """Get a clean basename from video name."""
13
+ basename = Path(video_name).stem
14
+ for char in [" ", ".", "-"]:
15
+ basename = basename.replace(char, "_")
16
+ while "__" in basename:
17
+ basename = basename.replace("__", "_")
18
+ return basename.strip("_")
19
+
20
+
21
+ def print_video_config(video_name: str):
22
+ """Print configuration for a specific video."""
23
+ basename = get_video_basename(video_name)
24
+ print(f"\n{'='*60}")
25
+ print(f"VIDEO: {video_name}")
26
+ print(f"Config basename: {basename}")
27
+ print("=" * 60)
28
+
29
+ # Check session config
30
+ session_config_path = OUTPUT_DIR / f"{basename}_config.json"
31
+ if session_config_path.exists():
32
+ with open(session_config_path, "r") as f:
33
+ config = json.load(f)
34
+ print(f"\n Session Config ({session_config_path.name}):")
35
+ print(f" Scorebug: ({config.get('scorebug_x')}, {config.get('scorebug_y')})")
36
+ print(f" Flag offset: ({config.get('flag_x_offset')}, {config.get('flag_y_offset')})")
37
+ print(f" Flag size: {config.get('flag_width')}x{config.get('flag_height')}")
38
+ else:
39
+ print(f"\n Session Config: NOT FOUND (expected: {session_config_path})")
40
+
41
+ # Check timeout config
42
+ timeout_config_path = OUTPUT_DIR / f"{basename}_timeout_config.json"
43
+ if timeout_config_path.exists():
44
+ print(f" Timeout Config: {timeout_config_path.name}")
45
+ else:
46
+ print(f" Timeout Config: NOT FOUND")
47
+
48
+ # Check playclock config
49
+ playclock_config_path = OUTPUT_DIR / f"{basename}_playclock_config.json"
50
+ if playclock_config_path.exists():
51
+ print(f" Playclock Config: {playclock_config_path.name}")
52
+ else:
53
+ print(f" Playclock Config: NOT FOUND")
54
+
55
+
56
+ def main():
57
+ print("=" * 80)
58
+ print("CONFIGURATION FILES STATUS")
59
+ print("=" * 80)
60
+
61
+ # Known videos
62
+ videos = [
63
+ "OSU vs Tenn 12.21.24.mkv",
64
+ "OSU vs Texas 01.10.25.mkv",
65
+ "OSU vs Oregon 01.01.25.mkv",
66
+ "OSU vs ND 01.20.25.mp4",
67
+ ]
68
+
69
+ for video in videos:
70
+ print_video_config(video)
71
+
72
+ # Check for GENERIC configs that might cause confusion
73
+ print("\n" + "=" * 80)
74
+ print("WARNING: GENERIC CONFIGS (may cause video cross-contamination)")
75
+ print("=" * 80)
76
+
77
+ generic_configs = [
78
+ DATA_CONFIG_DIR / "flag_region.json",
79
+ DATA_CONFIG_DIR / "play_clock_region.json",
80
+ DATA_CONFIG_DIR / "timeout_tracker_region.json",
81
+ ]
82
+
83
+ for config_path in generic_configs:
84
+ if config_path.exists():
85
+ with open(config_path, "r") as f:
86
+ config = json.load(f)
87
+ source = config.get("source_video", "UNKNOWN")
88
+ print(f" {config_path.name}: source={source}")
89
+ print(f" -> Scripts should use video-specific configs instead!")
90
+ else:
91
+ print(f" {config_path.name}: NOT FOUND (OK)")
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
src/pipeline/models.py CHANGED
@@ -103,6 +103,7 @@ class ParallelProcessingConfig(BaseModel):
103
  fixed_scorebug_coords: Tuple[int, int, int, int] = Field(..., description="(x, y, w, h) for scorebug region")
104
  template_library_path: Optional[str] = Field(None, description="Path to template library directory")
105
  timeout_config_path: Optional[str] = Field(None, description="Path to timeout tracker config")
 
106
  # FLAG region config (offsets relative to scorebug)
107
  flag_x_offset: Optional[int] = Field(None, description="FLAG region X offset from scorebug")
108
  flag_y_offset: Optional[int] = Field(None, description="FLAG region Y offset from scorebug")
 
103
  fixed_scorebug_coords: Tuple[int, int, int, int] = Field(..., description="(x, y, w, h) for scorebug region")
104
  template_library_path: Optional[str] = Field(None, description="Path to template library directory")
105
  timeout_config_path: Optional[str] = Field(None, description="Path to timeout tracker config")
106
+ scorebug_template_path: Optional[str] = Field(None, description="Path to scorebug template image for verification")
107
  # FLAG region config (offsets relative to scorebug)
108
  flag_x_offset: Optional[int] = Field(None, description="FLAG region X offset from scorebug")
109
  flag_y_offset: Optional[int] = Field(None, description="FLAG region Y offset from scorebug")
src/pipeline/parallel.py CHANGED
@@ -51,8 +51,9 @@ def _init_chunk_detectors(config: ParallelProcessingConfig) -> Tuple[Any, Any, A
51
  from readers import FlagReader, ReadPlayClock
52
  from setup import DigitTemplateLibrary, PlayClockRegionConfig, PlayClockRegionExtractor
53
 
54
- # Create scorebug detector with fixed region
55
- scorebug_detector = DetectScoreBug(template_path=None, use_split_detection=True)
 
56
  scorebug_detector.set_fixed_region(config.fixed_scorebug_coords)
57
 
58
  # Create play clock region extractor
@@ -115,6 +116,7 @@ def _process_frame(
115
  flag_reader: Any,
116
  stats: Dict[str, int],
117
  fixed_playclock_coords: Optional[Tuple[int, int, int, int]] = None,
 
118
  ) -> Dict[str, Any]:
119
  """
120
  Process a single video frame and extract detection results.
@@ -129,21 +131,32 @@ def _process_frame(
129
  flag_reader: Initialized FlagReader (or None).
130
  stats: Mutable dict to update with detection statistics.
131
  fixed_playclock_coords: Optional fixed play clock coordinates for padded matching.
 
132
 
133
  Returns:
134
  Dict with frame detection results.
135
  """
136
  # Detect scorebug (fast path with fixed region)
 
137
  scorebug = scorebug_detector.detect(img)
138
 
 
 
 
 
 
139
  # Initialize frame result using shared factory
 
 
140
  frame_result = create_frame_result(
141
  timestamp=timestamp,
142
- scorebug_detected=scorebug.detected,
143
- scorebug_bbox=scorebug.bbox if scorebug.detected else None,
144
  )
 
 
145
 
146
- if scorebug.detected:
147
  stats["frames_with_scorebug"] += 1
148
 
149
  # Read timeout indicators if available
@@ -154,8 +167,9 @@ def _process_frame(
154
  frame_result["timeout_confidence"] = timeout_reading.confidence
155
 
156
  # Read FLAG indicator if reader is configured
157
- if flag_reader:
158
- flag_reading = flag_reader.read(img, scorebug.bbox)
 
159
  frame_result["flag_detected"] = flag_reading.detected
160
  frame_result["flag_yellow_ratio"] = flag_reading.yellow_ratio
161
  frame_result["flag_mean_hue"] = flag_reading.mean_hue
@@ -168,9 +182,9 @@ def _process_frame(
168
  frame_result["clock_value"] = clock_result.value
169
  if clock_result.detected:
170
  stats["frames_with_clock"] += 1
171
- elif template_reader:
172
  # Fallback: extract region then match (for non-fixed-coords mode)
173
- play_clock_region = clock_reader.extract_region(img, scorebug.bbox)
174
  if play_clock_region is not None:
175
  clock_result = template_reader.read(play_clock_region)
176
  frame_result["clock_detected"] = clock_result.detected
@@ -190,10 +204,11 @@ def _process_chunk(
190
  progress_dict: Optional[MutableMapping[int, Any]] = None,
191
  ) -> ChunkResult:
192
  """
193
- Process a single video chunk using OpenCV.
194
 
195
  This function runs in a separate process and must be self-contained.
196
- It opens its own video file handle and creates its own detector instances.
 
197
 
198
  Args:
199
  chunk_id: Identifier for this chunk (for logging).
@@ -206,28 +221,15 @@ def _process_chunk(
206
  ChunkResult with processing results.
207
  """
208
  # pylint: disable=import-outside-toplevel
209
- # cv2 import must be inside function for multiprocessing - each subprocess
210
- # needs its own fresh import to avoid issues with OpenCV's internal state
211
- import cv2
212
 
213
  t_start = time.perf_counter()
214
 
215
  # Initialize all detection components
216
  scorebug_detector, clock_reader, template_reader, timeout_tracker, flag_reader = _init_chunk_detectors(config)
217
 
218
- # Open video and seek to start
219
- t_io_start = time.perf_counter()
220
- cap = cv2.VideoCapture(config.video_path)
221
- if not cap.isOpened():
222
- raise RuntimeError(f"Could not open video: {config.video_path}")
223
-
224
- fps = cap.get(cv2.CAP_PROP_FPS)
225
- frame_skip = int(config.frame_interval * fps)
226
- start_frame = int(chunk_start * fps)
227
- end_frame = int(chunk_end * fps)
228
- cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
229
- io_time = time.perf_counter() - t_io_start
230
-
231
  # Initialize processing state
232
  frame_data: List[Dict[str, Any]] = []
233
  stats = {"total_frames": 0, "frames_with_scorebug": 0, "frames_with_clock": 0}
@@ -237,45 +239,32 @@ def _process_chunk(
237
  if progress_dict is not None:
238
  progress_dict[chunk_id] = {"frames": 0, "total": total_expected_frames, "status": "running"}
239
 
240
- # Process frames in chunk
241
- current_frame = start_frame
242
- while current_frame < end_frame:
243
- # Read frame with I/O timing
244
- t_io_start = time.perf_counter()
245
- ret, img = cap.read()
246
- io_time += time.perf_counter() - t_io_start
247
-
248
- if not ret:
249
- break
250
-
251
- # Process this frame
252
- timestamp = current_frame / fps
253
- stats["total_frames"] += 1
254
- frame_result = _process_frame(
255
- img,
256
- timestamp,
257
- scorebug_detector,
258
- clock_reader,
259
- template_reader,
260
- timeout_tracker,
261
- flag_reader,
262
- stats,
263
- fixed_playclock_coords=config.fixed_playclock_coords,
264
- )
265
- frame_data.append(frame_result)
266
-
267
- # Update progress
268
- if progress_dict is not None:
269
- progress_dict[chunk_id] = {"frames": stats["total_frames"], "total": total_expected_frames, "status": "running"}
270
 
271
- # Skip to next sample frame
272
- t_io_start = time.perf_counter()
273
- for _ in range(frame_skip - 1):
274
- cap.grab()
275
- io_time += time.perf_counter() - t_io_start
276
- current_frame += frame_skip
277
 
278
- cap.release()
 
279
 
280
  # Mark chunk as complete
281
  if progress_dict is not None:
 
51
  from readers import FlagReader, ReadPlayClock
52
  from setup import DigitTemplateLibrary, PlayClockRegionConfig, PlayClockRegionExtractor
53
 
54
+ # Create scorebug detector with fixed region and template for verification
55
+ # Template is needed to correctly detect when scorebug is NOT present (replays, commercials)
56
+ scorebug_detector = DetectScoreBug(template_path=config.scorebug_template_path, use_split_detection=True)
57
  scorebug_detector.set_fixed_region(config.fixed_scorebug_coords)
58
 
59
  # Create play clock region extractor
 
116
  flag_reader: Any,
117
  stats: Dict[str, int],
118
  fixed_playclock_coords: Optional[Tuple[int, int, int, int]] = None,
119
+ fixed_scorebug_coords: Optional[Tuple[int, int, int, int]] = None,
120
  ) -> Dict[str, Any]:
121
  """
122
  Process a single video frame and extract detection results.
 
131
  flag_reader: Initialized FlagReader (or None).
132
  stats: Mutable dict to update with detection statistics.
133
  fixed_playclock_coords: Optional fixed play clock coordinates for padded matching.
134
+ fixed_scorebug_coords: Optional fixed scorebug coordinates.
135
 
136
  Returns:
137
  Dict with frame detection results.
138
  """
139
  # Detect scorebug (fast path with fixed region)
140
+ # In fixed region mode, this does template matching to verify scorebug presence
141
  scorebug = scorebug_detector.detect(img)
142
 
143
+ # For normal play detection, assume scorebug is present at fixed location
144
+ # (don't gate normal detection on template matching - only use for FLAG validation)
145
+ scorebug_assumed = fixed_scorebug_coords is not None
146
+ scorebug_bbox = fixed_scorebug_coords if scorebug_assumed else (scorebug.bbox if scorebug.detected else None)
147
+
148
  # Initialize frame result using shared factory
149
+ # scorebug_detected = True for fixed region mode (for backward compatibility)
150
+ # scorebug_verified = actual template match result (for FLAG validation)
151
  frame_result = create_frame_result(
152
  timestamp=timestamp,
153
+ scorebug_detected=scorebug_assumed or scorebug.detected,
154
+ scorebug_bbox=scorebug_bbox,
155
  )
156
+ # Track actual template verification for FLAG validation
157
+ frame_result["scorebug_verified"] = scorebug.detected
158
 
159
+ if scorebug_assumed or scorebug.detected:
160
  stats["frames_with_scorebug"] += 1
161
 
162
  # Read timeout indicators if available
 
167
  frame_result["timeout_confidence"] = timeout_reading.confidence
168
 
169
  # Read FLAG indicator if reader is configured
170
+ # Always read FLAG (will be validated later using scorebug_verified)
171
+ if flag_reader and scorebug_bbox:
172
+ flag_reading = flag_reader.read(img, scorebug_bbox)
173
  frame_result["flag_detected"] = flag_reading.detected
174
  frame_result["flag_yellow_ratio"] = flag_reading.yellow_ratio
175
  frame_result["flag_mean_hue"] = flag_reading.mean_hue
 
182
  frame_result["clock_value"] = clock_result.value
183
  if clock_result.detected:
184
  stats["frames_with_clock"] += 1
185
+ elif template_reader and scorebug_bbox:
186
  # Fallback: extract region then match (for non-fixed-coords mode)
187
+ play_clock_region = clock_reader.extract_region(img, scorebug_bbox)
188
  if play_clock_region is not None:
189
  clock_result = template_reader.read(play_clock_region)
190
  frame_result["clock_detected"] = clock_result.detected
 
204
  progress_dict: Optional[MutableMapping[int, Any]] = None,
205
  ) -> ChunkResult:
206
  """
207
+ Process a single video chunk using FFmpeg pipe for accurate VFR handling.
208
 
209
  This function runs in a separate process and must be self-contained.
210
+ It uses FFmpeg for frame extraction which correctly handles Variable Frame Rate
211
+ videos where OpenCV seeking would return frames out of chronological order.
212
 
213
  Args:
214
  chunk_id: Identifier for this chunk (for logging).
 
221
  ChunkResult with processing results.
222
  """
223
  # pylint: disable=import-outside-toplevel
224
+ # Import must be inside function for multiprocessing - each subprocess
225
+ # needs its own fresh imports to avoid pickling errors
226
+ from video.ffmpeg_reader import FFmpegFrameReader
227
 
228
  t_start = time.perf_counter()
229
 
230
  # Initialize all detection components
231
  scorebug_detector, clock_reader, template_reader, timeout_tracker, flag_reader = _init_chunk_detectors(config)
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  # Initialize processing state
234
  frame_data: List[Dict[str, Any]] = []
235
  stats = {"total_frames": 0, "frames_with_scorebug": 0, "frames_with_clock": 0}
 
239
  if progress_dict is not None:
240
  progress_dict[chunk_id] = {"frames": 0, "total": total_expected_frames, "status": "running"}
241
 
242
+ # Use FFmpeg pipe for accurate timestamp handling (handles VFR videos correctly)
243
+ # This is ~36x faster than OpenCV seeking and produces correct frame order
244
+ with FFmpegFrameReader(config.video_path, chunk_start, chunk_end, config.frame_interval) as reader:
245
+ for timestamp, img in reader:
246
+ # Process this frame
247
+ stats["total_frames"] += 1
248
+ frame_result = _process_frame(
249
+ img,
250
+ timestamp,
251
+ scorebug_detector,
252
+ clock_reader,
253
+ template_reader,
254
+ timeout_tracker,
255
+ flag_reader,
256
+ stats,
257
+ fixed_playclock_coords=config.fixed_playclock_coords,
258
+ fixed_scorebug_coords=config.fixed_scorebug_coords,
259
+ )
260
+ frame_data.append(frame_result)
 
 
 
 
 
 
 
 
 
 
 
261
 
262
+ # Update progress
263
+ if progress_dict is not None:
264
+ progress_dict[chunk_id] = {"frames": stats["total_frames"], "total": total_expected_frames, "status": "running"}
 
 
 
265
 
266
+ # Get I/O timing from reader
267
+ _, io_time = reader.get_stats()
268
 
269
  # Mark chunk as complete
270
  if progress_dict is not None:
src/pipeline/play_extractor.py CHANGED
@@ -738,6 +738,7 @@ class PlayExtractor:
738
  fixed_scorebug_coords=self.config.fixed_scorebug_coords,
739
  template_library_path=str(template_path) if template_path else None,
740
  timeout_config_path=timeout_config_path,
 
741
  flag_x_offset=flag_x_offset,
742
  flag_y_offset=flag_y_offset,
743
  flag_width=flag_width,
@@ -790,12 +791,14 @@ class PlayExtractor:
790
  confidence=frame.get("timeout_confidence", 0.0),
791
  )
792
  # Create FLAG info for penalty flag tracking
 
793
  flag_info = None
794
  if frame.get("flag_detected") is not None:
795
  flag_info = FlagInfo(
796
  detected=frame.get("flag_detected", False),
797
  yellow_ratio=frame.get("flag_yellow_ratio", 0.0),
798
  mean_hue=frame.get("flag_mean_hue", 0.0),
 
799
  )
800
  self.state_machine.update(frame["timestamp"], scorebug, clock_reading, timeout_info, flag_info)
801
  timing["state_machine"] = time.perf_counter() - t_sm_start
 
738
  fixed_scorebug_coords=self.config.fixed_scorebug_coords,
739
  template_library_path=str(template_path) if template_path else None,
740
  timeout_config_path=timeout_config_path,
741
+ scorebug_template_path=self.config.template_path, # For scorebug verification during FLAG detection
742
  flag_x_offset=flag_x_offset,
743
  flag_y_offset=flag_y_offset,
744
  flag_width=flag_width,
 
791
  confidence=frame.get("timeout_confidence", 0.0),
792
  )
793
  # Create FLAG info for penalty flag tracking
794
+ # scorebug_verified is used to filter false positives during replays/commercials
795
  flag_info = None
796
  if frame.get("flag_detected") is not None:
797
  flag_info = FlagInfo(
798
  detected=frame.get("flag_detected", False),
799
  yellow_ratio=frame.get("flag_yellow_ratio", 0.0),
800
  mean_hue=frame.get("flag_mean_hue", 0.0),
801
+ scorebug_verified=frame.get("scorebug_verified", True),
802
  )
803
  self.state_machine.update(frame["timestamp"], scorebug, clock_reading, timeout_info, flag_info)
804
  timing["state_machine"] = time.perf_counter() - t_sm_start
src/pipeline/template_builder_pass.py CHANGED
@@ -127,6 +127,8 @@ def _scan_video(
127
  accounts for rendering changes that occur during broadcasts (different score
128
  states, different lighting, etc.).
129
 
 
 
130
  Args:
131
  config: Detection configuration
132
  clock_reader: Play clock region extractor
@@ -140,7 +142,9 @@ def _scan_video(
140
  Returns:
141
  Tuple of (valid_samples, frames_scanned, frames_with_scorebug)
142
  """
143
- # Open video
 
 
144
  cap = cv2.VideoCapture(config.video_path)
145
  if not cap.isOpened():
146
  logger.error("Pass 0: Could not open video")
@@ -149,6 +153,7 @@ def _scan_video(
149
  fps = cap.get(cv2.CAP_PROP_FPS)
150
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
151
  video_duration = total_frames / fps
 
152
 
153
  # Determine effective range to scan (respect start_time/end_time if set)
154
  effective_start = config.start_time if config.start_time else 0.0
@@ -166,7 +171,8 @@ def _scan_video(
166
 
167
  # Frames to scan per sample point
168
  frames_per_point = max_scan_frames // len(sample_points)
169
- frame_skip = int(config.frame_interval * fps)
 
170
 
171
  logger.info(" Multi-point template building enabled (4 sample points)")
172
  logger.info(" Sample points: %s", [f"{t:.0f}s" for t in sample_points])
@@ -176,25 +182,16 @@ def _scan_video(
176
  frames_scanned = 0
177
  frames_with_scorebug = 0
178
 
179
- try:
180
- for point_idx, start_time in enumerate(sample_points):
181
- # Calculate start frame for this sample point
182
- start_frame = int(start_time * fps)
183
- cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
184
 
185
- point_frames_scanned = 0
186
- point_valid_samples = 0
187
-
188
- logger.info(" Scanning from %.0fs (point %d/%d)...", start_time, point_idx + 1, len(sample_points))
189
-
190
- while point_frames_scanned < frames_per_point:
191
- ret, frame = cap.read()
192
- if not ret:
193
- break
194
-
195
- current_frame = int(cap.get(cv2.CAP_PROP_POS_FRAMES)) - 1
196
- current_time = current_frame / fps
197
 
 
 
 
198
  frames_scanned += 1
199
  point_frames_scanned += 1
200
 
@@ -224,14 +221,11 @@ def _scan_video(
224
  logger.info(" Completion criteria met!")
225
  return valid_samples, frames_scanned, frames_with_scorebug
226
 
227
- # Skip frames
228
- for _ in range(frame_skip - 1):
229
- cap.grab()
230
-
231
- logger.info(" Point %d complete: %d samples from %d frames", point_idx + 1, point_valid_samples, point_frames_scanned)
232
 
233
- finally:
234
- cap.release()
235
 
236
  return valid_samples, frames_scanned, frames_with_scorebug
237
 
 
127
  accounts for rendering changes that occur during broadcasts (different score
128
  states, different lighting, etc.).
129
 
130
+ Uses FFmpeg for frame extraction to correctly handle VFR videos.
131
+
132
  Args:
133
  config: Detection configuration
134
  clock_reader: Play clock region extractor
 
142
  Returns:
143
  Tuple of (valid_samples, frames_scanned, frames_with_scorebug)
144
  """
145
+ from video.ffmpeg_reader import FFmpegFrameReader, get_video_dimensions
146
+
147
+ # Get video duration using OpenCV (quick metadata read)
148
  cap = cv2.VideoCapture(config.video_path)
149
  if not cap.isOpened():
150
  logger.error("Pass 0: Could not open video")
 
153
  fps = cap.get(cv2.CAP_PROP_FPS)
154
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
155
  video_duration = total_frames / fps
156
+ cap.release()
157
 
158
  # Determine effective range to scan (respect start_time/end_time if set)
159
  effective_start = config.start_time if config.start_time else 0.0
 
171
 
172
  # Frames to scan per sample point
173
  frames_per_point = max_scan_frames // len(sample_points)
174
+ # Duration to scan per point based on frame interval
175
+ duration_per_point = frames_per_point * config.frame_interval
176
 
177
  logger.info(" Multi-point template building enabled (4 sample points)")
178
  logger.info(" Sample points: %s", [f"{t:.0f}s" for t in sample_points])
 
182
  frames_scanned = 0
183
  frames_with_scorebug = 0
184
 
185
+ for point_idx, start_time in enumerate(sample_points):
186
+ point_frames_scanned = 0
187
+ point_valid_samples = 0
188
+ end_time_point = min(start_time + duration_per_point, effective_end)
 
189
 
190
+ logger.info(" Scanning from %.0fs (point %d/%d)...", start_time, point_idx + 1, len(sample_points))
 
 
 
 
 
 
 
 
 
 
 
191
 
192
+ # Use FFmpeg pipe for accurate VFR handling
193
+ with FFmpegFrameReader(config.video_path, start_time, end_time_point, config.frame_interval) as frame_reader:
194
+ for current_time, frame in frame_reader:
195
  frames_scanned += 1
196
  point_frames_scanned += 1
197
 
 
221
  logger.info(" Completion criteria met!")
222
  return valid_samples, frames_scanned, frames_with_scorebug
223
 
224
+ # Stop if we've scanned enough frames from this point
225
+ if point_frames_scanned >= frames_per_point:
226
+ break
 
 
227
 
228
+ logger.info(" Point %d complete: %d samples from %d frames", point_idx + 1, point_valid_samples, point_frames_scanned)
 
229
 
230
  return valid_samples, frames_scanned, frames_with_scorebug
231
 
src/tracking/flag_tracker.py CHANGED
@@ -36,15 +36,29 @@ class FlagEventData:
36
  frame_count: int = 0
37
  yellow_sum: float = 0.0
38
  hue_sum: float = 0.0
 
39
 
40
- def update(self, yellow_ratio: float, mean_hue: float) -> None:
41
- """Update running statistics with a new frame."""
 
 
 
 
 
 
42
  self.frame_count += 1
43
  self.yellow_sum += yellow_ratio
44
  self.hue_sum += mean_hue
45
  self.peak_yellow_ratio = max(self.peak_yellow_ratio, yellow_ratio)
46
  self.avg_yellow_ratio = self.yellow_sum / self.frame_count
47
  self.avg_hue = self.hue_sum / self.frame_count
 
 
 
 
 
 
 
48
 
49
 
50
  @dataclass
@@ -93,9 +107,10 @@ class FlagTracker:
93
  GAP_TOLERANCE = 12.0 # Maximum gap (seconds) between FLAG sightings to consider same event
94
  MIN_PEAK_YELLOW = 0.70 # Real FLAGs peak at 80%+, false positives at 30-40%
95
  MIN_AVG_YELLOW = 0.60 # Real FLAGs average 70%+, false positives much lower
96
- # Hue filtering: Real FLAG yellow has hue ~28-29, orange ~16-17, other graphics ~24-25
97
- MIN_MEAN_HUE = 28.0 # Ground truth FLAGs have hue >= 28
98
  MAX_MEAN_HUE = 31.0 # Reject lime-green graphics (hue > 31)
 
99
 
100
  def __init__(
101
  self,
@@ -105,6 +120,7 @@ class FlagTracker:
105
  min_avg_yellow: float = MIN_AVG_YELLOW,
106
  min_mean_hue: float = MIN_MEAN_HUE,
107
  max_mean_hue: float = MAX_MEAN_HUE,
 
108
  ):
109
  """
110
  Initialize the FLAG tracker.
@@ -116,6 +132,7 @@ class FlagTracker:
116
  min_avg_yellow: Minimum average yellow ratio required
117
  min_mean_hue: Minimum mean hue required (rejects orange/other yellow graphics)
118
  max_mean_hue: Maximum mean hue allowed (rejects lime-green graphics)
 
119
  """
120
  self.min_flag_duration = min_flag_duration
121
  self.gap_tolerance = gap_tolerance
@@ -123,6 +140,7 @@ class FlagTracker:
123
  self.min_avg_yellow = min_avg_yellow
124
  self.min_mean_hue = min_mean_hue
125
  self.max_mean_hue = max_mean_hue
 
126
  self._state = FlagTrackerState()
127
  self._play_count = 0 # Running count for play numbering
128
  self._last_flag_seen_at: Optional[float] = None # For gap tolerance
@@ -177,7 +195,7 @@ class FlagTracker:
177
  # No scorebug (e.g., replay) - use gap tolerance
178
  return self._handle_no_scorebug(timestamp)
179
 
180
- def _handle_flag_detected(self, timestamp: float, flag_info: FlagInfo) -> Optional[PlayEvent]: # pylint: disable=useless-return
181
  """Handle FLAG being detected."""
182
  self._last_flag_seen_at = timestamp
183
 
@@ -193,9 +211,9 @@ class FlagTracker:
193
  flag_info.mean_hue,
194
  )
195
 
196
- # Update current FLAG statistics
197
  if self._state.current_flag is not None:
198
- self._state.current_flag.update(flag_info.yellow_ratio, flag_info.mean_hue)
199
 
200
  return None
201
 
@@ -254,13 +272,16 @@ class FlagTracker:
254
  # Store completed flag data
255
  self._state.completed_flags.append(self._state.current_flag)
256
 
 
 
257
  logger.info(
258
- "FLAG EVENT ended at %.1fs (duration=%.1fs, peak=%.0f%%, avg=%.0f%%, hue=%.1f)",
259
  self._state.current_flag.end_time,
260
  duration,
261
  peak_yellow * 100,
262
  avg_yellow * 100,
263
  avg_hue,
 
264
  )
265
 
266
  # Check if FLAG event meets all criteria to become a FLAG PLAY
@@ -282,6 +303,9 @@ class FlagTracker:
282
  if avg_hue > self.max_mean_hue:
283
  reject_reasons.append(f"hue {avg_hue:.1f} > {self.max_mean_hue:.1f} (lime-green, not yellow)")
284
 
 
 
 
285
  if reject_reasons:
286
  logger.debug(
287
  "FLAG event rejected: %s",
 
36
  frame_count: int = 0
37
  yellow_sum: float = 0.0
38
  hue_sum: float = 0.0
39
+ scorebug_frames: int = 0 # Frames where scorebug was detected
40
 
41
+ def update(self, yellow_ratio: float, mean_hue: float, scorebug_verified: bool = True) -> None:
42
+ """Update running statistics with a new frame.
43
+
44
+ Args:
45
+ yellow_ratio: Yellow pixel ratio for this frame
46
+ mean_hue: Mean hue of yellow pixels
47
+ scorebug_verified: Whether scorebug was verified present via template matching
48
+ """
49
  self.frame_count += 1
50
  self.yellow_sum += yellow_ratio
51
  self.hue_sum += mean_hue
52
  self.peak_yellow_ratio = max(self.peak_yellow_ratio, yellow_ratio)
53
  self.avg_yellow_ratio = self.yellow_sum / self.frame_count
54
  self.avg_hue = self.hue_sum / self.frame_count
55
+ if scorebug_verified:
56
+ self.scorebug_frames += 1
57
+
58
+ @property
59
+ def scorebug_ratio(self) -> float:
60
+ """Ratio of frames where scorebug was detected."""
61
+ return self.scorebug_frames / self.frame_count if self.frame_count > 0 else 0.0
62
 
63
 
64
  @dataclass
 
107
  GAP_TOLERANCE = 12.0 # Maximum gap (seconds) between FLAG sightings to consider same event
108
  MIN_PEAK_YELLOW = 0.70 # Real FLAGs peak at 80%+, false positives at 30-40%
109
  MIN_AVG_YELLOW = 0.60 # Real FLAGs average 70%+, false positives much lower
110
+ # Hue filtering: Real FLAG yellow has hue ~26-30, orange ~16-17, other graphics ~24-25
111
+ MIN_MEAN_HUE = 25.0 # Ground truth FLAGs have hue >= 25 (lowered from 28)
112
  MAX_MEAN_HUE = 31.0 # Reject lime-green graphics (hue > 31)
113
+ MIN_SCOREBUG_RATIO = 0.50 # Require scorebug in at least 50% of FLAG frames (filters replays/commercials)
114
 
115
  def __init__(
116
  self,
 
120
  min_avg_yellow: float = MIN_AVG_YELLOW,
121
  min_mean_hue: float = MIN_MEAN_HUE,
122
  max_mean_hue: float = MAX_MEAN_HUE,
123
+ min_scorebug_ratio: float = MIN_SCOREBUG_RATIO,
124
  ):
125
  """
126
  Initialize the FLAG tracker.
 
132
  min_avg_yellow: Minimum average yellow ratio required
133
  min_mean_hue: Minimum mean hue required (rejects orange/other yellow graphics)
134
  max_mean_hue: Maximum mean hue allowed (rejects lime-green graphics)
135
+ min_scorebug_ratio: Minimum ratio of frames with scorebug present (filters replays/commercials)
136
  """
137
  self.min_flag_duration = min_flag_duration
138
  self.gap_tolerance = gap_tolerance
 
140
  self.min_avg_yellow = min_avg_yellow
141
  self.min_mean_hue = min_mean_hue
142
  self.max_mean_hue = max_mean_hue
143
+ self.min_scorebug_ratio = min_scorebug_ratio
144
  self._state = FlagTrackerState()
145
  self._play_count = 0 # Running count for play numbering
146
  self._last_flag_seen_at: Optional[float] = None # For gap tolerance
 
195
  # No scorebug (e.g., replay) - use gap tolerance
196
  return self._handle_no_scorebug(timestamp)
197
 
198
+ def _handle_flag_detected(self, timestamp: float, flag_info: FlagInfo) -> Optional[PlayEvent]: # pylint: disable=useless-return
199
  """Handle FLAG being detected."""
200
  self._last_flag_seen_at = timestamp
201
 
 
211
  flag_info.mean_hue,
212
  )
213
 
214
+ # Update current FLAG statistics (including scorebug verification from FlagInfo)
215
  if self._state.current_flag is not None:
216
+ self._state.current_flag.update(flag_info.yellow_ratio, flag_info.mean_hue, flag_info.scorebug_verified)
217
 
218
  return None
219
 
 
272
  # Store completed flag data
273
  self._state.completed_flags.append(self._state.current_flag)
274
 
275
+ scorebug_ratio = self._state.current_flag.scorebug_ratio
276
+
277
  logger.info(
278
+ "FLAG EVENT ended at %.1fs (duration=%.1fs, peak=%.0f%%, avg=%.0f%%, hue=%.1f, scorebug=%.0f%%)",
279
  self._state.current_flag.end_time,
280
  duration,
281
  peak_yellow * 100,
282
  avg_yellow * 100,
283
  avg_hue,
284
+ scorebug_ratio * 100,
285
  )
286
 
287
  # Check if FLAG event meets all criteria to become a FLAG PLAY
 
303
  if avg_hue > self.max_mean_hue:
304
  reject_reasons.append(f"hue {avg_hue:.1f} > {self.max_mean_hue:.1f} (lime-green, not yellow)")
305
 
306
+ if scorebug_ratio < self.min_scorebug_ratio:
307
+ reject_reasons.append(f"scorebug {scorebug_ratio:.0%} < {self.min_scorebug_ratio:.0%} (likely replay/commercial)")
308
+
309
  if reject_reasons:
310
  logger.debug(
311
  "FLAG event rejected: %s",
src/tracking/models.py CHANGED
@@ -128,6 +128,7 @@ class FlagInfo(BaseModel):
128
  detected: bool = Field(False, description="Whether FLAG is detected (yellow present AND valid hue)")
129
  yellow_ratio: float = Field(0.0, description="Ratio of yellow pixels in FLAG region")
130
  mean_hue: float = Field(0.0, description="Mean hue of yellow pixels (helps distinguish orange)")
 
131
  is_valid_yellow: bool = Field(False, description="True if mean_hue >= threshold (not orange)")
132
 
133
 
 
128
  detected: bool = Field(False, description="Whether FLAG is detected (yellow present AND valid hue)")
129
  yellow_ratio: float = Field(0.0, description="Ratio of yellow pixels in FLAG region")
130
  mean_hue: float = Field(0.0, description="Mean hue of yellow pixels (helps distinguish orange)")
131
+ scorebug_verified: bool = Field(True, description="Whether scorebug was verified present via template matching")
132
  is_valid_yellow: bool = Field(False, description="True if mean_hue >= threshold (not orange)")
133
 
134
 
src/video/__init__.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from .frame_extractor import extract_sample_frames, get_video_duration
4
  from .frame_reader import ThreadedFrameReader
 
5
  from .ffmpeg_ops import (
6
  extract_clip_stream_copy,
7
  extract_clip_reencode,
@@ -14,6 +15,10 @@ __all__ = [
14
  "extract_sample_frames",
15
  "get_video_duration",
16
  "ThreadedFrameReader",
 
 
 
 
17
  "extract_clip_stream_copy",
18
  "extract_clip_reencode",
19
  "concatenate_clips",
 
2
 
3
  from .frame_extractor import extract_sample_frames, get_video_duration
4
  from .frame_reader import ThreadedFrameReader
5
+ from .ffmpeg_reader import FFmpegFrameReader, extract_frames_ffmpeg_pipe, iter_frames_ffmpeg, get_video_dimensions
6
  from .ffmpeg_ops import (
7
  extract_clip_stream_copy,
8
  extract_clip_reencode,
 
15
  "extract_sample_frames",
16
  "get_video_duration",
17
  "ThreadedFrameReader",
18
+ "FFmpegFrameReader",
19
+ "extract_frames_ffmpeg_pipe",
20
+ "iter_frames_ffmpeg",
21
+ "get_video_dimensions",
22
  "extract_clip_stream_copy",
23
  "extract_clip_reencode",
24
  "concatenate_clips",
src/video/ffmpeg_reader.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FFmpeg-based frame reader for accurate VFR (Variable Frame Rate) video handling.
3
+
4
+ This module provides frame extraction using FFmpeg's accurate timestamp seeking,
5
+ which correctly handles VFR videos where OpenCV's seeking fails.
6
+
7
+ Key advantages over OpenCV seeking:
8
+ - Accurate timestamp handling for VFR videos
9
+ - ~36x faster than OpenCV's CAP_PROP_POS_FRAMES seeking
10
+ - Frames are returned in correct chronological order
11
+ """
12
+
13
+ import logging
14
+ import subprocess
15
+ from typing import Any, Callable, Generator, Optional, Tuple
16
+
17
+ import cv2
18
+ import numpy as np
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def get_video_dimensions(video_path: str) -> Tuple[int, int]:
24
+ """
25
+ Get video dimensions (width, height) using OpenCV.
26
+
27
+ Args:
28
+ video_path: Path to video file.
29
+
30
+ Returns:
31
+ Tuple of (width, height).
32
+
33
+ Raises:
34
+ ValueError: If video cannot be opened.
35
+ """
36
+ cap = cv2.VideoCapture(video_path)
37
+ if not cap.isOpened():
38
+ raise ValueError(f"Could not open video: {video_path}")
39
+
40
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
41
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
42
+ cap.release()
43
+
44
+ return width, height
45
+
46
+
47
+ def extract_frames_ffmpeg_pipe(
48
+ video_path: str,
49
+ start_time: float,
50
+ end_time: float,
51
+ frame_interval: float,
52
+ callback: Callable[[float, np.ndarray[Any, Any]], bool],
53
+ ) -> Tuple[int, float]:
54
+ """
55
+ Extract frames using FFmpeg pipe for accurate VFR handling.
56
+
57
+ FFmpeg seeks accurately to the start position and outputs frames at the
58
+ specified interval. Frames are piped directly to Python as raw BGR data,
59
+ avoiding temp files and providing accurate timestamps.
60
+
61
+ Args:
62
+ video_path: Path to video file.
63
+ start_time: Start time in seconds.
64
+ end_time: End time in seconds.
65
+ frame_interval: Interval between frames in seconds (e.g., 0.5 for 2 fps).
66
+ callback: Function called for each frame.
67
+ Signature: callback(timestamp: float, frame: np.ndarray) -> bool
68
+ Return False to stop processing early.
69
+
70
+ Returns:
71
+ Tuple of (frames_processed, io_time).
72
+ """
73
+ import time
74
+
75
+ # Get video dimensions
76
+ width, height = get_video_dimensions(video_path)
77
+ frame_size = width * height * 3 # BGR format
78
+
79
+ # Calculate output fps from interval
80
+ output_fps = 1.0 / frame_interval
81
+ duration = end_time - start_time
82
+
83
+ t_io_start = time.perf_counter()
84
+
85
+ # Build ffmpeg command
86
+ # -ss before -i enables fast seeking to keyframe, then accurate frame output
87
+ cmd = [
88
+ "ffmpeg",
89
+ "-ss",
90
+ str(start_time),
91
+ "-i",
92
+ str(video_path),
93
+ "-t",
94
+ str(duration),
95
+ "-vf",
96
+ f"fps={output_fps}", # Output at specified fps
97
+ "-f",
98
+ "rawvideo",
99
+ "-pix_fmt",
100
+ "bgr24", # OpenCV uses BGR format
101
+ "-loglevel",
102
+ "error",
103
+ "-", # Output to stdout
104
+ ]
105
+
106
+ # Start ffmpeg process
107
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
108
+
109
+ frames_processed = 0
110
+ current_time = start_time
111
+
112
+ try:
113
+ while True:
114
+ # Read raw frame data from stdout
115
+ raw_frame = process.stdout.read(frame_size)
116
+
117
+ # Check for end of stream
118
+ if len(raw_frame) != frame_size:
119
+ break
120
+
121
+ # Convert to numpy array (BGR format, same as OpenCV)
122
+ frame = np.frombuffer(raw_frame, dtype=np.uint8).reshape((height, width, 3))
123
+
124
+ # Call the callback with timestamp and frame
125
+ # Make a copy to ensure the frame data is not overwritten
126
+ continue_processing = callback(current_time, frame.copy())
127
+ frames_processed += 1
128
+
129
+ if not continue_processing:
130
+ break
131
+
132
+ current_time += frame_interval
133
+
134
+ finally:
135
+ # Clean up process
136
+ process.stdout.close()
137
+ process.stderr.close()
138
+ process.terminate()
139
+ process.wait()
140
+
141
+ io_time = time.perf_counter() - t_io_start
142
+
143
+ return frames_processed, io_time
144
+
145
+
146
+ def iter_frames_ffmpeg(
147
+ video_path: str,
148
+ start_time: float,
149
+ end_time: float,
150
+ frame_interval: float,
151
+ ) -> Generator[Tuple[float, np.ndarray[Any, Any]], None, None]:
152
+ """
153
+ Generator that yields frames using FFmpeg pipe.
154
+
155
+ This is an alternative interface for iterating over frames without a callback.
156
+
157
+ Args:
158
+ video_path: Path to video file.
159
+ start_time: Start time in seconds.
160
+ end_time: End time in seconds.
161
+ frame_interval: Interval between frames in seconds.
162
+
163
+ Yields:
164
+ Tuple of (timestamp, frame) for each frame.
165
+ """
166
+ import time
167
+
168
+ # Get video dimensions
169
+ width, height = get_video_dimensions(video_path)
170
+ frame_size = width * height * 3
171
+
172
+ # Calculate output fps from interval
173
+ output_fps = 1.0 / frame_interval
174
+ duration = end_time - start_time
175
+
176
+ # Build ffmpeg command
177
+ cmd = [
178
+ "ffmpeg",
179
+ "-ss",
180
+ str(start_time),
181
+ "-i",
182
+ str(video_path),
183
+ "-t",
184
+ str(duration),
185
+ "-vf",
186
+ f"fps={output_fps}",
187
+ "-f",
188
+ "rawvideo",
189
+ "-pix_fmt",
190
+ "bgr24",
191
+ "-loglevel",
192
+ "error",
193
+ "-",
194
+ ]
195
+
196
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
197
+ current_time = start_time
198
+
199
+ try:
200
+ while True:
201
+ raw_frame = process.stdout.read(frame_size)
202
+ if len(raw_frame) != frame_size:
203
+ break
204
+
205
+ frame = np.frombuffer(raw_frame, dtype=np.uint8).reshape((height, width, 3))
206
+ yield current_time, frame.copy()
207
+ current_time += frame_interval
208
+
209
+ finally:
210
+ process.stdout.close()
211
+ process.stderr.close()
212
+ process.terminate()
213
+ process.wait()
214
+
215
+
216
+ class FFmpegFrameReader:
217
+ """
218
+ Context manager for reading frames from video using FFmpeg pipe.
219
+
220
+ This class provides a cleaner interface for reading frames in a processing loop,
221
+ handling resource cleanup automatically.
222
+
223
+ Example:
224
+ with FFmpegFrameReader(video_path, start, end, interval) as reader:
225
+ for timestamp, frame in reader:
226
+ process_frame(timestamp, frame)
227
+ """
228
+
229
+ def __init__(self, video_path: str, start_time: float, end_time: float, frame_interval: float):
230
+ """
231
+ Initialize the FFmpeg frame reader.
232
+
233
+ Args:
234
+ video_path: Path to video file.
235
+ start_time: Start time in seconds.
236
+ end_time: End time in seconds.
237
+ frame_interval: Interval between frames in seconds.
238
+ """
239
+ self.video_path = video_path
240
+ self.start_time = start_time
241
+ self.end_time = end_time
242
+ self.frame_interval = frame_interval
243
+
244
+ self.process: Optional[subprocess.Popen[bytes]] = None
245
+ self.width = 0
246
+ self.height = 0
247
+ self.frame_size = 0
248
+ self.current_time = start_time
249
+ self.frames_read = 0
250
+ self.io_time = 0.0
251
+
252
+ def __enter__(self) -> "FFmpegFrameReader":
253
+ """Start the FFmpeg process."""
254
+ import time
255
+
256
+ # Get video dimensions
257
+ self.width, self.height = get_video_dimensions(self.video_path)
258
+ self.frame_size = self.width * self.height * 3
259
+
260
+ # Calculate parameters
261
+ output_fps = 1.0 / self.frame_interval
262
+ duration = self.end_time - self.start_time
263
+
264
+ # Build and start ffmpeg command
265
+ cmd = [
266
+ "ffmpeg",
267
+ "-ss",
268
+ str(self.start_time),
269
+ "-i",
270
+ str(self.video_path),
271
+ "-t",
272
+ str(duration),
273
+ "-vf",
274
+ f"fps={output_fps}",
275
+ "-f",
276
+ "rawvideo",
277
+ "-pix_fmt",
278
+ "bgr24",
279
+ "-loglevel",
280
+ "error",
281
+ "-",
282
+ ]
283
+
284
+ t_start = time.perf_counter()
285
+ self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
286
+ self.io_time = time.perf_counter() - t_start
287
+
288
+ self.current_time = self.start_time
289
+ self.frames_read = 0
290
+
291
+ return self
292
+
293
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
294
+ """Clean up the FFmpeg process."""
295
+ if self.process:
296
+ self.process.stdout.close()
297
+ self.process.stderr.close()
298
+ self.process.terminate()
299
+ self.process.wait()
300
+
301
+ def __iter__(self) -> "FFmpegFrameReader":
302
+ """Return self as iterator."""
303
+ return self
304
+
305
+ def __next__(self) -> Tuple[float, np.ndarray[Any, Any]]:
306
+ """Read and return the next frame."""
307
+ import time
308
+
309
+ if self.process is None:
310
+ raise StopIteration
311
+
312
+ t_start = time.perf_counter()
313
+ raw_frame = self.process.stdout.read(self.frame_size)
314
+ self.io_time += time.perf_counter() - t_start
315
+
316
+ if len(raw_frame) != self.frame_size:
317
+ raise StopIteration
318
+
319
+ frame = np.frombuffer(raw_frame, dtype=np.uint8).reshape((self.height, self.width, 3))
320
+ timestamp = self.current_time
321
+
322
+ self.current_time += self.frame_interval
323
+ self.frames_read += 1
324
+
325
+ return timestamp, frame.copy()
326
+
327
+ def get_stats(self) -> Tuple[int, float]:
328
+ """
329
+ Get reading statistics.
330
+
331
+ Returns:
332
+ Tuple of (frames_read, io_time).
333
+ """
334
+ return self.frames_read, self.io_time