andytaylor-smg commited on
Commit
07392e1
Β·
1 Parent(s): aa2c8ff

refactoring ui dir

Browse files
README.md CHANGED
@@ -43,7 +43,10 @@ cfb40/
43
  β”œβ”€β”€ main.py # Minimal pipeline entry point
44
  β”œβ”€β”€ src/
45
  β”‚ β”œβ”€β”€ ui/ # User interface modules
46
- β”‚ β”‚ β”œβ”€β”€ region_selector.py # Interactive region selection (OpenCV)
 
 
 
47
  β”‚ β”‚ └── __init__.py
48
  β”‚ β”œβ”€β”€ video/ # Video processing modules
49
  β”‚ β”‚ β”œβ”€β”€ ffmpeg_ops.py # FFmpeg clip extraction/concatenation
@@ -216,7 +219,8 @@ The codebase is organized into modular components under `src/`:
216
 
217
  ### Key Classes
218
 
219
- - **`RegionSelector`** (`ui/region_selector.py`) - Interactive region selection with two-click or drag modes
 
220
  - **`SessionConfig`** (`config/session.py`) - Dataclass holding all session configuration
221
  - **`PlayDetector`** (`pipeline/play_detector.py`) - Main detection pipeline
222
  - **`PlayStateMachine`** (`detectors/play_state_machine.py`) - Play boundary detection logic
 
43
  β”œβ”€β”€ main.py # Minimal pipeline entry point
44
  β”œβ”€β”€ src/
45
  β”‚ β”œβ”€β”€ ui/ # User interface modules
46
+ β”‚ β”‚ β”œβ”€β”€ models.py # Pydantic models (BBox, SelectionState, etc.)
47
+ β”‚ β”‚ β”œβ”€β”€ selector.py # RegionSelector class for mouse callbacks
48
+ β”‚ β”‚ β”œβ”€β”€ sessions.py # Interactive selection session classes
49
+ β”‚ β”‚ β”œβ”€β”€ api.py # Public API functions
50
  β”‚ β”‚ └── __init__.py
51
  β”‚ β”œβ”€β”€ video/ # Video processing modules
52
  β”‚ β”‚ β”œβ”€β”€ ffmpeg_ops.py # FFmpeg clip extraction/concatenation
 
219
 
220
  ### Key Classes
221
 
222
+ - **`RegionSelector`** (`ui/selector.py`) - Interactive region selection with two-click or drag modes
223
+ - **`BBox`** (`ui/models.py`) - Pydantic model for bounding box coordinates
224
  - **`SessionConfig`** (`config/session.py`) - Dataclass holding all session configuration
225
  - **`PlayDetector`** (`pipeline/play_detector.py`) - Main detection pipeline
226
  - **`PlayStateMachine`** (`detectors/play_state_machine.py`) - Play boundary detection logic
main.py CHANGED
@@ -49,7 +49,7 @@ from ui import (
49
  select_timeout_region,
50
  get_video_path_from_user,
51
  )
52
- from ui.region_selector import extract_sample_frames_for_selection
53
  from video import generate_clips
54
  from pipeline import run_detection, print_results_summary
55
 
 
49
  select_timeout_region,
50
  get_video_path_from_user,
51
  )
52
+ from ui.api import extract_sample_frames_for_selection
53
  from video import generate_clips
54
  from pipeline import run_detection, print_results_summary
55
 
src/detectors/scorebug_detector.py CHANGED
@@ -126,6 +126,8 @@ class ScorebugDetector:
126
  Detect scorebug in a frame.
127
 
128
  Uses fixed-region mode if configured (much faster), otherwise searches entire frame.
 
 
129
 
130
  Args:
131
  frame: Input frame (BGR format)
@@ -133,14 +135,22 @@ class ScorebugDetector:
133
  Returns:
134
  ScorebugDetection object with detection results
135
  """
136
- if self.template is None:
137
- logger.debug("No template loaded, cannot detect scorebug")
138
- return ScorebugDetection(detected=False, confidence=0.0, method="none")
139
-
140
  # Use fixed-region mode if configured (much faster - only checks known location)
141
  if self._use_fixed_region and self.fixed_region is not None:
 
 
 
 
 
 
 
142
  detection = self._detect_in_fixed_region(frame)
143
  else:
 
 
 
 
 
144
  # Full-frame template matching (slower, searches entire frame)
145
  detection = self._detect_by_template_fullsearch(frame)
146
 
 
126
  Detect scorebug in a frame.
127
 
128
  Uses fixed-region mode if configured (much faster), otherwise searches entire frame.
129
+ If a fixed region is set but no template is loaded, assumes scorebug is present
130
+ at the fixed location (useful when coordinates come from user selection).
131
 
132
  Args:
133
  frame: Input frame (BGR format)
 
135
  Returns:
136
  ScorebugDetection object with detection results
137
  """
 
 
 
 
138
  # Use fixed-region mode if configured (much faster - only checks known location)
139
  if self._use_fixed_region and self.fixed_region is not None:
140
+ # If no template loaded, assume scorebug is present at fixed location
141
+ # This is used when coordinates are provided via fixed config (no verification needed)
142
+ if self.template is None:
143
+ x, y, w, h = self.fixed_region
144
+ logger.debug("Fixed region mode without template - assuming scorebug present at %s", self.fixed_region)
145
+ return ScorebugDetection(detected=True, confidence=1.0, bbox=(x, y, w, h), method="fixed_region_assumed")
146
+
147
  detection = self._detect_in_fixed_region(frame)
148
  else:
149
+ # Need template for full-frame search
150
+ if self.template is None:
151
+ logger.debug("No template loaded, cannot detect scorebug")
152
+ return ScorebugDetection(detected=False, confidence=0.0, method="none")
153
+
154
  # Full-frame template matching (slower, searches entire frame)
155
  detection = self._detect_by_template_fullsearch(frame)
156
 
src/pipeline/play_detector.py CHANGED
@@ -26,7 +26,7 @@ import numpy as np
26
  from detectors import ScorebugDetector, ScorebugDetection, PlayClockReader, PlayStateMachine, PlayEvent, PlayClockReading, TimeoutTracker
27
  from detectors.play_clock_reader import _get_easyocr_reader
28
  from detectors.digit_template_reader import DigitTemplateBuilder, DigitTemplateLibrary, TemplatePlayClockReader
29
- from config.session import MIN_PLAY_DURATION
30
  from .models import DetectionConfig, FrameOCRTask, FrameTemplateTask, DetectionResult, VideoContext, Pass1Results
31
 
32
  logger = logging.getLogger(__name__)
@@ -95,7 +95,8 @@ class PlayDetector:
95
  if not video_path.exists():
96
  raise FileNotFoundError(f"Video not found: {self.config.video_path}")
97
 
98
- # In fixed coordinates mode, scorebug template is not needed
 
99
  if not self.config.fixed_playclock_coords:
100
  template_path = Path(self.config.template_path)
101
  if not template_path.exists():
@@ -109,17 +110,46 @@ class PlayDetector:
109
  """Initialize detection components."""
110
  logger.info("Initializing play detector components...")
111
 
112
- # In fixed coordinates mode, skip scorebug detector (not needed)
113
- if self.config.fixed_playclock_coords:
114
- self.scorebug_detector = None
115
- self.clock_reader = None
116
- logger.info("Fixed coordinates mode - scorebug detection disabled")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  else:
118
- # Initialize scorebug detector with split detection setting
119
  self.scorebug_detector = ScorebugDetector(template_path=self.config.template_path, use_split_detection=self.config.use_split_detection)
120
  logger.info("Scorebug detector initialized (split_detection=%s)", self.config.use_split_detection)
121
 
122
- # Initialize play clock reader
123
  self.clock_reader = PlayClockReader(region_config_path=self.config.clock_region_config_path)
124
  logger.info("Play clock reader initialized")
125
 
@@ -129,11 +159,8 @@ class PlayDetector:
129
 
130
  # Initialize template matching components if enabled
131
  if self.config.use_template_matching:
132
- # Determine region dimensions from fixed coords or clock reader config
133
- if self.config.fixed_playclock_coords:
134
- region_w = self.config.fixed_playclock_coords[2] # width from fixed coords
135
- region_h = self.config.fixed_playclock_coords[3] # height from fixed coords
136
- elif self.clock_reader and self.clock_reader.config:
137
  region_w = self.clock_reader.config.width
138
  region_h = self.clock_reader.config.height
139
  else:
@@ -149,8 +176,8 @@ class PlayDetector:
149
  self.template_library = None
150
  logger.info("Could not load templates, will build during detection")
151
 
152
- # Initialize template builder for collection phase (not used in fixed coords mode)
153
- if self.template_library is None and not self.config.fixed_playclock_coords:
154
  self.template_builder = DigitTemplateBuilder(region_w, region_h)
155
  logger.info("Template builder initialized for collection phase")
156
 
@@ -207,15 +234,18 @@ class PlayDetector:
207
 
208
  return context, stats, timing
209
 
