Files
orisys-app/apps/studio/tools/ui_snapshot.py
T
ykcheungab c2293d4d41 界面:三维可选「深度响应 / 光流幅值」上色(默认深度),并加一个「诊断信息」开关收拾面板脚下的文字
- What(颜色来源): `DisplaySettings` 增 `field`(`"depth"` 默认 / `"flow"`),随显示设置持久化
  (`scene3d/prefs.py`:新增 `FIELD_CHOICES`/`DEFAULT_FIELD`,手改文件里的非法值修回默认)。
  `FingerFrameAdapter` 按所选字段先试、失败再用另一个,并把"换了量"这件事记在
  `last_substitution`(新诊断码 `field_substituted`,info 级);两个字段都拿不到时,
  `last_diagnostic` 取**信息量最大**的那条(域不符/过期/未声明 > 没这个字段),而不是"所选字段"的
  那条空话。设置对话框新增「颜色来源」下拉,下面一行 `field_note` 直接写"这一帧画的是 X,不是所选的 Y";
  图例(`legend_label`)永远标注**实际**画出来的量,所以设定与画面不一致时两处都能看出来。
  深度响应 `[1]`、光流幅值 `[px]`,两者都是 `approximate`——代码与文案都不把它们说成毫米。
- What(诊断信息开关): 视图菜单新增 `诊断信息`(可勾选、持久化 `show_diagnostics`、**默认关**,
  由 `DisplayPrefsController` 持有并推给两个面板)。关掉后:2D 面板只留"画面 + 计划说明 + 深度色标",
  三条解释流水线的标签(箭头按位移放大、原始图像 → 算法输出、全部帧)隐藏;3D 面板只留
  色条 + 一行图例 + 来源,info 级诊断隐藏。**永不隐藏**:warning/error 级诊断(测量是否过期、
  字段是否本帧的、是否与设备核对过——`geometry_unverified` 虽然是 info 也白名单保留)与
  深度色标。3D 图例改成 `ShrinkableLabel`(一行、裁切 + tooltip,不再折行),色标数值从 4 位有效数字
  收到 3 位("0.0334 – 0.899")。设置对话框控件宽度 88 → 108 px(不再需要迁就 240 px 面板)。
- What(取景脚本): `tools/ui_snapshot.py --scene flow3d` 现在同时给 flow 与 depth(各带 provenance),
  所以「颜色来源」开关有东西可切;再加 `.repro/shot_variants.py` 一次出四张图
  (深度版 / 光流版 / 诊断开 / 设置对话框)。
- What(测试): `test_scene3d_adapters.py` 增 3 项(默认深度、换量时给出 `field_substituted` 并在恢复后
  清掉、depth 不在场景栅格时按 `field_domain_mismatch` 拒绝而不是缩放);`test_scene3d_view.py` 增
  1 项(field 的持久化与修复,并把 `field in FIELD_CHOICES` 加进"手改文件会被修复"的参数化);
  `test_scene3d_pane_ui.py` 增 3 项(两种量可选 + 说明行 + 恢复的偏好会移动下拉;诊断开关只隐藏 info、
  保留 warning 与"未核对";2D 脚注三标签的显隐与窗口开关一致);`test_display_prefs.py` 增 1 项
  (开关推到两个面板、写偏好)。`test_ui_smoke.py` 与 `tools/ui_check.py` 里"箭头图例可见"的四条断言
  改为按新规则检查(默认隐藏、开诊断后可见、关掉再隐藏)。
- Why: ①"深度"比"光流"更贴近操作员口中的"压得多深",但两者都只是近似量,所以做成开关而不是替换——
  并且换量必须说出来(否则画面会被读成所选的量)。②面板脚下的解释文字是"内部管线"而不是"画面是什么",
  拍视频与日常看都只需要后者;用一个开关收起,比做一个滚动日志面板诚实(这些是每帧状态,不是事件)。
- Verification: pytest shared/tests apps/studio/tests(offscreen)658 passed / 0 failed / 15 skipped;
  真 GL 组(gl_smoke + view + pane_ui)70 passed / 0 failed / 1 skipped;
  `tools/ui_check.py` 590 项检查 0 失败;`.repro/locale_check.py` 0 缺失(新增 7 个 key 双语齐备)。
  目视验收:同一批合成数据渲染的四张图(深色主题)——深度版(默认)、光流版、诊断打开、设置对话框。
- Not in this change: 不改采集/记录/契约之外的三维渲染;不做页签容器与窄窗口回退;曲线量程仍是出厂 ±20 N。
  真机待验证:手指路径上 `packet.depth` 的栅格是否就是场景的 300×280(若不是,面板会写明并回退到光流)。
2026-09-15 21:23:18 +08:00

458 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"), 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 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.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())