#!/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 _replay_data(window, app) -> Path: """Synthesize one short recording (with visible frames) and open it.""" import numpy as np from orisys_app_shared.record import ExperimentRecorder, RecordConfig from ui_check import _fake_packet root = APP_ROOT / "outputs" / "_snapshot_replay" if root.exists(): import shutil shutil.rmtree(root, ignore_errors=True) root.mkdir(parents=True, exist_ok=True) recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0)) recording = Path( recorder.start( root=root, session_id=1, metadata={ "device": { "sensor_type": "box", "config_ref": str(root / "sensor.json"), "config_sha256": "snapshot", }, "versions": {"sdk": "snapshot"}, "depth": {"stored": False, "replay": "recompute"}, }, ) ) # A recognisable frame: a horizontal gradient with a bright bar, so the # replay's "recorded image" view is reviewable instead of black. height, width = 108, 192 frame = np.zeros((height, width, 3), np.uint8) frame[:, :, 1] = np.linspace(20, 200, width, dtype=np.uint8)[None, :] frame[height // 3 : 2 * height // 3, :, 2] = 180 for index in range(150): recorder.on_packet(_fake_packet(index, frame=frame)) recorder.stop() return recording 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. ``replay`` writes one short synthetic recording and opens it in the replay view, which is the only way to review the progress bar without a session. """ from ui_check import _fake_packet, _StuckWorker if scene == "replay": window.brand_bar.btn_playback.click() for _ in range(4): app.processEvents() recording = _replay_data(window, app) window._load_recording(recording) # Park the playhead where the bar and the charts show a real position. window.replay.set_position(window.replay.duration_s() * 0.4) window.arrow_panel.set_view_mode("recorded") for _ in range(6): app.processEvents() return 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 _flow_scene(window, app, *, frames: int = 240) -> None: """Drive a synthetic session whose frames carry a real optical-flow field. The 3D pane is coloured by the frame's *own* flow (see ``orisys_studio.scene3d.adapters``): a packet that does not state one is refused, so a picture with colour needs packets that do. This is the scene the interface is reviewed from — the real widgets, the real renderer, the real adapter, and a field shaped like a pressed fingertip (strongest at the centre, fading to the rim). """ import time as _time import numpy as np from orisys_app_shared.acquisition.sdk_worker import SdkPacket from ui_check import _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() # The unrolled finger domain: 280x300, which is the grid the scene's UV island # uses. The flow is already unrolled, so the adapter is given no remap. height, width = 300, 280 grid_y, grid_x = np.mgrid[0:height, 0:width].astype(np.float32) centre_y, centre_x = height * 0.52, width / 2.0 dy, dx = grid_y - centre_y, grid_x - centre_x radius = np.sqrt(dx * dx + dy * dy) + 1e-3 profile = np.exp(-((radius / (width * 0.30)) ** 2)).astype(np.float32) flow = np.stack( [dx / radius * profile * 7.0, dy / radius * profile * 7.0], axis=-1 ).astype(np.float32) # The other quantity the pane can colour: the chain's deformation response (a # relative, unitless "how deep"). Both are carried so the 颜色来源 switch has # something to switch between, and each is stated as this frame's own field. depth = (profile * 0.9).astype(np.float32) # A recognisable camera image as well, so the 2D panel is not black next to it. frame = np.zeros((height, width, 3), np.uint8) frame[..., 1] = (40.0 + profile * 150.0).astype(np.uint8) frame[..., 2] = (profile * 90.0).astype(np.uint8) for index in range(frames): # The forces move: a flat line reads as "the demo is not running", and the # charts are half of what the video has to show. phase = index / 30.0 # A period of a couple of seconds, so the trace visibly moves inside the chart's # rolling window instead of looking like a slow ramp. normal = 3.4 + 2.4 * math.sin(phase * 2.4) shear_x = 1.6 * math.sin(phase * 3.1 + 0.4) shear_y = -1.3 * math.cos(phase * 2.7) stuck.emit( SdkPacket( raw_frame=frame, processed_frame=frame, flow=flow, depth=depth, timestamp=1000.0 + index / 30.0, mono_t=float(index) / 30.0, fps=30.0, process_ms=6.0, capture_fps=30.0, session_id=1, frame_id=index, valid=True, raw_available=True, normal_force=normal, shear_x=shear_x, shear_y=shear_y, normal_force_raw=9.0 + index * 0.1, shear_x_raw=0.4, shear_y_raw=-0.4, force_unit="N", force_calibrated=True, calibrated_axes=("fn", "fx", "fy"), flow_valid=True, flow_frame_id=index, depth_valid=True, depth_frame_id=index, forces_valid=True, ) ) # The window's own presenter runs *while* the frames arrive, the way it does in # a session: the charts then hold a real time series instead of one sample. if index % 4 == 0: window._on_run_tick() app.processEvents() # The 3D picture is composed on the view's own worker and taken one finished frame # at a time by the run timer, so pump both until something has actually been shown. view = window.mesh3d_panel.view deadline = _time.perf_counter() + 20.0 while _time.perf_counter() < deadline: window._on_run_tick() app.processEvents() if view is not None and view.render_totals().get("shown", 0) > 0: break _time.sleep(0.01) window._run_timer.stop() # The overview strip is rebuilt at most once a second (it is a whole-session # decimation) and this scene fills eight seconds of session in well under that, so # it is rebuilt once here — the same thing ``_demo_data`` does for the same reason. window.force_plots._overview_dirty = True window.force_plots._last_overview_at = 0.0 window.force_plots._sync_overview() for _ in range(6): app.processEvents() def _demo_data(window) -> None: """Fill the plots, the calibration table and the arrow canvas.""" from orisys_app_shared.force.session import SessionSample, SessionStore plots = window.force_plots # The panel reads a *selection* out of the attached source, so the demo needs # a real session store behind it (a store-less panel can only follow live). store = SessionStore() plots.set_store(store) 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) store.append( SessionSample( frame_id=i, t_rel_s=t, fn_raw=fn * 7.9 + 9.0, fx_raw=fx * 7.9, fy_raw=fy * 7.9, fn_n=fn, fx_n=fx, fy_n=fy, calibrated_axes=("fn", "fx", "fy"), ) ) plots.push(t, fn, fx, fy, unit="N") # The overview reads the *source*, and ``push`` only rebuilds it once a second # (the demo fills a whole session in a few milliseconds), so build it once here. plots._overview_dirty = True plots._last_overview_at = 0.0 plots._sync_overview() 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", "finger-mems"), 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", "replay", "flow3d"), 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); " "replay writes one short synthetic recording and opens it in the " "replay view so the progress bar can be reviewed; " "flow3d drives packets that carry a real optical-flow field, which is " "what colours the 3D pane (use with --sensor finger --panels 3d)" ), ) parser.add_argument( "--select", default="", help=( "A,B — browse the live demo session inside that window so the " "selection readout (cards = peak / mean · σ) can be reviewed" ), ) 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") selection = None if args.select: try: start, end = (float(part) for part in args.select.split(",", 1)) except ValueError: parser.error("--select must look like 2,6") selection = (start, end) panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} from PySide6.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.demo and args.scene == "replay": pass # the recording is the demo data 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.scene == "replay": _run_scene(window, app, "replay") if args.scene == "flow3d": _flow_scene(window, app) if args.demo and args.scene not in ("replay", "flow3d"): _demo_data(window) for _ in range(4): app.processEvents() if selection is not None: # A selection is a browse window narrower than the session: the value # cards state its peak and mean · σ, the strip only its size. window.force_plots.set_window(*selection) 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())