拆分 B:回放策略移入 ReplayController(app_window 2837→2499 行)

- What: 新增 apps/studio/src/orisys_studio/replay_controller.py:回放视图的**策略**
  (哪些入口能打开视频、transport 何时是播放何时是采集、从视频重算对面板与采集
  管线做什么)从 app_window 搬进 ReplayController,共 30 个公开方法。replay.py 仍是
  **引擎**(reader / 游标 / 异步深度重算),本模块是它外面的状态机。
  它直接驱动的对象只有五个:toolbar 的回放控件、曲线图(时间轴)、2D 面板、三维面板的
  来源标签、五个菜单项;其余依赖用 ReplayHost Protocol 写明(状态栏、轨道、视图切换、
  显示单位、2D 唯一入口 _show_image_frame、采集管线 _start_run/_stop_run)——这些是
  窗口级操作,所以留在窗口,并由 Protocol 把边界写清楚,而不是让控制器伸手进"上帝对象"。
- What: 播放时钟与定时器(33 ms)、_recompute_running、入口校验、菜单启用与提示、
  重算开关的全部规则都随之上移;窗口侧保留过渡 shim(_load_recording / _load_replay_video /
  _render_replay_frame / _start_playback / _pause_playback / _on_play_shortcut /
  _stop_replay_activity / _replay_owns_image / _can_recompute / _play_timer /
  _recompute_running)供 tests、tools/ui_check.py、tools/ui_snapshot.py 继续驱动窗口。
  窗口内部调用点(_apply_view_preset、_update_run_enabled、_sync_menu_checks、_on_run_toggled、
  _on_stop_shortcut、_on_reset、菜单与工具栏连线)改为直接调用 self.replay_ctl.*。
  删除窗口里已搬走的 _on_browse_video、_on_replay_position、_on_recording_changed、
  _wire_replay、_on_recompute_toggled 等,以及随之为空的导入(time、QFileDialog、
  SOURCE_REPLAY、PLAYBACK_TICK_MS)。顺带删掉一个从未接线的死方法
  _on_replay_unit_toggled(与 _on_chart_unit_toggle 重复,无人调用)。
- What: 新增 apps/studio/tests/test_replay_controller.py:8 个用例,用假的 host/engine/
  panel 直接测策略——播放在末尾从头开始、零长录制不播、playhead 跟墙钟并在末尾停下、
  重算开关打开时 transport 启动管线而非播放、2D 面板归属、裸视频被接受/在实时视图被拒、
  无标定录制回落到 raw、回放入口只在回放视图存在。
- Fix: 重算结束的 flag 由它自己的所有者清除(transport 暂停、开关关闭、stop_activity
  三条路径),不再依赖窗口的 _stop_run 顺手清;效果是"重算结束后停止按钮正确变为不可用",
  test_ui_smoke 里那个"手动补状态"的注释随之删除。
- Why: 回放是 app_window 的第二个独立变更驱动:录制格式变一次是引擎的事,操作员流程
  变一次不该动 2500 行窗口。搬完之后回放策略可单独阅读、单独测,窗口只剩装配与转发。
- Verification: pytest shared/tests apps/studio/tests(offscreen)611 passed / 0 failed /
  15 skipped(原 603 + 新增 8);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: C1 视图排列、C2 显示偏好、标定交互仍未拆分;docs 的行数/方法数
  指标待全部切片落定后统一刷新。真机 GL 冒烟仍无法复跑(本机创建不出 GL 上下文,
  eglError 3005),13 例跳过、probe 用例按机器实况失败,与本次改动无关。
