回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 9 项评审问题 - What: 回放不再是独立窗口——删除 PlaybackDialog,拆成 replay.py::ReplayController(读盘、游标、深度重算,只发信号)与 ui/replay_bar.py(录制选择、可拖拽进度条、单位、重算深度、提示行),品牌轨「历史回放」直接换主窗口布局,文件菜单两条入口都切到该视图,曲线改读 RecordingCurveSource(区间按需读盘 + 缓存包络),2D 面板切「原始图像 / 深度图」;传感器与标定合并到 SensorDialog(类型/配置下拉 + 当前生效标定 + 最近导出系数),标定面板加纵向滚动区、九按钮两行、六列表头 ResizeToContents。评审 9 项:1) 开场尖峰两个根因——push() 遇单位变化只记 _unit 而保留旧单位样本(raw 计数被画在牛顿轴上),改为「切换即换单位」:清缓冲按新单位重建、触发切换的那一帧仍刷数值卡;_start_run 先 session_store.reset() 再 set_store(),新会话不再带上次的尾巴(复现脚本 .repro/probe_item1.py:旧 39 样本峰值 9.841 → 新 37 样本峰值 2.390)。2) 箭头页脚改为「箭头按位移放大 ×{factor}」。3) 表头单位芯片由只读 QLabel 改为可点按钮,一次点击同时改曲线、数值卡与设置区下拉。4-6) 全程总览/框选/返回最新 改为 ui/icons.py 按主题令牌运行时绘制的图标,tooltip 与 accessibleName 保留文字,全程总览区域名仍是文字。7) stats() 在录制进行中即返回 _last_t - _t0,记录条开始计时(此前只在 _finalise 写、整场停在 00:00.0)。8) 容器 fps 改用传感器配置的速率(packet.fps 是"每秒完成多少帧"的计数器,突发/文件源会读到 48),视频按 1/fps 节流并把超前的帧计入 resampled_video(frames.csv/forces.csv 一行不少,报告写明原因),session.json 同时记 fps 与 measured_fps。9) _replay_owns_image() 让回放视图独占 2D 面板,实时帧不再覆盖;push() 在录制源挂载时连单位都不切,曲线/数值卡/单位一并归录制。附带:数值卡改读游标处样本(_readout_at_cursor)、回放按录制实际有的单位打开(_replay_unit_for + ReplayController.set_unit,避免 raw-only 录制在 newton 偏好下画三张空图)、视频速率下限。 - Why: 用户评审 9 点,其中 1/7/8/9 是代码缺陷(开场尖峰、录制时长不更新、录制 48fps 与实际 30fps 不符、回放时 2D 面板仍被摄像头刷新),2-6 是可读性问题(×2 含义不明、单位与视图控制应可点/应符号化)。 - Verification: ui_check.py 524 项 0 失败(新增 chart header and unit chip / acquisition correctness / replay owns the 2D pane 三段)、test_ui_smoke.py 69 项 0 失败(新增 7 个回归用例:单位切换不混单位、单位芯片、图标语义、录制中时长、传感器帧率+节流、回放独占面板、回放游标读数与单位回退)、drive_shared.py 146 项 0 失败、locale_studio.py(268 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图(深色实时/回放、浅色 1280x720 实时与标定)。 - Not in this change: 录制文件格式(forces.csv 列序、frames.csv、session.json 路径与分段规则)不变,节流只影响"哪些帧进视频",并显式计数上报;三维面板仍是占位符。

This commit is contained in:
ykcheungab
2026-09-13 20:46:27 +08:00
parent 35e86ec1f7
commit 824544fc7a
20 changed files with 3129 additions and 834 deletions
+694 -98
View File
@@ -175,14 +175,19 @@ def _fake_packet(
timestamp: float | None = None,
mono_t: float | None = None,
shear_n: float | None = None,
frame=None,
):
"""A packet shaped like the real worker's, for the record-channel checks."""
"""A packet shaped like the real worker's, for the record-channel checks.
``frame`` replaces the blank 8x8 image, so a synthetic recording can carry
something visible (see ``ui_snapshot --scene replay``).
"""
import numpy as np
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
nan = float("nan")
blank = np.zeros((8, 8, 3), np.uint8)
blank = np.zeros((8, 8, 3), np.uint8) if frame is None else frame
# A single tall sample makes it easy to prove decimation keeps peaks.
fn_raw = 99.0 if frame_id == 700 else (9.0 + frame_id * 0.01)
return SdkPacket(
@@ -863,29 +868,26 @@ def main() -> int:
f"{theme}: the settings area starts expanded",
)
check(window.toolbar.status_chip.text().strip(), f"{theme}: the phase chip is empty")
# The sensor is one summary button on the rail; the pickers (and the
# calibration they belong to) live in the dialog it opens.
check(
window.sensor_picker.isVisible(),
f"{theme}: the sensor picker is not on the status rail",
window.toolbar.btn_sensor.text() == t(
"sensor.summary", type="BOX", config="sensor.json"
),
f"{theme}: the sensor summary is wrong: "
f"{window.toolbar.btn_sensor.text()!r}",
)
check(
window.sensor_picker.type_label.text() == t("sensor.type")
and window.sensor_picker.config_label.text() == t("sensor.config"),
f"{theme}: the sensor picker labels are not the documented ones: "
f"{window.sensor_picker.type_label.text()!r} / "
f"{window.sensor_picker.config_label.text()!r}",
window.toolbar.btn_sensor.isVisible(),
f"{theme}: the sensor button is not on the status rail",
)
check(
window.sensor_picker.thumb_label.text() == "BOX",
f"{theme}: the product badge does not name the sensor: "
f"{window.sensor_picker.thumb_label.text()!r}",
window.toolbar.camera_group.isVisible(),
f"{theme}: the live view does not offer the camera picker",
)
check(
window.sensor_picker.config_label.toolTip() != "",
f"{theme}: the sensor config picker has no explanation",
)
check(
window.toolbar.sensor_summary.text() != "",
f"{theme}: the rail does not name the capture source",
not window.toolbar.video_group.isVisible(),
f"{theme}: the live camera view also shows the video-file group",
)
check(
window.toolbar.calibration_label.text().strip(),
@@ -913,6 +915,60 @@ def main() -> int:
f"{theme}: the status bar does not name the config file: "
f"{window.device_label.text()!r}",
)
check(
not hasattr(window.toolbar, "source_combo"),
f"{theme}: the source mode is still a combobox",
)
# The sensor dialog owns the pickers and the calibration facts.
window._open_sensor_dialog()
for _ in range(3):
app.processEvents()
dialog = window.sensor_dialog
check(dialog is not None and dialog.isVisible(), f"{theme}: the sensor dialog did not open")
check(
window.sensor_picker.isVisible(),
f"{theme}: the sensor pickers are not reachable in the dialog",
)
check(
window.sensor_picker.type_label.text() == "传感器"
and window.sensor_picker.config_label.text() == "传感器配置",
f"{theme}: the sensor picker labels are not the documented ones: "
f"{window.sensor_picker.type_label.text()!r} / "
f"{window.sensor_picker.config_label.text()!r}",
)
check(
dialog.version_label.text() != "" and dialog.axes_label.text() != "",
f"{theme}: the dialog states no calibration",
)
check(
t("sensor.export_none") in dialog.export_label.text()
or dialog.btn_apply.isEnabled(),
f"{theme}: the export row states neither a file nor its absence: "
f"{dialog.export_label.text()!r}",
)
# Changing the product in the dialog reaches the window.
window.sensor_picker.type_combo.setCurrentIndex(
window.sensor_picker.type_combo.findData("finger")
)
for _ in range(3):
app.processEvents()
check(
window.prefs.get("sensor_type") == "finger",
f"{theme}: the dialog's product change did not reach the window",
)
check(
"FINGER" in window.toolbar.btn_sensor.text(),
f"{theme}: the rail summary did not follow the product change: "
f"{window.toolbar.btn_sensor.text()!r}",
)
window.sensor_picker.type_combo.setCurrentIndex(
window.sensor_picker.type_combo.findData("box")
)
for _ in range(3):
app.processEvents()
dialog.close()
app.processEvents()
# The settings area is one click away, and remembers that it was opened.
window.toolbar.btn_settings.setChecked(True)
@@ -925,12 +981,8 @@ def main() -> int:
f"({expanded_height} vs {rail_height})",
)
check(
window.toolbar.source_combo.isVisible(),
f"{theme}: the source control is unreachable in the settings area",
)
check(
window.sensor_picker.isVisible(),
f"{theme}: the sensor picker is unreachable in the settings area",
window.toolbar.units_combo.isVisible() and window.toolbar.btn_preview.isVisible(),
f"{theme}: the unit and preview controls are unreachable",
)
check(
window.prefs.get("settings_expanded") is True,
@@ -966,9 +1018,10 @@ def main() -> int:
f"en_US at 1280x720: the status rail squeezes {rail_squeezed(english_window)}",
)
check(
"FINGER" == english_window.sensor_picker.thumb_label.text(),
f"the product badge is truncated: "
f"{english_window.sensor_picker.thumb_label.text()!r}",
t("sensor.summary", type="FINGER", config="sensor.json")
in english_window.toolbar.btn_sensor.text(),
f"the sensor summary is not stated in full: "
f"{english_window.toolbar.btn_sensor.text()!r}",
)
english_window.close()
app.processEvents()
@@ -1432,9 +1485,15 @@ def main() -> int:
not window.cal_panel.isVisible() and window.arrow_panel.isVisible(),
"the replay view is not the 2D + charts arrangement",
)
# No recording is open yet: the bar says so and the charts keep following the
# live session instead of pretending to replay something.
check(
window.force_plots.mode() == "browse",
"the replay view did not open on the whole session",
window.replay_bar.isVisible() and not window.replay.is_loaded(),
"the replay view is not showing an empty progress bar",
)
check(
window.force_plots.mode() == "follow",
"the replay view browses before a recording is open",
)
window.brand_bar.btn_live.click()
@@ -1496,6 +1555,315 @@ def main() -> int:
window.close()
app.processEvents()
section("chart header and unit chip")
from orisys_studio.ui.force_plots import unit_display
header_window = StudioMainWindow(
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
)
header_window.resize(1440, 880)
header_window.show()
for _ in range(3):
app.processEvents()
header_plots = header_window.force_plots
for button, name in (
(header_plots.btn_overview, "zoom to fit"),
(header_plots.btn_box_zoom, "box zoom"),
(header_plots.btn_latest, "back to latest"),
):
check(button.text() == "", f"the {name} action still shows text")
check(not button.icon().isNull(), f"the {name} action has no icon")
check(button.toolTip() != "", f"the {name} action has no tooltip")
check(button.accessibleName() != "", f"the {name} action has no accessible name")
check(
isinstance(header_plots.unit_badge, type(header_plots.btn_overview)),
"the unit chip is not a button",
)
check(
header_plots.unit_badge.toolTip() != "",
"the unit chip does not say what it does",
)
# The chip switches the same unit everywhere: the charts and the toolbar.
before_unit = header_plots.unit()
header_plots.unit_badge.click()
app.processEvents()
check(
header_plots.unit() != before_unit,
f"the unit chip did not switch the charts: {header_plots.unit()}",
)
check(
("newton" if header_plots.unit() == "N" else "raw")
== header_window.toolbar.units(),
"the unit chip and the toolbar disagree about the unit",
)
check(
header_plots.unit_badge.text() == unit_display(header_plots.unit()),
f"the chip does not state the active unit: {header_plots.unit_badge.text()!r}",
)
header_plots.unit_badge.click()
app.processEvents()
check(
header_plots.unit() == before_unit,
"the unit chip does not switch back",
)
# The arrow legend says what the factor multiplies.
check(
"×" in header_window.arrow_panel.scale_label.text(),
"the arrow footer lost its magnification",
)
check(
"位移" in header_window.arrow_panel.scale_label.text()
or "displacement" in header_window.arrow_panel.scale_label.text(),
f"the arrow footer does not say what is magnified: "
f"{header_window.arrow_panel.scale_label.text()!r}",
)
header_window.close()
app.processEvents()
section("acquisition correctness")
import csv as _csv
import shutil as _shutil
import numpy as np
# A device that only confirms its calibration a moment after the first
# frames reports raw counts for those frames. They must never be drawn on the
# newton axis: that was the tall transient at the start of every session.
stuck = _StuckWorker()
window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
)
window.resize(1440, 880)
window.show()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
for _ in range(3):
app.processEvents()
window._start_run()
window._run_timer.stop()
window._set_phase("running")
plots = window.force_plots
for index in range(3):
packet = _fake_packet(index, calibrated_axes=())
packet.force_calibrated = False
stuck.emit(packet)
window._on_run_tick()
check(
plots.unit() == "raw",
f"an uncalibrated device is not shown in raw units: {plots.unit()}",
)
for index in range(3, 40):
stuck.emit(_fake_packet(index))
window._on_run_tick()
state = plots.history.state()
check(plots.unit() == "N", f"the unit did not follow the calibration: {plots.unit()}")
check(
plots.history.sample_count == 37,
f"the raw frames were kept in the newton buffer: "
f"{plots.history.sample_count} samples",
)
peak = (
float(np.nanmax(np.abs(state.values[:, 0]))) if state is not None else float("inf")
)
check(
state is not None and peak < 5.0,
f"a raw count is still drawn on the newton axis (peak {peak})",
)
# A new session must not inherit the previous one's samples.
window._stop_run(reason="user_stop")
window._start_run()
window._run_timer.stop()
window._set_phase("running")
check(
plots.history.sample_count == 0 and plots.mode() == "follow",
f"a new session started with {plots.history.sample_count} old samples",
)
# While recording, the reported duration is the span captured so far: the
# record strip used to sit at 00:00.0 for the whole recording.
app_module = sys.modules["orisys_studio.app_window"]
scratch = APP_ROOT / "outputs" / "_correctness_check"
_shutil.rmtree(scratch, ignore_errors=True)
scratch.mkdir(parents=True, exist_ok=True)
original_root = app_module.recordings_dir
app_module.recordings_dir = lambda: scratch
try:
window.toolbar.btn_record.setChecked(True)
app.processEvents()
check(window.recorder.is_recording, "the recording did not start")
for index in range(96):
# 48 frames per second of acquisition time: above the 30 fps the
# sensor config declares, which is what the container must follow.
packet = _fake_packet(index, mono_t=index / 48.0)
packet.fps = 48.0
stuck.emit(packet)
window._on_run_tick()
live_stats = window.recorder.stats()
check(
live_stats.duration_s > 1.5,
f"the live recording duration stayed at {live_stats.duration_s}",
)
check(
"00:0" not in window.toolbar.record_chip.text()
or window.toolbar.record_chip.text()
!= t("record.state_recording", duration="00:00.0"),
f"the record strip does not count up: "
f"{window.toolbar.record_chip.text()!r}",
)
check(
abs(window._video_fps() - 30.0) < 0.01,
f"the video rate is not the sensor config's: {window._video_fps()}",
)
window.toolbar.btn_record.setChecked(False)
app.processEvents()
report = window._record_last_report
check(report is not None, "no record report was published")
if report is not None:
check(
report.stats.resampled_video > 0,
"the faster-than-configured frames were not reported",
)
with (report.directory / "frames.csv").open(encoding="utf-8") as handle:
rows = list(_csv.DictReader(handle))
check(
len(rows) == 96,
f"the frame rows were thinned by the video pacing: {len(rows)}",
)
check(
any("faster than" in problem for problem in report.problems),
f"the report does not explain the resampled frames: {report.problems}",
)
import cv2 as _cv2
segments = sorted(report.directory.glob("raw_*.mp4"))
check(bool(segments), "the recording wrote no video segment")
if segments:
capture = _cv2.VideoCapture(str(segments[0]))
try:
written = int(capture.get(_cv2.CAP_PROP_FRAME_COUNT))
container_fps = float(capture.get(_cv2.CAP_PROP_FPS))
finally:
capture.release()
check(
abs(container_fps - 30.0) < 1.0,
f"the video declares {container_fps} fps, not the sensor's 30",
)
check(
55 <= written <= 65,
f"the video holds {written} frames for 2 s at 30 fps",
)
finally:
app_module.recordings_dir = original_root
_shutil.rmtree(scratch, ignore_errors=True)
window._stop_run(reason="user_stop")
stuck.release()
app.processEvents()
section("replay owns the 2D pane")
# A replay running next to a live session must not be overwritten by the
# camera: the pane belongs to whichever source the view is showing.
replay_root = APP_ROOT / "outputs" / "_pane_check"
_shutil.rmtree(replay_root, ignore_errors=True)
replay_root.mkdir(parents=True, exist_ok=True)
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0))
recording = recorder.start(root=replay_root, session_id=1, metadata={})
for index in range(20):
# A raw-only recording: no calibration curve, so the calibrated columns
# stay empty. Showing that in newton mode would draw three blank charts.
packet = _fake_packet(index)
packet.force_calibrated = False
recorder.on_packet(packet)
recorder.stop()
stuck = _StuckWorker()
pane_window = StudioMainWindow(
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
)
pane_window.resize(1440, 880)
pane_window.show()
pane_window._ensure_workers = lambda: None
pane_window._box_worker = stuck
pane_window._finger_worker = stuck
pane_window._start_run()
pane_window._run_timer.stop()
pane_window._set_phase("running")
pane_window.toolbar.set_units("newton")
app.processEvents()
pane_window.brand_bar.btn_playback.click()
app.processEvents()
check(pane_window._load_recording(recording), "the replay could not open the recording")
app.processEvents()
check(pane_window._replay_owns_image(), "the replay does not own the 2D pane")
# The recording has no calibrated axis, so newton would be an empty chart.
check(
pane_window.force_plots.unit() == "raw",
f"a raw-only recording was shown in {pane_window.force_plots.unit()}",
)
check(
pane_window.toolbar.units() == "raw",
"the toolbar and the charts disagree about a recording's unit",
)
check(
pane_window.replay_bar.btn_unit.text() == t("playback.show_newton"),
"the replay bar's unit switch disagrees with the charts",
)
drawn = [int(curve.getData()[0].size) for curve in pane_window.force_plots.curves]
check(all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}")
# The cards read the point under the playhead, not the live acquisition.
pane_window.replay_bar.slider.setValue(pane_window.replay_bar.slider.maximum())
app.processEvents()
cursor_card = pane_window.force_plots.value_labels[0].text()
check(
cursor_card not in ("", t("chart.value_unavailable")),
f"the replay readout is empty: {cursor_card!r}",
)
check(
float(cursor_card) > 9.0,
f"the readout is not the sample at the cursor: {cursor_card}",
)
before_key = pane_window.arrow_panel.image_label.pixmap().cacheKey()
before_text = pane_window.arrow_panel.image_label.text()
for index in range(3):
packet = _fake_packet(index, mono_t=index / 30.0)
packet.raw_frame = np.full((24, 32, 3), 200, np.uint8)
stuck.emit(packet)
pane_window._on_run_tick()
app.processEvents()
check(
pane_window.arrow_panel.image_label.pixmap().cacheKey() == before_key
and pane_window.arrow_panel.image_label.text() == before_text,
"a live frame overwrote the replay's picture",
)
check(
pane_window.force_plots.unit() == "raw",
"a live sample switched the unit of the recording being replayed",
)
check(
pane_window.force_plots.value_labels[0].text() == cursor_card,
"a live sample overwrote the replay's readout",
)
pane_window.brand_bar.btn_live.click()
app.processEvents()
check(
not pane_window._replay_owns_image(),
"the live view still hands the 2D pane to the replay",
)
pane_window._on_run_tick()
app.processEvents()
pane_window.replay.close()
pane_window._stop_run(reason="user_stop")
stuck.release()
pane_window.close()
app.processEvents()
_shutil.rmtree(replay_root, ignore_errors=True)
section("clean shutdown")
# A camera SDK leaves native threads behind; the entry point must still come
# back to the console. This runs the real ``main()`` in a child process and
@@ -2072,6 +2440,16 @@ def main() -> int:
plots.stats_label.text() == "",
"the selection readout is not blank while following live",
)
# Zoom and pan are available while following too: a chart that ignored the
# mouse could never be dragged into browsing in the first place.
check(
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
"panning and zooming are disabled while following live",
)
check(
not any(cursor.movable for cursor in plots.cursors),
"the cursor is draggable while following live",
)
first, last = window.session_store.extent()
check(last > first, "the session store has no extent after recording")
@@ -2099,13 +2477,36 @@ def main() -> int:
"the whole-session overview strip is empty",
)
# A user gesture enters browse; "back to latest" must actually leave it.
# A user gesture (the signal pyqtgraph emits for a wheel/drag) enters browse;
# "back to latest" must actually leave it.
plots._syncing = False
plots.plots[0].setXRange(5.0, 8.0, padding=0.0)
view_box = plots.plots[0].getViewBox()
view_box.setXRange(5.0, 8.0, padding=0.0)
view_box.sigRangeChangedManually.emit([True, False])
app.processEvents()
check(plots.mode() == "browse", "a user pan did not enter browse mode")
check(
plots.window() == (5.0, 8.0),
f"the dragged window was not applied: {plots.window()}",
)
check(
all(cursor.movable for cursor in plots.cursors),
"the cursor is not draggable while browsing",
)
plots.follow_latest()
check(plots.mode() == "follow", "back-to-latest did not restore live mode")
check(
not any(cursor.movable for cursor in plots.cursors),
"the cursor stayed draggable after leaving browsing",
)
# A programmatic (or X-link propagated) range change is not a gesture.
plots._syncing = False
view_box.setXRange(1.0, 2.0, padding=0.0)
app.processEvents()
check(
plots.mode() == "follow",
"a programmatic range change switched the view into browsing",
)
plots.set_cursor(3.5, emit=True)
check(
@@ -2113,6 +2514,22 @@ def main() -> int:
"the time cursor is not shared across the three channels",
)
# Dragging the shared cursor moves the playhead and notifies the listeners.
plots.show_all()
app.processEvents()
dragged_cursor: list[float] = []
plots.cursor_moved.connect(dragged_cursor.append)
plots.cursors[0].setValue(2.5)
app.processEvents()
check(
[round(cursor.value(), 3) for cursor in plots.cursors] == [2.5, 2.5, 2.5],
"dragging one cursor did not move the shared cursor",
)
check(
dragged_cursor and dragged_cursor[-1] == 2.5,
f"the cursor drag did not notify the other views: {dragged_cursor}",
)
# History browsing is a display pause: capture and recording keep going.
plots.show_all()
before = window.session_store.count
@@ -2122,12 +2539,23 @@ def main() -> int:
window.session_store.count > before,
"browsing history stopped the record channel",
)
# Unit switches rebuild from the attached source without leaving live mode.
plots.follow_latest()
plots._unit = "raw"
window._on_units_changed("newton")
app.processEvents()
check(
plots.mode() == "follow",
f"switching units left the charts in browsing: {plots.mode()}",
)
check(plots._source_ready, "switching units detached the curve source")
check(plots._unit == "N", f"the unit switch did not reach the charts: {plots._unit}")
window.close()
app.processEvents()
stuck.release()
section("layout preset")
section("views and their source pickers")
stuck = _StuckWorker()
window = StudioMainWindow(
prefs={
@@ -2145,27 +2573,61 @@ def main() -> int:
window._finger_worker = stuck
app.processEvents()
check(window._layout_preset == "default", "the layout preset did not load")
check(window.mesh3d_panel.isVisible(), "the 3D panel is hidden in the free layout")
window._on_layout_preset("2d")
app.processEvents()
check(window._layout_preset == "2d", "the 2D preset did not apply")
check(not window.mesh3d_panel.isVisible(), "the 2D preset keeps the 3D panel")
# The 3D panel needs a finger sensor and its own toggle, and no layout preset
# is involved any more.
check(window.mesh3d_panel.isVisible(), "the finger 3D panel is hidden in the live view")
check(window.act_finger_3d.isEnabled(), "the 3D toggle is disabled for a finger sensor")
check(
window.arrow_panel.view_mode() == "depth",
"the 2D preset does not show the depth map",
not hasattr(window, "_layout_preset"),
"the removed layout preset is still part of the window",
)
check(
not hasattr(window, "act_layout_2d"),
"the removed layout menu entry is still part of the window",
)
check(not window.cal_panel.isVisible(), "the 2D preset keeps calibration expanded")
check(not window.act_finger_3d.isEnabled(), "the 3D toggle is live in the 2D preset")
check(window.act_layout_2d.isChecked(), "the 2D menu entry is not checked")
window._on_layout_preset("default")
app.processEvents()
check(window.mesh3d_panel.isVisible(), "the free layout did not restore the 3D panel")
check(window.act_finger_3d.isEnabled(), "the 3D toggle stayed disabled")
window._persist_prefs()
check(window.prefs.get("layout_preset") == "default", "the preset was not persisted")
# Live: the camera picker; replay: the recording picker and no camera.
check(
window.toolbar.camera_group.isVisible(),
"the live view does not show the camera picker",
)
window.brand_bar.btn_playback.click()
for _ in range(3):
app.processEvents()
check(
not window.toolbar.camera_group.isVisible()
and not window.toolbar.video_group.isVisible(),
"the replay view still shows the live capture pickers",
)
window.brand_bar.btn_live.click()
for _ in range(3):
app.processEvents()
check(
window.toolbar.camera_group.isVisible(),
"returning to the live view did not restore the camera picker",
)
# A video file is an offline acquisition run: the rail states it and offers
# the way back, with no source combobox anywhere.
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._set_source_mode("video")
for _ in range(3):
app.processEvents()
check(
window.toolbar.video_group.isVisible() and not window.toolbar.camera_group.isVisible(),
"video mode still offers the camera group",
)
check(
"demo.mp4" in window.toolbar.source_summary.text(),
f"the rail does not name the video file: {window.toolbar.source_summary.text()!r}",
)
window.toolbar.use_camera_clicked.emit()
for _ in range(3):
app.processEvents()
check(
window.toolbar.source_mode() == "camera" and window.toolbar.video_path() == "",
"the way back to the camera did not take effect",
)
window.close()
app.processEvents()
@@ -2198,21 +2660,86 @@ def main() -> int:
recorder.on_packet(_fake_packet(index))
recorder.stop()
from orisys_studio.ui.playback_dialog import PlaybackDialog
from orisys_studio.replay import ReplayController
playback = PlaybackDialog(theme="dark", directory=recording)
playback.resize(1000, 620)
playback.show()
# The replay lives in the main window: one engine, one progress bar, and the
# charts read the recording itself.
replay_window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
)
replay_window.resize(1440, 880)
replay_window.show()
app.processEvents()
replay_window.brand_bar.btn_playback.click()
app.processEvents()
check(
playback.position() == 0.0,
f"replay did not start at the beginning: {playback.position()}",
replay_window.replay_bar.isVisible(),
"the replay view has no progress bar",
)
check(
all(int(curve.getData()[0].size) == 40 for curve in playback.curves),
not replay_window.toolbar.camera_group.isVisible(),
"the replay view still offers the camera picker",
)
check(
replay_window._load_recording(recording),
"the replay view could not open the recording",
)
app.processEvents()
replay = replay_window.replay
replay_plots = replay_window.force_plots
check(
replay.position() == 0.0,
f"replay did not start at the beginning: {replay.position()}",
)
check(
all(int(curve.getData()[0].size) == 40 for curve in replay_plots.curves),
"replay did not draw every force sample",
)
check(
replay_plots.is_recording_source() and replay_plots.mode() == "browse",
"the charts do not browse the recording",
)
check(
replay_plots.mode_label.text() == t("chart.mode_replay"),
f"the charts do not say they are replaying: {replay_plots.mode_label.text()!r}",
)
check(
not replay_plots.btn_latest.isEnabled(),
"a recording offers 'back to latest'",
)
# The progress bar is the playhead, and the chart cursor is a second one.
check(replay_window.replay_bar.slider.isEnabled(), "the progress bar is disabled")
replay_window.replay_bar.slider.setValue(replay_window.replay_bar.slider.maximum() // 2)
app.processEvents()
check(
0.0 < replay.position() < replay.duration_s(),
f"dragging the progress bar did not move the playhead: {replay.position()}",
)
check(
abs(float(replay_plots.cursors[0].value()) - replay.position()) < 0.05,
"the chart cursor did not follow the progress bar",
)
replay_plots.cursors[0].setValue(0.25)
app.processEvents()
check(
abs(replay.position() - 0.25) < 0.05,
f"dragging the chart cursor did not scrub the replay: {replay.position()}",
)
check(
replay.video_image() is not None,
"no video frame was rendered at the cursor position",
)
check(
replay_window.arrow_panel.view_modes() == ("recorded", "depth"),
"the 2D panel still offers the live-only views while replaying",
)
# Long-history browsing: an interval read is bounded by the interval, and the
# whole-session envelope is a band rather than every row.
@@ -2238,26 +2765,44 @@ def main() -> int:
reader.info().total_rows == 40,
f"the recording reports {reader.info().total_rows} rows",
)
check(playback.slider.isEnabled(), "the replay position slider is disabled")
check(
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
in playback.warning_label.text(),
f"the replay notes are missing: {playback.warning_label.text()!r}",
in replay.warnings_text(),
f"the replay notes are missing: {replay.warnings_text()!r}",
)
check(
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
in replay_window.replay_bar.warning_label.text(),
f"the warnings are not shown in the bar: "
f"{replay_window.replay_bar.warning_label.text()!r}",
)
moved: list[float] = []
playback.cursor_moved.connect(moved.append)
playback.set_position(1.0)
replay.position_changed.connect(moved.append)
replay.set_position(1.0)
app.processEvents()
check(
[round(line.value(), 2) for line in playback.cursor_lines] == [1.0, 1.0, 1.0],
[round(line.value(), 2) for line in replay_plots.cursors] == [1.0, 1.0, 1.0],
"the replay cursor is not shared across the three charts",
)
check(moved == [1.0], f"the cursor signal did not fire once: {moved}")
check(
playback.video_label.pixmap() is not None
and not playback.video_label.pixmap().isNull(),
"no video frame was rendered at the cursor position",
"1.00" in replay_window.replay_bar.position_label.text(),
f"the bar does not show the playhead: "
f"{replay_window.replay_bar.position_label.text()!r}",
)
check(
replay_window.replay_bar.slider.value()
== int(replay_window.replay_bar.slider.maximum() * 1.0 / replay.duration_s()),
"the progress bar did not follow a programmatic position",
)
# The unit switch rebuilds the recording's curves, not the live ones.
replay_window.replay_bar.unit_toggled.emit()
app.processEvents()
check(
replay.unit() == "N" and replay_plots._unit == "N",
f"the replay unit switch did not reach the charts: {replay.unit()}",
)
# The depth recomputation must never run on the UI thread. A stub that blocks
@@ -2278,43 +2823,53 @@ def main() -> int:
def close(self) -> None:
return None
playback._recomputer = _Blocking() # noqa: SLF001 - exercising the contract
replay._recomputer = _Blocking() # noqa: SLF001 - exercising the contract
started = _time.perf_counter()
playback._on_recompute_depth()
replay.request_depth()
elapsed = _time.perf_counter() - started
check(elapsed < 0.5, f"the depth recompute blocked the UI for {elapsed:.2f}s")
check(
playback._depth_thread is not None and playback._depth_thread.is_alive(),
replay._depth_thread is not None and replay._depth_thread.is_alive(),
"the depth recompute did not run on a worker thread",
)
check(
playback._depth_thread.daemon,
replay._depth_thread.daemon,
"the depth worker is not a daemon, so a blocked SDK call would keep the "
"process alive at exit",
)
playback._on_recompute_depth() # a second request is refused, not queued
replay.request_depth() # a second request is refused, not queued
check(
playback.depth_label.text() == t("playback.depth_busy"),
f"a concurrent recompute was not refused: {playback.depth_label.text()!r}",
replay.depth_message() == t("playback.depth_busy"),
f"a concurrent recompute was not refused: {replay.depth_message()!r}",
)
_Blocking.released.set()
for _ in range(40):
app.processEvents()
_time.sleep(0.05)
if not playback._depth_busy(): # noqa: SLF001
if not replay._depth_busy(): # noqa: SLF001
break
check(not playback._depth_busy(), "the depth worker never finished")
check(not replay._depth_busy(), "the depth worker never finished")
# Deliver the worker result deterministically instead of waiting for the
# 120 ms poll timer.
playback._poll_depth()
replay._poll_depth()
app.processEvents()
check(
t("playback.depth_failed").split("{")[0].strip()[:4]
in playback.depth_label.text(),
f"a failed recomputation was not reported: {playback.depth_label.text()!r}",
t("playback.depth_failed").split("{")[0].strip()[:4] in replay.depth_message(),
f"a failed recomputation was not reported: {replay.depth_message()!r}",
)
playback.close()
check(
replay.depth_image() is None,
"a failed recomputation left an image in the depth pane",
)
# The 2D panel shows the depth pane's explanation, not the raw frame.
replay_window.arrow_panel.set_view_mode("depth")
app.processEvents()
check(
replay_window.arrow_panel.depth_scale_label.text() == ""
and replay_window.arrow_panel.colorbar.isVisible(),
"the replay depth view does not show its colour bar",
)
replay_window.arrow_panel.set_view_mode("recorded")
app.processEvents()
# The plan must state where to start, which reset it is anchored to and
@@ -2364,42 +2919,83 @@ def main() -> int:
# A reset boundary is re-applied, and a request is bound to its recording.
_Blocking.released.clear()
second = PlaybackDialog(theme="dark", directory=recording)
second.show()
app.processEvents()
serial_before = second._record_serial
second_controller = ReplayController()
second_controller.open_recording(recording)
serial_before = second_controller._record_serial
blocking = _Blocking()
second._recomputer = blocking
second._on_recompute_depth()
check(second._depth_pending_record == serial_before, "the request has no recording id")
second_controller._recomputer = blocking
second_controller.request_depth()
check(
second._depth_pending_seq is not None,
second_controller._depth_pending_record == serial_before,
"the request has no recording id",
)
check(
second_controller._depth_pending_seq is not None,
"the request carries no sequence number",
)
# Opening another recording invalidates the running request...
second.open_recording(recording)
second_controller.open_recording(recording)
check(
second._record_serial == serial_before + 1,
second_controller._record_serial == serial_before + 1,
"opening a recording did not invalidate the running request",
)
check(
second._recomputer is not blocking,
second_controller._recomputer is not blocking,
"the invalidated processor was not replaced",
)
_Blocking.released.set()
for _ in range(40):
app.processEvents()
_time.sleep(0.05)
if not second._depth_busy():
if not second_controller._depth_busy():
break
second._poll_depth()
second_controller._poll_depth()
app.processEvents()
pixmap = second.depth_label.pixmap()
check(
pixmap is None or pixmap.isNull(),
second_controller.depth_image() is None,
"an answer for a previous recording was drawn into the new one",
)
second.close()
second_controller.close()
app.processEvents()
# Leaving the replay view returns the charts to the live session and hides
# the bar: the replay stays available, it just stops being on screen.
check(
replay_window._load_recording(recording),
"the replay view could not be re-opened",
)
app.processEvents()
replay_window.brand_bar.btn_live.click()
app.processEvents()
check(
not replay_plots.is_recording_source() and replay_plots.mode() == "follow",
"leaving the replay view did not restore the live charts",
)
check(
not replay_window.replay_bar.isVisible(),
"the progress bar is still on screen in the live view",
)
check(
replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"),
"the 2D panel did not return to its live views",
)
check(
replay_window.replay.is_loaded(),
"leaving the view dropped the open recording",
)
# The replay engine is inside the shell now: there is no second window.
check(
not hasattr(replay_window, "_playback"),
"the window still keeps a replay dialog reference",
)
check(
not hasattr(replay_window, "_open_playback"),
"the window still opens a separate replay window",
)
replay_window.replay.close()
replay_window.close()
app.processEvents()
_shutil.rmtree(replay_root, ignore_errors=True)
+68 -3
View File
@@ -35,15 +35,72 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
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
@@ -160,12 +217,14 @@ def main() -> int:
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
parser.add_argument(
"--scene",
choices=("idle", "connecting", "recording"),
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)"
"(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("--out", required=True, help="output PNG path")
@@ -203,6 +262,9 @@ def main() -> int:
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.
@@ -211,7 +273,10 @@ def main() -> int:
if args.scene == "recording":
_run_scene(window, app, "recording")
if args.demo:
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()