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