mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
Reject conflicting speed reads from expanded crops
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user