Spaces:
Sleeping
Sleeping
Commit ·
137c6cf
1
Parent(s): aee009f
some decent progress generalizing
Browse files- docs/texas_video_vfr_issue.md +29 -12
- scripts/benchmark_extraction_methods.py +561 -0
- scripts/compare_tennessee_plays.py +126 -0
- scripts/diagnose_tennessee_regression.py +120 -0
- scripts/find_flag_ground_truth.py +86 -28
- scripts/test_flag_region_selection.py +113 -20
- scripts/test_frame_alignment.py +98 -0
- scripts/test_timeout_at_transitions.py +39 -4
- scripts/verify_config_loading.py +95 -0
- src/pipeline/models.py +1 -0
- src/pipeline/parallel.py +53 -64
- src/pipeline/play_extractor.py +3 -0
- src/pipeline/template_builder_pass.py +20 -26
- src/tracking/flag_tracker.py +32 -8
- src/tracking/models.py +1 -0
- src/video/__init__.py +5 -0
- src/video/ffmpeg_reader.py +334 -0
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
|
| 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
|
| 44 |
-
- Pros: Works with
|
| 45 |
-
- Cons: Requires code changes
|
| 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 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
1.
|
| 56 |
-
2.
|
| 57 |
-
|
|
|
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 182 |
-
"""
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
| 188 |
|
| 189 |
-
with open(config_path, "r", encoding="utf-8") as f:
|
| 190 |
-
return json.load(f)
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 710 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 / "
|
| 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
|
| 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
|
| 16 |
-
5.
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 392 |
-
|
|
|
|
| 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 |
-
#
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
if result is None:
|
| 431 |
print("\nERROR: No existing scorebug config found.")
|
| 432 |
-
print("Please run
|
| 433 |
return 1
|
| 434 |
|
| 435 |
scorebug_bbox, template_path = result
|
| 436 |
print(f"\nLoaded scorebug region: {scorebug_bbox.to_tuple()}")
|
| 437 |
|
| 438 |
-
|
| 439 |
-
|
| 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),
|
| 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 |
-
#
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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=
|
| 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 |
-
|
| 158 |
-
|
|
|
|
| 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,
|
| 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
|
| 194 |
|
| 195 |
This function runs in a separate process and must be self-contained.
|
| 196 |
-
It
|
|
|
|
| 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 |
-
#
|
| 210 |
-
# needs its own fresh
|
| 211 |
-
import
|
| 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 |
-
#
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 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 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
cap.grab()
|
| 275 |
-
io_time += time.perf_counter() - t_io_start
|
| 276 |
-
current_frame += frame_skip
|
| 277 |
|
| 278 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
| 184 |
|
| 185 |
-
|
| 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 |
-
#
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
logger.info(" Point %d complete: %d samples from %d frames", point_idx + 1, point_valid_samples, point_frames_scanned)
|
| 232 |
|
| 233 |
-
|
| 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 ~
|
| 97 |
-
MIN_MEAN_HUE =
|
| 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]:
|
| 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
|