import atexit import cffi import math import os import queue import time import signal import sys import pyray as rl import threading import platform import subprocess from contextlib import contextmanager from collections.abc import Callable from collections import deque from enum import IntEnum, StrEnum from pathlib import Path from typing import NamedTuple from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog from openpilot.common.hardware import HARDWARE, PC from openpilot.system.ui.lib.multilang import FONT_FALLBACK_LANGUAGES, TRANSLATIONS_DIR, multilang from openpilot.common.realtime import Ratekeeper _DEFAULT_FPS = int(os.getenv("FPS", {'tizi': 20}.get(HARDWARE.get_device_type(), 60))) FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops AUTO_TIMING_DURATION = 6.0 AUTO_TIMING_COOLDOWN = 60.0 AUTO_TIMING_MAX_SAMPLES = 360 FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz MAX_TOUCH_SLOTS = 2 TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out BIG_UI = os.getenv("BIG", "0") == "1" ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1" SHOW_FPS = os.getenv("SHOW_FPS") == "1" SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1" STRICT_MODE = os.getenv("STRICT_MODE") == "1" SCALE = float(os.getenv("SCALE", "1.0")) GRID_SIZE = int(os.getenv("GRID", "0")) UI_FRAME_TIMING = os.getenv("UI_FRAME_TIMING") == "1" PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0")) PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output RECORD = os.getenv("RECORD") == "1" RECORD_OUTPUT = str(Path(os.getenv("RECORD_OUTPUT", "output")).with_suffix(".mp4")) RECORD_QUALITY = int(os.getenv("RECORD_QUALITY", "23")) # Dynamic bitrate quality level (CRF); 0 is lossless (bigger size), max is 51, default is 23 for x264 RECORD_BITRATE = os.getenv("RECORD_BITRATE", "") # Target bitrate e.g. "2000k" (overrides RECORD_QUALITY when set) RECORD_SPEED = int(os.getenv("RECORD_SPEED", "1")) # Speed multiplier OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offline rendering GL_VERSION = """ #version 300 es precision highp float; """ if platform.system() == "Darwin": GL_VERSION = """ #version 330 core """ BURN_IN_MODE = "BURN_IN" in os.environ BURN_IN_VERTEX_SHADER = GL_VERSION + """ in vec3 vertexPosition; in vec2 vertexTexCoord; uniform mat4 mvp; out vec2 fragTexCoord; void main() { fragTexCoord = vertexTexCoord; gl_Position = mvp * vec4(vertexPosition, 1.0); } """ BURN_IN_FRAGMENT_SHADER = GL_VERSION + """ in vec2 fragTexCoord; uniform sampler2D texture0; out vec4 fragColor; void main() { vec4 sampled = texture(texture0, fragTexCoord); float intensity = sampled.b; // Map blue intensity to green -> yellow -> red to highlight burn-in risk. vec3 start = vec3(0.0, 1.0, 0.0); vec3 middle = vec3(1.0, 1.0, 0.0); vec3 end = vec3(1.0, 0.0, 0.0); vec3 gradient = mix(start, middle, clamp(intensity * 2.0, 0.0, 1.0)); gradient = mix(gradient, end, clamp((intensity - 0.5) * 2.0, 0.0, 1.0)); fragColor = vec4(gradient, sampled.a); } """ DEFAULT_TEXT_SIZE = 60 DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9)) # Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles # The real scales for the fonts below range from 1.212 to 1.266 FONT_SCALE = 1.242 if BIG_UI else 1.16 ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets") FONT_DIR = ASSETS_DIR.joinpath("fonts") EXTRA_FONT_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" NOTO_FONTS = { "ja": "NotoSansCJKjp-Regular.otf", "ko": "NotoSansCJKkr-Regular.otf", "th": "NotoSansThai-Regular.ttf", "zh-CHS": "NotoSansCJKsc-Regular.otf", "zh-CHT": "NotoSansCJKtc-Regular.otf", } class FontWeight(StrEnum): NORMAL = "Inter-Regular.ttf" if BIG_UI else "Inter-Medium.ttf" MEDIUM = "Inter-Medium.ttf" BOLD = "Inter-Bold.ttf" SEMI_BOLD = "Inter-SemiBold.ttf" UNIFONT = "unifont.otf" # Small UI fonts DISPLAY_REGULAR = "Inter-Regular.ttf" ROMAN = "Inter-Regular.ttf" DISPLAY = "Inter-Bold.ttf" class TextAlignment(IntEnum): LEFT = 0 CENTER = 1 RIGHT = 2 class TextAlignmentVertical(IntEnum): TOP = 0 MIDDLE = 1 BOTTOM = 2 def font_fallback(font: rl.Font) -> rl.Font: """Use a Noto fallback for languages not covered by Inter.""" if multilang.requires_font_fallback(): return gui_app.fallback_font() return font class MousePos(NamedTuple): x: float y: float class MousePosWithTime(NamedTuple): x: float y: float t: float class MouseEvent(NamedTuple): pos: MousePos slot: int left_pressed: bool left_released: bool left_down: bool t: float class MouseState: def __init__(self, scale: float = 1.0): self._scale = scale self._events: deque[MouseEvent] = deque(maxlen=MOUSE_THREAD_RATE) # bound event list self._prev_mouse_event: list[MouseEvent | None] = [None] * MAX_TOUCH_SLOTS self._rk = Ratekeeper(MOUSE_THREAD_RATE, print_delay_threshold=None) self._lock = threading.Lock() self._exit_event = threading.Event() self._thread = None def get_events(self) -> list[MouseEvent]: with self._lock: events = list(self._events) self._events.clear() return events def start(self): self._exit_event.clear() if self._thread is None or not self._thread.is_alive(): self._thread = threading.Thread(target=self._run_thread, daemon=True) self._thread.start() def stop(self): self._exit_event.set() if self._thread is not None and self._thread.is_alive(): self._thread.join() def _run_thread(self): while not self._exit_event.is_set(): rl.poll_input_events() self._handle_mouse_event() self._rk.keep_time() def _handle_mouse_event(self): # TODO: read touch events from evdev directly to get real kernel timestamps. # Polling at 140Hz with time.monotonic() causes timing jitter that makes scroll # velocity oscillate (alternating high/low). Real timestamps would also let us # detect swipe-stop-lift via event gaps instead of the fragile decel heuristic. for slot in range(MAX_TOUCH_SLOTS): mouse_pos = rl.get_touch_position(slot) x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x y = mouse_pos.y / self._scale if self._scale != 1.0 else mouse_pos.y ev = MouseEvent( MousePos(x, y), slot, rl.is_mouse_button_pressed(slot), # noqa: TID251 rl.is_mouse_button_released(slot), # noqa: TID251 rl.is_mouse_button_down(slot), time.monotonic(), ) self._append_mouse_event(ev) def _append_mouse_event(self, ev: MouseEvent): if ev.left_pressed and ev.left_released: self._append_mouse_event(MouseEvent(ev.pos, ev.slot, True, False, True, ev.t)) self._append_mouse_event(MouseEvent(ev.pos, ev.slot, False, True, False, ev.t)) return prev = self._prev_mouse_event[ev.slot] if prev is None or ev[:-1] != prev[:-1]: with self._lock: self._events.append(ev) self._prev_mouse_event[ev.slot] = ev class FrameTiming(NamedTuple): draw_wall_ms: float draw_cpu_ms: float update_wall_ms: float update_cpu_ms: float present_wall_ms: float present_cpu_ms: float FRAME_PHASES = ("camera", "model", "ui_state", "transition", "controllers") class GuiApplication: def __init__(self, width: int | None = None, height: int | None = None): self._set_log_callback() self._fonts: dict[FontWeight, rl.Font] = {} self._fallback_fonts: dict[str, rl.Font] = {} self._width = width if width is not None else GuiApplication._default_width() self._height = height if height is not None else GuiApplication._default_height() if PC and os.getenv("SCALE") is None: self._scale = self._calculate_auto_scale() else: self._scale = SCALE # Scale, then ensure dimensions are even self._scaled_width = int(self._width * self._scale) self._scaled_height = int(self._height * self._scale) self._scaled_width += self._scaled_width % 2 self._scaled_height += self._scaled_height % 2 self._render_prepare = {} self._render_texture: rl.RenderTexture | None = None self._burn_in_shader: rl.Shader | None = None self._ffmpeg_proc: subprocess.Popen | None = None self._ffmpeg_queue: queue.Queue | None = None self._ffmpeg_thread: threading.Thread | None = None self._ffmpeg_stop_event: threading.Event | None = None self._textures: dict[str, rl.Texture] = {} self._target_fps: int = _DEFAULT_FPS self._last_fps_log_time: float = time.monotonic() self._frame = 0 self._window_close_requested = False self._nav_stack: list[object] = [] self._nav_stack_ticks: list[Callable[[], None]] = [] self._nav_stack_widgets_to_render = 1 if self.big_ui() else 2 self._mouse = MouseState(self._scale) self._mouse_events: list[MouseEvent] = [] self._last_mouse_event: MouseEvent = MouseEvent(MousePos(0, 0), 0, False, False, False, 0.0) self._should_render = True # Debug variables self._mouse_history: deque[MousePosWithTime] = deque(maxlen=MOUSE_THREAD_RATE) self._show_touches = SHOW_TOUCHES self._show_fps = SHOW_FPS self._grid_size = GRID_SIZE self._frame_timing_enabled = UI_FRAME_TIMING self._collect_frame_timing: bool | None = None self._auto_timing_end = 0.0 self._auto_timing_next = 0.0 self._auto_timing_samples: deque[dict[str, tuple[float, float]]] = deque(maxlen=AUTO_TIMING_MAX_SAMPLES) self.frame_timing: FrameTiming | None = None self._frame_phases: dict[str, tuple[float, float]] = {} self._profile_render_frames = PROFILE_RENDER self._render_profiler = None self._render_profile_start_time = None @property def frame(self): return self._frame def set_show_touches(self, show: bool): self._show_touches = show def set_show_fps(self, show: bool): self._show_fps = show self._frame_timing_enabled = UI_FRAME_TIMING or show def measure_frame_phase(self, name: str, callback: Callable, *args, **kwargs): enabled = self._frame_timing_enabled if self._collect_frame_timing is None else self._collect_frame_timing if not enabled: return callback(*args, **kwargs) if name not in FRAME_PHASES: raise ValueError(name) wall_start, cpu_start = time.monotonic(), time.thread_time() try: return callback(*args, **kwargs) finally: wall_ms = (time.monotonic() - wall_start) * 1000 cpu_ms = (time.thread_time() - cpu_start) * 1000 previous_wall, previous_cpu = self._frame_phases.get(name, (0.0, 0.0)) self._frame_phases[name] = previous_wall + wall_ms, previous_cpu + cpu_ms @property def show_touches(self) -> bool: return self._show_touches @property def target_fps(self): return self._target_fps def add_render_prepare(self, callback, cleanup): self._render_prepare[callback] = cleanup def remove_render_prepare(self, callback): self._render_prepare.pop(callback, None) def request_close(self): self._window_close_requested = True def init_window(self, title: str, fps: int = _DEFAULT_FPS): with self._startup_profile_context(): def _close(sig, frame): self.close() sys.exit(0) signal.signal(signal.SIGINT, _close) atexit.register(self.close) 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._scaled_width, self._scaled_height, title) if PC and not rl.is_window_ready(): raise RuntimeError("Desktop UI window could not open; check the active monitor/display session") needs_render_texture = self._scale != 1.0 or BURN_IN_MODE or RECORD if self._scale != 1.0: rl.set_mouse_scale(1 / self._scale, 1 / self._scale) if needs_render_texture: self._render_texture = rl.load_render_texture(self._scaled_width, self._scaled_height) rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) if RECORD: output_fps = fps * RECORD_SPEED ffmpeg_args = [ 'ffmpeg', '-v', 'warning', # Reduce ffmpeg log spam '-nostats', # Suppress encoding progress '-f', 'rawvideo', # Input format '-pix_fmt', 'rgba', # Input pixel format '-s', f'{self._scaled_width}x{self._scaled_height}', # Input resolution '-r', str(fps), # Input frame rate '-i', 'pipe:0', # Input from stdin '-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p '-r', str(output_fps), # Output frame rate (for speed multiplier) '-c:v', 'libx264', '-preset', 'veryfast', '-crf', str(RECORD_QUALITY) ] if RECORD_BITRATE: # NOTE: custom bitrate overrides crf setting ffmpeg_args += ['-b:v', RECORD_BITRATE, '-maxrate', RECORD_BITRATE, '-bufsize', RECORD_BITRATE] ffmpeg_args += [ '-y', # Overwrite existing file '-f', 'mp4', # Output format RECORD_OUTPUT, # Output file path ] self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE) self._ffmpeg_queue = queue.Queue(maxsize=60) # Buffer up to 60 frames self._ffmpeg_stop_event = threading.Event() self._ffmpeg_thread = threading.Thread(target=self._ffmpeg_writer_thread, daemon=True) self._ffmpeg_thread.start() # four display runs slightly faster than 60 FPS, let it dictate rate so we don't drift and drop frames vblank_control = HARDWARE.get_device_type() == 'mici' rl.set_target_fps(0 if OFFSCREEN or vblank_control else fps) self._target_fps = fps self._load_fonts() self._patch_text_functions() self._patch_scissor_mode() if BURN_IN_MODE and self._burn_in_shader is None: self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER) if not PC: self._mouse.start() @contextmanager def _startup_profile_context(self): if "PROFILE_STARTUP" not in os.environ: yield return import cProfile import io import pstats profiler = cProfile.Profile() start_time = time.monotonic() profiler.enable() # do the init yield profiler.disable() elapsed_ms = (time.monotonic() - start_time) * 1e3 stats_stream = io.StringIO() pstats.Stats(profiler, stream=stats_stream).sort_stats("cumtime").print_stats(25) print("\n=== Startup profile ===") print(stats_stream.getvalue().rstrip()) green = "\033[92m" reset = "\033[0m" print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}") sys.exit(0) def _ffmpeg_writer_thread(self): """Background thread that writes frames to ffmpeg.""" while True: try: data = self._ffmpeg_queue.get(timeout=1.0) if data is None: # Sentinel to stop break self._ffmpeg_proc.stdin.write(data) except queue.Empty: if self._ffmpeg_stop_event.is_set(): break continue except Exception: break def push_widget(self, widget: object): if widget in self._nav_stack: cloudlog.warning("Widget already in stack, cannot push again!") return # disable previous widget to prevent input processing if len(self._nav_stack) > 0: prev_widget = self._nav_stack[-1] # TODO: change these to touch_valid prev_widget.set_enabled(False) self._nav_stack.append(widget) widget.show_event() widget.set_enabled(True) def pop_widget(self, idx: int | None = None): # Pops widget instantly without animation if len(self._nav_stack) < 2: cloudlog.warning("At least one widget should remain on the stack, ignoring pop!") return idx_to_pop = len(self._nav_stack) - 1 if idx is None else idx if idx_to_pop <= 0 or idx_to_pop >= len(self._nav_stack): cloudlog.warning(f"Invalid index {idx_to_pop} to pop, ignoring!") return # only re-enable previous widget if popping top widget if idx_to_pop == len(self._nav_stack) - 1: prev_widget = self._nav_stack[idx_to_pop - 1] prev_widget.set_enabled(True) widget = self._nav_stack.pop(idx_to_pop) widget.hide_event() def pop_widgets_to(self, widget: object, callback: Callable[[], None] | None = None, instant: bool = False): # Pops middle widgets instantly without animation then dismisses top, animated out if NavWidget if widget not in self._nav_stack: cloudlog.warning("Widget not in stack, cannot pop to it!") return # Nothing to pop, ensure we still run callback top_widget = self._nav_stack[-1] if top_widget == widget: if callback: callback() return # instantly pop widgets in between, then dismiss top widget for animation while len(self._nav_stack) > 1 and self._nav_stack[-2] != widget: self.pop_widget(len(self._nav_stack) - 2) if not instant: top_widget.dismiss(callback) else: self.pop_widget() def get_active_widget(self): if len(self._nav_stack) > 0: return self._nav_stack[-1] return None def widget_in_stack(self, widget: object) -> bool: return widget in self._nav_stack def add_nav_stack_tick(self, tick_function: Callable[[], None]): if tick_function not in self._nav_stack_ticks: self._nav_stack_ticks.append(tick_function) def remove_nav_stack_tick(self, tick_function: Callable[[], None]): if tick_function in self._nav_stack_ticks: self._nav_stack_ticks.remove(tick_function) def set_should_render(self, should_render: bool): self._should_render = should_render def texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True, flip_x: bool = False) -> rl.Texture: if width is not None: width = round(width) if height is not None: height = round(height) cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}_{keep_aspect_ratio}_{flip_x}" if cache_key in self._textures: return self._textures[cache_key] with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath: image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio, flip_x) texture_obj = self._load_texture_from_image(image_obj) # Set logical size so widget layout math stays at 1x coordinates if self._scale != 1.0 and width is not None and height is not None: texture_obj.width = width texture_obj.height = height self._textures[cache_key] = texture_obj return texture_obj def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None, alpha_premultiply: bool = False, keep_aspect_ratio: bool = True, flip_x: bool = False) -> rl.Image: """Load and resize an image, storing it for later automatic unloading.""" image = rl.load_image(image_path) if alpha_premultiply: rl.image_alpha_premultiply(image) # Scale up load size for sharper rendering, capped at source resolution if self._scale != 1.0 and width is not None and height is not None: width = min(int(width * self._scale), image.width) height = min(int(height * self._scale), image.height) if width is not None and height is not None: same_dimensions = image.width == width and image.height == height # Resize with aspect ratio preservation if requested if not same_dimensions: 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) else: assert keep_aspect_ratio, "Cannot resize without specifying width and height" if flip_x: rl.image_flip_horizontal(image) return image def _load_texture_from_image(self, image: rl.Image) -> rl.Texture: """Send image to GPU and unload original image.""" texture = rl.load_texture_from_image(image) # Set texture filtering to smooth the result rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) # prevent artifacts from wrapping coordinates rl.set_texture_wrap(texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP) rl.unload_image(image) return texture def close_ffmpeg(self): if self._ffmpeg_thread is not None: # Signal thread to stop, send sentinel, then wait for it to drain self._ffmpeg_stop_event.set() self._ffmpeg_queue.put(None) self._ffmpeg_thread.join(timeout=30) if self._ffmpeg_proc is not None: self._ffmpeg_proc.stdin.flush() self._ffmpeg_proc.stdin.close() try: self._ffmpeg_proc.wait(timeout=30) except subprocess.TimeoutExpired: self._ffmpeg_proc.terminate() self._ffmpeg_proc.wait() def close(self): if not rl.is_window_ready(): return for cleanup in tuple(self._render_prepare.values()): cleanup() self._render_prepare.clear() for texture in self._textures.values(): rl.unload_texture(texture) self._textures = {} for font in self._fonts.values(): rl.unload_font(font) self._fonts = {} for font in self._fallback_fonts.values(): rl.unload_font(font) self._fallback_fonts = {} if self._render_texture is not None: rl.unload_render_texture(self._render_texture) self._render_texture = None if self._burn_in_shader: rl.unload_shader(self._burn_in_shader) self._burn_in_shader = None if not PC: self._mouse.stop() self.close_ffmpeg() rl.close_window() @property def mouse_events(self) -> list[MouseEvent]: return self._mouse_events @property def last_mouse_event(self) -> MouseEvent: return self._last_mouse_event def render(self, before_frame: Callable[[], None] | None = None): try: if self._profile_render_frames > 0: import cProfile self._render_profiler = cProfile.Profile() self._render_profile_start_time = time.monotonic() self._render_profiler.enable() while not (self._window_close_requested or rl.window_should_close()): frame_start = time.monotonic() collect_timing = self._frame_timing_enabled or frame_start < self._auto_timing_end self._collect_frame_timing = collect_timing if not collect_timing: self.frame_timing = None self._frame_phases.clear() if collect_timing: self._frame_phases.clear() cpu_start = time.thread_time() if PC: # Thread is not used on PC, need to manually add mouse events self._mouse._handle_mouse_event() # Store all mouse events for the current frame self._mouse_events = self._mouse.get_events() if len(self._mouse_events) > 0: self._last_mouse_event = self._mouse_events[-1] if before_frame is not None: before_frame() # Skip rendering when screen is off if not self._should_render: self.frame_timing = None self._frame_phases.clear() self._auto_timing_end = 0.0 self._auto_timing_samples.clear() self._collect_frame_timing = None if PC: rl.poll_input_events() time.sleep(1 / self._target_fps) yield False, 0.0, 0.0 continue for prepare in tuple(self._render_prepare): prepare() if self._render_texture: rl.begin_texture_mode(self._render_texture) rl.clear_background(rl.BLACK) else: rl.begin_drawing() rl.clear_background(rl.BLACK) if self._scale != 1.0: rl.rl_push_matrix() rl.rl_scalef(self._scale, self._scale, 1.0) # Allow a Widget to still run a function regardless of the stack depth for tick in self._nav_stack_ticks: tick() # Only render top widgets for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]: widget.render(rl.Rectangle(0, 0, self.width, self.height)) frame_time = rl.get_frame_time() cpu_time = time.monotonic() - frame_start if collect_timing: draw_end = time.monotonic() draw_cpu_end = time.thread_time() yield True, frame_time, cpu_time # Allow the caller to publish telemetry before presenting the frame. if collect_timing: update_end = time.monotonic() update_cpu_end = time.thread_time() if self._scale != 1.0: rl.rl_pop_matrix() if self._render_texture: rl.end_texture_mode() rl.begin_drawing() rl.clear_background(rl.BLACK) src_rect = rl.Rectangle(0, 0, float(self._scaled_width), -float(self._scaled_height)) dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height)) texture = self._render_texture.texture if texture: if BURN_IN_MODE and self._burn_in_shader: rl.begin_shader_mode(self._burn_in_shader) rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) rl.end_shader_mode() else: rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) if self._show_fps: rl.draw_fps(10, 10) if self._show_touches: self._draw_touch_points() if self._grid_size > 0: self._draw_grid() rl.end_drawing() if collect_timing: present_end = time.monotonic() present_cpu_end = time.thread_time() self.frame_timing = FrameTiming( (draw_end - frame_start) * 1000, (draw_cpu_end - cpu_start) * 1000, (update_end - draw_end) * 1000, (update_cpu_end - draw_cpu_end) * 1000, (present_end - update_end) * 1000, (present_cpu_end - update_cpu_end) * 1000, ) if RECORD: image = rl.load_image_from_texture(self._render_texture.texture) data_size = image.width * image.height * 4 data = bytes(rl.ffi.buffer(image.data, data_size)) self._ffmpeg_queue.put(data) # Async write via background thread rl.unload_image(image) self._monitor_fps() self._collect_frame_timing = None self._frame += 1 if self._profile_render_frames > 0 and self._frame >= self._profile_render_frames: self._output_render_profile() except KeyboardInterrupt: pass finally: self._collect_frame_timing = None def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font: return self._fonts[font_weight] def fallback_font(self) -> rl.Font: language = multilang.language if language not in self._fallback_fonts: chars = set(map(chr, range(32, 127))) | set(EXTRA_FONT_CHARS) chars.update(TRANSLATIONS_DIR.joinpath(f"app_{language}.po").read_text(encoding="utf-8")) codepoints = sorted(map(ord, chars)) codepoint_buffer = rl.ffi.new("int[]", codepoints) with as_file(FONT_DIR) as fspath: font = rl.load_font_ex((fspath / NOTO_FONTS[language]).as_posix(), 48, rl.ffi.cast("int *", codepoint_buffer), len(codepoints)) rl.gen_texture_mipmaps(font.texture) rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) self._fallback_fonts[language] = font return self._fallback_fonts[language] @property def width(self): return self._width @property def height(self): return self._height def _load_fonts(self): base_chars = set(map(chr, range(32, 127))) | set(EXTRA_FONT_CHARS) unifont_chars = set(base_chars) for language, code in multilang.languages.items(): unifont_chars.update(language) if code not in FONT_FALLBACK_LANGUAGES: base_chars.update(TRANSLATIONS_DIR.joinpath(f"app_{code}.po").read_text(encoding="utf-8")) for font_weight_file in FontWeight: with as_file(FONT_DIR) as fspath: unifont = font_weight_file == FontWeight.UNIFONT codepoints = sorted(map(ord, unifont_chars if unifont else base_chars)) codepoint_buffer = rl.ffi.new("int[]", codepoints) font = rl.load_font_ex((fspath / font_weight_file).as_posix(), 16 if unifont else 200, rl.ffi.cast("int *", codepoint_buffer), len(codepoints)) if font_weight_file != FontWeight.UNIFONT: rl.gen_texture_mipmaps(font.texture) rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) self._fonts[font_weight_file] = font if multilang.requires_font_fallback(): self.fallback_font() def _patch_text_functions(self): # Wrap pyray text APIs to apply a global text size scale so our px sizes match Qt if not hasattr(rl, "_orig_draw_text_ex"): rl._orig_draw_text_ex = rl.draw_text_ex def _draw_text_ex_scaled(font, text, position, font_size, spacing, tint): font = font_fallback(font) return rl._orig_draw_text_ex(font, text, position, font_size * FONT_SCALE, spacing, tint) rl.draw_text_ex = _draw_text_ex_scaled def _patch_scissor_mode(self): if self._scale == 1.0: return if not hasattr(rl, "_orig_begin_scissor_mode"): rl._orig_begin_scissor_mode = rl.begin_scissor_mode def _begin_scissor_mode_scaled(x, y, width, height): return rl._orig_begin_scissor_mode( int(x * self._scale), int(y * self._scale), int(math.ceil(width * self._scale)), int(math.ceil(height * self._scale))) rl.begin_scissor_mode = _begin_scissor_mode_scaled def _set_log_callback(self): ffi_libc = cffi.FFI() ffi_libc.cdef(""" int vasprintf(char **strp, const char *fmt, void *ap); void free(void *ptr); """) libc = ffi_libc.dlopen(None) @rl.ffi.callback("void(int, char *, void *)") def trace_log_callback(log_level, text, args): try: text_addr = int(rl.ffi.cast("uintptr_t", text)) args_addr = int(rl.ffi.cast("uintptr_t", args)) text_libc = ffi_libc.cast("char *", text_addr) args_libc = ffi_libc.cast("void *", args_addr) out = ffi_libc.new("char **") if libc.vasprintf(out, text_libc, args_libc) >= 0 and out[0] != ffi_libc.NULL: text_str = ffi_libc.string(out[0]).decode("utf-8", "replace") libc.free(out[0]) else: text_str = rl.ffi.string(text).decode("utf-8", "replace") except Exception as e: text_str = f"[Log decode error: {e}]" 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}") # ensure we get all the logs forwarded to us rl.set_trace_log_level(rl.TraceLogLevel.LOG_DEBUG) # 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() current_time = time.monotonic() slow = fps < self._target_fps * FPS_DROP_THRESHOLD if self._auto_timing_end: if slow and self.frame_timing is not None: timing = self.frame_timing sample = { "draw": (timing.draw_wall_ms, timing.draw_cpu_ms), "update": (timing.update_wall_ms, timing.update_cpu_ms), "present": (timing.present_wall_ms, timing.present_cpu_ms), } sample.update({name: self._frame_phases[name] for name in FRAME_PHASES if name in self._frame_phases}) self._auto_timing_samples.append(sample) if current_time >= self._auto_timing_end: if self._auto_timing_samples: import statistics detail = [] for name in ("draw", "update", "present", *FRAME_PHASES): values = [sample[name] for sample in self._auto_timing_samples if name in sample] if values: wall, cpu = zip(*values, strict=True) detail.append(f"{name} wall/cpu median {statistics.median(wall):.1f}/{statistics.median(cpu):.1f}ms" + f" max {max(wall):.1f}/{max(cpu):.1f}ms") cloudlog.warning(f"Slow UI frame diagnostic: {len(self._auto_timing_samples)} slow samples; " + "; ".join(detail) + "; nested phases are not additive") self._auto_timing_end = 0.0 self._auto_timing_samples.clear() # 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: detail = "" if self.frame_timing is not None: timing = self.frame_timing detail = (f"; draw wall/cpu {timing.draw_wall_ms:.1f}/{timing.draw_cpu_ms:.1f}ms" + f", update wall/cpu {timing.update_wall_ms:.1f}/{timing.update_cpu_ms:.1f}ms" + f", present wall/cpu {timing.present_wall_ms:.1f}/{timing.present_cpu_ms:.1f}ms") for name in FRAME_PHASES: if phase := self._frame_phases.get(name): detail += f", {name} wall/cpu {phase[0]:.1f}/{phase[1]:.1f}ms" cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}{detail}") self._last_fps_log_time = current_time if not self._auto_timing_end and current_time >= self._auto_timing_next: self._auto_timing_end = current_time + AUTO_TIMING_DURATION self._auto_timing_next = self._auto_timing_end + AUTO_TIMING_COOLDOWN self._auto_timing_samples.clear() # 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.") self.close_ffmpeg() os._exit(1) def _draw_touch_points(self): current_time = time.monotonic() for mouse_event in self._mouse_events: if mouse_event.left_pressed: self._mouse_history.clear() self._mouse_history.append(MousePosWithTime(mouse_event.pos.x * self._scale, mouse_event.pos.y * self._scale, current_time)) # Remove old touch points that exceed the timeout while self._mouse_history and (current_time - self._mouse_history[0].t) > TOUCH_HISTORY_TIMEOUT: self._mouse_history.popleft() if self._mouse_history: mouse_pos = self._mouse_history[-1] rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 15, rl.RED) for idx, mouse_pos in enumerate(self._mouse_history): perc = idx / len(self._mouse_history) color = rl.Color(min(int(255 * (1.5 - perc)), 255), int(min(255 * (perc + 0.5), 255)), 50, 255) rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 5, color) def _draw_grid(self): grid_color = rl.Color(60, 60, 60, 255) # Draw vertical lines x = 0 while x <= self._scaled_width: rl.draw_line(x, 0, x, self._scaled_height, grid_color) x += self._grid_size # Draw horizontal lines y = 0 while y <= self._scaled_height: rl.draw_line(0, y, self._scaled_width, y, grid_color) y += self._grid_size def _output_render_profile(self): import io import pstats self._render_profiler.disable() assert self._render_profile_start_time is not None elapsed_ms = (time.monotonic() - self._render_profile_start_time) * 1e3 avg_frame_time = elapsed_ms / self._frame if self._frame > 0 else 0 stats_stream = io.StringIO() pstats.Stats(self._render_profiler, stream=stats_stream).sort_stats("cumtime").print_stats(PROFILE_STATS) print("\n=== Render loop profile ===") print(stats_stream.getvalue().rstrip()) green = "\033[92m" reset = "\033[0m" print(f"\n{green}Rendered {self._frame} frames in {elapsed_ms:.1f} ms{reset}") print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000/avg_frame_time:.1f} FPS){reset}") sys.exit(0) def _calculate_auto_scale(self) -> float: # Create temporary window to query monitor info rl.init_window(1, 1, "") if not rl.is_window_ready(): raise RuntimeError("Desktop UI window could not open; check the active monitor/display session") w, h = rl.get_monitor_width(0), rl.get_monitor_height(0) rl.close_window() if w == 0 or h == 0 or (w >= self._width and h >= self._height): return 1.0 # Apply 0.95 factor for window decorations/taskbar margin return max(0.3, min(w / self._width, h / self._height) * 0.95) @staticmethod def _default_width() -> int: return 2160 if GuiApplication.big_ui() else 536 @staticmethod def _default_height() -> int: return 1080 if GuiApplication.big_ui() else 240 @staticmethod def big_ui() -> bool: from openpilot.tools.replay.display_clock import DisplayClockReader if DisplayClockReader.enabled(): return BIG_UI return HARDWARE.get_device_type() in ('tici', 'tizi') or BIG_UI gui_app = GuiApplication()