This commit is contained in:
firestar5683
2026-07-20 16:02:11 -05:00
parent 94e60c5d70
commit c3d1f727c0
7 changed files with 227 additions and 40 deletions
+19 -3
View File
@@ -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
+22 -14
View File
@@ -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 = {}
@@ -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 == {}
+22 -15
View File
@@ -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 = {}
+62 -4
View File
@@ -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
+21 -4
View File
@@ -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:
@@ -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)