#!/usr/bin/env python3 """Render the studio UI offscreen to PNG files for visual review. The tool never opens a camera and never starts acquisition: it builds the real main window, optionally injects deterministic demo data, then grabs the window. Examples -------- python tools/ui_snapshot.py --theme dark --out out_dark.png python tools/ui_snapshot.py --theme light --sensor finger \ --panels cal,3d --demo --size 1600x900 --out out_light.png python tools/ui_snapshot.py --demo --scene recording --out out_recording.png """ from __future__ import annotations import argparse import math import os import sys from pathlib import Path APP_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = APP_ROOT.parents[1] for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src", APP_ROOT / "tools"): if _path.is_dir() and str(_path) not in sys.path: sys.path.insert(0, str(_path)) # Silent platform must be selected before QApplication exists. os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" if fonts.is_dir(): os.environ["QT_QPA_FONTDIR"] = str(fonts) os.chdir(APP_ROOT) def _run_scene(window, app, scene: str): """Drive the window into a scene that would otherwise need hardware. ``recording`` mocks the record channel's stats through the same single seam the app reads them from (``_record_stats``), so nothing is written to disk and no device is opened: the render is of the real widgets, not of a mockup. """ from ui_check import _fake_packet, _StuckWorker stuck = _StuckWorker() window._ensure_workers = lambda: None window._box_worker = stuck window._finger_worker = stuck window.toolbar.btn_run.setChecked(True) for _ in range(4): app.processEvents() if scene != "recording": return # Real packets so the phase reaches ``running`` and the rail holds real # values; the recording itself is the only mocked part. for index in range(300): stuck.emit(_fake_packet(index)) window._on_run_tick() for _ in range(4): app.processEvents() window._run_timer.stop() from orisys_app_shared.record.experiment import RecordStats from orisys_studio.ui import record_status directory = "20240101T101500Z_session0001_a1b2c3d4e5f6" window.toolbar.set_record_checked(True) window.toolbar.set_record_status(directory) window._record_stats = lambda: ( record_status.RECORDING, RecordStats( duration_s=12.4, frames=372, segments=1, dropped_data=3, bytes_written=48_000_000, ), directory, ) window._refresh_record_state() # A visible window inside the session, like the mock's 60-65 s of 0-65 s. first, last = window.session_store.extent() if last > first: window.force_plots.set_window(max(first, last - 6.0), last) for _ in range(4): app.processEvents() def _demo_data(window) -> None: """Fill the plots, the calibration table and the arrow canvas.""" plots = window.force_plots for i in range(240): t = i / 30.0 fn = 6.0 + 5.0 * math.sin(t * 1.4) fx = 4.0 * math.sin(t * 2.1 + 0.4) fy = -3.0 + 2.5 * math.cos(t * 1.1) plots.push(t, fn, fx, fy, unit="N") cal = window.cal_panel cal.set_running(True) cal.push_raw(9.81, -0.42, 0.10) for weight, raw, std in ((1.0, 9.72, 0.031), (2.0, 19.51, 0.044), (5.0, 48.90, 0.061)): from orisys_app_shared.force.calibration_io import CalSample cal._store.upsert("fn", CalSample(weight_n=weight, raw_mean=raw, n=180, std=std)) cal._refresh_table() # Synthetic outward flow so the branded canvas and flow ramp are reviewable. import numpy as np from orisys_app_shared.acquisition.arrows import ( draw_arrow_display, flow_colormap, hex_to_bgr, ) from orisys_app_shared.theme import color from orisys_studio.app_window import _bgr_to_qimage size = 320 grid_y, grid_x = np.mgrid[0:size, 0:size].astype(np.float32) centre = size / 2.0 flow = np.zeros((size, size, 2), np.float32) flow[..., 0] = (grid_x - centre) * 0.10 flow[..., 1] = (grid_y - centre) * 0.10 blank = np.zeros((size, size, 3), np.uint8) rendered = draw_arrow_display( blank, flow, background="pure_color", canvas_bgr=hex_to_bgr(color(window._theme, "arrow_canvas", "#0B1830")), draw_style="finger" if window.sensor_picker.sensor_type() == "finger" else "box", colormap=flow_colormap(window._theme), ) window.arrow_panel.show_frame(_bgr_to_qimage(rendered)) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--theme", choices=("dark", "light"), default="dark") parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN") parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box") parser.add_argument( "--panels", default="", help="comma list of optional panels to show: cal,3d", ) parser.add_argument("--size", default="1440x880", help="WxH window size") parser.add_argument("--view", default="arrows_pure") parser.add_argument( "--preset", choices=("custom", "live", "replay", "calibration"), default="custom", help="top-level view to render (the same layouts the brand rail switches)", ) parser.add_argument("--demo", action="store_true", help="inject demo plot/table data") parser.add_argument( "--scene", choices=("idle", "connecting", "recording"), default="idle", help=( "connecting renders the Stop/cancel state of a hung device open; " "recording renders the record strip of a running recording " "(mocked stats: no file is written, no device is opened)" ), ) parser.add_argument("--out", required=True, help="output PNG path") args = parser.parse_args() try: width, height = (int(part) for part in args.size.lower().split("x", 1)) except ValueError: parser.error("--size must look like 1440x880") panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} from PyQt5.QtWidgets import QApplication from orisys_app_shared.i18n import set_locale from orisys_app_shared.theme import build_qss from orisys_studio.app_window import StudioMainWindow app = QApplication(sys.argv) set_locale(args.lang) app.setStyleSheet(build_qss(args.theme)) prefs = { "language": args.lang, "theme": args.theme, "sensor_type": args.sensor, "show_cal_panel": "cal" in panels, "show_finger_3d": "3d" in panels, "show_fps": True, "image_view_mode": args.view, "view_preset": args.preset, } window = StudioMainWindow(prefs=prefs) window.resize(width, height) window.show() app.processEvents() if args.scene == "connecting": # A device open that never returns: the state the Stop button must # survive. ``ui_check._StuckWorker`` drives the same window state. _run_scene(window, app, "connecting") if args.scene == "recording": _run_scene(window, app, "recording") if args.demo: _demo_data(window) for _ in range(4): app.processEvents() out = Path(args.out) out.parent.mkdir(parents=True, exist_ok=True) ok = window.grab().save(str(out)) print(f"{'saved' if ok else 'FAILED'} {out} ({width}x{height}, theme={args.theme})") window.close() return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main())