回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 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:
+694
-98
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user