From 4e40bff0812fe39872eebfce214a6742d206d89c Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:03:57 -0600 Subject: [PATCH] Reject conflicting speed reads from expanded crops --- .../starpilot/speed_limits/vision/model.py | 24 ++- .../vision/tests/test_retry_consistency.py | 172 ++++++++++++++++++ .../vision/tests/test_retry_observation.py | 50 +++++ 3 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 openpilot/starpilot/speed_limits/vision/tests/test_retry_consistency.py create mode 100644 openpilot/starpilot/speed_limits/vision/tests/test_retry_observation.py diff --git a/openpilot/starpilot/speed_limits/vision/model.py b/openpilot/starpilot/speed_limits/vision/model.py index e525f89780..e67644322d 100644 --- a/openpilot/starpilot/speed_limits/vision/model.py +++ b/openpilot/starpilot/speed_limits/vision/model.py @@ -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): diff --git a/openpilot/starpilot/speed_limits/vision/tests/test_retry_consistency.py b/openpilot/starpilot/speed_limits/vision/tests/test_retry_consistency.py new file mode 100644 index 0000000000..79de484cef --- /dev/null +++ b/openpilot/starpilot/speed_limits/vision/tests/test_retry_consistency.py @@ -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() diff --git a/openpilot/starpilot/speed_limits/vision/tests/test_retry_observation.py b/openpilot/starpilot/speed_limits/vision/tests/test_retry_observation.py new file mode 100644 index 0000000000..3dc869e4f4 --- /dev/null +++ b/openpilot/starpilot/speed_limits/vision/tests/test_retry_observation.py @@ -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)