Serve replay playback from a cached analysis result

Replay re-ran the SDK chain on every seek; Part A's pass stores the flow
samples once. Opening a recording now reuses a compatible result or runs the
pass behind a progress/cancel surface, and one cursor with two modes
(recorded CSV, analysed) feeds the charts, the 2D canvas and the 3D pane by
frame id. A threshold, colour or idle-dot change redraws from the stored
frames and writes nothing; a cancelled pass is reported and never played.

Verified: apps/studio/tests 440 items / 0 failed / 18 skipped (15 new, no
existing assertion changed); shared 417 / 0 / 1; autocal 358.
This commit is contained in:
ykcheungab
2026-10-04 13:38:03 +08:00
parent b6f6a06851
commit cf49b0836e
11 changed files with 2731 additions and 29 deletions
@@ -0,0 +1,201 @@
"""Running the analysis pass off the GUI thread, with progress and a cancel.
Part A's :class:`~orisys_app_shared.analysis.service.AnalysisService` is a blocking
call that costs about half a wall-second per video-second. This module is the thread
boundary around it: the pass runs on a plain Python thread, its ``Progress`` callback
is coalesced and re-emitted through Qt signals (delivered to the GUI thread by Qt's
queued connections), and ``cancel()`` sets the service's own cancellation event.
**A plain thread, not a ``QThread``.** The pass holds no Qt and no widgets, and the
signals are emitted through a :class:`~PySide6.QtCore.QObject` that stays on the GUI
thread. Moving a worker object onto a ``QThread`` would buy nothing here and cost a
C++ lifetime to get right on teardown — including the teardown that happens while a
pass is still running.
**In this process, never concurrently.** The one global side effect is stated by the
service itself and respected here: constructing a box ``Sensor`` writes about 2 MB of
``config/compound_eye.png`` into the *process working directory*, so that open owns the
CWD under a process-wide lock for its duration. This module therefore never changes the
working directory, and it refuses to start a second pass while one is running; the
controller additionally refuses to start while a session or a recompute pass is live
(see ``ReplayController.on_analysis_requested``).
"""
from __future__ import annotations
import logging
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from orisys_app_shared.analysis.service import AnalysisOutcome, Progress
from PySide6.QtCore import QObject, Signal
logger = logging.getLogger(__name__)
#: How often the pass's per-frame progress may reach the GUI. The pass writes one frame
#: per call and a ten-minute recording holds ~18,000 of them; a signal per frame would
#: spend more time in the event loop than in the analysis.
PROGRESS_INTERVAL_S = 0.1
@dataclass(frozen=True)
class AnalysisReport:
"""What one pass did, in the terms the progress surface shows."""
outcome: AnalysisOutcome | None
cancelled: bool
failed: bool
reason: str
frames_written: int
frames_expected: int
elapsed_s: float
directory: Path | None
@property
def complete(self) -> bool:
return bool(
self.outcome is not None
and not self.cancelled
and not self.failed
and self.outcome.complete
)
def report_from(outcome: AnalysisOutcome | None, *, cancelled: bool) -> AnalysisReport:
"""Turn a service outcome into the surface's report."""
if outcome is None:
return AnalysisReport(
outcome=None,
cancelled=bool(cancelled),
failed=not cancelled,
reason="cancelled" if cancelled else "no_result",
frames_written=0,
frames_expected=0,
elapsed_s=0.0,
directory=None,
)
return AnalysisReport(
outcome=outcome,
cancelled=bool(cancelled),
failed=not bool(outcome.complete) and not cancelled,
reason=str(outcome.reason or ""),
frames_written=int(outcome.frames_written),
frames_expected=int(outcome.frames_expected),
elapsed_s=float(outcome.elapsed_s),
directory=Path(outcome.directory),
)
class AnalysisWorker(QObject):
"""One analysis pass, on its own thread, reporting progress as Qt signals.
``progressed`` and ``finished`` are declared on this object, which lives on the GUI
thread; emitting them from the pass's thread crosses the thread boundary, so Qt
queues them and the slots run on the GUI thread. That is the whole reason this
class exists rather than a bare ``threading.Thread`` at the call site.
"""
#: Emitted on the GUI thread with each coalesced ``Progress``.
progressed = Signal(object)
#: Emitted once, on the GUI thread, with the ``AnalysisReport``.
finished = Signal(object)
def __init__(
self,
service_factory: Callable[[threading.Event, Callable[[Progress], None]], Any],
*,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._service_factory = service_factory
self._thread: threading.Thread | None = None
self._cancel = threading.Event()
self._last_emit = 0.0
self._lock = threading.Lock()
self._service: Any = None
self._started_at = 0.0
self._failure = ""
# ------------------------------------------------------------------ control
def running(self) -> bool:
thread = self._thread
return thread is not None and thread.is_alive()
def start(self) -> bool:
"""Start the pass. Returns ``False`` when one is already running."""
if self.running():
return False
self._cancel.clear()
self._last_emit = 0.0
self._failure = ""
self._started_at = time.perf_counter()
thread = threading.Thread(target=self._run, name="analysis-pass", daemon=True)
self._thread = thread
thread.start()
return True
def cancel(self) -> None:
"""Ask the pass to stop at its next checkpoint.
The service checks the event between frames and between segments, so this stops
within one frame's work. Whatever was written stays on disk *marked incomplete*,
which is what keeps a cancelled pass from ever being played back.
"""
self._cancel.set()
def cancelled(self) -> bool:
return self._cancel.is_set()
def service(self) -> Any:
"""The live service, for a caller that wants its dependency report."""
with self._lock:
return self._service
def wait(self, timeout_s: float = 10.0) -> bool:
"""Block until the pass's thread has finished. For teardown and for tests."""
thread = self._thread
if thread is None:
return True
thread.join(float(timeout_s))
return not thread.is_alive()
# ------------------------------------------------------------------- thread
def _run(self) -> None:
outcome: AnalysisOutcome | None = None
try:
service = self._service_factory(self._cancel, self._on_progress)
with self._lock:
self._service = service
outcome = service.run()
except Exception as exc: # noqa: BLE001 - reported, never swallowed
logger.exception("the analysis pass raised")
self._failure = f"{type(exc).__name__}: {exc}"
finally:
with self._lock:
self._service = None
cancelled = self._cancel.is_set()
report = report_from(outcome, cancelled=cancelled)
if outcome is None and not cancelled:
report = AnalysisReport(
outcome=None,
cancelled=False,
failed=True,
reason=self._failure or "failed",
frames_written=0,
frames_expected=0,
elapsed_s=time.perf_counter() - self._started_at,
directory=None,
)
self.finished.emit(report)
def _on_progress(self, progress: Progress) -> None:
"""Runs on the pass's thread: coalesce, then hand the value across."""
now = time.perf_counter()
if now - self._last_emit < PROGRESS_INTERVAL_S:
return
self._last_emit = now
self.progressed.emit(progress)
+111
View File
@@ -58,6 +58,7 @@ from orisys_studio.scene3d.prefs import settings_to_prefs
from orisys_studio.session_controller import SessionController
from orisys_studio.ui import record_status as record_states
from orisys_studio.ui.about_dialog import AboutDialog
from orisys_studio.ui.analysis_progress import AnalysisProgress
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
from orisys_studio.ui.brand_bar import BrandBar
from orisys_studio.ui.cal_panel import CalPanel
@@ -121,6 +122,13 @@ VIEW_SHOWS_3D = ("custom", "live", "replay")
#: "not in the table" must not mean "standing at its minimum width".
DEFAULT_3D_PANE_WIDTH = 620
#: How long the window waits for the 3D view (built on first use) before giving up on
#: binding a display-settings change to the replay: 40 × 250 ms. Nothing depends on the
#: retry succeeding — a display change then simply redraws on the next cursor move — so
#: it is a bounded convenience, never a requirement.
ANALYSIS_DISPLAY_ATTEMPTS = 40
ANALYSIS_DISPLAY_INTERVAL_MS = 250
#: Kept under its historical name for ``tools/ui_snapshot.py``, which imports it from
#: here. There is one conversion in the application (``ui.arrow_panel.bgr_to_qimage``).
@@ -251,6 +259,11 @@ class StudioMainWindow(QMainWindow):
self.toolbar = ToolbarWidget(theme=self._theme)
root.addWidget(self.toolbar)
self.status_chip = self.toolbar.status_chip
# Part B: the analysis pass a replay is served from. The surface sits directly
# under the rail, so "what is it doing and can I stop it" is answered where the
# transport is; it is hidden until a pass (or a cache hit) has something to say.
self.analysis_progress = AnalysisProgress(theme=self._theme)
root.addWidget(self.analysis_progress)
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
@@ -284,9 +297,15 @@ class StudioMainWindow(QMainWindow):
charts=self.force_plots,
image_panel=self.arrow_panel,
mesh_panel=self.mesh3d_panel,
analysis_progress=self.analysis_progress,
parent=self,
)
self.replay_ctl.wire()
# Part B: a display-settings change while a replay is served from an analysis
# result is a *redraw*, never a new pass. The scene panel owns those controls,
# so its own signal is the one place that sees every one of them (threshold,
# colour range, idle dots, arrows).
self._await_analysis_display()
# How the numbers are shown (units, chart range, arrows, the fps readout) is a
# preference of its own: the dialog that changes the chart settings stays here,
# the values and what they do to the panels live there.
@@ -2706,6 +2725,91 @@ class StudioMainWindow(QMainWindow):
return
self.arrow_panel.show_frame(bgr_to_qimage(rendered))
# ------------------------------------------------------ analysis (Part B)
# What this window owes the replay controller, and nothing more. The *policy*
# (when a pass runs, what is done with its result, what a display change means)
# lives in ``ReplayController``; these are the three window-level things it cannot
# own: the sensor chain a pass is built from, the 3D pane's producer, and the
# scene panel's settings signal.
def _analysis_service(
self, recording: Path, cancelled=None, *, on_progress=None
) -> Any:
"""One ``AnalysisService`` over ``recording``, ready to run.
The chain comes from the recording's own ``session.json``
(``chain_spec_for``), which is what makes a stored result reproducible: the
configuration the footage was taken under, not whatever happens to be installed
now. ``cancelled`` is the pass's cancellation event and ``on_progress`` its
progress callback; both are ``None`` for a caller that only wants to ask whether
a compatible result is already stored.
"""
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
from orisys_app_shared.record import RecordingReader
session = RecordingReader(Path(recording)).session()
chain = chain_spec_for(session)
return AnalysisService(
Path(recording),
chain=chain,
cancelled=cancelled,
on_progress=on_progress,
)
def _show_analysis_frame(self, packet) -> None:
"""Hand one stored analysis frame to the 3D pane's producer.
The pane snapshots a packet through its ordinary path, so a replay's figures go
through the same adapter, domain latch and mask intersection as a live run. The
producer is O(1) and Qt-free, and it is the pane that decides whether it is on.
"""
view = self.mesh3d_panel.view
if view is None:
return
self._connect_analysis_display(self.mesh3d_panel)
view.submit_packet(packet)
def _connect_analysis_display(self, mesh_panel) -> bool:
"""Route every display-settings change through the replay controller.
Connected to the scene panel's ``settingsChanged`` rather than to each control,
so a new control cannot be added without the replay noticing it. The controller
decides what a change *means*: a redraw from the stored frame, and never a new
analysis pass.
The scene panel is built with the 3D view, which is built on first use, so this
is idempotent and answers whether the seam exists yet; the caller retries (see
``_await_analysis_display``) rather than reaching into another controller's
widgets.
"""
if getattr(self, "_analysis_display_connected", False):
return True
view = mesh_panel.view
panel = getattr(view, "panel", None) if view is not None else None
if panel is None or not hasattr(panel, "settingsChanged"):
return False
panel.settingsChanged.connect(self.replay_ctl.on_display_settings)
self._analysis_display_connected = True
return True
def _await_analysis_display(self) -> None:
"""Wait, briefly and off the critical path, for the 3D view to exist.
The display-settings signal is the only part of Part B that needs the 3D view,
and that view is created the first time the pane is used — possibly long after
this window is built. A short retry chain is the honest way to reach it from
here: the alternative is to reach into ``ViewLayoutController`` and create the
view early, which would change when GL is initialised for every user.
"""
if self._connect_analysis_display(self.mesh3d_panel):
return
self._analysis_display_attempts = (
int(getattr(self, "_analysis_display_attempts", 0)) + 1
)
if self._analysis_display_attempts > ANALYSIS_DISPLAY_ATTEMPTS:
logger.debug("the 3D display settings stayed unavailable; no signal bound")
return
QTimer.singleShot(ANALYSIS_DISPLAY_INTERVAL_MS, self._await_analysis_display)
def _restore_geometry(self) -> None:
geo = self.prefs.get("window_geometry")
if isinstance(geo, str) and geo:
@@ -2752,6 +2856,13 @@ class StudioMainWindow(QMainWindow):
# Detach the sinks first: a worker that is still winding down must not keep a
# reference to this window (and through it to widgets Qt is about to destroy).
self.session.detach_sinks()
# Part B: an analysis pass opens an SDK chain in this process, so it is stopped
# and waited for before the window goes — the same rule the acquisition session
# follows below. The partial result keeps its INCOMPLETE marker, so it will not
# be served on the next open either.
if self.replay_ctl.cancel_analysis():
self.replay_ctl.wait_for_analysis()
self.replay_ctl.results.close()
# Blocking: on the way out we must not leave a device handle or a
# half-written experiment behind.
self._stop_run(reason="user_stop", wait=True)
+211 -14
View File
@@ -1,10 +1,10 @@
"""Replay engine: one recording, one cursor, one frame at a time.
This is the headless half of the replay view. It owns the recording reader and
the cursor and it *shows* nothing: the main window renders the frame into the 2D
panel, the curves read the recording through
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport
on the chart header drives :meth:`set_position`.
This is the headless half of the replay view. It owns the recording reader, the
analysis session and the cursor, and it *shows* nothing: the main window renders the
frame into the 2D panel, the curves read the recording through
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport on the
chart header drives :meth:`set_position`.
It is an *engine*, not a controller: it answers "what is at t?" and knows nothing about
the transport's policy (when to start, what the end of the timeline means, whether a
@@ -12,9 +12,25 @@ recompute pass is running) or about the widgets. That policy lives in
``ReplayController`` (the window-level controller for this view), which is why the two
names are deliberately different — see ``apps/studio/docs/shell-lifecycle.md``.
There is no depth recomputation here: depth is not stored with a recording, the
replay view shows the recorded frames, and re-running the pipeline over a video
is the acquisition path's job (see the studio's "recompute" mode).
**One timeline, two modes.** The cursor is a single ``t_rel_s`` on the **signal** time
base in both modes — the recording's own ``frames.csv`` time, which is what
``RecordingReader`` and ``AnalysisReader`` both index by. The mode says what the frame
under the cursor is *read from*:
``"recording"``
the recorded video's own frames, decoded on demand (``RecordingReader``); no flow,
no depth, because a recording stores neither. This is the mode a finished record
opens in, and the one the charts read ``forces.csv`` for.
``"analysis"``
a stored analysis result (Part A): flow samples and the candidate grid the pass
recorded, read by frame id from the cache. A seek here is a file read — no pass,
no video, no SDK.
There is no third mode and there are no per-view sources: the charts, the 2D canvas and
the 3D pane all read this one cursor, in whichever mode it is in. Re-running the vision
chain over a video is *not* a mode of this engine: it is the acquisition pipeline's run
(see the studio's 「从视频重算」), which produces its own frames and, when it finishes,
can leave a result this engine then reads.
"""
from __future__ import annotations
@@ -27,8 +43,15 @@ from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader, VideoOnlySource
from PySide6.QtCore import QObject, Signal
from orisys_studio.replay_analysis import AnalysisSession, AnalysedFrame
logger = logging.getLogger(__name__)
#: The cursor reads the recording's own frames (decoded on demand).
MODE_RECORDING = "recording"
#: The cursor reads a stored analysis result (a file read per frame, no pass).
MODE_ANALYSIS = "analysis"
def _sdk_version() -> str:
"""The SDK version in use, or "" when the SDK is not importable."""
@@ -52,18 +75,29 @@ class ReplayEngine(QObject):
status_changed = Signal()
#: A recording was opened (True) or the previous one was dropped (False).
recording_changed = Signal(bool)
#: The timeline's mode changed (``MODE_RECORDING`` / ``MODE_ANALYSIS``), or a
#: result was adopted or dropped while staying in the same mode. Views that cache
#: what they drew must drop it; the number of readers did change.
timeline_changed = Signal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._reader: RecordingReader | None = None
#: A video with no recording beside it: playable, but it has no measurements.
self._video: VideoOnlySource | None = None
#: The stored analysis result the cursor reads, when the mode is analysis.
self._analysis: AnalysisSession | None = None
self._mode = MODE_RECORDING
self._unit = "raw"
self._position = 0.0
#: The frame cache: one video image per cursor position.
self._video_position: float | None = None
self._video_bgr: np.ndarray | None = None
self._video_reason = ""
#: The analysed frame under the cursor, and the id it was built for.
self._analysis_id: int | None = None
self._analysis_frame: AnalysedFrame | None = None
self._analysis_reason = ""
self._warnings = ""
# ------------------------------------------------------------- recording
@@ -90,6 +124,7 @@ class ReplayEngine(QObject):
"""Open a bare video for playback (no forces, no session metadata)."""
self._release_reader()
self._release_video()
self._release_analysis()
try:
self._video = VideoOnlySource(Path(path))
except Exception: # noqa: BLE001 - reported through the status text
@@ -101,7 +136,9 @@ class ReplayEngine(QObject):
return False
self._reset_frames()
self._position = 0.0
self._mode = MODE_RECORDING
self.recording_changed.emit(True)
self.timeline_changed.emit()
self._refresh_warnings()
self.set_position(0.0)
return True
@@ -124,8 +161,14 @@ class ReplayEngine(QObject):
return RecordingCurveSource(self._reader)
def open_recording(self, directory) -> bool:
"""Open a recording directory."""
"""Open a recording directory.
A new recording drops any analysis result that was open with the old one: the
result belongs to a recording, and keeping the cursor on another recording's
frames is how a picture ends up describing footage that is not on screen.
"""
self._release_reader()
self._release_analysis()
try:
self._reader = RecordingReader(directory)
self._reader.info()
@@ -138,7 +181,9 @@ class ReplayEngine(QObject):
return False
self._reset_frames()
self._position = 0.0
self._mode = MODE_RECORDING
self.recording_changed.emit(True)
self.timeline_changed.emit()
self._refresh_warnings()
self.set_position(0.0)
return True
@@ -147,8 +192,11 @@ class ReplayEngine(QObject):
"""Drop whatever is open (the decoder is released with it)."""
self._release_reader()
self._release_video()
self._release_analysis()
self._reset_frames()
self._mode = MODE_RECORDING
self.recording_changed.emit(False)
self.timeline_changed.emit()
self.status_changed.emit()
def _release_reader(self) -> None:
@@ -167,32 +215,129 @@ class ReplayEngine(QObject):
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("Could not close the video", exc_info=True)
def _release_analysis(self) -> None:
"""Forget the borrowed session. It does **not** close it.
The reader belongs to the controller's ``ResultLibrary``: closing it here would
leave the controller holding a session whose file handle this engine shut.
"""
self._analysis = None
self._analysis_id = None
self._analysis_frame = None
self._analysis_reason = ""
def _reset_frames(self) -> None:
self._video_position = None
self._video_bgr = None
self._video_reason = ""
# ---------------------------------------------------------------- cursor
def position(self) -> float:
return self._position
# --------------------------------------------------------------- timeline
def mode(self) -> str:
"""What the frame under the cursor is read from: the video, or a result."""
return self._mode
def analysis(self) -> AnalysisSession | None:
return self._analysis
def use_analysis(self, session: AnalysisSession) -> None:
"""Read the cursor from a stored result instead of decoding the video.
The session is *borrowed*, not owned: its reader belongs to whoever opened it
(``ResultLibrary`` in the controller), which is what lets a mode change be a
cheap switch rather than a file handle handed between two owners.
"""
self._analysis = session
self._mode = MODE_ANALYSIS
self._analysis_id = None
self._analysis_frame = None
self._analysis_reason = ""
# The cursor keeps its *time*: both timelines are the recording's own signal
# time, so the frame under it is the one the operator was already looking at
# (a different frame id may resolve, and that is the honest answer: the result
# covers the frames the pass wrote).
self.timeline_changed.emit()
self.set_position(self._position)
self._refresh_warnings()
def use_recording(self) -> None:
"""Read the cursor from the recording's own video again."""
if self._mode != MODE_ANALYSIS:
return
self._release_analysis()
self._mode = MODE_RECORDING
self.timeline_changed.emit()
self.set_position(self._position)
self._refresh_warnings()
def duration_s(self) -> float:
if self._mode == MODE_ANALYSIS and self._analysis is not None:
return self._analysis.duration_s()
if self._reader is not None:
return float(self._reader.info().duration_s)
if self._video is not None:
return float(self._video.duration_s)
return 0.0
# ---------------------------------------------------------------- cursor
def position(self) -> float:
return self._position
def frame_id(self) -> int | None:
"""The frame id under the cursor, or ``None`` when nothing is open.
In analysis mode this is the **recording's own frame id** — never an index into
the result. A recording whose ids run 20…52 answers 20…52 here, and a caller
that needs a specific frame asks :meth:`frame_id_for_time` for it.
"""
if self._mode == MODE_ANALYSIS and self._analysis is not None:
return self._analysis.frame_id_at(self._position)
if self._reader is not None:
image, frame = self._reader.video_frame_at(self._position)
return None if frame is None else int(frame.frame_id)
return None
def frame_id_for_time(self, t_rel_s: float) -> int | None:
"""The frame id a position resolves to, without moving the cursor."""
if self._mode == MODE_ANALYSIS and self._analysis is not None:
return self._analysis.frame_id_at(float(t_rel_s))
if self._reader is None:
return None
_image, frame = self._reader.video_frame_at(float(t_rel_s))
return None if frame is None else int(frame.frame_id)
def time_of_frame(self, frame_id: int) -> float | None:
"""The signal time of a frame **id**, or ``None`` when it is not in the result.
An id the recording does not hold is refused, not coerced: a caller that passes
an index gets ``None`` rather than the time of the frame at that offset.
"""
if self._mode == MODE_ANALYSIS and self._analysis is not None:
return self._analysis.time_of(int(frame_id))
return None
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: the video frame and the charts follow it."""
if not self.has_video():
if not self.has_video() and self._analysis is None:
return
duration = self.duration_s()
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self._refresh_video()
self._refresh_analysis()
self.position_changed.emit(self._position)
self.frames_changed.emit()
def redraw(self) -> None:
"""Re-emit the frame under the cursor without moving it.
What a display-settings change does: the picture is rebuilt from the same
stored frame, no result is written and no position moves. Anything that caches
what it drew must drop that cache, so the producers behind it are reset too.
"""
self._reset_frames()
self._analysis_id = None
self._analysis_frame = None
self.frames_changed.emit()
def unit(self) -> str:
return self._unit
@@ -207,15 +352,32 @@ class ReplayEngine(QObject):
return self._unit
# ----------------------------------------------------------------- frames
def analysed_frame(self) -> AnalysedFrame | None:
"""The stored frame under the cursor, or ``None`` outside analysis mode."""
if self._mode != MODE_ANALYSIS:
return None
self._refresh_analysis()
return self._analysis_frame
def video_frame_bgr(self) -> np.ndarray | None:
"""The recorded frame under the cursor, as BGR.
The engine hands over pixels, not a prepared widget image: the single 2D
backend (``acquisition.image_backend``) decides what the picture is.
backend (``acquisition.image_backend``) decides what the picture is. In
analysis mode the pixels are the result's own canvas — the flow field the pass
recorded, drawn on the domain grid — not a video frame.
"""
if self._mode == MODE_ANALYSIS:
frame = self.analysed_frame()
return None if frame is None else frame.base_bgr
self._refresh_video()
return self._video_bgr
def analysis_frame_bgr(self) -> np.ndarray | None:
"""The same pixels :meth:`video_frame_bgr` returns in analysis mode."""
frame = self.analysed_frame()
return None if frame is None else frame.base_bgr
def video_image(self):
"""The same frame as a ``QImage`` (kept for callers that predate the plan)."""
from orisys_studio.ui.arrow_panel import bgr_to_qimage
@@ -224,6 +386,9 @@ class ReplayEngine(QObject):
return None if frame is None else bgr_to_qimage(frame)
def video_message(self) -> str:
if self._mode == MODE_ANALYSIS:
self._refresh_analysis()
return self._analysis_reason
self._refresh_video()
return self._video_reason
@@ -257,12 +422,44 @@ class ReplayEngine(QObject):
self._video_reason = ""
self._video_bgr = image
def _refresh_analysis(self) -> None:
"""Read the stored frame under the cursor, once per frame id."""
session = self._analysis
if session is None or self._mode != MODE_ANALYSIS:
return
frame_id = session.frame_id_at(self._position)
if frame_id is None:
self._analysis_frame = None
self._analysis_id = None
self._analysis_reason = t("playback.analysis_empty")
return
if self._analysis_frame is not None and self._analysis_id == frame_id:
return
try:
frame = session.frame(frame_id)
except Exception as exc: # noqa: BLE001 - a broken result is reported, not raised
logger.exception("Could not read frame %s from the analysis result", frame_id)
self._analysis_frame = None
self._analysis_id = None
self._analysis_reason = t(
"playback.analysis_unreadable", error=type(exc).__name__
)
return
self._analysis_frame = frame
self._analysis_id = frame_id
self._analysis_reason = ""
# ---------------------------------------------------------------- notes
def warnings_text(self) -> str:
return self._warnings
def _refresh_warnings(self) -> None:
self._warnings = ""
if self._mode == MODE_ANALYSIS and self._analysis is not None:
# Nothing is stale about a result just because the SDK moved: that is
# reported, not hidden (``AnalysisReader.stale_reasons``), and the caller
# that opened the session is the one holding the facts to compare with.
return
if self._reader is None:
if self._video is not None:
# Not a warning: a statement of what this file *is*, so nobody reads an
@@ -0,0 +1,461 @@
"""Serving a replay from an analysis result: one position, one frame, no pass.
``replay.py`` is the *engine* for a recording: a reader, a cursor, and the recorded
picture under it. This module is the same thing for a **stored analysis result** —
Part A's output — and it exists so that playing a recording stops costing 0.48–0.51
wall-seconds per video-second.
Two decisions worth stating out loud, because both are load-bearing:
**Position is signal time, and frames are addressed by frame id.** A stored frame is
located by its ``t_rel_s`` (``reader.frame_at``), and the frame it resolves to carries
the recording's own ``frame_id`` — the acquisition session's counter, which does not
start at 0 or 1. Every ``frame_id`` this module is handed is checked against the result
and refused when it is absent (:meth:`AnalysisSession.frame`), so a caller that passes a
*position* where an *id* belongs gets an error rather than the frame at that offset.
Container video time is never derived from ``t_rel_s`` and never converted into it: the
two time bases are different numbers for the same footage (33 container frames at 30 fps
is 1.100 s; the signal those frames carry is 1.920 s at 16.664 fps) and this module only
ever speaks the second one.
**The result is read, never re-derived — but the display policy is still honoured.**
``flow_on_px`` arrives from the display settings and is applied to the stored
``magnitude`` by ``AnalysisReader.samples``, which is why a threshold change is a read
and never a new pass. The colour range and the idle-dot switch are not even inputs
here: they live in the draw call, so changing one redraws and writes nothing.
The picture is rebuilt from the stored samples: ``dx``/``dy`` are the canvas
displacements the pass selected (uv pixels, already divided by the policy's arrow
scale), so the flow field is recovered by dividing them back out by
:data:`~orisys_app_shared.scene3d.visual_policy.DISPLACEMENT_PX_PER_FLOW` and writing
those values at the recorded candidate locations. The 2D canvas then runs its ordinary
path — the shared selector, the same idle dots, the same colour ramp — over that field.
This is exact, not approximate, and ``apps/studio/tests/test_replay_analysis.py``
asserts it against a pass over real footage: re-selecting the recovered field through
the display policy reproduces the stored sample set element for element.
The 3D pane is handed the **stored** sample set directly (:attr:`AnalysedFrame.samples`)
— the object Part A documents as "what the canvas and the composer already draw" — so
the figure the pane composes from a replay is the figure the pass recorded, however the
display policy is set.
"""
from __future__ import annotations
import logging
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from orisys_app_shared.acquisition.models import SdkPacket
from orisys_app_shared.analysis.reader import AnalysisReader
from orisys_app_shared.scene3d.visual_policy import (
DISPLACEMENT_PX_PER_FLOW,
FlowSamples,
VisualPolicy,
)
logger = logging.getLogger(__name__)
__all__ = [
"AnalysedFrame",
"AnalysisSession",
"ResultLibrary",
"ResultPolicyError",
"policy_divergence",
]
#: The UV domain to rebuild the flow field in when the result carries no validity mask
#: keyframe (the box chain has none). Only the recorded candidate locations are ever
#: read back out of it, so its size is a canvas for the samples rather than a claim
#: about the device — but it has to hold every candidate, which
#: :meth:`AnalysisSession._recover_flow` checks.
DEFAULT_DOMAIN_HW = (1080, 1920)
#: The policy keys that decide *where* the candidate locations are. A display setting
#: that moves one of these cannot be served from a stored result: the recorded grid is
#: the pass's, and re-gridding it would draw samples nothing measured while looking
#: exactly like the stored result. ``flow_on_px``/``flow_off_px`` are deliberately not
#: among them — they decide which recorded samples are active, and ``magnitude`` is
#: stored precisely so that question can be asked again later.
GRID_KEYS = (
"flow_grid_spacing",
"flow_edge_margin",
"max_arrows",
"flow_length_margin",
"flow_arrow_scale",
)
class ResultPolicyError(RuntimeError):
"""A result that cannot be served: incomplete, or not in this display's domain."""
@dataclass(frozen=True)
class AnalysedFrame:
"""One frame of a stored result, ready for both views.
``flow``/``valid_mask`` are what the 2D canvas draws from; ``samples`` is the
stored selection the 3D pane overlays. Both describe the *same* frame id at the
*same* ``t_rel_s``, which is what makes one position drive two figures.
"""
frame_id: int
t_rel_s: float
flow: np.ndarray
valid_mask: np.ndarray | None
samples: FlowSamples
base_bgr: np.ndarray
depth_nonzero: int = 0
dropped: bool = False
@property
def domain_hw(self) -> tuple[int, int]:
return (int(self.flow.shape[0]), int(self.flow.shape[1]))
@property
def active_count(self) -> int:
return int(self.samples.active_count)
@property
def candidates(self) -> int:
return int(self.samples.rows.size)
def policy_divergence(stored: dict[str, Any], display: VisualPolicy) -> list[str]:
"""The location settings a display policy moves away from the stored ones.
Empty means the stored grid can serve this display: the candidate locations are
the recorded ones and only the activity verdict changes. A non-empty list is the
reason a replay cannot honour a display setting, named so the message can say
which one instead of "unsupported".
"""
wanted = display.normalised()
moved: list[str] = []
for key in GRID_KEYS:
if key not in stored:
continue
recorded = float(stored[key])
if abs(recorded - float(getattr(wanted, key))) > 1e-9:
moved.append(key)
return moved
class AnalysisSession:
"""A stored result, read as a timeline: t → frame id → the two views' inputs."""
def __init__(
self,
reader: AnalysisReader,
*,
policy: VisualPolicy | None = None,
display_policy: VisualPolicy | None = None,
session_id: int = 0,
recording: Path | None = None,
) -> None:
if not bool(getattr(reader, "complete", True)):
# A cancelled or failed pass must never be played from. The reader refuses
# such a result unless ``allow_incomplete=True`` was passed, and this
# refuses it even then: "inspectable" and "playable" are different claims.
raise ResultPolicyError(
"this analysis result is incomplete and cannot be played from"
)
self.reader = reader
self.policy = (policy or VisualPolicy()).normalised()
self.display_policy = (display_policy or self.policy).normalised()
self.session_id = int(session_id)
self.recording = None if recording is None else Path(recording)
self._frame_ids = np.asarray(reader.frame_ids, dtype=np.int64)
self._times = np.asarray(reader.times, dtype=np.float64)
self._mask: np.ndarray | None = None
self._mask_loaded = False
self._cache_id: int | None = None
self._cache: AnalysedFrame | None = None
# ------------------------------------------------------------------ identity
@property
def frame_count(self) -> int:
return int(self._frame_ids.size)
@property
def first_frame_id(self) -> int:
return int(self._frame_ids[0]) if self._frame_ids.size else 0
@property
def last_frame_id(self) -> int:
return int(self._frame_ids[-1]) if self._frame_ids.size else 0
@property
def frame_ids(self) -> np.ndarray:
"""Every stored frame id, in stored order (the recording's own counter)."""
return self._frame_ids
@property
def result_key(self) -> str:
return str(self.reader.key)
@property
def directory(self) -> Path:
return Path(self.reader.directory)
def has(self, frame_id: int) -> bool:
"""Whether the result holds this frame id. Never an index lookup."""
return bool(self._frame_ids.size) and bool(
np.any(self._frame_ids == int(frame_id))
)
def duration_s(self) -> float:
"""The result's span in **signal** time, from ``t_rel_s`` (never the container)."""
if self._times.size == 0:
return 0.0
return float(max(0.0, self._times[-1] - self._times[0]))
def origin_s(self) -> float:
return float(self._times[0]) if self._times.size else 0.0
def stored_policy(self) -> dict[str, Any]:
"""The selection policy the pass recorded, for comparison with the display's."""
return dict(getattr(self.reader.provenance, "policy", {}) or {})
# ------------------------------------------------------------------- policy
def serve_policy(self, display_policy: VisualPolicy) -> bool:
"""Adopt a display policy, if the stored grid can still serve it.
Returns ``False`` — and changes nothing — when the policy moves a location
setting (see :data:`GRID_KEYS`). Those decide where the candidates are, and the
recorded locations are the ones the pass measured. The threshold is not one of
them: it decides which recorded samples are active.
"""
if policy_divergence(self.stored_policy(), display_policy):
return False
self.display_policy = display_policy.normalised()
return True
def threshold(self) -> float:
return float(self.display_policy.flow_on_px)
# ------------------------------------------------------------------ looking
def frame_id_at(self, t_rel_s: float) -> int | None:
"""The stored frame at or just before ``t_rel_s``, by *signal* time."""
if self._times.size == 0:
return None
if bool(np.all(np.diff(self._times) >= 0)):
slot = int(np.searchsorted(self._times, float(t_rel_s), side="right")) - 1
slot = max(0, min(slot, self._times.size - 1))
else: # a foreign result whose times are not ordered: scan rather than guess
candidates = np.flatnonzero(self._times <= float(t_rel_s))
slot = int(candidates[-1]) if candidates.size else 0
return int(self._frame_ids[slot])
def time_of(self, frame_id: int) -> float | None:
"""The stored ``t_rel_s`` of one frame id, or ``None`` when it is absent."""
hits = np.flatnonzero(self._frame_ids == int(frame_id))
if not hits.size:
return None
return float(self._times[int(hits[0])])
def frame(self, frame_id: int) -> AnalysedFrame:
"""One frame by its recording id.
Raises :class:`~orisys_app_shared.analysis.reader.AnalysisNotFoundError` for an
id the result does not hold. That refusal is the point: a frame *index* is not
a frame *id*, and serving the frame at that offset would answer a question
nobody asked with a picture that looks right.
"""
wanted = int(frame_id)
if self._cache is not None and self._cache_id == wanted:
return self._cache
payload = self.reader.frame(wanted)
built = self._compose(payload)
self._cache = built
self._cache_id = wanted
return built
def frame_at(self, t_rel_s: float) -> AnalysedFrame | None:
"""One frame by signal time, or ``None`` for an empty result."""
frame_id = self.frame_id_at(t_rel_s)
return None if frame_id is None else self.frame(frame_id)
def frames(self) -> Iterator[AnalysedFrame]:
"""Every stored frame, in stored order (a pass over a result, not a replay)."""
for frame_id in self._frame_ids:
yield self.frame(int(frame_id))
# ------------------------------------------------------------------ packets
def packet(self, frame: AnalysedFrame, *, t_rel_s: float | None = None) -> SdkPacket:
"""One frame in the shape the 3D pane's producer consumes.
The pane is fed through its ordinary path — it snapshots a packet — so a
replay's figures go through the same adapter, the same domain latch and the
same validity intersection as a live or recomputed frame. Feeding it anything
else would make "the pane looks the same in every mode" an accident of two code
paths happening to agree.
The forces are zero and ``forces_valid`` is ``False``: the result stores no
force, ``forces.csv`` is where that measurement lives, and a zero here would be
a measurement nothing made.
"""
moment = float(frame.t_rel_s if t_rel_s is None else t_rel_s)
return SdkPacket(
raw_frame=frame.base_bgr,
processed_frame=frame.base_bgr,
timestamp=moment,
mono_t=moment,
fps=0.0,
process_ms=0.0,
session_id=self.session_id,
frame_id=int(frame.frame_id),
valid=True,
stale_forces=True,
forces_valid=False,
normal_force=0.0,
shear_x=0.0,
shear_y=0.0,
flow=frame.flow,
valid_mask=frame.valid_mask,
flow_valid=True,
flow_frame_id=int(frame.frame_id),
depth=None,
depth_valid=False,
depth_frame_id=None,
)
# ------------------------------------------------------------------ building
def mask(self) -> np.ndarray | None:
"""The validity mask the result was built with, read once."""
if not self._mask_loaded:
self._mask_loaded = True
self._mask = self.reader.mask()
return self._mask
def domain_hw(self) -> tuple[int, int]:
mask = self.mask()
if mask is None:
return DEFAULT_DOMAIN_HW
return (int(mask.shape[0]), int(mask.shape[1]))
def _compose(self, payload: Any) -> AnalysedFrame:
mask = self.mask()
shape_hw = self.domain_hw()
samples = payload.samples(threshold=self.threshold())
flow = self._recover_flow(samples, shape_hw)
base = np.zeros((shape_hw[0], shape_hw[1], 3), dtype=np.uint8)
return AnalysedFrame(
frame_id=int(payload.frame_id),
t_rel_s=float(payload.t_rel_s),
flow=flow,
valid_mask=mask,
samples=samples,
base_bgr=base,
depth_nonzero=int(getattr(payload, "depth_nonzero", 0) or 0),
dropped=bool(getattr(payload, "dropped", False)),
)
def _recover_flow(
self, samples: FlowSamples, shape_hw: tuple[int, int]
) -> np.ndarray:
"""Write the stored displacements back at the recorded candidate locations.
Only those locations carry a value; every other cell is ``NaN``, which
:func:`~orisys_app_shared.scene3d.visual_policy.select_flow_samples` treats as
invalid, so no neighbouring cell can contribute a sample the pass did not
record.
``dx``/``dy`` are the **drawn** displacements, which the selector produced as
``DISPLACEMENT_PX_PER_FLOW * flow / flow_arrow_scale`` and then scaled by
``flow_length_margin`` when the arrow was longer than its cell. Recovering the
field is therefore a division by the arrow scale, the display constant *and*
that margin — measured, not assumed: dividing by the first two alone leaves every
arrow 20% short (the finger margin is 0.8), which changes 319 of 330 samples and
one frame's active set. With the margin included the selector's own
``np.where(length > spacing * margin, ...)`` branch is idempotent, so
re-selecting the recovered field reproduces the stored samples exactly.
"""
rows = np.asarray(samples.rows, np.int64)
cols = np.asarray(samples.cols, np.int64)
height, width = int(shape_hw[0]), int(shape_hw[1])
if rows.size and (int(rows.max()) >= height or int(cols.max()) >= width):
raise ResultPolicyError(
"the result's candidate locations do not fit the domain it declares "
f"({height}x{width}); the mask keyframe and the grid keyframe disagree"
)
divisor = (
DISPLACEMENT_PX_PER_FLOW
* float(self.policy.flow_arrow_scale)
* float(self.policy.flow_length_margin)
)
flow = np.full((height, width, 2), np.nan, dtype=np.float64)
with np.errstate(invalid="ignore"):
# ``NaN`` is the point of the mask, and the selector's tip arithmetic on a
# discarded cell is defined to be discarded: say so here rather than let
# numpy warn about an invalid cast on every frame.
flow[rows, cols, 0] = np.asarray(samples.dx, np.float64) / divisor
flow[rows, cols, 1] = np.asarray(samples.dy, np.float64) / divisor
return flow
class ResultLibrary:
"""Opening stored results as playable sessions, one at a time.
The controller keeps one of these. It owns the *reader lifecycle* — the previous
session's file handle is closed when another is opened — so nothing else has to
remember that a result is a file on disk with a handle behind it.
"""
def __init__(self) -> None:
self._session: AnalysisSession | None = None
@property
def session(self) -> AnalysisSession | None:
return self._session
def open(
self,
directory: str | Path,
*,
policy: VisualPolicy | None = None,
display_policy: VisualPolicy | None = None,
session_id: int = 0,
recording: Path | None = None,
allow_incomplete: bool = False,
) -> AnalysisSession:
"""Open a result directory as a session, closing whatever was open.
``allow_incomplete`` is for *inspection* only and never reaches this class's
own session: an incomplete result raises
:class:`~orisys_app_shared.analysis.reader.AnalysisIncompleteError` here even
when the caller asked to look at one, because a partial pass that plays back
like a whole one is exactly the silent wrong answer Part A's marker exists to
prevent.
"""
reader = AnalysisReader(directory, allow_incomplete=allow_incomplete)
session = AnalysisSession(
reader,
policy=policy,
display_policy=display_policy,
session_id=session_id,
recording=recording,
)
self.open_session(session)
return session
def open_session(self, session: AnalysisSession) -> AnalysisSession:
"""Adopt a session that was built elsewhere, closing whatever was open.
The engine *borrows* its session (a mode switch must not hand a file handle
between two owners), so the library is the single owner and this is the only
place a reader is closed.
"""
self.close()
self._session = session
return session
def close(self) -> None:
session, self._session = self._session, None
if session is not None:
try:
session.reader.close()
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("could not close the analysis reader", exc_info=True)
@@ -28,12 +28,20 @@ from pathlib import Path
from typing import Any, Protocol
import numpy as np
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
from orisys_app_shared.acquisition.image_backend import SOURCE_ANALYSIS, SOURCE_REPLAY, ImageFrame
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QFileDialog
from orisys_studio.analysis_worker import AnalysisReport, AnalysisWorker
from orisys_studio.replay import MODE_ANALYSIS
from orisys_studio.replay_analysis import (
AnalysisSession,
ResultLibrary,
ResultPolicyError,
)
logger = logging.getLogger(__name__)
#: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the
@@ -72,6 +80,15 @@ class ReplayHost(Protocol):
def _show_image_frame(self, frame: ImageFrame) -> None: ...
def _show_analysis_frame(self, packet: Any) -> None:
"""Hand one stored analysis frame to the 3D pane's producer.
The pane is fed through its ordinary path (it snapshots a packet), so a replay
goes through the same adapter, the same domain latch and the same validity
intersection as a live run. The window owns the pane, so it owns this call.
"""
...
def _current_source_kind(self) -> str: ...
def _drop_render_marker(self) -> None: ...
@@ -86,6 +103,19 @@ class ReplayHost(Protocol):
def _refresh_status_rail(self) -> None: ...
def _analysis_service(
self, recording: Path, cancelled: Any, *, on_progress: Any = None
) -> Any:
"""A ready-to-run ``AnalysisService`` over ``recording``.
The window owns this because the chain specification comes from the app's
sensor configuration (``chain_spec_for`` over the recording's session), which is
window-level knowledge; the controller owns *when* a pass runs and what is done
with its result. ``cancelled`` is the pass's cancellation event (``None`` when
the caller only wants to ask whether a result is already stored).
"""
...
@dataclass
class ReplayActions:
@@ -110,6 +140,7 @@ class ReplayController(QObject):
charts: Any,
image_panel: Any,
mesh_panel: Any,
analysis_progress: Any | None = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
@@ -119,15 +150,27 @@ class ReplayController(QObject):
self._charts = charts
self._image_panel = image_panel
self._mesh_panel = mesh_panel
#: The progress and cancel surface for an analysis pass. Optional so a caller
#: with no place to put it (a headless controller, a test) still gets the
#: behaviour; a pass then runs without a visible bar, and that is the caller's
#: choice rather than a silent failure.
self._progress = analysis_progress
self._actions: ReplayActions | None = 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
#: The stored results the timeline can read from, and the pass in flight.
self.results = ResultLibrary()
self._analysis_worker: AnalysisWorker | None = None
self._analysis_target: Path | None = None
self._analysis_error = ""
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)
if self._progress is not None:
self._progress.cancel_requested.connect(self.on_analysis_cancel)
# ------------------------------------------------------------------ wiring
def wire(self) -> None:
@@ -139,7 +182,6 @@ class ReplayController(QObject):
# 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,
*,
@@ -354,6 +396,9 @@ class ReplayController(QObject):
self._host._notify(t("playback.open_failed", error=Path(directory).name))
return False
self.refresh_state()
# Analysis on open: serve the stored result when there is a compatible one, run
# the pass with a progress and cancel surface when there is not.
self.on_analysis_requested()
return True
def open_latest_recording(self) -> None:
@@ -568,20 +613,328 @@ class ReplayController(QObject):
# frames to show again now that the pass is over.
self.render_frame()
# ---------------------------------------------------------------- analysis
def analysis_session(self) -> AnalysisSession | None:
"""The stored result the timeline is reading, if any."""
session = self._engine.analysis()
if session is not None:
return session
return self.results.session
def analysis_active(self) -> bool:
"""True while the cursor is read from a stored result.
Asked with ``getattr`` because the engine is a seam a test can stand in for: a
stand-in that predates the analysable timeline has no mode, and "no mode" means
"no result to read", which is the safe answer rather than an error.
"""
mode = getattr(self._engine, "mode", None)
return (
callable(mode)
and mode() == MODE_ANALYSIS
and self._engine.analysis() is not None
)
def analysis_running(self) -> bool:
worker = self._analysis_worker
return worker is not None and worker.running()
def analysis_error(self) -> str:
"""Why the last pass did not leave a playable result ("" when it did)."""
return self._analysis_error
def on_display_settings(self, settings: Any) -> None:
"""A display setting changed: redraw from the stored frame, write nothing.
This is the property the result key was designed for. The threshold is applied
to the stored ``magnitude`` (so it is a *read*), the colour range and the idle
dots live in the draw call, and none of them is an input to the pass — so
nothing here touches the cache, and the assertion in
``test_replay_analysis`` that the result directory set is unchanged is a test of
this method, not of the panel.
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
re-gridding it would draw samples nothing measured.
"""
session = self.analysis_session()
if session is None or not self.analysis_active():
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:
self._host._notify(t("playback.analysis_grid_mismatch", keys="·".join(moved)))
self._engine.redraw()
def on_analysis_requested(self) -> None:
"""Run the pass over what is open, unless a compatible result already exists.
The operator-facing entry for "re-run": the recompute switch calls this once it
has been turned on, and opening a recording calls it when no result is stored.
"""
if self.analysis_running():
self._host._notify(t("playback.analysis_busy"))
return
if self._host._running or self._host._preview_open or self._host._session_active:
# The pass opens an SDK chain in this process, and so does a session. One
# at a time, and the working directory stays untouched while it runs.
self._host._notify(t("analysis.blocked"))
self._set_recompute_checked(False)
return
recording = self.recording_directory()
if recording is None:
self._host._notify(t("playback.analysis_no_source"))
self._set_recompute_checked(False)
return
self._analysis_error = ""
if self._activate_stored_result(recording, notify_reuse=True):
return
self._start_pass(recording)
def recording_directory(self) -> Path | None:
"""The recording directory an analysis pass should read."""
directory = self._engine.directory()
if directory is not None:
return Path(directory)
named = self._toolbar.video_path()
if named:
folder = Path(named)
if (folder / "session.json").is_file():
return folder
if (folder.parent / "session.json").is_file():
return folder.parent
return None
def _activate_stored_result(
self, recording: Path, *, notify_reuse: bool = False, reanalyse: bool = False
) -> bool:
"""Point the timeline at a stored result, if one is compatible.
Returns ``False`` when there is nothing to read — no result, or a result the
reader refuses (an incomplete one), which is a *reason to run a pass* rather
than something to serve. The refusal is deliberate and is the whole point of
Part A's marker: a cancelled pass must never play back like a finished one.
"""
service = self._host._analysis_service(recording, None)
reader = None
try:
reader = service.stored_result()
except Exception as exc: # noqa: BLE001 - an unreadable result means "run one"
logger.info("the stored result is not usable: %s", exc)
self._analysis_error = str(exc)
reader = None
if reader is None:
return False
try:
session = AnalysisSession(
reader,
policy=getattr(service, "policy", None),
display_policy=self._display_policy(),
session_id=self._session_number(recording),
recording=recording,
)
except ResultPolicyError as exc:
# Incomplete (or otherwise unplayable): close it and run a fresh pass.
logger.info("refusing to play an incomplete result: %s", exc)
self._analysis_error = str(exc)
try:
reader.close()
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("could not close the refused reader", exc_info=True)
if reanalyse and self._progress is not None:
self._progress.failed(reason=str(exc))
return False
self.results.close()
self._adopt_session(session)
frames = session.frame_count
if notify_reuse:
# The rail's own notification (the 3D pane's source line and the 2D footer say
# "analysis result"), not the transient status line: this is a state, and it
# stays true for as long as the timeline is in it.
self._host._refresh_status_rail()
if self._progress is not None:
self._progress.reused(frames=frames)
return True
def _adopt_session(self, session: AnalysisSession) -> None:
"""One timeline: the cursor, the charts and both figures read this result."""
self.results.open_session(session)
self._engine.use_analysis(session)
self._charts.set_recording_source(self._engine.reader())
self._image_panel.set_source_kind("analysis")
self._mesh_panel.set_source_kind("analysis")
if self._actions is not None:
self._actions.recompute.setEnabled(True)
self.render_frame()
def _display_policy(self) -> Any:
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
settings = getattr(self._mesh_panel, "display_settings", None)
return policy_for_settings(settings() if callable(settings) else None)
def _session_number(self, recording: Path) -> int:
reader = self._engine.reader()
if reader is not None:
try:
return int(dict(reader.session().get("session_id") or 0))
except Exception: # noqa: BLE001 - a session number is a nicety
logger.debug("could not read the session id", exc_info=True)
return 0
def _start_pass(self, recording: Path) -> None:
"""Run the analysis pass on a worker thread, with progress and a cancel."""
worker = AnalysisWorker(
lambda event, on_progress: self._host._analysis_service(
recording, event, on_progress=on_progress
),
parent=self,
)
worker.progressed.connect(self.on_analysis_progress)
worker.finished.connect(self.on_analysis_finished)
self._analysis_worker = worker
self._analysis_target = Path(recording)
if self._progress is not None:
self._progress.start()
if not worker.start(): # pragma: no cover - guarded by ``analysis_running``
logger.warning("an analysis pass was already running")
self._analysis_worker = None
def on_analysis_progress(self, progress: Any) -> None:
if self._progress is None:
return
self._progress.progress(
written=int(getattr(progress, "frames_written", 0)),
expected=int(getattr(progress, "frames_expected", 0)),
segment_index=int(getattr(progress, "segment_index", 0)),
segment_count=int(getattr(progress, "segment_count", 0)),
frame_id=int(getattr(progress, "frame_id", 0)),
elapsed_s=float(getattr(progress, "elapsed_s", 0.0)),
)
def on_analysis_finished(self, report: AnalysisReport) -> None:
"""The pass ended: serve it only when it is complete, and say so when it is not.
Nothing here writes to the transient status line, deliberately. An analysis pass
is asynchronous and can end seconds after the operator did something else, and a
status message that outlives its cause made an unrelated action look like it
failed — measured on ``test_the_replay_reset_is_the_display_reset_and_never_the_device``,
whose "display reset" line a failing pass overwrote. The progress surface stays
up with the outcome (complete, cancelled, or failed *with the reason*), and
:meth:`analysis_error` carries the same reason for a caller that asks.
"""
self._analysis_worker = None
target = self._analysis_target
if report.cancelled:
self._analysis_error = "cancelled"
if self._progress is not None:
self._progress.cancelled(frames=report.frames_written)
self._set_recompute_checked(False)
return
if not report.complete:
self._analysis_error = report.reason or "incomplete"
if self._progress is not None:
self._progress.failed(
reason=self._analysis_error, frames=report.frames_written
)
self._set_recompute_checked(False)
return
if target is None or not self._activate_stored_result(target):
# A complete pass that cannot be opened is a bug, not a state to show as
# success: report it rather than leaving the operator with a progress bar
# that says "finished" over a recording that is not being served.
self._analysis_error = "unreadable"
if self._progress is not None:
self._progress.failed(reason="unreadable")
return
if self._progress is not None:
self._progress.complete(
frames=report.frames_written, elapsed_s=report.elapsed_s
)
# The rail states what the timeline is reading from (the source label), so a
# completed pass says so where the state lives rather than in a line that expires.
self._host._refresh_status_rail()
self.refresh_state()
def on_analysis_cancel(self) -> None:
"""The operator pressed cancel: the pass stops and its result stays incomplete."""
worker = self._analysis_worker
if worker is None:
return
worker.cancel()
def cancel_analysis(self) -> bool:
"""Ask a running pass to stop. Returns whether there was one."""
worker = self._analysis_worker
if worker is None or not worker.running():
return False
worker.cancel()
return True
def wait_for_analysis(self, timeout_s: float = 10.0) -> bool:
"""Block until a pass has finished. For teardown and for tests."""
worker = self._analysis_worker
if worker is None:
return True
return worker.wait(timeout_s)
def _set_recompute_checked(self, value: bool) -> None:
if self._actions is None:
return
if self._actions.recompute.isChecked() == bool(value):
return
self._actions.recompute.blockSignals(True)
self._actions.recompute.setChecked(bool(value))
self._actions.recompute.blockSignals(False)
self._toolbar.set_recompute_checked(bool(value))
# ------------------------------------------------------------------ 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.
One method for both timeline modes, because it is one cursor: in analysis mode
the picture is the stored frame's own flow field (the pass's candidate
locations, on the domain grid), and in recording mode it is the decoded video
frame, which carries no flow and no depth — so the plan says so in one line and
the panel offers exactly the one view the frame can be. 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.
Nothing here writes anything: a display change reaches this method through the
engine's ``redraw()`` and rebuilds the picture from the same stored frame.
"""
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
if self.analysis_active():
frame = self._engine.analysed_frame()
if frame is None:
self._image_panel.clear_preview(self._engine.video_message())
return
self._host._show_image_frame(
ImageFrame(
frame.base_bgr,
flow=frame.flow,
valid_mask=frame.valid_mask,
source=SOURCE_ANALYSIS,
frame_id=int(frame.frame_id),
t_rel_s=float(frame.t_rel_s),
flow_valid=True,
flow_frame_id=int(frame.frame_id),
depth_valid=False,
)
)
session = self._engine.analysis()
if session is not None:
# The same frame, in the shape the 3D pane's producer consumes: one
# cursor, two figures, one frame id.
self._host._show_analysis_frame(session.packet(frame))
return
image = self._engine.video_frame_bgr()
if image is None:
self._image_panel.clear_preview(self._engine.video_message())
@@ -0,0 +1,275 @@
"""The progress and cancel surface for an analysis pass.
One widget, four states, and one button. It exists because analysis-on-open is the
approved default: opening a recording runs a pass that costs about half a wall-second
per video-second, so the operator has to be able to see that it is happening, see how
far along it is, and stop it.
The state it shows is deliberately the **result's** state, not the thread's:
``reused``
a compatible stored result was already there and is being played; nothing ran.
``running``
the pass is going. The bar is ``frames written / frames expected`` and the line
under it names the segment.
``complete``
the pass finished and the result may be played from.
``cancelled`` / ``failed``
the pass did **not** finish. Both say so in the same place, and neither is dressed
as success: a cancelled or failed pass leaves an incomplete result on disk, and the
reader refuses it, so "it looks finished" is exactly the state this surface must
never show for one.
Part C owns the rail's transport and reset controls; this widget owns nothing outside
its own frame, and its cancel button is enabled only while a pass is running.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QProgressBar,
QPushButton,
QSizePolicy,
QVBoxLayout,
)
#: The states the surface can be in. ``idle`` is "nothing to say" and hides the widget.
IDLE = "idle"
REUSED = "reused"
RUNNING = "running"
COMPLETE = "complete"
CANCELLED = "cancelled"
FAILED = "failed"
#: Which theme token colours the headline in each state.
_STATE_TOKEN = {
IDLE: "hint_text",
REUSED: "info",
RUNNING: "accent",
COMPLETE: "success",
CANCELLED: "warning",
FAILED: "danger",
}
#: The progress bar's thousandths (Qt's own percentage unit, 0–1000).
_FULL = 1000
class AnalysisProgress(QFrame):
"""Shows one analysis pass: how far, which segment, and how to stop it."""
#: The operator asked to cancel the running pass.
cancel_requested = Signal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self._theme = str(theme)
self._state = IDLE
self._detail = ""
self._cancelling = False
self.setObjectName("analysisProgress")
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
self.title_label = QLabel(t("analysis.title"), self)
self.title_label.setObjectName("analysisTitle")
self.detail_label = QLabel("", self)
self.detail_label.setObjectName("analysisDetail")
self.detail_label.setWordWrap(True)
self.progress_bar = QProgressBar(self)
self.progress_bar.setObjectName("analysisBar")
self.progress_bar.setRange(0, _FULL)
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(False)
self.progress_bar.setFixedHeight(6)
self.stats_label = QLabel("", self)
self.stats_label.setObjectName("analysisStats")
self.cancel_button = QPushButton(t("analysis.cancel"), self)
self.cancel_button.setObjectName("analysisCancel")
self.cancel_button.setToolTip(t("analysis.cancel_tip"))
self.cancel_button.clicked.connect(self._on_cancel_clicked)
footer = QHBoxLayout()
footer.setContentsMargins(0, 0, 0, 0)
footer.setSpacing(8)
footer.addWidget(self.stats_label, 1)
footer.addWidget(self.cancel_button, 0)
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(4)
layout.addWidget(self.title_label)
layout.addWidget(self.progress_bar)
layout.addLayout(footer)
layout.addWidget(self.detail_label)
self._refresh()
self.setVisible(False)
# --------------------------------------------------------------------- API
def state(self) -> str:
return self._state
def detail(self) -> str:
return self._detail
def cancelling(self) -> bool:
"""True between the cancel click and the pass's own report."""
return self._cancelling
def start(self) -> None:
"""A pass began: show the surface empty and cancellable."""
self._state = RUNNING
self._cancelling = False
self._detail = ""
self.progress_bar.setValue(0)
self.stats_label.setText(t("analysis.frames", written=0, expected=0))
self.setVisible(True)
self._refresh()
def progress(
self,
*,
written: int,
expected: int,
segment_index: int = 0,
segment_count: int = 0,
frame_id: int = 0,
elapsed_s: float = 0.0,
) -> None:
"""One coalesced update from the pass."""
if self._state != RUNNING:
return
fraction = 0.0 if expected <= 0 else min(1.0, written / expected)
self.progress_bar.setValue(int(round(fraction * _FULL)))
self.stats_label.setText(
t("analysis.frames", written=int(written), expected=int(expected))
)
parts: list[str] = []
if segment_count > 0:
parts.append(
t(
"analysis.segment",
index=int(segment_index),
count=int(segment_count),
)
)
if frame_id:
parts.append(f"#{int(frame_id)}")
if elapsed_s > 0:
parts.append(t("analysis.elapsed", seconds=float(elapsed_s)))
self._detail = " · ".join(parts)
self._refresh()
def reused(self, *, frames: int = 0) -> None:
"""A stored result was already there: nothing ran, and that is the good news."""
self._state = REUSED
self._cancelling = False
self.progress_bar.setValue(_FULL)
self.stats_label.setText(t("analysis.frames", written=frames, expected=frames))
self._detail = ""
self.setVisible(True)
self._refresh()
def complete(self, *, frames: int = 0, elapsed_s: float = 0.0) -> None:
"""The pass finished and the result may be played from."""
self._state = COMPLETE
self._cancelling = False
self.progress_bar.setValue(_FULL)
self.stats_label.setText(t("analysis.frames", written=frames, expected=frames))
self._detail = t("analysis.complete", frames=int(frames), seconds=float(elapsed_s))
self.setVisible(True)
self._refresh()
def cancelled(self, *, frames: int = 0) -> None:
"""The pass was stopped: the result is incomplete and will not be played."""
self._state = CANCELLED
self._cancelling = False
self.progress_bar.setValue(0)
self.stats_label.setText(t("analysis.frames", written=frames, expected=frames))
self._detail = t("analysis.cancelled", frames=int(frames))
self.setVisible(True)
self._refresh()
def failed(self, *, reason: str = "", frames: int = 0, cancelled: bool = False) -> None:
"""The pass did not finish, whether by cancellation or by failure."""
if cancelled:
self.cancelled(frames=frames)
return
self._state = FAILED
self._cancelling = False
self.progress_bar.setValue(0)
self.stats_label.setText(t("analysis.frames", written=frames, expected=0))
self._detail = t("analysis.failed", reason=reason or "?")
self.setVisible(True)
self._refresh()
def clear(self) -> None:
"""Hide the surface: no pass has anything to say."""
self._state = IDLE
self._cancelling = False
self._detail = ""
self.progress_bar.setValue(0)
self.setVisible(False)
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self._refresh()
def retranslate(self) -> None:
self.title_label.setText(t("analysis.title"))
self.cancel_button.setText(
t("analysis.cancelling") if self._cancelling else t("analysis.cancel")
)
self.cancel_button.setToolTip(t("analysis.cancel_tip"))
self._refresh()
# ------------------------------------------------------------------ private
def _on_cancel_clicked(self) -> None:
if self._state != RUNNING or self._cancelling:
return
self._cancelling = True
self.retranslate()
self.cancel_requested.emit()
def _refresh(self) -> None:
"""Repaint from the state: colour, which chrome is offered, and the tooltip.
The title is one string in every state — what a pass is doing does not change
its name — so the *state* reaches the operator through the colour token, the
detail line and whether cancel is offered, not through a second vocabulary.
"""
token = _STATE_TOKEN.get(self._state, "hint_text")
running = self._state == RUNNING
self.cancel_button.setVisible(running)
self.cancel_button.setEnabled(running and not self._cancelling)
self.progress_bar.setVisible(running)
self.title_label.setText(t("analysis.title"))
self.title_label.setStyleSheet(
f"color: {color(self._theme, token)}; font-weight: 600;"
)
self.detail_label.setText(self._detail)
self.detail_label.setVisible(bool(self._detail))
self.stats_label.setStyleSheet(f"color: {color(self._theme, 'text_secondary')};")
self.detail_label.setStyleSheet(
f"color: {color(self._theme, 'text_secondary')};"
)
self.setStyleSheet(
"QFrame#analysisProgress {"
f" background: {color(self._theme, 'card_bg')};"
f" border: 1px solid {color(self._theme, 'card_border')};"
" border-radius: 6px; }"
"QProgressBar {"
f" background: {color(self._theme, 'input_bg')};"
f" border: 1px solid {color(self._theme, 'input_border')};"
" border-radius: 3px; }"
"QProgressBar::chunk {"
f" background: {color(self._theme, 'accent')}; border-radius: 2px; }}"
)
self.setToolTip(self._detail)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)