210
- def _pass1_extract_frames(self, context: VideoContext, stats: Dict[str, Any], timing: Dict[str, float], use_fixed_coords: bool) -> Pass1Results:
211
  """
212
  Pass 1: Read frames, detect scorebug, extract play clock regions.
213
 
 
 
 
 
214
  Args:
215
  context: Video context with properties and capture object
216
  stats: Stats dictionary to update
217
  timing: Timing dictionary to update
218
- use_fixed_coords: Whether to use fixed coordinates mode
219
 
220
  Returns:
221
  Pass1Results with frame results, OCR tasks, and template tasks
@@ -241,7 +271,8 @@ class PlayDetector:
241
  template_tasks: List[FrameTemplateTask] = []
242
 
243
  # Flag to track if we've locked the scorebug region
244
- scorebug_region_locked = False
 
245
 
246
  current_frame = context.start_frame
247
 
@@ -264,11 +295,8 @@ class PlayDetector:
264
 
265
  stats["total_frames"] += 1
266
 
267
- # Fixed coordinates mode: skip scorebug detection entirely
268
- if use_fixed_coords:
269
- frame_result = self._process_frame_fixed_coords(frame, current_time, template_tasks, timing, stats)
270
- else:
271
- frame_result, scorebug_region_locked = self._process_frame_standard(frame, current_time, template_tasks, ocr_tasks, timing, stats, scorebug_region_locked)
272
 
273
  frame_results.append(frame_result)
274
 
@@ -289,51 +317,6 @@ class PlayDetector:
289
 
290
  return Pass1Results(frame_results=frame_results, ocr_tasks=ocr_tasks, template_tasks=template_tasks)
291
 
292
- def _process_frame_fixed_coords(
293
- self,
294
- frame: np.ndarray,
295
- current_time: float,
296
- template_tasks: List[FrameTemplateTask],
297
- timing: Dict[str, float],
298
- stats: Dict[str, Any],
299
- ) -> Dict[str, Any]:
300
- """
301
- Process a single frame in fixed coordinates mode.
302
-
303
- Args:
304
- frame: The video frame
305
- current_time: Current timestamp
306
- template_tasks: List to append template tasks to
307
- timing: Timing dictionary to update
308
- stats: Stats dictionary to update
309
-
310
- Returns:
311
- Frame result dictionary
312
- """
313
- # Use fixed coordinates - no scorebug detection needed
314
- scorebug_bbox = self.config.fixed_scorebug_coords or (0, 0, 0, 0)
315
- frame_result = {
316
- "timestamp": current_time,
317
- "scorebug_detected": True, # Always true in fixed mode
318
- "scorebug_bbox": scorebug_bbox,
319
- "home_timeouts": None,
320
- "away_timeouts": None,
321
- }
322
- stats["frames_with_scorebug"] += 1
323
-
324
- # Extract play clock region directly from fixed coordinates
325
- t_start = time.perf_counter()
326
- pc_x, pc_y, pc_w, pc_h = self.config.fixed_playclock_coords
327
- play_clock_region = frame[pc_y : pc_y + pc_h, pc_x : pc_x + pc_w]
328
-
329
- if play_clock_region is not None and play_clock_region.size > 0:
330
- # Store raw region for template matching (fixed coords mode always uses templates)
331
- template_tasks.append(FrameTemplateTask(timestamp=current_time, raw_region=play_clock_region.copy(), scorebug_bbox=scorebug_bbox))
332
- frame_result["template_task_idx"] = len(template_tasks) - 1
333
- timing["preprocessing"] += time.perf_counter() - t_start
334
-
335
- return frame_result
336
-
337
  def _process_frame_standard(
338
  self,
339
  frame: np.ndarray,
@@ -680,225 +663,27 @@ class PlayDetector:
680
  logger.info(" TOTAL: %.2fs", total_time)
681
  logger.info("=" * 50)
682
 
683
- # pylint: disable=too-many-locals,too-many-statements
684
- def _detect_single_pass_fixed_coords(self) -> DetectionResult:
685
- """
686
- Optimized single-pass detection for fixed coordinates mode.
687
-
688
- This is the FASTEST mode - no scorebug detection, template matching done inline.
689
- Everything happens in a single pass through the video.
690
- """
691
- logger.info("Mode: FIXED COORDINATES - SINGLE PASS (fastest)")
692
- logger.info(" Play clock coords: %s", self.config.fixed_playclock_coords)
693
- if self.config.fixed_scorebug_coords:
694
- logger.info(" Scorebug coords: %s", self.config.fixed_scorebug_coords)
695
-
696
- # Open video
697
- cap = cv2.VideoCapture(self.config.video_path)
698
- if not cap.isOpened():
699
- raise RuntimeError(f"Could not open video: {self.config.video_path}")
700
-
701
- # Get video properties
702
- fps = cap.get(cv2.CAP_PROP_FPS)
703
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
704
- duration = total_frames / fps if fps > 0 else 0
705
- logger.info("Video: %.1fs duration, %.2f fps, %d total frames", duration, fps, total_frames)
706
-
707
- # Determine segment bounds
708
- start_time = self.config.start_time
709
- end_time = self.config.end_time if self.config.end_time else duration
710
-
711
- # Stats and timing
712
- stats = {"total_frames": 0, "frames_with_scorebug": 0, "frames_with_clock": 0}
713
- timing = {"video_io": 0.0, "template_matching": 0.0, "state_machine": 0.0}
714
-
715
- # Frame navigation
716
- frame_skip = int(self.config.frame_interval * fps)
717
- start_frame = int(start_time * fps)
718
- end_frame = int(end_time * fps)
719
-
720
- t_io_start = time.perf_counter()
721
- cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
722
- timing["video_io"] += time.perf_counter() - t_io_start
723
-
724
- current_frame = start_frame
725
- logger.info("Single-pass: frame_skip=%d (%.2f fps effective), frames %d-%d", frame_skip, fps / frame_skip, start_frame, end_frame)
726
-
727
- # Fixed coordinates
728
- pc_x, pc_y, pc_w, pc_h = self.config.fixed_playclock_coords
729
- scorebug_bbox = self.config.fixed_scorebug_coords or (0, 0, 0, 0)
730
-
731
- # Dummy scorebug detection (always detected in fixed mode)
732
- dummy_scorebug = ScorebugDetection(detected=True, bbox=scorebug_bbox, confidence=1.0, method="fixed")
733
-
734
- # Collect frame data for clock reset detection
735
- frame_data_for_clock_reset: List[Dict[str, Any]] = []
736
-
737
- # === SINGLE PASS: Read, extract, match, update ===
738
- logger.info("Processing frames (single pass)...")
739
- process_start = time.perf_counter()
740
-
741
- while current_frame < end_frame:
742
- current_time = current_frame / fps
743
-
744
- # Read frame
745
- t_io = time.perf_counter()
746
- ret, frame = cap.read()
747
- timing["video_io"] += time.perf_counter() - t_io
748
-
749
- if not ret:
750
- # Skip frames and continue
751
- t_io = time.perf_counter()
752
- for _ in range(frame_skip - 1):
753
- cap.grab()
754
- timing["video_io"] += time.perf_counter() - t_io
755
- current_frame += frame_skip
756
- continue
757
-
758
- stats["total_frames"] += 1
759
- stats["frames_with_scorebug"] += 1 # Always true in fixed mode
760
-
761
- # Extract play clock region directly
762
- play_clock_region = frame[pc_y : pc_y + pc_h, pc_x : pc_x + pc_w]
763
-
764
- # Template matching
765
- t_match = time.perf_counter()
766
- clock_reading = self.template_reader.read(play_clock_region)
767
- timing["template_matching"] += time.perf_counter() - t_match
768
-
769
- # Create PlayClockReading for state machine
770
- if clock_reading.detected and clock_reading.value is not None:
771
- stats["frames_with_clock"] += 1
772
- clock = PlayClockReading(
773
- detected=True,
774
- value=clock_reading.value,
775
- confidence=clock_reading.confidence,
776
- raw_text=str(clock_reading.value),
777
- )
778
- else:
779
- clock = PlayClockReading(detected=False, value=None, confidence=0.0, raw_text="")
780
-
781
- # Update state machine
782
- t_sm = time.perf_counter()
783
- self.state_machine.update(current_time, dummy_scorebug, clock)
784
- timing["state_machine"] += time.perf_counter() - t_sm
785
-
786
- # Store frame data for clock reset detection
787
- frame_data_for_clock_reset.append(
788
- {
789
- "timestamp": current_time,
790
- "scorebug_detected": True,
791
- "scorebug_bbox": scorebug_bbox,
792
- "clock_detected": clock.detected,
793
- "clock_value": clock.value,
794
- "home_timeouts": None,
795
- "away_timeouts": None,
796
- }
797
- )
798
-
799
- # Progress logging every 5 minutes of video
800
- if stats["total_frames"] % int(300 / self.config.frame_interval) == 0:
801
- elapsed = time.perf_counter() - process_start
802
- pct = 100 * (current_time - start_time) / (end_time - start_time) if end_time > start_time else 0
803
- frames_per_sec = stats["total_frames"] / elapsed if elapsed > 0 else 0
804
- eta = (end_time - current_time) / (current_time - start_time) * elapsed if current_time > start_time else 0
805
- logger.info(
806
- "Progress: %.1f%% (%d:%02d / %d:%02d) - %.1f fps - ETA: %.0fs",
807
- pct,
808
- int(current_time // 60),
809
- int(current_time % 60),
810
- int(end_time // 60),
811
- int(end_time % 60),
812
- frames_per_sec,
813
- eta,
814
- )
815
-
816
- # Skip frames
817
- t_io = time.perf_counter()
818
- for _ in range(frame_skip - 1):
819
- cap.grab()
820
- timing["video_io"] += time.perf_counter() - t_io
821
- current_frame += frame_skip
822
-
823
- cap.release()
824
-
825
- logger.info("Single pass complete: %d frames processed", stats["total_frames"])
826
- logger.info("Clock readings: %d (%.1f%%)", stats["frames_with_clock"], 100 * stats["frames_with_clock"] / max(1, stats["total_frames"]))
827
-
828
- # Clock reset detection pass (detect 40->25 transitions)
829
- logger.info("Running clock reset detection...")
830
- clock_reset_plays, clock_reset_stats = self._detect_clock_resets(frame_data_for_clock_reset)
831
- logger.info(
832
- "Clock reset detection: %d total, %d weird (rejected), %d timeouts, %d special plays",
833
- clock_reset_stats.get("total", 0),
834
- clock_reset_stats.get("weird_clock", 0),
835
- clock_reset_stats.get("timeout", 0),
836
- clock_reset_stats.get("special", 0),
837
- )
838
-
839
- # Get state machine plays and merge with clock reset plays
840
- state_machine_plays = self.state_machine.get_plays()
841
- play_stats = self.state_machine.get_stats()
842
- play_stats["clock_reset_events"] = clock_reset_stats
843
-
844
- plays = self._merge_plays(state_machine_plays, clock_reset_plays)
845
-
846
- # Apply minimum duration filter (only to normal plays, not special/timeout)
847
- # Special plays and timeouts can have short durations due to sampling interval
848
- original_count = len(plays)
849
- plays = [p for p in plays if p.play_type in ("special", "timeout") or (p.end_time - p.start_time) >= MIN_PLAY_DURATION]
850
- filtered_count = original_count - len(plays)
851
- if filtered_count > 0:
852
- logger.info("Filtered out %d normal plays with duration < %.1fs", filtered_count, MIN_PLAY_DURATION)
853
-
854
- # Build result
855
- result = DetectionResult(
856
- video=Path(self.config.video_path).name,
857
- segment_start=start_time,
858
- segment_end=end_time,
859
- total_frames_processed=stats["total_frames"],
860
- frames_with_scorebug=stats["frames_with_scorebug"],
861
- frames_with_clock=stats["frames_with_clock"],
862
- plays=[self._play_to_dict(p) for p in plays],
863
- stats=play_stats,
864
- timing=timing,
865
- )
866
-
867
- total_time = sum(timing.values())
868
- logger.info("=" * 50)
869
- logger.info("TIMING BREAKDOWN (Single Pass)")
870
- logger.info("=" * 50)
871
- for section, t_duration in timing.items():
872
- pct = 100 * t_duration / total_time if total_time > 0 else 0
873
- logger.info(" %s: %.2fs (%.1f%%)", section, t_duration, pct)
874
- logger.info(" TOTAL: %.2fs (%.1f minutes)", total_time, total_time / 60)
875
- logger.info("=" * 50)
876
-
877
- logger.info("Detection complete!")
878
- logger.info("Plays detected: %d", len(plays))
879
-
880
- return result
881
-
882
  def detect(self) -> DetectionResult:
883
  """
884
  Run play detection on the video segment.
885
 
886
- Supports three modes:
887
- 1. FIXED COORDINATES mode (fastest - single pass):
888
- - Single pass: Read frame, extract region, template match, state machine update
889
- - No scorebug detection needed
890
 
891
- 2. Template matching mode (use_template_matching=True, ~34x faster than OCR):
892
- - Pass 1: Read frames, detect scorebug, store raw regions
893
  - Pass 1.5 (if no templates): Run OCR on first N frames to build templates
894
  - Pass 2: Run template matching on all frames
895
  - Pass 3: Process results through state machine
896
 
897
- 3. OCR mode (use_template_matching=False):
898
- - Pass 1: Read frames, detect scorebug, preprocess OCR images
899
  - Pass 2: Run OCR in parallel using thread pool
900
  - Pass 3: Process results through state machine
901
 
 
 
 
902
  Returns:
903
  DetectionResult with all detected plays
904
  """
@@ -906,14 +691,8 @@ class PlayDetector:
906
  logger.info("Video: %s", self.config.video_path)
907
  logger.info("Segment: %.1fs to %s", self.config.start_time, self.config.end_time or "end")
908
 
909
- # Check for fixed coordinates mode (fastest - single pass, no scorebug detection)
910
- use_fixed_coords = self.config.fixed_playclock_coords is not None
911
- if use_fixed_coords and self.template_reader:
912
- # Use optimized single-pass detection
913
- return self._detect_single_pass_fixed_coords()
914
-
915
- # Log mode info for multi-pass detection
916
- self._log_detection_mode(use_fixed_coords)
917
 
918
  # Initialize video and get processing context
919
  context, stats, timing = self._open_video_and_get_context()
@@ -924,8 +703,8 @@ class PlayDetector:
924
  logger.info("Pre-warming EasyOCR reader...")
925
  _get_easyocr_reader()
926
 
927
- # Pass 1: Frame extraction and preprocessing
928
- pass1_results = self._pass1_extract_frames(context, stats, timing, use_fixed_coords)
929
 
930
  # Pass 1.5: Build templates if needed
931
  self._pass15_build_templates(pass1_results, timing)
@@ -939,21 +718,25 @@ class PlayDetector:
939
  # Pass 4: Clock reset classification and result building
940
  return self._pass4_clock_reset_and_build_result(frame_results, ocr_results, context, stats, timing)
941
 
942
- def _log_detection_mode(self, use_fixed_coords: bool) -> None:
943
  """Log the detection mode being used."""
944
- if use_fixed_coords:
945
- logger.info("Mode: FIXED COORDINATES (fastest - no scorebug detection)")
946
- logger.info(" Play clock coords: %s", self.config.fixed_playclock_coords)
947
- if self.config.fixed_scorebug_coords:
948
- logger.info(" Scorebug coords: %s", self.config.fixed_scorebug_coords)
949
- elif self.config.use_template_matching:
950
- logger.info("Mode: Template matching (34x faster than OCR)")
 
 
 
 
951
  if self.template_reader:
952
  logger.info(" Using pre-built templates")
953
  else:
954
  logger.info(" Will build templates from first %d frames", self.config.template_collection_frames)
955
  else:
956
- logger.info("Mode: OCR (parallel=%s, workers=%d)", self.config.parallel_ocr, self.config.ocr_workers)
957
 
958
  def _run_parallel_ocr(self, tasks: List[FrameOCRTask]) -> List[PlayClockReading]:
959
  """
 
26
  from detectors import ScorebugDetector, ScorebugDetection, PlayClockReader, PlayStateMachine, PlayEvent, PlayClockReading, TimeoutTracker
27
  from detectors.play_clock_reader import _get_easyocr_reader
28
  from detectors.digit_template_reader import DigitTemplateBuilder, DigitTemplateLibrary, TemplatePlayClockReader
29
+ from detectors.models import PlayClockRegionConfig
30
  from .models import DetectionConfig, FrameOCRTask, FrameTemplateTask, DetectionResult, VideoContext, Pass1Results
31
 
32
  logger = logging.getLogger(__name__)
 
95
  if not video_path.exists():
96
  raise FileNotFoundError(f"Video not found: {self.config.video_path}")
97
 
98
+ # In fixed coordinates mode, template and clock config paths are not required
99
+ # since we derive the regions from the fixed coordinates
100
  if not self.config.fixed_playclock_coords:
101
  template_path = Path(self.config.template_path)
102
  if not template_path.exists():
 
110
  """Initialize detection components."""
111
  logger.info("Initializing play detector components...")
112
 
