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:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user