""" 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