mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-30 11:23:49 +08:00
Reduce onroad UI work and move parameter refresh off render thread
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user