Files
StarPilot/openpilot/starpilot/speed_limits/vision/model.py
T

1027 lines
39 KiB
Python

"""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 previous camera, stream or sign-unit setting."""
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 confirmed sign numeral in the configured units until reset or replacement."""
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