andytaylor-smg commited on
Commit
d303d3f
·
1 Parent(s): 0d63b43

this is the real v4 now

Browse files
docs/v4_baseline_comparison.md CHANGED
@@ -1,179 +1,128 @@
1
- # V4 Baseline Comparison: Template Matching vs OCR
2
 
3
  **Date:** January 7, 2026
4
- **Video:** OSU vs Tenn 12.21.24.mkv (2:26:17 duration)
5
- **Method:** Dynamic template capture with fixed coordinates
 
6
 
7
  ---
8
 
9
- ## Executive Summary
10
 
11
- V4 introduces **template matching** for play clock reading, replacing the EasyOCR-based approach. This provides a **2.6x speedup** while maintaining 100% recall against the V3 baseline.
 
 
 
 
 
 
 
 
12
 
13
- | Metric | V3 (OCR) | V4 (Template) | Improvement |
14
- |--------|----------|---------------|-------------|
15
- | **Processing Time** | 9.6 min | **3.7 min** | 2.6x faster |
16
- | **Plays Detected** | 176 | **181** | +5 plays |
17
- | **Recall vs V3** | 100% | **100%** | Same |
18
- | **Clock Reading** | 312.7s | **39.7s** | 7.9x faster |
19
- | **Scorebug Detection** | 107.6s | **0.2s** | 538x faster |
20
-
21
- ---
22
-
23
- ## Timing Breakdown
24
-
25
- ### V3 Baseline (EasyOCR)
26
-
27
- | Component | Time | % of Total |
28
- |-----------|------|------------|
29
- | Video I/O | 157.1s | 27.1% |
30
- | Scorebug Detection | 107.6s | 18.6% |
31
- | Preprocessing | 1.5s | 0.3% |
32
- | **Play Clock OCR** | **312.7s** | **54.0%** |
33
- | State Machine | 0.0s | 0.0% |
34
- | **TOTAL** | **578.9s** | **100%** |
35
-
36
- ### V4 Baseline (Template Matching)
37
-
38
- | Component | Time | % of Total |
39
- |-----------|------|------------|
40
- | Video I/O | 164.3s | 74.2% |
41
- | Scorebug Detection | 0.2s | 0.1% |
42
- | Preprocessing | 0.3s | 0.1% |
43
- | **Template Matching** | **39.7s** | **17.9%** |
44
- | Template Building | 16.8s | 7.6% |
45
- | State Machine | 0.0s | 0.0% |
46
- | **TOTAL** | **221.3s** | **100%** |
47
 
48
  ---
49
 
50
  ## Detection Quality
51
 
52
- ### Play Counts
 
 
 
53
 
54
- | Category | V3 | V4 | Notes |
55
- |----------|----|----|-------|
56
- | Total Plays | 176 | 181 | V4 finds more plays |
57
- | True Positives | 176 | 176 | All V3 plays matched |
58
- | False Positives | 0 | 5 | Actually valid plays* |
59
- | False Negatives | 0 | 0 | Perfect recall |
60
 
61
- *The 5 "false positives" in V4 are actually **legitimate plays** that V3 missed:
62
- 1. Opening kickoff (2:01)
63
- 2. Second half kickoff (10:14)
64
- 3. False start penalty (52:34)
65
- 4. Two brief clock events (~2s each)
66
 
67
- ### Detection Metrics
68
 
69
- | Metric | V3 | V4 |
70
- |--------|----|----|
71
- | Precision | 100% | 97.2% |
72
- | Recall | 100% | 100% |
73
- | F1 Score | - | 98.6% |
74
 
75
- **Note:** V4's lower precision is misleading - the "false positives" are real plays that V3 missed due to OCR limitations.
76
 
77
  ---
78
 
79
- ## Key Improvements in V4
80
 
81
- ### 1. Template-Based Clock Reading (7.9x faster)
82
- - **V3:** EasyOCR at ~49ms/frame (312.7s total)
83
- - **V4:** Template matching at ~2.2ms/frame (39.7s total)
84
 
85
- ### 2. Fixed Coordinates Mode (538x faster scorebug)
86
- - **V3:** Template search every frame (107.6s)
87
- - **V4:** Pre-configured region (0.2s)
 
 
 
 
 
 
88
 
89
- ### 3. Dynamic Template Capture
90
- - First 400 frames use OCR to build templates (~17s)
91
- - Remaining 17,739 frames use template matching
92
- - Templates adapt to specific video's font/styling
93
 
94
- ### 4. Better Play Coverage
95
- - Detects kickoffs at start of each half
96
- - Detects penalty-related clock resets
97
- - More robust to brief clock displays
 
 
98
 
99
- ---
100
-
101
- ## Architecture Changes
102
-
103
- ### V3 Pipeline
104
- ```
105
- Frame → Scorebug Search → Region Extract → Preprocess → EasyOCR → Parse → State Machine
106
- (template match) (~49ms)
107
- ```
108
 
109
- ### V4 Pipeline
110
- ```
111
- Frame Fixed Region Region Extract Template Match → State Machine
112
- (instant) (~2ms)
113
-
114
- First 400 frames: + OCR labeling for template building (~17s one-time)
115
- ```
116
 
117
  ---
118
 
119
- ## Detailed Timing Comparison
120
 
121
- | Operation | V3 Time | V4 Time | Speedup |
122
- |-----------|---------|---------|---------|
123
- | Video I/O | 157.1s | 164.3s | 0.96x (slight slowdown)* |
124
- | Scorebug Detection | 107.6s | 0.2s | **538x** |
125
- | Clock Reading | 312.7s | 56.5s** | **5.5x** |
126
- | State Machine | 0.02s | 0.02s | 1.0x |
127
- | **Total** | **578.9s** | **221.3s** | **2.6x** |
128
 
129
- *Video I/O slightly slower due to storing more frame data for template tasks
130
- **Includes template building (16.8s) + template matching (39.7s)
131
 
132
  ---
133
 
134
- ## Files Changed
135
-
136
- The V4 baseline required the following code changes:
137
-
138
- 1. **`src/pipeline/models.py`** - Removed OCR config fields
139
- 2. **`src/pipeline/play_detector.py`** - Removed OCR methods, template-only pipeline
140
- 3. **`src/detectors/play_clock_reader.py`** - Removed EasyOCR, kept region extraction
141
- 4. **`src/pipeline/__init__.py`** - Removed FrameOCRTask export
142
- 5. **`src/pipeline/orchestrator.py`** - **Critical fix:** Pass `fixed_playclock_coords` and `fixed_scorebug_coords` to DetectionConfig so detector initializes in fixed coordinates mode from the start (not just calling `set_fixed_region` after initialization)
143
-
144
- See `docs/ocr_to_template_migration.md` for complete migration details.
145
-
146
- ---
147
 
148
- ## Baseline File Locations
 
 
 
 
149
 
150
- | Version | File |
151
- |---------|------|
152
- | V3 (OCR) | `output/benchmarks/v3_special_plays_baseline.json` |
153
- | V4 (Template) | `output/benchmarks/v4_template_matching_baseline.json` |
154
 
155
  ---
156
 
