Make the cached analysis views reachable over a recording

Replay moved the cursor onto the result's timeline, so the recording's own
picture was not offered at all and ``use_recording()`` had no caller. Adopting a
result now keeps the cursor on the recording, opens the panel on a derived view,
and serves scrubs, playback and display changes from the cache (Part B §9).
Verified: apps/studio 455 items / 0 failed / 18 skipped, shared 427 / 0 / 1,
autocal 358, ui_check 628 checks / 0 failures, lint gate clean.
This commit is contained in:
ykcheungab
2026-10-04 16:04:02 +08:00
parent fbf32b5303
commit 1aa454311f
5 changed files with 649 additions and 72 deletions
+507
View File
@@ -0,0 +1,507 @@
"""Part B's wiring: the cached analysis views over a recording, in the running app.
The result contract ([replay-analysis.md](../docs/replay-analysis.md)), the playback
contract ([replay-analysis-part-b.md](../docs/replay-analysis-part-b.md)) and the panel
contract ([replay-panel.md](../docs/replay-panel.md)) all describe one state: a recording
is open, the cursor is on the recording's own signal time, and the arrows and the depth
map are served from the stored analysis result while the operator scrubs and plays — with
the recording's own picture one step away in the mode selector, and the recompute switch
left as the explicit "run the chain over this footage again" action.
What was missing was the wiring that reaches it. ``ReplayEngine.use_recording()`` had no
caller, so analysis-on-open moved the cursor onto the *result's* timeline instead: the
derived views were drawn, but the recording's own picture was not offered at all, and the
cursor could not leave the frames the result covers. These tests drive the window's own
「打开录像」 action over a recording whose result is already stored beside it — the result
is generated here into the recording's own cache, so the app *reuses* it and opens no SDK
chain — and assert on the picture the panel actually shows, not on a state a harness
built.
The recording is copied before use: a pass writes its result into the recording's own
directory, and nothing may write into ``dist/``.
"""
from __future__ import annotations
import shutil
import time
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr, flow_colormap
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
DERIVED_MODES,
RECORDED,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_app_shared.scene3d.visual_policy import (
select_flow_samples,
)
from orisys_studio.replay import MODE_RECORDING
from PySide6.QtWidgets import QApplication
REPO = Path(__file__).resolve().parents[3]
STUDIO = REPO / "dist" / "OrisysStudio"
SOURCE_RECORDING = (
STUDIO
/ "outputs"
/ "recordings"
/ "20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011"
)
#: What makes a directory a recording for the application, and all a replay reads.
RECORDING_FILES = (
"frames.csv",
"forces.csv",
"events.csv",
"session.json",
"raw_0001.mp4",
"preroll_0001.mp4",
)
def _require_inputs() -> None:
missing = [
str(SOURCE_RECORDING / name)
for name in ("frames.csv", "raw_0001.mp4", "session.json")
if not (SOURCE_RECORDING / name).exists()
]
if missing:
pytest.skip(
"the replay wiring tests need the evidence recording: " + ", ".join(missing)
)
pytest.importorskip("orisys", reason="the analysis pass drives the SDK chain")
def _copy_recording(destination: Path) -> Path:
"""The evidence recording, copied: nothing may write into ``dist/``."""
destination.mkdir(parents=True, exist_ok=True)
for name in RECORDING_FILES:
source = SOURCE_RECORDING / name
if source.exists():
shutil.copy2(source, destination / name)
return destination
def _run_the_pass(recording: Path):
"""One real pass, with the chain the *application* derives from the recording."""
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
from orisys_app_shared.record import RecordingReader
reader = RecordingReader(recording)
try:
chain = chain_spec_for(reader.session())
finally:
reader.close()
return AnalysisService(recording, chain=chain).run()
@pytest.fixture(scope="module")
def stored_recording(tmp_path_factory) -> Path:
"""A copy of the evidence recording whose own cache already holds a result."""
_require_inputs()
recording = _copy_recording(tmp_path_factory.mktemp("replay-wiring") / "recording")
outcome = _run_the_pass(recording)
assert outcome.complete, outcome.reason
assert outcome.frames_written > 0
return recording
@pytest.fixture
def window(make_window, qt_app):
"""The application, in the replay view, as the operator meets it."""
window = make_window(sensor_type="box", source_mode="camera")
window.resize(1440, 880)
window.show()
QApplication.processEvents()
window.brand_bar.btn_playback.click()
QApplication.processEvents()
assert window._in_replay_view(), "the window did not switch to the replay view"
return window
def _open(window, recording: Path, monkeypatch) -> None:
"""Press the window's own 「打开录像」 entry, with the file dialog answered for it."""
import orisys_studio.replay_controller as replay_controller
video = Path(recording) / "raw_0001.mp4"
monkeypatch.setattr(
replay_controller.QFileDialog,
"getOpenFileName",
lambda *args, **kwargs: (str(video), ""),
)
action = window.replay_ctl._actions.playback
assert action.isEnabled(), "the replay entries are not offered in the replay view"
action.trigger()
QApplication.processEvents()
assert window.replay.has_video(), "the recording did not open"
def _sink_frames(window, monkeypatch) -> list:
"""Every frame handed to the window's single 2D sink, in order."""
captured: list = []
original = window._show_image_frame
def spy(frame):
captured.append(frame)
return original(frame)
monkeypatch.setattr(window, "_show_image_frame", spy)
return captured
def _panel_bgr(window) -> np.ndarray:
"""The 2D panel's picture, in BGR, from the end of the real display path."""
image = window.arrow_panel._source_image
assert image is not None and not image.isNull(), "the 2D panel shows no picture"
width, height = image.width(), image.height()
rows = np.frombuffer(bytes(image.constBits()), dtype=np.uint8)
rows = rows.reshape(height, image.bytesPerLine())
return rows[:, : width * 3].reshape(height, width, 3)[:, :, ::-1].copy()
def _expected_depth(window, frame) -> np.ndarray:
"""A stored frame's depth view, drawn the way the window's own sink draws it."""
return depth_map_to_bgr(
frame.depth,
flow_colormap(window._theme),
value_range=window._depth_range(),
invalid_mask=window._domain_mask.mask,
)
def _set_mode(window, mode: str) -> None:
index = window.arrow_panel.view_combo.findData(mode)
assert index >= 0, f"the mode {mode} is not offered: {window.arrow_panel.view_modes()}"
window.arrow_panel.view_combo.setCurrentIndex(index)
QApplication.processEvents()
assert window.arrow_panel.view_mode() == mode
def _pump(seconds: float) -> None:
"""Let real time pass so the playback timer fires, as it does in the app."""
deadline = time.perf_counter() + float(seconds)
while time.perf_counter() < deadline:
QApplication.processEvents()
time.sleep(0.01)
def _cells(rows, cols) -> set:
return {(int(row), int(col)) for row, col in zip(np.asarray(rows), np.asarray(cols))}
def _active_cells(samples) -> set:
return {
(int(row), int(col))
for row, col, active in zip(
np.asarray(samples.rows), np.asarray(samples.cols), np.asarray(samples.active)
)
if bool(active)
}
def test_the_operator_sees_the_cached_views_over_the_recording(
stored_recording, window, monkeypatch
):
"""The state the contracts describe, reached the way an operator reaches it.
A recording with a result beside it opens on the **recording's** timeline (the result
is a cache behind the cursor, not a second timeline), the panel offers the recording's
own picture *and* the three derived views, and what is on screen is the stored frame —
not the camera, and not a placeholder.
"""
captured = _sink_frames(window, monkeypatch)
_open(window, stored_recording, monkeypatch)
assert window.replay.mode() == MODE_RECORDING, (
"the cursor left the recording's own timeline; the result is a cache behind it"
)
session = window.replay_ctl.available_result()
assert session is not None, "the stored result beside the recording was not adopted"
assert not window.replay_ctl.recompute_active(), (
"the cache was only reachable by turning the recompute switch on"
)
assert window.analysis_progress.state() == "reused", (
"a pass ran over a recording that already had a result"
)
assert window.arrow_panel.view_modes() == (
RECORDED,
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
), "the recording's own picture must stay one step away from the derived views"
assert window.arrow_panel.view_mode() in DERIVED_MODES, (
"the operator was left on the camera picture although a result is served"
)
assert window.arrow_panel.note_label.text() == "", (
"the panel says there is nothing to draw while it is drawing a result"
)
# The arrow view is the stored measurement, at the cursor's own frame.
frames = list(session.frames())
active_frame = next(frame for frame in frames if frame.active_count > 0)
window.replay.set_position(session.time_of(active_frame.frame_id))
QApplication.processEvents()
assert captured, "nothing reached the 2D sink"
drawn = captured[-1]
assert drawn.frame_id == active_frame.frame_id
assert drawn.result_available is True
stored_cells = _cells(active_frame.samples.rows, active_frame.samples.cols)
field = np.asarray(drawn.flow)
finite = np.argwhere(np.isfinite(field[..., 0]))
assert _cells(finite[:, 0], finite[:, 1]) == stored_cells, (
"the recovered flow field carries values outside the stored candidate locations"
)
# The height last: the shared selector re-reads the same cells, in its own order, and
# comes to the same activity verdict as the pass stored.
reselected = select_flow_samples(field, drawn.valid_mask, session.display_policy)
assert _cells(reselected.rows, reselected.cols) == stored_cells
assert _active_cells(reselected) == _active_cells(active_frame.samples)
arrows_bgr = _panel_bgr(window)
raw_here = window.replay.video_frame_bgr()
assert raw_here is not None
assert not np.array_equal(arrows_bgr, np.asarray(raw_here)), (
"the arrow view is the recording's raw picture"
)
# The depth view is the stored map, drawn through the window's own backend. This
# result's map is exactly zero on every frame it holds — the box chain's depth is
# flat on this footage, which the panel has its own sentence for — and it is still a
# stored array of the result's own shape, so the pixels are checked against it rather
# than against a flat placeholder.
_set_mode(window, DEPTH)
target = active_frame
assert target.depth is not None and target.depth_nonzero == 0
QApplication.processEvents()
drawn = captured[-1]
assert drawn.frame_id == target.frame_id
assert np.array_equal(
np.asarray(drawn.depth), np.asarray(target.depth)
), "the depth view is not the stored map"
shown = _panel_bgr(window)
expected = _expected_depth(window, target)
assert expected is not None
assert shown.shape == expected.shape
assert np.array_equal(shown, expected), (
"the depth view's pixels are not depth_map_to_bgr of the stored frame"
)
assert not np.array_equal(shown, arrows_bgr), "the depth view drew the arrow view"
assert t("panels.depth_empty", pixels=int(target.depth.size)) in (
window.arrow_panel.depth_scale_label.text()
), "an exactly-zero depth map is not stated as such"
# …and the recording's own picture is still reachable, at the same cursor.
_set_mode(window, RECORDED)
raw = window.replay.video_frame_bgr()
assert raw is not None, "the recording's own frame could not be decoded"
assert np.array_equal(_panel_bgr(window), np.asarray(raw)), (
"the recorded view is not the recording's own picture"
)
assert window.arrow_panel.note_label.text() == ""
assert window.replay.mode() == MODE_RECORDING
def test_playing_forward_and_seeking_draw_the_same_cached_frame(
stored_recording, window, monkeypatch
):
"""A seek is the frame playing forward passes through — on the operator's path."""
captured = _sink_frames(window, monkeypatch)
_open(window, stored_recording, monkeypatch)
session = window.replay_ctl.available_result()
assert session is not None
_set_mode(window, DEPTH)
ids = [int(value) for value in session.frame_ids]
target_id = ids[len(ids) // 4]
moment = session.time_of(target_id)
assert moment is not None
# Play forward from the top, a tick's worth of cursor at a time, ending on the
# target instant itself.
positions = [step / 240.0 for step in range(int(moment * 240.0) + 1)]
positions.append(moment)
for position in positions:
window.replay.set_position(position)
played = _panel_bgr(window)
played_id = captured[-1].frame_id
# …then seek straight to the same instant.
window.replay.set_position(moment)
QApplication.processEvents()
assert captured[-1].frame_id == played_id == target_id
assert np.array_equal(played, _panel_bgr(window)), (
"a seek drew a different frame from playing forward to the same position"
)
# Stepping one stored frame on moves to that frame's own picture.
later = ids[ids.index(target_id) + 1]
window.replay.set_position(session.time_of(later))
QApplication.processEvents()
assert captured[-1].frame_id == later
assert np.array_equal(
np.asarray(captured[-1].depth), np.asarray(session.frame(later).depth)
)
def test_playback_draws_the_cached_frame_not_the_video(
stored_recording, window, monkeypatch
):
"""Advance the real playback timer and check every drawn frame against the cache."""
captured = _sink_frames(window, monkeypatch)
_open(window, stored_recording, monkeypatch)
session = window.replay_ctl.available_result()
assert session is not None
_set_mode(window, DEPTH)
window.replay.set_position(0.0)
QApplication.processEvents()
captured.clear()
window.replay_ctl.start_playback()
assert window.replay_ctl.is_playing(), "playback did not start"
_pump(0.4)
position = window.replay.position()
assert position > 0.0, "the playhead did not advance"
assert captured, "playback drew nothing"
assert position <= session.duration_s(), "the playhead left the recording's timeline"
expected_id = session.frame_id_at(position)
assert expected_id is not None
for frame in captured:
assert frame.depth is not None, "a frame drawn during playback carries no depth"
assert frame.result_available is True
assert session.has(int(frame.frame_id)), (
f"playback drew frame {frame.frame_id}, which the result does not hold"
)
# Pausing leaves the frame the cursor is on, and it is the cached one.
window.replay_ctl.pause_playback()
assert not window.replay_ctl.is_playing()
assert captured[-1].frame_id == expected_id
expected = _expected_depth(window, session.frame(expected_id))
assert expected is not None
assert np.array_equal(_panel_bgr(window), expected), (
"the paused frame is not the stored frame for the cursor"
)
def test_a_finished_pass_puts_the_cache_on_screen(tmp_path, window, monkeypatch):
"""The other trigger: the pass completes and the panel serves what it stored.
Nothing is pre-placed here, so the window runs the pass over the copy (a box chain:
a couple of seconds for this recording) and the *completion* is what has to leave the
derived view on screen — with the cursor still on the recording.
"""
recording = _copy_recording(tmp_path / "recording")
_sink_frames(window, monkeypatch)
_open(window, recording, monkeypatch)
assert window.replay_ctl.wait_for_analysis(180.0), "the pass did not finish"
QApplication.processEvents()
assert window.replay_ctl.analysis_error() == "", (
f"the pass left no usable result: {window.replay_ctl.analysis_error()}"
)
assert window.analysis_progress.state() == "complete"
session = window.replay_ctl.available_result()
assert session is not None, "the completed result was not adopted"
assert window.replay.mode() == MODE_RECORDING, (
"a finished pass moved the cursor off the recording's own timeline"
)
assert window.arrow_panel.view_mode() in DERIVED_MODES, (
"the operator was left on the camera picture after the pass finished"
)
assert not window.replay_ctl.recompute_active()
frame_id = session.frame_id_at(window.replay.position())
assert frame_id is not None
_set_mode(window, DEPTH)
expected = _expected_depth(window, session.frame(frame_id))
assert expected is not None
assert np.array_equal(_panel_bgr(window), expected), (
"the finished result is not what the panel draws"
)
def test_the_mode_selector_is_the_trigger_for_a_derived_view(
stored_recording, window, monkeypatch
):
"""On the recording's timeline, choosing a derived view serves it from the cache."""
captured = _sink_frames(window, monkeypatch)
_open(window, stored_recording, monkeypatch)
session = window.replay_ctl.available_result()
assert session is not None
_set_mode(window, RECORDED)
position = session.time_of(int(session.frame_ids[len(session.frame_ids) // 2]))
assert position is not None
window.replay.set_position(position)
QApplication.processEvents()
assert window.replay.mode() == MODE_RECORDING
assert captured[-1].depth is None, "the recorded view drew a stored frame"
captured.clear()
_set_mode(window, DEPTH)
assert captured, "choosing a derived view drew nothing"
assert captured[-1].depth is not None, (
"a view that needs derived data drew the recording's raw picture"
)
assert captured[-1].frame_id == session.frame_id_at(position)
assert window.replay.mode() == MODE_RECORDING, (
"choosing a view moved the cursor's timeline"
)
def test_the_display_settings_still_redraw_from_the_cache(
stored_recording, window, monkeypatch
):
"""A threshold change is a read: it redraws the stored frame and writes nothing."""
from orisys_app_shared.analysis.cache import scan_results
_open(window, stored_recording, monkeypatch)
session = window.replay_ctl.available_result()
assert session is not None
_set_mode(window, ARROW_PURE)
cache_root = Path(stored_recording) / "analysis"
before = {entry.directory for entry in scan_results(cache_root)}
captured = _sink_frames(window, monkeypatch)
window.replay_ctl.on_display_settings(DisplaySettings(flow_on_px=0.9))
QApplication.processEvents()
assert session.threshold() == pytest.approx(0.9), (
"the display threshold never reached the session"
)
assert captured, "a display change did not redraw the frame"
assert captured[-1].frame_id == session.frame_id_at(window.replay.position())
assert {entry.directory for entry in scan_results(cache_root)} == before, (
"a display change wrote an analysis result"
)
def test_a_setting_that_moves_the_grid_is_still_refused(
stored_recording, window, monkeypatch
):
"""The stored candidate locations cannot be re-gridded; the frame is drawn anyway."""
from orisys_app_shared.analysis.cache import scan_results
_open(window, stored_recording, monkeypatch)
session = window.replay_ctl.available_result()
assert session is not None
_set_mode(window, ARROW_PURE)
before_threshold = session.threshold()
cache_root = Path(stored_recording) / "analysis"
before = {entry.directory for entry in scan_results(cache_root)}
captured = _sink_frames(window, monkeypatch)
window.replay_ctl.on_display_settings(DisplaySettings(max_arrows=50))
QApplication.processEvents()
assert session.threshold() == pytest.approx(before_threshold), (
"a refused policy changed the session"
)
assert captured, "the frame was not redrawn from the result's own grid"
assert captured[-1].frame_id == session.frame_id_at(window.replay.position())
assert {entry.directory for entry in scan_results(cache_root)} == before