cfb40 / src /detection /timeout_calibrator.py
andytaylor-smg's picture
removing dead code
47d79b8
"""
Timeout oval calibration module.
This module provides functions to automatically discover the locations of timeout
indicator ovals within a timeout region. It uses blob detection to find bright
oval-shaped regions against a dark background.
The calibration process:
1. Extract the timeout region from a reference frame (early in game when all 3 timeouts visible)
2. Apply adaptive thresholding to isolate bright regions
3. Find contours and filter by area/aspect ratio to identify ovals
4. Validate that exactly 3 ovals are found with consistent spacing
5. Store the precise sub-coordinates for each oval
"""
import logging
from typing import Any, List, Optional, Tuple, cast
import cv2
import numpy as np
from .models import CalibratedTimeoutRegion, OvalLocation
logger = logging.getLogger(__name__)
def calibrate_timeout_ovals(
frame: np.ndarray[Any, Any],
region_bbox: Tuple[int, int, int, int],
team_name: str,
timestamp: float = 0.0,
) -> Optional[CalibratedTimeoutRegion]:
"""
Find and calibrate timeout oval locations within a region.
Args:
frame: Full video frame (BGR format)
region_bbox: Bounding box of the timeout region (x, y, width, height)
team_name: 'home' or 'away'
timestamp: Video timestamp for reference
Returns:
CalibratedTimeoutRegion with discovered oval positions, or None if calibration failed
"""
x, y, w, h = region_bbox
# Validate bounds
frame_h, frame_w = frame.shape[:2]
if x < 0 or y < 0 or x + w > frame_w or y + h > frame_h:
logger.error("Timeout region out of bounds: %s (frame: %dx%d)", region_bbox, frame_w, frame_h)
return None
# Extract the region of interest
roi = frame[y : y + h, x : x + w]
# Find bright blobs in the region
ovals = _find_bright_ovals(roi)
if len(ovals) != 3:
logger.warning("Expected 3 ovals for %s team, found %d. Calibration may be unreliable.", team_name, len(ovals))
# If we found more than 3, take the 3 brightest
if len(ovals) > 3:
ovals = sorted(ovals, key=lambda o: o.baseline_brightness, reverse=True)[:3]
ovals = sorted(ovals, key=lambda o: o.y) # Re-sort by vertical position
elif len(ovals) == 0:
logger.error("No ovals found for %s team. Calibration failed.", team_name)
return None
# Validate oval pattern (consistent spacing)
if not _validate_oval_pattern(ovals):
logger.warning("Oval pattern validation failed for %s team. Spacing may be inconsistent.", team_name)
calibrated = CalibratedTimeoutRegion(
team_name=team_name,
bbox=region_bbox,
ovals=ovals,
calibration_timestamp=timestamp,
)
logger.info(
"Calibrated %s timeout region: %d ovals found at positions %s",
team_name,
len(ovals),
[(o.x, o.y, o.width, o.height) for o in ovals],
)
return calibrated
# pylint: disable=too-many-locals
def _find_bright_ovals(roi: np.ndarray[Any, Any]) -> List[OvalLocation]:
"""
Find bright oval-shaped blobs in the region of interest.
Uses adaptive thresholding and contour detection to find bright regions
that could be timeout indicator ovals.
Args:
roi: Region of interest (BGR format)
Returns:
List of OvalLocation objects for detected ovals
"""
# Convert to grayscale
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
# Apply Gaussian blur to reduce noise
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
# Use Otsu's thresholding to find bright regions
# This automatically determines the optimal threshold
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# Also try a fixed high threshold for very bright ovals
_, binary_high = cv2.threshold(blurred, 180, 255, cv2.THRESH_BINARY)
# Combine both approaches - use whichever finds more distinct blobs
binary_combined = cv2.bitwise_or(binary, binary_high)
# Apply morphological operations to clean up the mask
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
binary_cleaned = cv2.morphologyEx(binary_combined, cv2.MORPH_CLOSE, kernel)
binary_cleaned = cv2.morphologyEx(binary_cleaned, cv2.MORPH_OPEN, kernel)
# Find contours
contours, _ = cv2.findContours(binary_cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
ovals = []
roi_h, roi_w = roi.shape[:2]
for contour in contours:
# Get bounding rectangle
bx, by, bw, bh = cv2.boundingRect(contour)
# Filter by size - ovals should be a reasonable size relative to region
area = cv2.contourArea(contour)
min_area = (roi_w * roi_h) * 0.01 # At least 1% of region
max_area = (roi_w * roi_h) * 0.25 # At most 25% of region
if area < min_area or area > max_area:
continue
# Filter by aspect ratio - timeout ovals are typically wider than tall (horizontal bars)
# or roughly square, but not extremely tall and thin
aspect_ratio = bw / bh if bh > 0 else 0
if aspect_ratio < 0.3 or aspect_ratio > 5.0:
continue
# Calculate mean brightness of the contour region
mask = np.zeros(gray.shape, dtype=np.uint8)
cv2.drawContours(mask, [contour], -1, 255, -1)
# cv2.mean returns a tuple of 4 floats (per channel); extract the first channel
mean_brightness_tuple = cast(Tuple[float, float, float, float], cv2.mean(gray, mask=mask))
mean_brightness = mean_brightness_tuple[0]
# Only keep if significantly bright
if mean_brightness < 100:
continue
oval = OvalLocation(
x=bx,
y=by,
width=bw,
height=bh,
baseline_brightness=float(mean_brightness),
)
ovals.append(oval)
# Sort by vertical position (top to bottom) since ovals are stacked vertically
ovals = sorted(ovals, key=lambda o: o.y)
logger.debug("Found %d candidate ovals in region", len(ovals))
return ovals
# pylint: enable=too-many-locals
def _validate_oval_pattern(ovals: List[OvalLocation]) -> bool:
"""
Validate that ovals have consistent spacing (symmetry check).
For 3 ovals stacked vertically, the spacing between oval 1-2 should be
similar to the spacing between oval 2-3.
Args:
ovals: List of OvalLocation objects (should be sorted by y position)
Returns:
True if pattern is valid, False otherwise
"""
if len(ovals) < 2:
return False
if len(ovals) == 2:
# Can't validate spacing with only 2 ovals, but accept it
return True
# Calculate vertical spacing between consecutive ovals
spacings = []
for i in range(len(ovals) - 1):
# Distance from bottom of one oval to top of next
spacing = ovals[i + 1].y - (ovals[i].y + ovals[i].height)
spacings.append(spacing)
# Check if spacings are consistent (within 50% of each other)
if len(spacings) >= 2:
avg_spacing = sum(spacings) / len(spacings)
for spacing in spacings:
if avg_spacing > 0 and abs(spacing - avg_spacing) / avg_spacing > 0.5:
logger.debug("Inconsistent oval spacing: %s (avg: %.1f)", spacings, avg_spacing)
return False
# Check if oval sizes are consistent
widths = [o.width for o in ovals]
heights = [o.height for o in ovals]
avg_width = sum(widths) / len(widths)
avg_height = sum(heights) / len(heights)
for w, h in zip(widths, heights):
if avg_width > 0 and abs(w - avg_width) / avg_width > 0.5:
logger.debug("Inconsistent oval widths: %s (avg: %.1f)", widths, avg_width)
return False
if avg_height > 0 and abs(h - avg_height) / avg_height > 0.5:
logger.debug("Inconsistent oval heights: %s (avg: %.1f)", heights, avg_height)
return False
return True
def visualize_calibration(
frame: np.ndarray[Any, Any],
calibrated_region: CalibratedTimeoutRegion,
) -> np.ndarray[Any, Any]:
"""
Draw calibrated oval positions on frame for visualization.
Args:
frame: Input frame (BGR format)
calibrated_region: Calibrated timeout region with oval positions
Returns:
Frame with visualization overlay
"""
vis_frame = frame.copy()
rx, ry, rw, rh = calibrated_region.bbox
# Draw overall region
color = (255, 0, 0) if calibrated_region.team_name == "home" else (0, 165, 255)
cv2.rectangle(vis_frame, (rx, ry), (rx + rw, ry + rh), color, 2)
# Draw each oval
for i, oval in enumerate(calibrated_region.ovals):
abs_x = rx + oval.x
abs_y = ry + oval.y
# Draw oval bounding box
cv2.rectangle(vis_frame, (abs_x, abs_y), (abs_x + oval.width, abs_y + oval.height), (0, 255, 0), 1)
# Draw oval number
cv2.putText(
vis_frame,
str(i + 1),
(abs_x + oval.width + 2, abs_y + oval.height // 2),
cv2.FONT_HERSHEY_SIMPLEX,
0.4,
(0, 255, 0),
1,
)
# Add label
label = f"{calibrated_region.team_name.upper()}: {len(calibrated_region.ovals)} ovals"
cv2.putText(vis_frame, label, (rx, ry - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
return vis_frame