回放与实时统一为一套界面;修复 30 fps 设备被以 16.7 fps 消费的计时器缺陷 - What: 1) 删除 ui/replay_bar.py(进度条、录制目录选择、重算深度按钮、提示行)与回放条的全部接线:历史回放现在与实时监测同布局(分割比例同为 0,360,1080,0)、同一条状态轨、同一套曲线手势,唯一差别是来源槽——实时是摄像机,回放是「选择录制视频…」(toolbar.btn_replay_video,与文件菜单「打开视频…」同一条 _load_replay_video 路径:视频所在目录即录制目录,曲线自动配对同目录 forces.csv)。时间轴改为曲线游标本身:表头新增播放/暂停图标按钮(icons.play/pause,空格键同效)以 1× 推进(按墙钟计时,漏拍不变慢),到末尾自动停;拖动游标、框选、滚轮平移或拖总览条一律自动暂停(ForcePlotsPanel.pause_playback)。回放的降采样/跨版本提示移入来源摘要 tooltip,打开录制时另发一条 6 秒通知。修复视频文件名残留在实时视图的问题:_refresh_status_rail 只在 _view_preset == "replay" 时写视频名。2) 录制格式不变:raw_*.mp4 与 forces.csv/frames.csv/session.json 继续一起写。按「两者都要」新增 视图 → 由视频重算力值(act_recompute):默认曲线读录制的 CSV,打开后把录制视频重新跑一遍视觉管线(进 SessionStore、2D 面板交给管线绘制、只能顺序前进,播放按钮即开始/暂停,关闭即挂回 CSV),启动路径唯一(set_playing → _on_chart_play_toggled → _start_run),并让文件源的时间轴改为视频自身时间(mono_t = 帧号 × 1/fps),否则重算结果会落在与 CSV 不同的横轴上。回放 2D 面板不再提供深度视图(REPLAY_VIEW_MODES=("recorded",)),离线深度重算引擎保留但无界面入口。3) 采集只有 16.67 fps 的根因是上一轮引入的等待:Windows 默认计时器分辨率 15.6 ms,time.sleep(0.002) 实测睡 15.5 ms,重复帧等两拍即跨过 30 fps 的 33 ms 周期,30 fps 设备被以 ~16.7 fps 消费并隔帧丢弃(配置本身正确:mini/sensor.json 写 camera.fps=30;离线实测整套管线 15.2 ms/帧≈66 fps,余量充足)。新增 _high_resolution_timer() 在会话期间把分辨率提到 1 ms(结束还原),并把重复帧等待改为 min(距下一帧时间, 半个周期)、到期只 sleep(0)。 - Why: 用户评审 3 点——回放界面与实时不一致且拖拽条效果一般、不想为回放录制深度信息、想同时保留视频与 CSV 记录、以及采集帧率只有 16.67 fps(应为 30)。 - Verification: ui_check.py 562 项 0 失败(新增两视图同布局/无 bar、来源槽随视图切换且无残留、播放暂停与手动手势暂停、重算模式切换与视频源、回放提示在 tooltip);test_ui_smoke.py 73 项 0 失败(新增视图统一、传输控制、重算模式);drive_shared.py 150 项 0 失败(新增 shared/tests/test_worker_frame_rate.py::test_a_file_source_runs_on_the_video_s_own_timeline,并把 30 fps 用例改成必须落在 27–31.5 fps;同文件实测无 1 ms 计时器 21.6 fps、有则 30.0 fps,同一帧返回 3 次时 11317:39);locale_studio.py 266 键齐全、zh==en;reproduce.py 退出 0;ui_snapshot.py 三张复核图(统一后的回放、实时、选区中英)。 - Not in this change: 录制文件格式与 CSV 列序不变;离线深度重算引擎(ReplayController.request_depth / DepthRecomputer)保留并有独立测试,但界面已无入口,可下一步删除;重算模式用有损 mp4,数值与原 CSV 会有差异(这正是它要显式呈现的)。

