Spaces:
Sleeping
Sleeping
Commit ·
f7a96ab
1
Parent(s): 5963802
documenting texas
Browse files- docs/texas_ground_truth.md +80 -0
- scripts/test_opening_kickoff_fix.py +168 -0
- src/tracking/models.py +7 -0
- src/tracking/normal_play_tracker.py +93 -21
- src/tracking/play_merger.py +41 -1
- src/tracking/play_tracker.py +3 -0
docs/texas_ground_truth.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OSU vs Texas Ground Truth - Play Detection Analysis
|
| 2 |
+
|
| 3 |
+
This document tracks observations, issues, and ground truth for the Texas video analysis.
|
| 4 |
+
|
| 5 |
+
## Video Details
|
| 6 |
+
- **Video**: OSU vs Texas 01.10.25.mkv
|
| 7 |
+
- **Note**: This video has Variable Frame Rate (VFR) encoding - see [texas_video_vfr_issue.md](texas_video_vfr_issue.md) for details
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Known Issues
|
| 12 |
+
|
| 13 |
+
### 1. Opening Kickoff False Positive ⚠️
|
| 14 |
+
| Issue | Details |
|
| 15 |
+
|-------|---------|
|
| 16 |
+
| **Real kickoff starts** | ~7:21 in video |
|
| 17 |
+
| **Problem** | Strange detection of play clock before 7:21 causing false positive reading |
|
| 18 |
+
| **Status** | Left unfixed - couldn't resolve without causing regression on other videos |
|
| 19 |
+
| **Priority** | Low - edge case, revisit later |
|
| 20 |
+
|
| 21 |
+
### 2. Variable Frame Rate Timing Issues ⚠️
|
| 22 |
+
| Issue | Details |
|
| 23 |
+
|-------|---------|
|
| 24 |
+
| **Problem** | VFR video caused timing/padding issues |
|
| 25 |
+
| **Symptom** | Padding 2 seconds before was sometimes actually 4 seconds or 6 seconds before |
|
| 26 |
+
| **Impact** | Play clipping ffmpeg logic not well set up for VFR videos |
|
| 27 |
+
| **Note** | 4 seconds padding before may actually be preferable to 2 seconds |
|
| 28 |
+
| **TODO** | Consider changing main logic to use 4 second pre-padding instead of 2 |
|
| 29 |
+
| **Documentation** | See [texas_video_vfr_issue.md](texas_video_vfr_issue.md) for technical details |
|
| 30 |
+
|
| 31 |
+
### 3. FLAG Play Merge Duplication ❌
|
| 32 |
+
| Issue | Details |
|
| 33 |
+
|-------|---------|
|
| 34 |
+
| **Problem** | Flag plays overlap with regular plays before them |
|
| 35 |
+
| **Symptom** | Video shows end of regular play, then flag for a few seconds, then skips BACK in time to where FLAG first appeared |
|
| 36 |
+
| **Result** | Duplicated video time present in every FLAG event |
|
| 37 |
+
| **Suspected cause** | Buffer handling or merge logic issue, possibly VFR-related |
|
| 38 |
+
| **Severity** | High - very bad user experience, needs investigation |
|
| 39 |
+
|
| 40 |
+
### 4. Special Play Max Duration Too Long ⚠️
|
| 41 |
+
| Issue | Details |
|
| 42 |
+
|-------|---------|
|
| 43 |
+
| **Current value** | 10 seconds max for special plays (when clock reset happens) |
|
| 44 |
+
| **Recommended** | Reduce to 8 seconds |
|
| 45 |
+
| **Rationale** | 10 seconds is too long for most special plays |
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## Positive Results ✅
|
| 50 |
+
|
| 51 |
+
- **No significant missed plays detected** - core play detection appears to be working well for this video
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## TODO Items
|
| 56 |
+
|
| 57 |
+
1. [ ] Investigate FLAG merge duplication issue (high priority)
|
| 58 |
+
2. [ ] Reduce special play max duration from 10s to 8s
|
| 59 |
+
3. [ ] Consider increasing pre-play buffer from 2s to 4s
|
| 60 |
+
4. [ ] Investigate VFR handling in ffmpeg clip extraction logic
|
| 61 |
+
5. [ ] Investigate opening kickoff false positive (low priority)
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## Comparison with Other Videos
|
| 66 |
+
|
| 67 |
+
| Metric | Tennessee | Oregon | Texas |
|
| 68 |
+
|--------|-----------|--------|-------|
|
| 69 |
+
| Opening kickoff | ✅ Clean | ✅ Clean | ⚠️ False positive |
|
| 70 |
+
| FLAG merge | ✅ Working | ✅ Working | ❌ Duplicating |
|
| 71 |
+
| VFR issues | N/A | N/A | ⚠️ Present |
|
| 72 |
+
| Missed plays | ~1 acceptable | 3 missing | ✅ None significant |
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## Notes for Future Analysis
|
| 77 |
+
|
| 78 |
+
- This video is a good test case for VFR handling improvements
|
| 79 |
+
- FLAG merge logic needs to be more robust against timestamp inconsistencies
|
| 80 |
+
- The opening kickoff issue suggests the detection may be sensitive to pre-game content
|
scripts/test_opening_kickoff_fix.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Test script to verify the opening kickoff detection fix.
|
| 4 |
+
|
| 5 |
+
Tests:
|
| 6 |
+
1. Tennessee: Opening kickoff should be detected around 117s
|
| 7 |
+
2. Oregon: Opening kickoff should be detected around 332s
|
| 8 |
+
3. Texas: Should REJECT the fake kickoff at 20.5s and detect real game start around 444s
|
| 9 |
+
|
| 10 |
+
The fix requires:
|
| 11 |
+
- k=3 consecutive clock readings to confirm kickoff start
|
| 12 |
+
- Maximum kickoff duration of 90s (rejects if too long)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
import logging
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
# Add src to path
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
| 21 |
+
|
| 22 |
+
from config import SessionConfig, load_session_config, get_video_basename, OUTPUT_DIR
|
| 23 |
+
from pipeline import run_extraction
|
| 24 |
+
|
| 25 |
+
# Configure logging
|
| 26 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_video(video_name: str, video_path: str, start_time: float, end_time: float, expected_kickoff_start: float, expected_kickoff_tolerance: float = 30.0):
|
| 31 |
+
"""
|
| 32 |
+
Test opening kickoff detection on a video segment.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
video_name: Name for display
|
| 36 |
+
video_path: Path to video file
|
| 37 |
+
start_time: Start time in seconds
|
| 38 |
+
end_time: End time in seconds
|
| 39 |
+
expected_kickoff_start: Expected kickoff start time (within video segment)
|
| 40 |
+
expected_kickoff_tolerance: Tolerance for kickoff timing match
|
| 41 |
+
"""
|
| 42 |
+
print(f"\n{'=' * 60}")
|
| 43 |
+
print(f"Testing: {video_name}")
|
| 44 |
+
print(f"Video: {video_path}")
|
| 45 |
+
print(f"Time range: {start_time}s - {end_time}s")
|
| 46 |
+
print(f"Expected kickoff around: {expected_kickoff_start}s (±{expected_kickoff_tolerance}s)")
|
| 47 |
+
print("=" * 60)
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
# Load existing session config for this video
|
| 51 |
+
video_basename = get_video_basename(video_path, testing_mode=False)
|
| 52 |
+
config_path = OUTPUT_DIR / f"{video_basename}_config.json"
|
| 53 |
+
|
| 54 |
+
if not config_path.exists():
|
| 55 |
+
logger.error(f"Config not found: {config_path}")
|
| 56 |
+
logger.error("Run main.py with --use-saved-regions first")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
# Load config and update time bounds
|
| 60 |
+
session_config = load_session_config(str(config_path))
|
| 61 |
+
session_config.video_path = video_path
|
| 62 |
+
session_config.start_time = start_time
|
| 63 |
+
session_config.end_time = end_time
|
| 64 |
+
session_config.video_basename = video_basename
|
| 65 |
+
|
| 66 |
+
# Run extraction
|
| 67 |
+
results = run_extraction(session_config, OUTPUT_DIR, num_workers=1)
|
| 68 |
+
|
| 69 |
+
# Analyze results - plays are dicts, not objects
|
| 70 |
+
plays = results.get("plays", [])
|
| 71 |
+
kickoff_plays = [p for p in plays if p.get("play_type") == "kickoff"]
|
| 72 |
+
|
| 73 |
+
print(f"\nResults:")
|
| 74 |
+
print(f" Total plays detected: {len(plays)}")
|
| 75 |
+
print(f" Kickoff plays: {len(kickoff_plays)}")
|
| 76 |
+
|
| 77 |
+
success = True
|
| 78 |
+
if kickoff_plays:
|
| 79 |
+
for kp in kickoff_plays:
|
| 80 |
+
start = kp.get("start_time", 0)
|
| 81 |
+
end = kp.get("end_time", 0)
|
| 82 |
+
print(f"\n Kickoff found:")
|
| 83 |
+
print(f" Start: {start:.1f}s")
|
| 84 |
+
print(f" End: {end:.1f}s")
|
| 85 |
+
print(f" Duration: {end - start:.1f}s")
|
| 86 |
+
|
| 87 |
+
# Check if within expected range
|
| 88 |
+
diff = abs(start - expected_kickoff_start)
|
| 89 |
+
if diff <= expected_kickoff_tolerance:
|
| 90 |
+
print(f" ✅ PASS: Within expected range ({diff:.1f}s from expected)")
|
| 91 |
+
else:
|
| 92 |
+
print(f" ❌ FAIL: Outside expected range ({diff:.1f}s from expected)")
|
| 93 |
+
success = False
|
| 94 |
+
else:
|
| 95 |
+
print(f"\n ❌ No kickoff detected!")
|
| 96 |
+
success = False
|
| 97 |
+
|
| 98 |
+
# Show first few plays for context
|
| 99 |
+
print(f"\nFirst 5 plays:")
|
| 100 |
+
for p in plays[:5]:
|
| 101 |
+
pnum = p.get("play_number", "?")
|
| 102 |
+
pstart = p.get("start_time", 0)
|
| 103 |
+
pend = p.get("end_time", 0)
|
| 104 |
+
ptype = p.get("play_type", "?")
|
| 105 |
+
pmethod = p.get("start_method", "?")
|
| 106 |
+
print(f" Play #{pnum}: {pstart:.1f}s - {pend:.1f}s ({ptype}, {pmethod})")
|
| 107 |
+
|
| 108 |
+
return success
|
| 109 |
+
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Error testing {video_name}: {e}")
|
| 112 |
+
import traceback
|
| 113 |
+
traceback.print_exc()
|
| 114 |
+
return False
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def main():
|
| 118 |
+
"""Run all tests."""
|
| 119 |
+
base_path = Path("/Users/andytaylor/Documents/Personal/cfb40/full_videos")
|
| 120 |
+
|
| 121 |
+
# Test configurations
|
| 122 |
+
tests = [
|
| 123 |
+
{
|
| 124 |
+
"video_name": "Tennessee (baseline - should pass)",
|
| 125 |
+
"video_path": str(base_path / "OSU vs Tenn 12.21.24.mkv"),
|
| 126 |
+
"start_time": 0,
|
| 127 |
+
"end_time": 300, # First 5 minutes
|
| 128 |
+
"expected_kickoff_start": 117, # ~1:57 video time
|
| 129 |
+
"expected_kickoff_tolerance": 30,
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"video_name": "Oregon (baseline - should pass)",
|
| 133 |
+
"video_path": str(base_path / "OSU vs Oregon 01.01.25.mkv"),
|
| 134 |
+
"start_time": 200,
|
| 135 |
+
"end_time": 500, # Around kickoff time
|
| 136 |
+
"expected_kickoff_start": 332, # ~5:32 video time
|
| 137 |
+
"expected_kickoff_tolerance": 30,
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"video_name": "Texas (fix test - should reject fake kickoff at 20s)",
|
| 141 |
+
"video_path": str(base_path / "OSU vs Texas 01.10.25.mkv"),
|
| 142 |
+
"start_time": 0,
|
| 143 |
+
"end_time": 600, # First 10 minutes
|
| 144 |
+
"expected_kickoff_start": 444, # Real game starts ~7:24
|
| 145 |
+
"expected_kickoff_tolerance": 30,
|
| 146 |
+
},
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
print("\n" + "=" * 60)
|
| 150 |
+
print("OPENING KICKOFF DETECTION FIX TEST")
|
| 151 |
+
print("=" * 60)
|
| 152 |
+
|
| 153 |
+
results = []
|
| 154 |
+
for test_config in tests:
|
| 155 |
+
success = test_video(**test_config)
|
| 156 |
+
results.append((test_config["video_name"], success))
|
| 157 |
+
|
| 158 |
+
# Summary
|
| 159 |
+
print("\n" + "=" * 60)
|
| 160 |
+
print("TEST SUMMARY")
|
| 161 |
+
print("=" * 60)
|
| 162 |
+
for name, success in results:
|
| 163 |
+
status = "✅ PASS" if success else "❌ FAIL"
|
| 164 |
+
print(f" {status}: {name}")
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
if __name__ == "__main__":
|
| 168 |
+
main()
|
src/tracking/models.py
CHANGED
|
@@ -113,6 +113,10 @@ class TrackPlayStateConfig(BaseModel):
|
|
| 113 |
flag_extension_timeout: float = Field(15.0, description="Max seconds to extend play capture after FLAG first detected")
|
| 114 |
capture_flag_plays: bool = Field(True, description="Whether to capture plays with FLAGS (bypasses quiet time filter)")
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
class TimeoutInfo(BaseModel):
|
| 118 |
"""Timeout information for a frame."""
|
|
@@ -214,10 +218,13 @@ class NormalTrackerState(BaseModel):
|
|
| 214 |
# Opening kickoff tracking
|
| 215 |
# The opening kickoff is special because the scorebug appears mid-play
|
| 216 |
# We detect it when scorebug first appears and end it on first clock reset to 40
|
|
|
|
| 217 |
opening_kickoff_active: bool = Field(False, description="Whether we're currently tracking the opening kickoff")
|
| 218 |
opening_kickoff_complete: bool = Field(False, description="Whether opening kickoff has been recorded (only happens once)")
|
| 219 |
first_scorebug_timestamp: Optional[float] = Field(None, description="When scorebug first appeared in the video (verified by template matching)")
|
| 220 |
first_clock_reading_timestamp: Optional[float] = Field(None, description="When we first got a valid clock reading")
|
|
|
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
class SpecialTrackerState(BaseModel):
|
|
|
|
| 113 |
flag_extension_timeout: float = Field(15.0, description="Max seconds to extend play capture after FLAG first detected")
|
| 114 |
capture_flag_plays: bool = Field(True, description="Whether to capture plays with FLAGS (bypasses quiet time filter)")
|
| 115 |
|
| 116 |
+
# Opening kickoff detection settings
|
| 117 |
+
opening_kickoff_min_consecutive_frames: int = Field(3, description="Required consecutive frames with valid clock readings to confirm kickoff start")
|
| 118 |
+
max_opening_kickoff_duration: float = Field(90.0, description="Maximum valid kickoff duration (seconds). If exceeded, reject and keep searching.")
|
| 119 |
+
|
| 120 |
|
| 121 |
class TimeoutInfo(BaseModel):
|
| 122 |
"""Timeout information for a frame."""
|
|
|
|
| 218 |
# Opening kickoff tracking
|
| 219 |
# The opening kickoff is special because the scorebug appears mid-play
|
| 220 |
# We detect it when scorebug first appears and end it on first clock reset to 40
|
| 221 |
+
# Requires k consecutive valid clock readings to confirm kickoff (filters out pre-game noise)
|
| 222 |
opening_kickoff_active: bool = Field(False, description="Whether we're currently tracking the opening kickoff")
|
| 223 |
opening_kickoff_complete: bool = Field(False, description="Whether opening kickoff has been recorded (only happens once)")
|
| 224 |
first_scorebug_timestamp: Optional[float] = Field(None, description="When scorebug first appeared in the video (verified by template matching)")
|
| 225 |
first_clock_reading_timestamp: Optional[float] = Field(None, description="When we first got a valid clock reading")
|
| 226 |
+
opening_kickoff_consecutive_readings: int = Field(0, description="Count of consecutive frames with valid clock readings for kickoff detection")
|
| 227 |
+
opening_kickoff_candidate_timestamp: Optional[float] = Field(None, description="Timestamp when consecutive clock readings started (candidate kickoff start)")
|
| 228 |
|
| 229 |
|
| 230 |
class SpecialTrackerState(BaseModel):
|
src/tracking/normal_play_tracker.py
CHANGED
|
@@ -127,18 +127,16 @@ class NormalPlayTracker:
|
|
| 127 |
completed_play = None
|
| 128 |
|
| 129 |
if self._state.state == PlayState.IDLE:
|
| 130 |
-
# First clock reading -
|
| 131 |
-
|
| 132 |
self._state.state = PlayState.PRE_SNAP
|
| 133 |
self._state.last_clock_value = clock_value
|
| 134 |
self._state.last_clock_timestamp = timestamp
|
| 135 |
self._state.clock_stable_count = 1
|
| 136 |
-
self._state.first_clock_reading_timestamp = timestamp
|
| 137 |
|
| 138 |
-
#
|
| 139 |
-
# The opening kickoff starts when we first see the scorebug with a clock reading
|
| 140 |
if not self._state.opening_kickoff_complete:
|
| 141 |
-
self.
|
| 142 |
|
| 143 |
elif self._state.state == PlayState.PRE_SNAP:
|
| 144 |
# Watching for play to start (may return completed opening kickoff play)
|
|
@@ -171,6 +169,14 @@ class NormalPlayTracker:
|
|
| 171 |
if self._state.state == PlayState.IDLE:
|
| 172 |
return None
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
# Check if we've lost scorebug for too long
|
| 175 |
if self._state.last_scorebug_timestamp is not None:
|
| 176 |
time_since_scorebug = timestamp - self._state.last_scorebug_timestamp
|
|
@@ -208,8 +214,20 @@ class NormalPlayTracker:
|
|
| 208 |
if self._state.state == PlayState.PRE_SNAP and self._state.last_clock_value is not None:
|
| 209 |
logger.debug("Clock unreadable at %.1fs in PRE_SNAP state", timestamp)
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
def _handle_pre_snap(self, timestamp: float, clock_value: int, timeout_info: Optional[TimeoutInfo] = None) -> Optional[PlayEvent]:
|
| 212 |
"""Handle clock reading during PRE_SNAP state. May return completed opening kickoff play."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
if self._state.last_clock_value is None:
|
| 214 |
self._state.last_clock_value = clock_value
|
| 215 |
self._state.clock_stable_count = 1
|
|
@@ -690,6 +708,15 @@ class NormalPlayTracker:
|
|
| 690 |
self._state.state = PlayState.PRE_SNAP
|
| 691 |
return None
|
| 692 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
self._play_count += 1
|
| 694 |
|
| 695 |
play = PlayEvent(
|
|
@@ -718,6 +745,15 @@ class NormalPlayTracker:
|
|
| 718 |
|
| 719 |
def _end_play_capped(self, capped_end_time: float, clock_value: int, method: str) -> PlayEvent:
|
| 720 |
"""End the current play with a capped end time."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
self._play_count += 1
|
| 722 |
|
| 723 |
play = PlayEvent(
|
|
@@ -762,52 +798,85 @@ class NormalPlayTracker:
|
|
| 762 |
# Opening kickoff handling
|
| 763 |
# =========================================================================
|
| 764 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
def _start_opening_kickoff(self, timestamp: float) -> None:
|
| 766 |
"""
|
| 767 |
Start tracking the opening kickoff.
|
| 768 |
|
| 769 |
-
Called when we get
|
| 770 |
-
is special because the scorebug appears mid-play
|
| 771 |
-
|
| 772 |
-
The opening kickoff ends when we see the first clock reset to 40 from a lower
|
| 773 |
-
value, OR when the first countdown confirmation happens. This handles both:
|
| 774 |
-
- Tennessee: Clock shows 40, stays at 40, then counts down (no reset before first play)
|
| 775 |
-
- Oregon: Clock counts down during kickoff, resets to 25, then 40
|
| 776 |
|
| 777 |
Args:
|
| 778 |
-
timestamp: When first
|
| 779 |
"""
|
| 780 |
self._state.opening_kickoff_active = True
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
logger.info("Opening kickoff tracking started at %.1fs (first clock reading)", timestamp)
|
| 784 |
|
| 785 |
-
def _end_opening_kickoff(self, timestamp: float) -> PlayEvent:
|
| 786 |
"""
|
| 787 |
End the opening kickoff and create the play event.
|
| 788 |
|
| 789 |
Called when the first normal play starts (clock reset to 40 or countdown from 40).
|
| 790 |
The kickoff spans from when we first got a clock reading until this moment.
|
| 791 |
|
|
|
|
|
|
|
|
|
|
| 792 |
Args:
|
| 793 |
timestamp: When the first normal play is starting
|
| 794 |
|
| 795 |
Returns:
|
| 796 |
PlayEvent for the opening kickoff
|
| 797 |
"""
|
| 798 |
-
self._play_count += 1
|
| 799 |
-
|
| 800 |
# Kickoff starts when we first got a clock reading
|
| 801 |
start_time = self._state.first_clock_reading_timestamp or timestamp
|
| 802 |
|
| 803 |
# Kickoff ends just before the first normal play starts
|
| 804 |
-
# Use a small buffer to avoid overlap
|
| 805 |
kickoff_end_time = timestamp - 0.5
|
| 806 |
|
| 807 |
# Ensure end time is after start time with reasonable duration
|
| 808 |
if kickoff_end_time <= start_time:
|
| 809 |
kickoff_end_time = start_time + 2.0 # Minimum 2 second duration for kickoffs
|
| 810 |
|
|
|
|
|
|
|
| 811 |
play = PlayEvent(
|
| 812 |
play_number=self._play_count,
|
| 813 |
start_time=start_time,
|
|
@@ -934,6 +1003,9 @@ class NormalPlayTracker:
|
|
| 934 |
self._reset_freeze_tracking()
|
| 935 |
# Reset opening kickoff active flag (but NOT complete - that's permanent)
|
| 936 |
self._state.opening_kickoff_active = False
|
|
|
|
|
|
|
|
|
|
| 937 |
logger.debug("State machine reset to IDLE")
|
| 938 |
|
| 939 |
# =========================================================================
|
|
|
|
| 127 |
completed_play = None
|
| 128 |
|
| 129 |
if self._state.state == PlayState.IDLE:
|
| 130 |
+
# First clock reading - track consecutive readings before confirming kickoff
|
| 131 |
+
# This filters out isolated clock readings during pre-game content
|
| 132 |
self._state.state = PlayState.PRE_SNAP
|
| 133 |
self._state.last_clock_value = clock_value
|
| 134 |
self._state.last_clock_timestamp = timestamp
|
| 135 |
self._state.clock_stable_count = 1
|
|
|
|
| 136 |
|
| 137 |
+
# Track consecutive readings for opening kickoff detection
|
|
|
|
| 138 |
if not self._state.opening_kickoff_complete:
|
| 139 |
+
self._track_opening_kickoff_reading(timestamp, clock_value)
|
| 140 |
|
| 141 |
elif self._state.state == PlayState.PRE_SNAP:
|
| 142 |
# Watching for play to start (may return completed opening kickoff play)
|
|
|
|
| 169 |
if self._state.state == PlayState.IDLE:
|
| 170 |
return None
|
| 171 |
|
| 172 |
+
# Reset consecutive reading count for opening kickoff detection
|
| 173 |
+
# No scorebug means no clock reading - breaks the chain
|
| 174 |
+
if not self._state.opening_kickoff_complete and not self._state.opening_kickoff_active:
|
| 175 |
+
if self._state.opening_kickoff_consecutive_readings > 0:
|
| 176 |
+
logger.debug("Opening kickoff: consecutive clock readings reset (no scorebug at %.1fs)", timestamp)
|
| 177 |
+
self._state.opening_kickoff_consecutive_readings = 0
|
| 178 |
+
self._state.opening_kickoff_candidate_timestamp = None
|
| 179 |
+
|
| 180 |
# Check if we've lost scorebug for too long
|
| 181 |
if self._state.last_scorebug_timestamp is not None:
|
| 182 |
time_since_scorebug = timestamp - self._state.last_scorebug_timestamp
|
|
|
|
| 214 |
if self._state.state == PlayState.PRE_SNAP and self._state.last_clock_value is not None:
|
| 215 |
logger.debug("Clock unreadable at %.1fs in PRE_SNAP state", timestamp)
|
| 216 |
|
| 217 |
+
# Reset consecutive reading count for opening kickoff detection
|
| 218 |
+
# Invalid clock breaks the chain of valid readings
|
| 219 |
+
if not self._state.opening_kickoff_complete and not self._state.opening_kickoff_active:
|
| 220 |
+
if self._state.opening_kickoff_consecutive_readings > 0:
|
| 221 |
+
logger.debug("Opening kickoff: consecutive clock readings reset (invalid clock at %.1fs)", timestamp)
|
| 222 |
+
self._state.opening_kickoff_consecutive_readings = 0
|
| 223 |
+
self._state.opening_kickoff_candidate_timestamp = None
|
| 224 |
+
|
| 225 |
def _handle_pre_snap(self, timestamp: float, clock_value: int, timeout_info: Optional[TimeoutInfo] = None) -> Optional[PlayEvent]:
|
| 226 |
"""Handle clock reading during PRE_SNAP state. May return completed opening kickoff play."""
|
| 227 |
+
# Track consecutive clock readings for opening kickoff detection
|
| 228 |
+
if not self._state.opening_kickoff_complete and not self._state.opening_kickoff_active:
|
| 229 |
+
self._track_opening_kickoff_reading(timestamp, clock_value)
|
| 230 |
+
|
| 231 |
if self._state.last_clock_value is None:
|
| 232 |
self._state.last_clock_value = clock_value
|
| 233 |
self._state.clock_stable_count = 1
|
|
|
|
| 708 |
self._state.state = PlayState.PRE_SNAP
|
| 709 |
return None
|
| 710 |
|
| 711 |
+
# Mark opening kickoff as complete when first play finishes
|
| 712 |
+
# This prevents detecting a "kickoff" after the game has started
|
| 713 |
+
if not self._state.opening_kickoff_complete:
|
| 714 |
+
logger.debug("First play completed - marking opening kickoff tracking as complete")
|
| 715 |
+
self._state.opening_kickoff_complete = True
|
| 716 |
+
self._state.opening_kickoff_active = False
|
| 717 |
+
self._state.opening_kickoff_consecutive_readings = 0
|
| 718 |
+
self._state.opening_kickoff_candidate_timestamp = None
|
| 719 |
+
|
| 720 |
self._play_count += 1
|
| 721 |
|
| 722 |
play = PlayEvent(
|
|
|
|
| 745 |
|
| 746 |
def _end_play_capped(self, capped_end_time: float, clock_value: int, method: str) -> PlayEvent:
|
| 747 |
"""End the current play with a capped end time."""
|
| 748 |
+
# Mark opening kickoff as complete when first play finishes
|
| 749 |
+
# This prevents detecting a "kickoff" after the game has started
|
| 750 |
+
if not self._state.opening_kickoff_complete:
|
| 751 |
+
logger.debug("First play completed (capped) - marking opening kickoff tracking as complete")
|
| 752 |
+
self._state.opening_kickoff_complete = True
|
| 753 |
+
self._state.opening_kickoff_active = False
|
| 754 |
+
self._state.opening_kickoff_consecutive_readings = 0
|
| 755 |
+
self._state.opening_kickoff_candidate_timestamp = None
|
| 756 |
+
|
| 757 |
self._play_count += 1
|
| 758 |
|
| 759 |
play = PlayEvent(
|
|
|
|
| 798 |
# Opening kickoff handling
|
| 799 |
# =========================================================================
|
| 800 |
|
| 801 |
+
def _track_opening_kickoff_reading(self, timestamp: float, clock_value: int) -> None:
|
| 802 |
+
"""
|
| 803 |
+
Track consecutive clock readings to confirm opening kickoff start.
|
| 804 |
+
|
| 805 |
+
Requires k consecutive valid clock readings before starting kickoff tracking.
|
| 806 |
+
This filters out isolated/sporadic clock readings during pre-game content.
|
| 807 |
+
|
| 808 |
+
Args:
|
| 809 |
+
timestamp: Current video timestamp
|
| 810 |
+
clock_value: Current play clock value
|
| 811 |
+
"""
|
| 812 |
+
# Increment consecutive reading count
|
| 813 |
+
self._state.opening_kickoff_consecutive_readings += 1
|
| 814 |
+
|
| 815 |
+
# Record candidate start timestamp on first reading
|
| 816 |
+
if self._state.opening_kickoff_candidate_timestamp is None:
|
| 817 |
+
self._state.opening_kickoff_candidate_timestamp = timestamp
|
| 818 |
+
logger.debug("Opening kickoff: candidate start at %.1fs (clock=%d), tracking consecutive readings...", timestamp, clock_value)
|
| 819 |
+
|
| 820 |
+
# Check if we have enough consecutive readings to confirm kickoff
|
| 821 |
+
required_frames = self.config.opening_kickoff_min_consecutive_frames
|
| 822 |
+
if self._state.opening_kickoff_consecutive_readings >= required_frames:
|
| 823 |
+
logger.info(
|
| 824 |
+
"Opening kickoff: %d consecutive clock readings confirmed (started at %.1fs)",
|
| 825 |
+
self._state.opening_kickoff_consecutive_readings,
|
| 826 |
+
self._state.opening_kickoff_candidate_timestamp,
|
| 827 |
+
)
|
| 828 |
+
# Start tracking the opening kickoff using the candidate timestamp
|
| 829 |
+
self._start_opening_kickoff(self._state.opening_kickoff_candidate_timestamp)
|
| 830 |
+
else:
|
| 831 |
+
logger.debug(
|
| 832 |
+
"Opening kickoff: %d/%d consecutive readings (started at %.1fs)",
|
| 833 |
+
self._state.opening_kickoff_consecutive_readings,
|
| 834 |
+
required_frames,
|
| 835 |
+
self._state.opening_kickoff_candidate_timestamp,
|
| 836 |
+
)
|
| 837 |
+
|
| 838 |
def _start_opening_kickoff(self, timestamp: float) -> None:
|
| 839 |
"""
|
| 840 |
Start tracking the opening kickoff.
|
| 841 |
|
| 842 |
+
Called when we get k consecutive valid clock readings (confirmed kickoff).
|
| 843 |
+
The opening kickoff is special because the scorebug appears mid-play.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
|
| 845 |
Args:
|
| 846 |
+
timestamp: When kickoff tracking should start (first of k consecutive readings)
|
| 847 |
"""
|
| 848 |
self._state.opening_kickoff_active = True
|
| 849 |
+
self._state.first_clock_reading_timestamp = timestamp
|
| 850 |
+
logger.info("Opening kickoff tracking started at %.1fs (%d consecutive clock readings)", timestamp, self.config.opening_kickoff_min_consecutive_frames)
|
|
|
|
| 851 |
|
| 852 |
+
def _end_opening_kickoff(self, timestamp: float) -> Optional[PlayEvent]:
|
| 853 |
"""
|
| 854 |
End the opening kickoff and create the play event.
|
| 855 |
|
| 856 |
Called when the first normal play starts (clock reset to 40 or countdown from 40).
|
| 857 |
The kickoff spans from when we first got a clock reading until this moment.
|
| 858 |
|
| 859 |
+
Note: Kickoff plays that are too long will be filtered out by PlayMerger
|
| 860 |
+
using max_kickoff_duration filter.
|
| 861 |
+
|
| 862 |
Args:
|
| 863 |
timestamp: When the first normal play is starting
|
| 864 |
|
| 865 |
Returns:
|
| 866 |
PlayEvent for the opening kickoff
|
| 867 |
"""
|
|
|
|
|
|
|
| 868 |
# Kickoff starts when we first got a clock reading
|
| 869 |
start_time = self._state.first_clock_reading_timestamp or timestamp
|
| 870 |
|
| 871 |
# Kickoff ends just before the first normal play starts
|
|
|
|
| 872 |
kickoff_end_time = timestamp - 0.5
|
| 873 |
|
| 874 |
# Ensure end time is after start time with reasonable duration
|
| 875 |
if kickoff_end_time <= start_time:
|
| 876 |
kickoff_end_time = start_time + 2.0 # Minimum 2 second duration for kickoffs
|
| 877 |
|
| 878 |
+
self._play_count += 1
|
| 879 |
+
|
| 880 |
play = PlayEvent(
|
| 881 |
play_number=self._play_count,
|
| 882 |
start_time=start_time,
|
|
|
|
| 1003 |
self._reset_freeze_tracking()
|
| 1004 |
# Reset opening kickoff active flag (but NOT complete - that's permanent)
|
| 1005 |
self._state.opening_kickoff_active = False
|
| 1006 |
+
# Reset consecutive reading tracking (but NOT complete)
|
| 1007 |
+
self._state.opening_kickoff_consecutive_readings = 0
|
| 1008 |
+
self._state.opening_kickoff_candidate_timestamp = None
|
| 1009 |
logger.debug("State machine reset to IDLE")
|
| 1010 |
|
| 1011 |
# =========================================================================
|
src/tracking/play_merger.py
CHANGED
|
@@ -37,16 +37,19 @@ class PlayMerger: # pylint: disable=too-few-public-methods
|
|
| 37 |
- Adjusted to avoid overlap with previous play's end time (prevents duplicate footage)
|
| 38 |
"""
|
| 39 |
|
| 40 |
-
def __init__(self, proximity_threshold: float = 5.0, quiet_time: float = 10.0):
|
| 41 |
"""
|
| 42 |
Initialize the play merger.
|
| 43 |
|
| 44 |
Args:
|
| 45 |
proximity_threshold: Plays within this time (seconds) are considered the same event
|
| 46 |
quiet_time: Seconds after normal play ends before special/timeout plays are allowed
|
|
|
|
|
|
|
| 47 |
"""
|
| 48 |
self.proximity_threshold = proximity_threshold
|
| 49 |
self.quiet_time = quiet_time
|
|
|
|
| 50 |
|
| 51 |
def merge(self, *play_lists: List[PlayEvent]) -> List[PlayEvent]:
|
| 52 |
"""
|
|
@@ -74,6 +77,7 @@ class PlayMerger: # pylint: disable=too-few-public-methods
|
|
| 74 |
# Process non-FLAG plays (deduplicate and filter)
|
| 75 |
other_plays.sort(key=lambda p: p.start_time)
|
| 76 |
if other_plays:
|
|
|
|
| 77 |
other_plays = self._deduplicate(other_plays)
|
| 78 |
other_plays = self._apply_quiet_time_filter(other_plays)
|
| 79 |
|
|
@@ -95,6 +99,42 @@ class PlayMerger: # pylint: disable=too-few-public-methods
|
|
| 95 |
|
| 96 |
return all_plays
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def _deduplicate(self, plays: List[PlayEvent]) -> List[PlayEvent]:
|
| 99 |
"""
|
| 100 |
Remove overlapping and close plays, keeping the highest priority one.
|
|
|
|
| 37 |
- Adjusted to avoid overlap with previous play's end time (prevents duplicate footage)
|
| 38 |
"""
|
| 39 |
|
| 40 |
+
def __init__(self, proximity_threshold: float = 5.0, quiet_time: float = 10.0, max_kickoff_duration: float = 60.0):
|
| 41 |
"""
|
| 42 |
Initialize the play merger.
|
| 43 |
|
| 44 |
Args:
|
| 45 |
proximity_threshold: Plays within this time (seconds) are considered the same event
|
| 46 |
quiet_time: Seconds after normal play ends before special/timeout plays are allowed
|
| 47 |
+
max_kickoff_duration: Maximum duration (seconds) for kickoff plays. Longer ones are
|
| 48 |
+
filtered as false positives (e.g., pre-game content)
|
| 49 |
"""
|
| 50 |
self.proximity_threshold = proximity_threshold
|
| 51 |
self.quiet_time = quiet_time
|
| 52 |
+
self.max_kickoff_duration = max_kickoff_duration
|
| 53 |
|
| 54 |
def merge(self, *play_lists: List[PlayEvent]) -> List[PlayEvent]:
|
| 55 |
"""
|
|
|
|
| 77 |
# Process non-FLAG plays (deduplicate and filter)
|
| 78 |
other_plays.sort(key=lambda p: p.start_time)
|
| 79 |
if other_plays:
|
| 80 |
+
other_plays = self._filter_invalid_kickoffs(other_plays)
|
| 81 |
other_plays = self._deduplicate(other_plays)
|
| 82 |
other_plays = self._apply_quiet_time_filter(other_plays)
|
| 83 |
|
|
|
|
| 99 |
|
| 100 |
return all_plays
|
| 101 |
|
| 102 |
+
def _filter_invalid_kickoffs(self, plays: List[PlayEvent]) -> List[PlayEvent]:
|
| 103 |
+
"""
|
| 104 |
+
Filter out kickoff plays that exceed max_kickoff_duration.
|
| 105 |
+
|
| 106 |
+
Kickoff plays that are too long are likely false positives from pre-game
|
| 107 |
+
content where the scorebug is visible but actual gameplay hasn't started.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
plays: List of plays sorted by start time
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
Filtered list with invalid kickoffs removed
|
| 114 |
+
"""
|
| 115 |
+
filtered = []
|
| 116 |
+
removed_count = 0
|
| 117 |
+
|
| 118 |
+
for play in plays:
|
| 119 |
+
if play.play_type == "kickoff":
|
| 120 |
+
duration = play.end_time - play.start_time
|
| 121 |
+
if duration > self.max_kickoff_duration:
|
| 122 |
+
logger.warning(
|
| 123 |
+
"Filtering invalid kickoff: %.1fs - %.1fs (duration %.1fs exceeds max %.1fs)",
|
| 124 |
+
play.start_time,
|
| 125 |
+
play.end_time,
|
| 126 |
+
duration,
|
| 127 |
+
self.max_kickoff_duration,
|
| 128 |
+
)
|
| 129 |
+
removed_count += 1
|
| 130 |
+
continue
|
| 131 |
+
filtered.append(play)
|
| 132 |
+
|
| 133 |
+
if removed_count > 0:
|
| 134 |
+
logger.info("Kickoff filter removed %d plays exceeding %.1fs duration", removed_count, self.max_kickoff_duration)
|
| 135 |
+
|
| 136 |
+
return filtered
|
| 137 |
+
|
| 138 |
def _deduplicate(self, plays: List[PlayEvent]) -> List[PlayEvent]:
|
| 139 |
"""
|
| 140 |
Remove overlapping and close plays, keeping the highest priority one.
|
src/tracking/play_tracker.py
CHANGED
|
@@ -294,6 +294,9 @@ class PlayTracker:
|
|
| 294 |
is completed, ensuring the opening kickoff is captured even if special plays
|
| 295 |
are created before normal play detection kicks in.
|
| 296 |
|
|
|
|
|
|
|
|
|
|
| 297 |
Args:
|
| 298 |
first_play_start: Start time of the first detected play
|
| 299 |
|
|
|
|
| 294 |
is completed, ensuring the opening kickoff is captured even if special plays
|
| 295 |
are created before normal play detection kicks in.
|
| 296 |
|
| 297 |
+
Note: Kickoff plays that are too long will be filtered out by PlayMerger
|
| 298 |
+
using max_kickoff_duration filter.
|
| 299 |
+
|
| 300 |
Args:
|
| 301 |
first_play_start: Start time of the first detected play
|
| 302 |
|