157
- ## Recommendations
158
 
159
- 1. **Use V4 as the new default** - 2.6x faster with better play coverage
160
- 2. **Keep fixed coordinates mode** - 538x faster scorebug handling
161
- 3. **Dynamic templates recommended** - Adapts to different broadcasts
162
- 4. **1.0s minimum duration filter** - Removes clock noise while keeping valid plays
 
 
 
 
 
163
 
164
  ---
165
 
166
- ## Reproduction
167
-
168
- To reproduce the V4 baseline:
169
-
170
- ```bash
171
- cd /Users/andytaylor/Documents/Personal/cfb40
172
- source .venv/bin/activate
173
- python tests/test_digit_templates/test_fast_full_video.py
174
- ```
175
-
176
- Results are saved to:
177
- - `output/benchmarks/fast_template_evaluation_dynamic.json`
178
- - Copy to `v4_template_matching_baseline.json` for baseline storage
179
 
 
 
 
 
1
+ # V4 Baseline Comparison
2
 
3
  **Date:** January 7, 2026
4
+ **Video:** OSU vs Tenn 12.21.24.mkv
5
+ **Method:** Streaming detection with threaded I/O + template matching
6
+ **Baseline File:** `output/benchmarks/v4_baseline.json`
7
 
8
  ---
9
 
10
+ ## Summary
11
 
12
+ | Metric | V3 Baseline | V4 Baseline (This Run) | Change |
13
+ |--------|-------------|------------------------|--------|
14
+ | **Total Plays** | 176 | 179 | +3 |
15
+ | **True Positives** | 176 | 176 | Same |
16
+ | **False Positives** | 0 | 3* | +3 |
17
+ | **False Negatives** | 0 | 0 | Same |
18
+ | **Precision** | 100% | 98.3% | -1.7% |
19
+ | **Recall** | 100% | 100% | Same |
20
+ | **F1 Score** | - | 99.2% | - |
21
 
22
+ *The 3 "false positives" are actually **legitimate plays** missed by the v3 baseline (see below).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  ---
25
 
26
  ## Detection Quality
27
 
28
+ ### Metrics
29
+ - **Precision:** 98.3%
30
+ - **Recall:** 100.0%
31
+ - **F1 Score:** 99.2%
32
 
33
+ ### Play Type Breakdown
34
+ | Type | Count |
35
+ |------|-------|
36
+ | Normal | 151 |
37
+ | Timeout | 17 |
38
+ | Special | 11 |
39
 
40
+ ### "False Positives" Analysis
 
 
 
 
41
 
42
+ The 3 plays detected that don't match the v3 baseline are **actually legitimate plays**:
43
 
44
+ | Timestamp | Duration | Verdict | Notes |
45
+ |-----------|----------|---------|-------|
46
+ | 2:01.92 (121.9s) | 6.3s | **VALID** | Opening kickoff |
47
+ | 10:14.93 (614.9s) | 15.0s | **VALID** | Second half kickoff |
48
+ | 52:37.87 (3157.9s) | 10.0s | **VALID** | False start penalty |
49
 
50
+ **Conclusion:** V4 actually detects MORE legitimate plays than V3.
51
 
52
  ---
53
 
54
+ ## Performance
55
 
56
+ ### Timing Breakdown
 
 
57
 
58
+ | Phase | Time | % of Total |
59
+ |-------|------|------------|
60
+ | Video I/O | 167.0s | 76.9% |
61
+ | Template Matching | 37.9s | 17.5% |
62
+ | Template Building | 12.0s | 5.5% |
63
+ | Scorebug Detection | 0.19s | 0.1% |
64
+ | Preprocessing | 0.12s | 0.1% |
65
+ | State Machine | 0.02s | 0.0% |
66
+ | **TOTAL** | **217.3s** | **100%** |
67
 
68
+ ### Speed Comparison
 
 
 
69
 
70
+ | Metric | V3 Baseline | V4 Baseline | Improvement |
71
+ |--------|-------------|-------------|-------------|
72
+ | **Total Time** | 578.9s (9.6 min) | 217.3s (3.6 min) | **2.7x faster** |
73
+ | Scorebug Detection | 107.6s | 0.19s | **566x faster** |
74
+ | Clock Reading (OCR → Template) | 312.7s | 37.9s | **8.2x faster** |
75
+ | Video I/O | 157.1s | 167.0s | Similar |
76
 
77
+ ### Key Improvements
 
 
 
 
 
 
 
 
78
 
79
+ 1. **Streaming Architecture**: Single-pass processing instead of multi-pass
80
+ 2. **Threaded Video I/O**: Background thread reads frames while main thread processes
81
+ 3. **Template Matching**: 8.2x faster than OCR for play clock reading
82
+ 4. **Fixed Coordinates Mode**: 566x faster scorebug detection (no template search)
 
 
 
83
 
84
  ---
85
 
86
+ ## Frame Processing Stats
87
 
88
+ | Metric | V3 Baseline | V4 Baseline |
89
+ |--------|-------------|-------------|
90
+ | Total Frames | 18,139 | 18,139 |
91
+ | Frames with Scorebug | 12,738 (70.2%) | 18,139 (100%)* |
92
+ | Frames with Clock | 12,370 (68.2%) | 12,657 (69.8%) |
 
 
93
 
94
+ *V4 uses fixed coordinates mode, so scorebug is "detected" in all frames where the region exists.
 
95
 
96
  ---
97
 
98
+ ## Duration Statistics
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ | Stat | V3 Baseline | V4 Baseline |
101
+ |------|-------------|-------------|
102
+ | Average | 7.9s | 8.7s |
103
+ | Minimum | 3.9s | 4.4s |
104
+ | Maximum | 30.0s | 30.0s |
105
 
106
+ Note: V4 uses a 3.0s minimum duration filter (vs likely 1.0s for v3).
 
 
 
107
 
108
  ---
109
 
110
+ ## Configuration
111
 
112
+ ```json
113
+ {
114
+ "frame_interval": 0.5,
115
+ "min_play_duration": 3.0,
116
+ "fixed_coordinates_mode": true,
117
+ "template_matching_clock": true,
118
+ "threaded_video_io": true
119
+ }
120
+ ```
121
 
122
  ---
123
 
124
+ ## Files
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ - **V3 Baseline:** `output/benchmarks/v3_special_plays_baseline.json`
127
+ - **V4 Baseline:** `output/benchmarks/v4_baseline.json`
128
+ - **Detection Analysis:** `docs/detection_analysis.md`
src/pipeline/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
  """Pipeline modules for video processing and detection orchestration.
2
 
3
  Note: OCR-based clock reading has been removed in favor of template matching.
 
4
  See docs/ocr_to_template_migration.md for details.
5
  """
6
 
@@ -8,9 +9,7 @@ See docs/ocr_to_template_migration.md for details.
8
  from .models import (
9
  DetectionConfig,
10
  DetectionResult,
11
- FrameTemplateTask,
12
  VideoContext,
13
- Pass1Results,
14
  )
15
 
16
  # Pipeline classes and functions
