diff --git a/openpilot/starpilot/speed_limits/vision/model.py b/openpilot/starpilot/speed_limits/vision/model.py index a390f193a7..0c329c0698 100644 --- a/openpilot/starpilot/speed_limits/vision/model.py +++ b/openpilot/starpilot/speed_limits/vision/model.py @@ -946,6 +946,33 @@ 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 (frame_bgr.dtype == np.uint8 and 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[0] and + class_id == 0 and not speed_scores and not raw_is_regulatory and + getattr(self, 'classifier_net', None) is not None 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)): + from openpilot.starpilot.speed_limits.vision.speed_face import SpeedFaceVerifier, SharedSpeedFaceVerifier + if (type(face_filter) in (SpeedFaceVerifier, SharedSpeedFaceVerifier) and + getattr(face_filter.reject_once, '__func__', None) is SpeedFaceVerifier.reject_once and + face_filter._checked is True and + getattr(self._is_regulatory_speed_sign, '__func__', None) is + VisionModelCore._is_regulatory_speed_sign and + getattr(self._classify_speed_limit_from_model, '__func__', None) is + VisionModelCore._classify_speed_limit_from_model): + for left, top, right, bottom, _ in expansions[1:]: + remaining_crop = frame_bgr[ + max(int(y1 - box_height * top), 0):min(int(y2 + box_height * bottom), frame_height), + max(int(x1 - box_width * left), 0):min(int(x2 + box_width * right), frame_width), + ] + if remaining_crop.size and self._is_regulatory_speed_sign(remaining_crop): + break + else: + break 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 diff --git a/openpilot/starpilot/speed_limits/vision/tests/test_terminal_material_false.py b/openpilot/starpilot/speed_limits/vision/tests/test_terminal_material_false.py new file mode 100644 index 0000000000..9958b7b970 --- /dev/null +++ b/openpilot/starpilot/speed_limits/vision/tests/test_terminal_material_false.py @@ -0,0 +1,173 @@ +"""Synthetic pixels/default methods and fake outputs; no model loading or inference.""" +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import numpy as np + +from openpilot.starpilot.speed_limits.vision import model, speed_face + + +class Reader: + def __init__(self, advisory=False, confidence=.999): + self.calls = 0 + self.advisory = advisory + self.confidence = confidence + + def setInput(self, value): + self.input = value + + def forward(self, names=None): + self.calls += 1 + values = np.zeros((1, 19), np.float32) + values[0, model.US_CLASSIFIER_SPEED_VALUES.index(40)] = self.confidence + values[0, 18] = 1 - self.confidence + if self.advisory: + rejected = np.zeros((1, 19), np.float32) + rejected[0, 18] = .95 + rejected[0, model.US_CLASSIFIER_SPEED_VALUES.index(40)] = .05 + return [rejected, values] + return values + + +class Head: + def __init__(self, logit): + self.logit = logit + self.calls = 0 + + def setInput(self, value): + self.input = value + + def forward(self, names): + assert names == ['speed_face_logit'] + self.calls += 1 + return [np.array([[self.logit]], np.float32)] + + +@unittest.skipIf(model.cv2 is None, 'OpenCV is optional') +class TestTerminalMaterialFalse(unittest.TestCase): + def setup_case(self, *, proposals=None, control=False, face_type=speed_face.SpeedFaceVerifier): + core = object.__new__(model.VisionModelCore) + core.latest_detector_proposal = None + core.temporal_tracking_enabled = True + core.classifier_net = Reader() + core.classifier_input_size = 128 + core.classifier_logits_layer = None + 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 + face = object.__new__(face_type) + face.begin_frame() + face._checked = True + face.net = Head(8) + core._speed_face_filter = face + if control: + core.detector_classifier_expansions = list(model.DETECTOR_CLASSIFIER_EXPANSIONS) + proposals = proposals if proposals is not None else [(.4, 0, (1400, 100, 1490, 237))] + frame = np.zeros((1100, 2000, 3), np.uint8) + return core, face, proposals, frame + + def run_case(self, case): + core, _, proposals, frame = case + with patch.object(core, '_collect_detector_classifier_proposals', return_value=proposals): + result = core._detect_sign_from_detector_classifier(frame) + return result, core.latest_detector_proposal + + def test_terminal_canonical_elides_only_unadmittable_reader_work(self): + actual, control = self.setup_case(), self.setup_case(control=True) + self.assertEqual(self.run_case(actual), self.run_case(control)) + self.assertEqual(actual[0].classifier_net.calls, 0) + self.assertEqual(control[0].classifier_net.calls, 3) + self.assertFalse(actual[0].last_classifier_advisory_rejected) + + def test_first_original_identity_keep_or_veto_is_preserved(self): + for logit in (-8, 8): + for kind in (speed_face.SpeedFaceVerifier, speed_face.SharedSpeedFaceVerifier): + with self.subTest(logit=logit, kind=kind): + case = self.setup_case(face_type=kind) + core, face, proposals, _ = case + face._checked = False + face.net = Head(logit) + face._proposals[proposals[0]] = [(np.zeros((3, 256, 256), np.float32), np.array([80, 80, 100, 110], np.float32), 13)] + self.assertIsNone(self.run_case(case)[0]) + self.assertEqual(face.net.calls, 1) + self.assertEqual(face.last_dense_id, 13) + self.assertEqual(core.classifier_net.calls, 0) + + def test_later_material_true_falls_back_and_keeps_output(self): + actual, control = self.setup_case(), self.setup_case(control=True) + for case in (actual, control): + case[3][220:271, 1430:1480] = 255 + self.assertEqual(self.run_case(actual), self.run_case(control)) + self.assertGreater(actual[0].classifier_net.calls, 0) + + def test_custom_material_and_reader_methods_fall_back(self): + for name in ('_is_regulatory_speed_sign', '_classify_speed_limit_from_model'): + case = self.setup_case() + with patch.object(case[0], name, wraps=getattr(case[0], name)): + self.run_case(case) + self.assertEqual(case[0].classifier_net.calls, 2) + + def test_stateful_advisory_veto_callback_keeps_original_order(self): + case = self.setup_case() + core = case[0] + core.classifier_net = Reader(advisory=True) + core.classifier_logits_layer = 'original' + calls = [] + + def veto(candidate): + calls.append(candidate) + return len(calls) == 2 + + core._speed_face_filter = SimpleNamespace(reject_once=veto, _checked=True) + self.assertIsNone(self.run_case(case)[0]) + self.assertEqual(len(calls), 1) + self.assertEqual(core.classifier_net.calls, 1) + self.assertTrue(core.last_classifier_advisory_rejected) + + def test_nonlast_strong_trusted_and_custom_expansions_keep_reads(self): + variants = [ + {'proposals': [(.4, 0, (1400, 100, 1490, 237)), (.3, 0, (1550, 150, 1650, 300))]}, + {'proposals': [(.6, 0, (1400, 100, 1490, 237))]}, + {'proposals': [(.18, 0, (1040, 100, 1120, 155))]}, + {'control': True}, + ] + for kwargs in variants: + with self.subTest(kwargs=kwargs): + case = self.setup_case(**kwargs) + self.run_case(case) + self.assertGreaterEqual(case[0].classifier_net.calls, 3) + + def test_noncanonical_face_and_unchecked_custom_method_fall_back(self): + class CustomFace(speed_face.SpeedFaceVerifier): + pass + + for kind in (CustomFace, speed_face.SpeedFaceVerifier): + case = self.setup_case(face_type=kind) + if kind is speed_face.SpeedFaceVerifier: + with patch.object(case[1], 'reject_once', return_value=False): + self.run_case(case) + else: + self.run_case(case) + self.assertEqual(case[0].classifier_net.calls, 2) + + case = self.setup_case() + case = (case[0], case[1], case[2], case[3].astype(np.float32)) + self.run_case(case) + self.assertEqual(case[0].classifier_net.calls, 2) + + def test_previous_winner_and_latest_proposal_match_control(self): + proposals = [(.4, 0, (1100, 400, 1190, 537)), (.3, 0, (1400, 100, 1490, 237))] + actual = self.setup_case(proposals=proposals) + control = self.setup_case(proposals=proposals, control=True) + for case in (actual, control): + case[0].classifier_net = Reader(confidence=.6) + case[3][400:537, 1100:1190] = 255 + case[3][430:485, 1130:1160] = 0 + actual_result = self.run_case(actual) + self.assertEqual(actual_result, self.run_case(control)) + self.assertIsNotNone(actual_result[0]) + self.assertEqual(actual_result[0].speed_limit_mph, 40) + self.assertGreater(actual[0].classifier_net.calls, 0) + self.assertLess(actual[0].classifier_net.calls, control[0].classifier_net.calls) diff --git a/prebuilt.json b/prebuilt.json index 3683b3bba7..afc82eaf40 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "14e194326bcf0efef228ba09eaa7695844b0917bf1af867aa5ba510889396221", "schema_version": 1} +{"protected_inventory_sha256": "5cfc0547e6a1bd3cea5de0b6a48723fa8cac005413015faaa547712c468db4ad", "schema_version": 1}