Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |