andytaylor-smg commited on
Commit
251faa9
·
1 Parent(s): d12d00d

some large moving

Browse files
src/pipeline/__init__.py CHANGED
@@ -15,6 +15,7 @@ from .models import (
15
  # Pipeline classes and functions
16
  from .play_detector import PlayDetector, format_detection_result_dict
17
  from .orchestrator import run_detection, print_results_summary
 
18
 
19
  __all__ = [
20
  # Models
@@ -26,4 +27,5 @@ __all__ = [
26
  "format_detection_result_dict",
27
  "run_detection",
28
  "print_results_summary",
 
29
  ]
 
15
  # Pipeline classes and functions
16
  from .play_detector import PlayDetector, format_detection_result_dict
17
  from .orchestrator import run_detection, print_results_summary
18
+ from .template_builder_pass import TemplateBuildingPass
19
 
20
  __all__ = [
21
  # Models
 
27
  "format_detection_result_dict",
28
  "run_detection",
29
  "print_results_summary",
30
+ "TemplateBuildingPass",
31
  ]
src/pipeline/parallel.py CHANGED
@@ -123,7 +123,7 @@ def _process_frame(
123
  frame_result["away_timeouts"] = timeout_reading.away_timeouts
124
 
125
  # Extract play clock region and template match
126
- play_clock_region = clock_reader._extract_region(img, scorebug.bbox) # pylint: disable=protected-access
127
  if play_clock_region is not None and template_reader:
128
  clock_result = template_reader.read(play_clock_region)
129
  frame_result["clock_detected"] = clock_result.detected
 
123
  frame_result["away_timeouts"] = timeout_reading.away_timeouts
124
 
125
  # Extract play clock region and template match
126
+ play_clock_region = clock_reader._extract_region(img, scorebug.bbox)
127
  if play_clock_region is not None and template_reader:
128
  clock_result = template_reader.read(play_clock_region)
129
  frame_result["clock_detected"] = clock_result.detected
src/pipeline/play_detector.py CHANGED
@@ -1,4 +1,3 @@
1
- # pylint: disable=too-many-lines
2
  """
3
  Play detector pipeline module.
4
 
@@ -19,160 +18,24 @@ See docs/ocr_to_template_migration.md for details.
19
 
20
  import json
21
  import logging
22
- import queue
23
- import threading
24
  import time
25
  from pathlib import Path
26
  from typing import Optional, List, Dict, Any, Tuple
27
 
28
  import cv2
29
- import easyocr
30
  import numpy as np
31
 
32
  from detection import DetectScoreBug, ScorebugDetection, DetectTimeouts
33
- from readers import ReadPlayClock, PlayClockReading, TemplatePlayClockReading
34
  from setup import DigitTemplateBuilder, DigitTemplateLibrary, PlayClockRegionConfig, PlayClockRegionExtractor
35
- from tracking import TrackPlayState, PlayEvent
 
36
  from .models import DetectionConfig, DetectionResult, VideoContext
37
  from .parallel import process_video_parallel
 
38
 
39
  logger = logging.getLogger(__name__)
40
 
41
- # Global EasyOCR reader instance for template building (lazy-loaded)
42
- _easyocr_reader = None # pylint: disable=invalid-name
43
-
44
-
45
- class ThreadedFrameReader:
46
- """
47
- Background thread for reading video frames.
48
-
49
- Uses a producer-consumer pattern to overlap video I/O with processing.
50
- The reader thread reads frames ahead into a queue while the main thread
51
- processes frames from the queue.
52
-
53
- This provides significant speedup by hiding video decode latency.
54
- """
55
-
56
- def __init__(self, cap: cv2.VideoCapture, start_frame: int, end_frame: int, frame_skip: int, queue_size: int = 32):
57
- """
58
- Initialize the threaded frame reader.
59
-
60
- Args:
61
- cap: OpenCV VideoCapture object
62
- start_frame: First frame to read
63
- end_frame: Last frame to read
64
- frame_skip: Number of frames to skip between reads
65
- queue_size: Maximum frames to buffer (default 32)
66
- """
67
- self.cap = cap
68
- self.start_frame = start_frame
69
- self.end_frame = end_frame
70
- self.frame_skip = frame_skip
71
- self.queue_size = queue_size
72
-
73
- # Frame queue: (frame_number, frame_data) or (frame_number, None) for read failures
74
- self.frame_queue: queue.Queue = queue.Queue(maxsize=queue_size)
75
-
76
- # Control flags
77
- self.stop_flag = threading.Event()
78
- self.reader_thread: Optional[threading.Thread] = None
79
-
80
- # Timing stats
81
- self.io_time = 0.0
82
- self.frames_read = 0
83
-
84
- def start(self) -> None:
85
- """Start the background reader thread."""
86
- self.stop_flag.clear()
87
- self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
88
- self.reader_thread.start()
89
- logger.debug("Threaded frame reader started")
90
-
91
- def stop(self) -> None:
92
- """Stop the background reader thread."""
93
- self.stop_flag.set()
94
- if self.reader_thread and self.reader_thread.is_alive():
95
- # Drain the queue to unblock the reader thread
96
- try:
97
- while True:
98
- self.frame_queue.get_nowait()
99
- except queue.Empty:
100
- pass
101
- self.reader_thread.join(timeout=2.0)
102
- logger.debug("Threaded frame reader stopped (read %d frames, %.2fs I/O)", self.frames_read, self.io_time)
103
-
104
- def get_frame(self, timeout: float = 5.0) -> Optional[Tuple[int, Optional[np.ndarray]]]:
105
- """
106
- Get the next frame from the queue.
107
-
108
- Args:
109
- timeout: Maximum time to wait for a frame
110
-
111
- Returns:
112
- Tuple of (frame_number, frame_data) or None if queue is empty and reader is done
113
- """
114
- try:
115
- return self.frame_queue.get(timeout=timeout)
116
- except queue.Empty:
117
- return None
118
-
119
- def _reader_loop(self) -> None:
120
- """Background thread that reads frames into the queue."""
121
- # Seek to start position
122
- t_start = time.perf_counter()
123
- self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.start_frame)
124
- self.io_time += time.perf_counter() - t_start
125
-
126
- current_frame = self.start_frame
127
-
128
- while current_frame < self.end_frame and not self.stop_flag.is_set():
129
- # Read frame
130
- t_start = time.perf_counter()
131
- ret, frame = self.cap.read()
132
- self.io_time += time.perf_counter() - t_start
133
-
134
- if ret:
135
- self.frames_read += 1
136
- # Put frame in queue (blocks if queue is full)
137
- try:
138
- self.frame_queue.put((current_frame, frame), timeout=5.0)
139
- except queue.Full:
140
- if self.stop_flag.is_set():
141
- break
142
- logger.warning("Frame queue full, dropping frame %d", current_frame)
143
- else:
144
- # Signal read failure
145
- try:
146
- self.frame_queue.put((current_frame, None), timeout=1.0)
147
- except queue.Full:
148
- pass
149
-
150
- # Skip frames
151
- t_start = time.perf_counter()
152
- for _ in range(self.frame_skip - 1):
153
- if self.stop_flag.is_set():
154
- break
155
- self.cap.grab()
156
- self.io_time += time.perf_counter() - t_start
157
-
158
- current_frame += self.frame_skip
159
-
160
- # Signal end of stream
161
- try:
162
- self.frame_queue.put(None, timeout=1.0)
163
- except queue.Full:
164
- pass
165
-
166
-
167
- def _get_easyocr_reader() -> easyocr.Reader:
168
- """Get or create the global EasyOCR reader instance for template building."""
169
- global _easyocr_reader # pylint: disable=global-statement
170
- if _easyocr_reader is None:
171
- logger.info("Initializing EasyOCR reader for template building...")
172
- _easyocr_reader = easyocr.Reader(["en"], gpu=False, verbose=False)
173
- logger.info("EasyOCR reader initialized")
174
- return _easyocr_reader
175
-
176
 
177
  def format_detection_result_dict(result: DetectionResult) -> Dict[str, Any]:
178
  """
@@ -376,22 +239,7 @@ class PlayDetector:
376
  """
377
  Pass 0: Build digit templates by scanning video using TEMPLATE-BASED scorebug detection.
378
 
379
- This pass runs BEFORE the main detection loop. It:
380
- 1. Uses the scorebug template (created during user setup) for detection
381
- 2. Scans through video until finding frames with actual scorebugs
382
- 3. Runs OCR on ONLY frames where scorebug is detected
383
- 4. Builds templates from samples with high-confidence OCR results
384
-
385
- This solves the problem of building templates from pre-game content that
386
- has no scorebug, which causes all subsequent template matching to fail.
387
-
388
- The scorebug is verified using template matching (same as main detection),
389
- not just brightness/contrast heuristics.
390
-
391
- Completion criteria:
392
- - At least min_samples valid OCR samples collected
393
- - OR template coverage >= 70%
394
- - OR scanned max_scan_frames frames
395
 
396
  Args:
397
  timing: Timing dictionary to update
@@ -399,164 +247,18 @@ class PlayDetector:
399
  Returns:
400
  True if templates were built successfully, False otherwise
401
  """
402
- # Configuration for template building scan
403
- min_samples = 200 # Minimum valid OCR samples to collect
404
- max_scan_frames = 2000 # Maximum frames to scan (about 16 minutes at 0.5s interval)
405
- target_coverage = 0.70 # Target template coverage (70%)
406
-
407
- logger.info("Pass 0: Building templates using scorebug template detection...")
408
- logger.info(" Target: %d samples OR %.0f%% template coverage", min_samples, target_coverage * 100)
409
-
410
- t_build_start = time.perf_counter()
411
-
412
- # Need both template and fixed coordinates for Pass 0
413
- if not self.config.template_path:
414
- logger.warning("Pass 0: No scorebug template path provided, cannot detect scorebugs")
415
- return False
416
-
417
- template_path = Path(self.config.template_path)
418
- if not template_path.exists():
419
- logger.warning("Pass 0: Scorebug template not found at %s", template_path)
420
- return False
421
-
422
- if not self.config.fixed_scorebug_coords:
423
- logger.warning("Pass 0: No fixed scorebug coordinates provided")
424
- return False
425
-
426
- sb_x, sb_y, sb_w, sb_h = self.config.fixed_scorebug_coords
427
- logger.info(" Scorebug region: (%d, %d, %d, %d)", sb_x, sb_y, sb_w, sb_h)
428
- logger.info(" Scorebug template: %s", template_path)
429
-
430
- # Create a temporary DetectScoreBug for Pass 0 detection
431
- # This uses the user-created template + fixed region for fast/accurate detection
432
- temp_detector = DetectScoreBug(
433
- template_path=str(template_path),
434
- fixed_region=(sb_x, sb_y, sb_w, sb_h),
435
- use_split_detection=self.config.use_split_detection,
436
  )
437
 
438
- # Get EasyOCR reader for labeling
439
- reader = _get_easyocr_reader()
 
440
 
441
- # Open video for scanning
442
- cap = cv2.VideoCapture(self.config.video_path)
443
- if not cap.isOpened():
444
- logger.error("Pass 0: Could not open video")
445
- return False
446
-
447
- fps = cap.get(cv2.CAP_PROP_FPS)
448
- frame_skip = int(self.config.frame_interval * fps)
449
-
450
- # Scan from start of video to find where game content starts
451
- scan_start_frame = 0
452
-
453
- cap.set(cv2.CAP_PROP_POS_FRAMES, scan_start_frame)
454
-
455
- valid_samples = 0
456
- frames_scanned = 0
457
- frames_with_scorebug = 0
458
- current_frame = scan_start_frame
459
-
460
- logger.info(" Scanning from frame %d (%.1fs)...", scan_start_frame, scan_start_frame / fps)
461
-
462
- while frames_scanned < max_scan_frames:
463
- ret, frame = cap.read()
464
- if not ret:
465
- break
466
-
467
- current_time = current_frame / fps
468
- frames_scanned += 1
469
-
470
- # Use REAL scorebug detection (template matching)
471
- scorebug_result = temp_detector.detect(frame)
472
-
473
- if scorebug_result.detected:
474
- frames_with_scorebug += 1
475
-
476
- # Extract play clock region using the detected scorebug bbox
477
- scorebug_bbox = scorebug_result.bbox
478
- play_clock_region = self.clock_reader._extract_region(frame, scorebug_bbox) # pylint: disable=protected-access
479
-
480
- if play_clock_region is not None:
481
- # Preprocess and run OCR
482
- preprocessed = self.clock_reader._preprocess_for_ocr(play_clock_region) # pylint: disable=protected-access
483
-
484
- try:
485
- ocr_results = reader.readtext(preprocessed, allowlist="0123456789", detail=1)
486
- if ocr_results:
487
- best = max(ocr_results, key=lambda x: x[2])
488
- text, confidence = best[1].strip(), best[2]
489
-
490
- # Parse and validate
491
- if text and confidence >= 0.5:
492
- try:
493
- value = int(text)
494
- if 0 <= value <= 40:
495
- # Add sample to template builder
496
- self.template_builder.add_sample(play_clock_region, value, current_time, confidence)
497
- valid_samples += 1
498
- except ValueError:
499
- pass # Invalid text, skip
500
- except Exception as e: # pylint: disable=broad-except
501
- logger.debug("Pass 0: OCR error at %.1fs: %s", current_time, e)
502
-
503
- # Progress logging every 200 frames
504
- if frames_scanned % 200 == 0:
505
- # Check current template coverage
506
- coverage = self.template_builder.get_coverage_estimate()
507
- logger.info(
508
- " Pass 0 progress: %d frames scanned, %d with scorebug, %d valid samples, ~%.0f%% coverage",
509
- frames_scanned,
510
- frames_with_scorebug,
511
- valid_samples,
512
- coverage * 100,
513
- )
514
-
515
- # Check completion criteria
516
- if valid_samples >= min_samples or coverage >= target_coverage:
517
- logger.info(" Completion criteria met!")
518
- break
519
-
520
- # Skip frames
521
- for _ in range(frame_skip - 1):
522
- cap.grab()
523
- current_frame += frame_skip
524
-
525
- cap.release()
526
-
527
- logger.info(
528
- "Pass 0 scan complete: %d frames, %d with scorebug (%.1f%%), %d valid samples",
529
- frames_scanned,
530
- frames_with_scorebug,
531
- 100 * frames_with_scorebug / max(1, frames_scanned),
532
- valid_samples,
533
- )
534
-
535
- if valid_samples < 50: # Need at least 50 samples to build useful templates
536
- logger.warning("Pass 0: Insufficient samples (%d < 50), template building may fail", valid_samples)
537
- if frames_with_scorebug == 0:
538
- logger.error("Pass 0: No scorebugs detected! Check that the scorebug template matches the video.")
539
- return False
540
-
541
- # Build the templates
542
- self.template_library = self.template_builder.build_templates(min_samples=2)
543
- coverage = self.template_library.get_coverage_status()
544
- logger.info(
545
- "Pass 0 templates built: %d/%d (%.1f%%) coverage",
546
- coverage["total_have"],
547
- coverage["total_needed"],
548
- 100 * coverage["total_have"] / coverage["total_needed"],
549
- )
550
-
551
- # Create template reader
552
- region_w = self.clock_reader.config.width if self.clock_reader.config else 50
553
- region_h = self.clock_reader.config.height if self.clock_reader.config else 28
554
- self.template_reader = ReadPlayClock(self.template_library, region_w, region_h)
555
-
556
- timing["template_building"] = time.perf_counter() - t_build_start
557
- logger.info("Pass 0 complete: Template building took %.2fs", timing["template_building"])
558
-
559
- return True
560
 
561
  def _streaming_detection_pass(self, context: VideoContext, stats: Dict[str, Any], timing: Dict[str, float]) -> List[Dict[str, Any]]:
562
  """
@@ -692,6 +394,9 @@ class PlayDetector:
692
  "clock_detected": False,
693
  }
694
 
 
 
 
695
  if scorebug.detected:
696
  stats["frames_with_scorebug"] += 1
697
 
@@ -700,10 +405,15 @@ class PlayDetector:
700
  timeout_reading = self.timeout_tracker.read_timeouts(frame)
701
  frame_result["home_timeouts"] = timeout_reading.home_timeouts
702
  frame_result["away_timeouts"] = timeout_reading.away_timeouts
 
 
 
 
 
703
 
704
  # Extract play clock region and run template matching immediately
705
  t_start = time.perf_counter()
706
- play_clock_region = self.clock_reader._extract_region(frame, scorebug.bbox) # pylint: disable=protected-access
707
  timing["preprocessing"] += time.perf_counter() - t_start
708
 
709
  if play_clock_region is not None and self.template_reader:
@@ -718,7 +428,7 @@ class PlayDetector:
718
  if clock_result.detected:
719
  stats["frames_with_clock"] += 1
720
 
721
- # Update state machine immediately
722
  t_start = time.perf_counter()
723
  clock_reading = PlayClockReading(
724
  detected=clock_result.detected,
@@ -726,29 +436,32 @@ class PlayDetector:
726
  confidence=clock_result.confidence,
727
  raw_text=f"TEMPLATE_{clock_result.value}" if clock_result.detected else "TEMPLATE_FAILED",
728
  )
729
- self.state_machine.update(current_time, scorebug, clock_reading)
730
  timing["state_machine"] += time.perf_counter() - t_start
731
  else:
732
  # No scorebug - still update state machine
733
  t_start = time.perf_counter()
734
  clock_reading = PlayClockReading(detected=False, value=None, confidence=0.0, raw_text="NO_SCOREBUG")
735
- self.state_machine.update(current_time, scorebug, clock_reading)
736
  timing["state_machine"] += time.perf_counter() - t_start
737
 
738
  return frame_result
739
 
740
  def _finalize_detection(
741
  self,
742
- frame_data: List[Dict[str, Any]],
743
  context: VideoContext,
744
  stats: Dict[str, Any],
745
  timing: Dict[str, float],
746
  ) -> DetectionResult:
747
  """
748
- Finalize detection: clock reset classification and result building.
 
 
 
749
 
750
  Args:
751
- frame_data: Complete frame data from streaming detection pass
752
  context: Video context
753
  stats: Processing stats
754
  timing: Timing breakdown
@@ -756,30 +469,26 @@ class PlayDetector:
756
  Returns:
757
  Final DetectionResult
758
  """
759
- # Frame data already has clock values from streaming pass
 
 
 
 
 
760
 
761
- # Detect and classify clock resets
762
- clock_reset_plays, clock_reset_stats = self._detect_clock_resets(frame_data)
763
  logger.info(
764
- "Clock reset detection: %d total, %d weird (rejected), %d timeouts, %d special plays",
765
  clock_reset_stats.get("total", 0),
766
  clock_reset_stats.get("weird_clock", 0),
767
  clock_reset_stats.get("timeout", 0),
768
  clock_reset_stats.get("special", 0),
769
  )
770
 
771
- # Log timing breakdown
772
- self._log_timing_breakdown(timing)
773
-
774
- # Build result - combine state machine plays with clock reset plays
775
- state_machine_plays = self.state_machine.get_plays()
776
- play_stats = self.state_machine.get_stats()
777
-
778
- # Merge clock reset stats
779
- play_stats["clock_reset_events"] = clock_reset_stats
780
-
781
- # Combine and deduplicate plays
782
- plays = self._merge_plays(state_machine_plays, clock_reset_plays)
783
 
784
  result = DetectionResult(
785
  video=Path(self.config.video_path).name,
@@ -983,7 +692,14 @@ class PlayDetector:
983
  confidence=1.0 if frame.get("clock_detected") else 0.0,
984
  raw_text=f"PARALLEL_{frame.get('clock_value')}" if frame.get("clock_detected") else "PARALLEL_FAILED",
985
  )
986
- self.state_machine.update(frame["timestamp"], scorebug, clock_reading)
 
 
 
 
 
 
 
987
  timing["state_machine"] = time_module.perf_counter() - t_sm_start
988
 
989
  # Update stats dict
@@ -1013,290 +729,6 @@ class PlayDetector:
1013
  else:
1014
  logger.info(" Will build templates using fallback method")
1015
 
1016
- def _detect_clock_resets(self, frame_data: List[Dict[str, Any]]) -> Tuple[List[PlayEvent], Dict[str, int]]:
1017
- """
1018
- Detect and classify 40 -> 25 clock reset events.
1019
-
1020
- Classification:
1021
- - Class A (weird_clock): 25 counts down immediately -> rejected
1022
- - Class B (timeout): Timeout indicator changed -> tracked as timeout
1023
- - Class C (special): Neither A nor B -> special play with extension
1024
-
1025
- Args:
1026
- frame_data: List of frame data with clock values and timeout counts
1027
-
1028
- Returns:
1029
- Tuple of (list of PlayEvent for valid clock resets, stats dict)
1030
- """
1031
- plays = []
1032
- stats = {"total": 0, "weird_clock": 0, "timeout": 0, "special": 0}
1033
-
1034
- # Parameters
1035
- immediate_countdown_window = 2.0 # Seconds to check if 25 counts down
1036
- special_play_extension = 10.0 # Extension for Class C plays
1037
-
1038
- prev_clock = None
1039
- saw_40_at = None
1040
-
1041
- for i, frame in enumerate(frame_data):
1042
- clock_value = frame.get("clock_value")
1043
- timestamp = frame["timestamp"]
1044
-
1045
- if clock_value is not None:
1046
- # Detect 40 -> 25 transition
1047
- if prev_clock == 40 and clock_value == 25:
1048
- stats["total"] += 1
1049
- _ = timestamp - saw_40_at if saw_40_at else 0.0 # time_at_40 for potential future use
1050
-
1051
- # Check if 25 immediately counts down (Class A: weird clock)
1052
- is_immediate_countdown = self._check_immediate_countdown(frame_data, i, immediate_countdown_window)
1053
-
1054
- # Check if timeout changed (Class B: team timeout)
1055
- timeout_team = self._check_timeout_change(frame_data, i)
1056
-
1057
- if is_immediate_countdown:
1058
- # Class A: Weird clock behavior - reject
1059
- stats["weird_clock"] += 1
1060
- logger.debug("Clock reset at %.1fs: weird_clock (25 counts down immediately)", timestamp)
1061
- elif timeout_team:
1062
- # Class B: Team timeout - record but mark as timeout
1063
- stats["timeout"] += 1
1064
- play_end = self._find_clock_reset_play_end(frame_data, i, max_duration=30.0) # Timeouts can last longer
1065
- play = PlayEvent(
1066
- play_number=0,
1067
- start_time=timestamp,
1068
- end_time=play_end,
1069
- confidence=0.8,
1070
- start_method=f"timeout_{timeout_team}",
1071
- end_method="timeout_end",
1072
- direct_end_time=play_end,
1073
- start_clock_value=prev_clock,
1074
- end_clock_value=25,
1075
- play_type="timeout",
1076
- )
1077
- plays.append(play)
1078
- logger.debug("Clock reset at %.1fs: timeout (%s team)", timestamp, timeout_team)
1079
- else:
1080
- # Class C: Special play (injury/punt/FG/XP) - end at scorebug disappear OR max_duration from start
1081
- stats["special"] += 1
1082
- play_end = self._find_clock_reset_play_end(frame_data, i, max_duration=special_play_extension)
1083
- # Determine end method based on whether we hit max duration or scorebug disappeared first
1084
- play_duration = play_end - timestamp
1085
- if play_duration >= special_play_extension - 0.1: # Close to max duration (within tolerance)
1086
- end_method = "max_duration"
1087
- else:
1088
- end_method = "scorebug_disappeared"
1089
- play = PlayEvent(
1090
- play_number=0,
1091
- start_time=timestamp,
1092
- end_time=play_end,
1093
- confidence=0.8,
1094
- start_method="clock_reset_special",
1095
- end_method=end_method,
1096
- direct_end_time=play_end,
1097
- start_clock_value=prev_clock,
1098
- end_clock_value=25,
1099
- play_type="special",
1100
- )
1101
- plays.append(play)
1102
- logger.debug("Clock reset at %.1fs: special play (%.1fs duration)", timestamp, play_end - timestamp)
1103
-
1104
- # Track when 40 first appeared
1105
- if clock_value == 40 and prev_clock != 40:
1106
- saw_40_at = timestamp
1107
-
1108
- prev_clock = clock_value
1109
-
1110
- return plays, stats
1111
-
1112
- def _check_immediate_countdown(self, frame_data: List[Dict[str, Any]], frame_idx: int, window: float) -> bool:
1113
- """Check if 25 immediately starts counting down (indicates weird clock behavior)."""
1114
- reset_timestamp = frame_data[frame_idx]["timestamp"]
1115
-
1116
- for j in range(frame_idx + 1, len(frame_data)):
1117
- frame = frame_data[j]
1118
- elapsed = frame["timestamp"] - reset_timestamp
1119
- if elapsed > window:
1120
- break
1121
- clock_value = frame.get("clock_value")
1122
- if clock_value is not None and clock_value < 25:
1123
- return True # 25 counted down - weird clock
1124
-
1125
- return False
1126
-
1127
- def _check_timeout_change(self, frame_data: List[Dict[str, Any]], frame_idx: int) -> Optional[str]:
1128
- """Check if a timeout indicator changed around the reset."""
1129
- # Get timeout counts before reset
1130
- before_home = None
1131
- before_away = None
1132
-
1133
- for j in range(frame_idx - 1, max(0, frame_idx - 20), -1):
1134
- frame = frame_data[j]
1135
- if frame.get("home_timeouts") is not None:
1136
- before_home = frame.get("home_timeouts", 3)
1137
- before_away = frame.get("away_timeouts", 3)
1138
- break
1139
-
1140
- if before_home is None:
1141
- return None
1142
-
1143
- # Look forward for timeout change (up to 15 seconds)
1144
- frame_interval = frame_data[1]["timestamp"] - frame_data[0]["timestamp"] if len(frame_data) > 1 else 0.5
1145
- max_frames_forward = int(15.0 / frame_interval) if frame_interval > 0 else 30
1146
-
1147
- for j in range(frame_idx, min(len(frame_data), frame_idx + max_frames_forward)):
1148
- frame = frame_data[j]
1149
- if frame.get("home_timeouts") is not None:
1150
- after_home = frame.get("home_timeouts", 3)
1151
- after_away = frame.get("away_timeouts", 3)
1152
-
1153
- if after_home < before_home:
1154
- return "home"
1155
- if after_away < before_away:
1156
- return "away"
1157
-
1158
- return None
1159
-
1160
- def _find_clock_reset_play_end(self, frame_data: List[Dict[str, Any]], frame_idx: int, max_duration: float) -> float:
1161
- """
1162
- Find the end time for a clock reset play (Class C special play).
1163
-
1164
- The play ends when EITHER:
1165
- - Scorebug disappears (cut to commercial/replay)
1166
- - max_duration seconds have elapsed since play START
1167
-
1168
- Whichever comes FIRST.
1169
-
1170
- Args:
1171
- frame_data: Frame data list
1172
- frame_idx: Index of the frame where 40->25 reset occurred
1173
- max_duration: Maximum play duration from start (e.g., 10 seconds)
1174
-
1175
- Returns:
1176
- Play end timestamp
1177
- """
1178
- start_timestamp = frame_data[frame_idx]["timestamp"]
1179
- max_end_time = start_timestamp + max_duration
1180
-
1181
- # Look for scorebug disappearance (but cap at max_duration from start)
1182
- for j in range(frame_idx + 1, len(frame_data)):
1183
- frame = frame_data[j]
1184
- timestamp = frame["timestamp"]
1185
-
1186
- # If we've exceeded max_duration, end the play at max_duration
1187
- if timestamp >= max_end_time:
1188
- return max_end_time
1189
-
1190
- # Check for play clock disappearance (works for both fixed coords and standard mode)
1191
- clock_available = frame.get("clock_detected", frame.get("scorebug_detected", False))
1192
- if not clock_available:
1193
- return timestamp
1194
-
1195
- # Default: end at max_duration (or end of data if shorter)
1196
- return min(max_end_time, frame_data[-1]["timestamp"] if frame_data else max_end_time)
1197
-
1198
- def _merge_plays(self, state_machine_plays: List[PlayEvent], clock_reset_plays: List[PlayEvent]) -> List[PlayEvent]:
1199
- """
1200
- Merge plays from state machine and clock reset detection, removing overlaps and duplicates.
1201
-
1202
- Handles two types of duplicates:
1203
- 1. Overlapping plays (start_time < last.end_time)
1204
- 2. Close plays (start times within proximity_threshold) representing the same event
1205
-
1206
- Args:
1207
- state_machine_plays: Plays from the state machine
1208
- clock_reset_plays: Plays from clock reset detection
1209
-
1210
- Returns:
1211
- Merged list of plays sorted by start time
1212
- """
1213
- all_plays = list(state_machine_plays) + list(clock_reset_plays)
1214
- all_plays.sort(key=lambda p: p.start_time)
1215
-
1216
- if not all_plays:
1217
- return []
1218
-
1219
- # Proximity threshold: plays within this time are considered the same event
1220
- proximity_threshold = 5.0 # seconds
1221
-
1222
- # Remove overlapping and close plays (keep state machine plays over clock reset plays)
1223
- filtered = [all_plays[0]]
1224
- for play in all_plays[1:]:
1225
- last = filtered[-1]
1226
-
1227
- # Check for overlap OR proximity (both indicate same event)
1228
- is_overlapping = play.start_time < last.end_time
1229
- is_close = abs(play.start_time - last.start_time) < proximity_threshold
1230
-
1231
- if is_overlapping or is_close:
1232
- # Same event detected twice - keep the better one
1233
- # Priority: normal > special > timeout (normal plays are most reliable)
1234
- type_priority = {"normal": 3, "special": 2, "timeout": 1}
1235
- last_priority = type_priority.get(last.play_type, 0)
1236
- play_priority = type_priority.get(play.play_type, 0)
1237
-
1238
- if play_priority > last_priority:
1239
- filtered[-1] = play # Replace with higher priority play
1240
- elif play_priority == last_priority and play.confidence > last.confidence:
1241
- filtered[-1] = play # Same priority, but higher confidence
1242
- # else: keep existing
1243
- else:
1244
- filtered.append(play)
1245
-
1246
- # Apply quiet time filter to remove false positives after normal plays
1247
- filtered = self._apply_quiet_time_filter(filtered)
1248
-
1249
- # Renumber plays
1250
- for i, play in enumerate(filtered, 1):
1251
- play.play_number = i
1252
-
1253
- return filtered
1254
-
1255
- def _apply_quiet_time_filter(self, plays: List[PlayEvent], quiet_time: float = 10.0) -> List[PlayEvent]:
1256
- """
1257
- Apply quiet time filter after normal plays.
1258
-
1259
- After a normal play ends, no new special/timeout plays can start for quiet_time seconds.
1260
- This filters out false positives from penalties during plays (false starts, delay of game, etc.).
1261
-
1262
- Args:
1263
- plays: List of plays sorted by start time
1264
- quiet_time: Seconds of quiet time after normal plays (default 10.0)
1265
-
1266
- Returns:
1267
- Filtered list of plays
1268
- """
1269
- if not plays:
1270
- return []
1271
-
1272
- filtered = []
1273
- last_normal_end = -999.0 # Track when last normal play ended
1274
-
1275
- for play in plays:
1276
- # Check if this play starts during quiet time after a normal play
1277
- if play.start_time < last_normal_end + quiet_time and play.play_type != "normal":
1278
- # This non-normal play starts during quiet time - filter it out
1279
- time_since_normal = play.start_time - last_normal_end
1280
- logger.debug(
1281
- "Quiet time filter: Removing %s play at %.1fs (%.1fs after normal play ended)",
1282
- play.play_type,
1283
- play.start_time,
1284
- time_since_normal,
1285
- )
1286
- continue
1287
-
1288
- filtered.append(play)
1289
-
1290
- # Update last normal play end time
1291
- if play.play_type == "normal":
1292
- last_normal_end = play.end_time
1293
-
1294
- removed_count = len(plays) - len(filtered)
1295
- if removed_count > 0:
1296
- logger.info("Quiet time filter removed %d plays", removed_count)
1297
-
1298
- return filtered
1299
-
1300
  def _play_to_dict(self, play: PlayEvent) -> Dict[str, Any]:
1301
  """Convert PlayEvent to dictionary for JSON serialization."""
1302
  return {
 
 
1
  """
2
  Play detector pipeline module.
3
 
 
18
 
19
  import json
20
  import logging
 
 
21
  import time
22
  from pathlib import Path
23
  from typing import Optional, List, Dict, Any, Tuple
24
 
25
  import cv2
 
26
  import numpy as np
27
 
28
  from detection import DetectScoreBug, ScorebugDetection, DetectTimeouts
29
+ from readers import ReadPlayClock, PlayClockReading
30
  from setup import DigitTemplateBuilder, DigitTemplateLibrary, PlayClockRegionConfig, PlayClockRegionExtractor
31
+ from tracking import TrackPlayState, PlayEvent, PlayMerger, TimeoutInfo
32
+ from video import ThreadedFrameReader
33
  from .models import DetectionConfig, DetectionResult, VideoContext
34
  from .parallel import process_video_parallel
35
+ from .template_builder_pass import TemplateBuildingPass
36
 
37
  logger = logging.getLogger(__name__)
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  def format_detection_result_dict(result: DetectionResult) -> Dict[str, Any]:
41
  """
 
239
  """
240
  Pass 0: Build digit templates by scanning video using TEMPLATE-BASED scorebug detection.
241
 
242
+ Delegates to TemplateBuildingPass which handles the actual scanning and OCR.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
  Args:
245
  timing: Timing dictionary to update
 
247
  Returns:
248
  True if templates were built successfully, False otherwise
249
  """
250
+ # Use the extracted TemplateBuildingPass module
251
+ template_pass = TemplateBuildingPass(
252
+ config=self.config,
253
+ clock_reader=self.clock_reader,
254
+ template_builder=self.template_builder,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  )
256
 
257
+ # Run template building
258
+ self.template_library, self.template_reader, build_time = template_pass.run()
259
+ timing["template_building"] = build_time
260
 
261
+ return self.template_library is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
  def _streaming_detection_pass(self, context: VideoContext, stats: Dict[str, Any], timing: Dict[str, float]) -> List[Dict[str, Any]]:
264
  """
 
394
  "clock_detected": False,
395
  }
396
 
397
+ # Initialize timeout_info for state machine
398
+ timeout_info = None
399
+
400
  if scorebug.detected:
401
  stats["frames_with_scorebug"] += 1
402
 
 
405
  timeout_reading = self.timeout_tracker.read_timeouts(frame)
406
  frame_result["home_timeouts"] = timeout_reading.home_timeouts
407
  frame_result["away_timeouts"] = timeout_reading.away_timeouts
408
+ # Create TimeoutInfo for state machine clock reset classification
409
+ timeout_info = TimeoutInfo(
410
+ home_timeouts=timeout_reading.home_timeouts,
411
+ away_timeouts=timeout_reading.away_timeouts,
412
+ )
413
 
414
  # Extract play clock region and run template matching immediately
415
  t_start = time.perf_counter()
416
+ play_clock_region = self.clock_reader.extract_region(frame, scorebug.bbox)
417
  timing["preprocessing"] += time.perf_counter() - t_start
418
 
419
  if play_clock_region is not None and self.template_reader:
 
428
  if clock_result.detected:
429
  stats["frames_with_clock"] += 1
430
 
431
+ # Update state machine immediately with timeout info for clock reset classification
432
  t_start = time.perf_counter()
433
  clock_reading = PlayClockReading(
434
  detected=clock_result.detected,
 
436
  confidence=clock_result.confidence,
437
  raw_text=f"TEMPLATE_{clock_result.value}" if clock_result.detected else "TEMPLATE_FAILED",
438
  )
439
+ self.state_machine.update(current_time, scorebug, clock_reading, timeout_info)
440
  timing["state_machine"] += time.perf_counter() - t_start
441
  else:
442
  # No scorebug - still update state machine
443
  t_start = time.perf_counter()
444
  clock_reading = PlayClockReading(detected=False, value=None, confidence=0.0, raw_text="NO_SCOREBUG")
445
+ self.state_machine.update(current_time, scorebug, clock_reading, timeout_info)
446
  timing["state_machine"] += time.perf_counter() - t_start
447
 
448
  return frame_result
449
 
450
  def _finalize_detection(
451
  self,
452
+ frame_data: List[Dict[str, Any]], # pylint: disable=unused-argument
453
  context: VideoContext,
454
  stats: Dict[str, Any],
455
  timing: Dict[str, float],
456
  ) -> DetectionResult:
457
  """
458
+ Finalize detection: apply filtering and build result.
459
+
460
+ Clock reset classification is now handled inline by TrackPlayState during
461
+ the streaming pass, so we just need to merge/filter and build the result.
462
 
463
  Args:
464
+ frame_data: Complete frame data from streaming detection pass (unused, kept for API compatibility)
465
  context: Video context
466
  stats: Processing stats
467
  timing: Timing breakdown
 
469
  Returns:
470
  Final DetectionResult
471
  """
472
+ # Log timing breakdown
473
+ self._log_timing_breakdown(timing)
474
+
475
+ # Get plays from state machine (clock reset classification already done inline)
476
+ state_machine_plays = self.state_machine.get_plays()
477
+ play_stats = self.state_machine.get_stats()
478
 
479
+ # Log clock reset stats from state machine
480
+ clock_reset_stats = play_stats.get("clock_reset_events", {})
481
  logger.info(
482
+ "Clock reset classification: %d total, %d weird (rejected), %d timeouts, %d special plays",
483
  clock_reset_stats.get("total", 0),
484
  clock_reset_stats.get("weird_clock", 0),
485
  clock_reset_stats.get("timeout", 0),
486
  clock_reset_stats.get("special", 0),
487
  )
488
 
489
+ # Use PlayMerger to deduplicate and apply quiet time filter
490
+ merger = PlayMerger()
491
+ plays = merger.merge(state_machine_plays)
 
 
 
 
 
 
 
 
 
492
 
493
  result = DetectionResult(
494
  video=Path(self.config.video_path).name,
 
692
  confidence=1.0 if frame.get("clock_detected") else 0.0,
693
  raw_text=f"PARALLEL_{frame.get('clock_value')}" if frame.get("clock_detected") else "PARALLEL_FAILED",
694
  )
695
+ # Create timeout info for clock reset classification
696
+ timeout_info = None
697
+ if frame.get("home_timeouts") is not None or frame.get("away_timeouts") is not None:
698
+ timeout_info = TimeoutInfo(
699
+ home_timeouts=frame.get("home_timeouts"),
700
+ away_timeouts=frame.get("away_timeouts"),
701
+ )
702
+ self.state_machine.update(frame["timestamp"], scorebug, clock_reading, timeout_info)
703
  timing["state_machine"] = time_module.perf_counter() - t_sm_start
704
 
705
  # Update stats dict
 
729
  else:
730
  logger.info(" Will build templates using fallback method")
731
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732
  def _play_to_dict(self, play: PlayEvent) -> Dict[str, Any]:
733
  """Convert PlayEvent to dictionary for JSON serialization."""
734
  return {
src/pipeline/template_builder_pass.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Template building pass (Pass 0) for the play detection pipeline.
3
+
4
+ This module handles the initial phase of building digit templates by scanning
5
+ the video for frames with scorebugs and running OCR on the play clock region.
6
+ These templates are then used for fast template matching in subsequent passes.
7
+ """
8
+
9
+ import logging
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Dict, Optional, Tuple
13
+
14
+ import cv2
15
+ import easyocr
16
+
17
+ from detection import DetectScoreBug
18
+ from readers import ReadPlayClock
19
+ from setup import DigitTemplateBuilder, DigitTemplateLibrary, PlayClockRegionExtractor
20
+ from .models import DetectionConfig
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Global EasyOCR reader instance for template building (lazy-loaded)
25
+ _easyocr_reader = None # pylint: disable=invalid-name
26
+
27
+
28
+ def get_easyocr_reader() -> easyocr.Reader:
29
+ """Get or create the global EasyOCR reader instance for template building."""
30
+ global _easyocr_reader # pylint: disable=global-statement
31
+ if _easyocr_reader is None:
32
+ logger.info("Initializing EasyOCR reader for template building...")
33
+ _easyocr_reader = easyocr.Reader(["en"], gpu=False, verbose=False)
34
+ logger.info("EasyOCR reader initialized")
35
+ return _easyocr_reader
36
+
37
+
38
+ class TemplateBuildingPass:
39
+ """
40
+ Build digit templates by scanning video using template-based scorebug detection.
41
+
42
+ This pass runs BEFORE the main detection loop. It:
43
+ 1. Uses the scorebug template (created during user setup) for detection
44
+ 2. Scans through video until finding frames with actual scorebugs
45
+ 3. Runs OCR on ONLY frames where scorebug is detected
46
+ 4. Builds templates from samples with high-confidence OCR results
47
+
48
+ This solves the problem of building templates from pre-game content that
49
+ has no scorebug, which causes all subsequent template matching to fail.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ config: DetectionConfig,
55
+ clock_reader: PlayClockRegionExtractor,
56
+ template_builder: DigitTemplateBuilder,
57
+ min_samples: int = 200,
58
+ max_scan_frames: int = 2000,
59
+ target_coverage: float = 0.70,
60
+ ):
61
+ """
62
+ Initialize the template building pass.
63
+
64
+ Args:
65
+ config: Detection configuration
66
+ clock_reader: Play clock region extractor
67
+ template_builder: Digit template builder
68
+ min_samples: Minimum valid OCR samples to collect
69
+ max_scan_frames: Maximum frames to scan
70
+ target_coverage: Target template coverage (0.0-1.0)
71
+ """
72
+ self.config = config
73
+ self.clock_reader = clock_reader
74
+ self.template_builder = template_builder
75
+ self.min_samples = min_samples
76
+ self.max_scan_frames = max_scan_frames
77
+ self.target_coverage = target_coverage
78
+
79
+ def run(self) -> Tuple[Optional[DigitTemplateLibrary], Optional[ReadPlayClock], float]:
80
+ """
81
+ Run the template building pass.
82
+
83
+ Returns:
84
+ Tuple of (template_library, template_reader, build_time).
85
+ template_library and template_reader may be None if building failed.
86
+ """
87
+ logger.info("Pass 0: Building templates using scorebug template detection...")
88
+ logger.info(" Target: %d samples OR %.0f%% template coverage", self.min_samples, self.target_coverage * 100)
89
+
90
+ t_build_start = time.perf_counter()
91
+
92
+ # Validate configuration
93
+ if not self._validate_config():
94
+ return None, None, time.perf_counter() - t_build_start
95
+
96
+ # Create temporary scorebug detector for Pass 0
97
+ sb_x, sb_y, sb_w, sb_h = self.config.fixed_scorebug_coords
98
+ logger.info(" Scorebug region: (%d, %d, %d, %d)", sb_x, sb_y, sb_w, sb_h)
99
+ logger.info(" Scorebug template: %s", self.config.template_path)
100
+
101
+ temp_detector = DetectScoreBug(
102
+ template_path=str(self.config.template_path),
103
+ fixed_region=(sb_x, sb_y, sb_w, sb_h),
104
+ use_split_detection=self.config.use_split_detection,
105
+ )
106
+
107
+ # Get EasyOCR reader for labeling
108
+ reader = get_easyocr_reader()
109
+
110
+ # Scan video and collect samples
111
+ valid_samples, frames_scanned, frames_with_scorebug = self._scan_video(temp_detector, reader)
112
+
113
+ # Log scan results
114
+ logger.info(
115
+ "Pass 0 scan complete: %d frames, %d with scorebug (%.1f%%), %d valid samples",
116
+ frames_scanned,
117
+ frames_with_scorebug,
118
+ 100 * frames_with_scorebug / max(1, frames_scanned),
119
+ valid_samples,
120
+ )
121
+
122
+ # Check if we have enough samples
123
+ if valid_samples < 50:
124
+ logger.warning("Pass 0: Insufficient samples (%d < 50), template building may fail", valid_samples)
125
+ if frames_with_scorebug == 0:
126
+ logger.error("Pass 0: No scorebugs detected! Check that the scorebug template matches the video.")
127
+ return None, None, time.perf_counter() - t_build_start
128
+
129
+ # Build templates
130
+ template_library = self.template_builder.build_templates(min_samples=2)
131
+ coverage = template_library.get_coverage_status()
132
+ logger.info(
133
+ "Pass 0 templates built: %d/%d (%.1f%%) coverage",
134
+ coverage["total_have"],
135
+ coverage["total_needed"],
136
+ 100 * coverage["total_have"] / coverage["total_needed"],
137
+ )
138
+
139
+ # Create template reader
140
+ region_w = self.clock_reader.config.width if self.clock_reader.config else 50
141
+ region_h = self.clock_reader.config.height if self.clock_reader.config else 28
142
+ template_reader = ReadPlayClock(template_library, region_w, region_h)
143
+
144
+ build_time = time.perf_counter() - t_build_start
145
+ logger.info("Pass 0 complete: Template building took %.2fs", build_time)
146
+
147
+ return template_library, template_reader, build_time
148
+
149
+ def _validate_config(self) -> bool:
150
+ """Validate that required configuration is present."""
151
+ if not self.config.template_path:
152
+ logger.warning("Pass 0: No scorebug template path provided, cannot detect scorebugs")
153
+ return False
154
+
155
+ template_path = Path(self.config.template_path)
156
+ if not template_path.exists():
157
+ logger.warning("Pass 0: Scorebug template not found at %s", template_path)
158
+ return False
159
+
160
+ if not self.config.fixed_scorebug_coords:
161
+ logger.warning("Pass 0: No fixed scorebug coordinates provided")
162
+ return False
163
+
164
+ return True
165
+
166
+ def _scan_video(self, temp_detector: DetectScoreBug, reader: easyocr.Reader) -> Tuple[int, int, int]:
167
+ """
168
+ Scan video frames and collect OCR samples for template building.
169
+
170
+ Args:
171
+ temp_detector: Scorebug detector to use
172
+ reader: EasyOCR reader instance
173
+
174
+ Returns:
175
+ Tuple of (valid_samples, frames_scanned, frames_with_scorebug)
176
+ """
177
+ # Open video
178
+ cap = cv2.VideoCapture(self.config.video_path)
179
+ if not cap.isOpened():
180
+ logger.error("Pass 0: Could not open video")
181
+ return 0, 0, 0
182
+
183
+ fps = cap.get(cv2.CAP_PROP_FPS)
184
+ frame_skip = int(self.config.frame_interval * fps)
185
+
186
+ # Initialize counters
187
+ valid_samples = 0
188
+ frames_scanned = 0
189
+ frames_with_scorebug = 0
190
+ current_frame = 0
191
+
192
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
193
+ logger.info(" Scanning from frame 0 (0.0s)...")
194
+
195
+ try:
196
+ while frames_scanned < self.max_scan_frames:
197
+ ret, frame = cap.read()
198
+ if not ret:
199
+ break
200
+
201
+ current_time = current_frame / fps
202
+ frames_scanned += 1
203
+
204
+ # Detect scorebug using template matching
205
+ scorebug_result = temp_detector.detect(frame)
206
+
207
+ if scorebug_result.detected:
208
+ frames_with_scorebug += 1
209
+ valid_samples += self._process_scorebug_frame(frame, scorebug_result.bbox, current_time, reader)
210
+
211
+ # Progress logging every 200 frames
212
+ if frames_scanned % 200 == 0:
213
+ coverage = self.template_builder.get_coverage_estimate()
214
+ logger.info(
215
+ " Pass 0 progress: %d frames scanned, %d with scorebug, %d valid samples, ~%.0f%% coverage",
216
+ frames_scanned,
217
+ frames_with_scorebug,
218
+ valid_samples,
219
+ coverage * 100,
220
+ )
221
+
222
+ # Check completion criteria
223
+ if valid_samples >= self.min_samples or coverage >= self.target_coverage:
224
+ logger.info(" Completion criteria met!")
225
+ break
226
+
227
+ # Skip frames
228
+ for _ in range(frame_skip - 1):
229
+ cap.grab()
230
+ current_frame += frame_skip
231
+
232
+ finally:
233
+ cap.release()
234
+
235
+ return valid_samples, frames_scanned, frames_with_scorebug
236
+
237
+ def _process_scorebug_frame(self, frame, scorebug_bbox: Tuple[int, int, int, int], current_time: float, reader: easyocr.Reader) -> int:
238
+ """
239
+ Process a frame with detected scorebug and extract play clock via OCR.
240
+
241
+ Args:
242
+ frame: Video frame
243
+ scorebug_bbox: Scorebug bounding box (x, y, w, h)
244
+ current_time: Current timestamp
245
+ reader: EasyOCR reader
246
+
247
+ Returns:
248
+ 1 if a valid sample was collected, 0 otherwise
249
+ """
250
+ # Extract play clock region
251
+ play_clock_region = self.clock_reader.extract_region(frame, scorebug_bbox)
252
+ if play_clock_region is None:
253
+ return 0
254
+
255
+ # Preprocess and run OCR
256
+ preprocessed = self.clock_reader.preprocess_for_ocr(play_clock_region)
257
+
258
+ try:
259
+ ocr_results = reader.readtext(preprocessed, allowlist="0123456789", detail=1)
260
+ if not ocr_results:
261
+ return 0
262
+
263
+ best = max(ocr_results, key=lambda x: x[2])
264
+ text, confidence = best[1].strip(), best[2]
265
+
266
+ # Parse and validate
267
+ if text and confidence >= 0.5:
268
+ try:
269
+ value = int(text)
270
+ if 0 <= value <= 40:
271
+ self.template_builder.add_sample(play_clock_region, value, current_time, confidence)
272
+ return 1
273
+ except ValueError:
274
+ pass # Invalid text, skip
275
+
276
+ except Exception as e: # pylint: disable=broad-except
277
+ logger.debug("Pass 0: OCR error at %.1fs: %s", current_time, e)
278
+
279
+ return 0
src/tracking/__init__.py CHANGED
@@ -4,13 +4,20 @@ This package contains components that track state across multiple frames
4
  to detect play boundaries and other temporal events.
5
  """
6
 
7
- from .models import PlayEvent
8
- from .play_state import TrackPlayState, PlayState
 
9
 
10
  __all__ = [
11
  # Models
12
  "PlayEvent",
 
 
 
 
 
13
  # State machine
14
  "TrackPlayState",
15
- "PlayState",
 
16
  ]
 
4
  to detect play boundaries and other temporal events.
5
  """
6
 
7
+ from .models import PlayEvent, PlayState, PlayTrackingState, TrackPlayStateConfig, TimeoutInfo, ClockResetStats
8
+ from .play_state import TrackPlayState
9
+ from .play_merger import PlayMerger
10
 
11
  __all__ = [
12
  # Models
13
  "PlayEvent",
14
+ "PlayState",
15
+ "PlayTrackingState",
16
+ "TrackPlayStateConfig",
17
+ "TimeoutInfo",
18
+ "ClockResetStats",
19
  # State machine
20
  "TrackPlayState",
21
+ # Merger
22
+ "PlayMerger",
23
  ]
src/tracking/models.py CHANGED
@@ -1,14 +1,26 @@
1
  """
2
  Pydantic models for play tracking.
3
 
4
- These models represent detected plays and their temporal boundaries.
 
5
  """
6
 
7
- from typing import Optional
 
8
 
9
  from pydantic import BaseModel, Field
10
 
11
 
 
 
 
 
 
 
 
 
 
 
12
  class PlayEvent(BaseModel):
13
  """Represents a detected play with start and end times."""
14
 
@@ -22,3 +34,67 @@ class PlayEvent(BaseModel):
22
  start_clock_value: Optional[int] = Field(None, description="Clock value at start detection")
23
  end_clock_value: Optional[int] = Field(None, description="Clock value used for backward calculation")
24
  play_type: str = Field("normal", description="Type of play: 'normal', 'special' (punt/fg/xp after 25-second reset)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Pydantic models for play tracking.
3
 
4
+ These models represent detected plays, their temporal boundaries,
5
+ and the state machine configuration/state for play detection.
6
  """
7
 
8
+ from enum import Enum
9
+ from typing import Optional, List, Tuple
10
 
11
  from pydantic import BaseModel, Field
12
 
13
 
14
+ class PlayState(Enum):
15
+ """Current state of play detection."""
16
+
17
+ IDLE = "idle" # No scorebug detected, waiting
18
+ PRE_SNAP = "pre_snap" # Scorebug visible, clock ticking down before snap
19
+ PLAY_IN_PROGRESS = "play_in_progress" # Ball snapped, play is live
20
+ POST_PLAY = "post_play" # Play ended, waiting for next play setup
21
+ NO_SCOREBUG = "no_scorebug" # Scorebug lost during/after play (e.g., replay)
22
+
23
+
24
  class PlayEvent(BaseModel):
25
  """Represents a detected play with start and end times."""
26
 
 
34
  start_clock_value: Optional[int] = Field(None, description="Clock value at start detection")
35
  end_clock_value: Optional[int] = Field(None, description="Clock value used for backward calculation")
36
  play_type: str = Field("normal", description="Type of play: 'normal', 'special' (punt/fg/xp after 25-second reset)")
37
+
38
+
39
+ class TrackPlayStateConfig(BaseModel):
40
+ """Configuration settings for play state tracking.
41
+
42
+ These values control the detection thresholds and timing parameters
43
+ used by the state machine to identify play boundaries.
44
+ """
45
+
46
+ clock_stable_frames: int = Field(3, description="Frames with same clock value to consider it 'stable'")
47
+ max_play_duration: float = Field(15.0, description="Maximum expected play duration in seconds")
48
+ scorebug_lost_timeout: float = Field(30.0, description="Seconds before resetting state when scorebug lost")
49
+ required_countdown_ticks: int = Field(3, description="Number of consecutive descending ticks required to confirm play end")
50
+ min_clock_jump_for_reset: int = Field(5, description="Minimum jump in clock value to consider it a valid reset (40 from X where X <= 40 - this value)")
51
+
52
+
53
+ class TimeoutInfo(BaseModel):
54
+ """Timeout information for a frame."""
55
+
56
+ home_timeouts: Optional[int] = Field(None, description="Number of home team timeouts remaining")
57
+ away_timeouts: Optional[int] = Field(None, description="Number of away team timeouts remaining")
58
+
59
+
60
+ class ClockResetStats(BaseModel):
61
+ """Statistics about clock reset classifications."""
62
+
63
+ total: int = Field(0, description="Total 40→25 resets detected")
64
+ weird_clock: int = Field(0, description="Class A: 25 counts down immediately (rejected)")
65
+ timeout: int = Field(0, description="Class B: Timeout indicator changed")
66
+ special: int = Field(0, description="Class C: Special play (injury/punt/FG/XP)")
67
+
68
+
69
+ class PlayTrackingState(BaseModel):
70
+ """Internal mutable state for play tracking.
71
+
72
+ Tracks the current detection state, detected plays, and all intermediate
73
+ tracking variables used during play boundary detection.
74
+ """
75
+
76
+ # Current detection state
77
+ state: PlayState = Field(PlayState.IDLE, description="Current state of the play detection state machine")
78
+ plays: List[PlayEvent] = Field(default_factory=list, description="List of all detected plays")
79
+
80
+ # Tracking counters and values
81
+ play_count: int = Field(0, description="Total number of plays detected so far")
82
+ last_clock_value: Optional[int] = Field(None, description="Last observed play clock value")
83
+ last_clock_timestamp: Optional[float] = Field(None, description="Timestamp of last clock reading")
84
+ clock_stable_count: int = Field(0, description="Number of consecutive frames with same clock value")
85
+
86
+ # Current play tracking
87
+ current_play_start_time: Optional[float] = Field(None, description="Start time of the current play being tracked")
88
+ current_play_start_method: Optional[str] = Field(None, description="Method used to detect current play start")
89
+ current_play_start_clock: Optional[int] = Field(None, description="Clock value when current play started")
90
+ last_scorebug_timestamp: Optional[float] = Field(None, description="Timestamp of last scorebug detection")
91
+ direct_end_time: Optional[float] = Field(None, description="Direct end time observation (for comparison)")
92
+ countdown_history: List[Tuple[float, int]] = Field(default_factory=list, description="List of (timestamp, clock_value) for countdown tracking")
93
+ first_40_timestamp: Optional[float] = Field(None, description="When we first saw 40 in current play (for turnover detection)")
94
+ current_play_clock_base: int = Field(40, description="Clock base for current play (40 for normal, 25 for special teams)")
95
+ current_play_type: str = Field("normal", description="Type of current play being tracked: 'normal' or 'special'")
96
+
97
+ # Timeout tracking for clock reset classification
98
+ last_home_timeouts: Optional[int] = Field(None, description="Last observed home team timeouts")
99
+ last_away_timeouts: Optional[int] = Field(None, description="Last observed away team timeouts")
100
+ clock_reset_stats: ClockResetStats = Field(default_factory=ClockResetStats, description="Statistics about clock reset classifications")
src/tracking/play_merger.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Play merger module for combining and filtering detected plays.
3
+
4
+ This module handles merging plays from multiple sources (state machine, clock reset detection),
5
+ removing duplicates, and applying filters to reduce false positives.
6
+ """
7
+
8
+ import logging
9
+ from typing import List
10
+
11
+ from .models import PlayEvent
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class PlayMerger:
17
+ """
18
+ Merge plays from multiple sources with deduplication and filtering.
19
+
20
+ Handles:
21
+ - Overlapping plays (start_time < last.end_time)
22
+ - Close plays (start times within proximity_threshold) representing same event
23
+ - Quiet time filtering after normal plays
24
+ """
25
+
26
+ def __init__(self, proximity_threshold: float = 5.0, quiet_time: float = 10.0):
27
+ """
28
+ Initialize the play merger.
29
+
30
+ Args:
31
+ proximity_threshold: Plays within this time (seconds) are considered the same event
32
+ quiet_time: Seconds after normal play ends before special/timeout plays are allowed
33
+ """
34
+ self.proximity_threshold = proximity_threshold
35
+ self.quiet_time = quiet_time
36
+
37
+ def merge(self, *play_lists: List[PlayEvent]) -> List[PlayEvent]:
38
+ """
39
+ Merge multiple lists of plays, removing overlaps and duplicates.
40
+
41
+ Args:
42
+ play_lists: Variable number of play lists to merge
43
+
44
+ Returns:
45
+ Merged list of plays sorted by start time, deduplicated and renumbered
46
+ """
47
+ # Combine all play lists
48
+ all_plays = []
49
+ for plays in play_lists:
50
+ all_plays.extend(plays)
51
+
52
+ all_plays.sort(key=lambda p: p.start_time)
53
+
54
+ if not all_plays:
55
+ return []
56
+
57
+ # Remove overlapping and close plays
58
+ filtered = self._deduplicate(all_plays)
59
+
60
+ # Apply quiet time filter
61
+ filtered = self._apply_quiet_time_filter(filtered)
62
+
63
+ # Renumber plays
64
+ for i, play in enumerate(filtered, 1):
65
+ play.play_number = i
66
+
67
+ return filtered
68
+
69
+ def _deduplicate(self, plays: List[PlayEvent]) -> List[PlayEvent]:
70
+ """
71
+ Remove overlapping and close plays, keeping the highest priority one.
72
+
73
+ Priority: normal > special > timeout (normal plays are most reliable)
74
+
75
+ Args:
76
+ plays: Sorted list of plays
77
+
78
+ Returns:
79
+ Deduplicated list of plays
80
+ """
81
+ if not plays:
82
+ return []
83
+
84
+ filtered = [plays[0]]
85
+
86
+ for play in plays[1:]:
87
+ last = filtered[-1]
88
+
89
+ # Check for overlap OR proximity (both indicate same event)
90
+ is_overlapping = play.start_time < last.end_time
91
+ is_close = abs(play.start_time - last.start_time) < self.proximity_threshold
92
+
93
+ if is_overlapping or is_close:
94
+ # Same event detected twice - keep the better one
95
+ # Priority: normal > special > timeout (normal plays are most reliable)
96
+ type_priority = {"normal": 3, "special": 2, "timeout": 1}
97
+ last_priority = type_priority.get(last.play_type, 0)
98
+ play_priority = type_priority.get(play.play_type, 0)
99
+
100
+ if play_priority > last_priority:
101
+ filtered[-1] = play # Replace with higher priority play
102
+ elif play_priority == last_priority and play.confidence > last.confidence:
103
+ filtered[-1] = play # Same priority, but higher confidence
104
+ # else: keep existing
105
+ else:
106
+ filtered.append(play)
107
+
108
+ return filtered
109
+
110
+ def _apply_quiet_time_filter(self, plays: List[PlayEvent]) -> List[PlayEvent]:
111
+ """
112
+ Apply quiet time filter after normal plays.
113
+
114
+ After a normal play ends, no new special/timeout plays can start for quiet_time seconds.
115
+ This filters out false positives from penalties during plays (false starts, delay of game, etc.).
116
+
117
+ Args:
118
+ plays: List of plays sorted by start time
119
+
120
+ Returns:
121
+ Filtered list of plays
122
+ """
123
+ if not plays:
124
+ return []
125
+
126
+ filtered = []
127
+ last_normal_end = -999.0 # Track when last normal play ended
128
+
129
+ for play in plays:
130
+ # Check if this play starts during quiet time after a normal play
131
+ if play.start_time < last_normal_end + self.quiet_time and play.play_type != "normal":
132
+ # This non-normal play starts during quiet time - filter it out
133
+ time_since_normal = play.start_time - last_normal_end
134
+ logger.debug(
135
+ "Quiet time filter: Removing %s play at %.1fs (%.1fs after normal play ended)",
136
+ play.play_type,
137
+ play.start_time,
138
+ time_since_normal,
139
+ )
140
+ continue
141
+
142
+ filtered.append(play)
143
+
144
+ # Update last normal play end time
145
+ if play.play_type == "normal":
146
+ last_normal_end = play.end_time
147
+
148
+ removed_count = len(plays) - len(filtered)
149
+ if removed_count > 0:
150
+ logger.info("Quiet time filter removed %d plays", removed_count)
151
+
152
+ return filtered
src/tracking/play_state.py CHANGED
@@ -5,32 +5,25 @@ Play state machine module for detecting play start and end times.
5
  This module tracks play clock state changes to determine when plays begin and end.
6
  The primary method for determining play end time is backward counting from the
7
  next observed play clock value after the play.
 
 
 
 
 
 
8
  """
9
 
10
  import logging
11
- from dataclasses import dataclass, field
12
- from enum import Enum
13
  from typing import Optional, List
14
 
15
  from detection import ScorebugDetection
16
  from readers import PlayClockReading
17
- from .models import PlayEvent
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
 
22
- class PlayState(Enum):
23
- """Current state of play detection."""
24
-
25
- IDLE = "idle" # No scorebug detected, waiting
26
- PRE_SNAP = "pre_snap" # Scorebug visible, clock ticking down before snap
27
- PLAY_IN_PROGRESS = "play_in_progress" # Ball snapped, play is live
28
- POST_PLAY = "post_play" # Play ended, waiting for next play setup
29
- NO_SCOREBUG = "no_scorebug" # Scorebug lost during/after play (e.g., replay)
30
-
31
-
32
- @dataclass
33
- class TrackPlayState: # pylint: disable=too-many-instance-attributes
34
  """
35
  State machine for detecting play boundaries using play clock behavior.
36
 
@@ -46,33 +39,44 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
46
  This method is reliable even when the broadcast cuts to replays.
47
  """
48
 
49
- # Configuration
50
- clock_stable_frames: int = 3 # Frames with same clock value to consider it "stable"
51
- max_play_duration: float = 15.0 # Maximum expected play duration in seconds
52
- scorebug_lost_timeout: float = 30.0 # Seconds before resetting state when scorebug lost
53
- required_countdown_ticks: int = 3 # Number of consecutive descending ticks required to confirm play end
54
- min_clock_jump_for_reset: int = 5 # Minimum jump in clock value to consider it a valid reset (40 from X where X <= 40 - this value)
55
-
56
- # Internal state
57
- state: PlayState = field(default=PlayState.IDLE)
58
- plays: List[PlayEvent] = field(default_factory=list)
59
-
60
- # Tracking variables
61
- _play_count: int = field(default=0)
62
- _last_clock_value: Optional[int] = field(default=None)
63
- _last_clock_timestamp: Optional[float] = field(default=None)
64
- _clock_stable_count: int = field(default=0)
65
- _current_play_start_time: Optional[float] = field(default=None)
66
- _current_play_start_method: Optional[str] = field(default=None)
67
- _current_play_start_clock: Optional[int] = field(default=None)
68
- _last_scorebug_timestamp: Optional[float] = field(default=None)
69
- _direct_end_time: Optional[float] = field(default=None)
70
- _countdown_history: List[tuple] = field(default_factory=list) # List of (timestamp, clock_value) for countdown tracking
71
- _first_40_timestamp: Optional[float] = field(default=None) # When we first saw 40 in current play (for turnover detection)
72
- _current_play_clock_base: int = field(default=40) # Clock base for current play (40 for normal, 25 for special teams)
73
- _current_play_type: str = field(default="normal") # Type of current play being tracked
74
-
75
- def update(self, timestamp: float, scorebug: ScorebugDetection, clock: PlayClockReading) -> Optional[PlayEvent]:
 
 
 
 
 
 
 
 
 
 
 
76
  """
77
  Update the state machine with new frame data.
78
 
@@ -80,16 +84,24 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
80
  timestamp: Current video timestamp in seconds
81
  scorebug: Scorebug detection result
82
  clock: Play clock reading result
 
83
 
84
  Returns:
85
  PlayEvent if a play just ended, None otherwise
86
  """
 
 
 
 
 
 
 
87
  # Handle scorebug presence/absence
88
  if not scorebug.detected:
89
  return self._handle_no_scorebug(timestamp)
90
 
91
  # Update last scorebug timestamp
92
- self._last_scorebug_timestamp = timestamp
93
 
94
  # Handle invalid clock reading
95
  if not clock.detected or clock.value is None:
@@ -97,30 +109,30 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
97
  return None
98
 
99
  # Process valid clock reading
100
- return self._process_clock_value(timestamp, clock.value)
101
 
102
  def _handle_no_scorebug(self, timestamp: float) -> Optional[PlayEvent]:
103
  """Handle case when scorebug is not detected."""
104
- if self.state == PlayState.IDLE:
105
  return None
106
 
107
  # Check if we've lost scorebug for too long
108
- if self._last_scorebug_timestamp is not None:
109
- time_since_scorebug = timestamp - self._last_scorebug_timestamp
110
- if time_since_scorebug > self.scorebug_lost_timeout:
111
  logger.warning("Scorebug lost for %.1fs, resetting to IDLE", time_since_scorebug)
112
  self._reset_state()
113
  return None
114
 
115
  # TURNOVER DETECTION: If we were in PLAY_IN_PROGRESS with significant time at 40,
116
  # and scorebug disappears (likely for replay/review), record the play.
117
- if self.state == PlayState.PLAY_IN_PROGRESS and self._first_40_timestamp is not None:
118
- time_at_40 = (self._last_scorebug_timestamp - self._first_40_timestamp) if self._last_scorebug_timestamp else 0
119
  min_time_for_play = 2.0
120
 
121
  if time_at_40 > min_time_for_play:
122
  # Play happened, scorebug disappeared (likely for replay/review)
123
- play_end_time = self._last_scorebug_timestamp if self._last_scorebug_timestamp else timestamp
124
  logger.info(
125
  "Scorebug disappeared during play at %.1fs (%.1fs at 40). Recording play end at %.1fs.",
126
  timestamp,
@@ -129,156 +141,238 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
129
  )
130
  # Record the play before transitioning
131
  completed_play = self._end_play_with_backward_calc(timestamp, 40, play_end_time)
132
- self.state = PlayState.NO_SCOREBUG
133
  return completed_play
134
 
135
  # Transition to NO_SCOREBUG state if we were in a play
136
- if self.state in (PlayState.PRE_SNAP, PlayState.PLAY_IN_PROGRESS, PlayState.POST_PLAY):
137
  logger.debug("Scorebug lost at %.1fs, entering NO_SCOREBUG state", timestamp)
138
- self.state = PlayState.NO_SCOREBUG
139
 
140
  return None
141
 
142
  def _handle_invalid_clock(self, timestamp: float) -> None:
143
  """Handle case when clock reading is invalid but scorebug is present."""
144
  # If we're in pre-snap and clock becomes unreadable, might indicate play started
145
- if self.state == PlayState.PRE_SNAP and self._last_clock_value is not None:
146
  # Clock became unreadable - could be play in progress
147
  logger.debug("Clock unreadable at %.1fs in PRE_SNAP state", timestamp)
148
 
149
- def _process_clock_value(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
150
  """
151
  Process a valid clock reading and update state.
152
 
153
  Args:
154
  timestamp: Current timestamp
155
  clock_value: Detected play clock value (0-40)
 
156
 
157
  Returns:
158
  PlayEvent if a play just completed
159
  """
160
  completed_play = None
161
 
162
- if self.state == PlayState.IDLE:
163
  # First clock reading - transition to PRE_SNAP
164
  logger.debug("First clock reading (%d) at %.1fs, entering PRE_SNAP", clock_value, timestamp)
165
- self.state = PlayState.PRE_SNAP
166
- self._last_clock_value = clock_value
167
- self._last_clock_timestamp = timestamp
168
- self._clock_stable_count = 1
169
 
170
- elif self.state == PlayState.PRE_SNAP:
171
  # Watching for play to start (clock reset to 40 or freeze)
172
- completed_play = self._handle_pre_snap(timestamp, clock_value) # pylint: disable=assignment-from-none
173
 
174
- elif self.state == PlayState.PLAY_IN_PROGRESS:
175
  # Play is live, watching for it to end (clock restarts)
176
  completed_play = self._handle_play_in_progress(timestamp, clock_value)
177
 
178
- elif self.state == PlayState.POST_PLAY:
179
  # Play ended, transitioning back to PRE_SNAP
180
  self._handle_post_play(timestamp, clock_value)
181
 
182
- elif self.state == PlayState.NO_SCOREBUG:
183
  # Scorebug returned after being lost
184
  completed_play = self._handle_scorebug_returned(timestamp, clock_value)
185
 
186
  # Update tracking
187
- self._last_clock_value = clock_value
188
- self._last_clock_timestamp = timestamp
189
 
190
  return completed_play
191
 
192
- def _handle_pre_snap(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
193
- """Handle clock reading during PRE_SNAP state."""
194
- if self._last_clock_value is None:
195
- self._last_clock_value = clock_value
196
- self._clock_stable_count = 1
 
 
 
 
 
 
197
  return None
198
 
199
  # Check for clock reset to 40 (indicates ball was snapped for normal play)
200
  # Require a significant jump in clock value to avoid false positives from OCR noise
201
  # e.g., "40 from 39" is likely OCR noise, but "40 from 25" is a real reset
202
- max_prev_value = 40 - self.min_clock_jump_for_reset # e.g., 35 if min_jump=5
203
- if clock_value == 40 and self._last_clock_value <= max_prev_value:
204
- logger.info("Play START detected at %.1fs (clock reset to 40 from %d)", timestamp, self._last_clock_value)
205
- self._current_play_clock_base = 40
206
- self._current_play_type = "normal"
207
- self._start_play(timestamp, "clock_reset", self._last_clock_value)
208
  return None
209
 
210
  # Reject suspicious clock resets that look like OCR noise
211
- if clock_value == 40 and self._last_clock_value > max_prev_value:
212
  logger.debug(
213
  "Ignoring suspicious clock reset at %.1fs (40 from %d, requires prev <= %d)",
214
  timestamp,
215
- self._last_clock_value,
216
  max_prev_value,
217
  )
218
- # Don't update _last_clock_value - treat this 40 reading as noise
219
  return None
220
 
221
- # NEW: Check for clock reset to 25 (indicates special teams play - punt return, kickoff return, post-FG/XP)
222
- # This happens after possession-changing plays like punts, FGs, XPs
223
- # The clock resets to 25 instead of 40 for these plays
224
- if clock_value == 25 and self._last_clock_value is not None:
225
- clock_jump = abs(clock_value - self._last_clock_value)
 
 
226
  # Require significant jump to avoid false positives from normal countdown through 25
227
- # A jump of 5+ indicates a real reset (e.g., 40→25, 30→25 after brief 40, or 10→25 after play)
228
- if clock_jump >= self.min_clock_jump_for_reset:
229
  logger.info(
230
  "Special teams play START detected at %.1fs (clock reset to 25 from %d, jump of %d)",
231
  timestamp,
232
- self._last_clock_value,
233
  clock_jump,
234
  )
235
- self._current_play_clock_base = 25
236
- self._current_play_type = "special"
237
- self._start_play(timestamp, "clock_reset_25", self._last_clock_value)
238
  return None
239
 
240
  # Track clock stability (for potential future use)
241
- if clock_value == self._last_clock_value:
242
- self._clock_stable_count += 1
243
  else:
244
- self._clock_stable_count = 1
245
 
246
  # Note: "clock_freeze" detection disabled - was causing false positives
247
  # The clock_reset detection (going to 40 or 25) is the reliable method
248
  return None
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  # pylint: disable=too-many-return-statements,too-many-branches
251
  def _handle_play_in_progress(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
252
  """Handle clock reading during PLAY_IN_PROGRESS state."""
253
- if self._current_play_start_time is None:
254
  return None
255
 
256
  # Check for play duration timeout
257
- play_duration = timestamp - self._current_play_start_time
258
- if play_duration > self.max_play_duration:
259
  # Cap the end time at start + max_duration to avoid inflated durations
260
  # This prevents long gaps (commercials, etc.) from extending play end times
261
- capped_end_time = self._current_play_start_time + self.max_play_duration
262
  logger.warning(
263
  "Play duration (%.1fs) exceeded max (%.1fs), forcing end at %.1fs (capped from %.1fs)",
264
  play_duration,
265
- self.max_play_duration,
266
  capped_end_time,
267
  timestamp,
268
  )
269
- self._direct_end_time = capped_end_time
270
- self._countdown_history = [] # Reset countdown tracking
271
  return self._end_play_capped(capped_end_time, clock_value, "max_duration")
272
 
273
  # If clock is still at 40, the play just started and clock hasn't begun countdown yet
274
  # We need to wait for the clock to drop below 40 before we can detect play end
275
  if clock_value == 40:
276
  # Track when we first saw 40 (for turnover detection)
277
- if self._first_40_timestamp is None:
278
- self._first_40_timestamp = timestamp
279
  # Clock is still at 40 after reset - waiting for countdown to begin
280
- logger.debug("Play in progress at %.1fs, clock still at 40 (%.1fs at 40)", timestamp, timestamp - self._first_40_timestamp)
281
- self._countdown_history = [] # Reset countdown tracking
282
  return None
283
 
284
  # RAPID 40→25 TRANSITION DETECTION (possession change during play):
@@ -292,12 +386,12 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
292
  # 1. Mark this as a special play
293
  # 2. Update the clock base to 25
294
  # 3. Continue tracking until proper end detection (countdown confirmed, scorebug lost, or max duration)
295
- if clock_value == 25 and self._first_40_timestamp is not None:
296
- time_at_40 = timestamp - self._first_40_timestamp
297
  max_time_for_possession_change = 5.0 # 40→25 within 5 seconds indicates possession change
298
  min_time_at_40 = 0.5 # Must be at 40 briefly to avoid OCR noise (lowered from 1.0 for timing precision)
299
 
300
- if min_time_at_40 <= time_at_40 <= max_time_for_possession_change and len(self._countdown_history) == 0:
301
  # Possession change detected - play continues (e.g., punt return in progress)
302
  # DO NOT end the play here - let it continue until proper end detection
303
  logger.info(
@@ -306,10 +400,10 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
306
  time_at_40,
307
  )
308
  # Mark as special play and update clock base for proper end detection
309
- self._current_play_type = "special"
310
- self._current_play_clock_base = 25
311
- self._first_40_timestamp = None # Reset since we're now tracking at 25
312
- self._countdown_history = [] # Reset countdown tracking for fresh detection at 25
313
  return None # Continue tracking - DO NOT end the play!
314
 
315
  # ABNORMAL CLOCK DROP DETECTION:
@@ -318,13 +412,13 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
318
  # 1. A timeout/injury reset (no play happened) - clock was at 40 briefly
319
  # 2. A turnover/possession change (play DID happen) - clock was at 40 for several seconds during play
320
  # We distinguish by checking how long the clock was at 40.
321
- if len(self._countdown_history) == 0:
322
  # This is the first reading after 40
323
  clock_drop = 40 - clock_value
324
  max_normal_drop = 5 # Allow up to 5 second drop on first reading (accounts for timing/OCR variance)
325
  if clock_drop > max_normal_drop:
326
  # Check how long clock was at 40 to distinguish turnover from timeout
327
- time_at_40 = (timestamp - self._first_40_timestamp) if self._first_40_timestamp else 0
328
  min_time_for_play = 2.0 # If clock was at 40 for > 2 seconds, a play likely happened
329
 
330
  if time_at_40 > min_time_for_play:
@@ -349,17 +443,17 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
349
  time_at_40,
350
  )
351
  self._reset_play_tracking()
352
- self.state = PlayState.PRE_SNAP
353
  return None
354
 
355
  # Track countdown history for confirming play end
356
  # We require K consecutive descending ticks to confirm
357
- self._countdown_history.append((timestamp, clock_value))
358
 
359
  # Check if we have enough consecutive descending values
360
- if len(self._countdown_history) >= self.required_countdown_ticks:
361
  # Get last K readings
362
- recent = self._countdown_history[-self.required_countdown_ticks :]
363
  values = [v for _, v in recent]
364
 
365
  # Check if values are strictly descending (or stable which means same second)
@@ -374,19 +468,19 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
374
  # Use the first reading in our confirmed sequence for backward calculation
375
  first_timestamp, first_value = recent[0]
376
  # Use the correct clock base (40 for normal plays, 25 for special teams)
377
- calculated_end_time = first_timestamp - (self._current_play_clock_base - first_value)
378
  logger.info(
379
  "Play END confirmed via %d-tick countdown: %.1fs (clock=%d→%d, base=%d, observed %.1fs-%.1fs)",
380
- self.required_countdown_ticks,
381
  calculated_end_time,
382
  values[0],
383
  values[-1],
384
- self._current_play_clock_base,
385
  recent[0][0],
386
  recent[-1][0],
387
  )
388
- self._direct_end_time = timestamp # When we confirmed the countdown
389
- self._countdown_history = [] # Reset for next play
390
  return self._end_play_with_backward_calc(timestamp, first_value, calculated_end_time)
391
 
392
  return None
@@ -396,22 +490,22 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
396
  # Note: clock_value unused for now, but kept for potential future use
397
  # Transition back to PRE_SNAP
398
  logger.debug("Transitioning from POST_PLAY to PRE_SNAP at %.1fs", timestamp)
399
- self.state = PlayState.PRE_SNAP
400
- self._clock_stable_count = 1
401
 
402
  def _handle_scorebug_returned(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
403
  """Handle scorebug returning after being lost."""
404
  completed_play = None
405
 
406
  # If we were tracking a play, use backward counting to determine when it ended
407
- if self._current_play_start_time is not None:
408
  # Calculate when play ended using backward counting with correct clock base
409
- calculated_end_time = timestamp - (self._current_play_clock_base - clock_value)
410
  logger.info(
411
  "Scorebug returned at %.1fs (clock=%d, base=%d), backward calc play end: %.1fs",
412
  timestamp,
413
  clock_value,
414
- self._current_play_clock_base,
415
  calculated_end_time,
416
  )
417
  completed_play = self._end_play_with_backward_calc(timestamp, clock_value, calculated_end_time)
@@ -419,39 +513,39 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
419
  # No play was in progress, just return to PRE_SNAP
420
  logger.debug("Scorebug returned at %.1fs, no play in progress", timestamp)
421
 
422
- self.state = PlayState.PRE_SNAP
423
- self._clock_stable_count = 1
424
  return completed_play
425
 
426
  def _start_play(self, timestamp: float, method: str, clock_value: Optional[int]) -> None:
427
  """Record the start of a new play."""
428
- self._current_play_start_time = timestamp
429
- self._current_play_start_method = method
430
- self._current_play_start_clock = clock_value
431
- self._countdown_history = [] # Reset countdown tracking for new play
432
- self.state = PlayState.PLAY_IN_PROGRESS
433
  logger.debug("Play started: time=%.1fs, method=%s, clock=%s", timestamp, method, clock_value)
434
 
435
  def _end_play_capped(self, capped_end_time: float, clock_value: int, method: str) -> PlayEvent:
436
  """End the current play with a capped end time (for max duration exceeded)."""
437
- self._play_count += 1
438
 
439
  play = PlayEvent(
440
- play_number=self._play_count,
441
- start_time=self._current_play_start_time or capped_end_time,
442
  end_time=capped_end_time,
443
  confidence=0.7, # Lower confidence for capped plays
444
- start_method=self._current_play_start_method or "unknown",
445
  end_method=method,
446
- direct_end_time=self._direct_end_time,
447
- start_clock_value=self._current_play_start_clock,
448
  end_clock_value=clock_value,
449
- play_type=self._current_play_type,
450
  )
451
 
452
- self.plays.append(play)
453
  self._reset_play_tracking()
454
- self.state = PlayState.POST_PLAY
455
 
456
  logger.info(
457
  "Play #%d complete (capped, %s): %.1fs - %.1fs (duration: %.1fs)",
@@ -466,27 +560,27 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
466
 
467
  def _end_play(self, timestamp: float, clock_value: int, method: str) -> PlayEvent:
468
  """End the current play and create a PlayEvent."""
469
- self._play_count += 1
470
 
471
  # For direct detection, end time is the current timestamp
472
  end_time = timestamp
473
 
474
  play = PlayEvent(
475
- play_number=self._play_count,
476
- start_time=self._current_play_start_time or timestamp,
477
  end_time=end_time,
478
  confidence=0.8, # Base confidence for direct detection
479
- start_method=self._current_play_start_method or "unknown",
480
  end_method=method,
481
- direct_end_time=self._direct_end_time,
482
- start_clock_value=self._current_play_start_clock,
483
  end_clock_value=clock_value,
484
- play_type=self._current_play_type,
485
  )
486
 
487
- self.plays.append(play)
488
  self._reset_play_tracking()
489
- self.state = PlayState.POST_PLAY
490
 
491
  logger.info(
492
  "Play #%d complete (%s): %.1fs - %.1fs (duration: %.1fs)",
@@ -501,7 +595,7 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
501
 
502
  def _end_play_with_backward_calc(self, observation_time: float, clock_value: int, calculated_end_time: float) -> Optional[PlayEvent]:
503
  """End the current play using backward calculation for end time."""
504
- start_time = self._current_play_start_time or calculated_end_time
505
 
506
  # Sanity check: end time must be after start time
507
  if calculated_end_time < start_time:
@@ -511,40 +605,40 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
511
  start_time,
512
  )
513
  self._reset_play_tracking()
514
- self.state = PlayState.PRE_SNAP
515
  return None
516
 
517
  # Sanity check: play duration should be reasonable
518
  # Special plays (XP/FG completions) can have very short durations due to sampling interval
519
  duration = calculated_end_time - start_time
520
- min_duration = 0.0 if self._current_play_type == "special" else 0.5
521
  if duration < min_duration:
522
  logger.warning(
523
  "Rejecting invalid play: duration (%.1fs) is too short. This likely indicates OCR noise. Resetting state.",
524
  duration,
525
  )
526
  self._reset_play_tracking()
527
- self.state = PlayState.PRE_SNAP
528
  return None
529
 
530
- self._play_count += 1
531
 
532
  play = PlayEvent(
533
- play_number=self._play_count,
534
  start_time=start_time,
535
  end_time=calculated_end_time, # Use backward-calculated time
536
  confidence=0.9, # Higher confidence for backward calculation
537
- start_method=self._current_play_start_method or "unknown",
538
  end_method="backward_calc",
539
- direct_end_time=self._direct_end_time, # May be None
540
- start_clock_value=self._current_play_start_clock,
541
  end_clock_value=clock_value,
542
- play_type=self._current_play_type,
543
  )
544
 
545
- self.plays.append(play)
546
  self._reset_play_tracking()
547
- self.state = PlayState.POST_PLAY
548
 
549
  logger.info(
550
  "Play #%d complete (backward calc, %s): %.1fs - %.1fs (duration: %.1fs, observed at %.1fs with clock=%d)",
@@ -561,45 +655,46 @@ class TrackPlayState: # pylint: disable=too-many-instance-attributes
561
 
562
  def _reset_play_tracking(self) -> None:
563
  """Reset tracking variables for next play."""
564
- self._current_play_start_time = None
565
- self._current_play_start_method = None
566
- self._current_play_start_clock = None
567
- self._direct_end_time = None
568
- self._clock_stable_count = 0
569
- self._countdown_history = []
570
- self._first_40_timestamp = None
571
- self._current_play_clock_base = 40 # Reset to default
572
- self._current_play_type = "normal" # Reset to default
573
 
574
  def _reset_state(self) -> None:
575
  """Fully reset state machine."""
576
- self.state = PlayState.IDLE
577
  self._reset_play_tracking()
578
- self._last_clock_value = None
579
- self._last_clock_timestamp = None
580
- self._last_scorebug_timestamp = None
581
  logger.debug("State machine reset to IDLE")
582
 
583
  def get_plays(self) -> List[PlayEvent]:
584
  """Get all detected plays."""
585
- return self.plays.copy()
586
 
587
  def get_state(self) -> PlayState:
588
  """Get current state."""
589
- return self.state
590
 
591
  def get_stats(self) -> dict:
592
  """Get statistics about detected plays."""
593
- if not self.plays:
594
- return {"total_plays": 0}
595
 
596
- durations = [p.end_time - p.start_time for p in self.plays]
597
  return {
598
- "total_plays": len(self.plays),
599
  "avg_duration": sum(durations) / len(durations),
600
  "min_duration": min(durations),
601
  "max_duration": max(durations),
602
- "start_methods": {m: sum(1 for p in self.plays if p.start_method == m) for m in set(p.start_method for p in self.plays)},
603
- "end_methods": {m: sum(1 for p in self.plays if p.end_method == m) for m in set(p.end_method for p in self.plays)},
604
- "play_types": {t: sum(1 for p in self.plays if p.play_type == t) for t in set(p.play_type for p in self.plays)},
 
605
  }
 
5
  This module tracks play clock state changes to determine when plays begin and end.
6
  The primary method for determining play end time is backward counting from the
7
  next observed play clock value after the play.
8
+
9
+ Clock Reset Classification:
10
+ The state machine also classifies 40→25 clock reset events:
11
+ - Class A (weird_clock): 25 counts down immediately → rejected
12
+ - Class B (timeout): Timeout indicator changed → tracked as timeout
13
+ - Class C (special): Neither A nor B → special play (punt/FG/XP)
14
  """
15
 
16
  import logging
 
 
17
  from typing import Optional, List
18
 
19
  from detection import ScorebugDetection
20
  from readers import PlayClockReading
21
+ from .models import PlayEvent, PlayState, PlayTrackingState, TrackPlayStateConfig, TimeoutInfo
22
 
23
  logger = logging.getLogger(__name__)
24
 
25
 
26
+ class TrackPlayState: # pylint: disable=too-many-instance-attributes,too-many-public-methods
 
 
 
 
 
 
 
 
 
 
 
27
  """
28
  State machine for detecting play boundaries using play clock behavior.
29
 
 
39
  This method is reliable even when the broadcast cuts to replays.
40
  """
41
 
42
+ def __init__(self, config: Optional[TrackPlayStateConfig] = None):
43
+ """Initialize the state machine.
44
+
45
+ Args:
46
+ config: Configuration settings. Uses defaults if not provided.
47
+ """
48
+ self.config = config or TrackPlayStateConfig()
49
+ self._state = PlayTrackingState()
50
+
51
+ # =========================================================================
52
+ # Properties for backward compatibility (access state fields directly)
53
+ # =========================================================================
54
+
55
+ @property
56
+ def state(self) -> PlayState:
57
+ """Current state of the play detection state machine."""
58
+ return self._state.state
59
+
60
+ @state.setter
61
+ def state(self, value: PlayState) -> None:
62
+ self._state.state = value
63
+
64
+ @property
65
+ def plays(self) -> List[PlayEvent]:
66
+ """List of all detected plays."""
67
+ return self._state.plays
68
+
69
+ # =========================================================================
70
+ # Main update method
71
+ # =========================================================================
72
+
73
+ def update(
74
+ self,
75
+ timestamp: float,
76
+ scorebug: ScorebugDetection,
77
+ clock: PlayClockReading,
78
+ timeout_info: Optional[TimeoutInfo] = None,
79
+ ) -> Optional[PlayEvent]:
80
  """
81
  Update the state machine with new frame data.
82
 
 
84
  timestamp: Current video timestamp in seconds
85
  scorebug: Scorebug detection result
86
  clock: Play clock reading result
87
+ timeout_info: Optional timeout indicator information for clock reset classification
88
 
89
  Returns:
90
  PlayEvent if a play just ended, None otherwise
91
  """
92
+ # Update timeout tracking if provided
93
+ if timeout_info is not None:
94
+ if timeout_info.home_timeouts is not None:
95
+ self._state.last_home_timeouts = timeout_info.home_timeouts
96
+ if timeout_info.away_timeouts is not None:
97
+ self._state.last_away_timeouts = timeout_info.away_timeouts
98
+
99
  # Handle scorebug presence/absence
100
  if not scorebug.detected:
101
  return self._handle_no_scorebug(timestamp)
102
 
103
  # Update last scorebug timestamp
104
+ self._state.last_scorebug_timestamp = timestamp
105
 
106
  # Handle invalid clock reading
107
  if not clock.detected or clock.value is None:
 
109
  return None
110
 
111
  # Process valid clock reading
112
+ return self._process_clock_value(timestamp, clock.value, timeout_info)
113
 
114
  def _handle_no_scorebug(self, timestamp: float) -> Optional[PlayEvent]:
115
  """Handle case when scorebug is not detected."""
116
+ if self._state.state == PlayState.IDLE:
117
  return None
118
 
119
  # Check if we've lost scorebug for too long
120
+ if self._state.last_scorebug_timestamp is not None:
121
+ time_since_scorebug = timestamp - self._state.last_scorebug_timestamp
122
+ if time_since_scorebug > self.config.scorebug_lost_timeout:
123
  logger.warning("Scorebug lost for %.1fs, resetting to IDLE", time_since_scorebug)
124
  self._reset_state()
125
  return None
126
 
127
  # TURNOVER DETECTION: If we were in PLAY_IN_PROGRESS with significant time at 40,
128
  # and scorebug disappears (likely for replay/review), record the play.
129
+ if self._state.state == PlayState.PLAY_IN_PROGRESS and self._state.first_40_timestamp is not None:
130
+ time_at_40 = (self._state.last_scorebug_timestamp - self._state.first_40_timestamp) if self._state.last_scorebug_timestamp else 0
131
  min_time_for_play = 2.0
132
 
133
  if time_at_40 > min_time_for_play:
134
  # Play happened, scorebug disappeared (likely for replay/review)
135
+ play_end_time = self._state.last_scorebug_timestamp if self._state.last_scorebug_timestamp else timestamp
136
  logger.info(
137
  "Scorebug disappeared during play at %.1fs (%.1fs at 40). Recording play end at %.1fs.",
138
  timestamp,
 
141
  )
142
  # Record the play before transitioning
143
  completed_play = self._end_play_with_backward_calc(timestamp, 40, play_end_time)
144
+ self._state.state = PlayState.NO_SCOREBUG
145
  return completed_play
146
 
147
  # Transition to NO_SCOREBUG state if we were in a play
148
+ if self._state.state in (PlayState.PRE_SNAP, PlayState.PLAY_IN_PROGRESS, PlayState.POST_PLAY):
149
  logger.debug("Scorebug lost at %.1fs, entering NO_SCOREBUG state", timestamp)
150
+ self._state.state = PlayState.NO_SCOREBUG
151
 
152
  return None
153
 
154
  def _handle_invalid_clock(self, timestamp: float) -> None:
155
  """Handle case when clock reading is invalid but scorebug is present."""
156
  # If we're in pre-snap and clock becomes unreadable, might indicate play started
157
+ if self._state.state == PlayState.PRE_SNAP and self._state.last_clock_value is not None:
158
  # Clock became unreadable - could be play in progress
159
  logger.debug("Clock unreadable at %.1fs in PRE_SNAP state", timestamp)
160
 
161
+ def _process_clock_value(self, timestamp: float, clock_value: int, timeout_info: Optional[TimeoutInfo] = None) -> Optional[PlayEvent]:
162
  """
163
  Process a valid clock reading and update state.
164
 
165
  Args:
166
  timestamp: Current timestamp
167
  clock_value: Detected play clock value (0-40)
168
+ timeout_info: Optional timeout indicator information
169
 
170
  Returns:
171
  PlayEvent if a play just completed
172
  """
173
  completed_play = None
174
 
175
+ if self._state.state == PlayState.IDLE:
176
  # First clock reading - transition to PRE_SNAP
177
  logger.debug("First clock reading (%d) at %.1fs, entering PRE_SNAP", clock_value, timestamp)
178
+ self._state.state = PlayState.PRE_SNAP
179
+ self._state.last_clock_value = clock_value
180
+ self._state.last_clock_timestamp = timestamp
181
+ self._state.clock_stable_count = 1
182
 
183
+ elif self._state.state == PlayState.PRE_SNAP:
184
  # Watching for play to start (clock reset to 40 or freeze)
185
+ completed_play = self._handle_pre_snap(timestamp, clock_value, timeout_info)
186
 
187
+ elif self._state.state == PlayState.PLAY_IN_PROGRESS:
188
  # Play is live, watching for it to end (clock restarts)
189
  completed_play = self._handle_play_in_progress(timestamp, clock_value)
190
 
191
+ elif self._state.state == PlayState.POST_PLAY:
192
  # Play ended, transitioning back to PRE_SNAP
193
  self._handle_post_play(timestamp, clock_value)
194
 
195
+ elif self._state.state == PlayState.NO_SCOREBUG:
196
  # Scorebug returned after being lost
197
  completed_play = self._handle_scorebug_returned(timestamp, clock_value)
198
 
199
  # Update tracking
200
+ self._state.last_clock_value = clock_value
201
+ self._state.last_clock_timestamp = timestamp
202
 
203
  return completed_play
204
 
205
+ def _handle_pre_snap(self, timestamp: float, clock_value: int, timeout_info: Optional[TimeoutInfo] = None) -> Optional[PlayEvent]:
206
+ """Handle clock reading during PRE_SNAP state.
207
+
208
+ This method also classifies 40→25 clock reset events:
209
+ - Class A (weird_clock): 25 counts down immediately → rejected
210
+ - Class B (timeout): Timeout indicator changed → tracked as timeout play
211
+ - Class C (special): Neither A nor B → special play (punt/FG/XP)
212
+ """
213
+ if self._state.last_clock_value is None:
214
+ self._state.last_clock_value = clock_value
215
+ self._state.clock_stable_count = 1
216
  return None
217
 
218
  # Check for clock reset to 40 (indicates ball was snapped for normal play)
219
  # Require a significant jump in clock value to avoid false positives from OCR noise
220
  # e.g., "40 from 39" is likely OCR noise, but "40 from 25" is a real reset
221
+ max_prev_value = 40 - self.config.min_clock_jump_for_reset # e.g., 35 if min_jump=5
222
+ if clock_value == 40 and self._state.last_clock_value <= max_prev_value:
223
+ logger.info("Play START detected at %.1fs (clock reset to 40 from %d)", timestamp, self._state.last_clock_value)
224
+ self._state.current_play_clock_base = 40
225
+ self._state.current_play_type = "normal"
226
+ self._start_play(timestamp, "clock_reset", self._state.last_clock_value)
227
  return None
228
 
229
  # Reject suspicious clock resets that look like OCR noise
230
+ if clock_value == 40 and self._state.last_clock_value > max_prev_value:
231
  logger.debug(
232
  "Ignoring suspicious clock reset at %.1fs (40 from %d, requires prev <= %d)",
233
  timestamp,
234
+ self._state.last_clock_value,
235
  max_prev_value,
236
  )
237
+ # Don't update last_clock_value - treat this 40 reading as noise
238
  return None
239
 
240
+ # Check for 40→25 clock reset - classify and handle appropriately
241
+ if clock_value == 25 and self._state.last_clock_value == 40:
242
+ return self._classify_40_to_25_reset(timestamp, timeout_info)
243
+
244
+ # Check for other clock resets to 25 (significant jump indicates special teams play)
245
+ if clock_value == 25 and self._state.last_clock_value is not None:
246
+ clock_jump = abs(clock_value - self._state.last_clock_value)
247
  # Require significant jump to avoid false positives from normal countdown through 25
248
+ # A jump of 5+ indicates a real reset (e.g., 30→25 after brief 40, or 10→25 after play)
249
+ if clock_jump >= self.config.min_clock_jump_for_reset:
250
  logger.info(
251
  "Special teams play START detected at %.1fs (clock reset to 25 from %d, jump of %d)",
252
  timestamp,
253
+ self._state.last_clock_value,
254
  clock_jump,
255
  )
256
+ self._state.current_play_clock_base = 25
257
+ self._state.current_play_type = "special"
258
+ self._start_play(timestamp, "clock_reset_25", self._state.last_clock_value)
259
  return None
260
 
261
  # Track clock stability (for potential future use)
262
+ if clock_value == self._state.last_clock_value:
263
+ self._state.clock_stable_count += 1
264
  else:
265
+ self._state.clock_stable_count = 1
266
 
267
  # Note: "clock_freeze" detection disabled - was causing false positives
268
  # The clock_reset detection (going to 40 or 25) is the reliable method
269
  return None
270
 
271
+ def _classify_40_to_25_reset(self, timestamp: float, timeout_info: Optional[TimeoutInfo] = None) -> Optional[PlayEvent]:
272
+ """
273
+ Classify a 40→25 clock reset and handle appropriately.
274
+
275
+ Classification:
276
+ - Class A (weird_clock): Will be handled by checking if 25 counts down immediately
277
+ (detected later in _handle_play_in_progress via abnormal clock drop)
278
+ - Class B (timeout): Timeout indicator changed → tracked as timeout play
279
+ - Class C (special): Neither A nor B → special play (punt/FG/XP)
280
+
281
+ For now, we check for timeout change. If no timeout, treat as special play.
282
+ The weird_clock case will naturally be filtered when 25 starts counting down.
283
+
284
+ Args:
285
+ timestamp: Current timestamp
286
+ timeout_info: Optional timeout indicator information
287
+
288
+ Returns:
289
+ None (play tracking is started, not completed)
290
+ """
291
+ self._state.clock_reset_stats.total += 1
292
+
293
+ # Check for timeout change (Class B)
294
+ timeout_team = self._check_timeout_change(timeout_info)
295
+
296
+ if timeout_team:
297
+ # Class B: Team timeout detected
298
+ self._state.clock_reset_stats.timeout += 1
299
+ logger.info(
300
+ "Clock reset 40→25 at %.1fs classified as TIMEOUT (%s team)",
301
+ timestamp,
302
+ timeout_team,
303
+ )
304
+ self._state.current_play_clock_base = 25
305
+ self._state.current_play_type = "timeout"
306
+ self._start_play(timestamp, f"timeout_{timeout_team}", 40)
307
+ else:
308
+ # Class C: Special play (punt/FG/XP) - or potentially Class A (will be filtered later)
309
+ self._state.clock_reset_stats.special += 1
310
+ logger.info(
311
+ "Clock reset 40→25 at %.1fs classified as SPECIAL play",
312
+ timestamp,
313
+ )
314
+ self._state.current_play_clock_base = 25
315
+ self._state.current_play_type = "special"
316
+ self._start_play(timestamp, "clock_reset_special", 40)
317
+
318
+ return None
319
+
320
+ def _check_timeout_change(self, timeout_info: Optional[TimeoutInfo] = None) -> Optional[str]:
321
+ """
322
+ Check if a timeout indicator changed, indicating a team timeout.
323
+
324
+ Args:
325
+ timeout_info: Current timeout information
326
+
327
+ Returns:
328
+ "home" or "away" if a timeout was used, None otherwise
329
+ """
330
+ if timeout_info is None:
331
+ return None
332
+
333
+ # Compare current timeouts with last known values
334
+ if timeout_info.home_timeouts is not None and self._state.last_home_timeouts is not None:
335
+ if timeout_info.home_timeouts < self._state.last_home_timeouts:
336
+ return "home"
337
+
338
+ if timeout_info.away_timeouts is not None and self._state.last_away_timeouts is not None:
339
+ if timeout_info.away_timeouts < self._state.last_away_timeouts:
340
+ return "away"
341
+
342
+ return None
343
+
344
  # pylint: disable=too-many-return-statements,too-many-branches
345
  def _handle_play_in_progress(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
346
  """Handle clock reading during PLAY_IN_PROGRESS state."""
347
+ if self._state.current_play_start_time is None:
348
  return None
349
 
350
  # Check for play duration timeout
351
+ play_duration = timestamp - self._state.current_play_start_time
352
+ if play_duration > self.config.max_play_duration:
353
  # Cap the end time at start + max_duration to avoid inflated durations
354
  # This prevents long gaps (commercials, etc.) from extending play end times
355
+ capped_end_time = self._state.current_play_start_time + self.config.max_play_duration
356
  logger.warning(
357
  "Play duration (%.1fs) exceeded max (%.1fs), forcing end at %.1fs (capped from %.1fs)",
358
  play_duration,
359
+ self.config.max_play_duration,
360
  capped_end_time,
361
  timestamp,
362
  )
363
+ self._state.direct_end_time = capped_end_time
364
+ self._state.countdown_history = [] # Reset countdown tracking
365
  return self._end_play_capped(capped_end_time, clock_value, "max_duration")
366
 
367
  # If clock is still at 40, the play just started and clock hasn't begun countdown yet
368
  # We need to wait for the clock to drop below 40 before we can detect play end
369
  if clock_value == 40:
370
  # Track when we first saw 40 (for turnover detection)
371
+ if self._state.first_40_timestamp is None:
372
+ self._state.first_40_timestamp = timestamp
373
  # Clock is still at 40 after reset - waiting for countdown to begin
374
+ logger.debug("Play in progress at %.1fs, clock still at 40 (%.1fs at 40)", timestamp, timestamp - self._state.first_40_timestamp)
375
+ self._state.countdown_history = [] # Reset countdown tracking
376
  return None
377
 
378
  # RAPID 40→25 TRANSITION DETECTION (possession change during play):
 
386
  # 1. Mark this as a special play
387
  # 2. Update the clock base to 25
388
  # 3. Continue tracking until proper end detection (countdown confirmed, scorebug lost, or max duration)
389
+ if clock_value == 25 and self._state.first_40_timestamp is not None:
390
+ time_at_40 = timestamp - self._state.first_40_timestamp
391
  max_time_for_possession_change = 5.0 # 40→25 within 5 seconds indicates possession change
392
  min_time_at_40 = 0.5 # Must be at 40 briefly to avoid OCR noise (lowered from 1.0 for timing precision)
393
 
394
+ if min_time_at_40 <= time_at_40 <= max_time_for_possession_change and len(self._state.countdown_history) == 0:
395
  # Possession change detected - play continues (e.g., punt return in progress)
396
  # DO NOT end the play here - let it continue until proper end detection
397
  logger.info(
 
400
  time_at_40,
401
  )
402
  # Mark as special play and update clock base for proper end detection
403
+ self._state.current_play_type = "special"
404
+ self._state.current_play_clock_base = 25
405
+ self._state.first_40_timestamp = None # Reset since we're now tracking at 25
406
+ self._state.countdown_history = [] # Reset countdown tracking for fresh detection at 25
407
  return None # Continue tracking - DO NOT end the play!
408
 
409
  # ABNORMAL CLOCK DROP DETECTION:
 
412
  # 1. A timeout/injury reset (no play happened) - clock was at 40 briefly
413
  # 2. A turnover/possession change (play DID happen) - clock was at 40 for several seconds during play
414
  # We distinguish by checking how long the clock was at 40.
415
+ if len(self._state.countdown_history) == 0:
416
  # This is the first reading after 40
417
  clock_drop = 40 - clock_value
418
  max_normal_drop = 5 # Allow up to 5 second drop on first reading (accounts for timing/OCR variance)
419
  if clock_drop > max_normal_drop:
420
  # Check how long clock was at 40 to distinguish turnover from timeout
421
+ time_at_40 = (timestamp - self._state.first_40_timestamp) if self._state.first_40_timestamp else 0
422
  min_time_for_play = 2.0 # If clock was at 40 for > 2 seconds, a play likely happened
423
 
424
  if time_at_40 > min_time_for_play:
 
443
  time_at_40,
444
  )
445
  self._reset_play_tracking()
446
+ self._state.state = PlayState.PRE_SNAP
447
  return None
448
 
449
  # Track countdown history for confirming play end
450
  # We require K consecutive descending ticks to confirm
451
+ self._state.countdown_history.append((timestamp, clock_value))
452
 
453
  # Check if we have enough consecutive descending values
454
+ if len(self._state.countdown_history) >= self.config.required_countdown_ticks:
455
  # Get last K readings
456
+ recent = self._state.countdown_history[-self.config.required_countdown_ticks :]
457
  values = [v for _, v in recent]
458
 
459
  # Check if values are strictly descending (or stable which means same second)
 
468
  # Use the first reading in our confirmed sequence for backward calculation
469
  first_timestamp, first_value = recent[0]
470
  # Use the correct clock base (40 for normal plays, 25 for special teams)
471
+ calculated_end_time = first_timestamp - (self._state.current_play_clock_base - first_value)
472
  logger.info(
473
  "Play END confirmed via %d-tick countdown: %.1fs (clock=%d→%d, base=%d, observed %.1fs-%.1fs)",
474
+ self.config.required_countdown_ticks,
475
  calculated_end_time,
476
  values[0],
477
  values[-1],
478
+ self._state.current_play_clock_base,
479
  recent[0][0],
480
  recent[-1][0],
481
  )
482
+ self._state.direct_end_time = timestamp # When we confirmed the countdown
483
+ self._state.countdown_history = [] # Reset for next play
484
  return self._end_play_with_backward_calc(timestamp, first_value, calculated_end_time)
485
 
486
  return None
 
490
  # Note: clock_value unused for now, but kept for potential future use
491
  # Transition back to PRE_SNAP
492
  logger.debug("Transitioning from POST_PLAY to PRE_SNAP at %.1fs", timestamp)
493
+ self._state.state = PlayState.PRE_SNAP
494
+ self._state.clock_stable_count = 1
495
 
496
  def _handle_scorebug_returned(self, timestamp: float, clock_value: int) -> Optional[PlayEvent]:
497
  """Handle scorebug returning after being lost."""
498
  completed_play = None
499
 
500
  # If we were tracking a play, use backward counting to determine when it ended
501
+ if self._state.current_play_start_time is not None:
502
  # Calculate when play ended using backward counting with correct clock base
503
+ calculated_end_time = timestamp - (self._state.current_play_clock_base - clock_value)
504
  logger.info(
505
  "Scorebug returned at %.1fs (clock=%d, base=%d), backward calc play end: %.1fs",
506
  timestamp,
507
  clock_value,
508
+ self._state.current_play_clock_base,
509
  calculated_end_time,
510
  )
511
  completed_play = self._end_play_with_backward_calc(timestamp, clock_value, calculated_end_time)
 
513
  # No play was in progress, just return to PRE_SNAP
514
  logger.debug("Scorebug returned at %.1fs, no play in progress", timestamp)
515
 
516
+ self._state.state = PlayState.PRE_SNAP
517
+ self._state.clock_stable_count = 1
518
  return completed_play
519
 
520
  def _start_play(self, timestamp: float, method: str, clock_value: Optional[int]) -> None:
521
  """Record the start of a new play."""
522
+ self._state.current_play_start_time = timestamp
523
+ self._state.current_play_start_method = method
524
+ self._state.current_play_start_clock = clock_value
525
+ self._state.countdown_history = [] # Reset countdown tracking for new play
526
+ self._state.state = PlayState.PLAY_IN_PROGRESS
527
  logger.debug("Play started: time=%.1fs, method=%s, clock=%s", timestamp, method, clock_value)
528
 
529
  def _end_play_capped(self, capped_end_time: float, clock_value: int, method: str) -> PlayEvent:
530
  """End the current play with a capped end time (for max duration exceeded)."""
531
+ self._state.play_count += 1
532
 
533
  play = PlayEvent(
534
+ play_number=self._state.play_count,
535
+ start_time=self._state.current_play_start_time or capped_end_time,
536
  end_time=capped_end_time,
537
  confidence=0.7, # Lower confidence for capped plays
538
+ start_method=self._state.current_play_start_method or "unknown",
539
  end_method=method,
540
+ direct_end_time=self._state.direct_end_time,
541
+ start_clock_value=self._state.current_play_start_clock,
542
  end_clock_value=clock_value,
543
+ play_type=self._state.current_play_type,
544
  )
545
 
546
+ self._state.plays.append(play)
547
  self._reset_play_tracking()
548
+ self._state.state = PlayState.POST_PLAY
549
 
550
  logger.info(
551
  "Play #%d complete (capped, %s): %.1fs - %.1fs (duration: %.1fs)",
 
560
 
561
  def _end_play(self, timestamp: float, clock_value: int, method: str) -> PlayEvent:
562
  """End the current play and create a PlayEvent."""
563
+ self._state.play_count += 1
564
 
565
  # For direct detection, end time is the current timestamp
566
  end_time = timestamp
567
 
568
  play = PlayEvent(
569
+ play_number=self._state.play_count,
570
+ start_time=self._state.current_play_start_time or timestamp,
571
  end_time=end_time,
572
  confidence=0.8, # Base confidence for direct detection
573
+ start_method=self._state.current_play_start_method or "unknown",
574
  end_method=method,
575
+ direct_end_time=self._state.direct_end_time,
576
+ start_clock_value=self._state.current_play_start_clock,
577
  end_clock_value=clock_value,
578
+ play_type=self._state.current_play_type,
579
  )
580
 
581
+ self._state.plays.append(play)
582
  self._reset_play_tracking()
583
+ self._state.state = PlayState.POST_PLAY
584
 
585
  logger.info(
586
  "Play #%d complete (%s): %.1fs - %.1fs (duration: %.1fs)",
 
595
 
596
  def _end_play_with_backward_calc(self, observation_time: float, clock_value: int, calculated_end_time: float) -> Optional[PlayEvent]:
597
  """End the current play using backward calculation for end time."""
598
+ start_time = self._state.current_play_start_time or calculated_end_time
599
 
600
  # Sanity check: end time must be after start time
601
  if calculated_end_time < start_time:
 
605
  start_time,
606
  )
607
  self._reset_play_tracking()
608
+ self._state.state = PlayState.PRE_SNAP
609
  return None
610
 
611
  # Sanity check: play duration should be reasonable
612
  # Special plays (XP/FG completions) can have very short durations due to sampling interval
613
  duration = calculated_end_time - start_time
614
+ min_duration = 0.0 if self._state.current_play_type == "special" else 0.5
615
  if duration < min_duration:
616
  logger.warning(
617
  "Rejecting invalid play: duration (%.1fs) is too short. This likely indicates OCR noise. Resetting state.",
618
  duration,
619
  )
620
  self._reset_play_tracking()
621
+ self._state.state = PlayState.PRE_SNAP
622
  return None
623
 
624
+ self._state.play_count += 1
625
 
626
  play = PlayEvent(
627
+ play_number=self._state.play_count,
628
  start_time=start_time,
629
  end_time=calculated_end_time, # Use backward-calculated time
630
  confidence=0.9, # Higher confidence for backward calculation
631
+ start_method=self._state.current_play_start_method or "unknown",
632
  end_method="backward_calc",
633
+ direct_end_time=self._state.direct_end_time, # May be None
634
+ start_clock_value=self._state.current_play_start_clock,
635
  end_clock_value=clock_value,
636
+ play_type=self._state.current_play_type,
637
  )
638
 
639
+ self._state.plays.append(play)
640
  self._reset_play_tracking()
641
+ self._state.state = PlayState.POST_PLAY
642
 
643
  logger.info(
644
  "Play #%d complete (backward calc, %s): %.1fs - %.1fs (duration: %.1fs, observed at %.1fs with clock=%d)",
 
655
 
656
  def _reset_play_tracking(self) -> None:
657
  """Reset tracking variables for next play."""
658
+ self._state.current_play_start_time = None
659
+ self._state.current_play_start_method = None
660
+ self._state.current_play_start_clock = None
661
+ self._state.direct_end_time = None
662
+ self._state.clock_stable_count = 0
663
+ self._state.countdown_history = []
664
+ self._state.first_40_timestamp = None
665
+ self._state.current_play_clock_base = 40 # Reset to default
666
+ self._state.current_play_type = "normal" # Reset to default
667
 
668
  def _reset_state(self) -> None:
669
  """Fully reset state machine."""
670
+ self._state.state = PlayState.IDLE
671
  self._reset_play_tracking()
672
+ self._state.last_clock_value = None
673
+ self._state.last_clock_timestamp = None
674
+ self._state.last_scorebug_timestamp = None
675
  logger.debug("State machine reset to IDLE")
676
 
677
  def get_plays(self) -> List[PlayEvent]:
678
  """Get all detected plays."""
679
+ return self._state.plays.copy()
680
 
681
  def get_state(self) -> PlayState:
682
  """Get current state."""
683
+ return self._state.state
684
 
685
  def get_stats(self) -> dict:
686
  """Get statistics about detected plays."""
687
+ if not self._state.plays:
688
+ return {"total_plays": 0, "clock_reset_events": self._state.clock_reset_stats.model_dump()}
689
 
690
+ durations = [p.end_time - p.start_time for p in self._state.plays]
691
  return {
692
+ "total_plays": len(self._state.plays),
693
  "avg_duration": sum(durations) / len(durations),
694
  "min_duration": min(durations),
695
  "max_duration": max(durations),
696
+ "start_methods": {m: sum(1 for p in self._state.plays if p.start_method == m) for m in set(p.start_method for p in self._state.plays)},
697
+ "end_methods": {m: sum(1 for p in self._state.plays if p.end_method == m) for m in set(p.end_method for p in self._state.plays)},
698
+ "play_types": {t: sum(1 for p in self._state.plays if p.play_type == t) for t in set(p.play_type for p in self._state.plays)},
699
+ "clock_reset_events": self._state.clock_reset_stats.model_dump(),
700
  }
src/video/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
  """Video processing modules for frame extraction and FFmpeg operations."""
2
 
3
  from .frame_extractor import extract_sample_frames, get_video_duration, get_video_fps
 
4
  from .ffmpeg_ops import (
5
  extract_clip_stream_copy,
6
  extract_clip_reencode,
@@ -13,6 +14,7 @@ __all__ = [
13
  "extract_sample_frames",
14
  "get_video_duration",
15
  "get_video_fps",
 
16
  "extract_clip_stream_copy",
17
  "extract_clip_reencode",
18
  "concatenate_clips",
 
1
  """Video processing modules for frame extraction and FFmpeg operations."""
2
 
3
  from .frame_extractor import extract_sample_frames, get_video_duration, get_video_fps
4
+ from .frame_reader import ThreadedFrameReader
5
  from .ffmpeg_ops import (
6
  extract_clip_stream_copy,
7
  extract_clip_reencode,
 
14
  "extract_sample_frames",
15
  "get_video_duration",
16
  "get_video_fps",
17
+ "ThreadedFrameReader",
18
  "extract_clip_stream_copy",
19
  "extract_clip_reencode",
20
  "concatenate_clips",
src/video/frame_reader.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Threaded frame reader for video processing.
3
+
4
+ Provides a background thread for reading video frames using a producer-consumer pattern.
5
+ This overlaps video I/O with processing for better performance.
6
+ """
7
+
8
+ import logging
9
+ import queue
10
+ import threading
11
+ import time
12
+ from typing import Optional, Tuple
13
+
14
+ import cv2
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ThreadedFrameReader:
21
+ """
22
+ Background thread for reading video frames.
23
+
24
+ Uses a producer-consumer pattern to overlap video I/O with processing.
25
+ The reader thread reads frames ahead into a queue while the main thread
26
+ processes frames from the queue.
27
+
28
+ This provides significant speedup by hiding video decode latency.
29
+ """
30
+
31
+ def __init__(self, cap: cv2.VideoCapture, start_frame: int, end_frame: int, frame_skip: int, queue_size: int = 32):
32
+ """
33
+ Initialize the threaded frame reader.
34
+
35
+ Args:
36
+ cap: OpenCV VideoCapture object
37
+ start_frame: First frame to read
38
+ end_frame: Last frame to read
39
+ frame_skip: Number of frames to skip between reads
40
+ queue_size: Maximum frames to buffer (default 32)
41
+ """
42
+ self.cap = cap
43
+ self.start_frame = start_frame
44
+ self.end_frame = end_frame
45
+ self.frame_skip = frame_skip
46
+ self.queue_size = queue_size
47
+
48
+ # Frame queue: (frame_number, frame_data) or (frame_number, None) for read failures
49
+ self.frame_queue: queue.Queue = queue.Queue(maxsize=queue_size)
50
+
51
+ # Control flags
52
+ self.stop_flag = threading.Event()
53
+ self.reader_thread: Optional[threading.Thread] = None
54
+
55
+ # Timing stats
56
+ self.io_time = 0.0
57
+ self.frames_read = 0
58
+
59
+ def start(self) -> None:
60
+ """Start the background reader thread."""
61
+ self.stop_flag.clear()
62
+ self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
63
+ self.reader_thread.start()
64
+ logger.debug("Threaded frame reader started")
65
+
66
+ def stop(self) -> None:
67
+ """Stop the background reader thread."""
68
+ self.stop_flag.set()
69
+ if self.reader_thread and self.reader_thread.is_alive():
70
+ # Drain the queue to unblock the reader thread
71
+ try:
72
+ while True:
73
+ self.frame_queue.get_nowait()
74
+ except queue.Empty:
75
+ pass
76
+ self.reader_thread.join(timeout=2.0)
77
+ logger.debug("Threaded frame reader stopped (read %d frames, %.2fs I/O)", self.frames_read, self.io_time)
78
+
79
+ def get_frame(self, timeout: float = 5.0) -> Optional[Tuple[int, Optional[np.ndarray]]]:
80
+ """
81
+ Get the next frame from the queue.
82
+
83
+ Args:
84
+ timeout: Maximum time to wait for a frame
85
+
86
+ Returns:
87
+ Tuple of (frame_number, frame_data) or None if queue is empty and reader is done
88
+ """
89
+ try:
90
+ return self.frame_queue.get(timeout=timeout)
91
+ except queue.Empty:
92
+ return None
93
+
94
+ def _reader_loop(self) -> None:
95
+ """Background thread that reads frames into the queue."""
96
+ # Seek to start position
97
+ t_start = time.perf_counter()
98
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.start_frame)
99
+ self.io_time += time.perf_counter() - t_start
100
+
101
+ current_frame = self.start_frame
102
+
103
+ while current_frame < self.end_frame and not self.stop_flag.is_set():
104
+ # Read frame
105
+ t_start = time.perf_counter()
106
+ ret, frame = self.cap.read()
107
+ self.io_time += time.perf_counter() - t_start
108
+
109
+ if ret:
110
+ self.frames_read += 1
111
+ # Put frame in queue (blocks if queue is full)
112
+ try:
113
+ self.frame_queue.put((current_frame, frame), timeout=5.0)
114
+ except queue.Full:
115
+ if self.stop_flag.is_set():
116
+ break
117
+ logger.warning("Frame queue full, dropping frame %d", current_frame)
118
+ else:
119
+ # Signal read failure
120
+ try:
121
+ self.frame_queue.put((current_frame, None), timeout=1.0)
122
+ except queue.Full:
123
+ pass
124
+
125
+ # Skip frames
126
+ t_start = time.perf_counter()
127
+ for _ in range(self.frame_skip - 1):
128
+ if self.stop_flag.is_set():
129
+ break
130
+ self.cap.grab()
131
+ self.io_time += time.perf_counter() - t_start
132
+
133
+ current_frame += self.frame_skip
134
+
135
+ # Signal end of stream
136
+ try:
137
+ self.frame_queue.put(None, timeout=1.0)
138
+ except queue.Full:
139
+ pass