From ec0ab9d300db36aadc55fda41f42896d42ac3252 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:02:49 -0400 Subject: [PATCH] Big UI GPU Widget --- .../onroad/starpilot/starpilot_onroad_view.py | 7 +- .../onroad/starpilot/widget_layout_manager.py | 20 ++- .../ui/onroad/starpilot/widgets/__init__.py | 2 + selfdrive/ui/onroad/starpilot/widgets/base.py | 5 + .../onroad/starpilot/widgets/model_source.py | 125 ++++++++++++++++++ .../ui/tests/test_model_source_widget.py | 101 ++++++++++++++ .../ui/tests/test_onroad_render_layers.py | 2 + .../ui/tests/test_widget_layout_manager.py | 13 ++ 8 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 selfdrive/ui/onroad/starpilot/widgets/model_source.py create mode 100644 selfdrive/ui/tests/test_model_source_widget.py diff --git a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py index 2b665bd56..ec504fe1e 100644 --- a/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py +++ b/selfdrive/ui/onroad/starpilot/starpilot_onroad_view.py @@ -10,7 +10,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.widget_layout_manager import Widget from openpilot.selfdrive.ui.onroad.starpilot.widgets import ( SetSpeedWidget, SpeedLimitWidget, PedalIconsWidget, AetherGaugeWidget, PersonalityButtonWidget, DriverMonitorWidget, - SteeringWheelWidget, StoppedTimerWidget + SteeringWheelWidget, StoppedTimerWidget, ModelSourceWidget ) from openpilot.selfdrive.ui.onroad.starpilot.stopping_point import render_stopping_point from openpilot.selfdrive.ui.onroad.starpilot.pause_indicators import render_lateral_paused, render_longitudinal_paused @@ -70,6 +70,7 @@ class StarPilotOnroadView(AugmentedRoadView): self._pedals_widget = PedalIconsWidget() self._personality_button_widget = PersonalityButtonWidget() self._driver_monitor_widget = DriverMonitorWidget(self.driver_state_renderer) + self._model_source_widget = ModelSourceWidget() self._stopped_timer_widget = StoppedTimerWidget(self.is_in_reverse) # Register to layout zones @@ -78,6 +79,7 @@ class StarPilotOnroadView(AugmentedRoadView): self.layout_manager.register_widget("left", self._aethergauge_widget) self.layout_manager.register_widget("right", self._steering_wheel_widget) self.layout_manager.register_widget("right", self._pedals_widget) + self.layout_manager.register_widget("right_center", self._model_source_widget) self.layout_manager.register_widget("bottom", self._personality_button_widget) self.layout_manager.register_widget("bottom", self._driver_monitor_widget) @@ -89,6 +91,7 @@ class StarPilotOnroadView(AugmentedRoadView): self._child(self._pedals_widget) self._child(self._personality_button_widget) self._child(self._driver_monitor_widget) + self._child(self._model_source_widget) self._child(self._stopped_timer_widget) def _update_state(self) -> None: @@ -225,7 +228,7 @@ class StarPilotOnroadView(AugmentedRoadView): # Check if click maps to any of the layout widgets for zone in self.layout_manager.zones.values(): for widget in zone: - if widget.is_visible and rl.check_collision_point_rec(mouse_pos, widget.rect): + if widget.is_visible and widget.blocks_pointer and rl.check_collision_point_rec(mouse_pos, widget.rect): return super()._handle_mouse_press(mouse_pos) diff --git a/selfdrive/ui/onroad/starpilot/widget_layout_manager.py b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py index bd7ade88a..99c34aa54 100644 --- a/selfdrive/ui/onroad/starpilot/widget_layout_manager.py +++ b/selfdrive/ui/onroad/starpilot/widget_layout_manager.py @@ -8,7 +8,8 @@ class WidgetLayoutManager: self.zones = { "left": [], "bottom": [], - "right": [] + "right": [], + "right_center": [], } self.spacing = 15 # Spacing between widgets @@ -23,6 +24,7 @@ class WidgetLayoutManager: self._layout_left() self._layout_bottom(is_rhd) self._layout_right() + self._layout_right_center() def _layout_left(self): active_widgets = [w for w in self.zones["left"] if w.is_visible] @@ -70,6 +72,22 @@ class WidgetLayoutManager: widget.set_rect(rl.Rectangle(center_x - w / 2, current_y, w, h)) current_y += h + self.spacing + def _layout_right_center(self): + active_widgets = [w for w in self.zones["right_center"] if w.is_visible] + if not active_widgets: + return + + total_h = sum(w.get_size()[1] for w in active_widgets) + self.spacing * (len(active_widgets) - 1) + widest_widget = max(w.get_size()[0] for w in active_widgets) + # Preserve the shared anchor unless a wide center widget would reach the border. + right_inset = max(float(WIDGET_ANCHOR_OFFSET), widest_widget / 2) + center_x = self.content_rect.x + self.content_rect.width - right_inset + current_y = self.content_rect.y + (self.content_rect.height - total_h) / 2 + for widget in active_widgets: + w, h = widget.get_size() + widget.set_rect(rl.Rectangle(center_x - w / 2, current_y, w, h)) + current_y += h + self.spacing + def render_widgets(self, exclude: set[str] | None = None): """Render all visible registered widgets in their layout positions.""" skip = exclude or set() diff --git a/selfdrive/ui/onroad/starpilot/widgets/__init__.py b/selfdrive/ui/onroad/starpilot/widgets/__init__.py index f90b67e01..3d77fbaba 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/__init__.py +++ b/selfdrive/ui/onroad/starpilot/widgets/__init__.py @@ -7,6 +7,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.widgets.personality_button import P from openpilot.selfdrive.ui.onroad.starpilot.widgets.driver_monitor import DriverMonitorWidget from openpilot.selfdrive.ui.onroad.starpilot.widgets.steering_wheel import SteeringWheelWidget from openpilot.selfdrive.ui.onroad.starpilot.widgets.stopped_timer import StoppedTimerWidget +from openpilot.selfdrive.ui.onroad.starpilot.widgets.model_source import ModelSourceWidget __all__ = [ "LayoutWidget", @@ -18,4 +19,5 @@ __all__ = [ "DriverMonitorWidget", "SteeringWheelWidget", "StoppedTimerWidget", + "ModelSourceWidget", ] diff --git a/selfdrive/ui/onroad/starpilot/widgets/base.py b/selfdrive/ui/onroad/starpilot/widgets/base.py index 2a3db7825..907cc818b 100644 --- a/selfdrive/ui/onroad/starpilot/widgets/base.py +++ b/selfdrive/ui/onroad/starpilot/widgets/base.py @@ -17,6 +17,11 @@ class LayoutWidget(Widget): def get_size(self) -> tuple[float, float]: """Returns the width and height of the widget as (width, height).""" + @property + def blocks_pointer(self) -> bool: + """Whether this visual should suppress the on-road background tap.""" + return True + def _render(self, rect: rl.Rectangle) -> bool | int | None: # Subclasses will implement self._render instead of render # to integrate with openpilot.system.ui.widgets.Widget diff --git a/selfdrive/ui/onroad/starpilot/widgets/model_source.py b/selfdrive/ui/onroad/starpilot/widgets/model_source.py new file mode 100644 index 000000000..b610715e8 --- /dev/null +++ b/selfdrive/ui/onroad/starpilot/widgets/model_source.py @@ -0,0 +1,125 @@ +import math +from enum import Enum + +import pyray as rl + +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app + + +class ModelSourceStatus(Enum): + LOADING = "loading" + ACTIVE = "active" + FAILED = "failed" + FALLBACK_ENGAGED = "fallback_engaged" + + +class ModelSourceWidget(LayoutWidget): + """Show the external-GPU model state with the same status semantics as Mici.""" + + PERSISTENCE_SECONDS = 2.5 + # Mici uses a quarter-scale logical surface; preserve its physical icon size on Big UI. + SCALE = 4 + ICON_SIZES = { + ModelSourceStatus.LOADING: (60 * SCALE, 44 * SCALE), + ModelSourceStatus.ACTIVE: (60 * SCALE, 44 * SCALE), + ModelSourceStatus.FAILED: (75 * SCALE, 44 * SCALE), + ModelSourceStatus.FALLBACK_ENGAGED: (60 * SCALE, 52 * SCALE), + } + ASSET_PATHS = { + ModelSourceStatus.LOADING: "icons_mici/egpu_loading.png", + ModelSourceStatus.ACTIVE: "icons_mici/egpu_green.png", + ModelSourceStatus.FAILED: "icons_mici/egpu_orange.png", + ModelSourceStatus.FALLBACK_ENGAGED: "icons_mici/egpu_crossed.png", + } + SIZE = (300.0, 208.0) + + def __init__(self): + super().__init__("model_source", priority=1) + self.set_enabled(False) + self._small_model_engaged = False + self._engaged = False + self._fade_time = 0.0 + self._status: ModelSourceStatus | None = None + self._shown_status: ModelSourceStatus | None = None + self._alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._textures = { + status: gui_app.texture(path, *self.ICON_SIZES[status]) + for status, path in self.ASSET_PATHS.items() + } + + @property + def is_visible(self) -> bool: + return ui_state.usbgpu and ui_state.usbgpu_compiled + + @property + def blocks_pointer(self) -> bool: + return False + + def get_size(self) -> tuple[float, float]: + return self.SIZE + + @staticmethod + def _big_model_failed(active: bool | None, usbgpu: bool, model_seen: bool, model_alive: bool) -> bool: + return active is False or not usbgpu or (active is True and model_seen and not model_alive) + + @staticmethod + def _status_for(loading: bool, small_model_engaged: bool, big_failed: bool) -> ModelSourceStatus: + if loading: + return ModelSourceStatus.LOADING + if small_model_engaged: + return ModelSourceStatus.FALLBACK_ENGAGED + if big_failed: + return ModelSourceStatus.FAILED + return ModelSourceStatus.ACTIVE + + def _update_state(self) -> None: + sm = ui_state.sm + if sm.recv_frame["selfdriveState"] < ui_state.started_frame: + self._status = None + return + + model_seen = sm.recv_frame["modelV2"] > ui_state.started_frame + model_alive = sm.alive["modelV2"] if model_seen else True + loading = ui_state.usbgpu_loading + big_failed = self._big_model_failed(ui_state.usbgpu_active, ui_state.usbgpu, model_seen, model_alive) + engaged = sm["selfdriveState"].enabled + + if engaged and not self._engaged and not loading and ui_state.usbgpu_active is not True and model_seen: + self._small_model_engaged = True + if engaged != self._engaged: + self._fade_time = rl.get_time() if engaged else 0.0 + self._engaged = engaged + self._small_model_engaged &= big_failed + self._status = self._status_for(loading, self._small_model_engaged, big_failed) + + def _render(self, rect: rl.Rectangle) -> None: + if self._status is None: + return + + status = self._status + if status is ModelSourceStatus.LOADING: + pulse = 0.5 - 0.5 * math.cos(rl.get_time() * 6.0) + opacity = 0.35 + 0.65 * pulse + elif status is ModelSourceStatus.FALLBACK_ENGAGED: + opacity = 0.65 + else: + opacity = 1.0 + + if status is not self._shown_status: + self._fade_time = rl.get_time() + self._shown_status = status + alpha = self._alpha_filter.update( + status is ModelSourceStatus.LOADING or 0 < rl.get_time() - self._fade_time < self.PERSISTENCE_SECONDS + ) + if alpha < 1e-2: + return + + icon = self._textures[status] + pos = rl.Vector2( + rect.x + (rect.width - icon.width) / 2, + rect.y + (rect.height - icon.height) / 2, + ) + rl.draw_texture_ex(icon, pos, 0.0, 1.0, rl.Color(255, 255, 255, int(255 * opacity * alpha))) diff --git a/selfdrive/ui/tests/test_model_source_widget.py b/selfdrive/ui/tests/test_model_source_widget.py new file mode 100644 index 000000000..f3d1848f5 --- /dev/null +++ b/selfdrive/ui/tests/test_model_source_widget.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace + +from openpilot.selfdrive.ui.onroad.starpilot.widgets import model_source + + +class FakeSubMaster: + def __init__(self, *, selfdrive_frame: int, model_frame: int, model_alive: bool, enabled: bool): + self.recv_frame = {"selfdriveState": selfdrive_frame, "modelV2": model_frame} + self.alive = {"modelV2": model_alive} + self._selfdrive_state = SimpleNamespace(enabled=enabled) + + def __getitem__(self, service: str): + assert service == "selfdriveState" + return self._selfdrive_state + + +def test_model_source_status_prioritizes_loading_then_fallback_then_failure(): + status = model_source.ModelSourceWidget._status_for + + assert status(True, True, True) is model_source.ModelSourceStatus.LOADING + assert status(False, True, True) is model_source.ModelSourceStatus.FALLBACK_ENGAGED + assert status(False, False, True) is model_source.ModelSourceStatus.FAILED + assert status(False, False, False) is model_source.ModelSourceStatus.ACTIVE + + +def test_model_source_failure_detection_matches_the_backend_state_contract(): + failed = model_source.ModelSourceWidget._big_model_failed + + assert failed(False, True, False, True) + assert failed(True, False, False, True) + assert failed(True, True, True, False) + assert not failed(True, True, False, True) + + +def test_model_source_latches_small_model_engagement_until_the_big_model_recovers(monkeypatch): + widget = object.__new__(model_source.ModelSourceWidget) + widget._small_model_engaged = False + widget._engaged = False + widget._fade_time = 0.0 + widget._status = None + + sm = FakeSubMaster(selfdrive_frame=11, model_frame=11, model_alive=True, enabled=True) + monkeypatch.setattr( + model_source, + "ui_state", + SimpleNamespace( + sm=sm, + started_frame=10, + usbgpu=True, + usbgpu_compiled=True, + usbgpu_active=False, + usbgpu_loading=False, + ), + ) + monkeypatch.setattr(model_source.rl, "get_time", lambda: 42.0) + + widget._update_state() + + assert widget._small_model_engaged + assert widget._status is model_source.ModelSourceStatus.FALLBACK_ENGAGED + assert widget._fade_time == 42.0 + + model_source.ui_state.usbgpu_active = True + widget._update_state() + + assert not widget._small_model_engaged + assert widget._status is model_source.ModelSourceStatus.ACTIVE + + +def test_model_source_uses_the_approved_big_ui_footprint(): + assert model_source.ModelSourceWidget.SIZE == (300.0, 208.0) + assert model_source.ModelSourceWidget.ICON_SIZES[model_source.ModelSourceStatus.FAILED] == (300, 176) + + +def test_model_source_loads_and_centers_the_scaled_assets(monkeypatch): + calls = [] + + def texture(path, width, height): + calls.append((path, width, height)) + return SimpleNamespace(width=width, height=height) + + monkeypatch.setattr(model_source, "gui_app", SimpleNamespace(target_fps=60, texture=texture)) + widget = model_source.ModelSourceWidget() + widget._status = model_source.ModelSourceStatus.FAILED + widget._shown_status = model_source.ModelSourceStatus.FAILED + widget._fade_time = 1.0 + rendered = [] + monkeypatch.setattr(model_source.rl, "get_time", lambda: 2.0) + monkeypatch.setattr(model_source.rl, "draw_texture_ex", lambda *_args: rendered.append(_args)) + + widget._render(model_source.rl.Rectangle(1830, 436, 300, 208)) + + assert calls == [ + ("icons_mici/egpu_loading.png", 240, 176), + ("icons_mici/egpu_green.png", 240, 176), + ("icons_mici/egpu_orange.png", 300, 176), + ("icons_mici/egpu_crossed.png", 240, 208), + ] + assert not widget.blocks_pointer + assert rendered[0][1].x == 1830 + assert rendered[0][1].y == 452 diff --git a/selfdrive/ui/tests/test_onroad_render_layers.py b/selfdrive/ui/tests/test_onroad_render_layers.py index 5ef7d15bc..ed3884512 100644 --- a/selfdrive/ui/tests/test_onroad_render_layers.py +++ b/selfdrive/ui/tests/test_onroad_render_layers.py @@ -76,6 +76,7 @@ def _load_starpilot_onroad_view(monkeypatch): DriverMonitorWidget=dummy_widget, SteeringWheelWidget=dummy_widget, StoppedTimerWidget=dummy_widget, + ModelSourceWidget=dummy_widget, ) stub_module( "openpilot.selfdrive.ui.onroad.starpilot.stopping_point", @@ -193,6 +194,7 @@ def test_starpilot_road_overlays_use_the_parent_scissor(monkeypatch): _track_edge_vertices=SimpleNamespace(size=4), ) view._font_bold = object() + view._get_border_width = lambda: 0 monkeypatch.setattr(starpilot_onroad_view, "render_path_edges", lambda *_args: events.append("path_edges")) monkeypatch.setattr(starpilot_onroad_view, "render_adjacent_lanes", lambda *_args: events.append("adjacent_lanes")) diff --git a/selfdrive/ui/tests/test_widget_layout_manager.py b/selfdrive/ui/tests/test_widget_layout_manager.py index 4efe7f266..b9e6a1740 100644 --- a/selfdrive/ui/tests/test_widget_layout_manager.py +++ b/selfdrive/ui/tests/test_widget_layout_manager.py @@ -179,6 +179,19 @@ class TestWidgetLayoutManager(unittest.TestCase): self.assertEqual(w2.rect.x, 1934) self.assertEqual(w2.rect.y, 290) + def test_right_center_zone_is_centered_on_the_right_widget_column(self): + w1 = DummyLayoutWidget("model_source", priority=1, width=300, height=208) + self.layout_manager.register_widget("right_center", w1) + + self.layout_manager.update_layout(self.content_rect, is_rhd=False) + + # The 300px widget needs a 150px inset to remain inside the content rect. + # center_x = 30 + 2100 - 150 = 1980; center_y = 30 + 1020 / 2 = 540 + self.assertEqual(w1.rect.x, 1830) + self.assertEqual(w1.rect.y, 436) + self.assertEqual(w1.rect.width, 300) + self.assertEqual(w1.rect.height, 208) + if __name__ == "__main__":