Spaces:
Sleeping
Sleeping
| """ | |
| FLAG indicator reading via yellow color detection. | |
| This module provides: | |
| - FlagReader: Reads FLAG indicator presence from the scorebug region | |
| - FlagReading: Result model for FLAG detection | |
| The FLAG indicator appears in the same location as "1st & 10" on the scorebug. | |
| When a penalty flag is thrown, this area turns yellow with "FLAG" text. | |
| Detection uses HSV color space analysis with orange discrimination: | |
| - True FLAG yellow: hue ~28-29 | |
| - False positive orange (team colors): hue ~16-17 | |
| Parameters validated against OSU vs Tenn 12.21.24 video: | |
| - 8 true positives detected | |
| - 0 false positives | |
| - 100% precision and recall | |
| """ | |
| import logging | |
| from typing import Any, Tuple | |
| import cv2 | |
| import numpy as np | |
| from .models import FlagReading | |
| logger = logging.getLogger(__name__) | |
| class FlagReader: | |
| """ | |
| Reads FLAG indicator from scorebug using yellow color detection. | |
| The FLAG region is specified as an offset from the scorebug bounding box, | |
| similar to how the play clock region is defined. | |
| Detection algorithm: | |
| 1. Extract FLAG region from frame using scorebug position | |
| 2. Convert to HSV color space | |
| 3. Create mask for yellow pixels (hue, saturation, value thresholds) | |
| 4. Compute yellow pixel ratio | |
| 5. Compute mean hue of yellow pixels (to distinguish from orange) | |
| 6. Flag is detected if ratio >= threshold AND mean hue >= min_hue | |
| """ | |
| # HSV color detection parameters (validated against ground truth) | |
| YELLOW_HUE_MIN = 15 # Yellow starts around 15-20 in OpenCV HSV (0-180 scale) | |
| YELLOW_HUE_MAX = 35 # Yellow ends around 35-40 | |
| YELLOW_SAT_MIN = 100 # Need decent saturation to be "yellow" not white/gray | |
| YELLOW_VAL_MIN = 100 # Need decent brightness | |
| # Detection thresholds | |
| YELLOW_RATIO_THRESHOLD = 0.25 # 25% of region must be yellow | |
| # MIN_MEAN_HUE distinguishes actual FLAG yellow (hue ~29-33) from: | |
| # - Orange team colors (hue ~16-17) | |
| # - Gold-styled down-and-distance text (hue ~23-24) | |
| MIN_MEAN_HUE = 27 # Raised from 22 to filter out gold-styled scorebug text | |
| def __init__( | |
| self, | |
| flag_x_offset: int, | |
| flag_y_offset: int, | |
| flag_width: int, | |
| flag_height: int, | |
| yellow_threshold: float = YELLOW_RATIO_THRESHOLD, | |
| min_mean_hue: float = MIN_MEAN_HUE, | |
| ): | |
| """ | |
| Initialize the FLAG reader. | |
| Args: | |
| flag_x_offset: X offset of FLAG region from scorebug top-left | |
| flag_y_offset: Y offset of FLAG region from scorebug top-left | |
| flag_width: Width of FLAG region | |
| flag_height: Height of FLAG region | |
| yellow_threshold: Minimum yellow pixel ratio to trigger detection | |
| min_mean_hue: Minimum mean hue (rejects orange being mistaken for yellow) | |
| """ | |
| self.flag_x_offset = flag_x_offset | |
| self.flag_y_offset = flag_y_offset | |
| self.flag_width = flag_width | |
| self.flag_height = flag_height | |
| self.yellow_threshold = yellow_threshold | |
| self.min_mean_hue = min_mean_hue | |
| logger.info( | |
| "FlagReader initialized: offset=(%d, %d), size=(%d, %d), threshold=%.2f, min_hue=%.1f", | |
| flag_x_offset, | |
| flag_y_offset, | |
| flag_width, | |
| flag_height, | |
| yellow_threshold, | |
| min_mean_hue, | |
| ) | |
| def _extract_flag_region( | |
| self, | |
| frame: np.ndarray[Any, Any], | |
| scorebug_x: int, | |
| scorebug_y: int, | |
| ) -> np.ndarray[Any, Any] | None: | |
| """ | |
| Extract the FLAG region from a frame. | |
| Args: | |
| frame: Full video frame (BGR) | |
| scorebug_x: Scorebug X position | |
| scorebug_y: Scorebug Y position | |
| Returns: | |
| Cropped FLAG region, or None if out of bounds | |
| """ | |
| # Calculate absolute coordinates | |
| abs_x = scorebug_x + self.flag_x_offset | |
| abs_y = scorebug_y + self.flag_y_offset | |
| abs_x2 = abs_x + self.flag_width | |
| abs_y2 = abs_y + self.flag_height | |
| # Clamp to frame bounds | |
| frame_h, frame_w = frame.shape[:2] | |
| abs_x = max(0, abs_x) | |
| abs_y = max(0, abs_y) | |
| abs_x2 = min(frame_w, abs_x2) | |
| abs_y2 = min(frame_h, abs_y2) | |
| if abs_x2 <= abs_x or abs_y2 <= abs_y: | |
| return None | |
| return frame[abs_y:abs_y2, abs_x:abs_x2] | |
| def _compute_yellow_metrics(self, roi: np.ndarray[Any, Any]) -> Tuple[float, float]: | |
| """ | |
| Compute yellow ratio and mean hue of yellow pixels. | |
| Args: | |
| roi: Region of interest in BGR format | |
| Returns: | |
| Tuple of (yellow_ratio, mean_hue) | |
| - yellow_ratio: Ratio of yellow pixels (0.0 to 1.0) | |
| - mean_hue: Mean hue of yellow pixels (0-180), or 0 if no yellow pixels | |
| """ | |
| if roi.size == 0: | |
| return 0.0, 0.0 | |
| # Convert BGR to HSV | |
| hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) | |
| # Create mask for yellow pixels | |
| lower_yellow = np.array([self.YELLOW_HUE_MIN, self.YELLOW_SAT_MIN, self.YELLOW_VAL_MIN]) | |
| upper_yellow = np.array([self.YELLOW_HUE_MAX, 255, 255]) | |
| mask = cv2.inRange(hsv, lower_yellow, upper_yellow) | |
| # Compute ratio | |
| yellow_pixels = np.count_nonzero(mask) | |
| total_pixels = roi.shape[0] * roi.shape[1] | |
| yellow_ratio = yellow_pixels / total_pixels if total_pixels > 0 else 0.0 | |
| # Compute mean hue of yellow pixels | |
| if yellow_pixels > 0: | |
| yellow_hues = hsv[:, :, 0][mask > 0] | |
| mean_hue = float(np.mean(np.asarray(yellow_hues))) | |
| else: | |
| mean_hue = 0.0 | |
| return yellow_ratio, mean_hue | |
| def read( | |
| self, | |
| frame: np.ndarray[Any, Any], | |
| scorebug_bbox: Tuple[int, int, int, int], | |
| ) -> FlagReading: | |
| """ | |
| Read FLAG status from a frame. | |
| Args: | |
| frame: Full video frame (BGR) | |
| scorebug_bbox: Scorebug bounding box (x, y, width, height) | |
| Returns: | |
| FlagReading with detection results | |
| """ | |
| sb_x, sb_y, _, _ = scorebug_bbox | |
| # Extract FLAG region | |
| roi = self._extract_flag_region(frame, sb_x, sb_y) | |
| if roi is None: | |
| return FlagReading( | |
| detected=False, | |
| yellow_ratio=0.0, | |
| mean_hue=0.0, | |
| is_valid_yellow=False, | |
| ) | |
| # Compute yellow metrics | |
| yellow_ratio, mean_hue = self._compute_yellow_metrics(roi) | |
| # Check if this is valid yellow (not orange) | |
| is_valid_yellow = mean_hue >= self.min_mean_hue | |
| # Detect FLAG if yellow ratio is above threshold AND it's true yellow | |
| detected = yellow_ratio >= self.yellow_threshold and is_valid_yellow | |
| return FlagReading( | |
| detected=detected, | |
| yellow_ratio=yellow_ratio, | |
| mean_hue=mean_hue, | |
| is_valid_yellow=is_valid_yellow, | |
| ) | |
| def read_from_fixed_location( | |
| self, | |
| frame: np.ndarray[Any, Any], | |
| absolute_coords: Tuple[int, int, int, int], | |
| ) -> FlagReading: | |
| """ | |
| Read FLAG from a fixed absolute location in the frame. | |
| Useful when you know the exact scorebug position and want to | |
| bypass the offset calculation. | |
| Args: | |
| frame: Full video frame (BGR) | |
| absolute_coords: Absolute FLAG region location (x, y, width, height) | |
| Returns: | |
| FlagReading with detection results | |
| """ | |
| x, y, w, h = absolute_coords | |
| # 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: | |
| return FlagReading( | |
| detected=False, | |
| yellow_ratio=0.0, | |
| mean_hue=0.0, | |
| is_valid_yellow=False, | |
| ) | |
| # Extract region | |
| roi = frame[y : y + h, x : x + w] | |
| # Compute yellow metrics | |
| yellow_ratio, mean_hue = self._compute_yellow_metrics(roi) | |
| # Check if this is valid yellow (not orange) | |
| is_valid_yellow = mean_hue >= self.min_mean_hue | |
| # Detect FLAG if yellow ratio is above threshold AND it's true yellow | |
| detected = yellow_ratio >= self.yellow_threshold and is_valid_yellow | |
| return FlagReading( | |
| detected=detected, | |
| yellow_ratio=yellow_ratio, | |
| mean_hue=mean_hue, | |
| is_valid_yellow=is_valid_yellow, | |
| ) | |