cfb40 / scripts /find_missed_clock_readings.py
andytaylor-smg's picture
I think this fixes Oregon
aee009f
"""
Find frames where scorebug is detected but play clock reading fails.
Run OCR to confirm what the actual clock value is, then save debug images.
This produces images showing:
- The scorebug region (with play clock highlighted)
- The extracted play clock region (scaled up)
- The preprocessed play clock region
- OCR result vs template matching result
Usage:
python scripts/find_missed_clock_readings.py
"""
import json
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List, Tuple
import cv2
import easyocr
import numpy as np
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from readers import ReadPlayClock
from setup import DigitTemplateLibrary
from detection.scorebug import DetectScoreBug
from utils.regions import preprocess_playclock_region
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def seconds_to_timestamp(seconds: float) -> str:
"""Convert seconds to timestamp string (H:MM:SS)."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
def load_oregon_config() -> Dict[str, Any]:
"""Load the saved config for Oregon video."""
config_path = Path("output/OSU_vs_Oregon_01_01_25_config.json")
with open(config_path, "r") as f:
return json.load(f)
def find_missed_readings(
video_path: str,
playclock_coords: Tuple[int, int, int, int],
scorebug_coords: Tuple[int, int, int, int],
template_dir: str,
template_path: str,
output_dir: str,
target_count: int = 10,
sample_interval: float = 2.0, # Sample every 2 seconds to spread out
start_time: float = 900.0, # Start at 15 minutes (after early game where templates work)
end_time: float = 7200.0, # End at 2 hours
) -> List[Dict[str, Any]]:
"""
Find frames where scorebug is detected but clock reading fails.
Run OCR to confirm the actual clock value.
"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Load template library
logger.info("Loading template library from %s", template_dir)
library = DigitTemplateLibrary()
if not library.load(template_dir):
raise RuntimeError(f"Failed to load templates from {template_dir}")
# Create play clock reader
pc_w, pc_h = playclock_coords[2], playclock_coords[3]
reader = ReadPlayClock(library, region_width=pc_w, region_height=pc_h)
# Create scorebug detector
scorebug_detector = DetectScoreBug(
template_path=template_path,
fixed_region=scorebug_coords,
use_split_detection=True,
)
# Initialize EasyOCR
logger.info("Initializing EasyOCR...")
ocr_reader = easyocr.Reader(["en"], gpu=False, verbose=False)
logger.info("EasyOCR ready")
# Open video
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise RuntimeError(f"Failed to open video: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS)
logger.info("Video: %s", video_path)
logger.info("Searching for missed clock readings from %s to %s...", seconds_to_timestamp(start_time), seconds_to_timestamp(end_time))
missed_readings = []
current_time = start_time
while current_time < end_time and len(missed_readings) < target_count:
frame_num = int(current_time * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
ret, frame = cap.read()
if not ret or frame is None:
current_time += sample_interval
continue
# Check scorebug detection
sb_result = scorebug_detector.detect(frame)
if not sb_result.detected:
current_time += sample_interval
continue
# Check template-based clock reading
template_result = reader.read_from_fixed_location(frame, playclock_coords)
if template_result.detected:
# Template matching worked - skip this frame
current_time += sample_interval
continue
# Scorebug detected but template matching failed - run OCR to confirm actual value
pc_x, pc_y, pc_w, pc_h = playclock_coords
playclock_region = frame[pc_y : pc_y + pc_h, pc_x : pc_x + pc_w].copy()
# Preprocess for OCR (same as template building)
preprocessed = preprocess_playclock_region(playclock_region, scale_factor=4)
# Run OCR
try:
ocr_results = ocr_reader.readtext(preprocessed, allowlist="0123456789", detail=1)
if ocr_results:
best = max(ocr_results, key=lambda x: x[2])
ocr_text = best[1].strip()
ocr_confidence = best[2]
# Only keep if OCR finds a valid clock value
if ocr_text and ocr_confidence >= 0.5:
try:
ocr_value = int(ocr_text)
if 0 <= ocr_value <= 40:
# Valid reading - this is a missed detection
missed = {
"timestamp": current_time,
"timestamp_str": seconds_to_timestamp(current_time),
"ocr_value": ocr_value,
"ocr_confidence": ocr_confidence,
"template_confidence": template_result.confidence,
"scorebug_confidence": sb_result.confidence,
}
missed_readings.append(missed)
logger.info(
"Found missed reading #%d at %s: OCR=%d (%.2f), Template conf=%.2f",
len(missed_readings),
seconds_to_timestamp(current_time),
ocr_value,
ocr_confidence,
template_result.confidence,
)
# Save debug image
save_missed_debug_image(
frame,
playclock_coords,
scorebug_coords,
template_result,
ocr_value,
ocr_confidence,
current_time,
output_path / f"missed_{len(missed_readings):02d}_t{int(current_time)}_ocr{ocr_value}.png",
)
except ValueError:
pass
except Exception as e:
logger.debug("OCR error at %.1fs: %s", current_time, e)
current_time += sample_interval
cap.release()
logger.info("")
logger.info("Found %d missed readings", len(missed_readings))
# Save summary
summary_path = output_path / "missed_readings_summary.json"
with open(summary_path, "w") as f:
json.dump(missed_readings, f, indent=2)
logger.info("Summary saved to %s", summary_path)
return missed_readings
def save_missed_debug_image(
frame: np.ndarray,
playclock_coords: Tuple[int, int, int, int],
scorebug_coords: Tuple[int, int, int, int],
template_result,
ocr_value: int,
ocr_confidence: float,
timestamp: float,
output_path: Path,
) -> None:
"""Save a debug image showing the missed clock reading."""
pc_x, pc_y, pc_w, pc_h = playclock_coords
sb_x, sb_y, sb_w, sb_h = scorebug_coords
# Extract scorebug area with context
margin = 30
crop_y1 = max(0, sb_y - margin)
crop_y2 = min(frame.shape[0], sb_y + sb_h + margin)
crop_x1 = max(0, sb_x - margin)
crop_x2 = min(frame.shape[1], sb_x + sb_w + margin)
scorebug_crop = frame[crop_y1:crop_y2, crop_x1:crop_x2].copy()
# Draw play clock region on crop
pc_rel_x = pc_x - crop_x1
pc_rel_y = pc_y - crop_y1
cv2.rectangle(scorebug_crop, (pc_rel_x, pc_rel_y), (pc_rel_x + pc_w, pc_rel_y + pc_h), (0, 0, 255), 2)
# Extract play clock region
playclock_region = frame[pc_y : pc_y + pc_h, pc_x : pc_x + pc_w].copy()
# Scale up for visibility
pc_scaled = cv2.resize(playclock_region, None, fx=12, fy=12, interpolation=cv2.INTER_NEAREST)
# Preprocess
preprocessed = preprocess_playclock_region(playclock_region, scale_factor=4)
preprocessed_bgr = cv2.cvtColor(preprocessed, cv2.COLOR_GRAY2BGR)
preprocessed_scaled = cv2.resize(preprocessed_bgr, (pc_scaled.shape[1], pc_scaled.shape[0]), interpolation=cv2.INTER_NEAREST)
# Scale up scorebug
scorebug_scaled = cv2.resize(scorebug_crop, None, fx=2, fy=2)
# Create composite
composite_height = max(scorebug_scaled.shape[0], pc_scaled.shape[0] + preprocessed_scaled.shape[0] + 20)
composite_width = scorebug_scaled.shape[1] + max(pc_scaled.shape[1], preprocessed_scaled.shape[1]) + 40
composite = np.zeros((composite_height + 80, composite_width, 3), dtype=np.uint8)
# Place scorebug
composite[0 : scorebug_scaled.shape[0], 0 : scorebug_scaled.shape[1]] = scorebug_scaled
# Place original playclock
x_offset = scorebug_scaled.shape[1] + 20
composite[0 : pc_scaled.shape[0], x_offset : x_offset + pc_scaled.shape[1]] = pc_scaled
# Place preprocessed below
y_offset = pc_scaled.shape[0] + 10
composite[y_offset : y_offset + preprocessed_scaled.shape[0], x_offset : x_offset + preprocessed_scaled.shape[1]] = preprocessed_scaled
# Add labels
font = cv2.FONT_HERSHEY_SIMPLEX
# Title with timestamp
title = f"t={seconds_to_timestamp(timestamp)} - MISSED READING"
cv2.putText(composite, title, (10, composite_height + 25), font, 0.6, (255, 255, 255), 1)
# OCR result (green - correct)
ocr_text = f"OCR: {ocr_value} (conf: {ocr_confidence:.2f})"
cv2.putText(composite, ocr_text, (10, composite_height + 50), font, 0.5, (0, 255, 0), 1)
# Template result (red - failed)
tmpl_text = f"Template: NOT DETECTED (conf: {template_result.confidence:.2f})"
cv2.putText(composite, tmpl_text, (10, composite_height + 70), font, 0.5, (0, 0, 255), 1)
# Add labels for the image panels
cv2.putText(composite, "Raw", (x_offset, pc_scaled.shape[0] + 5), font, 0.4, (200, 200, 200), 1)
cv2.putText(composite, "Preprocessed", (x_offset, y_offset + preprocessed_scaled.shape[0] + 5), font, 0.4, (200, 200, 200), 1)
cv2.imwrite(str(output_path), composite)
def main():
"""Main function to find missed clock readings."""
config = load_oregon_config()
video_path = config["video_path"]
template_path = config["template_path"]
playclock_coords = (
config["scorebug_x"] + config["playclock_x_offset"],
config["scorebug_y"] + config["playclock_y_offset"],
config["playclock_width"],
config["playclock_height"],
)
scorebug_coords = (
config["scorebug_x"],
config["scorebug_y"],
config["scorebug_width"],
config["scorebug_height"],
)
logger.info("Finding missed clock readings in Oregon video")
logger.info("=" * 60)
missed = find_missed_readings(
video_path=video_path,
playclock_coords=playclock_coords,
scorebug_coords=scorebug_coords,
template_dir="output/debug/digit_templates",
template_path=template_path,
output_dir="output/debug/missed_clock_readings",
target_count=10,
sample_interval=5.0, # Sample every 5 seconds to spread out examples
start_time=900.0, # Start at 15 min
end_time=7200.0, # End at 2 hours
)
# Print summary
logger.info("")
logger.info("=" * 60)
logger.info("MISSED READINGS SUMMARY")
logger.info("=" * 60)
for i, m in enumerate(missed, 1):
logger.info(
"%2d. t=%s | OCR: %2d (%.2f) | Template conf: %.2f",
i,
m["timestamp_str"],
m["ocr_value"],
m["ocr_confidence"],
m["template_confidence"],
)
logger.info("")
logger.info("Debug images saved to: output/debug/missed_clock_readings/")
if __name__ == "__main__":
main()