回放与实时统一为一套界面;修复 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
+153 -30
View File
@@ -1518,26 +1518,44 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
def test_the_source_picker_follows_the_view(make_window):
"""Live offers a camera, a video-file run offers the way back, replay neither."""
"""Live offers a camera, the replay a recorded video — and no residue."""
from PyQt5.QtWidgets import QApplication
window, stuck = _running_window(make_window)
assert window.toolbar.camera_group.isVisible()
assert not window.toolbar.video_group.isVisible()
assert not window.toolbar.btn_replay_video.isVisible()
assert not hasattr(window.toolbar, "source_combo"), "the source is still a combobox"
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert not window.toolbar.camera_group.isVisible(), "the replay view shows a camera picker"
assert window.toolbar.btn_replay_video.isVisible()
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.toolbar.camera_group.isVisible()
# A recorded video belongs to the replay view: the live view is a camera, and
# it must not keep naming a file after the view has changed.
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._set_source_mode("video")
QApplication.processEvents()
assert window.toolbar.video_group.isVisible()
assert "demo.mp4" in window.toolbar.source_summary.text()
assert "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()
QApplication.processEvents()
assert "demo.mp4" in window.toolbar.source_summary.text(), (
f"the replay view does not name its video: "
f"{window.toolbar.source_summary.text()!r}"
)
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert "demo.mp4" not in window.toolbar.source_summary.text(), (
"the video name stayed on the rail after leaving the replay"
)
assert not window.toolbar.btn_preview.isEnabled(), "preview is offered for a video file"
window.toolbar.use_camera_clicked.emit()
@@ -1565,10 +1583,12 @@ def test_the_nav_items_switch_the_main_window_layout(make_window):
window.brand_bar.btn_playback.click()
assert window.view_preset() == "replay"
assert window.arrow_panel.isVisible() and not window.cal_panel.isVisible()
# No recording is open yet: the bar says so, and the charts keep following
# the live session instead of pretending to replay something.
assert window.replay_bar.isVisible() and not window.replay.is_loaded()
# 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.
assert not window.replay.is_loaded()
assert window.force_plots.mode() == "follow", "browsing starts with no recording"
assert window.toolbar.btn_replay_video.isVisible(), "no video picker in the replay"
live_sizes = tuple(window.content_splitter.sizes())
window.brand_bar.btn_live.click()
assert window.view_preset() == "live"
@@ -1612,24 +1632,45 @@ def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
)
def test_the_replay_bar_labels_its_controls(make_window):
"""The old replay window's controls had no text: it never retranslated."""
def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
"""One layout for both views; the only difference is the source slot.
The replay used to carry its own bar (a progress slider plus a recording
picker). It is gone: the chart's cursor is the playhead, its header carries
the transport, and the rail shows the video instead of the camera.
"""
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
bar = window.replay_bar
assert bar.btn_open.text() == t("playback.open")
assert bar.btn_open.toolTip() == t("playback.open_tip")
assert bar.btn_depth.text() == t("playback.recompute_depth")
assert bar.btn_depth.toolTip() == t("playback.recompute_depth_tip")
assert bar.btn_unit.text(), "the unit switch is unlabelled"
assert bar.slider.toolTip() == t("playback.scrub_tip")
assert bar.info_label.text() == t("playback.empty")
assert not bar.slider.isEnabled(), "the progress bar is live with no recording"
window.brand_bar.btn_live.click()
from PyQt5.QtWidgets import QApplication
QApplication.processEvents()
live_sizes = tuple(window.content_splitter.sizes())
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert not hasattr(window, "replay_bar"), "the replay still has a separate bar"
assert tuple(window.content_splitter.sizes()) == live_sizes, (
"the replay view is not laid out like the live view"
)
assert window.toolbar.btn_replay_video.isVisible(), "no video picker while replaying"
assert window.toolbar.btn_replay_video.text() == t("toolbar.choose_video")
assert window.toolbar.btn_replay_video.toolTip() == t("toolbar.choose_video_tip")
assert not window.toolbar.camera_group.isVisible(), "the replay offers the camera"
assert window.brand_bar.btn_live.property("navActive") != "true"
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert window.toolbar.camera_group.isVisible(), "the live view lost its camera picker"
assert not window.toolbar.btn_replay_video.isVisible(), (
"the live view still shows the replay's video picker"
)
assert not window.toolbar.btn_replay_video.isVisible()
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
"""The replay view reads the recording: charts, 2D panel and progress bar."""
"""The replay view reads the recording: charts, 2D panel and transport."""
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1637,7 +1678,6 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window.replay_bar.isVisible(), "the replay view has no progress bar"
assert window._load_recording(replay_recording)
QApplication.processEvents()
@@ -1649,28 +1689,35 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec
drawn = [int(curve.getData()[0].size) for curve in panel.curves]
assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}"
assert window.arrow_panel.view_modes() == ("recorded", "depth")
assert window.arrow_panel.view_modes() == ("recorded",)
assert window.replay.video_image() is not None, "no frame at the cursor"
assert window.replay_bar.slider.isEnabled()
assert panel.btn_play.isEnabled(), "the replay has no transport"
assert not panel.btn_play.isChecked(), "the replay starts out playing"
# The progress bar is the playhead.
window.replay_bar.slider.setValue(window.replay_bar.slider.maximum() // 2)
# The cursor is the playhead: setting it moves the video.
window.replay.set_position(window.replay.duration_s() / 2)
QApplication.processEvents()
assert 0.0 < window.replay.position() < window.replay.duration_s()
assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05
# Dragging the chart cursor scrubs the recording too.
# Dragging the chart cursor scrubs the recording too, and pauses it.
panel.btn_play.setChecked(True)
QApplication.processEvents()
panel.cursors[0].setValue(0.25)
QApplication.processEvents()
assert abs(window.replay.position() - 0.25) < 0.05
assert not panel.btn_play.isChecked(), "dragging the cursor did not pause playback"
# Leaving the view returns the charts to the live session.
window.brand_bar.btn_live.click()
QApplication.processEvents()
assert not panel.is_recording_source()
assert panel.mode() == "follow"
assert not window.replay_bar.isVisible()
assert not panel.btn_play.isEnabled(), "the live view still offers the transport"
assert window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth")
assert "raw_0001" not in window.toolbar.source_summary.text(), (
"the replay's video is still named in the live view"
)
# ------------------------------------------------- correctness regressions
@@ -1870,7 +1917,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
label = window.arrow_panel.image_label
# Park the playhead at the end of the recording and remember what the pane and
# the cards show there, so a live packet that overwrote either would show up.
window.replay_bar.slider.setValue(window.replay_bar.slider.maximum())
window.replay.set_position(window.replay.duration_s())
QApplication.processEvents()
before_key = label.pixmap().cacheKey()
before_text = label.text()
@@ -1922,12 +1969,11 @@ def test_the_readout_describes_the_point_the_cursor_is_on(
plots = window.force_plots
assert plots.mode() == "browse", "opening a recording did not browse it"
slider = window.replay_bar.slider
slider.setValue(slider.minimum())
window.replay.set_position(0.0)
QApplication.processEvents()
first = plots.value_labels[0].text()
assert first not in ("", t("chart.value_unavailable")), first
slider.setValue(slider.maximum())
window.replay.set_position(window.replay.duration_s())
QApplication.processEvents()
last = plots.value_labels[0].text()
@@ -1960,14 +2006,91 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor
panel = window.force_plots
assert panel.unit() == "raw", "a raw-only recording was shown in newton"
assert window.toolbar.units() == "raw", "the toolbar disagrees with the charts"
assert window.replay_bar.btn_unit.text() == t("playback.show_newton"), (
"the replay bar's unit switch disagrees with the charts"
assert panel.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 panel.curves]
assert all(count > 0 for count in drawn), f"the recording drew nothing: {drawn}"
assert panel.value_labels[0].text() not in ("", t("chart.value_unavailable"))
def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
"""Play/pause like an editor: 1× forward, and a manual gesture takes over."""
import time
from PyQt5.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
panel = window.force_plots
assert panel.btn_play.isEnabled() and not panel.btn_play.isChecked()
panel.btn_play.setChecked(True)
started = time.perf_counter()
for _ in range(10):
QApplication.processEvents()
time.sleep(0.03)
elapsed = time.perf_counter() - started
assert window.replay.position() > 0.05, "play did not advance the playhead"
assert window.replay.position() <= elapsed + 0.2, (
f"the playhead ran ahead of the clock: {window.replay.position()} > {elapsed}"
)
assert abs(panel.cursors[0].value() - window.replay.position()) < 0.05, (
"the chart cursor did not follow the transport"
)
# Dragging the cursor is a manual gesture: playback pauses where it is.
panel.cursors[0].setValue(0.2)
QApplication.processEvents()
assert not panel.btn_play.isChecked(), "dragging the cursor did not pause"
assert abs(window.replay.position() - 0.2) < 0.05
parked = window.replay.position()
for _ in range(6):
QApplication.processEvents()
time.sleep(0.03)
assert window.replay.position() == parked, "the playhead kept moving while paused"
def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
make_window, replay_recording
):
"""Off: the recorded CSV. On: the recorded video through the vision chain."""
from PyQt5.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._load_recording(replay_recording)
QApplication.processEvents()
panel = window.force_plots
assert panel.is_recording_source(), "the charts do not read the recording"
assert window.act_recompute.isEnabled(), "the recompute switch is not offered"
started: list = []
def fake_run() -> None:
started.append(True)
window._running = True
window._start_run = fake_run
window.act_recompute.setChecked(True)
QApplication.processEvents()
assert started == [True], f"the pass started {len(started)} times"
assert not panel.is_recording_source(), "the charts still read the CSV"
assert window.toolbar.source_mode() == "video"
assert window.toolbar.video_path().endswith(".mp4"), window.toolbar.video_path()
assert panel.btn_play.isChecked(), "the transport does not show the pass as running"
window._recompute_running = True
window.act_recompute.setChecked(False)
QApplication.processEvents()
assert panel.is_recording_source(), "turning the recompute off did not restore the CSV"
assert not panel.btn_play.isChecked(), "the transport stayed checked"
# ------------------------------------------- selection readout and notices