andytaylor-smg commited on
Commit
f7a96ab
·
1 Parent(s): 5963802

documenting texas

Browse files
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 - this is likely the opening kickoff
131
- logger.info("First clock reading (%d) at %.1fs, entering PRE_SNAP", clock_value, timestamp)
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
- # Start the opening kickoff if not already complete
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._start_opening_kickoff(timestamp)
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 the first clock reading in the video. The opening kickoff
770
- is special because the scorebug appears mid-play (during the kickoff).
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 clock reading was seen
779
  """
780
  self._state.opening_kickoff_active = True
781
- # Don't set current_play_start_time yet - we'll use a different mechanism
782
- # The kickoff tracking is separate from normal play tracking
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