回放与实时统一为一套界面;修复 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:
+150
-46
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user