feat(studio-2d): 回放可一键重算;实时界面不再有任何回放入口

- What:
  1. **回放里一键重算**:`ui/toolbar.py` 的 replay 组新增 `btn_recompute`(可选中的"由视频重算…",
     信号 `recompute_clicked`、`set_recompute_enabled/checked`、tooltip 说明"2D 面板会显示处理后的图像,
     录像本身只存了原始相机图像");`app_window` 把它接到 `act_recompute`(同一个开关,所以菜单、rail 与
     `_on_recompute_toggled` 不会各走一套),并在 `_refresh_replay_state`/`_sync_replay_actions` 里同步
     可用与选中状态。
  2. **实时界面禁止回放入口**:新增 `_sync_replay_actions()`——`act_open_video`/`act_playback`/
     `act_playback_latest` 只在 `_view_preset == "replay"` 时可用,否则禁用并把原因写进 tooltip
     (`toolbar.replay_view_only` = "请在「历史回放」视图里打开录像");`_load_replay_video()` 与
     `_open_latest_recording()` 在非 replay 预设下**直接拒绝并提示**,不再改动 `source_mode`/`video_path`
     (原来打开一段没有 forces.csv 的视频会走到 `_load_replay_video` 的裸视频分支,把采集源切成文件而 2D
     面板什么都没变)。rail 的回放按钮本来就在 replay 分组(`toolbar.py:476/598`),不改动;空格键原本就按
     `run_role == "playback"` 收口,加了断言。
  3. locale:新增 `toolbar.recompute`/`toolbar.recompute_tip`/`toolbar.replay_view_only`(中英各一套)。
- Why: 用户报的第二个 bug——「实时检测界面还是可以回放视频,建议回放会自动切到历史回放上面」,并在两个
  选项里选了"实时界面禁止回放入口(反向做法)"。核对确认:`_on_browse_video`/`_load_replay_video` 在实时
  预设下可用且只设 `source_mode="video"` + `video_path`,不切视图预设;随后 `_render_replay_frame()` 因
  `_view_preset != "replay"` 直接返回、`_can_recompute()` 同因返回 False,于是界面停在"实时"却已经把采集源
  换成了视频——按运行键就"在实时界面跑视频"。配套地,回放里"看不到处理后的图像"这一个用户期待,由
  「由视频重算」给出:它现在就在回放视图的 rail 上,点一下即可,2D 面板与实时共用同一个后端(见上一提交)。
  注意区分:实时视图用视频**当采集源**(`source_mode == "video"` 显示"回到摄像机")是另一项既有能力,保留
  不变,只是不再由回放路径隐式设置。
- Verification:
  · `test_ui_smoke.py` +1(`test_the_replay_entries_exist_only_in_the_replay_view`):实时预设下三个文件
  菜单动作禁用且 tooltip 带原因、rail 无回放/重算按钮、`_load_replay_video()` 返回 False 且
  `replay.has_video()`/`source_mode()`/播放定时器都不变、空格不启动回放;切到历史回放后三者可用、裸视频
  仍能打开(`plan_modes() == ("recorded",)`)、rail 的重算按钮可点并同步 `act_recompute`。
  · 既有用例继续通过:`test_a_bare_video_plays_even_though_it_is_not_a_recording`(先切到历史回放再打开,
  播放与逐帧解码不受影响)、`test_depth_view_...`、三维与记录相关用例。
  · 全量:apps/studio 237 项 0 失败 14 跳过;ui_check 587 项 0 失败;locale_check 0 缺失(zh/en 键集合
  仍一致)。
- Not in this change: 没有改成"打开视频时自动切视图"(按用户选择,改为禁止 + 说明);`source_mode == "video"`
  在实时视图的既有行为未动("由视频当相机"仍可用,只是入口不再是回放路径);阶段 8(界面与有效控制)与
  阶段 9(限速/统计/保护包)未开始。
