Reject conflicting speed reads from expanded crops

This commit is contained in:
firestar5683
2026-11-06 06:03:57 -06:00
parent 97a5e15cc1
commit 4e40bff081
3 changed files with 244 additions and 2 deletions
@@ -299,6 +299,7 @@ class VisionModelCore:
if cv2 is None:
raise RuntimeError("OpenCV DNN is unavailable")
self.is_metric = is_metric
self.last_classifier_reject_dominant = False
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)
if load_classifier else None)
@@ -330,6 +331,7 @@ class VisionModelCore:
"""Discard evidence from a previous camera, stream or sign-unit setting."""
self.history.clear()
self.last_classifier_advisory_rejected = False
self.last_classifier_reject_dominant = False
self.latest_detector_proposal = None
self.published_speed_limit_mph = 0
self.published_confidence = 0.0
@@ -705,6 +707,7 @@ class VisionModelCore:
def _classify_speed_limit_from_model(self, sign_crop):
self.last_classifier_advisory_rejected = False
self.last_classifier_reject_dominant = False
if self.classifier_net is None or sign_crop.size == 0:
return None
@@ -735,6 +738,10 @@ class VisionModelCore:
return None
probabilities = self._normalize_classifier_output(scores)
self.last_classifier_reject_dominant = bool(
has_reject_class and original_probabilities is not None and np.isfinite(probabilities).all() and
int(np.argmax(original_probabilities)) == speed_class_count and int(np.argmax(probabilities)) == speed_class_count
)
speed_probabilities = probabilities[:speed_class_count]
class_index = int(np.argmax(speed_probabilities))
confidence = float(speed_probabilities[class_index])
@@ -758,6 +765,7 @@ class VisionModelCore:
frame_height, frame_width = frame_bgr.shape[:2]
best_detection = None
best_score = 0.0
best_retry_conflict = False
rejected_advisory_boxes: list[tuple[int, int, int, int]] = []
for proposal_confidence, class_id, (x1, y1, x2, y2) in self._collect_detector_classifier_proposals(frame_bgr):
@@ -861,6 +869,8 @@ class VisionModelCore:
speed_model_only_rescue_support: dict[int, int] = {}
speed_direct_model_support: dict[int, int] = {}
speed_strong_model_support: dict[int, int] = {}
expanded_regulatory_speeds: set[int] = set()
primary_reject_dominant = False
expansions = getattr(self, "detector_classifier_expansions", DETECTOR_CLASSIFIER_EXPANSIONS)
for expand_left, expand_top, expand_right, expand_bottom, expansion_weight in expansions:
@@ -878,6 +888,10 @@ class VisionModelCore:
is_regulatory = True
model_read = self._classify_speed_limit_from_model(sign_crop)
if not any((expand_left, expand_top, expand_right, expand_bottom)):
primary_reject_dominant = bool(
not raw_is_regulatory and getattr(self, "last_classifier_reject_dominant", False)
)
if (class_id == 0 and not raw_is_regulatory and model_read is None and
getattr(self, "last_classifier_advisory_rejected", False)):
# A confident advisory veto applies to this object, not another crop of it.
@@ -947,6 +961,8 @@ class VisionModelCore:
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 raw_is_regulatory and any((expand_left, expand_top, expand_right, expand_bottom)):
expanded_regulatory_speeds.add(speed_limit_mph)
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:
@@ -1040,13 +1056,17 @@ class VisionModelCore:
if selection_score > best_score:
best_score = selection_score
best_detection = Detection(speed_limit_mph, published_score, strong_rescue)
best_retry_conflict = (
class_id == 0 and primary_reject_dominant and speed_limit_mph in expanded_regulatory_speeds and
speed_support_counts[speed_limit_mph] < DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_SUPPORT
)
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 None if best_retry_conflict else best_detection
return best_detection
return None if best_retry_conflict else best_detection
def _prune_history(self, now):
@@ -0,0 +1,172 @@
from collections import deque
from pathlib import Path
from typing import Any
import unittest
from unittest.mock import patch
import numpy as np
from openpilot.starpilot.speed_limits.vision import model
from openpilot.starpilot.speed_limits.vision.traffic_signs import TrafficSignModelCore
def probabilities(value=None, confidence=.8):
scores = np.full(19, (1 - confidence) / 18, dtype=np.float32)
scores[18 if value is None else model.US_CLASSIFIER_SPEED_VALUES.index(value)] = confidence
return scores
class FakeReader:
def __init__(self, reads):
self.reads = reads
self.calls = 0
def setInput(self, blob):
self.blob = blob
def getLayerId(self, name):
return 1
def forward(self, names=None):
output, raw = self.reads[self.calls]
self.calls += 1
if isinstance(output, Exception):
raise output
return output.copy() if names is None else [output.copy(), raw.copy()]
def core_fixture(reads, regulatory, proposals=None, cls=model.VisionModelCore):
core: Any = cls.__new__(cls)
core.is_metric = False
core.net = object()
core.classifier_net = FakeReader(reads)
core.classifier_logits_layer = 'original_logits'
core.classifier_input_size = 128
core.last_classifier_forward_count = 0
core.last_classifier_forward_duration_s = 0.
core.last_classifier_advisory_rejected = False
core.last_classifier_reject_dominant = False
core.temporal_tracking_enabled = False
core.history = deque()
core.speed_enabled = True
core.stop_detections = ()
core._is_regulatory_speed_sign = lambda crop: regulatory[core.classifier_net.calls]
core._collect_detector_classifier_proposals = lambda frame: proposals or [(.9, 0, (40, 20, 100, 100))]
core._iter_school_zone_read_crops = lambda crop: []
return core
class RetryConsistencyTests(unittest.TestCase):
def setUp(self):
self.no_model_loads = patch('cv2.dnn.readNetFromONNX', side_effect=AssertionError('Unit fixture must not load models'))
self.no_model_loads.start()
self.addCleanup(self.no_model_loads.stop)
self.frame = np.zeros((200, 200, 3), dtype=np.uint8)
self.reject = probabilities()
self.numeric = probabilities(25, .75)
def pair(self, scores):
return scores, scores
def test_conflicting_single_context_read_stops_at_original_short_circuit(self):
reads = [self.pair(self.reject), self.pair(self.reject), self.pair(self.numeric)]
proposals = [(.9, 0, (40, 20, 100, 100)), (.8, 0, (105, 20, 165, 100))]
core = core_fixture(reads, [False, True, True], proposals)
self.assertIsNone(core.infer(self.frame))
self.assertEqual(core.classifier_net.calls, 3)
self.assertEqual(core.last_classifier_forward_count, 3)
self.assertFalse(core.last_classifier_advisory_rejected)
def test_direct_numeric_evidence_remains_accepted(self):
core = core_fixture([self.pair(self.numeric), self.pair(self.reject), self.pair(self.reject)], [True, True, True])
self.assertEqual(core.infer(self.frame).speed_limit_mph, 25)
self.assertEqual(core.classifier_net.calls, 3)
def test_two_expanded_views_keep_existing_consensus(self):
core = core_fixture([self.pair(self.reject), self.pair(self.numeric), self.pair(self.numeric)], [False, True, True])
self.assertEqual(core.infer(self.frame).speed_limit_mph, 25)
self.assertEqual(core.classifier_net.calls, 3)
def test_raw_and_adjusted_reject_must_both_dominate(self):
core = core_fixture([(self.reject, self.numeric), self.pair(self.reject), self.pair(self.numeric)], [False, True, True])
self.assertEqual(core.infer(self.frame).speed_limit_mph, 25)
def test_regulatory_primary_does_not_trigger_context_conflict(self):
core = core_fixture([self.pair(self.reject), self.pair(self.reject), self.pair(self.numeric)], [True, True, True])
self.assertEqual(core.infer(self.frame).speed_limit_mph, 25)
def test_school_fallback_is_unchanged(self):
core = core_fixture([self.pair(self.reject), self.pair(self.reject), self.pair(self.numeric)],
[False, True, True], [(.9, 2, (40, 20, 100, 100))])
self.assertEqual(core.infer(self.frame).speed_limit_mph, 25)
self.assertEqual(core.classifier_net.calls, 3)
def test_new_winner_does_not_inherit_previous_proposal_conflict(self):
low = probabilities(25, .6001)
high = probabilities(25, .9)
reads = [self.pair(self.reject), self.pair(self.reject), self.pair(low)] + [self.pair(high)] * 3
proposals = [(.9, 0, (40, 20, 100, 100)), (.8, 0, (105, 20, 165, 100))]
core = core_fixture(reads, [False, True, True, True, True, True], proposals)
self.assertEqual(core.infer(self.frame).speed_limit_mph, 25)
self.assertEqual(core.classifier_net.calls, 6)
def test_missing_original_logits_does_not_invent_primary_reject_evidence(self):
core = core_fixture([self.pair(self.reject), self.pair(self.reject), self.pair(self.numeric)], [False, True, True])
core.classifier_logits_layer = None
self.assertEqual(core.infer(self.frame).speed_limit_mph, 25)
def test_empty_read_clears_previous_reject_evidence(self):
core = core_fixture([self.pair(self.reject)], [False])
self.assertIsNone(core._classify_speed_limit_from_model(self.frame))
self.assertTrue(core.last_classifier_reject_dominant)
self.assertIsNone(core._classify_speed_limit_from_model(self.frame[:0]))
self.assertFalse(core.last_classifier_reject_dominant)
def test_disabled_reader_clears_previous_reject_evidence_without_forward(self):
core = core_fixture([], [])
core.last_classifier_reject_dominant = True
core.classifier_net = None
self.assertIsNone(core._classify_speed_limit_from_model(self.frame))
self.assertFalse(core.last_classifier_reject_dominant)
self.assertEqual(core.last_classifier_forward_count, 0)
def test_failed_read_cannot_leave_reject_evidence(self):
core = core_fixture([(RuntimeError('reader failure'), self.reject)], [False])
core.last_classifier_reject_dominant = True
with self.assertRaisesRegex(RuntimeError, 'reader failure'):
core._classify_speed_limit_from_model(self.frame)
self.assertFalse(core.last_classifier_reject_dominant)
def test_invalid_original_logits_cannot_leave_reject_evidence(self):
core = core_fixture([(self.reject, np.zeros(18, dtype=np.float32))], [False])
core.last_classifier_reject_dominant = True
with self.assertRaisesRegex(ValueError, 'Invalid original classifier logits'):
core._classify_speed_limit_from_model(self.frame)
self.assertFalse(core.last_classifier_reject_dominant)
def test_reset_and_stop_only_mode_clear_evidence(self):
core = core_fixture([], [], cls=TrafficSignModelCore)
core.last_classifier_reject_dominant = True
core.reset()
self.assertFalse(core.last_classifier_reject_dominant)
core.last_classifier_reject_dominant = True
core.set_speed_enabled(False)
self.assertFalse(core.last_classifier_reject_dominant)
self.assertIsNone(core.classifier_net)
self.assertIsNone(core.infer(self.frame))
self.assertEqual(core.last_classifier_forward_count, 0)
def test_stop_only_constructor_has_no_numeric_evidence_or_numeric_load(self):
with patch.object(TrafficSignModelCore, '_load_net', return_value=FakeReader([])) as load, \
patch.object(TrafficSignModelCore, '_verified_model_path', return_value=Path('unused')), \
patch.object(TrafficSignModelCore, '_validate_model_abi'):
core = TrafficSignModelCore(is_metric=False, speed_enabled=False)
self.assertFalse(core.last_classifier_reject_dominant)
self.assertIsNone(core.classifier_net)
self.assertEqual(load.call_count, 1)
self.assertEqual(load.call_args.args[0], 'speed_limit_us_detector.onnx')
self.assertEqual(core.last_classifier_forward_count, 0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,50 @@
from unittest.mock import patch
import unittest
import numpy as np
from openpilot.starpilot.speed_limits.vision import model
from openpilot.starpilot.speed_limits.vision.tests.test_retry_consistency import core_fixture, probabilities
class ProposalLifecycleTests(unittest.TestCase):
def setUp(self):
guard = patch('cv2.dnn.readNetFromONNX', side_effect=AssertionError('No ML loads in lifecycle fixtures'))
guard.start()
self.addCleanup(guard.stop)
self.frame = np.zeros((200, 200, 3), dtype=np.uint8)
def contradictory_core(self, repeats):
reject = probabilities()
numeric = probabilities(25, .75)
core = core_fixture([(reject, reject), (reject, reject), (numeric, numeric)] * repeats,
[False, True, True] * repeats)
core.temporal_tracking_enabled = True
core.reset()
return core
def test_rejected_proposal_number_does_not_seed_observation_history(self):
core = self.contradictory_core(2)
for now in (1000., 1000.5):
self.assertIsNone(core.observe(self.frame, now=now))
self.assertEqual(core.latest_detector_proposal.speed_limit_mph, 25)
self.assertEqual(list(core.history), [])
self.assertEqual(core.published_speed_limit_mph, 0)
self.assertEqual(core.last_detection_at, 0.)
self.assertEqual(core.last_published_support_at, 0.)
self.assertEqual(core.classifier_net.calls, 6)
def test_veto_does_not_replace_or_restamp_previous_publication(self):
core = self.contradictory_core(1)
core.published_speed_limit_mph = 40
core.published_confidence = .9
core.published_support_count = 3
core.episode = 7
core.last_detection_at = 999.
core.last_published_support_at = 999.
core.history.append(model.HistoryEntry(40, .9, 999., False))
with patch.object(core, '_confirm_detection', side_effect=AssertionError('Veto must not confirm a new read')):
result = core.observe(self.frame, now=1000.)
self.assertEqual(result, (40, .9, 3, 7))
self.assertEqual(core.latest_detector_proposal.speed_limit_mph, 25)
self.assertEqual(core.last_detection_at, 999.)
self.assertEqual(core.last_published_support_at, 999.)
self.assertEqual(len(core.history), 1)