This commit is contained in:
ykcheungab
2026-09-15 09:38:40 +08:00
parent a35d1877d6
commit fbf808d878
4 changed files with 1201 additions and 431 deletions
+89 -427
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import logging import logging
import subprocess import subprocess
import sys import sys
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -16,7 +15,6 @@ from PyQt5.QtWidgets import (
QAction, QAction,
QActionGroup, QActionGroup,
QApplication, QApplication,
QFileDialog,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@@ -34,7 +32,6 @@ from orisys_app_shared.acquisition.image_backend import (
SOURCE_LIVE, SOURCE_LIVE,
SOURCE_PREVIEW, SOURCE_PREVIEW,
SOURCE_RECOMPUTE, SOURCE_RECOMPUTE,
SOURCE_REPLAY,
ImageFrame, ImageFrame,
plan_image, plan_image,
render_image, render_image,
@@ -48,6 +45,7 @@ from orisys_app_shared.record import RecordConfig
from orisys_app_shared.record.sample import to_record_sample from orisys_app_shared.record.sample import to_record_sample
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
from orisys_studio.replay import ReplayEngine from orisys_studio.replay import ReplayEngine
from orisys_studio.replay_controller import ReplayController
from orisys_studio import session_metadata from orisys_studio import session_metadata
from orisys_studio.session_controller import SessionController from orisys_studio.session_controller import SessionController
from orisys_studio.scene3d.prefs import settings_to_prefs from orisys_studio.scene3d.prefs import settings_to_prefs
@@ -114,10 +112,6 @@ VIEW_SHOWS_3D = ("custom", "live", "replay")
#: nothing. The table describes the two-dimensional layout; the pane is optional, and #: nothing. The table describes the two-dimensional layout; the pane is optional, and
#: "not in the table" must not mean "standing at its minimum width". #: "not in the table" must not mean "standing at its minimum width".
DEFAULT_3D_PANE_WIDTH = 340 DEFAULT_3D_PANE_WIDTH = 340
#: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the
#: cursor is advanced from the wall clock, so a missed tick does not slow the
#: recording down.
PLAYBACK_TICK_MS = 33
#: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from #: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from
@@ -164,8 +158,8 @@ class StudioMainWindow(QMainWindow):
self._view_preset = preset if preset in VIEW_PRESETS else "custom" self._view_preset = preset if preset in VIEW_PRESETS else "custom"
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True)) self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
self._show_fps = bool(self.prefs.get("show_fps", False)) self._show_fps = bool(self.prefs.get("show_fps", False))
#: True while the run over the video of the recompute mode is going. # Whether a recompute pass is running is the replay controller's state; the
self._recompute_running = False # window reads and writes it through the ``_recompute_running`` shim below.
self._preview = None self._preview = None
self._preview_timer = QTimer(self) self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33) self._preview_timer.setInterval(33)
@@ -174,9 +168,9 @@ class StudioMainWindow(QMainWindow):
self._last_rendered_view_mode: str | None = None self._last_rendered_view_mode: str | None = None
# ---- replay --------------------------------------------------------- # ---- replay ---------------------------------------------------------
# One engine for the history view: the recording reader, the cursor and # One engine for the history view: the recording reader, the cursor and
# the asynchronous depth recomputation. It renders nothing itself; the # the asynchronous depth recomputation. It renders nothing itself; the policy
# transport policy around it will move into a ReplayController (see # around it (transport, entry points, the recompute pass) lives in
# docs/architecture/studio-architecture.md, suggestion 1). # ``ReplayController``, which is built once the panels it drives exist.
self.replay = ReplayEngine(self) self.replay = ReplayEngine(self)
# ---- record channel ------------------------------------------------- # ---- record channel -------------------------------------------------
# Fed from the acquisition thread via the worker's sink list, so it does # Fed from the acquisition thread via the worker's sink list, so it does
@@ -267,11 +261,18 @@ class StudioMainWindow(QMainWindow):
# under the panels: the two views share one layout, and the transport is # under the panels: the two views share one layout, and the transport is
# the rail's primary button — 「运行」while measuring, 「回放」while # the rail's primary button — 「运行」while measuring, 「回放」while
# replaying (see ``_on_run_toggled`` / ``toolbar.set_run_role``). # replaying (see ``_on_run_toggled`` / ``toolbar.set_run_role``).
self._play_timer = QTimer(self) # The replay policy needs the panels (built above) and the menus (built below):
self._play_timer.setInterval(PLAYBACK_TICK_MS) # it is created here, between the two.
self._play_timer.timeout.connect(self._on_play_tick) self.replay_ctl = ReplayController(
self._play_started_at = 0.0 engine=self.replay,
self._play_from = 0.0 host=self,
toolbar=self.toolbar,
charts=self.force_plots,
image_panel=self.arrow_panel,
mesh_panel=self.mesh3d_panel,
parent=self,
)
self.replay_ctl.wire()
self.setStatusBar(_NoticeBar()) self.setStatusBar(_NoticeBar())
# The status bar carries **one-off notices** only (a recording finished, a # The status bar carries **one-off notices** only (a recording finished, a
@@ -285,7 +286,6 @@ class StudioMainWindow(QMainWindow):
self._build_menus() self._build_menus()
self._wire_toolbar() self._wire_toolbar()
self._wire_sensor_picker() self._wire_sensor_picker()
self._wire_replay()
self._restore_prefs_into_ui() self._restore_prefs_into_ui()
# The 3D pane's sensor and its persisted display settings, before anything # The 3D pane's sensor and its persisted display settings, before anything
# asks it to draw: the pane resolves its asset from the sensor, and restoring # asks it to draw: the pane resolves its asset from the sensor, and restoring
@@ -687,8 +687,8 @@ class StudioMainWindow(QMainWindow):
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed) self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
self.toolbar.sensor_clicked.connect(self._open_sensor_dialog) self.toolbar.sensor_clicked.connect(self._open_sensor_dialog)
self.toolbar.use_camera_clicked.connect(self._on_use_camera) self.toolbar.use_camera_clicked.connect(self._on_use_camera)
self.toolbar.replay_video_clicked.connect(self._on_open_recording) self.toolbar.replay_video_clicked.connect(self.replay_ctl.on_open_recording)
self.toolbar.recompute_clicked.connect(self._on_recompute_button) self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
self.toolbar.run_toggled.connect(self._on_run_toggled) self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.record_toggled.connect(self._on_record_toggled) self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset) self.toolbar.reset_clicked.connect(self._on_reset)
@@ -699,390 +699,68 @@ class StudioMainWindow(QMainWindow):
self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed) self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed)
self.sensor_picker.config_changed.connect(self._on_config_changed) self.sensor_picker.config_changed.connect(self._on_config_changed)
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed) self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
self.arrow_panel.view_mode_changed.connect(lambda _mode: self._render_replay_frame()) self.arrow_panel.view_mode_changed.connect(lambda _mode: self.replay_ctl.render_frame())
# The unit chip on the chart header switches the same unit everywhere. # The unit chip on the chart header switches the same unit everywhere.
self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle) self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle)
# The gear on the chart header opens the same dialog as 视图 ▸ 曲线图设置. # The gear on the chart header opens the same dialog as 视图 ▸ 曲线图设置.
self.force_plots.plot_settings_requested.connect(self._on_plot_settings) self.force_plots.plot_settings_requested.connect(self._on_plot_settings)
def _wire_replay(self) -> None:
"""The controller feeds the views; the chart is the only timeline."""
self.replay.position_changed.connect(self._on_replay_position)
self.replay.frames_changed.connect(self._render_replay_frame)
self.replay.status_changed.connect(self._refresh_replay_state)
self.replay.recording_changed.connect(self._on_recording_changed)
# Dragging the shared cursor on the charts scrubs the recording too.
self.force_plots.cursor_moved.connect(self._on_cursor_moved)
self.force_plots.timeline_taken_over.connect(self._pause_playback)
def _on_cursor_moved(self, t_rel_s: float) -> None: def _on_cursor_moved(self, t_rel_s: float) -> None:
if self._recompute_running: """The charts' shared cursor scrubs the recording (through the controller)."""
# The recompute pass owns the timeline: its samples are arriving in self.replay_ctl.on_cursor_moved(t_rel_s)
# order and the cursor follows them.
return
if self._view_preset == "replay" and self.replay.has_video():
self.replay.set_position(float(t_rel_s))
def _on_open_recording(self) -> None: # -------------------------------------------------------- replay (forwards)
"""Choose a recorded video and switch to the view that replays it. # The replay policy lives in ``ReplayController``. These forward so the call sites
# still in this file — and the tests / ``tools/ui_check`` / ``tools/ui_snapshot``,
The recording a video belongs to is its own directory, so the picker # which drive the window — keep working while the rest of the split lands. Each is
takes the *video* (what the operator recognises) and the charts pair it # a one-line delegation; they are deleted as the call sites move to
with the ``forces.csv`` written beside it. # ``self.replay_ctl.*``.
""" def _load_recording(self, directory: Path) -> bool:
start = str(recordings_dir()) return self.replay_ctl.load_recording(directory)
chosen, _filter = QFileDialog.getOpenFileName(
self,
t("playback.open"),
start,
t("playback.video_filter"),
)
if not chosen:
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: def _load_replay_video(self, video: Path) -> bool:
"""Open what the chosen video *is*: a recording, or just a video. return self.replay_ctl.load_video(video)
A **recording** is a directory that holds ``forces.csv`` beside the video — the
same rule ``list_recordings`` uses, so the two cannot disagree about what a
recording is. Anything else is still worth watching: an operator who points at
a clip expects the transport to play it, not to be told to press a different
button first. So a bare video is opened for playback (no forces, no session
metadata, and the rail says so), and only a file that cannot be decoded at all
is refused — with what is missing, and with 「从视频重算」 still offered, because
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):
self.toolbar.set_video_path(str(video))
self.prefs["video_path"] = str(video)
self._set_source_mode("video")
self._render_replay_frame()
self._notify(t("playback.video_only_opened", name=video.name))
self._refresh_replay_state()
return True
if video.is_file():
self.toolbar.set_video_path(str(video))
self.prefs["video_path"] = str(video)
self._set_source_mode("video")
if not self.replay.has_video():
# Nothing else is on screen, so the reason belongs in the panel that
# would have shown the frames — not only in a status line that expires.
self.arrow_panel.clear_preview(t("playback.video_only_panel"))
self._refresh_replay_state()
self._notify(t("playback.not_a_recording", name=video.name))
return False
if not self._load_recording(folder):
return False
self.toolbar.set_video_path(str(video))
self._refresh_status_rail()
return True
def _can_recompute(self) -> bool:
"""Whether 「从视频重算」 has something to read: a recording, or a video file."""
if self._view_preset != "replay":
return False
if self.replay.has_video():
return True
named = self.toolbar.video_path()
return bool(named) and Path(named).is_file()
def _load_recording(self, directory: Path) -> bool:
if self._view_preset != "replay":
self._apply_view_preset("replay")
opened = self.replay.open_recording(Path(directory))
if not opened:
self._notify(t("playback.open_failed", error=Path(directory).name))
return False
self._refresh_replay_state()
return True
def _open_latest_recording(self) -> None:
"""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"))
return
self._load_recording(Path(found[-1]))
def _on_recording_changed(self, opened: bool) -> None:
"""A recording was opened (or dropped): re-point everything that reads it."""
self._refresh_replay_state()
if not opened:
self._pause_playback()
return
if self._view_preset == "replay":
self._apply_replay_source(True)
self._render_replay_frame()
def _refresh_replay_state(self) -> None:
"""Push the engine's state into the charts' mode and the rail."""
if self.replay.is_video_only() and not self.toolbar.video_path():
path = self.replay.video_path()
if path is not None:
self.toolbar.set_video_path(str(path))
elif self.replay.is_loaded() and not self.toolbar.video_path():
# Opened without naming a video (the file menu's "latest"): name the
# segment that is on screen so the rail states the same thing the
# picker would.
reader = self.replay.reader()
segments = sorted(reader.segments()) if reader is not None else []
if segments:
self.toolbar.set_video_path(str(segments[0]))
self.toolbar.set_run_checked(self._play_timer.isActive())
# Loading a recording is what makes a recompute pass possible at all — but a
# 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()
def _on_replay_position(self, position_s: float) -> None:
self.force_plots.set_cursor(float(position_s))
def _on_replay_unit_toggled(self) -> None:
"""The chart's unit chip drives the same unit the replay reads."""
self._set_display_unit("raw" if self.force_plots.unit() == "N" else "N")
# ------------------------------------------------------------- playback
def _on_transport_toggled(self, playing: bool) -> None:
"""The rail's primary button in the replay view: play or pause."""
if self._recompute_active():
# A recompute pass is a *run*: play starts the pipeline over the
# video, pause stops it. It can only go forward (the chain is
# stateful), so playing again after the end starts from the top.
if playing:
self._recompute_running = True
self._start_run()
elif self._recompute_running:
self._stop_run(reason="user_stop")
self.toolbar.set_run_checked(bool(playing) and self._running)
return
if playing and self.replay.has_video():
self._start_playback()
else:
self._pause_playback()
def _start_playback(self) -> None:
duration = self.replay.duration_s()
if duration <= 0:
# A zero-length timeline is not something to play, and silently un-checking
# the button is how "pressing 回放 does nothing" felt to the operator.
self.toolbar.set_run_checked(False)
self._notify(t("playback.empty_recording"))
return
position = self.replay.position()
if position >= duration - 0.02:
# At the end: playing again means playing from the start.
self._set_replay_position(0.0)
position = 0.0
self._play_from = float(position)
self._play_started_at = time.perf_counter()
self._play_timer.start()
self.toolbar.set_run_checked(True)
self.toolbar.refresh_labels()
# The stop button appears with the playback: one place decides what the rail
# offers, so it is told here rather than kept in step in two places.
self._update_run_enabled()
def _pause_playback(self) -> None:
if self._play_timer.isActive():
self._play_timer.stop()
if self.toolbar.btn_run.isChecked():
self.toolbar.set_run_checked(False)
self.toolbar.refresh_labels()
self._update_run_enabled()
def _on_play_tick(self) -> None:
"""Advance the playhead from the wall clock, not from the tick count."""
duration = self.replay.duration_s()
elapsed = time.perf_counter() - self._play_started_at
position = self._play_from + elapsed
if position >= duration:
self._set_replay_position(duration)
self._pause_playback()
return
self._set_replay_position(position)
def _set_replay_position(self, position_s: float) -> None:
"""Move the playhead without the position signal echoing back into it."""
self.replay.set_position(float(position_s))
def _recompute_active(self) -> bool:
return bool(self.act_recompute.isChecked())
def _replay_video_segment(self) -> Path | None:
"""The recorded video a recompute pass should read."""
reader = self.replay.reader()
segments = sorted(reader.segments()) if reader is not None else []
if segments:
return Path(segments[0])
named = self.toolbar.video_path()
if named and Path(named).is_file():
return Path(named)
return None
def _on_recompute_toggled(self, checked: bool) -> None:
"""Off: read the recorded CSV. On: re-run the pipeline over the video.
The CSV is the session's own measurement. Re-running the vision chain
over the recorded (lossy) video answers a different question — "what
would this algorithm version say about that footage" — and it can only
run *forward*, because the chain is stateful, so it is a pass rather than
a scrub.
"""
wanted = bool(checked) and self._can_recompute()
if self.act_recompute.isChecked() != wanted:
self.act_recompute.blockSignals(True)
self.act_recompute.setChecked(wanted)
self.act_recompute.blockSignals(False)
self._pause_playback()
if wanted:
segment = self._replay_video_segment()
if segment is None:
self._notify(t("playback.no_video"))
self.act_recompute.blockSignals(True)
self.act_recompute.setChecked(False)
self.act_recompute.blockSignals(False)
return
# The charts stop reading the CSV; the pipeline's own samples become
# the data, and the 2D panel shows what it produces. The pass starts
# here so the switch is not a blank chart waiting for a second click.
self.force_plots.clear_recording_source()
self.arrow_panel.set_source_kind("recompute")
self.toolbar.set_source_mode("video")
self.toolbar.set_video_path(str(segment))
self._refresh_status_rail()
self._recompute_running = True
# The 3D pane now says "recomputed", not "replayed": the pass is what
# feeds it, through the ordinary acquisition sink.
self.mesh3d_panel.set_source_kind(self._current_source_kind())
# Playing is what starts the pass: one path in, so the button and the
# menu cannot start it twice.
self.toolbar.set_run_checked(True)
self.toolbar.refresh_labels()
self._on_transport_toggled(True)
return
if self._recompute_running:
self._stop_run(reason="user_stop")
# The pass is over, so the last recomputed frame must go: leaving it on
# screen would show a measurement nothing is producing any more.
self.mesh3d_panel.show_reference_only()
if self._view_preset == "replay":
self.mesh3d_panel.set_source_kind(self._current_source_kind())
if self.replay.is_loaded():
self._apply_replay_source(True)
# A bare video has no charts to point at the recording, but it does have
# frames to show again now that the pass is over.
self._render_replay_frame()
def _render_replay_frame(self) -> None: def _render_replay_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor. self.replay_ctl.render_frame()
A recording stores the raw camera image and nothing else, so the frame this def _start_playback(self) -> None:
builds has no flow and no depth — the plan then says so, in one line, and the self.replay_ctl.start_playback()
panel offers exactly the one view the frame can be. The processed picture of the
same footage is what 「从视频重算」 produces, through the *same* backend.
"""
if self._view_preset != "replay" or not self.replay.has_video():
return
if self._recompute_running:
# The pipeline is drawing its own frames (see ``_replay_owns_image``).
return
image = self.replay.video_frame_bgr()
if image is None:
self.arrow_panel.clear_preview(self.replay.video_message())
return
self._show_image_frame(
ImageFrame(
image,
source=SOURCE_REPLAY,
t_rel_s=float(self.replay.position()),
)
)
def _apply_replay_source(self, active: bool) -> None: def _pause_playback(self) -> None:
"""Point the charts at the recording (or back at the live session).""" self.replay_ctl.pause_playback()
if active and self.replay.is_loaded():
reader = self.replay.reader() def _on_play_shortcut(self) -> None:
self.force_plots.set_recording_source(reader) self.replay_ctl.on_play_shortcut()
# A recording is shown in a unit it actually carries: newton only when
# it has a calibrated axis somewhere, otherwise the charts would be def _stop_replay_activity(self) -> None:
# empty and every card a dash. The operator's preference is kept when self.replay_ctl.stop_activity()
# the recording can honour it.
wanted = "N" if self.force_plots.unit() == "N" else "raw" def _replay_owns_image(self) -> bool:
unit = self._replay_unit_for(reader) return self.replay_ctl.owns_image()
self.replay.set_unit(unit)
self._set_display_unit(unit) def _can_recompute(self) -> bool:
if unit != wanted: return self.replay_ctl.can_recompute()
self._notify(t("status.units_raw_fallback"))
self.force_plots.show_all() def _in_replay_view(self) -> bool:
self.arrow_panel.set_source_kind("replay") return self._view_preset == "replay"
return
if self.force_plots.is_recording_source(): def _drop_render_marker(self) -> None:
self.force_plots.clear_recording_source() """A recorded frame was on the panel: the next live packet must repaint it."""
self.force_plots.follow_latest()
self.arrow_panel.set_source_kind("live")
# The panel held a recorded frame: the next live packet must repaint it,
# so the "already rendered" marker is dropped.
self._last_rendered_view_mode = None self._last_rendered_view_mode = None
if not self._running and not self._preview_open:
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
def _replay_unit_for(self, reader) -> str: @property
"""The unit a recording can be shown in: raw unless it has newtons. def _play_timer(self):
return self.replay_ctl.play_timer
@property
def _recompute_running(self) -> bool:
return self.replay_ctl.recompute_running
@_recompute_running.setter
def _recompute_running(self, running: bool) -> None:
self.replay_ctl.recompute_running = running
``raw`` is always present (it is what the device measured); the calibrated
triplet only exists for the axes the recording actually calibrated. Asking
for newton on a raw-only recording draws three empty charts, so the same
fallback the live view uses applies here.
"""
if self.force_plots.unit() != "N":
return "raw"
info = reader.info()
newtons = getattr(info, "newtons", None)
if newtons is None:
return "raw"
values = np.asarray(newtons, dtype=float)
return "N" if values.size and bool(np.isfinite(values).any()) else "raw"
def _restore_prefs_into_ui(self) -> None: def _restore_prefs_into_ui(self) -> None:
raw_id = str(self.prefs.get("camera_id") or "0").strip() raw_id = str(self.prefs.get("camera_id") or "0").strip()
@@ -1142,17 +820,14 @@ class StudioMainWindow(QMainWindow):
self.file_menu = menu_bar.addMenu("") self.file_menu = menu_bar.addMenu("")
self.act_open_video = QAction(self) self.act_open_video = QAction(self)
self.act_open_video.triggered.connect(self._on_browse_video)
self.file_menu.addAction(self.act_open_video) self.file_menu.addAction(self.act_open_video)
self.act_reveal_config = QAction(self) self.act_reveal_config = QAction(self)
self.act_reveal_config.triggered.connect(self._on_reveal_config) self.act_reveal_config.triggered.connect(self._on_reveal_config)
self.file_menu.addAction(self.act_reveal_config) self.file_menu.addAction(self.act_reveal_config)
self.file_menu.addSeparator() self.file_menu.addSeparator()
self.act_playback = QAction(self) self.act_playback = QAction(self)
self.act_playback.triggered.connect(self._on_open_recording)
self.file_menu.addAction(self.act_playback) self.file_menu.addAction(self.act_playback)
self.act_playback_latest = QAction(self) self.act_playback_latest = QAction(self)
self.act_playback_latest.triggered.connect(self._open_latest_recording)
self.file_menu.addAction(self.act_playback_latest) self.file_menu.addAction(self.act_playback_latest)
# The transport is on the chart header; the space bar is the editing # The transport is on the chart header; the space bar is the editing
@@ -1160,7 +835,6 @@ class StudioMainWindow(QMainWindow):
self.act_play = QAction(self) self.act_play = QAction(self)
self.act_play.setShortcut("Space") self.act_play.setShortcut("Space")
self.act_play.setShortcutContext(Qt.ApplicationShortcut) self.act_play.setShortcutContext(Qt.ApplicationShortcut)
self.act_play.triggered.connect(self._on_play_shortcut)
self.addAction(self.act_play) self.addAction(self.act_play)
self.view_menu = menu_bar.addMenu("") self.view_menu = menu_bar.addMenu("")
@@ -1202,7 +876,6 @@ class StudioMainWindow(QMainWindow):
self.act_recompute = QAction(self) self.act_recompute = QAction(self)
self.act_recompute.setCheckable(True) self.act_recompute.setCheckable(True)
self.act_recompute.setChecked(False) self.act_recompute.setChecked(False)
self.act_recompute.toggled.connect(self._on_recompute_toggled)
self.view_menu.addAction(self.act_recompute) self.view_menu.addAction(self.act_recompute)
self.view_menu.addSeparator() self.view_menu.addSeparator()
@@ -1260,6 +933,15 @@ class StudioMainWindow(QMainWindow):
self.act_about.triggered.connect(self._show_about) self.act_about.triggered.connect(self._show_about)
self.help_menu.addAction(self.act_about) self.help_menu.addAction(self.act_about)
# The replay entries exist now: hand them over, and the controller connects
# them (it owns what they mean — see ``ReplayController.bind_actions``).
self.replay_ctl.bind_actions(
open_video=self.act_open_video,
playback=self.act_playback,
latest=self.act_playback_latest,
play=self.act_play,
recompute=self.act_recompute,
)
self._sync_menu_checks() self._sync_menu_checks()
def _sync_menu_checks(self) -> None: def _sync_menu_checks(self) -> None:
@@ -1275,7 +957,7 @@ class StudioMainWindow(QMainWindow):
self.act_finger_3d.blockSignals(True) self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False) self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
self.act_finger_3d.blockSignals(False) self.act_finger_3d.blockSignals(False)
self.act_recompute.setEnabled(self._can_recompute()) self.act_recompute.setEnabled(self.replay_ctl.can_recompute())
self._apply_arrow_rotation_for_sensor() self._apply_arrow_rotation_for_sensor()
def _current_source_kind(self) -> str: def _current_source_kind(self) -> str:
@@ -1386,7 +1068,7 @@ class StudioMainWindow(QMainWindow):
# The rail shows the source this view needs, and only that source: a # The rail shows the source this view needs, and only that source: a
# camera while measuring, a recorded video while replaying. It is the one # camera while measuring, a recorded video while replaying. It is the one
# visible difference between the two views. # visible difference between the two views.
self._pause_playback() self.replay_ctl.pause_playback()
self.toolbar.set_source_view("replay" if preset == "replay" else "live") self.toolbar.set_source_view("replay" if preset == "replay" else "live")
# The rail's primary button is this view's main action: acquire while # The rail's primary button is this view's main action: acquire while
# measuring, play back while replaying; and the second button is that view's # measuring, play back while replaying; and the second button is that view's
@@ -1400,16 +1082,16 @@ class StudioMainWindow(QMainWindow):
# History browsing is the point of this view: the charts read the # History browsing is the point of this view: the charts read the
# open recording, and the chart cursor is its timeline. Without a # open recording, and the chart cursor is its timeline. Without a
# recording the charts stay on the live session and follow it. # recording the charts stay on the live session and follow it.
self._apply_replay_source(True) self.replay_ctl.apply_source(True)
self._refresh_replay_state() self.replay_ctl.refresh_state()
elif preset == "live": elif preset == "live":
self._apply_replay_source(False) self.replay_ctl.apply_source(False)
# Leaving the replay must not leave its video on the rail: the live # Leaving the replay must not leave its video on the rail: the live
# view is a camera, and the summary says so (see ``_refresh_status_rail``). # view is a camera, and the summary says so (see ``_refresh_status_rail``).
self._on_recompute_toggled(False) self.replay_ctl.on_recompute_toggled(False)
self.brand_bar.set_active_view(preset if preset != "custom" else None) self.brand_bar.set_active_view(preset if preset != "custom" else None)
self._sync_menu_checks() self._sync_menu_checks()
self._sync_replay_actions() self.replay_ctl.sync_actions()
self._update_run_enabled() self._update_run_enabled()
self._refresh_status_rail() self._refresh_status_rail()
if persist: if persist:
@@ -1430,7 +1112,7 @@ class StudioMainWindow(QMainWindow):
if self.replay.is_video_only() if self.replay.is_video_only()
else "" else ""
) )
elif self._can_recompute(): elif self.replay_ctl.can_recompute():
# A video was chosen but it is not a recording: the transport cannot # A video was chosen but it is not a recording: the transport cannot
# play it, and the tooltip says which button can show it instead of # play it, and the tooltip says which button can show it instead of
# leaving the operator pressing a dead button. # leaving the operator pressing a dead button.
@@ -1438,12 +1120,12 @@ class StudioMainWindow(QMainWindow):
else: else:
reason = t("toolbar.playback_disabled") reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason) self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self._play_timer.isActive()) self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
# The stop button is offered whenever there is something to stop; the # The stop button is offered whenever there is something to stop; the
# operator must never have to guess whether that is the case. # operator must never have to guess whether that is the case.
if self.toolbar.reset_role() == "stop": if self.toolbar.reset_role() == "stop":
self.toolbar.set_reset_enabled( self.toolbar.set_reset_enabled(
self._play_timer.isActive() self.replay_ctl.is_playing()
or self._recompute_running or self._recompute_running
or self._session_active or self._session_active
) )
@@ -2206,8 +1888,8 @@ class StudioMainWindow(QMainWindow):
from the keyboard. from the keyboard.
""" """
if self.toolbar.run_role() == "playback": if self.toolbar.run_role() == "playback":
if self._play_timer.isActive() or self._session_active: if self.replay_ctl.is_playing() or self._session_active:
self._stop_replay_activity() self.replay_ctl.stop_activity()
return return
if not self._session_active: if not self._session_active:
return return
@@ -2225,26 +1907,6 @@ class StudioMainWindow(QMainWindow):
self._refresh_status_rail() self._refresh_status_rail()
self._persist_prefs() self._persist_prefs()
def _on_browse_video(self) -> None:
"""Open a recorded video for replay (file menu).
Choosing a video is the replay view's source selection, so this takes the
same path as the rail's button: a recorded video is something to *play*,
not a second way to acquire.
"""
if self._running or self._preview_open:
return
start = self.toolbar.video_path() or str(Path.home())
path, _ = QFileDialog.getOpenFileName(
self,
t("menu.open_video"),
start,
t("playback.video_filter"),
)
if not path:
return
self._load_replay_video(Path(path))
def _on_reveal_config(self) -> None: def _on_reveal_config(self) -> None:
sensor_type = self.sensor_picker.sensor_type() sensor_type = self.sensor_picker.sensor_type()
path = sensor_json_path(sensor_type) path = sensor_json_path(sensor_type)
@@ -2346,7 +2008,7 @@ class StudioMainWindow(QMainWindow):
def _on_run_toggled(self, checked: bool) -> None: def _on_run_toggled(self, checked: bool) -> None:
if self.toolbar.run_role() == "playback": if self.toolbar.run_role() == "playback":
# The primary button of the replay view is its transport. # The primary button of the replay view is its transport.
self._on_transport_toggled(bool(checked)) self.replay_ctl.on_transport_toggled(bool(checked))
return return
if checked: if checked:
self._start_run() self._start_run()
@@ -2525,7 +2187,7 @@ class StudioMainWindow(QMainWindow):
if self.toolbar.reset_role() == "stop": if self.toolbar.reset_role() == "stop":
# In the replay view that button is the operator's way out of a replay or # In the replay view that button is the operator's way out of a replay or
# a recompute pass, whatever state the transport is in. # a recompute pass, whatever state the transport is in.
self._stop_replay_activity() self.replay_ctl.stop_activity()
return return
if self._session_phase != "running" or self._active_worker is None: if self._session_phase != "running" or self._active_worker is None:
return return
@@ -0,0 +1,641 @@
"""The replay view's policy: what is open, what is playing, and what a pass is.
``replay.py`` holds the *engine* — the recording reader, the cursor, the asynchronous
depth recomputation. This module holds the *policy* around it, which used to live in
``app_window.py``: which entry points open a video and which refuse it, what the
transport does when the operator presses play, when the button is a transport and when
it is an acquire button, and what "从视频重算" does to the panels and to the run
pipeline.
The split is by change driver, not by convenience: the engine changes when the
*recording format* changes, this controller when the *operator's workflow* changes,
and the window when the furniture changes.
What this controller drives directly: the toolbar's replay controls, the charts (the
timeline), the 2D panel, the 3D pane's source label, and the five replay actions. What
it borrows from the window is named by :class:`ReplayHost` — the status line, the rail,
the view switch, the display unit, the single 2D sink, and the run pipeline a recompute
pass is. Those are window-level operations, so the window keeps them and this module
states its dependence on them out loud instead of reaching into a god object.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Protocol
import numpy as np
from PyQt5.QtCore import QObject, QTimer
from PyQt5.QtWidgets import QFileDialog
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
logger = logging.getLogger(__name__)
#: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the
#: cursor is advanced from the wall clock, so a missed tick does not slow the
#: recording down.
PLAYBACK_TICK_MS = 33
class ReplayHost(Protocol):
"""What the replay controller needs from the window.
The names are the window's own (its transitional shims included), because the
window is the only implementation today. They are listed here so the boundary is
explicit and so a test can stand in for the window with a fake.
"""
#: Operator preferences (the chosen video path is remembered here).
prefs: dict
@property
def _running(self) -> bool: ...
@property
def _preview_open(self) -> bool: ...
@property
def _session_active(self) -> bool: ...
def _in_replay_view(self) -> bool: ...
def _apply_view_preset(self, preset: str) -> None: ...
def _set_display_unit(self, unit: str) -> None: ...
def _set_source_mode(self, mode: str) -> None: ...
def _show_image_frame(self, frame: ImageFrame) -> None: ...
def _current_source_kind(self) -> str: ...
def _drop_render_marker(self) -> None: ...
def _notify(self, text: str, ms: int = ...) -> None: ...
def _start_run(self) -> None: ...
def _stop_run(self, *, reason: str = ..., wait: bool = ...) -> None: ...
def _update_run_enabled(self) -> None: ...
def _refresh_status_rail(self) -> None: ...
@dataclass
class ReplayActions:
"""The five menu/shortcut entries whose meaning depends on the replay state."""
open_video: Any
playback: Any
latest: Any
play: Any
recompute: Any
class ReplayController(QObject):
"""The replay view's state machine and the panels it drives."""
def __init__(
self,
*,
engine: Any,
host: ReplayHost,
toolbar: Any,
charts: Any,
image_panel: Any,
mesh_panel: Any,
parent: Optional[QObject] = None,
) -> None:
super().__init__(parent)
self._engine = engine
self._host = host
self._toolbar = toolbar
self._charts = charts
self._image_panel = image_panel
self._mesh_panel = mesh_panel
self._actions: Optional[ReplayActions] = None
#: True while a recompute pass is running: it is a *run* over a recording, so
#: the acquire pipeline owns the panels and the transport stops the pass.
self._recompute_running = False
self._play_from = 0.0
self._play_started_at = 0.0
self.play_timer = QTimer(self)
self.play_timer.setInterval(PLAYBACK_TICK_MS)
self.play_timer.timeout.connect(self.on_play_tick)
# ------------------------------------------------------------------ wiring
def wire(self) -> None:
"""Connect the engine and the charts. Called once, after construction."""
self._engine.position_changed.connect(self.on_replay_position)
self._engine.frames_changed.connect(self.render_frame)
self._engine.status_changed.connect(self.refresh_state)
self._engine.recording_changed.connect(self.on_recording_changed)
# Dragging the shared cursor on the charts scrubs the recording too.
self._charts.cursor_moved.connect(self.on_cursor_moved)
self._charts.timeline_taken_over.connect(self.pause_playback)
def bind_actions(
self,
*,
open_video: Any,
playback: Any,
latest: Any,
play: Any,
recompute: Any,
) -> None:
"""Take the replay entries once the menus exist, and connect them.
The controller owns what these entries *mean*, so it also owns their signals:
one place decides whether opening a video is allowed and what it does.
"""
self._actions = ReplayActions(
open_video=open_video,
playback=playback,
latest=latest,
play=play,
recompute=recompute,
)
open_video.triggered.connect(self.on_browse_video)
playback.triggered.connect(self.on_open_recording)
latest.triggered.connect(self.open_latest_recording)
play.triggered.connect(self.on_play_shortcut)
recompute.toggled.connect(self.on_recompute_toggled)
# ------------------------------------------------------------------- state
@property
def engine(self) -> Any:
return self._engine
@property
def recompute_running(self) -> bool:
return self._recompute_running
@recompute_running.setter
def recompute_running(self, running: bool) -> None:
self._recompute_running = bool(running)
def is_playing(self) -> bool:
return self.play_timer.isActive()
def recompute_active(self) -> bool:
"""Whether the operator asked for a recompute pass (the switch is on)."""
if self._actions is None:
return False
return bool(self._actions.recompute.isChecked())
def owns_image(self) -> bool:
"""True while the 2D panel shows the replay, not the live camera.
Without this the live renderer overwrote the replay's frame on every packet
*and* on every preview tick, so a replay running next to an active session (or
a preview) looked as if it had opened the camera instead of the recording. A
recompute pass is the pipeline drawing again, so it owns the panel while it
runs.
"""
return (
self._host._in_replay_view()
and self._engine.has_video()
and not self._recompute_running
)
# ------------------------------------------------------------------ entries
def on_cursor_moved(self, t_rel_s: float) -> None:
if self._recompute_running:
# The recompute pass owns the timeline: its samples are arriving in
# order and the cursor follows them.
return
if self._host._in_replay_view() and self._engine.has_video():
self._engine.set_position(float(t_rel_s))
def on_open_recording(self) -> None:
"""Choose a recorded video and switch to the view that replays it.
The recording a video belongs to is its own directory, so the picker takes the
*video* (what the operator recognises) and the charts pair it with the
``forces.csv`` written beside it.
"""
start = str(recordings_dir())
chosen, _filter = QFileDialog.getOpenFileName(
self._host,
t("playback.open"),
start,
t("playback.video_filter"),
)
if not chosen:
return
self.load_video(Path(chosen))
def on_browse_video(self) -> None:
"""Open a recorded video for replay (file menu).
Choosing a video is the replay view's source selection, so this takes the same
path as the rail's button: a recorded video is something to *play*, not a
second way to acquire.
"""
if self._host._running or self._host._preview_open:
return
start = self._toolbar.video_path() or str(Path.home())
path, _ = QFileDialog.getOpenFileName(
self._host,
t("menu.open_video"),
start,
t("playback.video_filter"),
)
if not path:
return
self.load_video(Path(path))
def on_recompute_button(self) -> None:
"""The replay view's own entry for the processed picture."""
if self._actions is None:
return
self._actions.recompute.setChecked(not self._actions.recompute.isChecked())
def on_play_shortcut(self) -> None:
"""Space plays or pauses the recording, if there is one to move through."""
if self._toolbar.run_role() != "playback" or not self._toolbar.btn_run.isEnabled():
return
self._toolbar.btn_run.setChecked(not self._toolbar.btn_run.isChecked())
# ------------------------------------------------------------------ opening
def sync_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.
"""
if self._actions is None:
return
allowed = self._host._in_replay_view()
reason = "" if allowed else t("toolbar.replay_view_only")
for action in (
self._actions.open_video,
self._actions.playback,
self._actions.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._actions.recompute.isChecked())
def can_recompute(self) -> bool:
"""Whether 「从视频重算」 has something to read: a recording, or a video file."""
if not self._host._in_replay_view():
return False
if self._engine.has_video():
return True
named = self._toolbar.video_path()
return bool(named) and Path(named).is_file()
def load_video(self, video: Path) -> bool:
"""Open what the chosen video *is*: a recording, or just a video.
A **recording** is a directory that holds ``forces.csv`` beside the video — the
same rule ``list_recordings`` uses, so the two cannot disagree about what a
recording is. Anything else is still worth watching: an operator who points at
a clip expects the transport to play it, not to be told to press a different
button first. So a bare video is opened for playback (no forces, no session
metadata, and the rail says so), and only a file that cannot be decoded at all
is refused — with what is missing, and with 「从视频重算」 still offered, because
that is the only way to get *numbers* out of a video that has no recording.
"""
video = Path(video)
if not self._host._in_replay_view():
# 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._host._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._engine.open_video(video):
self._toolbar.set_video_path(str(video))
self._host.prefs["video_path"] = str(video)
# The window's own switch, which also remembers the choice: the file
# menu's source selector and this path must leave the same state.
self._host._set_source_mode("video")
self.render_frame()
self._host._notify(t("playback.video_only_opened", name=video.name))
self.refresh_state()
return True
if video.is_file():
self._toolbar.set_video_path(str(video))
self._host.prefs["video_path"] = str(video)
self._host._set_source_mode("video")
if not self._engine.has_video():
# Nothing else is on screen, so the reason belongs in the panel that
# would have shown the frames — not only in a status line that expires.
self._image_panel.clear_preview(t("playback.video_only_panel"))
self.refresh_state()
self._host._notify(t("playback.not_a_recording", name=video.name))
return False
if not self.load_recording(folder):
return False
self._toolbar.set_video_path(str(video))
self._host._refresh_status_rail()
return True
def load_recording(self, directory: Path) -> bool:
if not self._host._in_replay_view():
self._host._apply_view_preset("replay")
opened = self._engine.open_recording(Path(directory))
if not opened:
self._host._notify(t("playback.open_failed", error=Path(directory).name))
return False
self.refresh_state()
return True
def open_latest_recording(self) -> None:
"""Switch to the replay view on the most recent recording."""
from orisys_app_shared.record import list_recordings
if not self._host._in_replay_view():
self._host._notify(t("toolbar.replay_view_only"))
return
found = list_recordings(recordings_dir())
if not found:
self._host._notify(t("playback.none_found"))
return
self.load_recording(Path(found[-1]))
def on_recording_changed(self, opened: bool) -> None:
"""A recording was opened (or dropped): re-point everything that reads it."""
self.refresh_state()
if not opened:
self.pause_playback()
return
if self._host._in_replay_view():
self.apply_source(True)
self.render_frame()
def refresh_state(self) -> None:
"""Push the engine's state into the charts' mode and the rail."""
if self._engine.is_video_only() and not self._toolbar.video_path():
path = self._engine.video_path()
if path is not None:
self._toolbar.set_video_path(str(path))
elif self._engine.is_loaded() and not self._toolbar.video_path():
# Opened without naming a video (the file menu's "latest"): name the
# segment that is on screen so the rail states the same thing the
# picker would.
reader = self._engine.reader()
segments = sorted(reader.segments()) if reader is not None else []
if segments:
self._toolbar.set_video_path(str(segments[0]))
self._toolbar.set_run_checked(self.play_timer.isActive())
# Loading a recording is what makes a recompute pass possible at all — but a
# 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.
if self._actions is not None:
self._actions.recompute.setEnabled(self.can_recompute())
self.sync_actions()
self._host._update_run_enabled()
self._host._refresh_status_rail()
# ---------------------------------------------------------------- transport
def on_replay_position(self, position_s: float) -> None:
self._charts.set_cursor(float(position_s))
def on_transport_toggled(self, playing: bool) -> None:
"""The rail's primary button in the replay view: play or pause."""
if self.recompute_active():
# A recompute pass is a *run*: play starts the pipeline over the
# video, pause stops it. It can only go forward (the chain is
# stateful), so playing again after the end starts from the top.
if playing:
self._recompute_running = True
self._host._start_run()
elif self._recompute_running:
# The flag is this controller's, so it is cleared here as well as in
# the session stop below: a pass that ended must not keep claiming to
# be running just because the stop came from the transport.
self._recompute_running = False
self._host._stop_run(reason="user_stop")
self._toolbar.set_run_checked(bool(playing) and self._host._running)
return
if playing and self._engine.has_video():
self.start_playback()
else:
self.pause_playback()
def start_playback(self) -> None:
duration = self._engine.duration_s()
if duration <= 0:
# A zero-length timeline is not something to play, and silently un-checking
# the button is how "pressing 回放 does nothing" felt to the operator.
self._toolbar.set_run_checked(False)
self._host._notify(t("playback.empty_recording"))
return
position = self._engine.position()
if position >= duration - 0.02:
# At the end: playing again means playing from the start.
self.set_position(0.0)
position = 0.0
self._play_from = float(position)
self._play_started_at = time.perf_counter()
self.play_timer.start()
self._toolbar.set_run_checked(True)
self._toolbar.refresh_labels()
# The stop button appears with the playback: one place decides what the rail
# offers, so it is told here rather than kept in step in two places.
self._host._update_run_enabled()
def pause_playback(self) -> None:
if self.play_timer.isActive():
self.play_timer.stop()
if self._toolbar.btn_run.isChecked():
self._toolbar.set_run_checked(False)
self._toolbar.refresh_labels()
self._host._update_run_enabled()
def on_play_tick(self) -> None:
"""Advance the playhead from the wall clock, not from the tick count."""
duration = self._engine.duration_s()
elapsed = time.perf_counter() - self._play_started_at
position = self._play_from + elapsed
if position >= duration:
self.set_position(duration)
self.pause_playback()
return
self.set_position(position)
def set_position(self, position_s: float) -> None:
"""Move the playhead without the position signal echoing back into it."""
self._engine.set_position(float(position_s))
def stop_activity(self) -> None:
"""Stop whatever the replay view is doing. The operator's escape hatch.
Two different things can be running there — plain playback of the recorded
timeline, and a recompute pass over the video — and the person in front of the
screen should not have to work out which one is stuck before they can stop it.
So this stops both, clears a recomputed 3D picture, and says what it stopped.
Safe to press when nothing is running (it says so rather than doing nothing
silently, which is what made a stuck state feel stuck).
"""
stopped: list[str] = []
if self._recompute_running or self._host._session_active:
self._recompute_running = False
self._host._stop_run(reason="user_stop")
stopped.append(t("toolbar.stopped_pass"))
if self.play_timer.isActive():
self.pause_playback()
stopped.append(t("toolbar.stopped_playback"))
self._mesh_panel.show_reference_only()
self._host._update_run_enabled()
if stopped:
self._host._notify(t("toolbar.stopped", what=", ".join(stopped)))
else:
self._host._notify(t("toolbar.nothing_to_stop"))
# ---------------------------------------------------------------- recompute
def video_segment(self) -> Optional[Path]:
"""The recorded video a recompute pass should read."""
reader = self._engine.reader()
segments = sorted(reader.segments()) if reader is not None else []
if segments:
return Path(segments[0])
named = self._toolbar.video_path()
if named and Path(named).is_file():
return Path(named)
return None
def on_recompute_toggled(self, checked: bool) -> None:
"""Off: read the recorded CSV. On: re-run the pipeline over the video.
The CSV is the session's own measurement. Re-running the vision chain over the
recorded (lossy) video answers a different question — "what would this algorithm
version say about that footage" — and it can only run *forward*, because the
chain is stateful, so it is a pass rather than a scrub.
"""
wanted = bool(checked) and self.can_recompute()
if self._actions is not None and self._actions.recompute.isChecked() != wanted:
self._actions.recompute.blockSignals(True)
self._actions.recompute.setChecked(wanted)
self._actions.recompute.blockSignals(False)
self.pause_playback()
if wanted:
segment = self.video_segment()
if segment is None:
self._host._notify(t("playback.no_video"))
if self._actions is not None:
self._actions.recompute.blockSignals(True)
self._actions.recompute.setChecked(False)
self._actions.recompute.blockSignals(False)
return
# The charts stop reading the CSV; the pipeline's own samples become the
# data, and the 2D panel shows what it produces. The pass starts here so
# the switch is not a blank chart waiting for a second click.
self._charts.clear_recording_source()
self._image_panel.set_source_kind("recompute")
self._toolbar.set_source_mode("video")
self._toolbar.set_video_path(str(segment))
self._host._refresh_status_rail()
self._recompute_running = True
# The 3D pane now says "recomputed", not "replayed": the pass is what feeds
# it, through the ordinary acquisition sink.
self._mesh_panel.set_source_kind(self._host._current_source_kind())
# Playing is what starts the pass: one path in, so the button and the menu
# cannot start it twice.
self._toolbar.set_run_checked(True)
self._toolbar.refresh_labels()
self.on_transport_toggled(True)
return
if self._recompute_running:
# The flag is cleared here, not only by the session stop: it belongs to this
# controller, and a pass that ended must stop claiming to be running.
self._recompute_running = False
self._host._stop_run(reason="user_stop")
# The pass is over, so the last recomputed frame must go: leaving it on
# screen would show a measurement nothing is producing any more.
self._mesh_panel.show_reference_only()
if self._host._in_replay_view():
self._mesh_panel.set_source_kind(self._host._current_source_kind())
if self._engine.is_loaded():
self.apply_source(True)
# A bare video has no charts to point at the recording, but it does have
# frames to show again now that the pass is over.
self.render_frame()
# ------------------------------------------------------------------ drawing
def render_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor.
A recording stores the raw camera image and nothing else, so the frame this
builds has no flow and no depth — the plan then says so, in one line, and the
panel offers exactly the one view the frame can be. The processed picture of the
same footage is what 「从视频重算」 produces, through the *same* backend.
"""
if not self._host._in_replay_view() or not self._engine.has_video():
return
if self._recompute_running:
# The pipeline is drawing its own frames (see ``owns_image``).
return
image = self._engine.video_frame_bgr()
if image is None:
self._image_panel.clear_preview(self._engine.video_message())
return
self._host._show_image_frame(
ImageFrame(
image,
source=SOURCE_REPLAY,
t_rel_s=float(self._engine.position()),
)
)
def apply_source(self, active: bool) -> None:
"""Point the charts at the recording (or back at the live session)."""
if active and self._engine.is_loaded():
reader = self._engine.reader()
self._charts.set_recording_source(reader)
# A recording is shown in a unit it actually carries: newton only when
# it has a calibrated axis somewhere, otherwise the charts would be
# empty and every card a dash. The operator's preference is kept when
# the recording can honour it.
wanted = "N" if self._charts.unit() == "N" else "raw"
unit = self.unit_for(reader)
self._engine.set_unit(unit)
self._host._set_display_unit(unit)
if unit != wanted:
self._host._notify(t("status.units_raw_fallback"))
self._charts.show_all()
self._image_panel.set_source_kind("replay")
return
if self._charts.is_recording_source():
self._charts.clear_recording_source()
self._charts.follow_latest()
self._image_panel.set_source_kind("live")
# The panel held a recorded frame: the next live packet must repaint it, so the
# "already rendered" marker is dropped.
self._host._drop_render_marker()
if not self._host._running and not self._host._preview_open:
self._image_panel.clear_preview(t("panels.arrow_empty"))
def unit_for(self, reader: Any) -> str:
"""The unit a recording can be shown in: raw unless it has newtons.
``raw`` is always present (it is what the device measured); the calibrated
triplet only exists for the axes the recording actually calibrated. Asking for
newton on a raw-only recording draws three empty charts, so the same fallback
the live view uses applies here.
"""
if self._charts.unit() != "N":
return "raw"
info = reader.info()
newtons = getattr(info, "newtons", None)
if newtons is None:
return "raw"
values = np.asarray(newtons, dtype=float)
return "N" if values.size and bool(np.isfinite(values).any()) else "raw"
+465
View File
@@ -0,0 +1,465 @@
"""Acceptance for the replay view's policy, on its own.
``ReplayController`` is the replay view's state machine: what is open, what plays, and
what 「从视频重算」 does to the panels and to the run pipeline. It talks to the window
through the small ``ReplayHost`` interface and to the panels through their own, so both
can be stood in for. That is the point of the split: this policy used to be reachable
only by building a whole ``StudioMainWindow``, and now it is a unit.
The engine is faked here as well (the real one is covered by ``test_playback`` and the
window-level tests): these are the *rules*, not the decoder.
"""
from __future__ import annotations
import pytest
from orisys_app_shared.i18n import t
from orisys_studio import replay_controller
from orisys_studio.replay_controller import ReplayController
# --------------------------------------------------------------------- fakes
class _Signal:
def __init__(self) -> None:
self.slots: list = []
def connect(self, slot) -> None:
self.slots.append(slot)
class _Action:
def __init__(self, checked: bool = False) -> None:
self._checked = bool(checked)
self.enabled: bool | None = None
self.tooltips: list[str] = []
self.triggered = _Signal()
self.toggled = _Signal()
def isChecked(self) -> bool: # noqa: N802 - Qt's spelling
return self._checked
def setChecked(self, value: bool) -> None: # noqa: N802
self._checked = bool(value)
def setEnabled(self, value: bool) -> None: # noqa: N802
self.enabled = bool(value)
def setToolTip(self, text: str) -> None: # noqa: N802
self.tooltips.append(str(text))
def blockSignals(self, flag: bool) -> None: # noqa: N802
pass
class _Button:
def __init__(self) -> None:
self.checked = False
self.enabled = True
def isChecked(self) -> bool: # noqa: N802
return self.checked
def isEnabled(self) -> bool: # noqa: N802
return self.enabled
def setChecked(self, value: bool) -> None: # noqa: N802
self.checked = bool(value)
class _Toolbar:
def __init__(self, *, video_path: str = "", run_role: str = "playback") -> None:
self.btn_run = _Button()
self._video_path = video_path
self._run_role = run_role
self.run_checked: list[bool] = []
self.refreshed = 0
self.source_modes: list[str] = []
self.recompute_enabled: list[bool] = []
self.recompute_checked: list[bool] = []
def run_role(self) -> str:
return self._run_role
def video_path(self) -> str:
return self._video_path
def set_video_path(self, path: str) -> None:
self._video_path = str(path)
def set_run_checked(self, value: bool) -> None:
self.run_checked.append(bool(value))
def refresh_labels(self) -> None:
self.refreshed += 1
def set_source_mode(self, mode: str) -> None:
self.source_modes.append(str(mode))
def set_recompute_enabled(self, value: bool) -> None:
self.recompute_enabled.append(bool(value))
def set_recompute_checked(self, value: bool) -> None:
self.recompute_checked.append(bool(value))
class _Charts:
def __init__(self, *, unit: str = "N", recording: bool = False) -> None:
self._unit = unit
self._recording = recording
self.cursors: list[float] = []
self.followed = 0
self.shown = 0
self.cleared = 0
def unit(self) -> str:
return self._unit
def set_cursor(self, position: float) -> None:
self.cursors.append(float(position))
def set_recording_source(self, reader) -> None:
self._recording = True
def clear_recording_source(self) -> None:
self.cleared += 1
self._recording = False
def is_recording_source(self) -> bool:
return self._recording
def follow_latest(self) -> None:
self.followed += 1
def show_all(self) -> None:
self.shown += 1
class _ImagePanel:
def __init__(self) -> None:
self.kinds: list[str] = []
self.cleared: list[str] = []
def set_source_kind(self, kind: str) -> None:
self.kinds.append(str(kind))
def clear_preview(self, message: str) -> None:
self.cleared.append(str(message))
class _Mesh:
def __init__(self) -> None:
self.kinds: list[str] = []
self.reference_only = 0
def set_source_kind(self, kind: str) -> None:
self.kinds.append(str(kind))
def show_reference_only(self) -> None:
self.reference_only += 1
class _Host:
"""The window as the controller sees it: the host protocol, and nothing else."""
def __init__(self, *, view: str = "replay") -> None:
self.prefs: dict = {}
self.view = view
self._running = False
self._preview_open = False
self._session_active = False
self.notes: list[str] = []
self.frames: list = []
self.presets: list[str] = []
self.units: list[str] = []
self.source_modes: list[str] = []
self.started = 0
self.stopped: list[str] = []
self.run_updates = 0
self.rail_updates = 0
self.markers = 0
# reads
def _in_replay_view(self) -> bool:
return self.view == "replay"
def _current_source_kind(self) -> str:
return "recompute"
# writes
def _apply_view_preset(self, preset: str) -> None:
self.presets.append(preset)
self.view = preset
def _set_display_unit(self, unit: str) -> None:
self.units.append(unit)
def _set_source_mode(self, mode: str) -> None:
self.source_modes.append(mode)
def _show_image_frame(self, frame) -> None:
self.frames.append(frame)
def _drop_render_marker(self) -> None:
self.markers += 1
def _notify(self, text: str, ms: int = 0) -> None:
self.notes.append(str(text))
def _start_run(self) -> None:
self.started += 1
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
self.stopped.append(reason)
def _update_run_enabled(self) -> None:
self.run_updates += 1
def _refresh_status_rail(self) -> None:
self.rail_updates += 1
class _Reader:
def __init__(self, *, newtons=None) -> None:
self._newtons = newtons
def segments(self):
return []
def info(self):
return type("Info", (), {"newtons": self._newtons})()
class _Engine:
def __init__(
self,
*,
duration: float = 1.0,
position: float = 0.0,
has_video: bool = False,
loaded: bool = False,
video_only: bool = False,
) -> None:
self._duration = duration
self._position = position
self._has_video = has_video
self._loaded = loaded
self._video_only = video_only
self._reader = None
self._path = None
self._frame = None
self.positions: list[float] = []
self.opened: list = []
self.units: list[str] = []
self.accept = True
# state
def has_video(self) -> bool:
return self._has_video
def is_loaded(self) -> bool:
return self._loaded
def is_video_only(self) -> bool:
return self._video_only
def duration_s(self) -> float:
return self._duration
def position(self) -> float:
return self._position
def set_position(self, position: float) -> None:
self._position = float(position)
self.positions.append(float(position))
def reader(self):
return self._reader
def video_path(self):
return self._path
def video_frame_bgr(self):
return self._frame
def video_message(self) -> str:
return "no frame"
def set_unit(self, unit: str) -> None:
self.units.append(str(unit))
# entries
def open_video(self, path) -> bool:
self.opened.append(("video", path))
if self.accept:
self._has_video = True
self._video_only = True
self._path = path
return self.accept
def open_recording(self, path) -> bool:
self.opened.append(("recording", path))
if self.accept:
self._has_video = True
self._loaded = True
return self.accept
def _controller(**kwargs):
"""A controller wired to fakes; returns ``(controller, host, engine, parts)``."""
engine = kwargs.pop("engine", None) or _Engine()
host = kwargs.pop("host", None) or _Host()
toolbar = kwargs.pop("toolbar", None) or _Toolbar()
charts = kwargs.pop("charts", None) or _Charts()
panel = kwargs.pop("image_panel", None) or _ImagePanel()
mesh = kwargs.pop("mesh_panel", None) or _Mesh()
controller = ReplayController(
engine=engine,
host=host,
toolbar=toolbar,
charts=charts,
image_panel=panel,
mesh_panel=mesh,
)
actions = {
"open_video": _Action(),
"playback": _Action(),
"latest": _Action(),
"play": _Action(),
"recompute": _Action(),
}
controller.bind_actions(**actions)
return controller, host, engine, (toolbar, charts, panel, mesh, actions)
# --------------------------------------------------------------------- tests
def test_starting_playback_at_the_end_starts_from_the_top(qt_app):
engine = _Engine(duration=2.0, position=2.0, has_video=True)
controller, host, engine, parts = _controller(engine=engine)
toolbar = parts[0]
controller.start_playback()
assert engine.positions[0] == 0.0, "playing at the end did not rewind"
assert controller.is_playing()
assert toolbar.run_checked[-1] is True
controller.pause_playback()
def test_a_zero_length_recording_is_not_something_to_play(qt_app):
"""Silently un-checking the button is how "pressing 回放 does nothing" felt."""
engine = _Engine(duration=0.0, has_video=True)
controller, host, engine, parts = _controller(engine=engine)
toolbar = parts[0]
controller.start_playback()
assert not controller.is_playing()
assert toolbar.run_checked[-1] is False
assert t("playback.empty_recording") in host.notes
def test_the_playhead_follows_the_wall_clock_and_stops_at_the_end(qt_app, monkeypatch):
engine = _Engine(duration=0.4, position=0.0, has_video=True)
controller, host, engine, parts = _controller(engine=engine)
clock = {"now": 100.0}
monkeypatch.setattr(replay_controller.time, "perf_counter", lambda: clock["now"])
controller.start_playback()
clock["now"] = 100.1
controller.on_play_tick()
assert engine.position() == pytest.approx(0.1, abs=1e-6)
clock["now"] = 100.9 # past the end
controller.on_play_tick()
assert engine.position() == pytest.approx(0.4, abs=1e-6)
assert not controller.is_playing(), "the transport kept running past the end"
def test_the_transport_runs_a_recompute_pass_instead_of_playing(qt_app):
"""With the recompute switch on, play starts the *pipeline*, not the cursor."""
engine = _Engine(duration=1.0, has_video=True)
controller, host, engine, parts = _controller(engine=engine)
parts[4]["recompute"].setChecked(True)
controller.on_transport_toggled(True)
assert controller.recompute_running
assert host.started == 1
assert not controller.is_playing(), "the recompute pass also started playback"
controller.on_transport_toggled(False)
assert not controller.recompute_running
assert host.stopped == ["user_stop"]
def test_the_replay_policy_owns_the_image_only_in_the_replay_view(qt_app):
controller, host, engine, parts = _controller(
engine=_Engine(has_video=True, loaded=True)
)
assert controller.owns_image()
host.view = "live"
assert not controller.owns_image(), "a live view does not hand the pane to a replay"
host.view = "replay"
controller.recompute_running = True
assert not controller.owns_image(), "the pass is drawing, not the replay"
def test_a_bare_video_is_opened_as_a_video_and_refused_outside_the_view(qt_app, tmp_path):
bare = tmp_path / "clip.mp4"
bare.write_bytes(b"not really a video")
engine = _Engine()
controller, host, engine, parts = _controller(engine=engine)
toolbar = parts[0]
host.view = "live"
assert controller.load_video(bare) is False
assert host.notes[-1] == t("toolbar.replay_view_only")
assert engine.opened == [], "a video was opened from the live view"
host.view = "replay"
assert controller.load_video(bare) is True, "a playable clip was refused"
assert engine.opened == [("video", bare)]
assert host.source_modes[-1] == "video"
assert host.prefs["video_path"] == str(bare)
def test_the_charts_fall_back_to_raw_when_a_recording_has_no_newtons(qt_app):
engine = _Engine(loaded=True, has_video=True)
engine._reader = _Reader(newtons=None)
controller, host, engine, parts = _controller(
engine=engine, charts=_Charts(unit="N")
)
controller.apply_source(True)
assert engine.units[-1] == "raw"
assert host.units[-1] == "raw"
assert t("status.units_raw_fallback") in host.notes
engine._reader = _Reader(newtons=[1.0, 2.0, 3.0])
controller.apply_source(True)
assert engine.units[-1] == "N", "a calibrated recording must keep newtons"
def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
controller, host, engine, parts = _controller()
actions = parts[4]
host.view = "live"
controller.sync_actions()
assert [
actions[name].enabled for name in ("open_video", "playback", "latest")
] == [False, False, False]
# The recompute *button* on the rail is what this syncs (the menu entry follows the
# engine's state in ``refresh_state``).
assert parts[0].recompute_enabled[-1] is False
assert actions["open_video"].tooltips[-1] == t("toolbar.replay_view_only")
host.view = "replay"
engine._has_video = True
controller.sync_actions()
assert actions["open_video"].enabled is True
assert actions["open_video"].tooltips[-1] == ""
assert parts[0].recompute_enabled[-1] is True
+6 -4
View File
@@ -2046,10 +2046,12 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
) )
# Nothing running: it says so instead of doing nothing at all. # Nothing running: it says so instead of doing nothing at all.
# (The stubbed ``_stop_run`` above records the reason but does not clear the flag # (The pass is over, so the stop button is correctly disabled — the escape hatch is
# the real one clears, so the state is completed by hand here.) # therefore exercised the way Esc reaches it, through the controller.)
window._recompute_running = False assert not window.toolbar.btn_reset.isEnabled(), (
window.toolbar.btn_reset.click() "a stop is still offered after the pass was stopped"
)
window.replay_ctl.stop_activity()
QApplication.processEvents() QApplication.processEvents()
assert t("toolbar.nothing_to_stop") in window.statusBar().currentMessage(), ( assert t("toolbar.nothing_to_stop") in window.statusBar().currentMessage(), (
window.statusBar().currentMessage() window.statusBar().currentMessage()