diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index b261662..92e2577 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -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", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 9e47ff0..129a4ef 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -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": "运行", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 6caa2bf..d4d722d 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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: diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index d3ff301..b44e4bd 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -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")) diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index df3524c..413cf80 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -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 ):