From aaf2aac050e1c0e7f33e018f415b07d9e6ce0c54 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Tue, 30 Sep 2025 03:11:42 -0700 Subject: [PATCH] raylib: training guide (#36224) * fix regulatory * debug slow loading * easy gather step coords * gotcha * and fix * dm option * fix final * fixes * progress bar! * "vibe coding is great" * wtf gpt5 * jfc * hand crafted >> vibe * it's slow so only load images if we're doing any kind of training * tf * format * clean up * clean up * no float * cmt * more clean up * clean up * eww * rm * no debug * match y * clean that up * here too * windows --- selfdrive/ui/layouts/main.py | 6 + selfdrive/ui/layouts/onboarding.py | 197 ++++++++++++++++++++++++ selfdrive/ui/layouts/settings/device.py | 16 +- 3 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 selfdrive/ui/layouts/onboarding.py diff --git a/selfdrive/ui/layouts/main.py b/selfdrive/ui/layouts/main.py index ffb45f821d..97f9d6b222 100644 --- a/selfdrive/ui/layouts/main.py +++ b/selfdrive/ui/layouts/main.py @@ -8,6 +8,7 @@ from openpilot.selfdrive.ui.layouts.settings.settings import SettingsLayout, Pan from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.system.ui.widgets import Widget +from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow ONROAD_FPS = 20 @@ -41,6 +42,11 @@ class MainLayout(Widget): # Set callbacks self._setup_callbacks() + # Start onboarding if terms or training not completed + self._onboarding_window = OnboardingWindow() + if not self._onboarding_window.completed: + gui_app.set_modal_overlay(self._onboarding_window) + def _render(self, _): self._handle_onroad_transition() self._render_main_content() diff --git a/selfdrive/ui/layouts/onboarding.py b/selfdrive/ui/layouts/onboarding.py new file mode 100644 index 0000000000..ea6fedebf7 --- /dev/null +++ b/selfdrive/ui/layouts/onboarding.py @@ -0,0 +1,197 @@ +import os +import re +from enum import IntEnum + +import pyray as rl +from openpilot.common.basedir import BASEDIR +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.button import Button, ButtonStyle +from openpilot.system.ui.widgets.label import Label, TextAlignment +from openpilot.selfdrive.ui.ui_state import ui_state + +DEBUG = False + +STEP_RECTS = [rl.Rectangle(104, 800, 633, 175), rl.Rectangle(1835, 0, 2159, 1080), rl.Rectangle(1835, 0, 2156, 1080), + rl.Rectangle(1526, 473, 427, 472), rl.Rectangle(1643, 441, 217, 223), rl.Rectangle(1835, 0, 2155, 1080), + rl.Rectangle(1786, 591, 267, 236), rl.Rectangle(1353, 0, 804, 1080), rl.Rectangle(1458, 485, 633, 211), + rl.Rectangle(95, 794, 1158, 187), rl.Rectangle(1560, 170, 392, 397), rl.Rectangle(1835, 0, 2159, 1080), + rl.Rectangle(1351, 0, 807, 1080), rl.Rectangle(1835, 0, 2158, 1080), rl.Rectangle(1531, 82, 441, 920), + rl.Rectangle(1336, 438, 490, 393), rl.Rectangle(1835, 0, 2159, 1080), rl.Rectangle(1835, 0, 2159, 1080), + rl.Rectangle(87, 795, 1187, 186)] + +DM_RECORD_STEP = 9 +DM_RECORD_YES_RECT = rl.Rectangle(695, 794, 558, 187) + +RESTART_TRAINING_RECT = rl.Rectangle(87, 795, 472, 186) + + +class OnboardingState(IntEnum): + TERMS = 0 + ONBOARDING = 1 + DECLINE = 2 + + +class TrainingGuide(Widget): + def __init__(self, completed_callback=None): + super().__init__() + self._completed_callback = completed_callback + + self._step = 0 + self._load_images() + + def _load_images(self): + self._images = [] + paths = [fn for fn in os.listdir(os.path.join(BASEDIR, "selfdrive/assets/training")) if re.match(r'^step\d*\.png$', fn)] + paths = sorted(paths, key=lambda x: int(re.search(r'\d+', x).group())) + for fn in paths: + path = os.path.join(BASEDIR, "selfdrive/assets/training", fn) + self._images.append(gui_app.texture(path, gui_app.width, gui_app.height)) + + def _handle_mouse_release(self, mouse_pos): + if rl.check_collision_point_rec(mouse_pos, STEP_RECTS[self._step]): + # Record DM camera? + if self._step == DM_RECORD_STEP: + yes = rl.check_collision_point_rec(mouse_pos, DM_RECORD_YES_RECT) + print(f"putting RecordFront to {yes}") + ui_state.params.put_bool("RecordFront", yes) + + # Restart training? + elif self._step == len(self._images) - 1: + if rl.check_collision_point_rec(mouse_pos, RESTART_TRAINING_RECT): + self._step = -1 + + self._step += 1 + + # Finished? + if self._step >= len(self._images): + self._step = 0 + if self._completed_callback: + self._completed_callback() + + def _render(self, _): + rl.draw_texture(self._images[self._step], 0, 0, rl.WHITE) + + # progress bar + if 0 < self._step < len(STEP_RECTS) - 1: + h = 20 + w = int((self._step / (len(STEP_RECTS) - 1)) * self._rect.width) + rl.draw_rectangle(int(self._rect.x), int(self._rect.y + self._rect.height - h), + w, h, rl.Color(70, 91, 234, 255)) + + if DEBUG: + rl.draw_rectangle_lines_ex(STEP_RECTS[self._step], 3, rl.RED) + + return -1 + + +class TermsPage(Widget): + def __init__(self, on_accept=None, on_decline=None): + super().__init__() + self._on_accept = on_accept + self._on_decline = on_decline + + self._title = Label("Welcome to openpilot", font_size=90, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.LEFT) + self._desc = Label("You must accept the Terms and Conditions to use openpilot. Read the latest terms at https://comma.ai/terms before continuing.", + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT) + + self._decline_btn = Button("Decline", click_callback=on_decline) + self._accept_btn = Button("Agree", button_style=ButtonStyle.PRIMARY, click_callback=on_accept) + + def _render(self, _): + welcome_x = self._rect.x + 165 + welcome_y = self._rect.y + 165 + welcome_rect = rl.Rectangle(welcome_x, welcome_y, self._rect.width - welcome_x, 90) + self._title.render(welcome_rect) + + desc_x = welcome_x + # TODO: Label doesn't top align when wrapping + desc_y = welcome_y - 100 + desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250) + self._desc.render(desc_rect) + + btn_y = self._rect.y + self._rect.height - 160 - 45 + btn_width = (self._rect.width - 45 * 3) / 2 + self._decline_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + self._accept_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160)) + + if DEBUG: + rl.draw_rectangle_lines_ex(welcome_rect, 3, rl.RED) + rl.draw_rectangle_lines_ex(desc_rect, 3, rl.RED) + + return -1 + + +class DeclinePage(Widget): + def __init__(self, back_callback=None): + super().__init__() + self._text = Label("You must accept the Terms and Conditions in order to use openpilot.", + font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT) + self._back_btn = Button("Back", click_callback=back_callback) + self._uninstall_btn = Button("Decline, uninstall openpilot", button_style=ButtonStyle.DANGER, + click_callback=self._on_uninstall_clicked) + + def _on_uninstall_clicked(self): + ui_state.params.put_bool("DoUninstall", True) + gui_app.request_close() + + def _render(self, _): + btn_y = self._rect.y + self._rect.height - 160 - 45 + btn_width = (self._rect.width - 45 * 3) / 2 + self._back_btn.render(rl.Rectangle(self._rect.x + 45, btn_y, btn_width, 160)) + self._uninstall_btn.render(rl.Rectangle(self._rect.x + 45 * 2 + btn_width, btn_y, btn_width, 160)) + + # text rect in middle of top and button + text_height = btn_y - (200 + 45) + text_rect = rl.Rectangle(self._rect.x + 165, self._rect.y + (btn_y - text_height) / 2 + 10, self._rect.width - (165 * 2), text_height) + if DEBUG: + rl.draw_rectangle_lines_ex(text_rect, 3, rl.RED) + self._text.render(text_rect) + + +class OnboardingWindow(Widget): + def __init__(self): + super().__init__() + self._current_terms_version = ui_state.params.get("TermsVersion") + self._current_training_version = ui_state.params.get("TrainingVersion") + self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == self._current_terms_version + self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == self._current_training_version + + self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING + + # Windows + self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined) + self._training_guide: TrainingGuide | None = None + self._decline_page = DeclinePage(back_callback=self._on_decline_back) + + @property + def completed(self) -> bool: + return self._accepted_terms and self._training_done + + def _on_terms_declined(self): + self._state = OnboardingState.DECLINE + + def _on_decline_back(self): + self._state = OnboardingState.TERMS + + def _on_terms_accepted(self): + ui_state.params.put("HasAcceptedTerms", self._current_terms_version) + self._state = OnboardingState.ONBOARDING + if self._training_done: + gui_app.set_modal_overlay(None) + + def _on_completed_training(self): + ui_state.params.put("CompletedTrainingVersion", self._current_training_version) + gui_app.set_modal_overlay(None) + + def _render(self, _): + if self._training_guide is None: + self._training_guide = TrainingGuide(completed_callback=self._on_completed_training) + + if self._state == OnboardingState.TERMS: + self._terms.render(self._rect) + if self._state == OnboardingState.ONBOARDING: + self._training_guide.render(self._rect) + elif self._state == OnboardingState.DECLINE: + self._decline_page.render(self._rect) + return -1 diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 14847df102..b34042d43e 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -5,6 +5,7 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.layouts.onboarding import TrainingGuide from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog from openpilot.system.hardware import TICI from openpilot.system.ui.lib.application import gui_app @@ -36,6 +37,7 @@ class DeviceLayout(Widget): self._driver_camera: DriverCameraDialog | None = None self._pair_device_dialog: PairingDialog | None = None self._fcc_dialog: HtmlRenderer | None = None + self._training_guide: TrainingGuide | None = None items = self._initialize_items() self._scroller = Scroller(items, line_separator=True, spacing=0) @@ -145,9 +147,11 @@ class DeviceLayout(Widget): def _on_regulatory(self): if not self._fcc_dialog: self._fcc_dialog = HtmlRenderer(os.path.join(BASEDIR, "selfdrive/assets/offroad/fcc.html")) - - gui_app.set_modal_overlay(self._fcc_dialog, - callback=lambda result: setattr(self, '_fcc_dialog', None), - ) - - def _on_review_training_guide(self): pass + gui_app.set_modal_overlay(self._fcc_dialog, callback=lambda result: setattr(self, '_fcc_dialog', None)) + + def _on_review_training_guide(self): + if not self._training_guide: + def completed_callback(): + gui_app.set_modal_overlay(None) + self._training_guide = TrainingGuide(completed_callback=completed_callback) + gui_app.set_modal_overlay(self._training_guide)