mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-04 07:03:44 +08:00
976 lines
38 KiB
Python
976 lines
38 KiB
Python
import time
|
|
import numpy as np
|
|
import pyray as rl
|
|
from cereal import messaging, car, log
|
|
from opendbc.car import structs
|
|
from msgq.visionipc import VisionStreamType
|
|
from openpilot.common.constants import CV
|
|
from openpilot.selfdrive.ui.ui_state import ui_state
|
|
from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
|
|
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer, ALERT_COLORS, AlertStatus
|
|
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
|
|
from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer, VISION_SPEED_LIMIT_PULSE_COLOR
|
|
from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
|
|
from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
|
|
from openpilot.selfdrive.ui.mici.onroad.sidebar_widgets import MiciSidebarWidgets
|
|
from openpilot.selfdrive.ui.mici.onroad.starpilot_status import (
|
|
ENGAGED_COLOR,
|
|
EXPERIMENTAL_COLOR,
|
|
TRAFFIC_COLOR,
|
|
get_border_color,
|
|
)
|
|
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
|
|
from openpilot.selfdrive.ui.lib.starpilot_visuals import get_border_width
|
|
from openpilot.starpilot.common.favorite_slots import is_favorite_action_key, load_favorite_slots, toggle_favorite_slot
|
|
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
|
|
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
|
from openpilot.system.ui.lib.wrap_text import wrap_text
|
|
from openpilot.system.ui.widgets.label import UnifiedLabel
|
|
from openpilot.system.ui.widgets import Widget
|
|
from openpilot.common.filter_simple import BounceFilter
|
|
from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
|
|
from openpilot.common.transformations.orientation import rot_from_euler
|
|
from enum import IntEnum
|
|
|
|
OpState = log.SelfdriveState.OpenpilotState
|
|
CALIBRATED = log.LiveCalibrationData.Status.calibrated
|
|
ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
|
|
WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
|
|
DRIVER_CAM = VisionStreamType.VISION_STREAM_DRIVER
|
|
GEAR_SHIFTER_REVERSE = structs.CarState.GearShifter.reverse
|
|
DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
|
|
|
|
CAMERA_VIEW_AUTO = 0
|
|
CAMERA_VIEW_DRIVER = 1
|
|
CAMERA_VIEW_STANDARD = 2
|
|
CAMERA_VIEW_WIDE = 3
|
|
CAMERA_VIEW_NONE = 4
|
|
|
|
|
|
class BookmarkState(IntEnum):
|
|
HIDDEN = 0
|
|
DRAGGING = 1
|
|
TRIGGERED = 2
|
|
|
|
WIDE_CAM_MAX_SPEED = 10.0 # m/s
|
|
ROAD_CAM_MIN_SPEED = 15.0 # m/s
|
|
|
|
CAM_Y_OFFSET = 20
|
|
REVERSE_DRIVER_CAMERA_DELAY_FRAMES = max(1, int(round(gui_app.target_fps * 0.5)))
|
|
|
|
|
|
class BookmarkIcon(Widget):
|
|
PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible
|
|
FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible
|
|
HIDDEN_OFFSET = -50 # How far offscreen when hidden
|
|
|
|
def __init__(self, bookmark_callback):
|
|
super().__init__()
|
|
self._bookmark_callback = bookmark_callback
|
|
self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180)
|
|
self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps)
|
|
|
|
# State
|
|
self._interacting = False
|
|
self._state = BookmarkState.HIDDEN
|
|
self._swipe_start_x = 0.0
|
|
self._swipe_current_x = 0.0
|
|
self._is_swiping = False
|
|
self._is_swiping_left: bool = False
|
|
self._triggered_time: float = 0.0
|
|
|
|
def is_swiping_left(self) -> bool:
|
|
"""Check if currently swiping left (for scroller to disable)."""
|
|
return self._is_swiping_left
|
|
|
|
def interacting(self):
|
|
interacting, self._interacting = self._interacting, False
|
|
return interacting
|
|
|
|
def _update_state(self):
|
|
if self._state == BookmarkState.DRAGGING:
|
|
# Allow pulling past activated position with rubber band effect
|
|
swipe_offset = self._swipe_start_x - self._swipe_current_x
|
|
swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50)
|
|
self._offset_filter.update(swipe_offset)
|
|
|
|
elif self._state == BookmarkState.TRIGGERED:
|
|
# Continue animating to fully visible
|
|
self._offset_filter.update(self.FULL_VISIBLE_OFFSET)
|
|
# Stay in TRIGGERED state for 1 second
|
|
if rl.get_time() - self._triggered_time >= 1.5:
|
|
self._state = BookmarkState.HIDDEN
|
|
|
|
elif self._state == BookmarkState.HIDDEN:
|
|
self._offset_filter.update(self.HIDDEN_OFFSET)
|
|
|
|
if self._offset_filter.x < 1e-3:
|
|
self._interacting = False
|
|
|
|
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
|
if not ui_state.started:
|
|
return
|
|
|
|
if mouse_event.left_pressed:
|
|
# Store relative position within widget
|
|
self._swipe_start_x = mouse_event.pos.x
|
|
self._swipe_current_x = mouse_event.pos.x
|
|
self._is_swiping = True
|
|
self._is_swiping_left = False
|
|
self._state = BookmarkState.DRAGGING
|
|
|
|
elif mouse_event.left_down and self._is_swiping:
|
|
self._swipe_current_x = mouse_event.pos.x
|
|
swipe_offset = self._swipe_start_x - self._swipe_current_x
|
|
self._is_swiping_left = swipe_offset > 0
|
|
if self._is_swiping_left:
|
|
self._interacting = True
|
|
|
|
elif mouse_event.left_released:
|
|
if self._is_swiping:
|
|
swipe_distance = self._swipe_start_x - self._swipe_current_x
|
|
|
|
# If peeking past threshold, transition to animating to fully visible and bookmark
|
|
if swipe_distance > self.PEEK_THRESHOLD:
|
|
self._state = BookmarkState.TRIGGERED
|
|
self._triggered_time = rl.get_time()
|
|
self._bookmark_callback()
|
|
else:
|
|
# Otherwise, transition back to hidden
|
|
self._state = BookmarkState.HIDDEN
|
|
|
|
# Reset swipe state
|
|
self._is_swiping = False
|
|
self._is_swiping_left = False
|
|
|
|
def _render(self, _):
|
|
"""Render the bookmark icon."""
|
|
if self._offset_filter.x > 0:
|
|
icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x)
|
|
icon_y = self.rect.y + (self.rect.height - self._icon.height) / 2 # Vertically centered
|
|
rl.draw_texture(self._icon, int(icon_x), int(icon_y), rl.WHITE)
|
|
|
|
|
|
class FavoriteSlotsOverlay(Widget):
|
|
SLOT_COUNT = 3
|
|
MAX_TAP_TRAVEL = 24
|
|
COLOR_TRANSITION_SECONDS = 0.65
|
|
FADE_START_SECONDS = 2.0
|
|
FEEDBACK_DURATION_SECONDS = 3.0
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._font = gui_app.font(FontWeight.SEMI_BOLD)
|
|
self._button_rects: list[tuple[int, rl.Rectangle]] = []
|
|
self._pressed_slot: int | None = None
|
|
self._press_pos: MousePos | None = None
|
|
self._max_tap_travel = 0.0
|
|
self._feedback_slot: int | None = None
|
|
self._feedback_started_at = -self.FEEDBACK_DURATION_SECONDS
|
|
self._feedback_value: bool | None = None
|
|
self._interacting = False
|
|
|
|
def interacting(self):
|
|
interacting, self._interacting = self._interacting, False
|
|
return interacting
|
|
|
|
def _visible_slots(self) -> list[tuple[int, dict]]:
|
|
visible = []
|
|
for index, slot in enumerate(load_favorite_slots(ui_state.params)):
|
|
if slot.get("enabled") and slot.get("show_onroad") and slot.get("key"):
|
|
visible.append((index, slot))
|
|
return visible
|
|
|
|
def _slot_rects(self, rect: rl.Rectangle, slots: list[tuple[int, dict]]) -> list[tuple[int, rl.Rectangle]]:
|
|
slot_width = rect.width / self.SLOT_COUNT
|
|
return [
|
|
(slot_index, rl.Rectangle(rect.x + slot_index * slot_width, rect.y, slot_width, rect.height))
|
|
for slot_index, _slot in slots
|
|
]
|
|
|
|
def _fit_label(self, label: str, max_width: float, max_height: float) -> tuple[list[str], int]:
|
|
label = label or "Favorite"
|
|
lines = [label]
|
|
for font_size in range(30, 17, -1):
|
|
if any(measure_text_cached(self._font, word, font_size).x > max_width for word in label.split()):
|
|
continue
|
|
lines = wrap_text(self._font, label, font_size, int(max_width)) or [label]
|
|
line_height = font_size * 1.12
|
|
if len(lines) <= 3 and len(lines) * line_height <= max_height:
|
|
return lines, font_size
|
|
return lines[:3], 18
|
|
|
|
@staticmethod
|
|
def _ease_out_cubic(progress: float) -> float:
|
|
progress = float(np.clip(progress, 0.0, 1.0))
|
|
return 1.0 - (1.0 - progress) ** 3
|
|
|
|
@staticmethod
|
|
def _blend_color(start: rl.Color, end: rl.Color, progress: float, alpha: int) -> rl.Color:
|
|
progress = float(np.clip(progress, 0.0, 1.0))
|
|
return rl.Color(
|
|
round(start.r + (end.r - start.r) * progress),
|
|
round(start.g + (end.g - start.g) * progress),
|
|
round(start.b + (end.b - start.b) * progress),
|
|
alpha,
|
|
)
|
|
|
|
def _feedback_alpha(self, elapsed: float) -> float:
|
|
if elapsed < self.FADE_START_SECONDS:
|
|
return 1.0
|
|
fade_duration = self.FEEDBACK_DURATION_SECONDS - self.FADE_START_SECONDS
|
|
return float(np.clip(1.0 - (elapsed - self.FADE_START_SECONDS) / fade_duration, 0.0, 1.0))
|
|
|
|
def _draw_feedback(self, slot_rect: rl.Rectangle, slot: dict, elapsed: float, held: bool) -> None:
|
|
alpha_scale = 1.0 if held else self._feedback_alpha(elapsed)
|
|
if alpha_scale <= 0.0:
|
|
return
|
|
|
|
color_progress = self._ease_out_cubic(elapsed / self.COLOR_TRANSITION_SECONDS)
|
|
alpha = round(255 * alpha_scale)
|
|
accent = self._blend_color(VISION_SPEED_LIMIT_PULSE_COLOR, rl.Color(255, 255, 255, 255), color_progress, alpha)
|
|
|
|
panel_margin = 12
|
|
panel_width = max(96.0, slot_rect.width - 2 * panel_margin)
|
|
panel_height = min(132.0, slot_rect.height * 0.5)
|
|
panel_rect = rl.Rectangle(
|
|
slot_rect.x + (slot_rect.width - panel_width) / 2,
|
|
slot_rect.y + (slot_rect.height - panel_height) / 2,
|
|
panel_width,
|
|
panel_height,
|
|
)
|
|
|
|
# A restrained initial glow gives the same purple acknowledgement as the
|
|
# vision speed-limit pulse without leaving persistent controls onscreen.
|
|
glow_strength = (1.0 - color_progress) * alpha_scale
|
|
for expansion, opacity in ((8, 22), (4, 44)):
|
|
glow_rect = rl.Rectangle(
|
|
panel_rect.x - expansion,
|
|
panel_rect.y - expansion,
|
|
panel_rect.width + 2 * expansion,
|
|
panel_rect.height + 2 * expansion,
|
|
)
|
|
glow = rl.Color(
|
|
VISION_SPEED_LIMIT_PULSE_COLOR.r,
|
|
VISION_SPEED_LIMIT_PULSE_COLOR.g,
|
|
VISION_SPEED_LIMIT_PULSE_COLOR.b,
|
|
round(opacity * glow_strength),
|
|
)
|
|
rl.draw_rectangle_rounded_lines_ex(glow_rect, 0.16, 12, 2, glow)
|
|
|
|
rl.draw_rectangle_rounded(panel_rect, 0.16, 12, rl.Color(0, 0, 0, round(178 * alpha_scale)))
|
|
rl.draw_rectangle_rounded_lines_ex(panel_rect, 0.16, 12, 3, accent)
|
|
|
|
label = slot.get("label") or slot.get("key") or "Favorite"
|
|
state_text = "PRESS" if is_favorite_action_key(slot.get("key")) else ("ON" if self._feedback_value else "OFF")
|
|
lines, font_size = self._fit_label(label, panel_rect.width - 20, panel_rect.height - 46)
|
|
line_height = font_size * 1.08
|
|
label_height = len(lines) * line_height
|
|
text_y = panel_rect.y + 12 + (panel_rect.height - 42 - label_height) / 2
|
|
for line in lines:
|
|
text_size = measure_text_cached(self._font, line, font_size)
|
|
text_x = panel_rect.x + (panel_rect.width - text_size.x) / 2
|
|
rl.draw_text_ex(self._font, line, rl.Vector2(text_x, text_y), font_size, 0, accent)
|
|
text_y += line_height
|
|
|
|
state_size = measure_text_cached(self._font, state_text, 20)
|
|
state_pos = rl.Vector2(panel_rect.x + (panel_rect.width - state_size.x) / 2, panel_rect.y + panel_rect.height - 30)
|
|
rl.draw_text_ex(self._font, state_text, state_pos, 20, 0, rl.Color(255, 255, 255, round(210 * alpha_scale)))
|
|
|
|
def _render(self, rect: rl.Rectangle):
|
|
visible_slots = self._visible_slots()
|
|
self._button_rects = self._slot_rects(rect, visible_slots)
|
|
slot_by_index = dict(visible_slots)
|
|
|
|
if self._feedback_slot not in slot_by_index:
|
|
self._feedback_slot = None
|
|
return
|
|
|
|
elapsed = max(0.0, rl.get_time() - self._feedback_started_at)
|
|
held = self._pressed_slot == self._feedback_slot
|
|
if not held and elapsed >= self.FEEDBACK_DURATION_SECONDS:
|
|
self._feedback_slot = None
|
|
return
|
|
|
|
feedback_rect = next(button_rect for slot_index, button_rect in self._button_rects if slot_index == self._feedback_slot)
|
|
self._draw_feedback(feedback_rect, slot_by_index[self._feedback_slot], elapsed, held)
|
|
|
|
def _slot_at(self, pos: MousePos) -> int | None:
|
|
for slot_index, rect in self._button_rects:
|
|
if rl.check_collision_point_rec(pos, rect):
|
|
return slot_index
|
|
return None
|
|
|
|
def _handle_mouse_press(self, mouse_pos: MousePos):
|
|
self._pressed_slot = self._slot_at(mouse_pos)
|
|
if self._pressed_slot is not None:
|
|
self._press_pos = mouse_pos
|
|
self._max_tap_travel = 0.0
|
|
self._feedback_slot = self._pressed_slot
|
|
self._feedback_started_at = rl.get_time()
|
|
slot = dict(self._visible_slots()).get(self._pressed_slot, {})
|
|
key = slot.get("key")
|
|
self._feedback_value = None if is_favorite_action_key(key) else (not ui_state.params.get_bool(key) if key else None)
|
|
self._interacting = True
|
|
|
|
def _handle_mouse_event(self, mouse_event: MouseEvent):
|
|
if self._pressed_slot is None or self._press_pos is None:
|
|
return
|
|
travel = ((mouse_event.pos.x - self._press_pos.x) ** 2 + (mouse_event.pos.y - self._press_pos.y) ** 2) ** 0.5
|
|
self._max_tap_travel = max(self._max_tap_travel, travel)
|
|
|
|
def _handle_mouse_release(self, mouse_pos: MousePos):
|
|
released_slot = self._slot_at(mouse_pos)
|
|
valid_tap = (
|
|
self._pressed_slot is not None and
|
|
released_slot == self._pressed_slot and
|
|
self._max_tap_travel <= self.MAX_TAP_TRAVEL
|
|
)
|
|
if valid_tap and toggle_favorite_slot(self._pressed_slot, ui_state.params, ui_state.params_memory):
|
|
self._feedback_slot = self._pressed_slot
|
|
self._feedback_started_at = rl.get_time()
|
|
self._interacting = True
|
|
else:
|
|
self._feedback_slot = None
|
|
self._pressed_slot = None
|
|
self._press_pos = None
|
|
self._max_tap_travel = 0.0
|
|
|
|
|
|
class MinSteerSpeedBanner(Widget):
|
|
"""One-shot-per-drive banner shown for the full first below-min-steer interval."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._shown_this_drive = False
|
|
self._showing_interval = False
|
|
self._has_been_above_min = False
|
|
self._was_under_min = False
|
|
self._last_started_frame = -1
|
|
self._label = UnifiedLabel(
|
|
"",
|
|
34,
|
|
FontWeight.BOLD,
|
|
text_color=rl.WHITE,
|
|
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
|
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
|
)
|
|
|
|
def _reset(self):
|
|
self._shown_this_drive = False
|
|
self._showing_interval = False
|
|
self._has_been_above_min = False
|
|
self._was_under_min = False
|
|
|
|
@staticmethod
|
|
def _get_message(min_steer_speed: float) -> str:
|
|
speed_units = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
|
|
speed = int(round(min_steer_speed * speed_units))
|
|
unit = "km/h" if ui_state.is_metric else "mph"
|
|
return f"Steer Unavailable Under {speed} {unit}"
|
|
|
|
def _update_state(self):
|
|
if not ui_state.started:
|
|
self._last_started_frame = -1
|
|
self._reset()
|
|
return
|
|
|
|
if ui_state.started_frame != self._last_started_frame:
|
|
self._last_started_frame = ui_state.started_frame
|
|
self._reset()
|
|
|
|
sm = ui_state.sm
|
|
if sm.recv_frame["carParams"] < ui_state.started_frame or sm.recv_frame["carState"] < ui_state.started_frame:
|
|
return
|
|
|
|
min_steer_speed = float(sm["carParams"].minSteerSpeed)
|
|
if min_steer_speed <= 0:
|
|
self._showing_interval = False
|
|
self._was_under_min = False
|
|
return
|
|
|
|
under_min = float(sm["carState"].vEgo) < min_steer_speed
|
|
if not under_min:
|
|
self._has_been_above_min = True
|
|
|
|
crossed_below = under_min and not self._was_under_min
|
|
if (not self._shown_this_drive) and crossed_below and self._has_been_above_min:
|
|
self._showing_interval = True
|
|
self._shown_this_drive = True
|
|
|
|
if self._showing_interval and not under_min:
|
|
self._showing_interval = False
|
|
|
|
self._was_under_min = under_min
|
|
if self._showing_interval:
|
|
self._label.set_text(self._get_message(min_steer_speed))
|
|
|
|
def _render(self, rect):
|
|
self._update_state()
|
|
if not self._showing_interval:
|
|
return
|
|
|
|
color = ALERT_COLORS[AlertStatus.userPrompt]
|
|
color = rl.Color(color.r, color.g, color.b, int(255 * 0.9))
|
|
translucent = rl.Color(color.r, color.g, color.b, 0)
|
|
dropdown_height = min(170, int(rect.height * 0.7))
|
|
solid_height = max(26, int(dropdown_height * 0.2))
|
|
|
|
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), solid_height, color)
|
|
rl.draw_rectangle_gradient_v(
|
|
int(rect.x),
|
|
int(rect.y + solid_height),
|
|
int(rect.width),
|
|
int(dropdown_height - solid_height),
|
|
color,
|
|
translucent,
|
|
)
|
|
|
|
text_rect = rl.Rectangle(rect.x + 26, rect.y - 2, rect.width - 52, dropdown_height)
|
|
self._label.set_text_color(rl.Color(255, 255, 255, 242))
|
|
self._label.render(text_rect)
|
|
|
|
|
|
class StandstillTimerOverlay:
|
|
def __init__(self):
|
|
self._last_started_frame = -1
|
|
self._standstill_duration = 0
|
|
self._standstill_started_at: float | None = None
|
|
self._font_bold = gui_app.font(FontWeight.BOLD)
|
|
self._font_medium = gui_app.font(FontWeight.MEDIUM)
|
|
|
|
def _reset(self):
|
|
self._standstill_duration = 0
|
|
self._standstill_started_at = None
|
|
|
|
@staticmethod
|
|
def _blend_colors(start: rl.Color, end: rl.Color, transition: float) -> rl.Color:
|
|
transition = float(np.clip(transition, 0.0, 1.0))
|
|
return rl.Color(
|
|
int(start.r + transition * (end.r - start.r)),
|
|
int(start.g + transition * (end.g - start.g)),
|
|
int(start.b + transition * (end.b - start.b)),
|
|
255,
|
|
)
|
|
|
|
def _get_duration_color(self) -> rl.Color:
|
|
if self._standstill_duration < 60:
|
|
return ENGAGED_COLOR
|
|
if self._standstill_duration < 150:
|
|
transition = (self._standstill_duration - 60) / 90.0
|
|
return self._blend_colors(ENGAGED_COLOR, EXPERIMENTAL_COLOR, transition)
|
|
if self._standstill_duration < 300:
|
|
transition = (self._standstill_duration - 150) / 150.0
|
|
return self._blend_colors(EXPERIMENTAL_COLOR, TRAFFIC_COLOR, transition)
|
|
return TRAFFIC_COLOR
|
|
|
|
def _update_state(self, in_reverse: bool) -> None:
|
|
if not ui_state.started:
|
|
self._last_started_frame = -1
|
|
self._reset()
|
|
return
|
|
|
|
if ui_state.started_frame != self._last_started_frame:
|
|
self._last_started_frame = ui_state.started_frame
|
|
self._reset()
|
|
|
|
if ui_state.sm.recv_frame["carState"] < ui_state.started_frame:
|
|
self._reset()
|
|
return
|
|
|
|
if in_reverse or not ui_state.params.get_bool("StoppedTimer"):
|
|
self._reset()
|
|
return
|
|
|
|
if not ui_state.sm["carState"].standstill:
|
|
self._reset()
|
|
return
|
|
|
|
now = time.monotonic()
|
|
if self._standstill_started_at is None:
|
|
self._standstill_started_at = now
|
|
self._standstill_duration = 0
|
|
return
|
|
|
|
if now - ui_state.started_time < 60.0:
|
|
self._standstill_duration = 0
|
|
return
|
|
|
|
self._standstill_duration = int(now - self._standstill_started_at)
|
|
|
|
@staticmethod
|
|
def _format_duration_text(total_seconds: int) -> tuple[str, str]:
|
|
minutes = total_seconds // 60
|
|
seconds = total_seconds % 60
|
|
minute_text = f"{minutes} minute" if minutes == 1 else f"{minutes} minutes"
|
|
second_text = f"{seconds} second" if seconds == 1 else f"{seconds} seconds"
|
|
return minute_text, second_text
|
|
|
|
def _draw_centered_text(self, rect: rl.Rectangle, text: str, y: float, font: rl.Font, font_size: int, color: rl.Color) -> None:
|
|
text_size = measure_text_cached(font, text, font_size)
|
|
text_pos = rl.Vector2(rect.x + rect.width / 2 - text_size.x / 2, rect.y + y - text_size.y / 2)
|
|
shadow_pos = rl.Vector2(text_pos.x + 2, text_pos.y + 2)
|
|
rl.draw_text_ex(font, text, shadow_pos, font_size, 0, rl.Color(0, 0, 0, 170))
|
|
rl.draw_text_ex(font, text, text_pos, font_size, 0, color)
|
|
|
|
@staticmethod
|
|
def _fit_font_size(font: rl.Font, text: str, initial_size: int, max_width: float, minimum_size: int) -> int:
|
|
font_size = max(initial_size, minimum_size)
|
|
while font_size > minimum_size and measure_text_cached(font, text, font_size).x > max_width:
|
|
font_size -= 2
|
|
return font_size
|
|
|
|
def render(self, rect: rl.Rectangle, in_reverse: bool) -> bool:
|
|
self._update_state(in_reverse)
|
|
if self._standstill_duration == 0:
|
|
return False
|
|
|
|
minute_text, second_text = self._format_duration_text(self._standstill_duration)
|
|
duration_color = self._get_duration_color()
|
|
max_text_width = max(rect.width - 36, 120)
|
|
minute_font_size = self._fit_font_size(self._font_bold, minute_text, int(rect.height * 0.34), max_text_width, 28)
|
|
second_font_size = self._fit_font_size(self._font_medium, second_text, int(rect.height * 0.15), max_text_width, 16)
|
|
self._draw_centered_text(rect, minute_text, rect.height * 0.42, self._font_bold, minute_font_size, duration_color)
|
|
self._draw_centered_text(rect, second_text, rect.height * 0.62, self._font_medium, second_font_size, rl.Color(255, 255, 255, 242))
|
|
return True
|
|
|
|
|
|
class AugmentedRoadView(CameraView):
|
|
def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
|
|
super().__init__("camerad", stream_type)
|
|
self._bookmark_callback = bookmark_callback
|
|
self._set_placeholder_color(rl.BLACK)
|
|
|
|
self.device_camera: DeviceCameraConfig | None = None
|
|
self.view_from_calib = view_frame_from_device_frame.copy()
|
|
self.view_from_wide_calib = view_frame_from_device_frame.copy()
|
|
|
|
self._last_calib_time: float = 0
|
|
self._last_rect_dims = (0.0, 0.0)
|
|
self._last_stream_type = stream_type
|
|
self._cached_matrix: np.ndarray | None = None
|
|
self._content_rect = rl.Rectangle()
|
|
self._last_click_time = 0.0
|
|
self._reverse_driver_camera_frames = 0
|
|
self._reverse_driver_camera_active = False
|
|
self._sidebar_personality_pressed = False
|
|
|
|
# Bookmark icon with swipe gesture
|
|
self._bookmark_icon = BookmarkIcon(bookmark_callback)
|
|
|
|
self._model_renderer = ModelRenderer()
|
|
self._hud_renderer = HudRenderer()
|
|
self._alert_renderer = AlertRenderer()
|
|
self._driver_state_renderer = DriverStateRenderer()
|
|
self._confidence_ball = ConfidenceBall()
|
|
self._sidebar_widgets = MiciSidebarWidgets(self._confidence_ball)
|
|
self._min_steer_speed_banner = MinSteerSpeedBanner()
|
|
self._standstill_timer = StandstillTimerOverlay()
|
|
self._favorite_slots = self._child(FavoriteSlotsOverlay())
|
|
self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY,
|
|
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
|
|
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
|
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
|
|
|
|
self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png")
|
|
|
|
# debug
|
|
self._pm = messaging.PubMaster(['uiDebug'])
|
|
|
|
@staticmethod
|
|
def _controls_ready() -> bool:
|
|
return ui_state.sm.recv_frame["selfdriveState"] >= ui_state.started_frame
|
|
|
|
def _update_reverse_driver_camera_state(self) -> bool:
|
|
should_force_driver = ui_state.started and ui_state.params.get_bool("DriverCamera") and self._is_in_reverse()
|
|
if not should_force_driver:
|
|
self._reverse_driver_camera_frames = 0
|
|
self._reverse_driver_camera_active = False
|
|
return False
|
|
|
|
self._reverse_driver_camera_frames = min(self._reverse_driver_camera_frames + 1, REVERSE_DRIVER_CAMERA_DELAY_FRAMES)
|
|
self._reverse_driver_camera_active = self._reverse_driver_camera_frames >= REVERSE_DRIVER_CAMERA_DELAY_FRAMES
|
|
return self._reverse_driver_camera_active
|
|
|
|
def is_swiping_left(self) -> bool:
|
|
"""Check if currently swiping left (for scroller to disable)."""
|
|
return self._bookmark_icon.is_swiping_left()
|
|
|
|
def _update_state(self):
|
|
super()._update_state()
|
|
|
|
# update offroad label
|
|
if ui_state.panda_type == log.PandaState.PandaType.unknown:
|
|
self._offroad_label.set_text("system booting")
|
|
elif ui_state.started and not self._controls_ready():
|
|
self._offroad_label.set_text("waiting for\ncontrols to start")
|
|
else:
|
|
self._offroad_label.set_text("start the car to\nuse openpilot")
|
|
|
|
def _sidebar_rect(self) -> rl.Rectangle:
|
|
return rl.Rectangle(
|
|
self.rect.x + self.rect.width - SIDE_PANEL_WIDTH,
|
|
self.rect.y,
|
|
SIDE_PANEL_WIDTH,
|
|
self.rect.height,
|
|
)
|
|
|
|
def _sidebar_widgets_visible(self) -> bool:
|
|
return not ui_state.params.get_bool("StockConfidenceBallWidget") or self._sidebar_widgets.demo_active
|
|
|
|
def _sidebar_personality_touch_enabled(self) -> bool:
|
|
return (
|
|
ui_state.started and
|
|
self._sidebar_widgets_visible() and
|
|
not ui_state.params.get_bool("SafeMode")
|
|
)
|
|
|
|
def _touch_in_sidebar(self, mouse_pos: MousePos) -> bool:
|
|
return rl.check_collision_point_rec(mouse_pos, self._sidebar_rect())
|
|
|
|
def _cycle_personality_profile(self) -> None:
|
|
current = ui_state.params.get_int("LongitudinalPersonality", return_default=True, default=int(log.LongitudinalPersonality.standard))
|
|
profiles = (
|
|
int(log.LongitudinalPersonality.aggressive),
|
|
int(log.LongitudinalPersonality.standard),
|
|
int(log.LongitudinalPersonality.relaxed),
|
|
)
|
|
try:
|
|
current_idx = profiles.index(int(current))
|
|
except ValueError:
|
|
current_idx = 1
|
|
next_personality = profiles[(current_idx + 1) % len(profiles)]
|
|
ui_state.params.put_int("LongitudinalPersonality", next_personality)
|
|
ui_state.personality = next_personality
|
|
|
|
def _handle_mouse_press(self, mouse_pos: MousePos):
|
|
self._sidebar_personality_pressed = (
|
|
self._sidebar_personality_touch_enabled() and
|
|
self._touch_in_sidebar(mouse_pos)
|
|
)
|
|
if not self._sidebar_personality_pressed:
|
|
super()._handle_mouse_press(mouse_pos)
|
|
|
|
def _handle_mouse_release(self, mouse_pos: MousePos):
|
|
if self._sidebar_personality_pressed:
|
|
if self._sidebar_personality_touch_enabled() and self._touch_in_sidebar(mouse_pos):
|
|
self._cycle_personality_profile()
|
|
self._sidebar_personality_pressed = False
|
|
return
|
|
|
|
self._sidebar_personality_pressed = False
|
|
|
|
# Don't trigger click callback if bookmark or HUD widgets consumed the tap.
|
|
if not self._bookmark_icon.interacting() and not self._hud_renderer.user_interacting() and not self._favorite_slots.interacting():
|
|
super()._handle_mouse_release(mouse_pos)
|
|
|
|
def _render(self, _):
|
|
start_draw = time.monotonic()
|
|
camera_view = self._camera_view()
|
|
camera_view_none = camera_view == CAMERA_VIEW_NONE
|
|
self._switch_stream_if_needed(ui_state.sm, camera_view)
|
|
|
|
# Update calibration before rendering
|
|
self._update_calibration()
|
|
|
|
# Create inner content area with border padding
|
|
self._content_rect = rl.Rectangle(
|
|
self.rect.x,
|
|
self.rect.y,
|
|
self.rect.width - SIDE_PANEL_WIDTH,
|
|
self.rect.height,
|
|
)
|
|
|
|
# Enable scissor mode to clip all rendering within content rectangle boundaries
|
|
# This creates a rendering viewport that prevents graphics from drawing outside the border
|
|
rl.begin_scissor_mode(
|
|
int(self._content_rect.x),
|
|
int(self._content_rect.y),
|
|
int(self._content_rect.width),
|
|
int(self._content_rect.height)
|
|
)
|
|
|
|
# Render the base camera view
|
|
if camera_view_none:
|
|
rl.draw_rectangle_rec(self._content_rect, rl.BLACK)
|
|
else:
|
|
super()._render(self._content_rect)
|
|
|
|
waiting_for_controls = ui_state.started and not self._controls_ready()
|
|
if waiting_for_controls:
|
|
rl.draw_rectangle(int(self._content_rect.x), int(self._content_rect.y),
|
|
int(self._content_rect.width), int(self._content_rect.height),
|
|
rl.Color(0, 0, 0, 145))
|
|
self._offroad_label.render(self._content_rect)
|
|
rl.end_scissor_mode()
|
|
self._draw_border()
|
|
self._bookmark_icon.render(self.rect)
|
|
|
|
msg = messaging.new_message('uiDebug')
|
|
msg.uiDebug.drawTimeMillis = (time.monotonic() - start_draw) * 1000
|
|
self._pm.send('uiDebug', msg)
|
|
return
|
|
|
|
in_reverse = self._is_in_reverse()
|
|
is_driver_stream = self.stream_type == DRIVER_CAM
|
|
draw_road_overlays = not in_reverse and not is_driver_stream and not camera_view_none
|
|
draw_hud_controls = camera_view_none or (not in_reverse and not is_driver_stream)
|
|
self._hud_renderer.prepare(self._content_rect)
|
|
|
|
# Draw all UI overlays
|
|
if draw_road_overlays:
|
|
self._model_renderer.render(self._content_rect)
|
|
|
|
# Fade out bottom of overlays for looks
|
|
rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE)
|
|
if draw_hud_controls:
|
|
self._hud_renderer.render_background()
|
|
|
|
alert_to_render, not_animating_out = self._alert_renderer.will_render()
|
|
|
|
should_draw_dmoji = ui_state.is_onroad() and (
|
|
is_driver_stream or camera_view_none or ((not in_reverse) and (not self._hud_renderer.drawing_top_icons()))
|
|
)
|
|
self._driver_state_renderer.set_should_draw(should_draw_dmoji)
|
|
self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10)
|
|
if camera_view_none or is_driver_stream or not in_reverse:
|
|
self._driver_state_renderer.render()
|
|
|
|
self._hud_renderer.set_can_draw_top_icons(draw_hud_controls and (alert_to_render is None))
|
|
self._hud_renderer.set_wheel_critical_icon(draw_hud_controls and alert_to_render is not None and not not_animating_out and
|
|
alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired)
|
|
# TODO: have alert renderer draw offroad mici label below
|
|
if ui_state.started:
|
|
self._alert_renderer.render(self._content_rect)
|
|
if draw_hud_controls:
|
|
self._hud_renderer.render_foreground()
|
|
rendered_standstill_timer = False
|
|
if draw_hud_controls:
|
|
rendered_standstill_timer = self._standstill_timer.render(self._content_rect, in_reverse)
|
|
if draw_hud_controls and not rendered_standstill_timer:
|
|
self._min_steer_speed_banner.render(self._content_rect)
|
|
|
|
# End clipping region
|
|
rl.end_scissor_mode()
|
|
|
|
# Custom UI extension point - add custom overlays here
|
|
# Use self._content_rect for positioning within camera bounds
|
|
if draw_road_overlays:
|
|
if ui_state.params.get_bool("StockConfidenceBallWidget") and not self._sidebar_widgets.demo_active:
|
|
self._confidence_ball.render(self.rect)
|
|
else:
|
|
self._sidebar_widgets.render(self.rect)
|
|
if draw_hud_controls and (camera_view_none or is_driver_stream or not in_reverse):
|
|
self._favorite_slots.render(self._content_rect)
|
|
if camera_view_none or is_driver_stream or not in_reverse:
|
|
self._draw_border()
|
|
|
|
self._bookmark_icon.render(self.rect)
|
|
|
|
# Draw darkened background and text if not onroad
|
|
if not ui_state.started:
|
|
rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175))
|
|
self._offroad_label.render(self._content_rect)
|
|
|
|
# publish uiDebug
|
|
msg = messaging.new_message('uiDebug')
|
|
msg.uiDebug.drawTimeMillis = (time.monotonic() - start_draw) * 1000
|
|
self._pm.send('uiDebug', msg)
|
|
|
|
def _draw_border(self):
|
|
border_size = self._get_border_width()
|
|
# Keep the outer edge pinned to the camera bounds. Wider borders grow inward
|
|
# so they cannot paint over the fixed right-side widget column.
|
|
border_rect = rl.Rectangle(
|
|
self._content_rect.x + border_size / 2,
|
|
self._content_rect.y + border_size / 2,
|
|
self._content_rect.width - border_size,
|
|
self._content_rect.height - border_size,
|
|
)
|
|
rl.begin_scissor_mode(
|
|
int(self._content_rect.x),
|
|
int(self._content_rect.y),
|
|
int(self._content_rect.width),
|
|
int(self._content_rect.height),
|
|
)
|
|
rl.draw_rectangle_rounded_lines_ex(border_rect, 0.12, 16, border_size, get_border_color(ui_state))
|
|
rl.end_scissor_mode()
|
|
|
|
def _get_border_width(self) -> int:
|
|
return get_border_width(8, ui_state.params)
|
|
|
|
@staticmethod
|
|
def _is_in_reverse() -> bool:
|
|
if ui_state.sm.recv_frame["carState"] < ui_state.started_frame:
|
|
return False
|
|
|
|
try:
|
|
gear = ui_state.sm["carState"].gearShifter
|
|
except Exception:
|
|
return False
|
|
|
|
if gear == GEAR_SHIFTER_REVERSE:
|
|
return True
|
|
|
|
reverse_enum = getattr(car.CarState.GearShifter, "reverse", None)
|
|
if reverse_enum is not None and gear == reverse_enum:
|
|
return True
|
|
|
|
return str(gear).split(".")[-1].lower() == "reverse"
|
|
|
|
def is_in_reverse(self) -> bool:
|
|
return self._is_in_reverse()
|
|
|
|
@staticmethod
|
|
def _camera_view() -> int:
|
|
camera_view = ui_state.params.get_int("CameraView", return_default=True, default=CAMERA_VIEW_WIDE)
|
|
if camera_view not in (CAMERA_VIEW_AUTO, CAMERA_VIEW_DRIVER, CAMERA_VIEW_STANDARD, CAMERA_VIEW_WIDE, CAMERA_VIEW_NONE):
|
|
return CAMERA_VIEW_WIDE
|
|
return camera_view
|
|
|
|
def _switch_stream_if_needed(self, sm, camera_view: int):
|
|
if camera_view == CAMERA_VIEW_NONE:
|
|
self._cancel_pending_switch()
|
|
self._reverse_driver_camera_frames = 0
|
|
self._reverse_driver_camera_active = False
|
|
return
|
|
|
|
reentry_selection_pending = (getattr(self, "_onroad_reentry_pending", False) and
|
|
not getattr(self, "_reentry_stream_selected", False))
|
|
if reentry_selection_pending:
|
|
self._refresh_available_streams()
|
|
|
|
if self._update_reverse_driver_camera_state():
|
|
self.switch_stream(DRIVER_CAM)
|
|
return
|
|
|
|
wide_available = WIDE_CAM in self.available_streams
|
|
if camera_view == CAMERA_VIEW_DRIVER:
|
|
target = DRIVER_CAM
|
|
elif camera_view == CAMERA_VIEW_STANDARD:
|
|
target = ROAD_CAM
|
|
elif camera_view == CAMERA_VIEW_WIDE:
|
|
target = WIDE_CAM if wide_available else ROAD_CAM
|
|
elif sm['selfdriveState'].experimentalMode and wide_available:
|
|
v_ego = sm['carState'].vEgo
|
|
if v_ego < WIDE_CAM_MAX_SPEED:
|
|
target = WIDE_CAM
|
|
elif v_ego > ROAD_CAM_MIN_SPEED:
|
|
target = ROAD_CAM
|
|
else:
|
|
# Hysteresis zone - keep the current road camera selection.
|
|
target = WIDE_CAM if self.stream_type == WIDE_CAM and wide_available else ROAD_CAM
|
|
else:
|
|
target = ROAD_CAM
|
|
|
|
if (reentry_selection_pending or
|
|
self.stream_type != target or (self._switching and self._target_stream_type != target)):
|
|
self.switch_stream(target)
|
|
|
|
def _update_calibration(self):
|
|
# Update device camera if not already set
|
|
sm = ui_state.sm
|
|
if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']:
|
|
self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
|
|
|
|
# Check if live calibration data is available and valid
|
|
if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']):
|
|
return
|
|
|
|
calib = sm['liveCalibration']
|
|
if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED:
|
|
return
|
|
|
|
# Update view_from_calib matrix
|
|
device_from_calib = rot_from_euler(calib.rpyCalib)
|
|
self.view_from_calib = view_frame_from_device_frame @ device_from_calib
|
|
|
|
# Update wide calibration if available
|
|
if hasattr(calib, 'wideFromDeviceEuler') and len(calib.wideFromDeviceEuler) == 3:
|
|
wide_from_device = rot_from_euler(calib.wideFromDeviceEuler)
|
|
self.view_from_wide_calib = view_frame_from_device_frame @ wide_from_device @ device_from_calib
|
|
|
|
def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
|
|
if self.stream_type == DRIVER_CAM:
|
|
base = CameraView._calc_frame_matrix(self, rect)
|
|
driver_view_ratio = 1.5
|
|
base[0, 0] *= driver_view_ratio
|
|
base[1, 1] *= driver_view_ratio
|
|
return base
|
|
|
|
# Get camera configuration
|
|
# TODO: cache with vEgo?
|
|
calib_time = ui_state.sm.recv_frame['liveCalibration']
|
|
current_dims = (self._content_rect.width, self._content_rect.height)
|
|
device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
|
|
is_wide_camera = self.stream_type == WIDE_CAM
|
|
intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics
|
|
calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib
|
|
if is_wide_camera:
|
|
zoom = 0.7 * 1.5
|
|
else:
|
|
zoom = np.interp(ui_state.sm['carState'].vEgo, [10, 30], [0.8, 1.0])
|
|
|
|
# Calculate transforms for vanishing point
|
|
inf_point = np.array([1000.0, 0.0, 0.0])
|
|
calib_transform = intrinsic @ calibration
|
|
kep = calib_transform @ inf_point
|
|
|
|
# Calculate center points and dimensions
|
|
x, y = self._content_rect.x, self._content_rect.y
|
|
w, h = self._content_rect.width, self._content_rect.height
|
|
cx, cy = intrinsic[0, 2], intrinsic[1, 2]
|
|
|
|
# Ensure zoom views the whole area
|
|
zoom = max(zoom, w / (2 * cx), h / (2 * cy))
|
|
|
|
# Calculate max allowed offsets with margins
|
|
margin = 5
|
|
max_x_offset = max(0.0, cx * zoom - w / 2 - margin)
|
|
max_y_offset = max(0.0, cy * zoom - h / 2 - margin)
|
|
|
|
# Calculate and clamp offsets to prevent out-of-bounds issues
|
|
try:
|
|
if abs(kep[2]) > 1e-6:
|
|
x_offset = np.clip((kep[0] / kep[2] - cx) * zoom, -max_x_offset, max_x_offset)
|
|
y_offset = np.clip((kep[1] / kep[2] - cy) * zoom + CAM_Y_OFFSET, -max_y_offset, max_y_offset)
|
|
else:
|
|
x_offset, y_offset = 0, 0
|
|
except (ZeroDivisionError, OverflowError):
|
|
x_offset, y_offset = 0, 0
|
|
|
|
# Cache the computed transformation matrix to avoid recalculations
|
|
self._last_calib_time = calib_time
|
|
self._last_rect_dims = current_dims
|
|
self._last_stream_type = self.stream_type
|
|
self._cached_matrix = np.array([
|
|
[zoom * 2 * cx / w, 0, -x_offset / w * 2],
|
|
[0, zoom * 2 * cy / h, -y_offset / h * 2],
|
|
[0, 0, 1.0]
|
|
])
|
|
|
|
video_transform = np.array([
|
|
[zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
|
|
[0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
|
|
[0.0, 0.0, 1.0]
|
|
])
|
|
self._model_renderer.set_transform(video_transform @ calib_transform)
|
|
|
|
return self._cached_matrix
|
|
|
|
|
|
if __name__ == "__main__":
|
|
gui_app.init_window("OnRoad Camera View")
|
|
road_camera_view = AugmentedRoadView(ROAD_CAM)
|
|
print("***press space to switch camera view***")
|
|
try:
|
|
for _ in gui_app.render():
|
|
ui_state.update()
|
|
if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
|
|
if WIDE_CAM in road_camera_view.available_streams:
|
|
stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM
|
|
road_camera_view.switch_stream(stream)
|
|
road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
|
finally:
|
|
road_camera_view.close()
|