829d2c1b53
箭头/采集图像与 live 的 processed_frame 对齐;deliver_measurement 先画 2D 再提交同一包给 3D。已用 overlay、scene3d 与 replay 相关测试验证。
632 lines
20 KiB
Python
632 lines
20 KiB
Python
"""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_flag = False
|
|
self._preview_open_flag = False
|
|
self._session_active_flag = False
|
|
self.notes: list[str] = []
|
|
self.frames: list = []
|
|
self.analysis_packets: 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
|
|
#: What the setup prompt answers for a bare video: a workspace directory, or
|
|
#: ``None`` for "the operator declined". The window builds a real dialog; a fake
|
|
#: has no dialog, so the answer is the field a test sets.
|
|
self.workspace = None
|
|
self.workspace_requests: list = []
|
|
self.workspace_kwargs: dict = {}
|
|
self.override_requests: list = []
|
|
|
|
# AcquireGate reads (legacy private names kept as writeable fields for tests)
|
|
@property
|
|
def running(self) -> bool:
|
|
return self._running_flag
|
|
|
|
@running.setter
|
|
def running(self, value: bool) -> None:
|
|
self._running_flag = bool(value)
|
|
|
|
@property
|
|
def preview_open(self) -> bool:
|
|
return self._preview_open_flag
|
|
|
|
@preview_open.setter
|
|
def preview_open(self, value: bool) -> None:
|
|
self._preview_open_flag = bool(value)
|
|
|
|
@property
|
|
def session_active(self) -> bool:
|
|
return self._session_active_flag
|
|
|
|
@session_active.setter
|
|
def session_active(self, value: bool) -> None:
|
|
self._session_active_flag = bool(value)
|
|
|
|
# compatibility for tests that still assign ``_running`` / friends
|
|
@property
|
|
def _running(self) -> bool:
|
|
return self._running_flag
|
|
|
|
@_running.setter
|
|
def _running(self, value: bool) -> None:
|
|
self._running_flag = bool(value)
|
|
|
|
@property
|
|
def _preview_open(self) -> bool:
|
|
return self._preview_open_flag
|
|
|
|
@_preview_open.setter
|
|
def _preview_open(self, value: bool) -> None:
|
|
self._preview_open_flag = bool(value)
|
|
|
|
@property
|
|
def _session_active(self) -> bool:
|
|
return self._session_active_flag
|
|
|
|
@_session_active.setter
|
|
def _session_active(self, value: bool) -> None:
|
|
self._session_active_flag = bool(value)
|
|
|
|
def current_source_kind(self) -> str:
|
|
return "recompute"
|
|
|
|
def bare_video_workspace(self, video, **kwargs):
|
|
self.workspace_requests.append(video)
|
|
self.workspace_kwargs = dict(kwargs)
|
|
return self.workspace
|
|
|
|
def recording_analysis_overrides(self, recording):
|
|
self.override_requests.append(recording)
|
|
return False
|
|
|
|
def analysis_service(self, recording, cancelled=None, *, on_progress=None):
|
|
raise AssertionError("the test did not provide an analysis service")
|
|
|
|
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_frame(self, frame) -> None:
|
|
self.frames.append(frame)
|
|
|
|
def show_analysis_packet(self, packet) -> None:
|
|
self.deliver_measurement(packet, advance="flush", paint_2d=False)
|
|
|
|
def deliver_measurement(
|
|
self,
|
|
packet,
|
|
*,
|
|
advance: str = "tick",
|
|
arrow_policy=None,
|
|
image_source: str | None = None,
|
|
paint_2d: bool = True,
|
|
) -> None:
|
|
if paint_2d:
|
|
from orisys_app_shared.acquisition.image_backend import ImageFrame
|
|
|
|
self.frames.append(
|
|
ImageFrame(
|
|
getattr(packet, "processed_frame", None),
|
|
flow=getattr(packet, "flow", None),
|
|
depth=getattr(packet, "depth", None),
|
|
valid_mask=getattr(packet, "valid_mask", None),
|
|
source=image_source or "replay",
|
|
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
|
arrow_policy=arrow_policy,
|
|
result_available=True,
|
|
)
|
|
)
|
|
self.analysis_packets.append(packet)
|
|
|
|
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_acquire(self) -> None:
|
|
self.started += 1
|
|
|
|
def stop_acquire(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
|
|
|
|
def persist_prefs(self) -> None:
|
|
pass
|
|
|
|
|
|
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 directory(self):
|
|
"""No recording directory: a stub opens a video, not a recorded session."""
|
|
return None
|
|
|
|
def video_path(self):
|
|
return self._path
|
|
|
|
def video_frame_bgr(self):
|
|
return self._frame
|
|
|
|
def video_message(self) -> str:
|
|
return "no frame"
|
|
|
|
def analysis(self):
|
|
"""No stored result in this stub: the derived views fall back to the video."""
|
|
return None
|
|
|
|
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 _wire_replay(controller, host):
|
|
"""Signals replace private start/stop/view calls on the shell."""
|
|
controller.view_requested.connect(
|
|
lambda preset: (
|
|
host.presets.append(preset),
|
|
setattr(host, "view", preset),
|
|
controller.set_active_view(preset),
|
|
)
|
|
)
|
|
controller.acquire_requested.connect(host.start_acquire)
|
|
controller.acquire_stop_requested.connect(
|
|
lambda reason: host.stop_acquire(reason=reason)
|
|
)
|
|
controller.set_active_view(host.view)
|
|
return controller
|
|
|
|
|
|
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 = _wire_replay(
|
|
ReplayController(
|
|
engine=engine,
|
|
host=host,
|
|
toolbar=toolbar,
|
|
charts=charts,
|
|
image_panel=panel,
|
|
mesh_panel=mesh,
|
|
),
|
|
host,
|
|
)
|
|
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_plays_even_when_the_recompute_switch_is_on(qt_app):
|
|
"""Analysis is a separate pass; the rail's play button always moves 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.is_playing(), "play must scrub the recording"
|
|
assert host.started == 0, "play must not open an acquisition chain"
|
|
|
|
controller.on_transport_toggled(False)
|
|
assert not controller.is_playing()
|
|
assert host.stopped == []
|
|
|
|
|
|
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()
|
|
|
|
controller.set_active_view("live")
|
|
assert not controller.owns_image(), "a live view does not hand the pane to a replay"
|
|
|
|
controller.set_active_view("replay")
|
|
# An analysis pass is drawn by this controller too, so ownership stays with replay.
|
|
assert controller.owns_image()
|
|
|
|
|
|
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)
|
|
|
|
controller.set_active_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"
|
|
|
|
controller.set_active_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)
|
|
# Opening a bare video asks how to analyse it — once, after it is on screen. The answer
|
|
# here is "declined", and that must leave today's behaviour intact: the video plays,
|
|
# and no workspace is claimed as this timeline's input.
|
|
assert host.workspace_requests == [bare], "the setup prompt was never asked"
|
|
assert controller.recording_directory() is None
|
|
assert controller.result_state()[1] == t("playback.analysis_setup_declined")
|
|
|
|
|
|
def test_a_bare_video_uses_the_workspace_the_prompt_answers_with(qt_app, tmp_path):
|
|
"""The generated workspace *is* the recording directory for everything downstream."""
|
|
bare = tmp_path / "clip.mp4"
|
|
bare.write_bytes(b"not really a video")
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
(workspace / "session.json").write_text("{}", encoding="utf-8")
|
|
host = _Host()
|
|
host.workspace = workspace
|
|
controller, host, engine, parts = _controller(host=host)
|
|
|
|
assert controller.load_video(bare) is True
|
|
assert host.workspace_requests == [bare]
|
|
assert controller.recording_directory() == workspace, (
|
|
"the pass would have read the video's own folder instead of the workspace"
|
|
)
|
|
|
|
# Dropping what is open must drop the claim with it: the workspace is keyed by the
|
|
# video, so reopening finds it, but it is not this timeline's input any more.
|
|
controller.on_recording_changed(False)
|
|
assert controller.recording_directory() is None
|
|
|
|
|
|
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]
|
|
|
|
controller.set_active_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")
|
|
|
|
controller.set_active_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
|
|
|
|
|
|
def test_analyze_opens_a_remembered_video_that_is_not_loaded_yet(qt_app, tmp_path):
|
|
"""A toolbar path alone used to notify ``playback.no_video`` and skip the dialog.
|
|
|
|
The rail can show a file name from a previous session while the engine has not opened
|
|
anything. 「分析视频」 must call ``load_video`` so the setup prompt appears — the same
|
|
path as picking the file again in the explorer.
|
|
"""
|
|
bare = tmp_path / "1.mp4"
|
|
bare.write_bytes(b"not really a video")
|
|
engine = _Engine(has_video=False, video_only=False)
|
|
toolbar = _Toolbar(video_path=str(bare))
|
|
controller, host, engine, parts = _controller(engine=engine, toolbar=toolbar)
|
|
|
|
controller.on_recompute_toggled(True)
|
|
assert engine.opened == [("video", bare)], "the remembered file was not opened"
|
|
assert host.workspace_requests == [bare], "the setup dialog was not shown"
|
|
assert t("playback.no_video") not in host.notes
|