Files
orisys-app/apps/studio/tools/ui_snapshot.py
T
ykcheungab e6a1e77a5b 采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - What: 1) 选区统计不再挤在曲线下方(中文长句会顶出面板右边界)——框选后三个数值卡的大号数字变成该窗口的峰值、下方小字为该窗口的均值·σ(chart.selection_detail),峰值语义放在卡片 tooltip;曲线下方只保留「选区 4.00s / 121 点」(完整一行留在它的 tooltip 里)。「全程总览」不算选区(那是默认适配),此时卡片回到"游标处样本";切换单位/换数据源/返回最新都会正确切回。2) 上方的 63.0 FPS 是采集循环自己的轮询速率,根因是同一帧被反复当成新样本:CaptureSource.read_latest 返回后台线程的最新帧,没有新帧时把同一帧再给一次,循环未做判断,于是 30 fps 的设备录出约 63 行/秒(frame_id 还各不相同)。新增 _capture_sequence():读 capture_source.status.last_seq,计数未变即"还没有新帧",睡 2 ms 重试、不进处理链、不发 packet;只有带后台读取线程的实时相机有该标记,视频文件是同步解码、每次都取下一帧,因此忽略(行为不变)。packet.fps 改为速率 (窗口帧数-1)/(首末到达差),窗口 1 s——按"1 秒内到达几帧"数数会系统性偏大。容器速率仍取传感器配置值,与此层无关。3) 底部状态栏只留一次性通知:删除右下角常驻「配置 sensor.json」(顶栏摘要按钮已写明),删除与顶栏重复的 status.run_started(运行中(0))与 status.connecting(正在连接…);QStatusBar 换成 _NoticeBar——Qt 的 showMessage(text) 默认 msecs=0 表示永不消失,这正是那句「运行中(0)」一直挂到底部的原因,子类统一补 6 秒超时;status.units_raw_fallback 之前每帧都发(等于永久占住通知行),改为每次运行只提示一次。附带修复:英文下表头模式名被截断(长句移入 tooltip,标签只留模式名 chart.mode_*_tip);数据源切换后曲线下方的选区文案会残留,改为在 _on_source_changed 里清零。 - Why: 用户评审 3 点——选区统计显示不全、顶部 fps 应受硬件限制最大 30、右下角配置文件名与左下角「运行中(0)」和顶栏信息重复。第 2 点查出根因是重复采样(数据缺陷,不只是显示问题),修掉后设备速率与记录行数才真正一致。 - Verification: ui_check.py 546 项 0 失败(新增:选区统计在卡片、卡片小字不裁切、状态栏无常驻标签且消息过期、顶栏 FPS 即 packet 交付速率、表头模式名中英不裁切);test_ui_smoke.py 71 项 0 失败(新增选区读数与通知行过期两例);drive_shared.py 148 项 0 失败(新增 shared/tests/test_worker_frame_rate.py:同一帧返回 3 次时 packet 数≈交付帧数,去掉判据为 11317:39,且 fps 与交付速率一致);locale_studio.py 267 键齐全、zh==en、prefs 对齐;reproduce.py 退出 0;ui_snapshot.py 新增 --select A,B,六张复核图(选区中英、实时、录制中、回放、标定 1280x720)。 - Not in this change: 录制文件格式与 CSV 列序不变;去重只影响"哪些帧算一帧",视频文件源行为不变;曲线下方不再显示三通道峰/均/σ(数值在卡片上,完整一行在 tooltip 里)。
2026-09-13 21:19:23 +08:00

342 lines
12 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 _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"),
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"
),
)
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.demo and args.scene != "replay":
_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())