This commit is contained in:
ykcheungab
2026-09-14 21:28:07 +08:00
parent 8e6f9658b4
commit 7e415c4ae9
5 changed files with 123 additions and 0 deletions
+3
View File
@@ -86,6 +86,9 @@
"use_camera_tip": "Go back to acquiring from a camera (the data source is a video file)",
"choose_video": "Choose a recorded video…",
"choose_video_tip": "Open a recorded video: the 2D panel plays it and the charts read the forces.csv beside it",
"recompute": "Recompute from video…",
"recompute_tip": "Run the pipeline over this recording again: the 2D panel then shows the processed image (the recording itself stores only the raw camera image) and the charts and 3D view use the recomputed values. You can press Stop at any time.",
"replay_view_only": "Open a recording from the History replay view",
"preview": "Preview",
"preview_stop": "Stop preview",
"run": "Run",
+3
View File
@@ -86,6 +86,9 @@
"use_camera_tip": "回到摄像机采集(当前的数据源是视频文件)",
"choose_video": "选择录制视频…",
"choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)",
"recompute": "由视频重算…",
"recompute_tip": "对这段录像重新跑一遍算法:2D 面板显示处理后的图像(录像本身只存了原始相机图像),曲线与三维也改用重算结果。可以随时「停止」。",
"replay_view_only": "请在「历史回放」视图里打开录像",
"preview": "预览",
"preview_stop": "停止预览",
"run": "运行",
@@ -790,6 +790,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.sensor_clicked.connect(self._open_sensor_dialog)
self.toolbar.use_camera_clicked.connect(self._on_use_camera)
self.toolbar.replay_video_clicked.connect(self._on_open_recording)
self.toolbar.recompute_clicked.connect(self._on_recompute_button)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
@@ -842,6 +843,29 @@ class StudioMainWindow(QMainWindow):
return
self._load_replay_video(Path(chosen))
def _on_recompute_button(self) -> None:
"""The replay view's own entry for the processed picture (see ``recompute``)."""
self.act_recompute.setChecked(not self.act_recompute.isChecked())
def _sync_replay_actions(self) -> None:
"""Replay entries exist in the replay view, and nowhere else.
Opening a recording from the *live* view used to leave the window in a state
nothing described: the source became a video file, the 2D panel did not change
(the replay renderer only draws in the replay view) and the Run button acquired
from that file. The entries are therefore disabled outside the replay view, with
the reason in the tooltip, instead of quietly doing half of it.
"""
allowed = self._view_preset == "replay"
reason = "" if allowed else t("toolbar.replay_view_only")
for action in (self.act_open_video, self.act_playback, self.act_playback_latest):
action.setEnabled(allowed)
action.setToolTip(reason)
# The rail's entries are in the replay group already; the recompute button also
# has to follow whether there is anything to recompute *from*.
self.toolbar.set_recompute_enabled(allowed and self._can_recompute())
self.toolbar.set_recompute_checked(self.act_recompute.isChecked())
def _load_replay_video(self, video: Path) -> bool:
"""Open what the chosen video *is*: a recording, or just a video.
@@ -855,6 +879,12 @@ class StudioMainWindow(QMainWindow):
that is the only way to get *numbers* out of a video that has no recording.
"""
video = Path(video)
if self._view_preset != "replay":
# Defensive: the entries are disabled outside the replay view, so this only
# happens programmatically — and refusing is better than leaving the window
# with a video source and no replay renderer.
self._notify(t("toolbar.replay_view_only"))
return False
folder = video.parent if video.is_file() else video
if not (folder / "forces.csv").is_file():
if video.is_file() and self.replay.open_video(video):
@@ -905,6 +935,9 @@ class StudioMainWindow(QMainWindow):
"""Switch to the replay view on the most recent recording."""
from orisys_app_shared.record import list_recordings
if self._view_preset != "replay":
self._notify(t("toolbar.replay_view_only"))
return
found = list_recordings(recordings_dir())
if not found:
self._notify(t("playback.none_found"))
@@ -940,6 +973,7 @@ class StudioMainWindow(QMainWindow):
# chosen video file is enough to recompute *from*, even when it is not a
# recording: that is the way to look at a video that has no forces.csv.
self.act_recompute.setEnabled(self._can_recompute())
self._sync_replay_actions()
self._update_run_enabled()
self._refresh_status_rail()
@@ -1477,6 +1511,7 @@ class StudioMainWindow(QMainWindow):
self._on_recompute_toggled(False)
self.brand_bar.set_active_view(preset if preset != "custom" else None)
self._sync_menu_checks()
self._sync_replay_actions()
self._update_run_enabled()
self._refresh_status_rail()
if persist:
@@ -79,6 +79,8 @@ class ToolbarWidget(QWidget):
use_camera_clicked = pyqtSignal()
#: Replay view: pick the recorded video to play.
replay_video_clicked = pyqtSignal()
#: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
@@ -210,6 +212,17 @@ class ToolbarWidget(QWidget):
self.btn_replay_video.setMinimumHeight(BUTTON_HEIGHT)
self.btn_replay_video.clicked.connect(self.replay_video_clicked.emit)
replay_layout.addWidget(self.btn_replay_video)
# The processed picture of a recording is what the recompute pass produces, and
# that is the only way to see it: the recording stores the raw camera image.
# Putting the entry here (rather than only in the View menu) is what makes the
# replay view able to answer "why is this picture unprocessed" in one click.
self.btn_recompute = QPushButton()
self.btn_recompute.setProperty("compact", "true")
self.btn_recompute.setMinimumHeight(BUTTON_HEIGHT)
self.btn_recompute.setCheckable(True)
self.btn_recompute.setEnabled(False)
self.btn_recompute.clicked.connect(self.recompute_clicked.emit)
replay_layout.addWidget(self.btn_recompute)
slot.addWidget(self.camera_group)
slot.addWidget(self.video_group)
slot.addWidget(self.replay_group)
@@ -475,6 +488,15 @@ class ToolbarWidget(QWidget):
# same rule as the camera picker: not while a session is running.
self.btn_replay_video.setEnabled(bool(enabled) and not live)
def set_recompute_enabled(self, enabled: bool) -> None:
"""The recompute entry lives in the replay view; its checked state follows."""
self.btn_recompute.setEnabled(bool(enabled))
def set_recompute_checked(self, checked: bool) -> None:
self.btn_recompute.blockSignals(True)
self.btn_recompute.setChecked(bool(checked))
self.btn_recompute.blockSignals(False)
def set_source_controls_enabled(self, enabled: bool) -> None:
self._source_controls_enabled = bool(enabled)
self.set_camera_controls_enabled(self._source_controls_enabled)
@@ -607,6 +629,7 @@ class ToolbarWidget(QWidget):
self.source_summary.setToolTip(path or self.source_summary.text())
self.btn_use_camera.setToolTip(t("toolbar.use_camera_tip"))
self.btn_replay_video.setToolTip(t("toolbar.choose_video_tip"))
self.btn_recompute.setToolTip(t("toolbar.recompute_tip"))
def _on_preview_toggled(self, checked: bool) -> None:
self._refresh_preview_label()
@@ -719,6 +742,7 @@ class ToolbarWidget(QWidget):
self.btn_search.setText(t("toolbar.search_cameras"))
self.btn_use_camera.setText(t("toolbar.use_camera"))
self.btn_replay_video.setText(t("toolbar.choose_video"))
self.btn_recompute.setText(t("toolbar.recompute"))
self.refresh_labels()
self._refresh_reset_label()
self.units_label.setText(t("toolbar.units"))
+58
View File
@@ -2247,6 +2247,64 @@ def test_the_application_converts_bgr_to_qimage_in_one_place():
assert others == [], f"another BGR→QImage conversion exists: {others}"
def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_root):
"""Stage 7: the live view has no replay entry, and says where they live.
Opening a video from the live view used to set the acquisition source to a file
while the 2D panel kept showing the camera and Run acquired from the video — a state
nothing on screen described.
"""
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import t
bare = Path(record_root) / "gated_video.mp4"
writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48))
for index in range(10):
writer.write(np.full((48, 64, 3), index * 8, dtype=np.uint8))
writer.release()
window = _show(make_window(), (1440, 880))
try:
window._apply_view_preset("live")
QApplication.processEvents()
for action in (window.act_open_video, window.act_playback, window.act_playback_latest):
assert not action.isEnabled(), "a replay entry is offered in the live view"
assert t("toolbar.replay_view_only") in action.toolTip()
assert not window.toolbar.btn_replay_video.isVisible()
assert not window.toolbar.btn_recompute.isEnabled()
# Programmatic use is refused too, and leaves nothing half-changed.
assert window._load_replay_video(bare) is False
QApplication.processEvents()
assert not window.replay.has_video()
assert window.toolbar.source_mode() == "camera"
assert not window._play_timer.isActive()
window._on_play_shortcut()
assert not window._play_timer.isActive(), "Space started a replay in the live view"
# In the replay view they are offered again, and the recompute entry follows
# whether there is anything to recompute from.
window._apply_view_preset("replay")
QApplication.processEvents()
for action in (window.act_open_video, window.act_playback, window.act_playback_latest):
assert action.isEnabled()
assert window._load_replay_video(bare) is True
QApplication.processEvents()
assert window.arrow_panel.plan_modes() == ("recorded",)
assert window.toolbar.btn_recompute.isEnabled(), (
"a video with no recording beside it can still be recomputed"
)
window.toolbar.btn_recompute.click()
QApplication.processEvents()
assert window.act_recompute.isChecked(), "the rail's recompute entry did not toggle"
window.act_recompute.setChecked(False)
finally:
window.close()
def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
make_window, record_root
):