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
@@ -29,6 +29,7 @@ from typing import Any, Protocol
import numpy as np
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
DERIVED_MODES,
SOURCE_ANALYSIS,
SOURCE_REPLAY,
@@ -45,6 +46,7 @@ from orisys_studio.replay_analysis import (
AnalysisSession,
ResultLibrary,
ResultPolicyError,
policy_divergence,
)
logger = logging.getLogger(__name__)
@@ -620,7 +622,13 @@ class ReplayController(QObject):
# ---------------------------------------------------------------- analysis
def analysis_session(self) -> AnalysisSession | None:
"""The stored result the timeline is reading, if any."""
"""Whatever result is open: the cursor's own, else the library's.
Not the question a view asks — that is :meth:`available_result`, which answers "is
there a measurement for *this recording*" and is what the app's timeline (always
the recording's) is served from. This one is the raw pair, kept for a caller that
wants the session itself.
"""
session = self._engine.analysis()
if session is not None:
return session
@@ -658,19 +666,25 @@ class ReplayController(QObject):
``test_replay_analysis`` that the result directory set is unchanged is a test of
this method, not of the panel.
It is asked of ``available_result``, not of ``analysis_active``: a result is a
property of the recording, and the cursor is normally on the recording's own
timeline while the arrows and the depth map are drawn from it. Gating on the
timeline instead would make a threshold change stop mattering in exactly the
state this view is served in.
A policy that moves the sample *grid* cannot be served by the stored candidate
locations; :meth:`AnalysisSession.serve_policy` says so, the message names the
key, and the frame is still redrawn from the result's own grid, because
locations; :meth:`AnalysisSession.serve_policy` refuses it, the message names the
keys, and the frame is still redrawn from the result's own grid, because
re-gridding it would draw samples nothing measured.
"""
session = self.analysis_session()
if session is None or not self.analysis_active():
session = self.available_result()
if session is None:
return
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
policy = policy_for_settings(settings)
moved = not session.serve_policy(policy)
if moved:
if not session.serve_policy(policy):
moved = policy_divergence(session.stored_policy(), policy)
self._host._notify(t("playback.analysis_grid_mismatch", keys="·".join(moved)))
self._engine.redraw()
@@ -765,15 +779,54 @@ class ReplayController(QObject):
return True
def _adopt_session(self, session: AnalysisSession) -> None:
"""One timeline: the cursor, the charts and both figures read this result."""
"""One timeline, one cache behind it: the cursor, the charts and both figures.
**The engine stays on the recording.** The stored result is adopted *behind* the
cursor — :meth:`available_result` answers "is there a measurement for this
recording?" — and ``ReplayEngine.use_recording()`` is what keeps the cursor's own
timeline the recording's: its signal time, its own frame ids, and its whole span,
so a position the pass did not cover is still a position the operator can be at.
Moving the cursor onto the result instead (``use_analysis``) is what made the
recording's own picture unreachable: with the cursor on a result, the recorded
view has nothing to decode.
"""
self.results.open_session(session)
self._engine.use_analysis(session)
self._engine.use_recording()
self._charts.set_recording_source(self._engine.reader())
self._image_panel.set_source_kind("analysis")
# The timeline is the recording's, so the panel is told "replay" — the same word
# ``apply_source`` uses for an open recording. It used to be told "analysis",
# which is not a 2D source kind at all: ``ArrowPanel`` knows live/recompute/replay
# and coerced it, so a stored result was worded as the live pipeline.
self._image_panel.set_source_kind("replay")
self._mesh_panel.set_source_kind("analysis")
if self._actions is not None:
self._actions.recompute.setEnabled(True)
self.render_frame()
# A result just became readable, so it is what the operator is shown — unless a
# derived view was already on screen (a depth view chosen while the pass ran).
self.serve_stored_view()
def serve_stored_view(self) -> bool:
"""Put the cached view on screen when a result becomes readable.
The trigger for "draw the cache without being asked": a stored result is adopted
— on opening a recording that has one, or when a pass commits — and the panel is
showing the recording's own picture, so the operator would otherwise be left
looking at the camera while the measurement sits on disk. The recording's own
picture stays in the same selector, one step away, and the recompute switch is
not involved.
Answers whether the selection moved. A derived selection is left alone: the mode
the operator chose is not this method's to override.
"""
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
if current in DERIVED_MODES:
return False
# The plan for the frame just drawn offers the derived modes (the frame carries
# ``result_available``), so this selection is one the panel can honour.
self._image_panel.set_view_mode(ARROW_PURE)
self.render_frame()
return True
def _display_policy(self) -> Any:
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
@@ -907,6 +960,10 @@ class ReplayController(QObject):
map can be drawn at all. The session the library holds is checked against the
recording that is open, so a previous recording's result cannot answer for this
one.
This is the normal state of a replay, not a fallback: the cursor stays on the
recording's own timeline (``_adopt_session``) and the result is the cache behind
it, so the answers and the view modes come from here.
"""
session = self._engine.analysis()
if session is not None:
@@ -950,12 +1007,13 @@ class ReplayController(QObject):
def render_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor.
One method for both timeline modes, because it is one cursor: in analysis mode
the picture is the stored frame's own flow field and depth map (the pass's
candidate locations, on the domain grid), and in recording mode it is the
decoded video frame. The window's single 2D sink draws both through the *same*
backend, so the arrows, the idle dots, the colour range and the domain boundary
behave identically in the two modes.
One method for both timeline modes, because it is one cursor: with the cursor on
a stored result (``analysis_active``) the picture is the stored frame's own flow
field and depth map, and with the cursor on the recording's own timeline it is the
decoded video frame — unless the *view* the operator asked for is a measurement,
in which case it is read from the result for the same cursor. The window's single
2D sink draws all of them through the *same* backend, so the arrows, the idle
dots, the colour range and the domain boundary behave identically.
**A mode that needs derived data is served from the result, never faked.** The
arrows and the depth map are the pass's measurements; with a result in hand they