cfb40 / scripts /test_texas_yellow_digits.py
andytaylor-smg's picture
I think this fixes Oregon
aee009f
"""
Test if yellow-tinted play clock digits are causing misreads in Texas video.
Compares template matching results against OCR ground truth to identify
any systematic misreads due to the yellow color not normalizing correctly.
Usage:
python scripts/test_texas_yellow_digits.py
"""
import json
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List, Tuple
import cv2
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
from utils.color import normalize_to_grayscale, detect_red_digits
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
# Lazy import easyocr to avoid slow startup
_ocr_reader = None
def get_ocr_reader():
"""Lazy-load EasyOCR reader."""
global _ocr_reader
if _ocr_reader is None:
import easyocr
_ocr_reader = easyocr.Reader(["en"], gpu=False, verbose=False)
return _ocr_reader
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_texas_config() -> Dict[str, Any]:
"""Load the saved config for Texas video."""
config_path = Path("output/OSU_vs_Texas_01_10_25_config.json")
with open(config_path, "r") as f:
return json.load(f)
def read_clock_with_ocr(region: np.ndarray) -> Tuple[bool, int, float, str]:
"""
Read play clock value using OCR.
Returns:
Tuple of (detected, value, confidence, raw_text)
"""
reader = get_ocr_reader()
# Scale up for better OCR
scaled = cv2.resize(region, None, fx=4, fy=4, interpolation=cv2.INTER_LINEAR)
# Try OCR on the scaled image
results = reader.readtext(scaled, allowlist="0123456789")
if not results:
return False, -1, 0.0, ""
# Get the best result
best_result = max(results, key=lambda x: x[2])
raw_text = best_result[1]
confidence = best_result[2]
# Parse the text
try:
value = int(raw_text)
if 0 <= value <= 40:
return True, value, confidence, raw_text
except ValueError:
pass
return False, -1, confidence, raw_text
def analyze_color_distribution(region: np.ndarray) -> Dict[str, Any]:
"""
Analyze the color distribution in the play clock region.
Returns info about whether digits appear white, yellow, or red.
"""
# Convert to HSV for color analysis
hsv = cv2.cvtColor(region, cv2.COLOR_BGR2HSV)
# Get the brightest pixels (likely the digits)
gray = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY)
bright_mask = gray > 150 # Threshold for bright pixels
if not np.any(bright_mask):
return {"has_bright_pixels": False}
# Analyze hue of bright pixels
bright_hues = hsv[:, :, 0][bright_mask]
bright_sats = hsv[:, :, 1][bright_mask]
mean_hue = np.mean(bright_hues)
mean_sat = np.mean(bright_sats)
# Classify color
# White: low saturation
# Yellow: hue ~20-40, moderate saturation
# Red: hue ~0-10 or ~170-180, high saturation
is_red = detect_red_digits(region)
if mean_sat < 30:
color_class = "white"
elif 15 <= mean_hue <= 45 and mean_sat > 30:
color_class = "yellow"
elif (mean_hue < 15 or mean_hue > 165) and mean_sat > 50:
color_class = "red"
else:
color_class = "other"
return {
"has_bright_pixels": True,
"mean_hue": float(mean_hue),
"mean_saturation": float(mean_sat),
"color_class": color_class,
"is_red_detected": is_red,
}
def test_template_vs_ocr(
video_path: str,
playclock_coords: Tuple[int, int, int, int],
scorebug_coords: Tuple[int, int, int, int],
template_dir: str,
template_path: str,
timestamps: List[float],
output_dir: str,
) -> List[Dict[str, Any]]:
"""
Compare template matching results against OCR ground truth.
"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Load template library
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,
)
# 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)
results = []
logger.info("Comparing template matching vs OCR on %d timestamps...", len(timestamps))
logger.info("(OCR is slow, this may take a minute)")
for i, ts in enumerate(timestamps):
frame_num = int(ts * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_num)
ret, frame = cap.read()
if not ret or frame is None:
continue
# Check scorebug
sb_result = scorebug_detector.detect(frame)
if not sb_result.detected:
continue
# Extract play clock region
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()
# Analyze color
color_info = analyze_color_distribution(playclock_region)
# Template matching (with padding=10 as currently used)
tmpl_result = reader.read_from_fixed_location(frame, playclock_coords, padding=10)
# OCR ground truth
ocr_detected, ocr_value, ocr_conf, ocr_raw = read_clock_with_ocr(playclock_region)
# Compare results
match = False
if tmpl_result.detected and ocr_detected:
match = (tmpl_result.value == ocr_value)
result = {
"timestamp": ts,
"timestamp_str": seconds_to_timestamp(ts),
"color_info": color_info,
"template_detected": tmpl_result.detected,
"template_value": tmpl_result.value,
"template_confidence": tmpl_result.confidence,
"ocr_detected": ocr_detected,
"ocr_value": ocr_value,
"ocr_confidence": ocr_conf,
"ocr_raw": ocr_raw,
"match": match,
"mismatch": tmpl_result.detected and ocr_detected and not match,
}
results.append(result)
# Save debug image for mismatches
if result["mismatch"]:
save_mismatch_image(
frame,
playclock_region,
playclock_coords,
scorebug_coords,
result,
output_path / f"mismatch_{i:02d}_t{int(ts)}_tmpl{tmpl_result.value}_ocr{ocr_value}.png",
)
status = "MATCH" if match else ("MISMATCH" if result["mismatch"] else "incomplete")
logger.info(
"[%d/%d] %s: tmpl=%s, ocr=%s, color=%s [%s]",
i + 1,
len(timestamps),
seconds_to_timestamp(ts),
tmpl_result.value if tmpl_result.detected else "X",
ocr_value if ocr_detected else "X",
color_info.get("color_class", "?"),
status,
)
cap.release()
return results
def save_mismatch_image(
frame: np.ndarray,
playclock_region: np.ndarray,
playclock_coords: Tuple[int, int, int, int],
scorebug_coords: Tuple[int, int, int, int],
result: Dict[str, Any],
output_path: Path,
) -> None:
"""Save a debug image for a mismatch case."""
pc_x, pc_y, pc_w, pc_h = playclock_coords
sb_x, sb_y, sb_w, sb_h = scorebug_coords
# Extract scorebug area
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
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)
# Scale images
pc_scaled = cv2.resize(playclock_region, None, fx=12, fy=12, interpolation=cv2.INTER_NEAREST)
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)
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)
composite[0 : scorebug_scaled.shape[0], 0 : scorebug_scaled.shape[1]] = scorebug_scaled
x_offset = scorebug_scaled.shape[1] + 20
composite[0 : pc_scaled.shape[0], x_offset : x_offset + pc_scaled.shape[1]] = pc_scaled
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
# Labels
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(composite, f"MISMATCH at {result['timestamp_str']}", (10, composite_height + 25), font, 0.6, (0, 0, 255), 1)
cv2.putText(composite, f"Template: {result['template_value']} (conf={result['template_confidence']:.2f})", (10, composite_height + 50), font, 0.5, (255, 255, 255), 1)
cv2.putText(composite, f"OCR: {result['ocr_value']} (conf={result['ocr_confidence']:.2f}, raw='{result['ocr_raw']}')", (10, composite_height + 70), font, 0.5, (0, 255, 0), 1)
cv2.imwrite(str(output_path), composite)
def main():
"""Main function to test yellow digit handling."""
config = load_texas_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("=" * 70)
logger.info("TEXAS YELLOW DIGIT COLOR TEST")
logger.info("=" * 70)
logger.info("Video: %s", video_path)
logger.info("")
# Sample timestamps throughout the video
# Mix of early game, mid game, and late game (where short plays were detected)
timestamps = [
# Early game
500.0, 600.0, 700.0, 800.0, 900.0,
# Mid game
2000.0, 2100.0, 2200.0, 2300.0, 2400.0,
# Late game (around short plays)
5930.0, 5935.0, 5940.0, 5945.0, 5950.0,
5955.0, 5960.0, 5965.0, 5970.0, 5975.0,
]
results = test_template_vs_ocr(
video_path=video_path,
playclock_coords=playclock_coords,
scorebug_coords=scorebug_coords,
template_dir="output/debug/digit_templates",
template_path=template_path,
timestamps=timestamps,
output_dir="output/debug/texas_yellow_test",
)
# Summary
logger.info("")
logger.info("=" * 70)
logger.info("SUMMARY")
logger.info("=" * 70)
total = len(results)
both_detected = sum(1 for r in results if r["template_detected"] and r["ocr_detected"])
matches = sum(1 for r in results if r["match"])
mismatches = sum(1 for r in results if r["mismatch"])
# Color distribution
color_counts = {}
for r in results:
color = r["color_info"].get("color_class", "unknown")
color_counts[color] = color_counts.get(color, 0) + 1
logger.info("Total samples: %d", total)
logger.info("Both detected: %d (%.1f%%)", both_detected, 100 * both_detected / total if total > 0 else 0)
logger.info("Matches: %d (%.1f%% of both detected)", matches, 100 * matches / both_detected if both_detected > 0 else 0)
logger.info("MISMATCHES: %d", mismatches)
logger.info("")
logger.info("Color distribution:")
for color, count in sorted(color_counts.items()):
logger.info(" %s: %d (%.1f%%)", color, count, 100 * count / total if total > 0 else 0)
# Check if mismatches correlate with color
if mismatches > 0:
logger.info("")
logger.info("MISMATCH DETAILS:")
for r in results:
if r["mismatch"]:
logger.info(
" %s: tmpl=%s, ocr=%s, color=%s, hue=%.1f, sat=%.1f",
r["timestamp_str"],
r["template_value"],
r["ocr_value"],
r["color_info"].get("color_class", "?"),
r["color_info"].get("mean_hue", 0),
r["color_info"].get("mean_saturation", 0),
)
# Save full results
summary_path = Path("output/debug/texas_yellow_test_summary.json")
with open(summary_path, "w") as f:
json.dump(results, f, indent=2)
logger.info("")
logger.info("Full results saved to: %s", summary_path)
logger.info("Mismatch images saved to: output/debug/texas_yellow_test/")
if __name__ == "__main__":
main()