From 7f2273aabfb1e33b73d9dcb1e9dec8b1d35fe52b Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:53:31 +0000 Subject: [PATCH] Avoid numeric reads that cannot change sign results --- .../starpilot/speed_limits/vision/model.py | 13 +- .../tests/test_vision_final_unusable_crop.py | 119 ++++++++++++++++++ prebuilt.json | 2 +- 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 openpilot/starpilot/speed_limits/vision/tests/test_vision_final_unusable_crop.py diff --git a/openpilot/starpilot/speed_limits/vision/model.py b/openpilot/starpilot/speed_limits/vision/model.py index f1561409cc..61073bcf4c 100644 --- a/openpilot/starpilot/speed_limits/vision/model.py +++ b/openpilot/starpilot/speed_limits/vision/model.py @@ -805,7 +805,8 @@ class VisionModelCore: cached_bytes += crop.nbytes return result - for proposal_confidence, class_id, (x1, y1, x2, y2) in self._collect_detector_classifier_proposals(frame_bgr): + proposals = self._collect_detector_classifier_proposals(frame_bgr) + for proposal_index, (proposal_confidence, class_id, (x1, y1, x2, y2)) in enumerate(proposals): if class_id == 1: continue if class_id == 0: @@ -945,6 +946,16 @@ class VisionModelCore: if face_filter is not None and face_filter.reject_once((proposal_confidence, class_id, (x1, y1, x2, y2))): self.latest_detector_proposal = None return None + if (type(proposals) is list and proposal_index == len(proposals) - 1 and + expansions is DETECTOR_CLASSIFIER_EXPANSIONS and + (expand_left, expand_top, expand_right, expand_bottom, expansion_weight) == expansions[-1] and + getattr(self, 'classifier_net', None) is not None and class_id == 0 and not speed_scores and not raw_is_regulatory and + proposal_confidence < DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE and + not (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)): + break model_read = classify_crop(sign_crop) if not any((expand_left, expand_top, expand_right, expand_bottom)): primary_reject_dominant = bool( diff --git a/openpilot/starpilot/speed_limits/vision/tests/test_vision_final_unusable_crop.py b/openpilot/starpilot/speed_limits/vision/tests/test_vision_final_unusable_crop.py new file mode 100644 index 0000000000..67cd75fee8 --- /dev/null +++ b/openpilot/starpilot/speed_limits/vision/tests/test_vision_final_unusable_crop.py @@ -0,0 +1,119 @@ +import unittest +from types import SimpleNamespace + +import numpy as np + +from openpilot.starpilot.speed_limits.vision import model + + +class TestFinalUnusableCrop(unittest.TestCase): + def run_case(self, *, proposals=None, regulatory=(False, False, False), reads=None, + control=False, custom=False, net=True, primary_reject=False, veto_at=None): + core = object.__new__(model.VisionModelCore) + core.latest_detector_proposal = None + core.temporal_tracking_enabled = True + core.last_classifier_advisory_rejected = False + core.last_classifier_reject_dominant = False + if net: + core.classifier_net = object() + proposals = proposals if proposals is not None else [(.4, 0, (1400, 100, 1490, 237))] + core._collect_detector_classifier_proposals = lambda frame: proposals + if control or custom: + core.detector_classifier_expansions = list(model.DETECTOR_CLASSIFIER_EXPANSIONS) + core._iter_school_zone_read_crops = lambda crop: [(crop, 1.)] + regions = iter(regulatory) + region_count = [0] + + def color(crop): + region_count[0] += 1 + return next(regions) + + core._is_regulatory_speed_sign = color + calls, identity_calls = [], [] + values = iter(reads) if reads is not None else None + + def classify(crop): + index = len(calls) + calls.append(region_count[0]) + result = next(values) if values is not None else (40, .8) + core.last_classifier_advisory_rejected = result is None and not primary_reject + core.last_classifier_reject_dominant = primary_reject and index == 0 + return result + + def reject(candidate): + identity_calls.append(candidate) + return len(identity_calls) == veto_at + + core._classify_speed_limit_from_model = classify + core._speed_face_filter = SimpleNamespace(reject_once=reject) + result = core._detect_sign_from_detector_classifier(np.zeros((1100, 2000, 3), dtype=np.uint8)) + return result, core.latest_detector_proposal, calls, identity_calls + + def test_only_final_unusable_read_is_skipped(self): + actual = self.run_case() + control = self.run_case(control=True) + self.assertEqual(actual[:2], control[:2]) + self.assertEqual(actual[2], [1, 2]) + self.assertEqual(control[2], [1, 2, 3]) + self.assertEqual(actual[3], control[3]) + + def test_eligibility_counterexamples_keep_all_reads(self): + cases = ( + ('strong_boundary', {'proposals': [(.60, 0, (1400, 100, 1490, 237))]}), + ('trusted_shape_boundary', {'proposals': [(.18, 0, (1040, 100, 1120, 155))]}), + ('tiny_trusted', {'proposals': [(.18, 0, (1040, 100, 1060, 130))]}), + ('accumulated_scores', {'regulatory': (True, False, False)}), + ('regulatory', {'regulatory': (True, True, True)}), + ('custom_expansions', {'custom': True}), + ('missing_net', {'net': False}), + ) + for name, kwargs in cases: + with self.subTest(name=name): + actual = self.run_case(**kwargs) + control = self.run_case(control=True, **kwargs) + self.assertEqual(actual[:2], control[:2]) + self.assertEqual(actual[2], [1, 2, 3]) + self.assertEqual(actual[3], control[3]) + + def test_nonfinal_proposal_keeps_its_final_read(self): + proposals = [(.4, 0, (1400, 100, 1490, 237)), (.3, 0, (1550, 150, 1650, 300))] + actual = self.run_case(proposals=proposals, regulatory=(False,) * 6) + control = self.run_case(proposals=proposals, regulatory=(False,) * 6, control=True) + self.assertEqual(actual[:2], control[:2]) + self.assertEqual(actual[2], [1, 2, 3, 4, 5]) + self.assertEqual(control[2], [1, 2, 3, 4, 5, 6]) + + def test_prior_best_and_retry_conflict_are_preserved(self): + proposals = [(.4, 0, (1400, 100, 1490, 237)), (.3, 0, (1550, 150, 1650, 300))] + cases = ( + ((True, True, True, False, False, False), ((40, .5),) * 3 + ((50, .8),) * 3, False, 40), + ((False, True, True, False, False, False), (None, (40, .5), None) + ((50, .8),) * 3, True, None), + ) + for regulatory, reads, primary_reject, expected in cases: + with self.subTest(retry_conflict=primary_reject): + kwargs = {'proposals': proposals, 'regulatory': regulatory, 'reads': reads, 'primary_reject': primary_reject} + actual = self.run_case(**kwargs) + control = self.run_case(control=True, **kwargs) + self.assertEqual(actual[:2], control[:2]) + self.assertEqual(None if actual[0] is None else actual[0].speed_limit_mph, expected) + self.assertEqual(len(actual[2]), 5) + self.assertEqual(len(control[2]), 6) + + def test_school_class_is_unchanged(self): + reads = (None,) * len(model.SCHOOL_ZONE_DIRECT_EXPANSIONS) + ((40, .8),) * 3 + kwargs = {'proposals': [(.4, 2, (1400, 100, 1490, 237))], 'reads': reads} + actual = self.run_case(**kwargs) + control = self.run_case(control=True, **kwargs) + self.assertEqual(actual, control) + self.assertEqual(len(actual[2]), 4) + + def test_identity_veto_runs_before_skip(self): + actual = self.run_case(veto_at=3) + self.assertIsNone(actual[0]) + self.assertIsNone(actual[1]) + self.assertEqual(actual[2], [1, 2]) + self.assertEqual(len(actual[3]), 3) + + +if __name__ == '__main__': + unittest.main() diff --git a/prebuilt.json b/prebuilt.json index ba28ea9236..729a119572 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "7e0769655012151a24d010b6fe2498b7f002d08a1821c5285825fcea7037fbe5", "schema_version": 1} +{"protected_inventory_sha256": "2d361bacbecdd746f4a72467a899523697cc45c88d70a3ccb8500c8ef6e15f63", "schema_version": 1}