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