From c3d1f727c084e0dc3bd666959ed5273b40a6af5c Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:02:11 -0500 Subject: [PATCH] yas --- selfdrive/ui/layouts/home.py | 22 ++++++- selfdrive/ui/mici/onroad/cameraview.py | 36 ++++++---- .../ui/mici/tests/test_camera_cleanup.py | 51 ++++++++++++++ selfdrive/ui/onroad/cameraview.py | 37 ++++++----- selfdrive/ui/widgets/exp_mode_button.py | 66 +++++++++++++++++-- starpilot/system/speed_limit_vision.py | 25 +++++-- .../system/tests/test_speed_limit_vision.py | 30 +++++++++ 7 files changed, 227 insertions(+), 40 deletions(-) diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index 445d84cfe..c759346f6 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -179,7 +179,12 @@ class HomeLayout(Widget): version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y, version_text_width, self.header_rect.height) - gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) + version_font_size = 48 + version_text_size = measure_text_cached(font, self._version_text, version_font_size) + if version_text_size.x > version_rect.width: + version_font_size = max(32, int(version_font_size * version_rect.width / version_text_size.x)) + gui_label(version_rect, self._version_text, version_font_size, rl.WHITE, font_weight=FontWeight.MEDIUM, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT) def _render_home_content(self): self._render_left_column() @@ -233,6 +238,17 @@ class HomeLayout(Widget): self._prev_alerts_present = alerts_present def _get_version_text(self) -> str: - brand = "openpilot" + brand = "StarPilot" description = starpilot_display_description(self.params.get("UpdaterCurrentDescription")) - return f"{brand} {description}" if description else brand + version_text = f"{brand} {description}" if description else brand + + model_name = self.params.get("DrivingModelName", encoding="utf-8") or self.params.get_default_value("DrivingModelName") + if isinstance(model_name, bytes): + model_name = model_name.decode("utf-8", errors="ignore") + model_name = str(model_name or "").replace("_default", "").replace("(Default)", "").strip() + + if not model_name: + model_name = (self.params.get("Model", encoding="utf-8") or + self.params.get("DrivingModel", encoding="utf-8") or "").strip() + + return f"{version_text} - {model_name}" if model_name else version_text diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py index 3974a1304..f71835605 100644 --- a/selfdrive/ui/mici/onroad/cameraview.py +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -140,10 +140,7 @@ class CameraView(Widget): # Initialize EGL for zero-copy rendering when available. if self._use_egl: - # Create a 1x1 pixel placeholder texture for EGL image binding - temp_image = rl.gen_image_color(1, 1, rl.BLACK) - self.egl_texture = rl.load_texture_from_image(temp_image) - rl.unload_image(temp_image) + self._create_egl_texture() self_ref = weakref.ref(self) @@ -206,11 +203,6 @@ class CameraView(Widget): self._offroad_transition_callback = None self._clear_textures() - # Clean up EGL texture - if self.egl_texture: - rl.unload_texture(self.egl_texture) - self.egl_texture = None - # Clean up shader if self.shader and self.shader.id: rl.unload_shader(self.shader) @@ -360,6 +352,9 @@ class CameraView(Widget): return False self.last_connection_attempt = current_time + # A GL texture can retain the last EGL image after camerad exits. Release + # it before connect() frees and replaces the client's imported buffers. + self._clear_textures() if not self.client.connect(False) or not self.client.num_buffers: return False @@ -390,9 +385,9 @@ class CameraView(Widget): def _complete_switch(self) -> None: """Instantly switch to target stream.""" cloudlog.debug(f"Switching to {self._target_stream_type}") - # Clean up current resources - if self.client: - del self.client + # Delete the GL texture before releasing the old client. Merely destroying + # the EGLImage handle leaves its storage alive while a texture sibling exists. + self._clear_textures() # Switch to target self.client = self._target_client @@ -409,12 +404,20 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not self._use_egl: + if self._use_egl: + self._create_egl_texture() + else: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) + def _create_egl_texture(self): + # A fresh texture has no EGL image sibling from a previous camera client. + temp_image = rl.gen_image_color(1, 1, rl.BLACK) + self.egl_texture = rl.load_texture_from_image(temp_image) + rl.unload_image(temp_image) + def _clear_textures(self): if self.texture_y and self.texture_y.id: rl.unload_texture(self.texture_y) @@ -424,8 +427,13 @@ class CameraView(Widget): rl.unload_texture(self.texture_uv) self.texture_uv = None - # Clean up EGL resources + # Delete the texture first. eglDestroyImageKHR only destroys the EGLImage + # handle; the image storage stays alive while a GL texture sibling exists. if self._use_egl: + if self.egl_texture and self.egl_texture.id: + rl.unload_texture(self.egl_texture) + self.egl_texture = None + for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/mici/tests/test_camera_cleanup.py b/selfdrive/ui/mici/tests/test_camera_cleanup.py index af10cdb7e..2164d456a 100644 --- a/selfdrive/ui/mici/tests/test_camera_cleanup.py +++ b/selfdrive/ui/mici/tests/test_camera_cleanup.py @@ -70,3 +70,54 @@ def test_transition_callback_does_not_retain_camera_view(monkeypatch, module): assert view_ref() is None assert callbacks == [] + + +@pytest.mark.parametrize("module", (mici_cameraview, big_cameraview)) +def test_stream_switch_releases_graphics_before_old_client(module): + events = [] + + class FakeClient: + pass + + view = module.CameraView.__new__(module.CameraView) + view.client = FakeClient() + old_client_finalizer = weakref.finalize(view.client, events.append, "client") + view._target_client = FakeClient() + view._target_stream_type = object() + view._stream_type = object() + view._switching = True + view._texture_needs_update = False + view._closed = True + view._clear_textures = lambda: events.append("graphics") + view._initialize_textures = lambda: events.append("initialize") + + view._complete_switch() + gc.collect() + + assert old_client_finalizer.alive is False + assert events == ["graphics", "client", "initialize"] + + +@pytest.mark.parametrize("module", (mici_cameraview, big_cameraview)) +def test_egl_cleanup_deletes_texture_before_images(monkeypatch, module): + events = [] + view = module.CameraView.__new__(module.CameraView) + view.texture_y = None + view.texture_uv = None + view.egl_texture = SimpleNamespace(id=7) + view.egl_images = {0: object(), 1: object()} + view._closed = True + + if module is mici_cameraview: + view._use_egl = True + else: + monkeypatch.setattr(module, "TICI", True) + + monkeypatch.setattr(module.rl, "unload_texture", lambda _texture: events.append("texture")) + monkeypatch.setattr(module, "destroy_egl_image", lambda _image: events.append("image")) + + view._clear_textures() + + assert events == ["texture", "image", "image"] + assert view.egl_texture is None + assert view.egl_images == {} diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index 03638a1c3..9e93beb9d 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -100,11 +100,7 @@ class CameraView(Widget): if TICI: if not init_egl(): raise RuntimeError("Failed to initialize EGL") - - # Create a 1x1 pixel placeholder texture for EGL image binding - temp_image = rl.gen_image_color(1, 1, rl.BLACK) - self.egl_texture = rl.load_texture_from_image(temp_image) - rl.unload_image(temp_image) + self._create_egl_texture() self_ref = weakref.ref(self) @@ -167,11 +163,6 @@ class CameraView(Widget): self._offroad_transition_callback = None self._clear_textures() - # Clean up EGL texture - if TICI and self.egl_texture: - rl.unload_texture(self.egl_texture) - self.egl_texture = None - # Clean up shader if self.shader and self.shader.id: rl.unload_shader(self.shader) @@ -311,6 +302,9 @@ class CameraView(Widget): return False self.last_connection_attempt = current_time + # A GL texture can retain the last EGL image after camerad exits. Release + # it before connect() frees and replaces the client's imported buffers. + self._clear_textures() if not self.client.connect(False) or not self.client.num_buffers: return False @@ -341,9 +335,9 @@ class CameraView(Widget): def _complete_switch(self) -> None: """Instantly switch to target stream.""" cloudlog.debug(f"Switching to {self._target_stream_type}") - # Clean up current resources - if self.client: - del self.client + # Delete the GL texture before releasing the old client. Merely destroying + # the EGLImage handle leaves its storage alive while a texture sibling exists. + self._clear_textures() # Switch to target self.client = self._target_client @@ -360,12 +354,20 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not TICI: + if TICI: + self._create_egl_texture() + else: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) + def _create_egl_texture(self): + # A fresh texture has no EGL image sibling from a previous camera client. + temp_image = rl.gen_image_color(1, 1, rl.BLACK) + self.egl_texture = rl.load_texture_from_image(temp_image) + rl.unload_image(temp_image) + def _clear_textures(self): if self.texture_y and self.texture_y.id: rl.unload_texture(self.texture_y) @@ -375,8 +377,13 @@ class CameraView(Widget): rl.unload_texture(self.texture_uv) self.texture_uv = None - # Clean up EGL resources + # Delete the texture first. eglDestroyImageKHR only destroys the EGLImage + # handle; the image storage stays alive while a GL texture sibling exists. if TICI: + if self.egl_texture and self.egl_texture.id: + rl.unload_texture(self.egl_texture) + self.egl_texture = None + for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/widgets/exp_mode_button.py b/selfdrive/ui/widgets/exp_mode_button.py index d9e85c7b8..241d48169 100644 --- a/selfdrive/ui/widgets/exp_mode_button.py +++ b/selfdrive/ui/widgets/exp_mode_button.py @@ -2,6 +2,7 @@ import pyray as rl from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.starpilot.common.experimental_state import requested_experimental_mode @@ -17,12 +18,23 @@ class ExperimentalModeButton(Widget): self.params = Params() self.experimental_mode = requested_experimental_mode(self.params, ui_state.params_memory) + self.conditional_mode = self._get_conditional_mode() self.chill_pixmap = gui_app.texture("icons/couch.png", self.img_width, self.img_width) self.experimental_pixmap = gui_app.texture("icons/experimental_grey.png", self.img_width, self.img_width) def show_event(self): self.experimental_mode = requested_experimental_mode(self.params, ui_state.params_memory) + self.conditional_mode = self._get_conditional_mode() + + def _get_conditional_mode(self): + if self.params.get_bool("SafeMode"): + return None + if self.params.get_bool("ConditionalExperimental"): + return "experimental" + if self.params.get_bool("ConditionalChill"): + return "chill" + return None def _get_gradient_colors(self): alpha = 0xCC if self.is_pressed else 0xFF @@ -33,6 +45,40 @@ class ExperimentalModeButton(Widget): return rl.Color(20, 255, 171, alpha), rl.Color(35, 149, 255, alpha) def _draw_gradient_background(self, rect): + if self.conditional_mode: + alpha = 0xCC if self.is_pressed else 0xFF + blue = rl.Color(35, 149, 255, alpha) + mint = rl.Color(20, 255, 171, alpha) + orange = rl.Color(255, 138, 22, alpha) + red = rl.Color(219, 56, 34, alpha) + if self.conditional_mode == "experimental": + dominant_start, dominant_end = blue, mint + target_start, target_end = orange, red + else: + dominant_start, dominant_end = orange, red + target_start, target_end = blue, mint + + transition_start = int(rect.x + rect.width * 0.58) + transition_end = int(rect.x + rect.width * 0.80) + right = int(rect.x + rect.width) + dominant_width = transition_start - int(rect.x) + target_width = right - transition_end + target_progress = 0.67 + target_visible_end = rl.Color( + round(target_start.r + (target_end.r - target_start.r) * target_progress), + round(target_start.g + (target_end.g - target_start.g) * target_progress), + round(target_start.b + (target_end.b - target_start.b) * target_progress), + alpha, + ) + + rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), dominant_width, int(rect.height), + dominant_start, dominant_end) + rl.draw_rectangle_gradient_h(transition_start, int(rect.y), transition_end - transition_start, int(rect.height), + dominant_end, target_start) + rl.draw_rectangle_gradient_h(transition_end, int(rect.y), target_width, int(rect.height), + target_start, target_visible_end) + return + start_color, end_color = self._get_gradient_colors() rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), int(rect.width), int(rect.height), start_color, end_color) @@ -49,11 +95,23 @@ class ExperimentalModeButton(Widget): rl.draw_line_ex(rl.Vector2(line_x, rect.y), rl.Vector2(line_x, rect.y + rect.height), 3, separator_color) # Draw text label (left aligned) - text = tr("EXPERIMENTAL MODE ON") if self.experimental_mode else tr("CHILL MODE ON") - text_x = rect.x + self.horizontal_padding - text_y = rect.y + rect.height / 2 - 45 * FONT_SCALE // 2 # Center vertically + if self.conditional_mode == "experimental": + text = tr("CONDITIONAL EXPERIMENTAL") + elif self.conditional_mode == "chill": + text = tr("CONDITIONAL CHILL") + else: + text = tr("EXPERIMENTAL MODE ON") if self.experimental_mode else tr("CHILL MODE ON") - rl.draw_text_ex(gui_app.font(FontWeight.NORMAL), text, rl.Vector2(int(text_x), int(text_y)), 45, 0, rl.BLACK) + text_x = rect.x + self.horizontal_padding + font = gui_app.font(FontWeight.NORMAL) + font_size = 45 + available_width = line_x - text_x - self.horizontal_padding + measured_width = measure_text_cached(font, text, font_size).x + if measured_width > available_width: + font_size = max(32, int(font_size * available_width / measured_width)) + text_y = rect.y + rect.height / 2 - font_size * FONT_SCALE // 2 # Center vertically + + rl.draw_text_ex(font, text, rl.Vector2(int(text_x), int(text_y)), font_size, 0, rl.BLACK) # Draw icon (right aligned) icon_x = rect.x + rect.width - self.horizontal_padding - self.img_width diff --git a/starpilot/system/speed_limit_vision.py b/starpilot/system/speed_limit_vision.py index b2825769e..4e10f2ace 100644 --- a/starpilot/system/speed_limit_vision.py +++ b/starpilot/system/speed_limit_vision.py @@ -1035,6 +1035,25 @@ class SpeedLimitVisionDaemon: self.stream_type = None self.stream_name = "" + def _receive_frame_bgr(self): + # Keep VisionBuf and its NumPy view inside this short-lived scope. A local + # in run() survives loop iterations and can otherwise retain the old + # VisionIpcClient, including all imported camera buffers, while offroad. + client = self.client + if client is None: + return None + + buffer = client.recv() + if buffer is None: + return None + + data = buffer.data + if not data.any(): + return None + + image = np.frombuffer(data, dtype=np.uint8).reshape((len(data) // client.stride, client.stride)) + return cv2.cvtColor(image[:client.height * 3 // 2, :client.width], cv2.COLOR_YUV2BGR_NV12) + @staticmethod def _letterbox(image, shape=(640, 640), color=(114, 114, 114)): image_height, image_width = image.shape[:2] @@ -2526,7 +2545,7 @@ class SpeedLimitVisionDaemon: ratekeeper.keep_time() continue - buffer = self.client.recv() if self.client is not None else None + frame_bgr = self._receive_frame_bgr() self.inference_count += 1 inference_started_at = time.monotonic() self.last_frame_process_duration_s = 0.0 @@ -2534,7 +2553,7 @@ class SpeedLimitVisionDaemon: self.last_detector_forward_duration_s = 0.0 self.last_classifier_forward_count = 0 self.last_classifier_forward_duration_s = 0.0 - if buffer is None or not buffer.data.any(): + if frame_bgr is None: self.empty_frame_count += 1 stale_cleared = self._clear_published_detection_if_stale(now, "empty_frame") if self.published_speed_limit_mph > 0 and not stale_cleared: @@ -2548,8 +2567,6 @@ class SpeedLimitVisionDaemon: ratekeeper.keep_time() continue - image = np.frombuffer(buffer.data, dtype=np.uint8).reshape((len(buffer.data) // self.client.stride, self.client.stride)) - frame_bgr = cv2.cvtColor(image[:self.client.height * 3 // 2, :self.client.width], cv2.COLOR_YUV2BGR_NV12) self.current_frame_bgr = frame_bgr if detector_due: diff --git a/starpilot/system/tests/test_speed_limit_vision.py b/starpilot/system/tests/test_speed_limit_vision.py index fb413f15c..f433187d4 100644 --- a/starpilot/system/tests/test_speed_limit_vision.py +++ b/starpilot/system/tests/test_speed_limit_vision.py @@ -1,4 +1,6 @@ from collections import deque +import gc +import weakref import numpy as np import pytest @@ -63,6 +65,34 @@ def test_disconnect_camera_releases_client_state(): assert daemon.stream_name == "" +def test_receive_frame_does_not_retain_vision_buffer(monkeypatch): + buffer_refs = [] + + class FakeBuffer: + def __init__(self): + self.data = np.ones(6, dtype=np.uint8) + + class FakeClient: + width = 2 + height = 2 + stride = 2 + + def recv(self): + buffer = FakeBuffer() + buffer_refs.append(weakref.ref(buffer)) + return buffer + + daemon = SpeedLimitVisionDaemon.__new__(SpeedLimitVisionDaemon) + daemon.client = FakeClient() + monkeypatch.setattr(slv.cv2, "cvtColor", lambda image, _conversion: np.array(image, copy=True)) + + frame = daemon._receive_frame_bgr() + gc.collect() + + assert frame.shape == (3, 2) + assert buffer_refs[0]() is None + + def test_published_sign_value_uses_configured_units(): imperial_daemon = publishing_daemon(False) metric_daemon = publishing_daemon(True)