import atexit import os import time import pyray as rl from enum import IntEnum from openpilot.common.basedir import BASEDIR from openpilot.common.swaglog import cloudlog from openpilot.system.hardware import HARDWARE DEFAULT_FPS = 60 FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions ENABLE_VSYNC = os.getenv("ENABLE_VSYNC") == "1" DEBUG_FPS = os.getenv("DEBUG_FPS") == '1' STRICT_MODE = os.getenv("STRICT_MODE") == '1' DEFAULT_TEXT_SIZE = 60 DEFAULT_TEXT_COLOR = rl.Color(200, 200, 200, 255) ASSETS_DIR = os.path.join(BASEDIR, "selfdrive/assets") FONT_DIR = os.path.join(BASEDIR, "selfdrive/assets/fonts") class FontWeight(IntEnum): BLACK = 0 BOLD = 1 EXTRA_BOLD = 2 EXTRA_LIGHT = 3 MEDIUM = 4 NORMAL = 5 SEMI_BOLD = 6 THIN = 7 class GuiApplication: def __init__(self, width: int, height: int): self._fonts: dict[FontWeight, rl.Font] = {} self._width = width self._height = height self._textures: dict[str, rl.Texture] = {} self._target_fps: int = DEFAULT_FPS self._last_fps_log_time: float = time.monotonic() self._window_close_requested = False self._trace_log_callback = None def request_close(self): self._window_close_requested = True def init_window(self, title: str, fps: int = DEFAULT_FPS): atexit.register(self.close) # Automatically call close() on exit HARDWARE.set_display_power(True) HARDWARE.set_screen_brightness(65) self._set_log_callback() rl.set_trace_log_level(rl.TraceLogLevel.LOG_ALL) flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT if ENABLE_VSYNC: flags |= rl.ConfigFlags.FLAG_VSYNC_HINT rl.set_config_flags(flags) rl.init_window(self._width, self._height, title) rl.set_target_fps(fps) self._target_fps = fps self._set_styles() self._load_fonts() def texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True): cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}" if cache_key in self._textures: return self._textures[cache_key] texture_obj = self._load_texture_from_image(os.path.join(ASSETS_DIR, asset_path), width, height, alpha_premultiply, keep_aspect_ratio) self._textures[cache_key] = texture_obj return texture_obj def _load_texture_from_image(self, image_path: str, width: int, height: int, alpha_premultiply = False, keep_aspect_ratio=True): """Load and resize a texture, storing it for later automatic unloading.""" if image_path.endswith('.svg'): image = self._load_image_from_svg(image_path) else: image = rl.load_image(image_path) if alpha_premultiply: rl.image_alpha_premultiply(image) # Resize with aspect ratio preservation if requested if keep_aspect_ratio: orig_width = image.width orig_height = image.height scale_width = width / orig_width scale_height = height / orig_height # Calculate new dimensions scale = min(scale_width, scale_height) new_width = int(orig_width * scale) new_height = int(orig_height * scale) rl.image_resize(image, new_width, new_height) else: rl.image_resize(image, width, height) texture = rl.load_texture_from_image(image) # Set texture filtering to smooth the result rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) rl.unload_image(image) return texture def _load_image_from_svg(self, svg_path: str): # TODO: Implement SVG loading assert(0) def close(self): if not rl.is_window_ready(): return for texture in self._textures.values(): rl.unload_texture(texture) self._textures = {} for font in self._fonts.values(): rl.unload_font(font) self._fonts = {} rl.close_window() def render(self): while not (self._window_close_requested or rl.window_should_close()): rl.begin_drawing() rl.clear_background(rl.BLACK) yield if DEBUG_FPS: rl.draw_fps(10, 10) rl.end_drawing() self._monitor_fps() def font(self, font_weight: FontWeight=FontWeight.NORMAL): return self._fonts[font_weight] @property def width(self): return self._width @property def height(self): return self._height def _load_fonts(self): font_files = ( "Inter-Black.ttf", "Inter-Bold.ttf", "Inter-ExtraBold.ttf", "Inter-ExtraLight.ttf", "Inter-Medium.ttf", "Inter-Regular.ttf", "Inter-SemiBold.ttf", "Inter-Thin.ttf" ) for index, font_file in enumerate(font_files): font = rl.load_font_ex(os.path.join(FONT_DIR, font_file), 120, None, 0) rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) self._fonts[index] = font rl.gui_set_font(self._fonts[FontWeight.NORMAL]) def _set_styles(self): rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0) rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE) rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.BLACK)) rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR)) rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255))) def _set_log_callback(self): @rl.ffi.callback("void(int, char *, void *)") def trace_log_callback(log_level, text, args): try: text_str = rl.ffi.string(text).decode('utf-8') except (TypeError, UnicodeDecodeError): text_str = str(text) if log_level == rl.TraceLogLevel.LOG_ERROR: cloudlog.error(f"raylib: {text_str}") elif log_level == rl.TraceLogLevel.LOG_WARNING: cloudlog.warning(f"raylib: {text_str}") elif log_level == rl.TraceLogLevel.LOG_INFO: cloudlog.info(f"raylib: {text_str}") elif log_level == rl.TraceLogLevel.LOG_DEBUG: cloudlog.debug(f"raylib: {text_str}") else: cloudlog.error(f"raylib: Unknown level {log_level}: {text_str}") # Store callback reference self._trace_log_callback = trace_log_callback rl.set_trace_log_callback(self._trace_log_callback) def _monitor_fps(self): fps = rl.get_fps() # Log FPS drop below threshold at regular intervals if fps < self._target_fps * FPS_DROP_THRESHOLD: current_time = time.monotonic() if current_time - self._last_fps_log_time >= FPS_LOG_INTERVAL: cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}") self._last_fps_log_time = current_time # Strict mode: terminate UI if FPS drops too much if STRICT_MODE and fps < self._target_fps * FPS_CRITICAL_THRESHOLD: cloudlog.error(f"FPS dropped critically below {fps}. Shutting down UI.") os._exit(1) gui_app = GuiApplication(2160, 1080)