Spaces:
Sleeping
Sleeping
Commit ·
d303d3f
1
Parent(s): 0d63b43
this is the real v4 now
Browse files- docs/v4_baseline_comparison.md +84 -135
- src/pipeline/__init__.py +1 -4
- src/pipeline/models.py +4 -31
- src/pipeline/play_detector.py +244 -328
docs/v4_baseline_comparison.md
CHANGED
|
@@ -1,179 +1,128 @@
|
|
| 1 |
-
# V4 Baseline Comparison
|
| 2 |
|
| 3 |
**Date:** January 7, 2026
|
| 4 |
-
**Video:** OSU vs Tenn 12.21.24.mkv
|
| 5 |
-
**Method:**
|
|
|
|
| 6 |
|
| 7 |
---
|
| 8 |
|
| 9 |
-
##
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 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 |
-
###
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
|
| 56 |
-
|
|
| 57 |
-
|
|
| 58 |
-
|
|
| 59 |
-
|
|
| 60 |
|
| 61 |
-
|
| 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 |
-
|
| 68 |
|
| 69 |
-
|
|
| 70 |
-
|--------|----|----|
|
| 71 |
-
|
|
| 72 |
-
|
|
| 73 |
-
|
|
| 74 |
|
| 75 |
-
**
|
| 76 |
|
| 77 |
---
|
| 78 |
|
| 79 |
-
##
|
| 80 |
|
| 81 |
-
###
|
| 82 |
-
- **V3:** EasyOCR at ~49ms/frame (312.7s total)
|
| 83 |
-
- **V4:** Template matching at ~2.2ms/frame (39.7s total)
|
| 84 |
|
| 85 |
-
|
| 86 |
-
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
-
###
|
| 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 |
-
|
| 95 |
-
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
First 400 frames: + OCR labeling for template building (~17s one-time)
|
| 115 |
-
```
|
| 116 |
|
| 117 |
---
|
| 118 |
|
| 119 |
-
##
|
| 120 |
|
| 121 |
-
|
|
| 122 |
-
|--------
|
| 123 |
-
|
|
| 124 |
-
|
|
| 125 |
-
|
|
| 126 |
-
| State Machine | 0.02s | 0.02s | 1.0x |
|
| 127 |
-
| **Total** | **578.9s** | **221.3s** | **2.6x** |
|
| 128 |
|
| 129 |
-
*
|
| 130 |
-
**Includes template building (16.8s) + template matching (39.7s)
|
| 131 |
|
| 132 |
---
|
| 133 |
|
| 134 |
-
##
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
|
| 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 |
-
##
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
---
|
| 165 |
|
| 166 |
-
##
|
| 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
|
| 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
|
| 31 |
-
|
| 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 |
-
-
|
|
|
|
| 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,
|
| 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
|
| 435 |
"""
|
| 436 |
-
|
| 437 |
|
| 438 |
-
This
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 449 |
"""
|
| 450 |
-
logger.info("
|
| 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 |
-
"
|
| 459 |
context.frame_skip,
|
| 460 |
context.fps / context.frame_skip,
|
| 461 |
context.start_frame,
|
| 462 |
context.end_frame,
|
| 463 |
)
|
| 464 |
|
| 465 |
-
#
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 474 |
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
ret, frame = context.cap.read()
|
| 481 |
-
timing["video_io"] += time.perf_counter() - t_io_start
|
| 482 |
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
timing["video_io"] += time.perf_counter() - t_io_start
|
| 489 |
-
current_frame += context.frame_skip
|
| 490 |
-
continue
|
| 491 |
|
| 492 |
-
|
|
|
|
| 493 |
|
| 494 |
-
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
| 496 |
|
| 497 |
-
|
| 498 |
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
current_frame += context.frame_skip
|
| 510 |
|
| 511 |
-
|
| 512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
|
| 514 |
-
return
|
| 515 |
|
| 516 |
-
def
|
| 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 |
-
) ->
|
| 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 |
-
|
| 540 |
"""
|
| 541 |
# Detect scorebug
|
| 542 |
t_start = time.perf_counter()
|
| 543 |
if not scorebug_region_locked:
|
| 544 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 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 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 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
|
| 731 |
|
| 732 |
-
def
|
| 733 |
self,
|
| 734 |
-
|
| 735 |
-
ocr_results: List[PlayClockReading],
|
| 736 |
context: VideoContext,
|
| 737 |
stats: Dict[str, Any],
|
| 738 |
timing: Dict[str, float],
|
| 739 |
) -> DetectionResult:
|
| 740 |
"""
|
| 741 |
-
|
| 742 |
|
| 743 |
Args:
|
| 744 |
-
|
| 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 |
-
#
|
| 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(
|
| 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
|
| 848 |
-
- Pass
|
| 849 |
-
-
|
| 850 |
-
|
| 851 |
-
-
|
| 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,
|
| 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 |
-
#
|
| 894 |
-
|
|
|
|
| 895 |
|
| 896 |
-
#
|
| 897 |
-
|
| 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=
|
| 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,
|