Files
2026-09-28 00:44:41 -04:00

211 lines
7.6 KiB
Python

import sys
import types
from types import SimpleNamespace
# Mock C-extensions and modules not available in x86 dev environment
if "cereal.messaging" not in sys.modules:
try:
import cereal.messaging # noqa: F401
except ImportError:
class DummyMaster:
pass
msg_mod = types.ModuleType("cereal.messaging")
msg_mod.SubMaster = DummyMaster
msg_mod.PubMaster = DummyMaster
sys.modules["cereal.messaging"] = msg_mod
if "openpilot.selfdrive.locationd.calibrationd" not in sys.modules:
try:
import openpilot.selfdrive.locationd.calibrationd
except ImportError:
calib_mod = types.ModuleType("openpilot.selfdrive.locationd.calibrationd")
calib_mod.HEIGHT_INIT = [1.22]
sys.modules["openpilot.selfdrive.locationd.calibrationd"] = calib_mod
if "openpilot.selfdrive.ui.ui_state" not in sys.modules:
try:
import openpilot.selfdrive.ui.ui_state # noqa: F401
except ImportError:
ui_state_mod = types.ModuleType("openpilot.selfdrive.ui.ui_state")
ui_state_mod.ui_state = SimpleNamespace(
sm=SimpleNamespace(valid={}),
status=0,
always_on_lateral_active=False,
is_metric=False,
starpilot_toggles={},
started_frame=0,
)
ui_state_mod.UIStatus = SimpleNamespace(DISENGAGED=0, ENGAGED=1, OVERRIDE=2)
sys.modules["openpilot.selfdrive.ui.ui_state"] = ui_state_mod
import numpy as np
import pytest
from openpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer
@pytest.fixture
def renderer():
r = object.__new__(ModelRenderer)
r._car_space_transform = np.array([
[580.0, -480.0, 0.0],
[400.0, 0.0, -480.0],
[1.0, 0.0, 0.0],
], dtype=np.float32)
r._transform_dirty = False
r._clip_region = SimpleNamespace(x=-500, y=-500, width=3160, height=2080)
r._get_active_leads = list
return r
def test_batched_projection_matches_single_line_projection(renderer):
rng = np.random.default_rng(42)
lines = [
np.column_stack((np.linspace(0, 100, 33), rng.normal(0, 3, 33), rng.normal(0, 0.5, 33))).astype(np.float32)
for _ in range(6)
]
widths = [0.1, 0.15, 0.15, 0.1, 0.05, 0.05]
max_idx = 30
max_distance = 90.0
# Batched call
batched_polys = renderer._map_lines_to_polygons(lines, widths, 0.0, max_idx, max_distance)
# Individual calls via _map_line_to_polygon
individual_polys = [
renderer._map_line_to_polygon(line, w, 0.0, max_idx, max_distance)
for line, w in zip(lines, widths, strict=True)
]
assert len(batched_polys) == len(lines)
for b_poly, i_poly in zip(batched_polys, individual_polys, strict=True):
assert b_poly.dtype == np.float32
assert b_poly.shape == i_poly.shape
np.testing.assert_allclose(b_poly, i_poly, rtol=1e-5, atol=1e-3)
def test_empty_lines_and_zero_lengths(renderer):
lines = [
np.empty((0, 3), dtype=np.float32),
np.array([[10, 1, 0], [20, 2, 0]], dtype=np.float32),
np.empty((0, 3), dtype=np.float32),
]
widths = [0.1, 0.2, 0.1]
polys = renderer._map_lines_to_polygons(lines, widths, 0.0, 10, 100.0)
assert len(polys) == 3
assert polys[0].shape == (0, 2)
assert polys[1].shape[0] > 0
assert polys[2].shape == (0, 2)
def test_lead_vehicle_clipping_parity(renderer):
lead_mock = SimpleNamespace(dRel=30.0, yRel=0.0, status=True)
renderer._get_active_leads = lambda: [("ego", lead_mock)]
rng = np.random.default_rng(123)
lines = [
np.column_stack((np.linspace(0, 80, 50), rng.normal(0, 1, 50), np.zeros(50))).astype(np.float32)
for _ in range(4)
]
widths = [0.1] * 4
batched_polys = renderer._map_lines_to_polygons(lines, widths, 0.0, 45, 80.0, clip_by_lead=True)
unclipped_polys = renderer._map_lines_to_polygons(lines, widths, 0.0, 45, 80.0, clip_by_lead=False)
individual_polys = [
renderer._map_line_to_polygon(l, w, 0.0, 45, 80.0, clip_by_lead=True)
for l, w in zip(lines, widths, strict=True)
]
# Parity: batched output must match individual output
for b_poly, i_poly in zip(batched_polys, individual_polys, strict=True):
assert b_poly.shape == i_poly.shape
np.testing.assert_allclose(b_poly, i_poly, rtol=1e-5, atol=1e-3)
# Functional guarantee: lines MUST be truncated before the lead vehicle at 30.0m - 1.5m
for b_poly, u_poly in zip(batched_polys, unclipped_polys, strict=True):
assert b_poly.shape[0] < u_poly.shape[0]
def test_allow_invert_hill_geometry(renderer):
x = np.linspace(5, 100, 40, dtype=np.float32)
y = np.zeros(40, dtype=np.float32)
z = np.sin(np.linspace(0, np.pi, 40)).astype(np.float32) * 5.0
line = np.column_stack((x, y, z))
poly_no_invert = renderer._map_lines_to_polygons([line], [0.2], 0.0, 39, 100.0, allow_invert=False)[0]
poly_invert = renderer._map_lines_to_polygons([line], [0.2], 0.0, 39, 100.0, allow_invert=True)[0]
individual_no_invert = renderer._map_line_to_polygon(line, 0.2, 0.0, 39, 100.0, allow_invert=False)
# Parity check
assert poly_no_invert.shape == individual_no_invert.shape
np.testing.assert_allclose(poly_no_invert, individual_no_invert, rtol=1e-5, atol=1e-3)
# Functional guarantee: allow_invert=False MUST discard downward reverse-slope points
assert poly_no_invert.shape[0] < poly_invert.shape[0]
def test_clipping_region_bounds_parity(renderer):
renderer._clip_region = SimpleNamespace(x=100, y=100, width=500, height=500)
rng = np.random.default_rng(999)
lines = [
np.column_stack((np.linspace(1, 150, 60), rng.uniform(-10, 10, 60), np.zeros(60))).astype(np.float32)
for _ in range(3)
]
widths = [0.1, 0.2, 0.3]
batched = renderer._map_lines_to_polygons(lines, widths, 0.0, 55, 150.0)
individual = [renderer._map_line_to_polygon(l, w, 0.0, 55, 150.0) for l, w in zip(lines, widths, strict=True)]
for b, i in zip(batched, individual, strict=True):
assert b.shape == i.shape
np.testing.assert_allclose(b, i, rtol=1e-5, atol=1e-3)
def test_radar_update_decoupled_from_model_regeneration(monkeypatch):
from unittest.mock import MagicMock
import pyray as rl
from openpilot.selfdrive.ui.onroad.model_renderer import ModelPoints
r = object.__new__(ModelRenderer)
r._path = ModelPoints()
r._path.raw_points = np.zeros((10, 3), dtype=np.float32)
r._transform_dirty = False
r._started_frame = 0
r._should_render_lead_indicator = lambda rs: True
r._update_model = MagicMock()
r._update_leads = MagicMock()
r._update_adjacent_leads = MagicMock()
r._draw_lane_lines = MagicMock()
r._draw_path = MagicMock()
r._draw_lead_indicator = MagicMock()
r._draw_radar_tracks = MagicMock()
r._update_raw_points = MagicMock()
r._params = SimpleNamespace(get_bool=lambda *args, **kwargs: False)
from openpilot.selfdrive.ui.ui_state import ui_state
class MockSM:
recv_frame = {"liveCalibration": 1, "modelV2": 1}
updated = {"carParams": False, "modelV2": False, "radarState": True}
valid = {"radarState": True, "starpilotRadarState": False}
def __getitem__(self, k):
return SimpleNamespace(openpilotLongitudinalControl=False, experimentalMode=False, leadOne=None, position=None, height=[1.22])
mock_sm = MockSM()
monkeypatch.setattr(ui_state, "sm", mock_sm)
monkeypatch.setattr(ui_state, "started_frame", 0)
# Frame 1: Only radarState updated. _update_model must NOT run, but _update_leads MUST run.
r._render(rl.Rectangle(0, 0, 100, 100))
assert not r._update_model.called, "Model geometry should not reproject on radarState alone"
assert r._update_leads.called, "Lead indicators should update on radarState"
# Frame 2: modelV2 updated. _update_model MUST run.
mock_sm.updated["modelV2"] = True
mock_sm.updated["radarState"] = False
r._update_model.reset_mock()
r._render(rl.Rectangle(0, 0, 100, 100))
assert r._update_model.called, "Model geometry should reproject when modelV2 updates"