From cdc6b3bd689eedefb5d435e3d20033e84ddf85ba Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:49:27 -0500 Subject: [PATCH] Reduce onroad UI work and move parameter refresh off render thread --- selfdrive/ui/lib/ui_param_cache.py | 112 +++++- selfdrive/ui/mici/onroad/model_renderer.py | 69 +--- .../ui/onroad/starpilot/navigation_card.py | 48 ++- .../ui/tests/test_mici_model_renderer.py | 103 ++++++ selfdrive/ui/tests/test_navigation_card.py | 176 +++++++++ selfdrive/ui/tests/test_ui_param_cache.py | 334 ++++++++++++++---- selfdrive/ui/ui.py | 5 +- system/ui/lib/application.py | 36 +- system/ui/lib/shader_polygon.py | 14 +- system/ui/lib/tests/test_application.py | 69 +++- .../ui/lib/tests/test_scroller_interaction.py | 3 +- system/ui/lib/tests/test_shader_polygon.py | 61 +++- system/ui/tests/test_nav_widget.py | 128 +++++++ system/ui/widgets/__init__.py | 3 + system/ui/widgets/nav_widget.py | 8 + 15 files changed, 1010 insertions(+), 159 deletions(-) create mode 100644 selfdrive/ui/tests/test_mici_model_renderer.py create mode 100644 selfdrive/ui/tests/test_navigation_card.py create mode 100644 system/ui/tests/test_nav_widget.py diff --git a/selfdrive/ui/lib/ui_param_cache.py b/selfdrive/ui/lib/ui_param_cache.py index 0e2ab2e924..80c1945639 100644 --- a/selfdrive/ui/lib/ui_param_cache.py +++ b/selfdrive/ui/lib/ui_param_cache.py @@ -2,18 +2,29 @@ Parameter reads are file-backed. The raylib UIs ask for the same values from multiple widgets during a frame, so a short cache avoids repeated open/read/ -close cycles without making settings changes sticky: every write invalidates -the affected key immediately and the short TTL bounds visibility of writes -from other processes. +close cycles. Writes invalidate immediately; opt-in background refresh keeps +expired reads off the render thread while retaining the latest cached value. """ from __future__ import annotations +import os +import queue +import threading import time from collections.abc import Callable -from typing import Any +from typing import Any, NamedTuple from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.system.hardware import PC + + +class _RefreshRequest(NamedTuple): + cache_key: tuple[Any, ...] + cached: tuple[float, Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] class UIParamCache: @@ -23,9 +34,83 @@ class UIParamCache: self._ttl = max(0.0, ttl) self._clock = clock self._cache: dict[tuple[Any, ...], tuple[float, Any]] = {} + self._lock = threading.Lock() + self._worker: threading.Thread | None = None + self._refresh_queue: queue.Queue[_RefreshRequest | None] = queue.Queue() + self._pending: set[tuple[Any, ...]] = set() + self._stop_event = threading.Event() + + def start(self) -> None: + with self._lock: + if self._worker is not None: + return + self._refresh_queue = queue.Queue() + self._pending = set() + self._stop_event = threading.Event() + self._worker = threading.Thread(target=self._refresh_worker, name="ui-param-cache", daemon=True, + args=(self._refresh_queue, self._pending, self._stop_event)) + self._worker.start() + + def stop(self, timeout: float = 1.0) -> None: + with self._lock: + worker = self._worker + if worker is None: + return + self._worker = None + self._stop_event.set() + self._refresh_queue.put(None) + worker.join(timeout) + + def _refresh_worker(self, requests: queue.Queue[_RefreshRequest | None], pending: set[tuple[Any, ...]], + stop_event: threading.Event) -> None: + if not PC: + try: + os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0)) + except OSError: + cloudlog.exception("Unable to lower UI parameter worker priority") + with self._lock: + if self._stop_event is stop_event: + self._worker = None + return + + while True: + request = requests.get() + try: + if request is None: + return + with self._lock: + current = not stop_event.is_set() and self._cache.get(request.cache_key) is request.cached + if not current: + continue + + method, key = request.cache_key[:2] + try: + value = getattr(self._params, method)(key, *request.args, **request.kwargs) + except Exception: + value = request.cached[1] + refreshed_at = self._clock() + with self._lock: + if not stop_event.is_set() and self._cache.get(request.cache_key) is request.cached: + self._cache[request.cache_key] = (refreshed_at, value) + finally: + if request is not None: + with self._lock: + pending.discard(request.cache_key) + requests.task_done() + + def _queue_refresh(self, cache_key: tuple[Any, ...], cached: tuple[float, Any], + args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + if cache_key in self._pending: + return + with self._lock: + if self._worker is not None and cache_key not in self._pending and self._cache.get(cache_key) is cached: + self._pending.add(cache_key) + self._refresh_queue.put(_RefreshRequest(cache_key, cached, args, kwargs)) @staticmethod def _cache_key(method: str, key: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any, ...]: + if not args and not kwargs: + return method, key # Params arguments are primitive values in UI call sites. repr keeps this # robust for an occasional list/dict default without requiring hashability. return (method, key, repr(args), repr(sorted(kwargs.items()))) @@ -37,8 +122,14 @@ class UIParamCache: if cached is not None and now - cached[0] < self._ttl: return cached[1] + blocking = kwargs.get("block", args[0] if args else False) + if cached is not None and self._worker is not None and self._ttl > 0 and not blocking: + self._queue_refresh(cache_key, cached, args, kwargs) + return cached[1] + value = getattr(self._params, method)(key, *args, **kwargs) - self._cache[cache_key] = (now, value) + with self._lock: + self._cache[cache_key] = (now, value) return value def get(self, key: str, *args: Any, **kwargs: Any) -> Any: @@ -54,11 +145,12 @@ class UIParamCache: return self._read("get_float", key, *args, **kwargs) def invalidate(self, key: str | None = None) -> None: - if key is None: - self._cache.clear() - return - self._cache = {cache_key: value for cache_key, value in self._cache.items() - if cache_key[1] != key} + with self._lock: + if key is None: + self._cache.clear() + return + self._cache = {cache_key: value for cache_key, value in self._cache.items() + if cache_key[1] != key} def put(self, key: str, value: Any, *args: Any, **kwargs: Any) -> None: self._params.put(key, value, *args, **kwargs) diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py index adf8b866d7..abeb6d9aeb 100644 --- a/selfdrive/ui/mici/onroad/model_renderer.py +++ b/selfdrive/ui/mici/onroad/model_renderer.py @@ -108,8 +108,10 @@ class ModelRenderer(Widget): self._longitudinal_control = cp.openpilotLongitudinalControl def set_transform(self, transform: np.ndarray): - self._car_space_transform = transform.astype(np.float32) - self._transform_dirty = True + transform = transform.astype(np.float32) + if not np.array_equal(transform, self._car_space_transform): + self._car_space_transform = transform + self._transform_dirty = True def _render(self, rect: rl.Rectangle): sm = ui_state.sm @@ -578,66 +580,31 @@ class ModelRenderer(Widget): if line.shape[0] == 0: return np.empty((0, 2), dtype=np.float32) - # Slice points and filter non-negative x-coordinates points = line[:max_idx + 1] - points = points[points[:, 0] >= 0] - if points.shape[0] == 0: + n = len(points) + if n == 0: return np.empty((0, 2), dtype=np.float32) - N = points.shape[0] - # Generate left and right 3D points in one array using broadcasting offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32) - points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3 - points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3 + points_3d = points[None, :, :] + offsets[:, None, :] + proj = (self._car_space_transform @ points_3d.reshape(2 * n, 3).T).reshape(3, 2, n) - # Transform all points to projected space in one operation - proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N) - proj = proj.reshape(3, 2, N) - left_proj = proj[:, 0, :] - right_proj = proj[:, 1, :] + valid = (points[:, 0] >= 0) & (np.abs(proj[2, 0]) >= 1e-6) & (np.abs(proj[2, 1]) >= 1e-6) + np.divide(proj[:2], proj[2], out=proj[:2], where=valid) - # Filter points where z is sufficiently large - valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6) - if not np.any(valid_proj): - return np.empty((0, 2), dtype=np.float32) - - # Compute screen coordinates - left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :] - right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :] - - # Define clip region bounds clip = self._clip_region x_min, x_max = clip.x, clip.x + clip.width y_min, y_max = clip.y, clip.y + clip.height + for side in (0, 1): + valid &= ((proj[0, side] >= x_min) & (proj[0, side] <= x_max) & + (proj[1, side] >= y_min) & (proj[1, side] <= y_max)) - # Filter points within clip region - left_in_clip = ( - (left_screen[0] >= x_min) & (left_screen[0] <= x_max) & - (left_screen[1] >= y_min) & (left_screen[1] <= y_max) - ) - right_in_clip = ( - (right_screen[0] >= x_min) & (right_screen[0] <= x_max) & - (right_screen[1] >= y_min) & (right_screen[1] <= y_max) - ) - both_in_clip = left_in_clip & right_in_clip + screen = proj[:2, :, valid] + if not allow_invert: + keep = screen[1, 0] == np.minimum.accumulate(screen[1, 0]) + screen = screen[:, :, keep] - if not np.any(both_in_clip): - return np.empty((0, 2), dtype=np.float32) - - # Select valid and clipped points - left_screen = left_screen[:, both_in_clip] - right_screen = right_screen[:, both_in_clip] - - # Handle Y-coordinate inversion on hills - if not allow_invert and left_screen.shape[1] > 1: - y = left_screen[1, :] # y-coordinates - keep = y == np.minimum.accumulate(y) - if not np.any(keep): - return np.empty((0, 2), dtype=np.float32) - left_screen = left_screen[:, keep] - right_screen = right_screen[:, keep] - - return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32) + return np.concatenate((screen[:, 0].T, screen[:, 1, ::-1].T)).astype(np.float32, copy=False) @staticmethod def _hsla_to_color(h, s, l, a): diff --git a/selfdrive/ui/onroad/starpilot/navigation_card.py b/selfdrive/ui/onroad/starpilot/navigation_card.py index 23be70e1c5..e22e6c4f8d 100644 --- a/selfdrive/ui/onroad/starpilot/navigation_card.py +++ b/selfdrive/ui/onroad/starpilot/navigation_card.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import math +import time from pathlib import Path import pyray as rl @@ -15,6 +16,7 @@ from openpilot.system.ui.widgets import Widget ASSETS_PATH = Path(__file__).resolve().parents[4] / "starpilot" / "assets" / "navigation" FALLBACK_ICON = "direction_turn_straight.png" NAV_CANCEL_HOLD_SECONDS = 0.65 +NAV_STATE_UPDATE_INTERVAL = 0.1 def _format_distance(distance_m: float, is_metric: bool) -> str: @@ -62,6 +64,7 @@ class NavigationCardRenderer(Widget): self._font_bold = gui_app.font(FontWeight.BOLD) self._font_medium = gui_app.font(FontWeight.MEDIUM) self._icons: dict[str, rl.Texture2D] = {} + self._icon_filenames: dict[tuple[str, str], str] = {} self._layout_variant = layout_variant self._enabled = False @@ -80,6 +83,8 @@ class NavigationCardRenderer(Widget): self._interactive_rect = rl.Rectangle(0, 0, 0, 0) self._click_delay = 0.15 self._press_started_at: float | None = None + self._last_state_update = -math.inf + self._state_context: tuple[object, bool] | None = None @property def _hit_rect(self) -> rl.Rectangle: @@ -97,7 +102,8 @@ class NavigationCardRenderer(Widget): except UnknownKeyName: self._collapsed_param_supported = False self._collapsed_fallback = new_state - self._collapsed = new_state + self._collapsed = new_state + self._last_state_update = -math.inf def _cancel_navigation(self) -> None: params = ui_state.ui_params @@ -120,6 +126,9 @@ class NavigationCardRenderer(Widget): self._collapsed_fallback = False self._collapsed = False self._valid = False + self._interactive_rect = rl.Rectangle(0, 0, 0, 0) + self._last_state_update = -math.inf + self._state_context = None def _handle_mouse_press(self, mouse_pos) -> None: self._press_started_at = rl.get_time() @@ -140,20 +149,24 @@ class NavigationCardRenderer(Widget): self._toggle_collapsed() def _icon_filename(self, maneuver_type: str, modifier: str) -> str: + key = (maneuver_type, modifier) + if key in self._icon_filenames: + return self._icon_filenames[key] + normalized_type = _normalize_maneuver_type(maneuver_type) if modifier == "uturn": - return "direction_uturn.png" + candidate = "direction_uturn.png" + else: + suffix = _modifier_suffix(modifier) + candidate = f"direction_{normalized_type}.png" if suffix == "" else f"direction_{normalized_type}_{suffix}.png" - suffix = _modifier_suffix(modifier) - candidate = f"direction_{normalized_type}.png" if suffix == "" else f"direction_{normalized_type}_{suffix}.png" - return candidate if (ASSETS_PATH / candidate).exists() else FALLBACK_ICON + self._icon_filenames[key] = candidate if (ASSETS_PATH / candidate).exists() else FALLBACK_ICON + return self._icon_filenames[key] def _get_icon(self, maneuver_type: str, modifier: str): icon_name = self._icon_filename(maneuver_type, modifier) if icon_name not in self._icons: icon_path = ASSETS_PATH / icon_name - if not icon_path.exists(): - icon_path = ASSETS_PATH / FALLBACK_ICON image = rl.load_image(str(icon_path)) texture = rl.load_texture_from_image(image) rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) @@ -165,15 +178,23 @@ class NavigationCardRenderer(Widget): def _update_state(self) -> None: params = ui_state.ui_params self._enabled = params.get_bool("NavigationUI") + destination = params.get("NavDestination") if self._enabled else None + if not destination: + self._valid = False + self._interactive_rect = rl.Rectangle(0, 0, 0, 0) + self._state_context = None + return + + now = time.monotonic() + context = (destination, ui_state.is_metric) + if context == self._state_context and now - self._last_state_update < NAV_STATE_UPDATE_INTERVAL: + return + + self._last_state_update = now + self._state_context = context self._valid = False self._interactive_rect = rl.Rectangle(0, 0, 0, 0) - if not self._enabled: - return - - if not (params.get("NavDestination") or ""): - return - raw_state = ui_state.params_memory.get("NavInstructionState") or {} if isinstance(raw_state, str): try: @@ -513,7 +534,6 @@ class NavigationCardRenderer(Widget): ) def _render(self, rect: rl.Rectangle) -> None: - self._update_state() if not self._valid: return if self._collapsed: diff --git a/selfdrive/ui/tests/test_mici_model_renderer.py b/selfdrive/ui/tests/test_mici_model_renderer.py new file mode 100644 index 0000000000..c08a4375aa --- /dev/null +++ b/selfdrive/ui/tests/test_mici_model_renderer.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace + +import numpy as np +import pytest + +from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer + + +@pytest.fixture +def renderer(): + renderer = object.__new__(ModelRenderer) + renderer._car_space_transform = np.array([[580, -480, 0], [400, 0, -480], [1, 0, 0]], dtype=np.float32) + renderer._transform_dirty = False + renderer._clip_region = SimpleNamespace(x=-500, y=-500, width=2160, height=1800) + return renderer + + +def reference_polygon(renderer, line, y_off, z_off, max_idx, allow_invert): + left, right = [], [] + clip = renderer._clip_region + previous_y = float('inf') + for point in line[:max_idx + 1]: + if not point[0] >= 0: + continue + edges = [renderer._car_space_transform @ (point + np.array([0, offset, z_off], dtype=np.float32)) + for offset in (-y_off, y_off)] + if not all(abs(edge[2]) >= 1e-6 for edge in edges): + continue + edges = [edge[:2] / edge[2] for edge in edges] + if not all(clip.x <= edge[0] <= clip.x + clip.width and clip.y <= edge[1] <= clip.y + clip.height for edge in edges): + continue + if not allow_invert and edges[0][1] > previous_y: + continue + previous_y = edges[0][1] + left.append(edges[0]) + right.append(edges[1]) + return np.array(left + right[::-1], dtype=np.float32).reshape(-1, 2) + + +@pytest.mark.parametrize('allow_invert', [False, True]) +@pytest.mark.parametrize('length', [0, 1, 33, 100]) +@pytest.mark.parametrize('max_idx', [0, 15, 100]) +@pytest.mark.parametrize('dtype', [np.float32, np.float64]) +def test_projection_preserves_clipping_and_hill_geometry(renderer, allow_invert, length, max_idx, dtype): + rng = np.random.default_rng(42) + line = np.column_stack((np.linspace(-5, 110, length), rng.normal(0, 6, length), rng.normal(0, 1, length))).astype(dtype) + expected = reference_polygon(renderer, line, 0.9, 1.22, max_idx, allow_invert) + actual = renderer._map_line_to_polygon(line, 0.9, 1.22, max_idx, allow_invert) + + assert actual.dtype == np.float32 + assert actual.flags.c_contiguous + np.testing.assert_allclose(actual, expected, rtol=1e-5, atol=1e-3) + + +@pytest.mark.parametrize('allow_invert', [False, True]) +def test_projection_discards_zero_depth_and_out_of_view_points(renderer, allow_invert): + line = np.array([[0, 0, 0], [1e-7, 0, 0], [-5, 0, 0], [1, 100, 0]], dtype=np.float32) + with np.errstate(divide='raise', invalid='raise'): + actual = renderer._map_line_to_polygon(line, 0.9, 1.22, len(line), allow_invert) + assert actual.shape == (0, 2) + + +def test_projection_requires_both_polygon_edges_inside_clip(renderer): + renderer._clip_region = SimpleNamespace(x=0, y=0, width=100, height=100) + renderer._car_space_transform = np.eye(3, dtype=np.float32) + line = np.array([[10, 10, 1], [20, 99, 1], [30, 15, 1]], dtype=np.float32) + + actual = renderer._map_line_to_polygon(line, 2, 0, len(line)) + + np.testing.assert_array_equal(actual, [[10, 8], [30, 13], [30, 17], [10, 12]]) + + +def test_projection_removes_hill_inversions_after_clipping(renderer): + renderer._car_space_transform = np.eye(3, dtype=np.float32) + line = np.array([[10, 50, 1], [20, 40, 1], [30, 45, 1], [40, 30, 1], [50, 30, 1]], dtype=np.float32) + + actual = renderer._map_line_to_polygon(line, 2, 0, len(line), allow_invert=False) + + np.testing.assert_array_equal(actual[:len(actual) // 2], [[10, 48], [20, 38], [40, 28], [50, 28]]) + + +def test_unchanged_transform_keeps_geometry_cached(renderer): + original = renderer._car_space_transform + renderer.set_transform(original.astype(np.float64)) + + assert renderer._car_space_transform is original + assert not renderer._transform_dirty + + renderer._transform_dirty = True + renderer.set_transform(original) + assert renderer._transform_dirty + + +def test_changed_transform_invalidates_geometry_and_copies_input(renderer): + transform = renderer._car_space_transform.astype(np.float64) + transform[0, 0] += 1 + renderer.set_transform(transform) + + assert renderer._transform_dirty + assert renderer._car_space_transform.dtype == np.float32 + np.testing.assert_array_equal(renderer._car_space_transform, transform) + transform[0, 0] += 1 + assert renderer._car_space_transform[0, 0] != transform[0, 0] diff --git a/selfdrive/ui/tests/test_navigation_card.py b/selfdrive/ui/tests/test_navigation_card.py new file mode 100644 index 0000000000..ac1e6e4d36 --- /dev/null +++ b/selfdrive/ui/tests/test_navigation_card.py @@ -0,0 +1,176 @@ +import json +from collections import Counter +from types import SimpleNamespace + +import pyray as rl +import pytest + +from openpilot.common.params import UnknownKeyName +from openpilot.selfdrive.ui.onroad.starpilot import navigation_card + + +class FakeParams: + def __init__(self, values): + self.values = values + self.reads = Counter() + + def get(self, key): + self.reads[key] += 1 + return self.values.get(key) + + def get_bool(self, key): + return bool(self.get(key)) + + def put_bool(self, key, value): + self.values[key] = value + + def remove(self, key): + self.values.pop(key, None) + + +@pytest.fixture +def card_state(monkeypatch): + params = FakeParams({"NavigationUI": True, "NavDestination": {"name": "Home"}}) + nav_state = {"valid": True, "maneuverPrimaryText": "Main Street", "maneuverDistance": 1500} + memory = FakeParams({"NavInstructionState": json.dumps(nav_state), "NavInstructionCollapsed": False}) + ui = SimpleNamespace(ui_params=params, params_memory=memory, is_metric=True) + clock = [0.0] + monkeypatch.setattr(navigation_card, "ui_state", ui) + monkeypatch.setattr(navigation_card.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(navigation_card.gui_app, "font", lambda _: None) + card = navigation_card.NavigationCardRenderer() + return card, ui, clock + + +def test_navigation_state_reads_are_bounded_at_sixty_fps(card_state): + card, ui, clock = card_state + for frame in range(60): + clock[0] = frame / 60 + card._update_state() + assert card._valid + + assert 8 <= ui.params_memory.reads["NavInstructionState"] <= 10 + assert ui.params_memory.reads["NavInstructionCollapsed"] == ui.params_memory.reads["NavInstructionState"] + + +def test_render_does_not_repeat_state_update(card_state, monkeypatch): + card, ui, _ = card_state + card._update_state() + reads = ui.ui_params.reads.copy() + monkeypatch.setattr(card, "_render_default", lambda _: None) + + card._render(rl.Rectangle(0, 0, 100, 100)) + + assert ui.ui_params.reads == reads + + +def test_metric_and_destination_changes_bypass_refresh_interval(card_state): + card, ui, clock = card_state + card._update_state() + assert card._distance == "1.5 km" + + clock[0] = 0.01 + ui.is_metric = False + card._update_state() + assert card._distance == "0.9 mi" + + ui.ui_params.values["NavDestination"] = {"name": "Work"} + ui.params_memory.values["NavInstructionState"] = {"valid": True, "maneuverPrimaryText": "Second Street"} + card._update_state() + assert card._primary_text == "Second Street" + assert ui.params_memory.reads["NavInstructionState"] == 3 + + +@pytest.mark.parametrize("key, value", [("NavigationUI", False), ("NavDestination", None)]) +def test_disabled_navigation_clears_card_and_reenables_immediately(card_state, key, value): + card, ui, clock = card_state + card._update_state() + card._interactive_rect = rl.Rectangle(0, 0, 100, 100) + previous = ui.ui_params.values[key] + ui.ui_params.values[key] = value + clock[0] = 0.01 + card._update_state() + assert not card._valid + assert card._hit_rect.width == 0 + assert ui.params_memory.reads["NavInstructionState"] == 1 + + ui.ui_params.values[key] = previous + card._update_state() + assert card._valid + assert ui.params_memory.reads["NavInstructionState"] == 2 + + +@pytest.mark.parametrize("state", [None, "{invalid json", {"valid": False}, {"valid": True, "maneuverPrimaryText": ""}]) +def test_invalid_state_clears_card_at_next_refresh(card_state, state): + card, ui, clock = card_state + card._update_state() + card._interactive_rect = rl.Rectangle(0, 0, 100, 100) + ui.params_memory.values["NavInstructionState"] = state + clock[0] = 0.1 + card._update_state() + + assert not card._valid + assert card._hit_rect.width == 0 + + +@pytest.mark.parametrize("supported", [False, True]) +def test_collapse_changes_immediately_and_forces_refresh(card_state, monkeypatch, supported): + card, ui, _ = card_state + card._update_state() + if not supported: + def unsupported(*_): + raise UnknownKeyName("NavInstructionCollapsed") + monkeypatch.setattr(ui.params_memory, "put_bool", unsupported) + + card._toggle_collapsed() + assert card._collapsed + card._update_state() + assert card._collapsed + assert ui.params_memory.reads["NavInstructionState"] == 2 + + card._toggle_collapsed() + assert not card._collapsed + + +def test_cancel_navigation_clears_card_immediately(card_state): + card, ui, _ = card_state + card._update_state() + card._interactive_rect = rl.Rectangle(0, 0, 100, 100) + card._cancel_navigation() + + assert not card._valid + assert card._hit_rect.width == 0 + assert "NavDestination" not in ui.ui_params.values + assert "NavInstructionState" not in ui.params_memory.values + assert "NavInstructionCollapsed" not in ui.params_memory.values + card._update_state() + assert not card._valid + + +@pytest.mark.parametrize("maneuver, modifier, expected", [ + ("turn", "left", "direction_turn_left.png"), + ("turn", "uturn", "direction_uturn.png"), + ("missing", "right", navigation_card.FALLBACK_ICON), +]) +def test_icon_resolution_and_texture_load_are_cached(card_state, monkeypatch, maneuver, modifier, expected): + card, _, _ = card_state + checks = [] + loads = [] + texture = object() + + def exists(path): + checks.append(path.name) + return not path.name.startswith("direction_missing") + + monkeypatch.setattr(navigation_card.Path, "exists", exists) + monkeypatch.setattr(rl, "load_image", lambda path: loads.append(path)) + monkeypatch.setattr(rl, "load_texture_from_image", lambda _: texture) + monkeypatch.setattr(rl, "set_texture_filter", lambda *_: None) + monkeypatch.setattr(rl, "set_texture_wrap", lambda *_: None) + monkeypatch.setattr(rl, "unload_image", lambda _: None) + + for _ in range(60): + assert card._get_icon(maneuver, modifier) is texture + + assert len(checks) == 1 + assert loads == [str(navigation_card.ASSETS_PATH / expected)] diff --git a/selfdrive/ui/tests/test_ui_param_cache.py b/selfdrive/ui/tests/test_ui_param_cache.py index f052642d0a..dcd7e40088 100644 --- a/selfdrive/ui/tests/test_ui_param_cache.py +++ b/selfdrive/ui/tests/test_ui_param_cache.py @@ -1,5 +1,8 @@ -import unittest +import threading +import pytest + +from openpilot.selfdrive.ui.lib import ui_param_cache from openpilot.selfdrive.ui.lib.ui_param_cache import UIParamCache @@ -50,72 +53,273 @@ class FakeParams: self.values.clear() -class TestUIParamCache(unittest.TestCase): - def test_reads_are_shared_until_ttl(self): - now = [0.0] - params = FakeParams() - cached = UIParamCache(params, ttl=0.1, clock=lambda: now[0]) +def test_reads_are_shared_until_ttl(): + now = [0.0] + params = FakeParams() + cached = UIParamCache(params, ttl=0.1, clock=lambda: now[0]) - self.assertFalse(cached.get_bool("enabled")) - self.assertFalse(cached.get_bool("enabled")) - self.assertEqual(params.calls, [("get_bool", "enabled")]) + assert not cached.get_bool("enabled") + assert not cached.get_bool("enabled") + assert params.calls == [("get_bool", "enabled")] - now[0] = 0.11 + now[0] = 0.11 + params.values["enabled"] = True + assert cached.get_bool("enabled") + assert params.calls.count(("get_bool", "enabled")) == 2 + + +def test_writes_invalidate_immediately(): + params = FakeParams() + cached = UIParamCache(params, ttl=10.0) + + assert not cached.get_bool("enabled") + cached.put_bool("enabled", True) + assert cached.get_bool("enabled") + assert params.calls.count(("get_bool", "enabled")) == 2 + + +@pytest.mark.parametrize("background", [False, True]) +def test_zero_ttl_disables_caching(background): + params = FakeParams() + cached = UIParamCache(params, ttl=0.0) + if background: + cached.start() + try: + assert not cached.get_bool("enabled") params.values["enabled"] = True - self.assertTrue(cached.get_bool("enabled")) - self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) - - def test_writes_invalidate_immediately(self): - params = FakeParams() - cached = UIParamCache(params, ttl=10.0) - - self.assertFalse(cached.get_bool("enabled")) - cached.put_bool("enabled", True) - self.assertTrue(cached.get_bool("enabled")) - self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) - - def test_zero_ttl_disables_caching(self): - params = FakeParams() - cached = UIParamCache(params, ttl=0.0) - - self.assertFalse(cached.get_bool("enabled")) - params.values["enabled"] = True - self.assertTrue(cached.get_bool("enabled")) - self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) - - def test_all_write_paths_invalidate(self): - operations = ( - lambda cache: cache.put("enabled", True), - lambda cache: cache.put_bool("enabled", True), - lambda cache: cache.put_int("enabled", 1), - lambda cache: cache.put_float("enabled", 1.0), - lambda cache: cache.put_nonblocking("enabled", True), - lambda cache: cache.put_bool_nonblocking("enabled", True), - ) - for operation_index, operation in enumerate(operations): - with self.subTest(operation_index=operation_index): - params = FakeParams() - cached = UIParamCache(params, ttl=10.0) - self.assertFalse(cached.get_bool("enabled")) - operation(cached) - self.assertTrue(cached.get_bool("enabled")) - self.assertEqual(params.calls.count(("get_bool", "enabled")), 2) - - def test_remove_and_clear_all_invalidate(self): - params = FakeParams() - cached = UIParamCache(params, ttl=10.0) - - self.assertTrue(cached.get_int("count")) - cached.remove("count") - self.assertEqual(cached.get_int("count"), 0) - - self.assertFalse(cached.get_bool("enabled")) - params.values["enabled"] = True - cached.clear_all("flag") - params.values["enabled"] = True - self.assertTrue(cached.get_bool("enabled")) - self.assertIn(("clear_all", "flag"), params.calls) + assert cached.get_bool("enabled") + assert params.calls.count(("get_bool", "enabled")) == 2 + finally: + cached.stop() -if __name__ == "__main__": - unittest.main() +@pytest.mark.parametrize("method", ["put", "put_bool", "put_int", "put_float", "put_nonblocking", "put_bool_nonblocking"]) +def test_all_write_paths_invalidate(method): + params = FakeParams() + cached = UIParamCache(params, ttl=10.0) + assert not cached.get_bool("enabled") + getattr(cached, method)("enabled", True) + assert cached.get_bool("enabled") + assert params.calls.count(("get_bool", "enabled")) == 2 + + +def test_remove_and_clear_all_invalidate(): + params = FakeParams() + cached = UIParamCache(params, ttl=10.0) + + assert cached.get_int("count") == 1 + cached.remove("count") + assert cached.get_int("count") == 0 + + assert not cached.get_bool("enabled") + params.values["enabled"] = True + cached.clear_all("flag") + params.values["enabled"] = True + assert cached.get_bool("enabled") + assert ("clear_all", "flag") in params.calls + + +class ControlledParams(FakeParams): + def __init__(self): + super().__init__() + self.block_next = False + self.fail_next = False + self.read_started = threading.Event() + self.release_read = threading.Event() + self.reader_threads = [] + + def get_int(self, key, *args, **kwargs): + self.reader_threads.append(threading.current_thread()) + value = super().get_int(key, **kwargs) + if self.block_next: + self.block_next = False + self.read_started.set() + if not self.release_read.wait(3): + raise TimeoutError("Blocked parameter test read was not released") + if self.fail_next: + self.fail_next = False + raise OSError("Parameter temporarily unavailable") + return value + + +@pytest.fixture +def async_cache(): + params = ControlledParams() + now = [0.0] + cache = UIParamCache(params, ttl=0.1, clock=lambda: now[0]) + cache.start() + try: + yield params, cache, now + finally: + params.release_read.set() + cache.stop() + + +def test_background_cache_keeps_cold_reads_synchronous(async_cache): + params, cache, _ = async_cache + assert cache.get_int("count") == 1 + assert params.reader_threads == [threading.current_thread()] + + +def test_stale_read_returns_while_refresh_is_blocked_and_deduplicates(async_cache): + params, cache, now = async_cache + assert cache.get_int("count") == 1 + params.values["count"] = 2 + params.block_next = True + now[0] = 0.2 + + assert cache.get_int("count") == 1 + assert params.read_started.wait(1) + for _ in range(100): + assert cache.get_int("count") == 1 + assert len(params.calls) == 2 + assert len(cache._pending) == 1 + + now[0] = 0.5 + params.release_read.set() + cache._refresh_queue.join() + assert cache.get_int("count") == 2 + assert len(params.calls) == 2 + assert not cache._pending + + +@pytest.mark.parametrize("clear_all", [False, True]) +def test_invalidating_an_inflight_refresh_preserves_new_reads(async_cache, clear_all): + params, cache, now = async_cache + assert cache.get_int("count") == 1 + params.block_next = True + now[0] = 0.2 + assert cache.get_int("count") == 1 + assert params.read_started.wait(1) + + if clear_all: + cache.clear_all() + params.values["count"] = 3 + else: + cache.put_int("count", 3) + assert cache.get_int("count") == 3 + params.release_read.set() + cache._refresh_queue.join() + assert cache.get_int("count") == 3 + + +def test_background_errors_keep_value_and_allow_later_refresh(async_cache): + params, cache, now = async_cache + assert cache.get_int("count") == 1 + params.fail_next = True + params.values["count"] = 2 + now[0] = 0.2 + assert cache.get_int("count") == 1 + cache._refresh_queue.join() + assert cache.get_int("count") == 1 + assert cache._worker.is_alive() + assert len(params.calls) == 2 + + now[0] = 0.4 + assert cache.get_int("count") == 1 + cache._refresh_queue.join() + assert cache.get_int("count") == 2 + + +def test_blocking_reads_remain_synchronous(async_cache): + params, cache, now = async_cache + assert cache.get_int("count", block=True) == 1 + params.values["count"] = 2 + now[0] = 0.2 + assert cache.get_int("count", block=True) == 2 + assert params.reader_threads == [threading.current_thread()] * 2 + assert not cache._pending + + +def test_background_cache_does_not_refresh_unused_settings(async_cache): + params, cache, now = async_cache + assert cache.get_bool("enabled") is False + assert cache.get_int("count") == 1 + now[0] = 0.2 + cache.get_int("count") + cache._refresh_queue.join() + assert params.calls.count(("get_bool", "enabled")) == 1 + assert params.calls.count(("get_int", "count")) == 2 + + +def test_stop_releases_worker_and_restores_synchronous_refresh(async_cache): + params, cache, now = async_cache + assert cache.get_int("count") == 1 + worker = cache._worker + cache.start() + assert cache._worker is worker + cache.stop() + assert not worker.is_alive() + params.values["count"] = 2 + now[0] = 0.2 + assert cache.get_int("count") == 2 + assert params.reader_threads == [threading.current_thread()] * 2 + + +def test_timed_out_worker_cannot_publish_after_restart(async_cache): + params, cache, now = async_cache + assert cache.get_int("count") == 1 + params.values["count"] = 2 + params.block_next = True + now[0] = 0.2 + assert cache.get_int("count") == 1 + assert params.read_started.wait(1) + old_worker, old_queue = cache._worker, cache._refresh_queue + cache.stop(timeout=0.01) + assert old_worker.is_alive() + + params.values["count"] = 3 + cache.start() + assert cache.get_int("count") == 1 + cache._refresh_queue.join() + assert cache.get_int("count") == 3 + params.release_read.set() + old_queue.join() + old_worker.join(1) + assert not old_worker.is_alive() + assert cache.get_int("count") == 3 + + +def test_worker_lowers_its_own_realtime_priority(monkeypatch): + calls = [] + configured = threading.Event() + + def set_scheduler(pid, policy, priority): + calls.append((pid, policy, priority, threading.current_thread())) + configured.set() + + monkeypatch.setattr(ui_param_cache, "PC", False) + monkeypatch.setattr(ui_param_cache.os, "SCHED_OTHER", 0, raising=False) + monkeypatch.setattr(ui_param_cache.os, "sched_param", lambda priority: priority, raising=False) + monkeypatch.setattr(ui_param_cache.os, "sched_setscheduler", set_scheduler, raising=False) + cache = UIParamCache(FakeParams()) + cache.start() + try: + assert configured.wait(1) + assert calls == [(0, 0, 0, cache._worker)] + finally: + cache.stop() + + +def test_worker_priority_failure_restores_synchronous_reads(monkeypatch): + def set_scheduler(*_): + raise PermissionError("Unable to change scheduler") + + monkeypatch.setattr(ui_param_cache, "PC", False) + monkeypatch.setattr(ui_param_cache.os, "SCHED_OTHER", 0, raising=False) + monkeypatch.setattr(ui_param_cache.os, "sched_param", lambda priority: priority, raising=False) + monkeypatch.setattr(ui_param_cache.os, "sched_setscheduler", set_scheduler, raising=False) + monkeypatch.setattr(ui_param_cache.cloudlog, "exception", lambda *_: None) + params = FakeParams() + now = [0.0] + cache = UIParamCache(params, clock=lambda: now[0]) + assert cache.get_int("count") == 1 + cache.start() + worker = cache._worker + if worker is not None: + worker.join(1) + assert not worker.is_alive() + params.values["count"] = 2 + now[0] = 0.2 + assert cache.get_int("count") == 2 + assert cache._worker is None diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py index 8a98ae87a4..ed730322ad 100644 --- a/selfdrive/ui/ui.py +++ b/selfdrive/ui/ui.py @@ -23,6 +23,7 @@ def _stall_context() -> dict[str, object]: "ui_state_frame": ui_state.sm.frame, "target_fps": gui_app.target_fps, "active_widget": type(active_widget).__name__ if active_widget is not None else "none", + "frame_timing": gui_app.frame_timing._asdict(), } try: @@ -44,13 +45,14 @@ def _stall_context() -> dict[str, object]: def main(): cores = {5, } - config_realtime_process(0, Priority.UI) + config_realtime_process(0, Priority.CTRL_HIGH) stall_monitor = UIStallMonitor("raylib_ui") stall_monitor.progress("ui.before_init_window") stall_monitor.start() try: + ui_state.ui_params.start() gui_app.init_window("UI") stall_monitor.progress("ui.after_init_window") gui_app.set_progress_hook(stall_monitor.progress) @@ -88,6 +90,7 @@ def main(): stall_monitor.progress("ui.loop_idle") finally: gui_app.set_progress_hook(None) + ui_state.ui_params.stop() stall_monitor.stop() diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 1c13c40155..9a081473e2 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -65,7 +65,7 @@ OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offli def _raylib_target_fps(fps: int) -> int: - return 0 if OFFSCREEN else fps + return 0 if OFFSCREEN or (DEVICE_TYPE == "mici" and not PC) else fps GL_VERSION = """ #version 300 es @@ -195,6 +195,14 @@ class MouseEvent(NamedTuple): t: float +class FrameTiming(NamedTuple): + frame_ms: float = 0.0 + cpu_ms: float = 0.0 + draw_ms: float = 0.0 + update_ms: float = 0.0 + present_ms: float = 0.0 + + class DesktopMouseSample(NamedTuple): pos: MousePos left_pressed: bool @@ -433,7 +441,7 @@ class MouseState: self._append_mouse_event(event) return - left_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) # noqa: TID251 + left_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) left_pressed = ( rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) or # noqa: TID251 (left_down and not self._desktop_left_down) @@ -525,6 +533,7 @@ class GuiApplication: self._last_fps_log_time: float = time.monotonic() self._burn_in_start_time = time.monotonic() self._frame = 0 + self.frame_timing = FrameTiming() self._window_close_requested = False self._nav_stack: list[object] = [] self._nav_stack_ticks: list[Callable[[], None]] = [] @@ -1031,6 +1040,8 @@ class GuiApplication: self._render_profiler.enable() while not (self._window_close_requested or rl.window_should_close()): + frame_start = time.monotonic() + cpu_start = time.thread_time() self._mark_progress("gui_app.loop_start") self._apply_render_mode() if PC: @@ -1087,12 +1098,18 @@ class GuiApplication: # Only render top widgets self._mark_progress("gui_app.before_widget_render") - for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]: + viewport = rl.Rectangle(0, 0, self.width, self.height) + widgets = self._nav_stack[-self._nav_stack_widgets_to_render:] + if len(widgets) > 1 and widgets[-1].covers_background(viewport): + widgets = widgets[-1:] + for widget in widgets: widget.render(rl.Rectangle(0, 0, self.width, self.height)) self._mark_progress("gui_app.after_widget_render") self._mark_progress("gui_app.frame_ready") + draw_end = time.monotonic() yield True + update_end = time.monotonic() if needs_render_transform: rl.rl_pop_matrix() @@ -1136,6 +1153,7 @@ class GuiApplication: self._mark_progress("gui_app.before_end_drawing") rl.end_drawing() self._mark_progress("gui_app.after_end_drawing") + present_end = time.monotonic() self._populate_render_texture_cache() if RECORD: @@ -1145,6 +1163,13 @@ class GuiApplication: self._ffmpeg_queue.put(data) # Async write via background thread rl.unload_image(image) + self.frame_timing = FrameTiming( + (time.monotonic() - frame_start) * 1000, + (time.thread_time() - cpu_start) * 1000, + (draw_end - frame_start) * 1000, + (update_end - draw_end) * 1000, + (present_end - update_end) * 1000, + ) self._monitor_fps() self._frame += 1 self._mark_progress("gui_app.loop_idle") @@ -1282,7 +1307,10 @@ class GuiApplication: if fps < self._target_fps * FPS_DROP_THRESHOLD: current_time = time.monotonic() if current_time - self._last_fps_log_time >= FPS_LOG_INTERVAL: - cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}") + timing = self.frame_timing + cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps} " + + f"(frame={timing.frame_ms:.1f}ms cpu={timing.cpu_ms:.1f}ms " + + f"draw={timing.draw_ms:.1f}ms update={timing.update_ms:.1f}ms present={timing.present_ms:.1f}ms)") self._last_fps_log_time = current_time # Strict mode: terminate UI if FPS drops too much diff --git a/system/ui/lib/shader_polygon.py b/system/ui/lib/shader_polygon.py index 32cf677bf3..ab8b4473bb 100644 --- a/system/ui/lib/shader_polygon.py +++ b/system/ui/lib/shader_polygon.py @@ -186,7 +186,7 @@ def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4) -def triangulate(pts: np.ndarray) -> list[tuple[float, float]]: +def triangulate(pts: np.ndarray) -> np.ndarray: """Only supports simple polygons with two chains (ribbon).""" # interleave points to produce a triangle strip @@ -194,10 +194,10 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]: pts = pts[:-1] half = len(pts) // 2 - tri_strip = np.empty_like(pts) + tri_strip = np.empty(pts.shape, dtype=np.float32) tri_strip[0::2] = pts[:half] tri_strip[1::2] = pts[half:][::-1] - return cast(list, tri_strip.tolist()) + return tri_strip def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, @@ -210,15 +210,15 @@ def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, if len(points) < 3: return - # Ensure (N,2) float32 contiguous array - pts = np.ascontiguousarray(points, dtype=np.float32) + pts = np.asarray(points) assert pts.ndim == 2 and pts.shape[1] == 2, "points must be (N,2)" # Triangulate via interleaving tri_strip = triangulate(pts) + vertices = rl.ffi.from_buffer("Vector2 *", tri_strip) if gradient is None: - rl.draw_triangle_strip(tri_strip, len(tri_strip), color or rl.WHITE) + rl.draw_triangle_strip(vertices, len(tri_strip), color or rl.WHITE) return state = ShaderState.get_instance() @@ -228,7 +228,7 @@ def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, # Draw strip, color here doesn't matter rl.begin_shader_mode(state.shader) - rl.draw_triangle_strip(tri_strip, len(tri_strip), rl.WHITE) + rl.draw_triangle_strip(vertices, len(tri_strip), rl.WHITE) rl.end_shader_mode() diff --git a/system/ui/lib/tests/test_application.py b/system/ui/lib/tests/test_application.py index 511701737e..346b5d8377 100644 --- a/system/ui/lib/tests/test_application.py +++ b/system/ui/lib/tests/test_application.py @@ -1,12 +1,23 @@ from importlib.resources import as_file from types import SimpleNamespace +import pytest + from openpilot.system.ui.lib import application -def test_raylib_target_fps_limits_mici(monkeypatch): +def test_raylib_target_fps_uses_mici_display_refresh(monkeypatch): monkeypatch.setattr(application, "OFFSCREEN", False) monkeypatch.setattr(application, "DEVICE_TYPE", "mici") + monkeypatch.setattr(application, "PC", False) + + assert application._raylib_target_fps(60) == 0 + + +def test_raylib_target_fps_limits_mici_desktop_preview(monkeypatch): + monkeypatch.setattr(application, "OFFSCREEN", False) + monkeypatch.setattr(application, "DEVICE_TYPE", "mici") + monkeypatch.setattr(application, "PC", True) assert application._raylib_target_fps(60) == 60 @@ -25,6 +36,62 @@ def test_raylib_target_fps_disables_limit_for_offscreen(monkeypatch): assert application._raylib_target_fps(60) == 0 +@pytest.mark.parametrize("covered", [False, True]) +def test_render_preserves_visible_layers_and_measures_complete_frame(monkeypatch, covered): + monkeypatch.setattr(application, "PC", False) + monkeypatch.setattr(application, "RECORD", False) + monkeypatch.setattr(application.GuiApplication, "_set_log_callback", lambda _: None) + app = application.GuiApplication(536, 240) + app._scale = 1.0 + app._nav_stack_widgets_to_render = 2 + app._mouse = SimpleNamespace(get_events=list) + app._burn_in_shift = lambda: (0, 0) + app._monitor_fps = lambda: None + app._show_fps = app._show_touches = False + app._grid_size = app._profile_render_frames = 0 + + clock = SimpleNamespace(wall=10.0, cpu=1.0) + monkeypatch.setattr(application.time, "monotonic", lambda: clock.wall) + monkeypatch.setattr(application.time, "thread_time", lambda: clock.cpu) + monkeypatch.setattr(application.rl, "window_should_close", lambda: False) + monkeypatch.setattr(application.rl, "begin_drawing", lambda: None) + monkeypatch.setattr(application.rl, "clear_background", lambda _: None) + rendered = [] + + def draw(name): + rendered.append(name) + clock.wall += 0.004 + clock.cpu += 0.003 + + def present(): + clock.wall += 0.011 + clock.cpu += 0.001 + + def populate_cache(): + clock.wall += 0.003 + clock.cpu += 0.002 + + monkeypatch.setattr(application.rl, "end_drawing", present) + app._populate_render_texture_cache = populate_cache + app._nav_stack = [ + SimpleNamespace(render=lambda _: draw("road")), + SimpleNamespace(covers_background=lambda _: covered, render=lambda _: draw("panel")), + ] + frames = app.render() + assert next(frames) + clock.wall += 0.002 + clock.cpu += 0.001 + app.request_close() + with pytest.raises(StopIteration): + next(frames) + + assert rendered == (["panel"] if covered else ["road", "panel"]) + draws = len(rendered) + assert app.frame_timing == pytest.approx(application.FrameTiming( + draws * 4 + 16, draws * 3 + 4, draws * 4, 2, 11, + )) + + def test_burn_in_shift_transitions_between_positions(monkeypatch): app = object.__new__(application.GuiApplication) app._burn_in_start_time = 100.0 diff --git a/system/ui/lib/tests/test_scroller_interaction.py b/system/ui/lib/tests/test_scroller_interaction.py index 85b96f98bc..432f8863b3 100644 --- a/system/ui/lib/tests/test_scroller_interaction.py +++ b/system/ui/lib/tests/test_scroller_interaction.py @@ -1,5 +1,6 @@ import pyray as rl import pytest +from types import SimpleNamespace from openpilot.system.ui import widgets from openpilot.system.ui.lib import scroll_panel2 @@ -32,7 +33,7 @@ def make_scroller(monkeypatch): monkeypatch.setattr(gui_app, "_show_touches", False) monkeypatch.setattr(gui_app, "_mouse_events", []) monkeypatch.setattr(widgets, "PC", False) - monkeypatch.setattr(widgets.device, "awake", True) + monkeypatch.setattr(widgets, "device", SimpleNamespace(awake=True)) monkeypatch.setattr(scroll_panel2, "TICI", True) def make(items=None, **kwargs): diff --git a/system/ui/lib/tests/test_shader_polygon.py b/system/ui/lib/tests/test_shader_polygon.py index 52ea45f88c..faf6e88dc8 100644 --- a/system/ui/lib/tests/test_shader_polygon.py +++ b/system/ui/lib/tests/test_shader_polygon.py @@ -1,6 +1,10 @@ import numpy as np +import pyray as rl +import pytest +from types import SimpleNamespace -from openpilot.system.ui.lib.shader_polygon import triangulate +from openpilot.system.ui.lib import shader_polygon +from openpilot.system.ui.lib.shader_polygon import Gradient, draw_polygon, triangulate def test_triangulate_interleaves_polygon_chains(): @@ -13,11 +17,11 @@ def test_triangulate_interleaves_polygon_chains(): [10.0, 100.0], ], dtype=np.float32) - assert triangulate(points) == [ + np.testing.assert_array_equal(triangulate(points), [ [1.0, 10.0], [10.0, 100.0], [2.0, 20.0], [20.0, 200.0], [3.0, 30.0], [30.0, 300.0], - ] + ]) def test_triangulate_drops_unpaired_last_point(): @@ -29,7 +33,54 @@ def test_triangulate_drops_unpaired_last_point(): [99.0, 99.0], ], dtype=np.float32) - assert triangulate(points) == [ + np.testing.assert_array_equal(triangulate(points), [ [1.0, 10.0], [10.0, 100.0], [2.0, 20.0], [20.0, 200.0], - ] + ]) + + +@pytest.mark.parametrize("points", [ + np.arange(132, dtype=np.float32).reshape(-1, 2), + np.arange(132, dtype=np.float64).reshape(-1, 2), + np.arange(264, dtype=np.float64).reshape(-1, 2)[::2, ::-1], + np.asfortranarray(np.arange(132, dtype=np.float32).reshape(-1, 2)), +]) +def test_triangulate_preserves_coordinates_in_contiguous_float_buffer(points): + original = points.copy() + points.flags.writeable = False + strip = triangulate(points) + expected = [point for pair in zip(points[:len(points) // 2], points[len(points) // 2:][::-1], strict=True) for point in pair] + + assert strip.dtype == np.float32 + assert strip.flags.c_contiguous + np.testing.assert_array_equal(strip, np.asarray(expected, dtype=np.float32)) + np.testing.assert_array_equal(points, original) + + +@pytest.mark.parametrize("use_gradient", [False, True]) +@pytest.mark.parametrize("point_count", [4, 5, 66]) +def test_draw_polygon_passes_native_vertices(monkeypatch, use_gradient, point_count): + points = np.arange(point_count * 4, dtype=np.float64).reshape(-1, 2)[::2] + rect = rl.Rectangle(0, 0, 100, 100) + color = rl.Color(10, 20, 30, 40) + gradient = Gradient((0, 0), (1, 1), [color, rl.Color(*rl.WHITE)], [0, 1]) if use_gradient else None + calls = [] + state = SimpleNamespace(initialize=lambda: calls.append("initialize"), shader="shader") + + def draw_strip(vertices, count, fill): + assert rl.ffi.typeof(vertices) == rl.ffi.typeof("Vector2 *") + coordinates = [[vertices[i].x, vertices[i].y] for i in range(count)] + np.testing.assert_array_equal(coordinates, triangulate(points)) + assert fill == (rl.WHITE if use_gradient else color) + calls.append("draw") + + monkeypatch.setattr(shader_polygon.ShaderState, "get_instance", lambda: state) + monkeypatch.setattr(shader_polygon, "_configure_shader_color", lambda *args: calls.append(("configure", args))) + monkeypatch.setattr(rl, "begin_shader_mode", lambda shader: calls.append(("begin", shader))) + monkeypatch.setattr(rl, "end_shader_mode", lambda: calls.append("end")) + monkeypatch.setattr(rl, "draw_triangle_strip", draw_strip) + + draw_polygon(rect, points, gradient=gradient, color=None if use_gradient else color) + + assert calls == (["initialize", ("configure", (state, None, gradient, rect)), ("begin", "shader"), "draw", "end"] + if use_gradient else ["draw"]) diff --git a/system/ui/tests/test_nav_widget.py b/system/ui/tests/test_nav_widget.py new file mode 100644 index 0000000000..9ff232194e --- /dev/null +++ b/system/ui/tests/test_nav_widget.py @@ -0,0 +1,128 @@ +import pyray as rl +import pytest + +from openpilot.system.ui.lib.application import MouseEvent, MousePos, gui_app +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget + + +class NavScreen(NavWidget): + def _render(self, _): + pass + + +class TransparentScreen(Widget): + def _render(self, _): + pass + + +@pytest.fixture +def viewport(): + return rl.Rectangle(0, 0, gui_app.width, gui_app.height) + + +@pytest.fixture +def screen(monkeypatch, viewport): + monkeypatch.setattr(rl, "draw_rectangle_rec", lambda *_: None) + monkeypatch.setattr(rl, "get_time", lambda: 10.0) + monkeypatch.setattr(gui_app, "_show_touches", False) + monkeypatch.setattr(gui_app, "_mouse_events", []) + screen = NavScreen() + screen.set_rect(viewport) + monkeypatch.setattr(screen._nav_bar, "render", lambda: None) + return screen + + +def touch(monkeypatch, y, *, pressed=False, released=False): + event = MouseEvent(MousePos(20, y), 0, pressed, released, not released, 10.0) + monkeypatch.setattr(gui_app, "_mouse_events", [event]) + monkeypatch.setattr(gui_app, "_last_mouse_event", event) + + +def test_only_settled_opaque_navigation_covers_background(screen, viewport): + assert screen.covers_background(viewport) + assert not TransparentScreen().covers_background(viewport) + + class TransparentNav(NavScreen): + def _layout(self): + pass + + transparent = TransparentNav() + transparent.set_rect(viewport) + assert not transparent.covers_background(viewport) + screen.set_visible(False) + assert not screen.covers_background(viewport) + + +@pytest.mark.parametrize("x,y,width,height", [(1, 0, 1, 1), (0, 1, 1, 1), (0, 0, 0.5, 1), (0, 0, 1, 0.5)]) +def test_partial_navigation_does_not_cover_background(screen, viewport, x, y, width, height): + screen.set_rect(rl.Rectangle(x, y, viewport.width * width, viewport.height * height)) + assert not screen.covers_background(viewport) + + +@pytest.mark.parametrize("position,velocity", [(0.1, 0), (-0.1, 0), (0, 0.1), (0, -0.1)]) +def test_bounce_keeps_background_visible(screen, viewport, position, velocity): + screen._y_pos_filter.x = position + screen._y_pos_filter.velocity.x = velocity + assert not screen.covers_background(viewport) + + +def test_show_animation_keeps_background_until_settled(screen, viewport): + shown = [] + screen.set_shown_callback(lambda: shown.append(True)) + screen.show_event() + assert not screen.covers_background(viewport) + for _ in range(300): + screen.render(viewport) + if screen.covers_background(viewport): + break + assert screen.covers_background(viewport) + assert shown == [True] + + +def test_swipe_uncovers_background_before_first_moving_frame(monkeypatch, screen, viewport): + touch(monkeypatch, 20, pressed=True) + assert screen.covers_background(viewport) + screen.render(viewport) + assert screen.rect.y == 0 + + touch(monkeypatch, 120) + assert not screen.covers_background(viewport) + screen.render(viewport) + assert screen.rect.y > 0 + + popped = [] + monkeypatch.setattr(gui_app, "pop_widget", lambda: popped.append(True)) + touch(monkeypatch, 120, released=True) + screen.render(viewport) + monkeypatch.setattr(gui_app, "_mouse_events", []) + for _ in range(300): + assert not screen.covers_background(viewport) + screen.render(viewport) + if popped: + break + assert popped == [True] + + +def test_cancelled_swipe_restores_culling_only_after_settling(monkeypatch, screen, viewport): + touch(monkeypatch, 20, pressed=True) + screen.render(viewport) + touch(monkeypatch, 50) + screen.render(viewport) + touch(monkeypatch, 50, released=True) + screen.render(viewport) + assert not screen.covers_background(viewport) + monkeypatch.setattr(gui_app, "_mouse_events", []) + for _ in range(300): + screen.render(viewport) + if screen.covers_background(viewport): + break + assert screen.covers_background(viewport) + assert screen.rect.y == 0 + + +def test_programmatic_dismiss_uncovers_background_before_first_moving_frame(screen, viewport): + screen.dismiss() + assert not screen.covers_background(viewport) + screen.render(viewport) + assert screen.rect.y > 0 diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index b0b645faf3..c439588951 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -80,6 +80,9 @@ class Widget(abc.ABC): def set_visible(self, visible: bool | Callable[[], bool]) -> None: self._is_visible = visible + def covers_background(self, rect: rl.Rectangle) -> bool: + return False + def set_click_callback(self, click_callback: Callable[[], None] | None) -> None: """Set a callback to be called when the widget is clicked.""" self._click_callback = click_callback diff --git a/system/ui/widgets/nav_widget.py b/system/ui/widgets/nav_widget.py index 11770bbe5d..9c84d69a0c 100644 --- a/system/ui/widgets/nav_widget.py +++ b/system/ui/widgets/nav_widget.py @@ -84,6 +84,14 @@ class NavWidget(Widget, abc.ABC): def set_shown_callback(self, callback: Callable[[], None] | None) -> None: self._shown_callback = callback + def covers_background(self, rect: rl.Rectangle) -> bool: + return (self.is_visible and type(self)._layout is NavWidget._layout and + self._rect.x == rect.x == 0 and self._rect.y == rect.y == 0 and + self._rect.width >= rect.width > 0 and self._rect.height >= rect.height > 0 and + self._y_pos_filter.x == 0 and self._y_pos_filter.velocity.x == 0 and + self._drag_start_pos is None and not self._dragging_down and + not self._playing_dismiss_animation and self._shown_callback is None) + def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: super()._handle_mouse_event(mouse_event)