"""Frozen active US sign inference, isolated from camera/Params and debug collection.""" from __future__ import annotations from collections import Counter, deque from dataclasses import dataclass from pathlib import Path import hashlib import math import time import numpy as np try: import cv2 except ImportError: # Keep inference unavailable when OpenCV is absent. cv2 = None MIN_DETECTION_CONFIDENCE = 0.2 PUBLISHED_CHANGE_COOLDOWN_SECONDS = 1.4 PUBLISHED_REVERT_CONFIDENCE = 0.97 TEMPORAL_TRACKING_ENABLED = False TRACK_MIN_PROPOSAL_CONFIDENCE = 0.10 TRACK_UNREADABLE_MIN_PROPOSAL_CONFIDENCE = 0.22 DETECTOR_CLASSIFIER_REGION_MODE = "right_roi" # full, right_roi, full_and_right_roi STRONG_DETECTION_CONFIDENCE = 0.72 HISTORY_SECONDS = 2.0 CONSISTENT_DETECTIONS = 2 CHANGE_CONSISTENT_DETECTIONS = 2 CHANGE_SINGLE_READ_MIN_CONFIDENCE = 0.83 CHANGE_REPEAT_MIN_CONFIDENCE = 0.70 LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS = 2 LOW_SPEED_CHANGE_MIN_CONFIDENCE = 0.90 LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS = True MODEL_DETECTION_SHORT_CIRCUIT_CONFIDENCE = 0.65 MODEL_PROPOSAL_MAX_COUNT = 4 MODEL_PROPOSAL_MAX_AREA_RATIO = 0.18 MODEL_PROPOSAL_MIN_WIDTH = 10 MODEL_PROPOSAL_MIN_HEIGHT = 18 MODEL_PROPOSAL_MIN_X_RATIO = 0.35 MODEL_PROPOSAL_MAX_Y_RATIO = 0.82 ROI_WINDOWS = ( {"bounds": (0.48, 0.00, 0.98, 0.42), "min_confidence": MIN_DETECTION_CONFIDENCE}, {"bounds": (0.52, 0.02, 0.97, 0.58), "min_confidence": 0.22}, {"bounds": (0.62, 0.02, 0.99, 0.68), "min_confidence": 0.18}, {"bounds": (0.45, 0.00, 1.00, 0.82), "min_confidence": 0.06}, ) REGULATORY_WHITE_VALUE_MIN = 135 REGULATORY_WHITE_SAT_MAX = 70 REGULATORY_DARK_VALUE_MAX = 115 REGULATORY_DARK_SAT_MAX = 110 REGULATORY_YELLOW_HUE_MIN = 12 REGULATORY_YELLOW_HUE_MAX = 45 REGULATORY_YELLOW_SAT_MIN = 70 REGULATORY_YELLOW_VALUE_MIN = 85 REGULATORY_RED_LOW_HUE_MAX = 12 REGULATORY_RED_HIGH_HUE_MIN = 168 REGULATORY_RED_SAT_MIN = 80 REGULATORY_RED_VALUE_MIN = 60 REGULATORY_GREEN_HUE_MIN = 45 REGULATORY_GREEN_HUE_MAX = 90 REGULATORY_BLUE_HUE_MIN = 90 REGULATORY_BLUE_HUE_MAX = 135 REGULATORY_COLORED_SAT_MIN = 70 REGULATORY_COLORED_VALUE_MIN = 70 REGULATORY_MIN_WHITE_RATIO = 0.08 REGULATORY_MIN_DARK_RATIO = 0.01 REGULATORY_MAX_YELLOW_RATIO = 0.12 REGULATORY_MAX_RED_RATIO = 0.10 REGULATORY_MAX_GREEN_RATIO = 0.35 REGULATORY_MAX_BLUE_RATIO = 0.35 REGULATORY_MIN_WHITE_COMPONENT_RATIO = 0.012 REGULATORY_MIN_COMPONENT_FILL = 0.36 REGULATORY_MIN_COMPONENT_HEIGHT_RATIO = 0.2 REGULATORY_MIN_COMPONENT_WIDTH_RATIO = 0.12 REGULATORY_MIN_ASPECT_RATIO = 0.28 REGULATORY_MAX_ASPECT_RATIO = 1.25 MIN_PUBLISHABLE_SPEED_LIMIT_MPH = 5 MAX_IMPERIAL_PUBLISHABLE_SPEED_LIMIT_MPH = 80 US_DETECTOR_CLASSES = { 0: "regulatory_speed_limit", 1: "advisory_speed_limit", 2: "school_zone_speed_limit", } US_CLASSIFIER_SPEED_VALUES = (10, 100, 15, 20, 25, 30, 35, 40, 45, 5, 50, 55, 60, 65, 70, 75, 80, 90) EXTENDED_CLASSIFIER_SPEED_VALUES = frozenset((5, 10, 80, 90, 100)) SCHOOL_ZONE_SPEED_VALUES = frozenset((15, 20, 25)) US_DETECTOR_MIN_CONFIDENCE = 0.06 US_CLASSIFIER_MIN_CONFIDENCE = 0.60 EXTENDED_CLASSIFIER_MIN_CONFIDENCE = 0.90 US_CLASSIFIER_REJECT_MIN_CONFIDENCE = 0.85 DETECTOR_CLASSIFIER_EXPANSIONS = ( (0.00, 0.00, 0.00, 0.00, 1.10), (0.10, 0.06, 0.10, 0.12, 1.00), (0.00, 0.00, 0.18, 0.18, 0.55), ) SCHOOL_ZONE_DIRECT_EXPANSIONS = ( (0.00, 0.00, 0.18, 0.18), (0.00, 0.00, 0.22, 0.18), ) SCHOOL_ZONE_READ_VARIANTS = ( (0.00, 0.00, 1.00, 1.00, 0.80), (0.00, 0.35, 1.00, 1.00, 0.88), (0.00, 0.45, 1.00, 1.00, 0.96), (0.12, 0.38, 0.88, 1.00, 1.00), ) DETECTOR_CLASSIFIER_SUPPORT_BONUS = 0.06 DETECTOR_CLASSIFIER_REGULATORY_BONUS = 0.05 DETECTOR_CLASSIFIER_NON_REGULATORY_PENALTY = 0.03 DETECTOR_CLASSIFIER_SMALL_BOX_AREA_RATIO = 0.004 DETECTOR_CLASSIFIER_TINY_LOW_CONF_AREA_RATIO = 0.002 DETECTOR_CLASSIFIER_TINY_LOW_CONF_MIN_CONFIDENCE = 0.16 DETECTOR_CLASSIFIER_MIN_ACCEPT_WIDTH = 28 DETECTOR_CLASSIFIER_MIN_ACCEPT_HEIGHT = 40 DETECTOR_CLASSIFIER_RESCUE_MIN_WIDTH = 14 DETECTOR_CLASSIFIER_RESCUE_MIN_HEIGHT = 18 DETECTOR_CLASSIFIER_RESCUE_MIN_X_RATIO = 0.52 DETECTOR_CLASSIFIER_RESCUE_MIN_SUPPORT = 2 DETECTOR_CLASSIFIER_RESCUE_MIN_CONFIDENCE = 0.90 DETECTOR_CLASSIFIER_RESCUE_MAX_SCORE = 0.64 DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_SUPPORT = 3 DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE = 0.60 DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_READ_CONFIDENCE = 0.995 DETECTOR_CLASSIFIER_STRONG_RESCUE_MAX_SCORE = 0.74 DETECTOR_CLASSIFIER_TRUSTED_MODEL_MAX_HEIGHT = 55 DETECTOR_CLASSIFIER_TRUSTED_MODEL_MAX_AREA_RATIO = 0.002 DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_PROPOSAL_CONFIDENCE = 0.18 DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_X_RATIO = 0.52 DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = 0.65 DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_SUPPORT = 2 DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE = 0.60 DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_READ_CONFIDENCE = 0.995 DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_READ_CONFIDENCE = 0.95 DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED = True DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_SUPPORT = 2 DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_CONFIDENCE = 0.90 DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_SUPPORT = 2 SCHOOL_ZONE_SPEED_PRIOR = 0.12 SCHOOL_ZONE_SUPPORT_BONUS = 0.08 SCHOOL_ZONE_MIN_SUPPORT = 2 SCHOOL_ZONE_MIN_CONFIDENCE = 0.70 SCHOOL_ZONE_SINGLE_READ_CONFIDENCE = 0.975 SCHOOL_ZONE_SHORT_CIRCUIT_CONFIDENCE = 0.78 SCHOOL_ZONE_FALLBACK_MIN_CONFIDENCE = 0.35 NON_SCHOOL_LOW_SPEED_COMPETING_MIN_CONFIDENCE = 0.95 @dataclass(frozen=True) class Detection: speed_limit_mph: int confidence: float strong_consensus: bool = False @dataclass(frozen=True) class DetectorProposal: confidence: float class_id: int bbox: tuple[int, int, int, int] speed_limit_mph: int = 0 @dataclass(frozen=True) class HistoryEntry: speed_limit_mph: int confidence: float created_at: float strong_consensus: bool = False class VisionModelCore: """The frozen active detector/classifier path, with no camera or Params ownership.""" ASSET_DIR = Path(__file__).resolve().parent / "assets" DETECTOR_SHA256 = "82408b68c79c269296f0af942130c5383cace4ee06c78e2a4690e8488720116a" CLASSIFIER_SHA256 = "07c6696e530eb940d2757d5849b4bc0f1d785cda704e5296e18c0a94959f30a5" DETECTOR_SIZE = 256 CLASSIFIER_SIZE = 128 def __init__(self, *, is_metric: bool): if cv2 is None: raise RuntimeError("OpenCV DNN is unavailable") self.is_metric = is_metric self.net = self._load_net("speed_limit_us_detector.onnx", self.DETECTOR_SHA256) self.classifier_net = self._load_net("speed_limit_us_value_classifier.onnx", self.CLASSIFIER_SHA256) self._validate_model_abi() self.detector_input_size = self.DETECTOR_SIZE self.classifier_input_size = self.CLASSIFIER_SIZE self.last_detector_forward_count = 0 self.last_detector_forward_duration_s = 0.0 self.last_classifier_forward_count = 0 self.last_classifier_forward_duration_s = 0.0 self.temporal_tracking_enabled = False self.latest_detector_proposal = None self.history: deque[HistoryEntry] = deque() self.published_speed_limit_mph = 0 self.published_confidence = 0.0 self.previous_published_speed_limit_mph = 0 self.last_publish_change_at = 0.0 self.last_detection_at = 0.0 self.last_published_support_at = 0.0 self.episode = 0 self.published_support_count = 0 def reset(self) -> None: """Discard evidence from a disconnected camera or a previous stream.""" self.history.clear() self.latest_detector_proposal = None self.published_speed_limit_mph = 0 self.published_confidence = 0.0 self.previous_published_speed_limit_mph = 0 self.last_publish_change_at = 0.0 self.last_detection_at = 0.0 self.last_published_support_at = 0.0 self.published_support_count = 0 self.episode = 0 @classmethod def _load_net(cls, name: str, expected_sha256: str): path = cls.ASSET_DIR / name with path.open("rb") as stream: actual = hashlib.file_digest(stream, "sha256").hexdigest() if actual != expected_sha256: raise ValueError(f"Vision model identity mismatch: {name}") net = cv2.dnn.readNetFromONNX(str(path)) net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) return net def _validate_model_abi(self) -> None: self.net.setInput(np.zeros((1, 3, self.DETECTOR_SIZE, self.DETECTOR_SIZE), dtype=np.float32)) detector_output = self.net.forward() self.classifier_net.setInput(np.zeros((1, 3, self.CLASSIFIER_SIZE, self.CLASSIFIER_SIZE), dtype=np.float32)) classifier_output = self.classifier_net.forward() if (detector_output.shape != (1, 5, 1344) or classifier_output.shape != (1, 19) or not np.isfinite(detector_output).all() or not np.isfinite(classifier_output).all()): raise ValueError("Unsupported Vision model output shape or nonfinite output") def infer(self, frame_bgr: np.ndarray) -> Detection | None: if frame_bgr.ndim != 3 or frame_bgr.shape[2] != 3 or frame_bgr.size == 0: raise ValueError("Expected a nonempty BGR frame") return self._publishable_detection(self._detect_sign_from_detector_classifier(frame_bgr)) def observe(self, frame_bgr: np.ndarray, *, now: float) -> tuple[int, float, int, int] | None: """Return the last confirmed limit until replacement or camera-session reset.""" if not math.isfinite(now) or now <= 0: raise ValueError("Invalid observation time") detection = self.infer(frame_bgr) if detection is not None and math.isfinite(detection.confidence) and 0 <= detection.confidence <= 1: self.last_detection_at = now self.history.append(HistoryEntry(detection.speed_limit_mph, detection.confidence, now, detection.strong_consensus)) self._prune_history(now) confirmed = self._confirm_detection() if confirmed is not None: speed, confidence = confirmed reverting_early = (speed != self.published_speed_limit_mph and speed == self.previous_published_speed_limit_mph and now - self.last_publish_change_at < PUBLISHED_CHANGE_COOLDOWN_SECONDS and confidence < PUBLISHED_REVERT_CONFIDENCE) if reverting_early: return ( self.published_speed_limit_mph, self.published_confidence, self.published_support_count, self.episode) support = sum(entry.speed_limit_mph == speed for entry in self.history) if speed != self.published_speed_limit_mph: self.previous_published_speed_limit_mph = self.published_speed_limit_mph self.published_speed_limit_mph = speed self.last_publish_change_at = now self.episode += 1 self.history.clear() self.history.append(HistoryEntry(speed, confidence, now, detection.strong_consensus)) self.published_confidence = confidence self.published_support_count = support if detection.speed_limit_mph == speed: self.last_published_support_at = now if self.published_speed_limit_mph: return self.published_speed_limit_mph, self.published_confidence, self.published_support_count, self.episode return None @staticmethod def _letterbox(image, shape=(640, 640), color=(114, 114, 114)): image_height, image_width = image.shape[:2] ratio = min(shape[0] / image_height, shape[1] / image_width) resized_width = int(round(image_width * ratio)) resized_height = int(round(image_height * ratio)) pad_width = (shape[1] - resized_width) / 2 pad_height = (shape[0] - resized_height) / 2 if (image_width, image_height) != (resized_width, resized_height): image = cv2.resize(image, (resized_width, resized_height), interpolation=cv2.INTER_LINEAR) top = int(round(pad_height - 0.1)) bottom = int(round(pad_height + 0.1)) left = int(round(pad_width - 0.1)) right = int(round(pad_width + 0.1)) image = cv2.copyMakeBorder(image, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return image, ratio, pad_width, pad_height def _remember_detector_proposal(self, confidence, class_id, bbox, speed_limit_mph=0, preferred=False): min_confidence = TRACK_MIN_PROPOSAL_CONFIDENCE if speed_limit_mph else TRACK_UNREADABLE_MIN_PROPOSAL_CONFIDENCE if not getattr(self, "temporal_tracking_enabled", TEMPORAL_TRACKING_ENABLED) or class_id == 1 or confidence < min_confidence: return proposal = DetectorProposal(float(confidence), int(class_id), bbox, int(speed_limit_mph)) latest_proposal = getattr(self, "latest_detector_proposal", None) if preferred or latest_proposal is None or proposal.confidence > latest_proposal.confidence: self.latest_detector_proposal = proposal def _publishable_detection(self, detection): if detection is None: return None if detection.speed_limit_mph < MIN_PUBLISHABLE_SPEED_LIMIT_MPH: return None if not getattr(self, "is_metric", False) and detection.speed_limit_mph > MAX_IMPERIAL_PUBLISHABLE_SPEED_LIMIT_MPH: return None return detection def _is_regulatory_speed_sign(self, sign_crop): if sign_crop.size == 0: return False crop_height, crop_width = sign_crop.shape[:2] crop_area = crop_height * crop_width if crop_area <= 0: return False hsv = cv2.cvtColor(sign_crop, cv2.COLOR_BGR2HSV) hue = hsv[:, :, 0] saturation = hsv[:, :, 1] value = hsv[:, :, 2] white_mask = ((value >= REGULATORY_WHITE_VALUE_MIN) & (saturation <= REGULATORY_WHITE_SAT_MAX)).astype(np.uint8) dark_mask = ((value <= REGULATORY_DARK_VALUE_MAX) & (saturation <= REGULATORY_DARK_SAT_MAX)).astype(np.uint8) yellow_mask = ( (hue >= REGULATORY_YELLOW_HUE_MIN) & (hue <= REGULATORY_YELLOW_HUE_MAX) & (saturation >= REGULATORY_YELLOW_SAT_MIN) & (value >= REGULATORY_YELLOW_VALUE_MIN) ).astype(np.uint8) red_mask = ( (((hue <= REGULATORY_RED_LOW_HUE_MAX) | (hue >= REGULATORY_RED_HIGH_HUE_MIN))) & (saturation >= REGULATORY_RED_SAT_MIN) & (value >= REGULATORY_RED_VALUE_MIN) ).astype(np.uint8) green_mask = ( (hue >= REGULATORY_GREEN_HUE_MIN) & (hue <= REGULATORY_GREEN_HUE_MAX) & (saturation >= REGULATORY_COLORED_SAT_MIN) & (value >= REGULATORY_COLORED_VALUE_MIN) ).astype(np.uint8) blue_mask = ( (hue >= REGULATORY_BLUE_HUE_MIN) & (hue <= REGULATORY_BLUE_HUE_MAX) & (saturation >= REGULATORY_COLORED_SAT_MIN) & (value >= REGULATORY_COLORED_VALUE_MIN) ).astype(np.uint8) white_ratio = float(white_mask.mean()) dark_ratio = float(dark_mask.mean()) yellow_ratio = float(yellow_mask.mean()) red_ratio = float(red_mask.mean()) green_ratio = float(green_mask.mean()) blue_ratio = float(blue_mask.mean()) if white_ratio < REGULATORY_MIN_WHITE_RATIO or dark_ratio < REGULATORY_MIN_DARK_RATIO: return False if yellow_ratio > REGULATORY_MAX_YELLOW_RATIO and yellow_ratio > white_ratio * 0.45: return False if red_ratio > REGULATORY_MAX_RED_RATIO and red_ratio > white_ratio * 0.35: return False if green_ratio > REGULATORY_MAX_GREEN_RATIO and green_ratio > white_ratio * 0.60: return False if blue_ratio > REGULATORY_MAX_BLUE_RATIO and blue_ratio > white_ratio * 0.60: return False white_binary = (white_mask * 255).astype(np.uint8) contours, _ = cv2.findContours(white_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) min_component_area = crop_area * REGULATORY_MIN_WHITE_COMPONENT_RATIO for contour in contours: area = cv2.contourArea(contour) if area < min_component_area: continue x, y, width, height = cv2.boundingRect(contour) if height < crop_height * REGULATORY_MIN_COMPONENT_HEIGHT_RATIO: continue if width < crop_width * REGULATORY_MIN_COMPONENT_WIDTH_RATIO: continue aspect_ratio = width / max(height, 1) if aspect_ratio < REGULATORY_MIN_ASPECT_RATIO or aspect_ratio > REGULATORY_MAX_ASPECT_RATIO: continue fill_ratio = area / max(width * height, 1) if fill_ratio < REGULATORY_MIN_COMPONENT_FILL: continue return True return False @staticmethod def _softmax(scores): scores = scores.astype(np.float32) scores = scores - np.max(scores) exp_scores = np.exp(scores) denominator = np.sum(exp_scores) if denominator <= 0: return exp_scores return exp_scores / denominator @staticmethod def _normalize_classifier_output(scores): scores = scores.astype(np.float32) if scores.size == 0: return scores # Ultralytics classifier ONNX exports may already emit normalized probabilities. if np.all(scores >= 0.0) and np.all(scores <= 1.0): total = float(np.sum(scores)) if 0.99 <= total <= 1.01: return scores return VisionModelCore._softmax(scores) @staticmethod def _square_resize(image, size=128, color=(114, 114, 114)): image_height, image_width = image.shape[:2] ratio = min(size / max(image_height, 1), size / max(image_width, 1)) resized_width = max(int(round(image_width * ratio)), 1) resized_height = max(int(round(image_height * ratio)), 1) image = cv2.resize(image, (resized_width, resized_height), interpolation=cv2.INTER_LINEAR) canvas = np.full((size, size, image.shape[2]), color, dtype=image.dtype) offset_x = (size - resized_width) // 2 offset_y = (size - resized_height) // 2 canvas[offset_y:offset_y + resized_height, offset_x:offset_x + resized_width] = image return canvas @staticmethod def _crop_by_ratio(image, left_ratio, top_ratio, right_ratio, bottom_ratio): image_height, image_width = image.shape[:2] x1 = max(int(image_width * left_ratio), 0) y1 = max(int(image_height * top_ratio), 0) x2 = min(int(image_width * right_ratio), image_width) y2 = min(int(image_height * bottom_ratio), image_height) if x2 <= x1 or y2 <= y1: return None crop = image[y1:y2, x1:x2] return crop if crop.size > 0 else None def _iter_school_zone_read_crops(self, sign_crop): yielded = set() for left_ratio, top_ratio, right_ratio, bottom_ratio, weight in SCHOOL_ZONE_READ_VARIANTS: crop = self._crop_by_ratio(sign_crop, left_ratio, top_ratio, right_ratio, bottom_ratio) if crop is None: continue key = (crop.shape[1], crop.shape[0], left_ratio, top_ratio, right_ratio, bottom_ratio) if key in yielded: continue yielded.add(key) yield crop, weight def _collect_detector_classifier_proposals_from_region(self, frame_bgr, origin_x, origin_y, full_frame_width, full_frame_height, min_confidence): if self.net is None: return [] region_height, region_width = frame_bgr.shape[:2] detector_shape = (self.detector_input_size, self.detector_input_size) letterboxed, ratio, pad_width, pad_height = self._letterbox(frame_bgr, shape=detector_shape) blob = cv2.dnn.blobFromImage(letterboxed, scalefactor=1 / 255.0, size=detector_shape, swapRB=True, crop=False) self.net.setInput(blob) forward_started_at = time.monotonic() predictions = np.squeeze(self.net.forward()) self.last_detector_forward_count += 1 self.last_detector_forward_duration_s += time.monotonic() - forward_started_at if predictions.ndim != 2: return [] if predictions.shape[0] < predictions.shape[1]: predictions = predictions.T candidates = [] class_scores = predictions[:, 4:] class_ids = np.argmax(class_scores, axis=1) confidences = class_scores[np.arange(len(predictions)), class_ids].astype(np.float64) selected = np.flatnonzero(np.isin(class_ids, tuple(US_DETECTOR_CLASSES)) & ~(confidences < min_confidence)) for index in selected: prediction = predictions[index] class_id = int(class_ids[index]) confidence = float(confidences[index]) center_x, center_y, width, height = prediction[:4] x1 = max(int((center_x - width / 2 - pad_width) / ratio), 0) y1 = max(int((center_y - height / 2 - pad_height) / ratio), 0) x2 = min(int((center_x + width / 2 - pad_width) / ratio), region_width) y2 = min(int((center_y + height / 2 - pad_height) / ratio), region_height) if x2 <= x1 or y2 <= y1: continue x1 += origin_x y1 += origin_y x2 += origin_x y2 += origin_y box_width = x2 - x1 box_height = y2 - y1 if box_width < MODEL_PROPOSAL_MIN_WIDTH or box_height < MODEL_PROPOSAL_MIN_HEIGHT: continue if box_width * box_height > full_frame_width * full_frame_height * MODEL_PROPOSAL_MAX_AREA_RATIO: continue if (x1 + x2) / 2 < full_frame_width * MODEL_PROPOSAL_MIN_X_RATIO: continue if y1 > full_frame_height * MODEL_PROPOSAL_MAX_Y_RATIO: continue candidates.append((confidence, class_id, (x1, y1, x2, y2))) return candidates def _collect_detector_classifier_proposals(self, frame_bgr): if self.net is None: return [] frame_height, frame_width = frame_bgr.shape[:2] candidates = [] if DETECTOR_CLASSIFIER_REGION_MODE in ("full", "full_and_right_roi"): candidates.extend(self._collect_detector_classifier_proposals_from_region( frame_bgr, 0, 0, frame_width, frame_height, US_DETECTOR_MIN_CONFIDENCE, )) # A second pass on a focused right-side ROI materially improves small U.S. sign reads. if DETECTOR_CLASSIFIER_REGION_MODE in ("right_roi", "full_and_right_roi"): bounds = ROI_WINDOWS[-1]["bounds"] minimum = ROI_WINDOWS[-1]["min_confidence"] assert isinstance(bounds, tuple) and isinstance(minimum, (int, float)) left_ratio, top_ratio, right_ratio, bottom_ratio = bounds left = int(frame_width * left_ratio) top = int(frame_height * top_ratio) right = int(frame_width * right_ratio) bottom = int(frame_height * bottom_ratio) roi = frame_bgr[top:bottom, left:right] if roi.size > 0: candidates.extend(self._collect_detector_classifier_proposals_from_region( roi, left, top, frame_width, frame_height, max(float(minimum), US_DETECTOR_MIN_CONFIDENCE), )) return sorted(candidates, reverse=True)[:MODEL_PROPOSAL_MAX_COUNT] def _classify_speed_limit_from_model(self, sign_crop): if self.classifier_net is None or sign_crop.size == 0: return None speed_class_count = len(US_CLASSIFIER_SPEED_VALUES) input_size = self.classifier_input_size padded_crop = self._square_resize(sign_crop, size=input_size) blob = cv2.dnn.blobFromImage(padded_crop, scalefactor=1 / 255.0, size=(input_size, input_size), swapRB=True, crop=False) self.classifier_net.setInput(blob) forward_started_at = time.monotonic() scores = np.array(self.classifier_net.forward()).reshape(-1) self.last_classifier_forward_count += 1 self.last_classifier_forward_duration_s += time.monotonic() - forward_started_at has_reject_class = scores.size == speed_class_count + 1 if scores.size != speed_class_count and not has_reject_class: return None probabilities = self._normalize_classifier_output(scores) speed_probabilities = probabilities[:speed_class_count] class_index = int(np.argmax(speed_probabilities)) confidence = float(speed_probabilities[class_index]) speed_limit = US_CLASSIFIER_SPEED_VALUES[class_index] if has_reject_class and float(probabilities[speed_class_count]) >= max(confidence, US_CLASSIFIER_REJECT_MIN_CONFIDENCE): return None minimum_confidence = ( EXTENDED_CLASSIFIER_MIN_CONFIDENCE if speed_limit in EXTENDED_CLASSIFIER_SPEED_VALUES else US_CLASSIFIER_MIN_CONFIDENCE ) if confidence < minimum_confidence: return None return speed_limit, confidence def _detect_sign_from_detector_classifier(self, frame_bgr): frame_height, frame_width = frame_bgr.shape[:2] best_detection = None best_score = 0.0 for proposal_confidence, class_id, (x1, y1, x2, y2) in self._collect_detector_classifier_proposals(frame_bgr): if class_id == 1: continue box_width = x2 - x1 box_height = y2 - y1 if box_width <= 0 or box_height <= 0: continue is_small_box = box_width < DETECTOR_CLASSIFIER_MIN_ACCEPT_WIDTH or box_height < DETECTOR_CLASSIFIER_MIN_ACCEPT_HEIGHT # Tiny far-away proposals are the main nighttime false-positive source. # Keep a narrow rescue path for right-side high-confidence reads, # then let temporal confirmation decide whether they are real. if is_small_box and ( box_width < DETECTOR_CLASSIFIER_RESCUE_MIN_WIDTH or box_height < DETECTOR_CLASSIFIER_RESCUE_MIN_HEIGHT or x1 < frame_width * DETECTOR_CLASSIFIER_RESCUE_MIN_X_RATIO ): continue proposal_area_ratio = (box_width * box_height) / max(frame_width * frame_height, 1) is_tiny_low_conf_box = ( class_id != 2 and proposal_area_ratio < DETECTOR_CLASSIFIER_TINY_LOW_CONF_AREA_RATIO and proposal_confidence < DETECTOR_CLASSIFIER_TINY_LOW_CONF_MIN_CONFIDENCE ) self._remember_detector_proposal(proposal_confidence, class_id, (x1, y1, x2, y2)) if class_id == 2: school_scores: dict[int, float] = {} competing_scores: dict[int, float] = {} school_best_confidences: dict[int, float] = {} school_support_counts: dict[int, int] = {} for expand_left, expand_top, expand_right, expand_bottom in SCHOOL_ZONE_DIRECT_EXPANSIONS: expanded_x1 = max(int(x1 - box_width * expand_left), 0) expanded_y1 = max(int(y1 - box_height * expand_top), 0) expanded_x2 = min(int(x2 + box_width * expand_right), frame_width) expanded_y2 = min(int(y2 + box_height * expand_bottom), frame_height) sign_crop = frame_bgr[expanded_y1:expanded_y2, expanded_x1:expanded_x2] if sign_crop.size == 0: continue for school_crop, crop_weight in self._iter_school_zone_read_crops(sign_crop): read_result = self._classify_speed_limit_from_model(school_crop) if read_result is None: continue speed_limit_mph, read_confidence = read_result if speed_limit_mph not in SCHOOL_ZONE_SPEED_VALUES: competing_scores[speed_limit_mph] = competing_scores.get(speed_limit_mph, 0.0) + read_confidence * crop_weight continue school_scores[speed_limit_mph] = school_scores.get(speed_limit_mph, 0.0) + read_confidence * crop_weight school_best_confidences[speed_limit_mph] = max(school_best_confidences.get(speed_limit_mph, 0.0), read_confidence) school_support_counts[speed_limit_mph] = school_support_counts.get(speed_limit_mph, 0) + 1 if school_scores: speed_limit_mph = max( school_scores, key=lambda speed: ( school_scores[speed] + max(school_support_counts[speed] - 1, 0) * SCHOOL_ZONE_SUPPORT_BONUS, school_best_confidences[speed], ), ) read_confidence = school_best_confidences[speed_limit_mph] support_count = school_support_counts[speed_limit_mph] if school_scores[speed_limit_mph] > max(competing_scores.values(), default=0.0): if ( (support_count >= SCHOOL_ZONE_MIN_SUPPORT and read_confidence >= SCHOOL_ZONE_MIN_CONFIDENCE) or read_confidence >= SCHOOL_ZONE_SINGLE_READ_CONFIDENCE ): score = min( read_confidence * 0.72 + proposal_confidence * 0.22 + max(support_count - 1, 0) * SCHOOL_ZONE_SUPPORT_BONUS + 0.04, 0.95, ) if score >= SCHOOL_ZONE_SHORT_CIRCUIT_CONFIDENCE: self._remember_detector_proposal( proposal_confidence, class_id, (x1, y1, x2, y2), speed_limit_mph, preferred=True, ) return Detection(speed_limit_mph, score) speed_scores: dict[int, float] = {} speed_best_confidences: dict[int, float] = {} speed_support_counts: dict[int, int] = {} speed_regulatory_support: dict[int, int] = {} speed_trusted_model_support: dict[int, int] = {} speed_model_only_rescue_support: dict[int, int] = {} speed_direct_model_support: dict[int, int] = {} speed_strong_model_support: dict[int, int] = {} expansions = getattr(self, "detector_classifier_expansions", DETECTOR_CLASSIFIER_EXPANSIONS) for expand_left, expand_top, expand_right, expand_bottom, expansion_weight in expansions: expanded_x1 = max(int(x1 - box_width * expand_left), 0) expanded_y1 = max(int(y1 - box_height * expand_top), 0) expanded_x2 = min(int(x2 + box_width * expand_right), frame_width) expanded_y2 = min(int(y2 + box_height * expand_bottom), frame_height) sign_crop = frame_bgr[expanded_y1:expanded_y2, expanded_x1:expanded_x2] if sign_crop.size == 0: continue raw_is_regulatory = self._is_regulatory_speed_sign(sign_crop) is_regulatory = raw_is_regulatory if class_id == 2: is_regulatory = True model_read = self._classify_speed_limit_from_model(sign_crop) trusted_model_read = ( class_id == 0 and model_read is not None and x1 >= frame_width * DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_X_RATIO and box_height <= DETECTOR_CLASSIFIER_TRUSTED_MODEL_MAX_HEIGHT and proposal_area_ratio <= DETECTOR_CLASSIFIER_TRUSTED_MODEL_MAX_AREA_RATIO and proposal_confidence >= DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_PROPOSAL_CONFIDENCE and model_read[1] >= DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE ) strong_model_read = ( class_id == 0 and model_read is not None and not is_small_box and proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE and model_read[1] >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_READ_CONFIDENCE ) strong_model_consensus_read = ( class_id == 0 and model_read is not None and not is_small_box and proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE and model_read[1] >= DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_READ_CONFIDENCE ) model_only_consensus_read = ( class_id == 0 and model_read is not None and not is_small_box and model_read[1] >= DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_CONFIDENCE and (is_regulatory or proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE) ) needs_ocr_confirmation = ( class_id != 2 and (not is_regulatory or is_tiny_low_conf_box) and not trusted_model_read and not strong_model_read ) read_result = model_read if read_result is None: continue if needs_ocr_confirmation and not trusted_model_read and not strong_model_read and not model_only_consensus_read: continue speed_limit_mph, read_confidence = read_result score_is_regulatory = is_regulatory or trusted_model_read or strong_model_read if ( class_id == 2 and proposal_confidence < SCHOOL_ZONE_FALLBACK_MIN_CONFIDENCE and not raw_is_regulatory ): continue score = read_confidence * expansion_weight if score_is_regulatory: score += DETECTOR_CLASSIFIER_REGULATORY_BONUS elif proposal_area_ratio >= DETECTOR_CLASSIFIER_SMALL_BOX_AREA_RATIO: score -= DETECTOR_CLASSIFIER_NON_REGULATORY_PENALTY if class_id == 2: score += SCHOOL_ZONE_SPEED_PRIOR if speed_limit_mph in SCHOOL_ZONE_SPEED_VALUES else -SCHOOL_ZONE_SPEED_PRIOR speed_scores[speed_limit_mph] = speed_scores.get(speed_limit_mph, 0.0) + score speed_best_confidences[speed_limit_mph] = max(speed_best_confidences.get(speed_limit_mph, 0.0), read_confidence) speed_support_counts[speed_limit_mph] = speed_support_counts.get(speed_limit_mph, 0) + 1 if is_regulatory or class_id == 2 or strong_model_read: speed_regulatory_support[speed_limit_mph] = speed_regulatory_support.get(speed_limit_mph, 0) + 1 if trusted_model_read: speed_trusted_model_support[speed_limit_mph] = speed_trusted_model_support.get(speed_limit_mph, 0) + 1 if strong_model_consensus_read: speed_strong_model_support[speed_limit_mph] = speed_strong_model_support.get(speed_limit_mph, 0) + 1 if needs_ocr_confirmation and model_only_consensus_read: speed_model_only_rescue_support[speed_limit_mph] = speed_model_only_rescue_support.get(speed_limit_mph, 0) + 1 elif model_read is not None: speed_direct_model_support[speed_limit_mph] = speed_direct_model_support.get(speed_limit_mph, 0) + 1 if not speed_scores: continue speed_limit_mph = max( speed_scores, key=lambda speed: ( speed_scores[speed] + max(speed_support_counts[speed] - 1, 0) * DETECTOR_CLASSIFIER_SUPPORT_BONUS, speed_best_confidences[speed], ), ) if class_id == 2 and speed_limit_mph not in SCHOOL_ZONE_SPEED_VALUES: continue model_only_rescue_support = speed_model_only_rescue_support.get(speed_limit_mph, 0) if ( speed_direct_model_support.get(speed_limit_mph, 0) < 1 and 0 < model_only_rescue_support < DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_SUPPORT ): continue if ( speed_regulatory_support.get(speed_limit_mph, 0) < 1 and 0 < speed_trusted_model_support.get(speed_limit_mph, 0) < DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_SUPPORT ): continue if class_id != 2 and speed_limit_mph in SCHOOL_ZONE_SPEED_VALUES: competing_speed_limit_mph = max( (speed for speed in speed_scores if speed not in SCHOOL_ZONE_SPEED_VALUES), key=lambda speed: (speed_best_confidences[speed], speed_scores[speed]), default=None, ) if competing_speed_limit_mph is not None: read_confidence = speed_best_confidences[speed_limit_mph] competing_confidence = speed_best_confidences[competing_speed_limit_mph] if ( competing_confidence >= NON_SCHOOL_LOW_SPEED_COMPETING_MIN_CONFIDENCE and competing_confidence >= read_confidence ): speed_limit_mph = competing_speed_limit_mph read_confidence = speed_best_confidences[speed_limit_mph] support_count = speed_support_counts[speed_limit_mph] strong_rescue = ( DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED and speed_strong_model_support.get(speed_limit_mph, 0) >= DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_SUPPORT ) score = min( read_confidence * 0.72 + proposal_confidence * 0.24 + max(support_count - 1, 0) * DETECTOR_CLASSIFIER_SUPPORT_BONUS, 0.95, ) selection_score = score published_score = score if class_id == 2: if speed_limit_mph in SCHOOL_ZONE_SPEED_VALUES: selection_score = min(score + 0.06, 0.95) published_score = selection_score else: selection_score = max(score - 0.06, 0.0) published_score = selection_score elif is_small_box: if ( speed_regulatory_support.get(speed_limit_mph, 0) < 1 and speed_trusted_model_support.get(speed_limit_mph, 0) < DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_SUPPORT ): continue if support_count < DETECTOR_CLASSIFIER_RESCUE_MIN_SUPPORT: continue if read_confidence < DETECTOR_CLASSIFIER_RESCUE_MIN_CONFIDENCE: continue strong_rescue = strong_rescue or ( speed_trusted_model_support.get(speed_limit_mph, 0) >= DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_SUPPORT and proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE and read_confidence >= DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_READ_CONFIDENCE ) rescue_max_score = ( DETECTOR_CLASSIFIER_STRONG_RESCUE_MAX_SCORE if strong_rescue else DETECTOR_CLASSIFIER_RESCUE_MAX_SCORE ) published_score = min(score, rescue_max_score) if speed_trusted_model_support.get(speed_limit_mph, 0) < DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_SUPPORT: selection_score = published_score if selection_score > best_score: best_score = selection_score best_detection = Detection(speed_limit_mph, published_score, strong_rescue) self._remember_detector_proposal( proposal_confidence, class_id, (x1, y1, x2, y2), speed_limit_mph, preferred=True, ) if best_detection is not None and best_detection.confidence >= MODEL_DETECTION_SHORT_CIRCUIT_CONFIDENCE: return best_detection return best_detection def _prune_history(self, now): while self.history and now - self.history[0].created_at > HISTORY_SECONDS: self.history.popleft() def _confirm_detection(self): if not self.history: return None counts = Counter(entry.speed_limit_mph for entry in self.history) candidate_speed_limit, candidate_count = counts.most_common(1)[0] matching_entries = [entry for entry in self.history if entry.speed_limit_mph == candidate_speed_limit] matching_confidences = sorted((entry.confidence for entry in matching_entries), reverse=True) best_confidence = max(entry.confidence for entry in matching_entries) has_strong_consensus = any(entry.strong_consensus for entry in matching_entries) current_speed_limit = self.published_speed_limit_mph current_count = counts.get(current_speed_limit, 0) if current_speed_limit > 0 else 0 if current_speed_limit > 0 and candidate_speed_limit != current_speed_limit: required_count = CHANGE_CONSISTENT_DETECTIONS allow_single_frame_confirmation = ( has_strong_consensus or best_confidence >= CHANGE_SINGLE_READ_MIN_CONFIDENCE ) if current_speed_limit >= 30 and candidate_speed_limit < 30: required_count = LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS allow_single_frame_confirmation = ( best_confidence >= CHANGE_SINGLE_READ_MIN_CONFIDENCE or (has_strong_consensus and LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS) ) if best_confidence < LOW_SPEED_CHANGE_MIN_CONFIDENCE: return None if candidate_count < required_count and not allow_single_frame_confirmation: return None if ( not allow_single_frame_confirmation and matching_confidences[required_count - 1] < CHANGE_REPEAT_MIN_CONFIDENCE ): return None if candidate_count <= current_count: return None return candidate_speed_limit, best_confidence if has_strong_consensus or best_confidence >= STRONG_DETECTION_CONFIDENCE or candidate_count >= CONSISTENT_DETECTIONS: return candidate_speed_limit, best_confidence return None