This commit is contained in:
ykcheungab
2026-09-13 22:04:01 +08:00
parent e6a1e77a5b
commit 9b3c5fe46c
17 changed files with 896 additions and 387 deletions
+150 -46
View File
@@ -517,7 +517,7 @@ def main() -> int:
from orisys_app_shared.force.calibration_io import CalSample
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.theme import build_qss, load_colors
from orisys_studio.app_window import StudioMainWindow
from orisys_studio.app_window import VIEW_SPLITTER_SIZES, StudioMainWindow
app = QApplication.instance() or QApplication(sys.argv)
set_locale("zh_CN")
@@ -1491,17 +1491,44 @@ 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.replay_bar.isVisible() and not window.replay.is_loaded(),
"the replay view is not showing an empty progress bar",
)
# No recording is open yet: the charts keep following the live session
# instead of pretending to replay something, and the layout is the live one.
check(not window.replay.is_loaded(), "a recording is open before one was chosen")
check(
window.force_plots.mode() == "follow",
"the replay view browses before a recording is open",
)
# The replay view is the live arrangement, measured rather than assumed: the
# splitter hands out its handles' width, so compare like with like.
window.brand_bar.btn_live.click()
for _ in range(3):
app.processEvents()
live_sizes = tuple(window.content_splitter.sizes())
window.brand_bar.btn_playback.click()
for _ in range(3):
app.processEvents()
replay_sizes = tuple(window.content_splitter.sizes())
check(
replay_sizes == live_sizes,
f"the replay view is not the live arrangement: {replay_sizes} vs {live_sizes}",
)
check(
not hasattr(window, "replay_bar"),
"the replay view still carries a separate bar",
)
check(
window.toolbar.btn_replay_video.isVisible(),
"the replay view does not offer a video to replay",
)
# The one difference between the two views: the source slot.
window.brand_bar.btn_live.click()
for _ in range(3):
app.processEvents()
check(
not window.toolbar.btn_replay_video.isVisible()
and window.toolbar.source_slot.isVisible(),
"the live view still shows the replay's video picker",
)
window.brand_bar.btn_live.click()
for _ in range(3):
app.processEvents()
@@ -1859,13 +1886,14 @@ def main() -> int:
"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",
pane_window.force_plots.unit_badge.text() == t("units.raw_short"),
"the chart's unit chip 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())
duration = pane_window.replay.duration_s()
pane_window.replay.set_position(duration)
app.processEvents()
cursor_card = pane_window.force_plots.value_labels[0].text()
check(
@@ -2706,8 +2734,8 @@ def main() -> int:
"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.
# A video file is a *replay* source: the live view offers the way back to the
# camera, and the video is named only in the replay view.
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._set_source_mode("video")
for _ in range(3):
@@ -2716,10 +2744,25 @@ def main() -> int:
window.toolbar.video_group.isVisible() and not window.toolbar.camera_group.isVisible(),
"video mode still offers the camera group",
)
check(
"demo.mp4" not in window.toolbar.source_summary.text(),
f"the live view names a replay's video: "
f"{window.toolbar.source_summary.text()!r}",
)
window.brand_bar.btn_playback.click()
for _ in range(3):
app.processEvents()
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.brand_bar.btn_live.click()
for _ in range(3):
app.processEvents()
check(
"demo.mp4" not in window.toolbar.source_summary.text(),
"the video name stayed on the rail after leaving the replay",
)
window.toolbar.use_camera_clicked.emit()
for _ in range(3):
app.processEvents()
@@ -2777,8 +2820,8 @@ def main() -> int:
replay_window.brand_bar.btn_playback.click()
app.processEvents()
check(
replay_window.replay_bar.isVisible(),
"the replay view has no progress bar",
replay_window.toolbar.btn_replay_video.isVisible(),
"the replay view does not offer a recorded video",
)
check(
not replay_window.toolbar.camera_group.isVisible(),
@@ -2789,6 +2832,13 @@ def main() -> int:
"the replay view could not open the recording",
)
app.processEvents()
replay_window.toolbar.set_video_path(str(replay_root / "raw_0001.mp4"))
replay_window._refresh_status_rail()
check(
"raw_0001.mp4" in replay_window.toolbar.source_summary.text(),
f"the replay does not name its video: "
f"{replay_window.toolbar.source_summary.text()!r}",
)
replay = replay_window.replay
replay_plots = replay_window.force_plots
@@ -2813,20 +2863,39 @@ def main() -> int:
"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)
# The chart cursor is the playhead, and the transport moves it at 1x.
check(replay_plots.btn_play.isEnabled(), "the replay has no transport")
check(not replay_plots.btn_play.isChecked(), "the replay starts while playing")
replay_plots.btn_play.setChecked(True)
started = time.perf_counter()
for _ in range(12):
app.processEvents()
time.sleep(0.03)
elapsed = time.perf_counter() - started
replay_plots.btn_play.setChecked(False)
app.processEvents()
check(
0.0 < replay.position() < replay.duration_s(),
f"dragging the progress bar did not move the playhead: {replay.position()}",
replay.position() > 0.05,
f"the transport did not advance the playhead: {replay.position()}",
)
check(
replay.position() <= elapsed + 0.2,
f"the playhead ran ahead of the clock: {replay.position()} > {elapsed}",
)
check(
abs(float(replay_plots.cursors[0].value()) - replay.position()) < 0.05,
"the chart cursor did not follow the progress bar",
"the chart cursor did not follow the transport",
)
check(not replay_plots.btn_play.isChecked(), "the transport stayed checked after pause")
# A manual gesture on the timeline takes over: playback pauses.
replay_plots.btn_play.setChecked(True)
app.processEvents()
replay_plots.cursors[0].setValue(0.25)
app.processEvents()
check(
not replay_plots.btn_play.isChecked(),
"dragging the cursor did not pause the playback",
)
check(
abs(replay.position() - 0.25) < 0.05,
f"dragging the chart cursor did not scrub the replay: {replay.position()}",
@@ -2836,7 +2905,7 @@ def main() -> int:
"no video frame was rendered at the cursor position",
)
check(
replay_window.arrow_panel.view_modes() == ("recorded", "depth"),
replay_window.arrow_panel.view_modes() == ("recorded",),
"the 2D panel still offers the live-only views while replaying",
)
@@ -2871,9 +2940,9 @@ def main() -> int:
)
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}",
in replay_window.toolbar.source_summary.toolTip(),
f"the replay notes are not reachable from the rail: "
f"{replay_window.toolbar.source_summary.toolTip()!r}",
)
moved: list[float] = []
@@ -2886,22 +2955,17 @@ def main() -> int:
)
check(moved == [1.0], f"the cursor signal did not fire once: {moved}")
check(
"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",
abs(replay_plots.cursor_time() - 1.0) < 0.05,
f"the chart cursor did not follow a programmatic position: "
f"{replay_plots.cursor_time()}",
)
# The unit switch rebuilds the recording's curves, not the live ones.
replay_window.replay_bar.unit_toggled.emit()
# The chart's unit chip rebuilds the recording's curves, not the live ones.
replay_plots.unit_badge.click()
app.processEvents()
check(
replay.unit() == "N" and replay_plots._unit == "N",
f"the replay unit switch did not reach the charts: {replay.unit()}",
f"the unit chip did not reach the replay: {replay.unit()}",
)
# The depth recomputation must never run on the UI thread. A stub that blocks
@@ -2960,16 +3024,14 @@ def main() -> int:
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()
# The replay view no longer offers a depth pane: depth is not stored with a
# recording, and re-running the pipeline over the video is the recompute
# mode's job (see the `recompute` section).
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.view_modes() == ("recorded",),
f"the replay still offers a depth pane: "
f"{replay_window.arrow_panel.view_modes()}",
)
replay_window.arrow_panel.set_view_mode("recorded")
app.processEvents()
# The plan must state where to start, which reset it is anchored to and
# whether the state is really recoverable; and a request must belong to the
@@ -3057,6 +3119,42 @@ def main() -> int:
second_controller.close()
app.processEvents()
# The recompute mode: off (default) the charts read the recorded CSV; on, the
# recorded video is run through the pipeline instead. The run itself is not
# started here (that needs the SDK), only the wiring around it.
started: list = []
replay_window._start_run = lambda: started.append(True)
replay_window.act_recompute.setChecked(True)
for _ in range(3):
app.processEvents()
check(
started == [True],
"the recompute mode did not hand the video to the pipeline",
)
check(
not replay_plots.is_recording_source(),
"the charts still read the CSV while recomputing",
)
check(
replay_window.toolbar.source_mode() == "video"
and replay_window.toolbar.video_path().endswith(".mp4"),
f"the recompute mode did not select the video: "
f"{replay_window.toolbar.source_mode()!r} / "
f"{replay_window.toolbar.video_path()!r}",
)
replay_window._recompute_running = True
replay_window.act_recompute.setChecked(False)
for _ in range(3):
app.processEvents()
check(
replay_plots.is_recording_source(),
"turning the recompute off did not restore the recorded curves",
)
check(
replay_window.toolbar.source_mode() == "camera" or not replay_window._running,
"the recompute pass was left running",
)
# 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(
@@ -3071,8 +3169,14 @@ def main() -> int:
"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",
not replay_plots.btn_play.isEnabled(),
"the live view still offers the replay's transport",
)
check(
not replay_window.toolbar.source_summary.text().endswith(".mp4")
and "raw_0001" not in replay_window.toolbar.source_summary.text(),
f"the replay's video is still named in the live view: "
f"{replay_window.toolbar.source_summary.text()!r}",
)
check(
replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"),