@@ -21,9 +20,7 @@ __all__ = [
21
  # Models
22
  "DetectionConfig",
23
  "DetectionResult",
24
- "FrameTemplateTask",
25
  "VideoContext",
26
- "Pass1Results",
27
  # Pipeline
28
  "PlayDetector",
29
  "format_detection_result_dict",
 
1
  """Pipeline modules for video processing and detection orchestration.
2
 
3
  Note: OCR-based clock reading has been removed in favor of template matching.
4
+ Streaming processing is used for optimal performance.
5
  See docs/ocr_to_template_migration.md for details.
6
  """
7
 
 
9
  from .models import (
10
  DetectionConfig,
11
  DetectionResult,
 
12
  VideoContext,
 
13
  )
14
 
15
  # Pipeline classes and functions
 
20
  # Models
21
  "DetectionConfig",
22
  "DetectionResult",
 
23
  "VideoContext",
 
24
  # Pipeline
25
  "PlayDetector",
26
  "format_detection_result_dict",
src/pipeline/models.py CHANGED
@@ -5,16 +5,12 @@ This module contains all the data structures used by the pipeline components
5
  for configuration, intermediate results, and final output.
6
 
7
  Note: OCR-based clock reading has been removed in favor of template matching.
 
8
  See docs/ocr_to_template_migration.md for details.
9
  """
10
 
11
  from dataclasses import dataclass, field
12
- from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
13
-
14
- import numpy as np
15
-
16
- if TYPE_CHECKING:
17
- pass # Reserved for future type imports if needed
18
 
19
 
20
  # =============================================================================
@@ -27,8 +23,8 @@ class DetectionConfig:
27
  """Configuration for play detection pipeline.
28
 
29
  Uses template matching for play clock reading (~34x faster than OCR).
30
- Templates are built dynamically from the first N frames using OCR
31
- for labeling, then template matching is used for all subsequent frames.
32
  """
33
 
34
  video_path: str # Path to video file
@@ -46,20 +42,6 @@ class DetectionConfig:
46
  fixed_scorebug_coords: Optional[Tuple[int, int, int, int]] = None # (x, y, w, h) scorebug region (for metadata)
47
 
48
 
49
- # =============================================================================
50
- # Processing Task Models
51
- # =============================================================================
52
-
53
-
54
- @dataclass
55
- class FrameTemplateTask:
56
- """Container for a frame that needs template-based clock reading."""
57
-
58
- timestamp: float # Frame timestamp
59
- raw_region: np.ndarray # Raw play clock region (BGR format)
60
- scorebug_bbox: Tuple[int, int, int, int] # Scorebug bounding box for reference
61
-
62
-
63
  # =============================================================================
64
  # Video Processing Models
65
  # =============================================================================
@@ -80,15 +62,6 @@ class VideoContext:
80
  end_frame: int # Last frame to process
81
 
82
 
83
- @dataclass
84
- class Pass1Results:
85
- """Results from Pass 1: Frame extraction and preprocessing."""
86
-
87
- frame_results: List[Dict[str, Any]] = field(default_factory=list) # Frame metadata
88
- template_tasks: List[FrameTemplateTask] = field(default_factory=list) # Template matching task queue
89
- ocr_samples: List[Tuple[float, np.ndarray, np.ndarray]] = field(default_factory=list) # (timestamp, raw_region, preprocessed) for template building
90
-
91
-
92
  # =============================================================================
93
  # Result Models
94
  # =============================================================================
 
5
  for configuration, intermediate results, and final output.
6
 
7
  Note: OCR-based clock reading has been removed in favor of template matching.
8
+ Streaming processing is used for optimal performance (read frame -> process immediately).
9
  See docs/ocr_to_template_migration.md for details.
10
  """
11
 
12
  from dataclasses import dataclass, field
13
+ from typing import Optional, List, Dict, Any, Tuple
 
 
 
 
 
14
 
15
 
16
  # =============================================================================
 
23
  """Configuration for play detection pipeline.
24
 
25
  Uses template matching for play clock reading (~34x faster than OCR).
26
+ Templates are built dynamically during Pass 0 using OCR for labeling,
27
+ then streaming detection processes each frame immediately via template matching.
28
  """
29
 
30
  video_path: str # Path to video file
 
42
  fixed_scorebug_coords: Optional[Tuple[int, int, int, int]] = None # (x, y, w, h) scorebug region (for metadata)
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  # =============================================================================
46
  # Video Processing Models
47
  # =============================================================================
 
62
  end_frame: int # Last frame to process
63
 
64
 
 
 
 
 
 
 
 
 
 
65
  # =============================================================================
66
  # Result Models
67
  # =============================================================================
src/pipeline/play_detector.py CHANGED
@@ -9,7 +9,8 @@ This module orchestrates the complete play detection pipeline:
9
  4. Play state machine processing
10
 
11
  Performance optimizations:
12
- - Sequential frame reading using grab() instead of seeking
 
13
  - Template matching for clock reading (~34x faster than OCR)
14
 
15
  Note: OCR-based clock reading has been removed in favor of template matching.
@@ -18,6 +19,8 @@ See docs/ocr_to_template_migration.md for details.
18
 
19
  import json
20
  import logging
 
 
21
  import time
22
  from pathlib import Path
23
  from typing import Optional, List, Dict, Any, Tuple
@@ -29,7 +32,7 @@ import numpy as np
29
  from detectors import ScorebugDetector, ScorebugDetection, PlayClockReader, PlayStateMachine, PlayEvent, PlayClockReading, TimeoutTracker
30
  from detectors.digit_template_reader import DigitTemplateBuilder, DigitTemplateLibrary, TemplatePlayClockReader
31
  from detectors.models import PlayClockRegionConfig
32
- from .models import DetectionConfig, FrameTemplateTask, DetectionResult, VideoContext, Pass1Results
33
 
34
  logger = logging.getLogger(__name__)
35
 
@@ -37,6 +40,128 @@ logger = logging.getLogger(__name__)
37
  _easyocr_reader = None # pylint: disable=invalid-name
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def _get_easyocr_reader() -> easyocr.Reader:
41
  """Get or create the global EasyOCR reader instance for template building."""
42
  global _easyocr_reader # pylint: disable=global-statement
@@ -431,13 +556,19 @@ class PlayDetector:
431
 
432
  return True
433
 
434
- def _pass1_extract_frames(self, context: VideoContext, stats: Dict[str, Any], timing: Dict[str, float]) -> Pass1Results:
435
  """
436
- Pass 1: Read frames, detect scorebug, extract play clock regions.
437
 
438
- This method uses the same logic whether coordinates were provided via
439
- fixed_coords config or via user selection - the scorebug_detector handles
440
- the difference via its fixed_region setting.
 
 
 
 
 
 
441
 
442
  Args:
443
  context: Video context with properties and capture object
@@ -445,115 +576,118 @@ class PlayDetector:
445
  timing: Timing dictionary to update
446
 
447
  Returns:
448
- Pass1Results with frame results and template tasks
449
  """
450
- logger.info("Pass 1: Frame extraction and preprocessing...")
451
-
452
- # Seek to start position
453
- t_io_start = time.perf_counter()
454
- context.cap.set(cv2.CAP_PROP_POS_FRAMES, context.start_frame)
455
- timing["video_io"] += time.perf_counter() - t_io_start
456
 
457
  logger.info(
458
- "Sequential reading: frame_skip=%d (%.2f fps effective), frames %d-%d",
459
  context.frame_skip,
460
  context.fps / context.frame_skip,
461
  context.start_frame,
462
  context.end_frame,
463
  )
464
 
465
- # Data structures for processing
466
- frame_results: List[Dict[str, Any]] = []
467
- template_tasks: List[FrameTemplateTask] = []
468
- ocr_samples: List[Tuple[float, np.ndarray, np.ndarray]] = [] # For template building
 
 
469
 
470
  # Flag to track if we've locked the scorebug region
471
  scorebug_region_locked = self.scorebug_detector._use_fixed_region if self.scorebug_detector else False
472
 
473
- current_frame = context.start_frame
 
474
 
475
- while current_frame < context.end_frame: # pylint: disable=too-many-nested-blocks
476
- current_time = current_frame / context.fps
 
 
 
 
477
 
478
- # Read frame
479
- t_io_start = time.perf_counter()
480
- ret, frame = context.cap.read()
481
- timing["video_io"] += time.perf_counter() - t_io_start
482
 
483
- if not ret:
484
- logger.warning("Could not read frame %d at %.1fs", current_frame, current_time)
485
- t_io_start = time.perf_counter()
486
- for _ in range(context.frame_skip - 1):
487
- context.cap.grab()
488
- timing["video_io"] += time.perf_counter() - t_io_start
489
- current_frame += context.frame_skip
490
- continue
491
 
492
- stats["total_frames"] += 1
 
493
 
494
- # Process frame
495
- frame_result, scorebug_region_locked = self._process_frame(frame, current_time, template_tasks, ocr_samples, timing, stats, scorebug_region_locked)
 
 
 
496
 
497
- frame_results.append(frame_result)
498
 
499
- # Progress logging every 30 seconds of video
500
- if stats["total_frames"] % int(30 / self.config.frame_interval) == 0:
501
- progress_pct = 100 * (current_time - context.start_time) / (context.end_time - context.start_time)
502
- logger.info("Pass 1 progress: %.1fs / %.1fs (%.0f%%)", current_time, context.end_time, progress_pct)
503
 
504
- # Skip frames sequentially
505
- t_io_start = time.perf_counter()
506
- for _ in range(context.frame_skip - 1):
507
- context.cap.grab()
508
- timing["video_io"] += time.perf_counter() - t_io_start
509
- current_frame += context.frame_skip
510
 
511
- context.cap.release()
512
- logger.info("Pass 1 complete: %d frames, %d template tasks, %d OCR samples", len(frame_results), len(template_tasks), len(ocr_samples))
 
 
 
 
513
 
514
- return Pass1Results(frame_results=frame_results, template_tasks=template_tasks, ocr_samples=ocr_samples)
515
 
516
- def _process_frame(
517
  self,
518
  frame: np.ndarray,
519
  current_time: float,
520
- template_tasks: List[FrameTemplateTask],
521
- ocr_samples: List[Tuple[float, np.ndarray, np.ndarray]],
522
  timing: Dict[str, float],
523
  stats: Dict[str, Any],
524
  scorebug_region_locked: bool,
525
- ) -> Tuple[Dict[str, Any], bool]:
526
  """
527
- Process a single frame.
 
 
 
528
 
529
  Args:
530
  frame: The video frame
531
  current_time: Current timestamp
532
- template_tasks: List to append template tasks to
533
- ocr_samples: List to append OCR samples to (for template building)
534
  timing: Timing dictionary to update
535
  stats: Stats dictionary to update
536
  scorebug_region_locked: Whether the scorebug region has been locked
537
 
538
  Returns:
539
- Tuple of (frame result dictionary, updated scorebug_region_locked)
540
  """
541
  # Detect scorebug
542
  t_start = time.perf_counter()
543
  if not scorebug_region_locked:
544
- if self.scorebug_detector.discover_and_lock_region(frame):
545
- scorebug_region_locked = True
546
- logger.info("Scorebug region locked at %s", self.scorebug_detector.fixed_region)
547
  scorebug = self.scorebug_detector.detect(frame)
548
  timing["scorebug_detection"] += time.perf_counter() - t_start
549
 
550
- # Store frame result
551
  frame_result = {
552
  "timestamp": current_time,
553
  "scorebug_detected": scorebug.detected,
554
  "scorebug_bbox": scorebug.bbox if scorebug.detected else None,
555
  "home_timeouts": None,
556
  "away_timeouts": None,
 
 
557
  }
558
 
559
  if scorebug.detected:
@@ -565,184 +699,54 @@ class PlayDetector:
565
  frame_result["home_timeouts"] = timeout_reading.home_timeouts
566
  frame_result["away_timeouts"] = timeout_reading.away_timeouts
567
 
568
- # Extract play clock region
569
  t_start = time.perf_counter()
570
  play_clock_region = self.clock_reader._extract_region(frame, scorebug.bbox) # pylint: disable=protected-access
571
- if play_clock_region is not None:
572
- # Store raw region for template matching
573
- template_tasks.append(FrameTemplateTask(timestamp=current_time, raw_region=play_clock_region.copy(), scorebug_bbox=scorebug.bbox))
574
- frame_result["template_task_idx"] = len(template_tasks) - 1
575
-
576
- # Legacy fallback: Store for OCR if Pass 0 didn't build templates
577
- # This only happens if no scorebug template was available for Pass 0
578
- if not self.template_reader and self.template_builder and len(ocr_samples) < self.config.template_collection_frames:
579
- preprocessed = self.clock_reader._preprocess_for_ocr(play_clock_region) # pylint: disable=protected-access
580
- ocr_samples.append((current_time, play_clock_region.copy(), preprocessed))
581
-
582
  timing["preprocessing"] += time.perf_counter() - t_start
583
 
584
- return frame_result, scorebug_region_locked
585
-
586
- def _pass15_build_templates(self, pass1_results: Pass1Results, timing: Dict[str, float]) -> None:
587
- """
588
- Pass 1.5: Build digit templates from OCR samples if needed.
589
-
590
- Args:
591
- pass1_results: Results from Pass 1
592
- timing: Timing dictionary to update
593
- """
594
- if self.template_reader or not pass1_results.ocr_samples:
595
- return
596
-
597
- logger.info("Pass 1.5: Building digit templates from %d OCR samples...", len(pass1_results.ocr_samples))
598
- t_build_start = time.perf_counter()
599
-
600
- # Get EasyOCR reader for labeling
601
- reader = _get_easyocr_reader()
602
-
603
- # Run OCR on samples and build templates
604
- for timestamp, raw_region, preprocessed in pass1_results.ocr_samples:
605
- try:
606
- # Run OCR to get label
607
- ocr_results = reader.readtext(preprocessed, allowlist="0123456789", detail=1)
608
- if ocr_results:
609
- best = max(ocr_results, key=lambda x: x[2])
610
- text, confidence = best[1].strip(), best[2]
611
-
612
- # Parse and validate
613
- try:
614
- value = int(text) if text and 0 <= int(text) <= 40 else None
615
- if value is not None:
616
- # Add sample to template builder
617
- self.template_builder.add_sample(raw_region, value, timestamp, confidence)
618
- except ValueError:
619
- pass # Invalid text, skip
620
- except Exception as e: # pylint: disable=broad-except
621
- logger.debug("OCR error during template building at %.1fs: %s", timestamp, e)
622
-
623
- # Build the templates
624
- self.template_library = self.template_builder.build_templates(min_samples=2)
625
- coverage = self.template_library.get_coverage_status()
626
- logger.info("Template coverage: %d/%d (%.1f%%)", coverage["total_have"], coverage["total_needed"], 100 * coverage["total_have"] / coverage["total_needed"])
627
-
628
- # Create template reader
629
- region_w = self.clock_reader.config.width if self.clock_reader.config else 50
630
- region_h = self.clock_reader.config.height if self.clock_reader.config else 28
631
- self.template_reader = TemplatePlayClockReader(self.template_library, region_w, region_h)
632
-
633
- timing["template_building"] = time.perf_counter() - t_build_start
634
- logger.info("Pass 1.5 complete: Template building took %.2fs", timing["template_building"])
635
-
636
- def _pass2_run_clock_reading(self, pass1_results: Pass1Results, timing: Dict[str, float]) -> Tuple[List[PlayClockReading], List[Dict[str, Any]]]:
637
- """
638
- Pass 2: Run clock reading using template matching.
639
-
640
- Args:
641
- pass1_results: Results from Pass 1
642
- timing: Timing dictionary to update
643
-
644
- Returns:
645
- Tuple of (clock reading results, updated frame_results)
646
- """
647
- frame_results = pass1_results.frame_results
648
-
649
- logger.info("Pass 2: Running template matching on %d frames...", len(pass1_results.template_tasks))
650
- t_match_start = time.perf_counter()
651
- clock_results = self._run_template_matching(pass1_results.template_tasks)
652
- timing["template_matching"] = time.perf_counter() - t_match_start
653
- logger.info("Pass 2 complete: Template matching took %.2fs", timing["template_matching"])
654
-
655
- # Convert to PlayClockReading for compatibility
656
- ocr_results = []
657
- for result in clock_results:
658
- ocr_results.append(
659
- PlayClockReading(
660
- detected=result.detected,
661
- value=result.value,
662
- confidence=result.confidence,
663
- raw_text=f"TEMPLATE_{result.value}" if result.detected else "TEMPLATE_FAILED",
664
  )
665
- )
666
-
667
- # Update frame_results to use template_task_idx for result lookup
668
- for fr in frame_results:
669
- if "template_task_idx" in fr:
670
- fr["clock_result_idx"] = fr["template_task_idx"]
671
-
672
- return ocr_results, frame_results
673
-
674
- def _pass3_run_state_machine(self, frame_results: List[Dict[str, Any]], ocr_results: List[PlayClockReading], stats: Dict[str, Any], timing: Dict[str, float]) -> None:
675
- """
676
- Pass 3: Process clock readings through state machine.
677
-
678
- Args:
679
- frame_results: Frame metadata from Pass 1
680
- ocr_results: Clock reading results from Pass 2
681
- stats: Stats dictionary to update
682
- timing: Timing dictionary to update
683
- """
684
- logger.info("Pass 3: Running state machine...")
685
- t_sm_start = time.perf_counter()
686
-
687
- for frame_result in frame_results:
688
- timestamp = frame_result["timestamp"]
689
- scorebug_detected = frame_result["scorebug_detected"]
690
- scorebug_bbox = frame_result["scorebug_bbox"]
691
-
692
- # Create scorebug detection object
693
- scorebug = ScorebugDetection(
694
- detected=scorebug_detected, bbox=scorebug_bbox, confidence=1.0 if scorebug_detected else 0.0, method="fixed" if scorebug_detected else "none"
695
- )
696
-
697
- # Get clock reading result
698
- clock = self._get_clock_reading_for_frame(frame_result, ocr_results)
699
-
700
- if clock.detected:
701
- stats["frames_with_clock"] += 1
702
-
703
- # Update state machine
704
- self.state_machine.update(timestamp, scorebug, clock)
705
-
706
- timing["state_machine"] = time.perf_counter() - t_sm_start
707
-
708
- def _get_clock_reading_for_frame(self, frame_result: Dict[str, Any], ocr_results: List[PlayClockReading]) -> PlayClockReading:
709
- """
710
- Get the clock reading for a specific frame.
711
-
712
- Args:
713
- frame_result: Frame metadata
714
- ocr_results: Clock reading results
715
-
716
- Returns:
717
- PlayClockReading for this frame
718
- """
719
- clock = None
720
-
721
- if "clock_result_idx" in frame_result:
722
- clock = ocr_results[frame_result["clock_result_idx"]]
723
- elif "template_task_idx" in frame_result:
724
- clock = ocr_results[frame_result["template_task_idx"]]
725
-
726
- if clock is None:
727
- scorebug_detected = frame_result.get("scorebug_detected", False)
728
- clock = PlayClockReading(detected=False, value=None, confidence=0.0, raw_text="NO_SCOREBUG" if not scorebug_detected else "PREPROCESS_FAILED")
729
 
730
- return clock
731
 
732
- def _pass4_clock_reset_and_build_result(
733
  self,
734
- frame_results: List[Dict[str, Any]],
735
- ocr_results: List[PlayClockReading],
736
  context: VideoContext,
737
  stats: Dict[str, Any],
738
  timing: Dict[str, float],
739
  ) -> DetectionResult:
740
  """
741
- Pass 4: Clock reset classification and result building.
742
 
743
  Args:
744
- frame_results: Frame metadata from Pass 1
745
- ocr_results: Clock reading results from Pass 2
746
  context: Video context
747
  stats: Processing stats
748
  timing: Timing breakdown
@@ -750,11 +754,10 @@ class PlayDetector:
750
  Returns:
751
  Final DetectionResult
752
  """
753
- # Build complete frame data for clock reset detection
754
- complete_frame_data = self._build_complete_frame_data(frame_results, ocr_results)
755
 
756
  # Detect and classify clock resets
757
- clock_reset_plays, clock_reset_stats = self._detect_clock_resets(complete_frame_data)
758
  logger.info(
759
  "Clock reset detection: %d total, %d weird (rejected), %d timeouts, %d special plays",
760
  clock_reset_stats.get("total", 0),
@@ -797,37 +800,6 @@ class PlayDetector:
797
 
798
  return result
799
 
800
- def _build_complete_frame_data(self, frame_results: List[Dict[str, Any]], ocr_results: List[PlayClockReading]) -> List[Dict[str, Any]]:
801
- """
802
- Build complete frame data for clock reset detection.
803
-
804
- Args:
805
- frame_results: Frame metadata from Pass 1
806
- ocr_results: Clock reading results from Pass 2
807
-
808
- Returns:
809
- List of frame data dictionaries with clock values
810
- """
811
- complete_frame_data = []
812
- for frame_result in frame_results:
813
- frame_data = {
814
- "timestamp": frame_result["timestamp"],
815
- "scorebug_detected": frame_result["scorebug_detected"],
816
- "home_timeouts": frame_result.get("home_timeouts"),
817
- "away_timeouts": frame_result.get("away_timeouts"),
818
- "clock_value": None,
819
- "clock_detected": False,
820
- }
821
-
822
- # Get clock result
823
- clock = self._get_clock_reading_for_frame(frame_result, ocr_results)
824
- frame_data["clock_detected"] = clock.detected
825
- frame_data["clock_value"] = clock.value
826
-
827
- complete_frame_data.append(frame_data)
828
-
829
- return complete_frame_data
830
-
831
  def _log_timing_breakdown(self, timing: Dict[str, float]) -> None:
832
  """Log the timing breakdown for the detection run."""
833
  total_time = sum(timing.values())
@@ -844,12 +816,11 @@ class PlayDetector:
844
  """
845
  Run play detection on the video segment.
846
 
847
- Uses template matching for clock reading (~34x faster than OCR):
848
- - Pass 1: Read frames, detect/verify scorebug, store raw regions
849
- - Pass 1.5 (if no templates): Run OCR on first N frames to build templates
850
- - Pass 2: Run template matching on all frames
851
- - Pass 3: Process results through state machine
852
- - Pass 4: Clock reset classification and result building
853
 
854
  When fixed coordinates are provided, the scorebug detection step simply verifies
855
  the scorebug is present at the known location (faster than searching).
@@ -884,28 +855,14 @@ class PlayDetector:
884
  self._log_detection_mode()
885
 
886
  # Initialize video and get processing context
887
- context, stats, timing_update = self._open_video_and_get_context()
888
- # Merge timing (preserve template_building from Pass 0)
889
- for k, v in timing_update.items():
890
- if k != "template_building" or timing.get(k, 0) == 0:
891
- timing[k] = v
892
 
893
- # Pass 1: Frame extraction and preprocessing (templates already built)
894
- pass1_results = self._pass1_extract_frames(context, stats, timing)
 
895
 
896
- # Pass 1.5: (Legacy) Build templates if Pass 0 didn't run
897
- # This is a fallback for cases where Pass 0 couldn't run (e.g., no template file)
898
- if not self.template_reader:
899
- self._pass15_build_templates(pass1_results, timing)
900
-
901
- # Pass 2: Run clock reading via template matching
902
- ocr_results, frame_results = self._pass2_run_clock_reading(pass1_results, timing)
903
-
904
- # Pass 3: Process through state machine
905
- self._pass3_run_state_machine(frame_results, ocr_results, stats, timing)
906
-
907
- # Pass 4: Clock reset classification and result building
908
- return self._pass4_clock_reset_and_build_result(frame_results, ocr_results, context, stats, timing)
909
 
910
  def _log_detection_mode(self) -> None:
911
  """Log the detection mode being used."""
@@ -924,47 +881,6 @@ class PlayDetector:
924
  else:
925
  logger.info(" Will build templates using fallback method")
926
 
927
- def _run_template_matching(self, tasks: List[FrameTemplateTask]) -> List[PlayClockReading]:
928
- """
929
- Run template matching on all frames.
930
-
931
- This is ~34x faster than OCR (~1.4ms vs ~49ms per frame).
932
-
933
- Args:
934
- tasks: List of FrameTemplateTask with raw play clock regions
935
-
936
- Returns:
937
- List of PlayClockReading results in same order as input
938
- """
939
- if not self.template_reader:
940
- logger.error("Template reader not initialized")
941
- return [PlayClockReading(detected=False, value=None, confidence=0.0, raw_text="NO_TEMPLATE_READER")] * len(tasks)
942
-
943
- results = []
944
- total_tasks = len(tasks)
945
- progress_interval = max(100, total_tasks // 10)
946
-
947
- for idx, task in enumerate(tasks):
948
- # Run template matching
949
- result = self.template_reader.read(task.raw_region)
950
-
951
- # Convert to PlayClockReading for compatibility with rest of pipeline
952
- reading = PlayClockReading(
953
- detected=result.detected,
954
- value=result.value,
955
- confidence=result.confidence,
956
- raw_text=f"TEMPLATE_{result.value}" if result.detected else "TEMPLATE_FAILED",
957
- )
958
- results.append(reading)
959
-
960
- # Log progress periodically
961
- completed = idx + 1
962
- if completed % progress_interval == 0 or completed == total_tasks:
963
- pct = 100 * completed / total_tasks
964
- logger.info("Template matching progress: %d/%d (%.0f%%)", completed, total_tasks, pct)
965
-
966
- return results
967
-
968
  def _detect_clock_resets(self, frame_data: List[Dict[str, Any]]) -> Tuple[List[PlayEvent], Dict[str, int]]:
969
  """
970
  Detect and classify 40 -> 25 clock reset events.
@@ -1013,7 +929,7 @@ class PlayDetector:
1013
  elif timeout_team:
1014
  # Class B: Team timeout - record but mark as timeout
1015
  stats["timeout"] += 1
1016
- play_end = self._find_clock_reset_play_end(frame_data, i, max_duration=30.0) # Timeouts can last longer
1017
  play = PlayEvent(
1018
  play_number=0,
1019
  start_time=timestamp,
 
9
  4. Play state machine processing
10
 
11
  Performance optimizations:
12
+ - Streaming processing: read frame -> process immediately (no intermediate storage)
13
+ - Threaded video I/O: background thread reads frames while main thread processes
14
  - Template matching for clock reading (~34x faster than OCR)
15
 
16
  Note: OCR-based clock reading has been removed in favor of template matching.
 
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
 
32
  from detectors import ScorebugDetector, ScorebugDetection, PlayClockReader, PlayStateMachine, PlayEvent, PlayClockReading, TimeoutTracker
33
  from detectors.digit_template_reader import DigitTemplateBuilder, DigitTemplateLibrary, TemplatePlayClockReader
34
  from detectors.models import PlayClockRegionConfig
35
+ from .models import DetectionConfig, DetectionResult, VideoContext
36
 
37
  logger = logging.getLogger(__name__)
38
 
 
40
  _easyocr_reader = None # pylint: disable=invalid-name
41
 
42
 
43
+ class ThreadedFrameReader:
44
+ """
45
+ Background thread for reading video frames.
46
+
47
+ Uses a producer-consumer pattern to overlap video I/O with processing.
48
+ The reader thread reads frames ahead into a queue while the main thread
49
+ processes frames from the queue.
50
+
51
+ This provides significant speedup by hiding video decode latency.
52
+ """
53
+
54
+ def __init__(self, cap: cv2.VideoCapture, start_frame: int, end_frame: int, frame_skip: int, queue_size: int = 32):
55
+ """
56
+ Initialize the threaded frame reader.
57
+
58
+ Args:
59
+ cap: OpenCV VideoCapture object
60
+ start_frame: First frame to read
61
+ end_frame: Last frame to read
62
+ frame_skip: Number of frames to skip between reads
63
+ queue_size: Maximum frames to buffer (default 32)
64
+ """
65
+ self.cap = cap
66
+ self.start_frame = start_frame
67
+ self.end_frame = end_frame
68
+ self.frame_skip = frame_skip
69
+ self.queue_size = queue_size
70
+
71
+ # Frame queue: (frame_number, frame_data) or (frame_number, None) for read failures
72
+ self.frame_queue: queue.Queue = queue.Queue(maxsize=queue_size)
73
+
74
+ # Control flags
75
+ self.stop_flag = threading.Event()
76
+ self.reader_thread: Optional[threading.Thread] = None
77
+
78
+ # Timing stats
79
+ self.io_time = 0.0
80
+ self.frames_read = 0
81
+
82
+ def start(self) -> None:
83
+ """Start the background reader thread."""
84
+ self.stop_flag.clear()
85
+ self.reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
86
+ self.reader_thread.start()
87
+ logger.debug("Threaded frame reader started")
88
+
89
+ def stop(self) -> None:
90
+ """Stop the background reader thread."""
91
+ self.stop_flag.set()
92
+ if self.reader_thread and self.reader_thread.is_alive():
93
+ # Drain the queue to unblock the reader thread
94
+ try:
95
+ while True:
96
+ self.frame_queue.get_nowait()
97
+ except queue.Empty:
98
+ pass
99
+ self.reader_thread.join(timeout=2.0)
100
+ logger.debug("Threaded frame reader stopped (read %d frames, %.2fs I/O)", self.frames_read, self.io_time)
101
+
102
+ def get_frame(self, timeout: float = 5.0) -> Optional[Tuple[int, Optional[np.ndarray]]]:
103
+ """
104
+ Get the next frame from the queue.
105
+
106
+ Args:
107
+ timeout: Maximum time to wait for a frame
108
+
109
+ Returns:
110
+ Tuple of (frame_number, frame_data) or None if queue is empty and reader is done
111
+ """
112
+ try:
113
+ return self.frame_queue.get(timeout=timeout)
114
+ except queue.Empty:
115
+ return None
116
+
117
+ def _reader_loop(self) -> None:
118
+ """Background thread that reads frames into the queue."""
119
+ # Seek to start position
120
+ t_start = time.perf_counter()
121
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.start_frame)
122
+ self.io_time += time.perf_counter() - t_start
123
+
124
+ current_frame = self.start_frame
125
+
126
+ while current_frame < self.end_frame and not self.stop_flag.is_set():
127
+ # Read frame
128
+ t_start = time.perf_counter()
129
+ ret, frame = self.cap.read()
130
+ self.io_time += time.perf_counter() - t_start
131
+
132
+ if ret:
133
+ self.frames_read += 1
134
+ # Put frame in queue (blocks if queue is full)
135
+ try:
136
+ self.frame_queue.put((current_frame, frame), timeout=5.0)
137
+ except queue.Full:
138
+ if self.stop_flag.is_set():
139
+ break
140
+ logger.warning("Frame queue full, dropping frame %d", current_frame)
141
+ else:
142
+ # Signal read failure
143
+ try:
144
+ self.frame_queue.put((current_frame, None), timeout=1.0)
145
+ except queue.Full:
146
+ pass
147
+
148
+ # Skip frames
149
+ t_start = time.perf_counter()
150
+ for _ in range(self.frame_skip - 1):
151
+ if self.stop_flag.is_set():
152
+ break
153
+ self.cap.grab()
154
+ self.io_time += time.perf_counter() - t_start
155
+
156
+ current_frame += self.frame_skip
157
+
158
+ # Signal end of stream
159
+ try:
160
+ self.frame_queue.put(None, timeout=1.0)
161
+ except queue.Full:
162
+ pass
163
+
164
+
165
  def _get_easyocr_reader() -> easyocr.Reader:
166
  """Get or create the global EasyOCR reader instance for template building."""
167
  global _easyocr_reader # pylint: disable=global-statement
 
556
 
557
  return True
558
 
559
+ def _streaming_detection_pass(self, context: VideoContext, stats: Dict[str, Any], timing: Dict[str, float]) -> List[Dict[str, Any]]:
560
  """
561
+ Streaming detection pass: Read frames, process immediately, no intermediate storage.
562
 
563
+ This combines the old Pass 1 (frame extraction) and Pass 2 (template matching) into
564
+ a single streaming pass. Each frame is:
565
+ 1. Read from video (in background thread)
566
+ 2. Scorebug detected/verified
567
+ 3. Play clock region extracted
568
+ 4. Template matched immediately
569
+ 5. State machine updated
570
+
571
+ Uses threaded video I/O to overlap reading with processing for better performance.
572
 
573
  Args:
574
  context: Video context with properties and capture object
 
576
  timing: Timing dictionary to update
577
 
578
  Returns:
579
+ List of frame data dictionaries with all processing results
580
  """
581
+ logger.info("Streaming detection pass: frame extraction + template matching...")
 
 
 
 
 
582
 
583
  logger.info(
584
+ "Threaded reading: frame_skip=%d (%.2f fps effective), frames %d-%d",
585
  context.frame_skip,
586
  context.fps / context.frame_skip,
587
  context.start_frame,
588
  context.end_frame,
589
  )
590
 
591
+ # Start threaded frame reader
592
+ frame_reader = ThreadedFrameReader(context.cap, context.start_frame, context.end_frame, context.frame_skip, queue_size=32)
593
+ frame_reader.start()
594
+
595
+ # Data structures for results
596
+ frame_data: List[Dict[str, Any]] = []
597
 
598
  # Flag to track if we've locked the scorebug region
599
  scorebug_region_locked = self.scorebug_detector._use_fixed_region if self.scorebug_detector else False
600
 
601
+ # Progress tracking
602
+ progress_interval = int(30 / self.config.frame_interval) # Log every 30 seconds of video
603
 
604
+ try:
605
+ while True:
606
+ # Get next frame from background reader
607
+ result = frame_reader.get_frame(timeout=10.0)
608
+ if result is None:
609
+ break # End of stream
610
 
611
+ current_frame, frame = result
612
+ current_time = current_frame / context.fps
 
 
613
 
614
+ if frame is None:
615
+ logger.warning("Could not read frame %d at %.1fs", current_frame, current_time)
616
+ continue
617
+
618
+ stats["total_frames"] += 1
 
 
 
619
 
620
+ # Process frame with immediate template matching
621
+ frame_result = self._process_frame_streaming(frame, current_time, timing, stats, scorebug_region_locked)
622
 
623
+ # Update scorebug lock status
624
+ if not scorebug_region_locked and frame_result.get("scorebug_detected"):
625
+ if self.scorebug_detector.discover_and_lock_region(frame):
626
+ scorebug_region_locked = True
627
+ logger.info("Scorebug region locked at %s", self.scorebug_detector.fixed_region)
628
 
629
+ frame_data.append(frame_result)
630
 
631
+ # Progress logging
632
+ if stats["total_frames"] % progress_interval == 0:
633
+ progress_pct = 100 * (current_time - context.start_time) / (context.end_time - context.start_time)
634
+ logger.info("Detection progress: %.1fs / %.1fs (%.0f%%)", current_time, context.end_time, progress_pct)
635
 
636
+ finally:
637
+ # Stop the reader thread and get I/O timing
638
+ frame_reader.stop()
639
+ timing["video_io"] = frame_reader.io_time
640
+ context.cap.release()
 
641
 
642
+ logger.info(
643
+ "Streaming detection complete: %d frames processed, %d with scorebug, %d with clock",
644
+ stats["total_frames"],
645
+ stats["frames_with_scorebug"],
646
+ stats["frames_with_clock"],
647
+ )
648
 
649
+ return frame_data
650
 
651
+ def _process_frame_streaming(
652
  self,
653
  frame: np.ndarray,
654
  current_time: float,
 
 
655
  timing: Dict[str, float],
656
  stats: Dict[str, Any],
657
  scorebug_region_locked: bool,
658
+ ) -> Dict[str, Any]:
659
  """
660
+ Process a single frame with immediate template matching.
661
+
662
+ This is the streaming version that processes each frame completely
663
+ without storing intermediate data.
664
 
665
  Args:
666
  frame: The video frame
667
  current_time: Current timestamp
 
 
668
  timing: Timing dictionary to update
669
  stats: Stats dictionary to update
670
  scorebug_region_locked: Whether the scorebug region has been locked
671
 
672
  Returns:
673
+ Frame data dictionary with all processing results
674
  """
675
  # Detect scorebug
676
  t_start = time.perf_counter()
677
  if not scorebug_region_locked:
678
+ self.scorebug_detector.discover_and_lock_region(frame)
 
 
679
  scorebug = self.scorebug_detector.detect(frame)
680
  timing["scorebug_detection"] += time.perf_counter() - t_start
681
 
682
+ # Initialize frame result
683
  frame_result = {
684
  "timestamp": current_time,
685
  "scorebug_detected": scorebug.detected,
686
  "scorebug_bbox": scorebug.bbox if scorebug.detected else None,
687
  "home_timeouts": None,
688
  "away_timeouts": None,
689
+ "clock_value": None,
690
+ "clock_detected": False,
691
  }
692
 
693
  if scorebug.detected:
 
699
  frame_result["home_timeouts"] = timeout_reading.home_timeouts
700
  frame_result["away_timeouts"] = timeout_reading.away_timeouts
701
 
702
+ # Extract play clock region and run template matching immediately
703
  t_start = time.perf_counter()
704
  play_clock_region = self.clock_reader._extract_region(frame, scorebug.bbox) # pylint: disable=protected-access
 
 
 
 
 
 
 
 
 
 
 
705
  timing["preprocessing"] += time.perf_counter() - t_start
706
 
707
+ if play_clock_region is not None and self.template_reader:
708
+ # Run template matching immediately (no intermediate storage!)
709
+ t_start = time.perf_counter()
710
+ clock_result = self.template_reader.read(play_clock_region)
711
+ timing["template_matching"] += time.perf_counter() - t_start
712
+
713
+ frame_result["clock_detected"] = clock_result.detected
714
+ frame_result["clock_value"] = clock_result.value
715
+
716
+ if clock_result.detected:
717
+ stats["frames_with_clock"] += 1
718
+
719
+ # Update state machine immediately
720
+ t_start = time.perf_counter()
721
+ clock_reading = PlayClockReading(
722
+ detected=clock_result.detected,
723
+ value=clock_result.value,
724
+ confidence=clock_result.confidence,
725
+ raw_text=f"TEMPLATE_{clock_result.value}" if clock_result.detected else "TEMPLATE_FAILED",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  )
727
+ self.state_machine.update(current_time, scorebug, clock_reading)
728
+ timing["state_machine"] += time.perf_counter() - t_start
729
+ else:
730
+ # No scorebug - still update state machine
731
+ t_start = time.perf_counter()
732
+ clock_reading = PlayClockReading(detected=False, value=None, confidence=0.0, raw_text="NO_SCOREBUG")
733
+ self.state_machine.update(current_time, scorebug, clock_reading)
734
+ timing["state_machine"] += time.perf_counter() - t_start
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
 
736
+ return frame_result
737
 
738
+ def _finalize_detection(
739
  self,
740
+ frame_data: List[Dict[str, Any]],
 
741
  context: VideoContext,
742
  stats: Dict[str, Any],
743
  timing: Dict[str, float],
744
  ) -> DetectionResult:
745
  """
746
+ Finalize detection: clock reset classification and result building.
747
 
748
  Args:
749
+ frame_data: Complete frame data from streaming detection pass
 
750
  context: Video context
751
  stats: Processing stats
752
  timing: Timing breakdown
 
754
  Returns:
755
  Final DetectionResult
756
  """
757
+ # Frame data already has clock values from streaming pass
 
758
 
759
  # Detect and classify clock resets
760
+ clock_reset_plays, clock_reset_stats = self._detect_clock_resets(frame_data)
761
  logger.info(
762
  "Clock reset detection: %d total, %d weird (rejected), %d timeouts, %d special plays",
763
  clock_reset_stats.get("total", 0),
 
800
 
801
  return result
802
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
803
  def _log_timing_breakdown(self, timing: Dict[str, float]) -> None:
804
  """Log the timing breakdown for the detection run."""
805
  total_time = sum(timing.values())
 
816
  """
817
  Run play detection on the video segment.
818
 
819
+ Uses streaming processing for optimal performance:
820
+ - Pass 0 (if needed): Build digit templates using OCR on scorebug-verified frames
821
+ - Streaming pass: Read frame -> extract region -> template match -> state machine update
822
+ (threaded video I/O overlaps reading with processing)
823
+ - Finalize: Clock reset classification and result building
 
824
 
825
  When fixed coordinates are provided, the scorebug detection step simply verifies
826
  the scorebug is present at the known location (faster than searching).
 
855
  self._log_detection_mode()
856
 
857
  # Initialize video and get processing context
858
+ context, stats, _ = self._open_video_and_get_context()
 
 
 
 
859
 
860
+ # Streaming detection pass: read frames + template match + state machine (all in one)
861
+ # Uses threaded video I/O to overlap reading with processing
862
+ frame_data = self._streaming_detection_pass(context, stats, timing)
863
 
864
+ # Finalize: Clock reset classification and result building
865
+ return self._finalize_detection(frame_data, context, stats, timing)
 
 
 
 
 
 
 
 
 
 
 
866
 
867
  def _log_detection_mode(self) -> None:
868
  """Log the detection mode being used."""
 
881
  else:
882
  logger.info(" Will build templates using fallback method")
883
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
  def _detect_clock_resets(self, frame_data: List[Dict[str, Any]]) -> Tuple[List[PlayEvent], Dict[str, int]]:
885
  """
886
  Detect and classify 40 -> 25 clock reset events.
 
929
  elif timeout_team:
930
  # Class B: Team timeout - record but mark as timeout
931
  stats["timeout"] += 1
932
+ play_end = self._find_clock_reset_play_end(frame_data, i, max_duration=15.0) # Same as normal plays
933
  play = PlayEvent(
934
  play_number=0,
935
  start_time=timestamp,