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