113
+ # Determine if we're using fixed coordinates mode
114
+ # In this mode, we still use the same detection logic but with pre-set regions
115
+ use_fixed_coords = self.config.fixed_playclock_coords is not None
116
+
117
+ if use_fixed_coords:
118
+ # Fixed coordinates mode: derive play clock offset from absolute coords
119
+ # We still create scorebug_detector and clock_reader for unified processing
120
+ logger.info("Fixed coordinates mode - regions pre-configured")
121
+
122
+ # Compute play clock offset relative to scorebug from absolute coordinates
123
+ pc_x, pc_y, pc_w, pc_h = self.config.fixed_playclock_coords
124
+ if self.config.fixed_scorebug_coords:
125
+ sb_x, sb_y, _, _ = self.config.fixed_scorebug_coords
126
+ x_offset = pc_x - sb_x
127
+ y_offset = pc_y - sb_y
128
+ else:
129
+ # If no scorebug coords provided, treat play clock coords as offset from (0,0)
130
+ x_offset, y_offset = pc_x, pc_y
131
+
132
+ # Create a minimal PlayClockRegionConfig for the clock reader
133
+ playclock_config = PlayClockRegionConfig(x_offset=x_offset, y_offset=y_offset, width=pc_w, height=pc_h, source_video="", scorebug_template="", samples_used=0)
134
+
135
+ # Initialize scorebug detector (will set fixed region below)
136
+ # Use empty template path with a dummy - we'll set fixed region immediately
137
+ self.scorebug_detector = ScorebugDetector(template_path=None, use_split_detection=self.config.use_split_detection)
138
+
139
+ # Set the fixed region immediately so no template matching is needed
140
+ if self.config.fixed_scorebug_coords:
141
+ self.scorebug_detector.set_fixed_region(self.config.fixed_scorebug_coords)
142
+ logger.info("Scorebug fixed region set: %s", self.config.fixed_scorebug_coords)
143
+
144
+ # Initialize play clock reader with the derived config
145
+ self.clock_reader = PlayClockReader(region_config=playclock_config)
146
+ logger.info("Play clock reader initialized with offset=(%d, %d), size=(%d, %d)", x_offset, y_offset, pc_w, pc_h)
147
  else:
148
+ # Standard mode: use template and config files
149
  self.scorebug_detector = ScorebugDetector(template_path=self.config.template_path, use_split_detection=self.config.use_split_detection)
150
  logger.info("Scorebug detector initialized (split_detection=%s)", self.config.use_split_detection)
151
 
152
+ # Initialize play clock reader from config file
153
  self.clock_reader = PlayClockReader(region_config_path=self.config.clock_region_config_path)
154
  logger.info("Play clock reader initialized")
155
 
 
159
 
160
  # Initialize template matching components if enabled
161
  if self.config.use_template_matching:
162
+ # Determine region dimensions from clock reader config
163
+ if self.clock_reader and self.clock_reader.config:
 
 
 
164
  region_w = self.clock_reader.config.width
165
  region_h = self.clock_reader.config.height
166
  else:
 
176
  self.template_library = None
177
  logger.info("Could not load templates, will build during detection")
178
 
179
+ # Initialize template builder for collection phase
180
+ if self.template_library is None:
181
  self.template_builder = DigitTemplateBuilder(region_w, region_h)
182
  logger.info("Template builder initialized for collection phase")
183
 
 
234
 
235
  return context, stats, timing
236
 
237
+ def _pass1_extract_frames(self, context: VideoContext, stats: Dict[str, Any], timing: Dict[str, float]) -> Pass1Results:
238
  """
239
  Pass 1: Read frames, detect scorebug, extract play clock regions.
240
 
241
+ This method uses the same logic whether coordinates were provided via
242
+ fixed_coords config or via user selection - the scorebug_detector handles
243
+ the difference via its fixed_region setting.
244
+
245
  Args:
246
  context: Video context with properties and capture object
247
  stats: Stats dictionary to update
248
  timing: Timing dictionary to update
 
249
 
250
  Returns:
251
  Pass1Results with frame results, OCR tasks, and template tasks
 
271
  template_tasks: List[FrameTemplateTask] = []
272
 
273
  # Flag to track if we've locked the scorebug region
274
+ # If using fixed coordinates, the region is already locked in _initialize_components
275
+ scorebug_region_locked = self.scorebug_detector._use_fixed_region if self.scorebug_detector else False
276
 
277
  current_frame = context.start_frame
278
 
 
295
 
296
  stats["total_frames"] += 1
297
 
298
+ # Process frame using standard method (handles both fixed and discovered regions)
299
+ frame_result, scorebug_region_locked = self._process_frame_standard(frame, current_time, template_tasks, ocr_tasks, timing, stats, scorebug_region_locked)
 
 
 
300
 
301
  frame_results.append(frame_result)
302
 
 
317
 
318
  return Pass1Results(frame_results=frame_results, ocr_tasks=ocr_tasks, template_tasks=template_tasks)
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  def _process_frame_standard(
321
  self,
322
  frame: np.ndarray,
 
663
  logger.info(" TOTAL: %.2fs", total_time)
664
  logger.info("=" * 50)
665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  def detect(self) -> DetectionResult:
667
  """
668
  Run play detection on the video segment.
669
 
670
+ Uses a unified multi-pass architecture regardless of how coordinates were obtained
671
+ (either from fixed coordinates config or via user selection):
 
 
672
 
673
+ 1. Template matching mode (use_template_matching=True, ~34x faster than OCR):
674
+ - Pass 1: Read frames, detect/verify scorebug, store raw regions
675
  - Pass 1.5 (if no templates): Run OCR on first N frames to build templates
676
  - Pass 2: Run template matching on all frames
677
  - Pass 3: Process results through state machine
678
 
679
+ 2. OCR mode (use_template_matching=False):
680
+ - Pass 1: Read frames, detect/verify scorebug, preprocess OCR images
681
  - Pass 2: Run OCR in parallel using thread pool
682
  - Pass 3: Process results through state machine
683
 
684
+ When fixed coordinates are provided, the scorebug detection step simply verifies
685
+ the scorebug is present at the known location (faster than searching).
686
+
687
  Returns:
688
  DetectionResult with all detected plays
689
  """
 
691
  logger.info("Video: %s", self.config.video_path)
692
  logger.info("Segment: %.1fs to %s", self.config.start_time, self.config.end_time or "end")
693
 
694
+ # Log mode info
695
+ self._log_detection_mode()
 
 
 
 
 
 
696
 
697
  # Initialize video and get processing context
698
  context, stats, timing = self._open_video_and_get_context()
 
703
  logger.info("Pre-warming EasyOCR reader...")
704
  _get_easyocr_reader()
705
 
706
+ # Pass 1: Frame extraction and preprocessing (unified for both fixed coords and user selection)
707
+ pass1_results = self._pass1_extract_frames(context, stats, timing)
708
 
709
  # Pass 1.5: Build templates if needed
710
  self._pass15_build_templates(pass1_results, timing)
 
718
  # Pass 4: Clock reset classification and result building
719
  return self._pass4_clock_reset_and_build_result(frame_results, ocr_results, context, stats, timing)
720
 
721
+ def _log_detection_mode(self) -> None:
722
  """Log the detection mode being used."""
723
+ use_fixed_region = self.scorebug_detector and self.scorebug_detector._use_fixed_region
724
+
725
+ if use_fixed_region:
726
+ logger.info("Mode: Fixed region (scorebug location pre-configured)")
727
+ if self.scorebug_detector.fixed_region:
728
+ logger.info(" Scorebug region: %s", self.scorebug_detector.fixed_region)
729
+ else:
730
+ logger.info("Mode: Dynamic scorebug detection (will discover and lock region)")
731
+
732
+ if self.config.use_template_matching:
733
+ logger.info("Clock reading: Template matching (34x faster than OCR)")
734
  if self.template_reader:
735
  logger.info(" Using pre-built templates")
736
  else:
737
  logger.info(" Will build templates from first %d frames", self.config.template_collection_frames)
738
  else:
739
+ logger.info("Clock reading: OCR (parallel=%s, workers=%d)", self.config.parallel_ocr, self.config.ocr_workers)
740
 
741
  def _run_parallel_ocr(self, tasks: List[FrameOCRTask]) -> List[PlayClockReading]:
742
  """
src/ui/__init__.py CHANGED
@@ -1,19 +1,52 @@
1
- """UI modules for interactive user input and region selection."""
 
2
 
3
- from .region_selector import (
4
- RegionSelector,
5
- select_scorebug_region,
6
- select_playclock_region,
7
- select_timeout_region,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  get_video_path_from_user,
9
  print_banner,
 
 
 
10
  )
11
 
12
  __all__ = [
 
 
 
 
 
 
13
  "RegionSelector",
14
- "select_scorebug_region",
15
- "select_playclock_region",
16
- "select_timeout_region",
 
 
 
 
17
  "get_video_path_from_user",
18
  "print_banner",
 
 
 
19
  ]
 
1
+ """
2
+ UI module for interactive region selection.
3
 
4
+ This module provides tools for interactively selecting regions in video frames,
5
+ including scorebug, play clock, and timeout indicator regions.
6
+ """
7
+
8
+ # Models
9
+ from .models import BBox, SelectionState, SelectionViewConfig
10
+
11
+ # Selector
12
+ from .selector import KeyHandler, RegionSelector
13
+
14
+ # Sessions
15
+ from .sessions import (
16
+ InteractiveSelectionSession,
17
+ PlayClockSelectionSession,
18
+ ScorebugSelectionSession,
19
+ TimeoutSelectionSession,
20
+ )
21
+
22
+ # Public API
23
+ from .api import (
24
+ extract_sample_frames_for_selection,
25
  get_video_path_from_user,
26
  print_banner,
27
+ select_playclock_region,
28
+ select_scorebug_region,
29
+ select_timeout_region,
30
  )
31
 
32
  __all__ = [
33
+ # Models
34
+ "BBox",
35
+ "SelectionState",
36
+ "SelectionViewConfig",
37
+ # Selector
38
+ "KeyHandler",
39
  "RegionSelector",
40
+ # Sessions
41
+ "InteractiveSelectionSession",
42
+ "PlayClockSelectionSession",
43
+ "ScorebugSelectionSession",
44
+ "TimeoutSelectionSession",
45
+ # Public API
46
+ "extract_sample_frames_for_selection",
47
  "get_video_path_from_user",
48
  "print_banner",
49
+ "select_playclock_region",
50
+ "select_scorebug_region",
51
+ "select_timeout_region",
52
  ]
src/ui/api.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Public API functions for the UI module.
3
+
4
+ This module provides the high-level functions for interactive region selection
5
+ that are intended to be called by external code.
6
+ """
7
+
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import List, Optional, Tuple
11
+
12
+ import numpy as np
13
+
14
+ from video.frame_extractor import extract_sample_frames
15
+
16
+ from .models import BBox
17
+ from .sessions import PlayClockSelectionSession, ScorebugSelectionSession, TimeoutSelectionSession
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def print_banner():
23
+ """Print the welcome banner for CFB40."""
24
+ print("\n" + "=" * 60)
25
+ print(" CFB40 - College Football Play Detection Pipeline")
26
+ print("=" * 60 + "\n")
27
+
28
+
29
+ def extract_sample_frames_for_selection(video_path: str, start_time: float, num_frames: int = 5, interval: float = 2.0) -> List[Tuple[float, np.ndarray]]:
30
+ """Extract sample frames from the video for region selection."""
31
+ return extract_sample_frames(video_path, start_time, num_frames, interval)
32
+
33
+
34
+ def select_scorebug_region(frames: List[Tuple[float, np.ndarray]], video_path: str = None) -> Tuple[Optional[Tuple[int, int, int, int]], Optional[Tuple[float, np.ndarray]]]:
35
+ """
36
+ Interactive selection of scorebug region.
37
+
38
+ Args:
39
+ frames: List of (timestamp, frame) tuples to choose from.
40
+ video_path: Path to video file (for loading additional frames if needed).
41
+
42
+ Returns:
43
+ Tuple of (scorebug_bbox, selected_frame) where:
44
+ - scorebug_bbox: (x, y, width, height) or None if cancelled
45
+ - selected_frame: The specific (timestamp, frame) tuple that was selected
46
+ """
47
+ if not frames:
48
+ logger.error("No frames provided for selection")
49
+ return None, None
50
+
51
+ print("\n[Phase 2] Region Setup - Scorebug Selection")
52
+ print("-" * 50)
53
+ print("Instructions:")
54
+ print(" 1. Click on the TOP-LEFT corner of the scorebug")
55
+ print(" 2. Click on the BOTTOM-RIGHT corner of the scorebug")
56
+ print(" 'n' = next frame, 'p' = previous frame")
57
+ print(" 's' = skip forward 30s, 'S' = skip forward 5min")
58
+ print(" 'r' = reset selection, 'q' = quit")
59
+ print("-" * 50)
60
+
61
+ session = ScorebugSelectionSession(frames, video_path)
62
+ bbox = session.run()
63
+
64
+ if bbox is None:
65
+ return None, None
66
+
67
+ selected_frame = session.state.current_frame
68
+ logger.info("Selected frame %d/%d @ %.1fs for scorebug region", session.state.frame_idx + 1, len(session.state.frames), selected_frame[0])
69
+
70
+ return bbox.to_tuple(), selected_frame
71
+
72
+
73
+ def select_playclock_region(selected_frame: Tuple[float, np.ndarray], scorebug_bbox: Tuple[int, int, int, int]) -> Optional[Tuple[int, int, int, int]]:
74
+ """
75
+ Interactive selection of play clock region within the scorebug.
76
+
77
+ Args:
78
+ selected_frame: The (timestamp, frame) tuple selected during scorebug selection.
79
+ scorebug_bbox: Scorebug bounding box (x, y, width, height).
80
+
81
+ Returns:
82
+ Play clock region as (x_offset, y_offset, width, height) relative to scorebug,
83
+ or None if cancelled.
84
+ """
85
+ if selected_frame is None:
86
+ logger.error("No frame provided for selection")
87
+ return None
88
+
89
+ timestamp, frame = selected_frame
90
+ sb_bbox = BBox.from_tuple(scorebug_bbox)
91
+
92
+ print("\n[Phase 2] Region Setup - Play Clock Selection")
93
+ print("-" * 50)
94
+ print("Instructions:")
95
+ print(" 1. Click on the TOP-LEFT corner of the play clock digits")
96
+ print(" 2. Click on the BOTTOM-RIGHT corner of the play clock digits")
97
+ print(" Press 'r' to reset, 'q' to quit")
98
+ print("-" * 50)
99
+
100
+ logger.info("Using frame @ %.1fs for play clock selection (same as scorebug selection)", timestamp)
101
+
102
+ session = PlayClockSelectionSession(frame, sb_bbox, scale_factor=3)
103
+ bbox = session.get_unscaled_bbox() if session.run() else None
104
+
105
+ return bbox.to_tuple() if bbox else None
106
+
107
+
108
+ def select_timeout_region(selected_frame: Tuple[float, np.ndarray], scorebug_bbox: Tuple[int, int, int, int], team: str) -> Optional[Tuple[int, int, int, int]]:
109
+ """
110
+ Interactive selection of timeout indicator region for a team.
111
+
112
+ Args:
113
+ selected_frame: The (timestamp, frame) tuple selected during scorebug selection.
114
+ scorebug_bbox: Scorebug bounding box (x, y, width, height).
115
+ team: "home" or "away".
116
+
117
+ Returns:
118
+ Timeout region as (x, y, width, height) in absolute frame coordinates,
119
+ or None if cancelled.
120
+ """
121
+ if selected_frame is None:
122
+ logger.error("No frame provided for selection")
123
+ return None
124
+
125
+ timestamp, frame = selected_frame
126
+ sb_bbox = BBox.from_tuple(scorebug_bbox)
127
+
128
+ print(f"\n[Phase 2] Region Setup - {team.upper()} Team Timeout Selection")
129
+ print("-" * 50)
130
+ print("Instructions:")
131
+ print(f" Select the 3 timeout indicator ovals for the {team.upper()} team")
132
+ print(" (They are vertically stacked - white = available, dark = used)")
133
+ print(" 1. Click on the TOP-LEFT corner of the timeout region")
134
+ print(" 2. Click on the BOTTOM-RIGHT corner of the timeout region")
135
+ print(" Press 'r' to reset, 'q' to quit")
136
+ print("-" * 50)
137
+
138
+ logger.info("Using frame @ %.1fs for %s timeout selection", timestamp, team)
139
+
140
+ session = TimeoutSelectionSession(frame, sb_bbox, team, scale_factor=3, padding=50)
141
+ session.run()
142
+ bbox = session.get_absolute_bbox()
143
+
144
+ return bbox.to_tuple() if bbox else None
145
+
146
+
147
+ def get_video_path_from_user(project_root: Path) -> Optional[str]:
148
+ """
149
+ Prompt user to enter video file path.
150
+
151
+ Args:
152
+ project_root: Root directory of the project.
153
+
154
+ Returns:
155
+ Path to selected video file, or None if no valid selection.
156
+ """
157
+ print("\nAvailable videos in full_videos/:")
158
+ videos_dir = project_root / "full_videos"
159
+ videos = []
160
+ if videos_dir.exists():
161
+ videos = list(videos_dir.glob("*.mp4")) + list(videos_dir.glob("*.mkv"))
162
+ for i, v in enumerate(videos, 1):
163
+ print(f" {i}. {v.name}")
164
+
165
+ print("\nEnter video path (or number from list above):")
166
+ user_input = input("> ").strip()
167
+
168
+ if not user_input:
169
+ return None
170
+
171
+ # Check if user entered a number
172
+ try:
173
+ idx = int(user_input) - 1
174
+ if 0 <= idx < len(videos):
175
+ return str(videos[idx])
176
+ except ValueError:
177
+ pass
178
+
179
+ # Check if it's a valid path
180
+ if Path(user_input).exists():
181
+ return user_input
182
+
183
+ # Check if it's relative to full_videos
184
+ relative_path = videos_dir / user_input
185
+ if relative_path.exists():
186
+ return str(relative_path)
187
+
188
+ logger.error("Video not found: %s", user_input)
189
+ return None
src/ui/models.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic models and data structures for the UI module.
3
+
4
+ This module contains all the data models used for region selection,
5
+ including bounding boxes, view configurations, and selection state.
6
+ """
7
+
8
+ from typing import List, Optional, Tuple
9
+
10
+ import numpy as np
11
+ from pydantic import BaseModel, ConfigDict
12
+
13
+
14
+ class BBox(BaseModel):
15
+ """Bounding box with x, y, width, height coordinates."""
16
+
17
+ x: int
18
+ y: int
19
+ width: int
20
+ height: int
21
+
22
+ @property
23
+ def x2(self) -> int:
24
+ """Get the right edge x coordinate."""
25
+ return self.x + self.width
26
+
27
+ @property
28
+ def y2(self) -> int:
29
+ """Get the bottom edge y coordinate."""
30
+ return self.y + self.height
31
+
32
+ def to_tuple(self) -> Tuple[int, int, int, int]:
33
+ """Convert to (x, y, width, height) tuple."""
34
+ return (self.x, self.y, self.width, self.height)
35
+
36
+ def scaled(self, factor: int) -> "BBox":
37
+ """Return a new BBox scaled by the given factor."""
38
+ return BBox(x=self.x * factor, y=self.y * factor, width=self.width * factor, height=self.height * factor)
39
+
40
+ def unscaled(self, factor: int) -> "BBox":
41
+ """Return a new BBox divided by the given scale factor."""
42
+ return BBox(x=self.x // factor, y=self.y // factor, width=self.width // factor, height=self.height // factor)
43
+
44
+ def offset(self, dx: int, dy: int) -> "BBox":
45
+ """Return a new BBox offset by dx, dy."""
46
+ return BBox(x=self.x + dx, y=self.y + dy, width=self.width, height=self.height)
47
+
48
+ @classmethod
49
+ def from_points(cls, p1: Tuple[int, int], p2: Tuple[int, int]) -> "BBox":
50
+ """Create BBox from two corner points."""
51
+ x = min(p1[0], p2[0])
52
+ y = min(p1[1], p2[1])
53
+ w = abs(p2[0] - p1[0])
54
+ h = abs(p2[1] - p1[1])
55
+ return cls(x=x, y=y, width=w, height=h)
56
+
57
+ @classmethod
58
+ def from_tuple(cls, t: Tuple[int, int, int, int]) -> "BBox":
59
+ """Create BBox from (x, y, width, height) tuple."""
60
+ return cls(x=t[0], y=t[1], width=t[2], height=t[3])
61
+
62
+
63
+ class SelectionViewConfig(BaseModel):
64
+ """Configuration for display/view settings during selection."""
65
+
66
+ scale_factor: int = 1
67
+ padding: int = 0
68
+ window_width: int = 1280
69
+ window_height: int = 720
70
+
71
+
72
+ class SelectionState(BaseModel):
73
+ """Mutable state for a region selection session."""
74
+
75
+ model_config = ConfigDict(arbitrary_types_allowed=True)
76
+
77
+ frame_idx: int = 0
78
+ frames: List[Tuple[float, np.ndarray]] = []
79
+ should_quit: bool = False
80
+ should_confirm: bool = False
81
+ video_path: Optional[str] = None
82
+
83
+ @property
84
+ def current_frame(self) -> Tuple[float, np.ndarray]:
85
+ """Get the current (timestamp, frame) tuple."""
86
+ return self.frames[self.frame_idx]
87
+
88
+ @property
89
+ def timestamp(self) -> float:
90
+ """Get the current frame timestamp."""
91
+ return self.frames[self.frame_idx][0]
92
+
93
+ @property
94
+ def frame(self) -> np.ndarray:
95
+ """Get the current frame."""
96
+ return self.frames[self.frame_idx][1]
src/ui/selector.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mouse-based region selector using OpenCV callbacks.
3
+
4
+ This module provides the low-level RegionSelector class that handles
5
+ mouse events for interactive region selection.
6
+ """
7
+
8
+ from typing import Callable, List, Optional, Tuple
9
+
10
+ import cv2
11
+
12
+ from .models import BBox, SelectionState
13
+
14
+
15
+ # Type alias for key handler functions
16
+ KeyHandler = Callable[[SelectionState, "RegionSelector"], None]
17
+
18
+
19
+ class RegionSelector:
20
+ """
21
+ Interactive region selector using OpenCV mouse callbacks.
22
+
23
+ Supports two selection modes:
24
+ - Two-click selection: click top-left, then click bottom-right
25
+ - Click-and-drag selection: click, drag, and release
26
+ """
27
+
28
+ def __init__(self, window_name: str, mode: str = "two_click"):
29
+ """Initialize the region selector."""
30
+ self.window_name = window_name
31
+ self.mode = mode
32
+
33
+ # Two-click mode state
34
+ self.points: List[Tuple[int, int]] = []
35
+ self.current_point: Optional[Tuple[int, int]] = None
36
+
37
+ # Drag mode state
38
+ self.start_point: Optional[Tuple[int, int]] = None
39
+ self.end_point: Optional[Tuple[int, int]] = None
40
+ self.drawing = False
41
+
42
+ self.selection_complete = False
43
+
44
+ def mouse_callback(self, event, x, y, flags, param): # pylint: disable=unused-argument
45
+ """Handle mouse events for region selection."""
46
+ if self.mode == "two_click":
47
+ if event == cv2.EVENT_MOUSEMOVE:
48
+ self.current_point = (x, y)
49
+ elif event == cv2.EVENT_LBUTTONDOWN:
50
+ self.points.append((x, y))
51
+ if len(self.points) >= 2:
52
+ self.selection_complete = True
53
+ else:
54
+ if event == cv2.EVENT_LBUTTONDOWN:
55
+ self.start_point = (x, y)
56
+ self.end_point = (x, y)
57
+ self.drawing = True
58
+ self.selection_complete = False
59
+ elif event == cv2.EVENT_MOUSEMOVE and self.drawing:
60
+ self.end_point = (x, y)
61
+ elif event == cv2.EVENT_LBUTTONUP:
62
+ self.end_point = (x, y)
63
+ self.drawing = False
64
+ self.selection_complete = True
65
+
66
+ def get_bbox(self) -> Optional[BBox]:
67
+ """Get the selected bounding box."""
68
+ if self.mode == "two_click":
69
+ if len(self.points) < 2:
70
+ return None
71
+ p1, p2 = self.points[0], self.points[1]
72
+ else:
73
+ if self.start_point is None or self.end_point is None:
74
+ return None
75
+ p1, p2 = self.start_point, self.end_point
76
+
77
+ bbox = BBox.from_points(p1, p2)
78
+ if bbox.width == 0 or bbox.height == 0:
79
+ return None
80
+ return bbox
81
+
82
+ def reset(self):
83
+ """Reset the selection state."""
84
+ self.points = []
85
+ self.current_point = None
86
+ self.start_point = None
87
+ self.end_point = None
88
+ self.drawing = False
89
+ self.selection_complete = False
src/ui/{region_selector.py β†’ sessions.py} RENAMED
@@ -1,207 +1,26 @@
1
  """
2
- Interactive region selection tools using OpenCV.
3
 
4
- This module provides classes and functions for interactive selection of regions
5
- within video frames, including scorebug, play clock, and timeout indicator regions.
 
 
6
  """
7
 
8
- import logging
9
- from dataclasses import dataclass, field
10
- from pathlib import Path
11
- from typing import Callable, Dict, List, Optional, Tuple
12
 
13
  import cv2
14
  import numpy as np
15
- from pydantic import BaseModel
16
 
17
- from video.frame_extractor import extract_sample_frames
 
18
 
19
- logger = logging.getLogger(__name__)
20
 
 
 
 
21
 
22
- # =============================================================================
23
- # Pydantic Models for Region Coordinates
24
- # =============================================================================
25
-
26
-
27
- class BBox(BaseModel):
28
- """Bounding box with x, y, width, height coordinates."""
29
-
30
- x: int
31
- y: int
32
- width: int
33
- height: int
34
-
35
- @property
36
- def x2(self) -> int:
37
- """Get the right edge x coordinate."""
38
- return self.x + self.width
39
-
40
- @property
41
- def y2(self) -> int:
42
- """Get the bottom edge y coordinate."""
43
- return self.y + self.height
44
-
45
- def to_tuple(self) -> Tuple[int, int, int, int]:
46
- """Convert to (x, y, width, height) tuple."""
47
- return (self.x, self.y, self.width, self.height)
48
-
49
- def scaled(self, factor: int) -> "BBox":
50
- """Return a new BBox scaled by the given factor."""
51
- return BBox(x=self.x * factor, y=self.y * factor, width=self.width * factor, height=self.height * factor)
52
-
53
- def unscaled(self, factor: int) -> "BBox":
54
- """Return a new BBox divided by the given scale factor."""
55
- return BBox(x=self.x // factor, y=self.y // factor, width=self.width // factor, height=self.height // factor)
56
-
57
- def offset(self, dx: int, dy: int) -> "BBox":
58
- """Return a new BBox offset by dx, dy."""
59
- return BBox(x=self.x + dx, y=self.y + dy, width=self.width, height=self.height)
60
-
61
- @classmethod
62
- def from_points(cls, p1: Tuple[int, int], p2: Tuple[int, int]) -> "BBox":
63
- """Create BBox from two corner points."""
64
- x = min(p1[0], p2[0])
65
- y = min(p1[1], p2[1])
66
- w = abs(p2[0] - p1[0])
67
- h = abs(p2[1] - p1[1])
68
- return cls(x=x, y=y, width=w, height=h)
69
-
70
- @classmethod
71
- def from_tuple(cls, t: Tuple[int, int, int, int]) -> "BBox":
72
- """Create BBox from (x, y, width, height) tuple."""
73
- return cls(x=t[0], y=t[1], width=t[2], height=t[3])
74
-
75
-
76
- class SelectionViewConfig(BaseModel):
77
- """Configuration for display/view settings during selection."""
78
-
79
- scale_factor: int = 1
80
- padding: int = 0
81
- window_width: int = 1280
82
- window_height: int = 720
83
-
84
-
85
- # =============================================================================
86
- # Selection State Container
87
- # =============================================================================
88
-
89
-
90
- @dataclass
91
- class SelectionState:
92
- """Mutable state for a region selection session."""
93
-
94
- frame_idx: int = 0
95
- frames: List[Tuple[float, np.ndarray]] = field(default_factory=list)
96
- should_quit: bool = False
97
- should_confirm: bool = False
98
- video_path: Optional[str] = None
99
-
100
- @property
101
- def current_frame(self) -> Tuple[float, np.ndarray]:
102
- """Get the current (timestamp, frame) tuple."""
103
- return self.frames[self.frame_idx]
104
-
105
- @property
106
- def timestamp(self) -> float:
107
- """Get the current frame timestamp."""
108
- return self.frames[self.frame_idx][0]
109
-
110
- @property
111
- def frame(self) -> np.ndarray:
112
- """Get the current frame."""
113
- return self.frames[self.frame_idx][1]
114
-
115
-
116
- # =============================================================================
117
- # Mouse-based Region Selector
118
- # =============================================================================
119
-
120
-
121
- class RegionSelector:
122
- """
123
- Interactive region selector using OpenCV mouse callbacks.
124
-
125
- Supports two selection modes:
126
- - Two-click selection: click top-left, then click bottom-right
127
- - Click-and-drag selection: click, drag, and release
128
- """
129
-
130
- def __init__(self, window_name: str, mode: str = "two_click"):
131
- """Initialize the region selector."""
132
- self.window_name = window_name
133
- self.mode = mode
134
-
135
- # Two-click mode state
136
- self.points: List[Tuple[int, int]] = []
137
- self.current_point: Optional[Tuple[int, int]] = None
138
-
139
- # Drag mode state
140
- self.start_point: Optional[Tuple[int, int]] = None
141
- self.end_point: Optional[Tuple[int, int]] = None
142
- self.drawing = False
143
-
144
- self.selection_complete = False
145
-
146
- def mouse_callback(self, event, x, y, flags, param): # pylint: disable=unused-argument
147
- """Handle mouse events for region selection."""
148
- if self.mode == "two_click":
149
- if event == cv2.EVENT_MOUSEMOVE:
150
- self.current_point = (x, y)
151
- elif event == cv2.EVENT_LBUTTONDOWN:
152
- self.points.append((x, y))
153
- if len(self.points) >= 2:
154
- self.selection_complete = True
155
- else:
156
- if event == cv2.EVENT_LBUTTONDOWN:
157
- self.start_point = (x, y)
158
- self.end_point = (x, y)
159
- self.drawing = True
160
- self.selection_complete = False
161
- elif event == cv2.EVENT_MOUSEMOVE and self.drawing:
162
- self.end_point = (x, y)
163
- elif event == cv2.EVENT_LBUTTONUP:
164
- self.end_point = (x, y)
165
- self.drawing = False
166
- self.selection_complete = True
167
-
168
- def get_bbox(self) -> Optional[BBox]:
169
- """Get the selected bounding box."""
170
- if self.mode == "two_click":
171
- if len(self.points) < 2:
172
- return None
173
- p1, p2 = self.points[0], self.points[1]
174
- else:
175
- if self.start_point is None or self.end_point is None:
176
- return None
177
- p1, p2 = self.start_point, self.end_point
178
-
179
- bbox = BBox.from_points(p1, p2)
180
- if bbox.width == 0 or bbox.height == 0:
181
- return None
182
- return bbox
183
-
184
- def reset(self):
185
- """Reset the selection state."""
186
- self.points = []
187
- self.current_point = None
188
- self.start_point = None
189
- self.end_point = None
190
- self.drawing = False
191
- self.selection_complete = False
192
-
193
-
194
- # =============================================================================
195
- # Key Handler Type
196
- # =============================================================================
197
-
198
- # Type alias for key handler functions
199
- KeyHandler = Callable[[SelectionState, RegionSelector], None]
200
-
201
-
202
- # =============================================================================
203
- # Interactive Selection Session
204
- # =============================================================================
205
 
206
 
207
  class InteractiveSelectionSession:
@@ -269,7 +88,7 @@ class InteractiveSelectionSession:
269
  last_ts = state.frames[-1][0]
270
  new_start = last_ts + 30
271
  print(f" Skipping to {new_start:.1f}s...")
272
- new_frames = extract_sample_frames_for_selection(state.video_path, new_start, num_frames=5, interval=2.0)
273
  if new_frames:
274
  state.frames = new_frames
275
  state.frame_idx = 0
@@ -285,7 +104,7 @@ class InteractiveSelectionSession:
285
  last_ts = state.frames[-1][0]
286
  new_start = last_ts + 300
287
  print(f" Skipping to {new_start:.1f}s ({new_start / 60:.1f} min)...")
288
- new_frames = extract_sample_frames_for_selection(state.video_path, new_start, num_frames=5, interval=2.0)
289
  if new_frames:
290
  state.frames = new_frames
291
  state.frame_idx = 0
@@ -366,11 +185,6 @@ class InteractiveSelectionSession:
366
  return None
367
 
368
 
369
- # =============================================================================
370
- # Scorebug Selection Session
371
- # =============================================================================
372
-
373
-
374
  class ScorebugSelectionSession(InteractiveSelectionSession):
375
  """Interactive session for selecting the scorebug region."""
376
 
@@ -417,11 +231,6 @@ class ScorebugSelectionSession(InteractiveSelectionSession):
417
  return display_frame
418
 
419
 
420
- # =============================================================================
421
- # Play Clock Selection Session
422
- # =============================================================================
423
-
424
-
425
  class PlayClockSelectionSession(InteractiveSelectionSession):
426
  """Interactive session for selecting the play clock region within a scorebug."""
427
 
@@ -473,11 +282,6 @@ class PlayClockSelectionSession(InteractiveSelectionSession):
473
  return bbox.unscaled(self.scale_factor)
474
 
475
 
476
- # =============================================================================
477
- # Timeout Selection Session
478
- # =============================================================================
479
-
480
-
481
  class TimeoutSelectionSession(InteractiveSelectionSession):
482
  """Interactive session for selecting timeout indicator regions."""
483
 
@@ -549,178 +353,3 @@ class TimeoutSelectionSession(InteractiveSelectionSession):
549
  # Unscale and add padding offset
550
  unscaled = bbox.unscaled(self.scale_factor)
551
  return unscaled.offset(self.pad_x1, self.pad_y1)
552
-
553
-
554
- # =============================================================================
555
- # Public API Functions
556
- # =============================================================================
557
-
558
-
559
- def print_banner():
560
- """Print the welcome banner for CFB40."""
561
- print("\n" + "=" * 60)
562
- print(" CFB40 - College Football Play Detection Pipeline")
563
- print("=" * 60 + "\n")
564
-
565
-
566
- def extract_sample_frames_for_selection(video_path: str, start_time: float, num_frames: int = 5, interval: float = 2.0) -> List[Tuple[float, np.ndarray]]:
567
- """Extract sample frames from the video for region selection."""
568
- return extract_sample_frames(video_path, start_time, num_frames, interval)
569
-
570
-
571
- def select_scorebug_region(frames: List[Tuple[float, np.ndarray]], video_path: str = None) -> Tuple[Optional[Tuple[int, int, int, int]], Optional[Tuple[float, np.ndarray]]]:
572
- """
573
- Interactive selection of scorebug region.
574
-
575
- Args:
576
- frames: List of (timestamp, frame) tuples to choose from.
577
- video_path: Path to video file (for loading additional frames if needed).
578
-
579
- Returns:
580
- Tuple of (scorebug_bbox, selected_frame) where:
581
- - scorebug_bbox: (x, y, width, height) or None if cancelled
582
- - selected_frame: The specific (timestamp, frame) tuple that was selected
583
- """
584
- if not frames:
585
- logger.error("No frames provided for selection")
586
- return None, None
587
-
588
- print("\n[Phase 2] Region Setup - Scorebug Selection")
589
- print("-" * 50)
590
- print("Instructions:")
591
- print(" 1. Click on the TOP-LEFT corner of the scorebug")
592
- print(" 2. Click on the BOTTOM-RIGHT corner of the scorebug")
593
- print(" 'n' = next frame, 'p' = previous frame")
594
- print(" 's' = skip forward 30s, 'S' = skip forward 5min")
595
- print(" 'r' = reset selection, 'q' = quit")
596
- print("-" * 50)
597
-
598
- session = ScorebugSelectionSession(frames, video_path)
599
- bbox = session.run()
600
-
601
- if bbox is None:
602
- return None, None
603
-
604
- selected_frame = session.state.current_frame
605
- logger.info("Selected frame %d/%d @ %.1fs for scorebug region", session.state.frame_idx + 1, len(session.state.frames), selected_frame[0])
606
-
607
- return bbox.to_tuple(), selected_frame
608
-
609
-
610
- def select_playclock_region(selected_frame: Tuple[float, np.ndarray], scorebug_bbox: Tuple[int, int, int, int]) -> Optional[Tuple[int, int, int, int]]:
611
- """
612
- Interactive selection of play clock region within the scorebug.
613
-
614
- Args:
615
- selected_frame: The (timestamp, frame) tuple selected during scorebug selection.
616
- scorebug_bbox: Scorebug bounding box (x, y, width, height).
617
-
618
- Returns:
619
- Play clock region as (x_offset, y_offset, width, height) relative to scorebug,
620
- or None if cancelled.
621
- """
622
- if selected_frame is None:
623
- logger.error("No frame provided for selection")
624
- return None
625
-
626
- timestamp, frame = selected_frame
627
- sb_bbox = BBox.from_tuple(scorebug_bbox)
628
-
629
- print("\n[Phase 2] Region Setup - Play Clock Selection")
630
- print("-" * 50)
631
- print("Instructions:")
632
- print(" 1. Click on the TOP-LEFT corner of the play clock digits")
633
- print(" 2. Click on the BOTTOM-RIGHT corner of the play clock digits")
634
- print(" Press 'r' to reset, 'q' to quit")
635
- print("-" * 50)
636
-
637
- logger.info("Using frame @ %.1fs for play clock selection (same as scorebug selection)", timestamp)
638
-
639
- session = PlayClockSelectionSession(frame, sb_bbox, scale_factor=3)
640
- bbox = session.get_unscaled_bbox() if session.run() else None
641
-
642
- return bbox.to_tuple() if bbox else None
643
-
644
-
645
- def select_timeout_region(selected_frame: Tuple[float, np.ndarray], scorebug_bbox: Tuple[int, int, int, int], team: str) -> Optional[Tuple[int, int, int, int]]:
646
- """
647
- Interactive selection of timeout indicator region for a team.
648
-
649
- Args:
650
- selected_frame: The (timestamp, frame) tuple selected during scorebug selection.
651
- scorebug_bbox: Scorebug bounding box (x, y, width, height).
652
- team: "home" or "away".
653
-
654
- Returns:
655
- Timeout region as (x, y, width, height) in absolute frame coordinates,
656
- or None if cancelled.
657
- """
658
- if selected_frame is None:
659
- logger.error("No frame provided for selection")
660
- return None
661
-
662
- timestamp, frame = selected_frame
663
- sb_bbox = BBox.from_tuple(scorebug_bbox)
664
-
665
- print(f"\n[Phase 2] Region Setup - {team.upper()} Team Timeout Selection")
666
- print("-" * 50)
667
- print("Instructions:")
668
- print(f" Select the 3 timeout indicator ovals for the {team.upper()} team")
669
- print(" (They are vertically stacked - white = available, dark = used)")
670
- print(" 1. Click on the TOP-LEFT corner of the timeout region")
671
- print(" 2. Click on the BOTTOM-RIGHT corner of the timeout region")
672
- print(" Press 'r' to reset, 'q' to quit")
673
- print("-" * 50)
674
-
675
- logger.info("Using frame @ %.1fs for %s timeout selection", timestamp, team)
676
-
677
- session = TimeoutSelectionSession(frame, sb_bbox, team, scale_factor=3, padding=50)
678
- session.run()
679
- bbox = session.get_absolute_bbox()
680
-
681
- return bbox.to_tuple() if bbox else None
682
-
683
-
684
- def get_video_path_from_user(project_root: Path) -> Optional[str]:
685
- """
686
- Prompt user to enter video file path.
687
-
688
- Args:
689
- project_root: Root directory of the project.
690
-
691
- Returns:
692
- Path to selected video file, or None if no valid selection.
693
- """
694
- print("\nAvailable videos in full_videos/:")
695
- videos_dir = project_root / "full_videos"
696
- videos = []
697
- if videos_dir.exists():
698
- videos = list(videos_dir.glob("*.mp4")) + list(videos_dir.glob("*.mkv"))
699
- for i, v in enumerate(videos, 1):
700
- print(f" {i}. {v.name}")
701
-
702
- print("\nEnter video path (or number from list above):")
703
- user_input = input("> ").strip()
704
-
705
- if not user_input:
706
- return None
707
-
708
- # Check if user entered a number
709
- try:
710
- idx = int(user_input) - 1
711
- if 0 <= idx < len(videos):
712
- return str(videos[idx])
713
- except ValueError:
714
- pass
715
-
716
- # Check if it's a valid path
717
- if Path(user_input).exists():
718
- return user_input
719
-
720
- # Check if it's relative to full_videos
721
- relative_path = videos_dir / user_input
722
- if relative_path.exists():
723
- return str(relative_path)
724
-
725
- logger.error("Video not found: %s", user_input)
726
- return None
 
1
  """
2
+ Interactive selection session classes.
3
 
4
+ This module provides session classes for different types of region selection:
5
+ - ScorebugSelectionSession: For selecting the main scorebug region
6
+ - PlayClockSelectionSession: For selecting the play clock within scorebug
7
+ - TimeoutSelectionSession: For selecting timeout indicator regions
8
  """
9
 
10
+ from typing import Dict, List, Optional, Tuple
 
 
 
11
 
12
  import cv2
13
  import numpy as np
 
14
 
15
+ from .models import BBox, SelectionState, SelectionViewConfig
16
+ from .selector import KeyHandler, RegionSelector
17
 
 
18
 
19
+ def _extract_sample_frames_for_selection(video_path: str, start_time: float, num_frames: int = 5, interval: float = 2.0) -> List[Tuple[float, np.ndarray]]:
20
+ """Extract sample frames - import here to avoid circular imports."""
21
+ from video.frame_extractor import extract_sample_frames # pylint: disable=import-outside-toplevel
22
 
23
+ return extract_sample_frames(video_path, start_time, num_frames, interval)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  class InteractiveSelectionSession:
 
88
  last_ts = state.frames[-1][0]
89
  new_start = last_ts + 30
90
  print(f" Skipping to {new_start:.1f}s...")
91
+ new_frames = _extract_sample_frames_for_selection(state.video_path, new_start, num_frames=5, interval=2.0)
92
  if new_frames:
93
  state.frames = new_frames
94
  state.frame_idx = 0
 
104
  last_ts = state.frames[-1][0]
105
  new_start = last_ts + 300
106
  print(f" Skipping to {new_start:.1f}s ({new_start / 60:.1f} min)...")
107
+ new_frames = _extract_sample_frames_for_selection(state.video_path, new_start, num_frames=5, interval=2.0)
108
  if new_frames:
109
  state.frames = new_frames
110
  state.frame_idx = 0
 
185
  return None
186
 
187
 
 
 
 
 
 
188
  class ScorebugSelectionSession(InteractiveSelectionSession):
189
  """Interactive session for selecting the scorebug region."""
190
 
 
231
  return display_frame
232
 
233
 
 
 
 
 
 
234
  class PlayClockSelectionSession(InteractiveSelectionSession):
235
  """Interactive session for selecting the play clock region within a scorebug."""
236
 
 
282
  return bbox.unscaled(self.scale_factor)
283
 
284
 
 
 
 
 
 
285
  class TimeoutSelectionSession(InteractiveSelectionSession):
286
  """Interactive session for selecting timeout indicator regions."""
287
 
 
353
  # Unscale and add padding offset
354
  unscaled = bbox.unscaled(self.scale_factor)
355
  return unscaled.offset(self.pad_x1, self.pad_y1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_digit_templates/test_fast_full_video.py CHANGED
@@ -2,13 +2,13 @@
2
  """
3
  FAST full video evaluation using template-based play clock reading.
4
 
5
- This script uses PlayDetector in FIXED COORDINATES mode which:
6
- 1. Skips scorebug detection entirely (uses known fixed coordinates)
7
  2. Uses template matching for play clock reading
8
  3. Includes all detection logic (state machine + clock reset detection)
9
 
10
- This is much faster than standard mode AND uses the same detection logic
11
- as main.py, avoiding the bug where we missed the clock reset detection pass.
12
 
13
  Usage:
14
  cd /Users/andytaylor/Documents/Personal/cfb40
 
2
  """
3
  FAST full video evaluation using template-based play clock reading.
4
 
5
+ This script uses PlayDetector with pre-configured fixed coordinates, which:
6
+ 1. Uses the known scorebug region (skips region discovery/searching)
7
  2. Uses template matching for play clock reading
8
  3. Includes all detection logic (state machine + clock reset detection)
9
 
10
+ The detection logic is unified - whether coordinates come from user selection
11
+ or from fixed config like here, the same multi-pass architecture is used.
12
 
13
  Usage:
14
  cd /Users/andytaylor/Documents/Personal/cfb40