mirror of
https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
synced 2026-07-24 21:12:05 +08:00
754 lines
32 KiB
Python
Executable File
754 lines
32 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
╔══════════════════════════════════════════════════════════════════════════╗
|
||
║ IQ.Pilot MICI UI Preview Tool ║
|
||
║ ────────────────────────────────────────────────────────────────────── ║
|
||
║ Renders any MICI layout/widget at 536×240 on your Mac desktop so you ║
|
||
║ can visually inspect and iterate without a physical comma 4. ║
|
||
║ ║
|
||
║ MODES ║
|
||
║ screenshot — render N frames, save PNG(s), open in Preview ║
|
||
║ video — render N seconds to MP4 via ffmpeg, open in QuickTime ║
|
||
║ live — interactive window, hot-reload on file-save ║
|
||
║ ║
|
||
║ USAGE ║
|
||
║ python tools/iqpilot/mici_preview.py [OPTIONS] ║
|
||
║ ║
|
||
║ OPTIONS ║
|
||
║ --panel PANEL Panel to render: steering, visuals, display, ║
|
||
║ software, cruise, trips, osm, models, toggles, ║
|
||
║ device, developer, home, settings (default: steering)║
|
||
║ --mode MODE screenshot | video | live (default: screenshot) ║
|
||
║ --frames N Frames to settle before screenshot (default: 90) ║
|
||
║ --shots N Number of screenshots to take (default: 1) ║
|
||
║ --duration S Video duration in seconds (default: 4) ║
|
||
║ --fps N Render FPS (default: 60) ║
|
||
║ --scale F Window scale multiplier (2.0 = 1072×480 window) ║
|
||
║ (default: 2.5) ║
|
||
║ --out PATH Output file/dir (default: /tmp/mici_preview/) ║
|
||
║ --open Open output file(s) after capture (default: True) ║
|
||
║ --mock Use mock UI state (no real params needed) ║
|
||
║ ║
|
||
║ EXAMPLES ║
|
||
║ # Screenshot the steering panel (scaled 2.5×, opens in Preview) ║
|
||
║ python tools/iqpilot/mici_preview.py --panel steering ║
|
||
║ ║
|
||
║ # 4-second video of the neon glow animation ║
|
||
║ python tools/iqpilot/mici_preview.py --panel steering --mode video ║
|
||
║ ║
|
||
║ # Live interactive window with hot-reload ║
|
||
║ python tools/iqpilot/mici_preview.py --panel visuals --mode live ║
|
||
║ ║
|
||
║ # Multiple panels in one go (screenshots) ║
|
||
║ python tools/iqpilot/mici_preview.py --panel all ║
|
||
╚══════════════════════════════════════════════════════════════════════════╝
|
||
"""
|
||
|
||
import argparse
|
||
import importlib
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import queue
|
||
import threading
|
||
from pathlib import Path
|
||
|
||
# ── Project root on sys.path ──────────────────────────────────────────────
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
# Force MICI mode (no BIG UI)
|
||
os.environ.setdefault("BIG", "0")
|
||
os.environ.setdefault("IQPILOT_UI", "1")
|
||
# Use offscreen mode for screenshot/video (no FPS cap → fast capture)
|
||
# Live mode overrides this below.
|
||
|
||
import pyray as rl # noqa: E402 — must come after env setup
|
||
|
||
# ── MICI canvas dimensions ─────────────────────────────────────────────────
|
||
MICI_W = 536
|
||
MICI_H = 240
|
||
|
||
# ── Output directory ───────────────────────────────────────────────────────
|
||
DEFAULT_OUT = Path("/tmp/mici_preview")
|
||
|
||
# ── Color palette for the preview chrome ──────────────────────────────────
|
||
CHROME_BG = rl.Color(18, 18, 22, 255) # dark bg behind the MICI canvas
|
||
LABEL_COLOR = rl.Color(160, 160, 160, 200) # panel label text
|
||
|
||
# ── All available panels ───────────────────────────────────────────────────
|
||
ALL_PANELS = [
|
||
"steering", "visuals", "display", "software", "cruise",
|
||
"trips", "osm", "models", "toggles", "device", "developer", "home",
|
||
]
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Mock UI state so we can run without a live comma process
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _patch_mock_state():
|
||
"""Replace real ui_state and Params with lightweight mocks."""
|
||
import types
|
||
|
||
# ── Shared in-memory store ────────────────────────────────────────────────
|
||
_store: dict = {}
|
||
|
||
class MockParams:
|
||
"""
|
||
Drop-in Params mock that also supports `Params | None` type expressions
|
||
at module import time by implementing __or__ / __ror__ on the class itself.
|
||
"""
|
||
# Allow `Params | None` as a runtime type-union expression
|
||
def __class_getitem__(cls, item):
|
||
return cls
|
||
def __or__(cls, other):
|
||
import types as _t
|
||
return _t.UnionType if hasattr(_t, 'UnionType') else object
|
||
__ror__ = __or__
|
||
|
||
def __init__(self, _=None, **__):
|
||
pass # ignore any constructor args (real Params accepts path kwarg)
|
||
|
||
def get(self, key, default=None, return_default=False):
|
||
val = _store.get(key, default)
|
||
# Real Params.get() returns str or None; convert bools → str
|
||
if isinstance(val, bool):
|
||
return str(int(val))
|
||
return val
|
||
|
||
def get_bool(self, key, default=False):
|
||
val = _store.get(key, default)
|
||
if isinstance(val, str):
|
||
return val not in ('', '0', 'false', 'False', 'None')
|
||
return bool(val)
|
||
|
||
def put(self, key, val):
|
||
_store[key] = val
|
||
|
||
def put_bool(self, key, val):
|
||
_store[key] = bool(val)
|
||
|
||
def put_nonblocking(self, key, val):
|
||
_store[key] = val
|
||
|
||
# Seed some sensible defaults so widgets render realistically
|
||
mp = MockParams()
|
||
# ── Steering ────────────────────────────────────────────────────────────
|
||
mp.put("AolEnabled", False)
|
||
mp.put("AolSteeringMode", 0) # 0=remain active, 1=pause, 2=disengage
|
||
mp.put("AolMainCruiseAllowed", True)
|
||
mp.put("AolUnifiedEngagementMode", False)
|
||
mp.put("NeuralNetworkFeedForward", False)
|
||
mp.put("IQLaneChangeTimer", 0) # nudge
|
||
mp.put("IQLaneChangeBsmDelay", False)
|
||
|
||
# ── Visuals (correct param keys matching visuals.py) ─────────────────────
|
||
mp.put("BlindSpot", True)
|
||
mp.put("TorqueBar", True)
|
||
mp.put("RoadNameToggle", True)
|
||
mp.put("ShowTurnSignals", True)
|
||
mp.put("RocketFuel", False)
|
||
mp.put("ChevronInfo", 0) # 0=off
|
||
mp.put("IQDevUIInfo", 0) # 0=off
|
||
mp.put("AlphaLongitudinalEnabled", False) # real param; gates ChevronInfo
|
||
|
||
# ── Display ───────────────────────────────────────────────────────────────
|
||
mp.put("OnroadScreenOffBrightness", 0) # 0=auto
|
||
mp.put("OnroadScreenOffTimer", 60) # 1m
|
||
mp.put("InteractivityTimeout", 0) # default
|
||
|
||
# ── Software ──────────────────────────────────────────────────────────────
|
||
mp.put("DisableUpdates", False)
|
||
mp.put("GitBranch", "master-mici")
|
||
mp.put("Version", "IQ.Pilot 0.9.5-mici")
|
||
|
||
# ── Models ────────────────────────────────────────────────────────────────
|
||
mp.put("IQLiveSteerDelay", False)
|
||
mp.put("IQLaneTurnDesire", False)
|
||
mp.put("IQLaneTurnValue", "19.0")
|
||
|
||
# ── Cruise ────────────────────────────────────────────────────────────────
|
||
mp.put("ExperimentalMode", False)
|
||
mp.put("IQDynamicMode", False)
|
||
mp.put("LongitudinalPersonality", 1) # 0=aggressive,1=standard,2=relaxed,3=stock
|
||
mp.put("IQSpeedAssistMode", 0) # 0=off
|
||
|
||
# ── Misc / system ─────────────────────────────────────────────────────────
|
||
mp.put("IsMetric", False)
|
||
mp.put("UIAccentColor", "#00FFF5") # default neon cyan
|
||
|
||
# Inject mock into common.params
|
||
# IMPORTANT: inject the CLASS (not an instance) so that `Params | None`
|
||
# type-union expressions in downstream modules work at import time.
|
||
mock_module = types.ModuleType("openpilot.common.params")
|
||
mock_module.Params = MockParams
|
||
sys.modules["openpilot.common.params"] = mock_module
|
||
|
||
# ── Mock ui_state (both the iqpilot layer and the top-level selfdrive layer) ─
|
||
class MockCP:
|
||
enableBsm = True
|
||
openpilotLongitudinalControl = True
|
||
alphaLongitudinalAvailable = False
|
||
|
||
# Minimal mock for ui_state.sm — returns empty/default objects for any key
|
||
class _MockBundle:
|
||
internalName = "mock-model"
|
||
displayName = "Mock Model"
|
||
index = 0
|
||
status = None # not downloading
|
||
models = []
|
||
overrides = []
|
||
|
||
class _MockModelManager:
|
||
availableBundles = []
|
||
activeBundle = _MockBundle()
|
||
selectedBundle = None # None = not downloading
|
||
|
||
class _MockSM:
|
||
"""Minimal SubMaster-like dict that returns sensible defaults."""
|
||
_data = {
|
||
"iqModelManager": _MockModelManager(),
|
||
}
|
||
def __getitem__(self, key):
|
||
return self._data.get(key, type("Empty", (), {"enabled": False})())
|
||
@property
|
||
def updated(self):
|
||
return type("U", (), {"__getitem__": lambda s, k: False})()
|
||
@property
|
||
def alive(self):
|
||
return type("A", (), {"__getitem__": lambda s, k: True})()
|
||
@property
|
||
def valid(self):
|
||
return type("V", (), {"__getitem__": lambda s, k: True})()
|
||
@property
|
||
def frame(self): return 0
|
||
def __contains__(self, key): return True
|
||
|
||
class MockUIState:
|
||
CP = MockCP()
|
||
params = mp
|
||
sm = _MockSM()
|
||
started = False
|
||
ignition = False
|
||
is_metric = False
|
||
has_longitudinal_control = True
|
||
always_on_dm = False
|
||
recording_audio = False
|
||
personality = 1 # standard
|
||
custom_interactive_timeout = 0
|
||
light_sensor = -1.0
|
||
is_release = False
|
||
# IQ-specific extras
|
||
aol_enabled = False
|
||
aol_state = 0
|
||
|
||
def is_offroad(self): return True
|
||
def is_onroad(self): return False
|
||
def add_offroad_transition_callback(self, cb): pass
|
||
def add_engaged_transition_callback(self, cb): pass
|
||
def update_params(self): pass
|
||
|
||
@property
|
||
def engaged(self): return False
|
||
|
||
_mock_ui_state = MockUIState()
|
||
|
||
# openpilot.selfdrive.ui.ui_state (base openpilot layer — hosts the IQ UI state classes/enums)
|
||
from enum import Enum, IntEnum
|
||
class _UIStatus(Enum):
|
||
DISENGAGED = "disengaged"
|
||
ENGAGED = "engaged"
|
||
OVERRIDE = "override"
|
||
LAT_ONLY = "lat_only"
|
||
LONG_ONLY = "long_only"
|
||
|
||
class _OnroadTimerStatus(Enum):
|
||
NONE = 0
|
||
PAUSE = 1
|
||
RESUME = 2
|
||
|
||
class _OnroadBrightness(IntEnum):
|
||
AUTO = 0
|
||
AUTO_DARK = 1
|
||
|
||
mock_base_ui_mod = types.ModuleType("openpilot.selfdrive.ui.ui_state")
|
||
mock_base_ui_mod.ui_state = _mock_ui_state
|
||
mock_base_ui_mod.UIStatus = _UIStatus
|
||
mock_base_ui_mod.OnroadTimerStatus = _OnroadTimerStatus
|
||
mock_base_ui_mod.OnroadBrightness = _OnroadBrightness
|
||
mock_base_ui_mod.device = type("MockDevice", (), {"awake": True})()
|
||
sys.modules["openpilot.selfdrive.ui.ui_state"] = mock_base_ui_mod
|
||
|
||
return mp
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Panel loader
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def load_panel(name: str):
|
||
"""
|
||
Instantiate a MICI panel/layout by name.
|
||
Returns a Widget instance with set_rect() already called.
|
||
"""
|
||
rect = rl.Rectangle(0, 0, MICI_W, MICI_H)
|
||
|
||
layouts = {
|
||
"steering": ("openpilot.iqpilot.ui.mici.layouts.steering", "SteeringLayoutMici"),
|
||
"visuals": ("openpilot.iqpilot.ui.mici.layouts.visuals", "VisualsLayoutMici"),
|
||
"display": ("openpilot.iqpilot.ui.mici.layouts.display", "DisplayLayoutMici"),
|
||
"software": ("openpilot.iqpilot.ui.mici.layouts.software", "SoftwareLayoutMici"),
|
||
"cruise": ("openpilot.iqpilot.ui.mici.layouts.cruise", "CruiseLayoutMici"),
|
||
"trips": ("openpilot.iqpilot.ui.mici.layouts.trips", "TripsLayoutMici"),
|
||
"osm": ("openpilot.iqpilot.ui.mici.layouts.osm", "OSMLayoutMici"),
|
||
"models": ("openpilot.iqpilot.ui.mici.layouts.models", "ModelsLayoutMici"),
|
||
"toggles": ("openpilot.selfdrive.ui.mici.layouts.settings.toggles", "TogglesLayoutMici"),
|
||
"device": ("openpilot.selfdrive.ui.mici.layouts.settings.device", "DeviceLayoutMici"),
|
||
"developer":("openpilot.selfdrive.ui.mici.layouts.settings.developer","DeveloperLayoutMici"),
|
||
"home": ("openpilot.selfdrive.ui.mici.layouts.home", "MiciHomeLayout"),
|
||
"settings": ("openpilot.iqpilot.ui.mici.layouts.settings", "IQMiciSettingsLayout"),
|
||
}
|
||
|
||
if name not in layouts:
|
||
raise ValueError(f"Unknown panel '{name}'. Choose from: {', '.join(layouts)}")
|
||
|
||
# All IQ.Pilot MICI layout panels accept an optional back_callback
|
||
NEEDS_BACK_CB = {
|
||
"steering", "visuals", "display", "software", "cruise",
|
||
"trips", "osm", "models", "toggles", "device", "developer",
|
||
}
|
||
|
||
mod_path, cls_name = layouts[name]
|
||
mod = importlib.import_module(mod_path)
|
||
cls = getattr(mod, cls_name)
|
||
if name in NEEDS_BACK_CB:
|
||
widget = cls(back_callback=lambda: None)
|
||
else:
|
||
widget = cls()
|
||
widget.set_rect(rect)
|
||
widget.show_event()
|
||
return widget
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Rendering helpers
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _draw_chrome(panel_name: str, scale: float, canvas_x: int, canvas_y: int):
|
||
"""Draw the preview window chrome: background, label, dimension hint."""
|
||
# Nothing to draw outside the canvas — the window IS the canvas (+ padding)
|
||
pass
|
||
|
||
|
||
def _render_frame(widget, render_tex: rl.RenderTexture, canvas_x: int, canvas_y: int, scale: float, panel_name: str):
|
||
"""
|
||
Render one frame:
|
||
1. Draw the MICI widget into render_tex (536×240 offscreen)
|
||
2. Blit the texture into the window at the correct position + scale
|
||
3. Draw chrome overlays (panel label, grid, etc.)
|
||
"""
|
||
# Draw into the MICI-sized render texture
|
||
rl.begin_texture_mode(render_tex)
|
||
rl.clear_background(rl.Color(0, 0, 0, 255))
|
||
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
|
||
rl.end_texture_mode()
|
||
|
||
# Blit to screen (flip Y because OpenGL textures are upside-down)
|
||
src = rl.Rectangle(0, 0, MICI_W, -MICI_H) # negative H = flip
|
||
dst = rl.Rectangle(canvas_x, canvas_y, MICI_W * scale, MICI_H * scale)
|
||
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
|
||
|
||
# Panel name label bottom-left
|
||
rl.draw_text(panel_name.upper(), canvas_x + 6, canvas_y + int(MICI_H * scale) + 6,
|
||
14, rl.Color(120, 120, 120, 180))
|
||
|
||
# Dimension hint bottom-right
|
||
hint = f"{MICI_W}×{MICI_H} (×{scale:.1f})"
|
||
hint_w = rl.measure_text(hint, 12)
|
||
win_w = rl.get_screen_width()
|
||
rl.draw_text(hint, win_w - hint_w - 8, canvas_y + int(MICI_H * scale) + 6,
|
||
12, rl.Color(80, 80, 80, 160))
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Screenshot mode
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def run_screenshot(panel_name: str, args, out_dir: Path) -> list[Path]:
|
||
"""
|
||
Render `args.frames` frames (so animations settle), then take `args.shots`
|
||
screenshots 0.5s apart. Returns list of saved PNG paths.
|
||
|
||
Uses gui_app.init_window() so fonts are loaded correctly, and the SCALE
|
||
env var (set to args.scale in main()) controls window size.
|
||
"""
|
||
from openpilot.system.ui.lib.application import gui_app
|
||
|
||
gui_app.init_window(f"MICI Preview — {panel_name}", fps=args.fps)
|
||
|
||
# Offscreen MICI-resolution render texture
|
||
render_tex = rl.load_render_texture(MICI_W, MICI_H)
|
||
|
||
# Load widget AFTER window/fonts are initialized
|
||
widget = load_panel(panel_name)
|
||
|
||
saved: list[Path] = []
|
||
frame = 0
|
||
shots_taken = 0
|
||
next_shot_frame = args.frames # first shot after settle
|
||
|
||
# Display at native scaled window coords (0,0 → screen size)
|
||
win_w = rl.get_screen_width()
|
||
win_h = rl.get_screen_height()
|
||
|
||
while not rl.window_should_close() and shots_taken < args.shots:
|
||
rl.begin_drawing()
|
||
rl.clear_background(CHROME_BG)
|
||
|
||
# Render widget into the MICI-sized texture, then blit full-window
|
||
rl.begin_texture_mode(render_tex)
|
||
rl.clear_background(rl.BLACK)
|
||
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
|
||
rl.end_texture_mode()
|
||
|
||
# Blit the render texture to fill the whole window (Y-flipped)
|
||
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
|
||
dst = rl.Rectangle(0, 0, win_w, win_h)
|
||
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
|
||
|
||
# Settle progress bar
|
||
if frame < args.frames:
|
||
pct = frame / args.frames
|
||
rl.draw_rectangle(0, win_h - 3, int(win_w * pct), 3, rl.Color(0, 255, 245, 140))
|
||
rl.draw_text(f"settling {frame}/{args.frames}", 6, 6, 11, rl.Color(100, 100, 100, 160))
|
||
|
||
rl.end_drawing()
|
||
frame += 1
|
||
|
||
if frame == next_shot_frame:
|
||
# Read from render texture (native MICI res, no padding to crop)
|
||
img = rl.load_image_from_texture(render_tex.texture)
|
||
rl.image_flip_vertical(img)
|
||
# Save at 2× native for clarity on Retina displays
|
||
rl.image_resize(img, MICI_W * 2, MICI_H * 2)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
ts = int(time.time() * 1000)
|
||
path = out_dir / f"mici_{panel_name}_{shots_taken + 1:02d}_{ts}.png"
|
||
rl.export_image(img, str(path))
|
||
rl.unload_image(img)
|
||
saved.append(path)
|
||
print(f" 📸 saved: {path}")
|
||
shots_taken += 1
|
||
next_shot_frame += int(args.fps * 0.5) # 0.5s between shots
|
||
|
||
rl.unload_render_texture(render_tex)
|
||
rl.close_window()
|
||
return saved
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Video mode
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def run_video(panel_name: str, args, out_dir: Path) -> Path:
|
||
"""
|
||
Render args.duration seconds at args.fps, pipe raw RGBA frames to ffmpeg → MP4.
|
||
Returns the output MP4 path.
|
||
"""
|
||
from openpilot.system.ui.lib.application import gui_app
|
||
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
ts = int(time.time())
|
||
mp4_path = out_dir / f"mici_{panel_name}_{ts}.mp4"
|
||
|
||
# Launch ffmpeg to accept raw RGBA frames at MICI native resolution
|
||
ffmpeg = subprocess.Popen([
|
||
"ffmpeg", "-v", "warning", "-nostats",
|
||
"-f", "rawvideo", "-pix_fmt", "rgba",
|
||
"-s", f"{MICI_W}x{MICI_H}",
|
||
"-r", str(args.fps),
|
||
"-i", "pipe:0",
|
||
"-vf", "vflip,format=yuv420p",
|
||
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||
"-y", str(mp4_path),
|
||
], stdin=subprocess.PIPE)
|
||
|
||
# Write-queue so rendering doesn't block on ffmpeg
|
||
frame_queue: queue.Queue = queue.Queue(maxsize=args.fps * 2)
|
||
stop_event = threading.Event()
|
||
|
||
def _writer():
|
||
while not stop_event.is_set() or not frame_queue.empty():
|
||
try:
|
||
data = frame_queue.get(timeout=0.1)
|
||
ffmpeg.stdin.write(data)
|
||
except queue.Empty:
|
||
pass
|
||
ffmpeg.stdin.close()
|
||
|
||
writer_thread = threading.Thread(target=_writer, daemon=True)
|
||
writer_thread.start()
|
||
|
||
gui_app.init_window(f"MICI Preview (recording) — {panel_name}", fps=args.fps)
|
||
render_tex = rl.load_render_texture(MICI_W, MICI_H)
|
||
widget = load_panel(panel_name)
|
||
|
||
win_w = rl.get_screen_width()
|
||
win_h = rl.get_screen_height()
|
||
total_frames = int(args.fps * args.duration)
|
||
frame = 0
|
||
settle = min(args.frames, total_frames // 4)
|
||
|
||
print(f" 🎬 recording {args.duration}s at {args.fps}fps → {mp4_path.name}")
|
||
|
||
while not rl.window_should_close() and frame < total_frames:
|
||
rl.begin_drawing()
|
||
rl.clear_background(CHROME_BG)
|
||
|
||
rl.begin_texture_mode(render_tex)
|
||
rl.clear_background(rl.BLACK)
|
||
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
|
||
rl.end_texture_mode()
|
||
|
||
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
|
||
dst = rl.Rectangle(0, 0, win_w, win_h)
|
||
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
|
||
|
||
# REC progress bar
|
||
pct = frame / total_frames
|
||
rl.draw_rectangle(0, win_h - 3, int(win_w * pct), 3, rl.Color(255, 80, 80, 180))
|
||
rl.draw_text(f"REC {frame}/{total_frames}", 6, 6, 11, rl.Color(255, 80, 80, 200))
|
||
|
||
rl.end_drawing()
|
||
|
||
# Queue raw RGBA from MICI-res texture for ffmpeg
|
||
if frame >= settle:
|
||
import ctypes
|
||
img = rl.load_image_from_texture(render_tex.texture)
|
||
colors = rl.load_image_colors(img)
|
||
raw = bytes(ctypes.string_at(colors, MICI_W * MICI_H * 4))
|
||
rl.unload_image_colors(colors)
|
||
rl.unload_image(img)
|
||
try:
|
||
frame_queue.put(raw, timeout=1.0)
|
||
except queue.Full:
|
||
pass
|
||
|
||
frame += 1
|
||
|
||
rl.unload_render_texture(render_tex)
|
||
rl.close_window()
|
||
|
||
stop_event.set()
|
||
writer_thread.join(timeout=10)
|
||
ffmpeg.wait(timeout=15)
|
||
|
||
print(f" ✅ video saved: {mp4_path}")
|
||
return mp4_path
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Live / hot-reload mode
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def run_live(panel_name: str, args, mock_params):
|
||
"""
|
||
Interactive window with hot-reload.
|
||
Watches the source file of the selected panel; re-imports it on save.
|
||
Press S to take a screenshot, R to force reload, Q/Esc to quit.
|
||
"""
|
||
from openpilot.system.ui.lib.application import gui_app
|
||
|
||
gui_app.init_window(f"MICI Live — {panel_name} [S=shot R=reload Q=quit]", fps=args.fps)
|
||
render_tex = rl.load_render_texture(MICI_W, MICI_H)
|
||
win_w = rl.get_screen_width()
|
||
win_h = rl.get_screen_height()
|
||
|
||
widget = load_panel(panel_name)
|
||
last_mtime: dict[str, float] = {}
|
||
|
||
def _watch_paths() -> list[Path]:
|
||
"""Files to watch for changes (panel module + shared theme/button)."""
|
||
mods = [
|
||
ROOT / "selfdrive/ui/iqpilot/mici/layouts" / f"{panel_name}.py",
|
||
ROOT / "selfdrive/ui/iqpilot/theme.py",
|
||
ROOT / "selfdrive/ui/mici/widgets/button.py",
|
||
]
|
||
return [p for p in mods if p.exists()]
|
||
|
||
def _needs_reload() -> bool:
|
||
for p in _watch_paths():
|
||
mtime = p.stat().st_mtime
|
||
if last_mtime.get(str(p), 0) != mtime:
|
||
last_mtime[str(p)] = mtime
|
||
return True
|
||
return False
|
||
|
||
def _reload():
|
||
nonlocal widget
|
||
print(" 🔄 reloading...")
|
||
# Invalidate cached modules so importlib picks up changes
|
||
prefix = "openpilot.iqpilot.ui.mici.layouts"
|
||
for key in list(sys.modules.keys()):
|
||
if key.startswith(prefix) or key == "openpilot.iqpilot.ui.theme":
|
||
del sys.modules[key]
|
||
try:
|
||
widget = load_panel(panel_name)
|
||
print(" ✅ reloaded OK")
|
||
except Exception as e:
|
||
print(f" ❌ reload error: {e}")
|
||
|
||
# Seed mtimes
|
||
for p in _watch_paths():
|
||
last_mtime[str(p)] = p.stat().st_mtime
|
||
|
||
out_dir = Path(args.out)
|
||
shot_count = 0
|
||
|
||
while not rl.window_should_close():
|
||
# Check for file changes
|
||
if _needs_reload():
|
||
_reload()
|
||
|
||
# Key bindings
|
||
if rl.is_key_pressed(rl.KeyboardKey.KEY_Q) or rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
||
break
|
||
if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
|
||
_reload()
|
||
if rl.is_key_pressed(rl.KeyboardKey.KEY_S):
|
||
# Take screenshot from render texture (native MICI res)
|
||
img = rl.load_image_from_texture(render_tex.texture)
|
||
rl.image_flip_vertical(img)
|
||
rl.image_resize(img, MICI_W * 2, MICI_H * 2)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
ts = int(time.time() * 1000)
|
||
path = out_dir / f"mici_{panel_name}_live_{shot_count:03d}_{ts}.png"
|
||
rl.export_image(img, str(path))
|
||
rl.unload_image(img)
|
||
shot_count += 1
|
||
print(f" 📸 screenshot saved: {path}")
|
||
if args.open:
|
||
subprocess.Popen(["open", str(path)])
|
||
|
||
rl.begin_drawing()
|
||
rl.clear_background(CHROME_BG)
|
||
|
||
# Render widget into MICI-sized texture, blit to full window
|
||
rl.begin_texture_mode(render_tex)
|
||
rl.clear_background(rl.BLACK)
|
||
widget.render(rl.Rectangle(0, 0, MICI_W, MICI_H))
|
||
rl.end_texture_mode()
|
||
|
||
src = rl.Rectangle(0, 0, MICI_W, -MICI_H)
|
||
dst = rl.Rectangle(0, 0, win_w, win_h)
|
||
rl.draw_texture_pro(render_tex.texture, src, dst, rl.Vector2(0, 0), 0, rl.WHITE)
|
||
|
||
# Status hint overlay
|
||
watch_files = _watch_paths()
|
||
hint = f"watching {len(watch_files)} file(s) | S=shot R=reload Q=quit"
|
||
rl.draw_text(hint, 6, win_h - 18, 11, rl.Color(80, 80, 80, 160))
|
||
|
||
rl.end_drawing()
|
||
|
||
rl.unload_render_texture(render_tex)
|
||
rl.close_window()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Entry point
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(
|
||
description="IQ.Pilot MICI UI Preview Tool",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=__doc__,
|
||
)
|
||
p.add_argument("--panel", default="steering",
|
||
help="Panel name or 'all'")
|
||
p.add_argument("--mode", default="screenshot",
|
||
choices=["screenshot", "video", "live"],
|
||
help="Capture mode")
|
||
p.add_argument("--frames", type=int, default=90,
|
||
help="Settle frames before screenshot")
|
||
p.add_argument("--shots", type=int, default=1,
|
||
help="Number of screenshots")
|
||
p.add_argument("--duration", type=float, default=4.0,
|
||
help="Video duration in seconds")
|
||
p.add_argument("--fps", type=int, default=60,
|
||
help="Render FPS")
|
||
p.add_argument("--scale", type=float, default=2.5,
|
||
help="Window scale multiplier (1.0 = native 536×240)")
|
||
p.add_argument("--out", default=str(DEFAULT_OUT),
|
||
help="Output directory")
|
||
p.add_argument("--open", action="store_true", default=True,
|
||
help="Open output after capture (macOS)")
|
||
p.add_argument("--no-open", dest="open", action="store_false")
|
||
p.add_argument("--mock", action="store_true", default=True,
|
||
help="Use mock UI state (no live process needed)")
|
||
p.add_argument("--no-mock", dest="mock", action="store_false")
|
||
p.add_argument("--accent", default=None,
|
||
help="Override neon accent color, e.g. '#FF6B00'")
|
||
return p.parse_args()
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
out_dir = Path(args.out)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Set SCALE env var BEFORE gui_app module is imported (it reads it at import time)
|
||
os.environ["SCALE"] = str(args.scale)
|
||
|
||
# Apply mock state early (before any widget imports)
|
||
mock_params = None
|
||
if args.mock:
|
||
print(" 🎭 using mock UI state (--no-mock to use live params)")
|
||
mock_params = _patch_mock_state()
|
||
|
||
# Override accent color if requested
|
||
if args.accent:
|
||
if mock_params:
|
||
mock_params.put("UIAccentColor", args.accent)
|
||
print(f" 🎨 accent color: {args.accent}")
|
||
|
||
panels = ALL_PANELS if args.panel == "all" else [args.panel]
|
||
mode = args.mode
|
||
|
||
if mode == "live" and len(panels) > 1:
|
||
print(" ⚠️ live mode only supports a single panel. Using first panel.")
|
||
panels = panels[:1]
|
||
|
||
all_outputs: list[Path] = []
|
||
|
||
for panel_name in panels:
|
||
print(f"\n ▶ {mode.upper()} — panel: {panel_name}")
|
||
|
||
try:
|
||
if mode == "live":
|
||
run_live(panel_name, args, mock_params)
|
||
elif mode == "screenshot":
|
||
saved = run_screenshot(panel_name, args, out_dir)
|
||
all_outputs.extend(saved)
|
||
elif mode == "video":
|
||
mp4 = run_video(panel_name, args, out_dir)
|
||
all_outputs.append(mp4)
|
||
except Exception as e:
|
||
print(f" ❌ failed for panel '{panel_name}': {e}")
|
||
import traceback; traceback.print_exc()
|
||
if rl.is_window_ready():
|
||
rl.close_window()
|
||
|
||
# Open all outputs at once
|
||
if args.open and all_outputs:
|
||
print(f"\n 📂 opening {len(all_outputs)} output(s)...")
|
||
subprocess.Popen(["open"] + [str(p) for p in all_outputs])
|
||
|
||
print("\n ✨ done\n")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|