mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-30 20:53:42 +08:00
Big UI GPU Widget
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user