Big UI GPU Widget

This commit is contained in:
firestarsdog
2026-08-28 01:02:49 -04:00
parent f501a4de37
commit ec0ab9d300
8 changed files with 272 additions and 3 deletions
@@ -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)
@@ -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()
@@ -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",
]
@@ -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
@@ -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)))
@@ -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
@@ -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"))
@@ -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__":