Read the analysis cache before refusing a pass, and own the cache root

Opening a recording while the session that recorded it was still live never reached
``stored_result()``, so the panel reported no result over a recording whose result was on
disk. The guard now follows the cache read, results move to the app's derived-data root
(swept at startup, the old in-recording root ignored), and the panel states which of the
four no-result states it is in, with the pass's own reason.
Verified: apps/studio 462/0/18, shared 431/0/1, autocal 358, ui_check 628/0, both gates
clean, real-GL snapshots per state.
This commit is contained in:
ykcheungab
2026-10-04 16:53:50 +08:00
parent 1aa454311f
commit a92a2ba237
14 changed files with 1138 additions and 170 deletions
@@ -301,6 +301,11 @@ class StudioMainWindow(QMainWindow):
parent=self,
)
self.replay_ctl.wire()
# The analysis cache has no other owner: results live in the app's own
# derived-data root, so the app is what tidies them. Startup is the only moment
# nothing is open yet, and the sweep is what gives ``evict()`` a call site —
# incomplete results first, then the size cap (Part A §7).
self.replay_ctl.sweep_cache()
# 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,
@@ -31,6 +31,10 @@ import numpy as np
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
DERIVED_MODES,
RESULT_FAILED,
RESULT_IMPOSSIBLE,
RESULT_NONE,
RESULT_RUNNING,
SOURCE_ANALYSIS,
SOURCE_REPLAY,
ImageFrame,
@@ -170,7 +174,16 @@ class ReplayController(QObject):
self.results = ResultLibrary()
self._analysis_worker: AnalysisWorker | None = None
self._analysis_target: Path | None = None
#: Why the last pass left no playable result ("" when it did), why no pass ran at
#: all, and why no pass *could* run. All three are state the panel reads
#: (:meth:`result_state`): the first is the pass's own reason, the second the
#: refusal's, and keeping them apart is what lets the line say "a session is
#: running" instead of "failed".
self._analysis_error = ""
self._analysis_skip = ""
#: Non-empty when this recording names no chain to analyse with: the key cannot be
#: computed, so neither a cache read nor a pass is possible.
self._analysis_no_chain = ""
self._play_from = 0.0
self._play_started_at = 0.0
self.play_timer = QTimer(self)
@@ -653,8 +666,14 @@ class ReplayController(QObject):
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
"""Why no playable result came out of the last attempt ("" when one did).
Two ways to end with nothing, and both are the operator's business: a pass that
ran and did not finish, and a pass that was refused before it opened a chain
(a session or a preview is live). :meth:`result_state` keeps them apart for the
panel; a caller that only asks "is something wrong?" gets either.
"""
return self._analysis_error or self._analysis_skip
def on_display_settings(self, settings: Any) -> None:
"""A display setting changed: redraw from the stored frame, write nothing.
@@ -693,26 +712,84 @@ class ReplayController(QObject):
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.
**The stored result is read before the session guard, and that order is the
point.** Serving a result is a *file read* — no SDK chain, no working directory,
no device — so the rule that one chain runs at a time does not apply to it. When
the guard came first, opening a recording while a session (or a preview) was
open refused the whole call, which meant the cache was never read: the operator
was shown "this recording has no analysis result" over a recording whose result
was sitting on disk, and had to stop the session and reopen it to see the arrows.
Only a *pass* is refused now, and that refusal is recorded so the panel can say
it (:meth:`result_state`) instead of promising a pass that will not come.
"""
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:
# No session metadata and no segment: nothing here can be analysed at all,
# which is a different statement from "not analysed yet" (see ``result_state``).
self._clear_analysis_state()
self._host._notify(t("playback.analysis_no_source"))
self._set_recompute_checked(False)
self.render_frame()
return
self._analysis_error = ""
self._clear_analysis_state()
if self._activate_stored_result(recording, notify_reuse=True):
return
if self._host._running or self._host._preview_open or self._host._session_active:
# A 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. The refusal
# is *state*, not a status line: the panel says which one it is, and
# 「从视频重算」 starts the pass the moment the session is stopped.
self._analysis_skip = t("analysis.blocked")
self._host._notify(self._analysis_skip)
self._set_recompute_checked(False)
self.render_frame()
return
if self._analysis_no_chain:
# No chain could be built for this recording, so a pass cannot be started
# either. The reason is already recorded and stated; running a worker only to
# watch it fail would replace that sentence with a traceback line.
self._set_recompute_checked(False)
self.render_frame()
return
self._start_pass(recording)
def _clear_analysis_state(self) -> None:
"""Forget the previous attempt's outcome before a new one is made."""
self._analysis_error = ""
self._analysis_skip = ""
self._analysis_no_chain = ""
def result_state(self) -> tuple[str, str]:
"""Which no-result state the 2D panel is in, and the reason to show with it.
Four states, and the panel has to tell them apart because the operator's next
action is different in each: a pass is coming (:data:`RESULT_RUNNING`), a pass
ended without a result or was refused (:data:`RESULT_FAILED`, with the reason),
no pass can serve this frame at all (:data:`RESULT_IMPOSSIBLE`), or — the only
state whose line may promise the automatic pass — nothing has run *yet*
(:data:`RESULT_NONE`). It is empty when there *is* a result, because then the
panel has modes to offer instead of an excuse.
The reason is the controller's own record — ``_analysis_error`` for a pass that
ended without a result (including a stored result it could not read), and the
skip reason for a pass that was refused before it started. Neither is invented
here: a failure with no recorded reason says so.
"""
if self.available_result() is not None:
return "", ""
if self.analysis_running():
return RESULT_RUNNING, ""
reason = self._analysis_error or self._analysis_skip
if reason:
return RESULT_FAILED, reason
if self.recording_directory() is None:
return RESULT_IMPOSSIBLE, ""
return RESULT_NONE, ""
def recording_directory(self) -> Path | None:
"""The recording directory an analysis pass should read."""
directory = self._engine.directory()
@@ -732,12 +809,26 @@ class ReplayController(QObject):
) -> 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.
Returns ``False`` when there is nothing to read — no result, a result the
reader refuses (an incomplete one), or a recording that names no chain at all
(see below). Each of those is a *reason to run a pass* rather than something to
serve; whether a pass can then run is the caller's question.
"""
service = self._host._analysis_service(recording, None)
try:
service = self._host._analysis_service(recording, None)
except Exception as exc: # noqa: BLE001 - a recording with no chain is not fatal
# The recording does not say which sensor and configuration produced it, so
# the result key cannot even be computed and no pass could be built. Recorded
# as the reason the panel states: raised from here it would escape into the
# Qt slot that opened the recording and vanish, leaving the operator with a
# half-opened replay and no explanation. Starting a pass is pointless too, so
# the caller is told why: only a *reading* failure is a reason to run one.
logger.info("this recording names no analysis chain: %s", exc)
self._analysis_no_chain = t(
"playback.analysis_no_chain", error=f"{type(exc).__name__}: {exc}"
)
self._analysis_error = self._analysis_no_chain
return False
reader = None
try:
reader = service.stored_result()
@@ -768,6 +859,10 @@ class ReplayController(QObject):
return False
self.results.close()
self._adopt_session(session)
# A readable result is the end of every "why is there nothing to draw" story: the
# reasons are cleared here so the panel cannot keep reporting a pass that a later
# one superseded.
self._clear_analysis_state()
frames = session.frame_count
if notify_reuse:
# The rail's own notification (the 3D pane's source line and the 2D footer say
@@ -860,6 +955,12 @@ class ReplayController(QObject):
if not worker.start(): # pragma: no cover - guarded by ``analysis_running``
logger.warning("an analysis pass was already running")
self._analysis_worker = None
return
# The pass is now a fact the panel can state ("analysis is running"), so it is
# redrawn once the thread is really alive — before that, "a pass is running" would
# be a claim nothing backs. The note must not keep promising a pass that has
# already started.
self.render_frame()
def on_analysis_progress(self, progress: Any) -> None:
if self._progress is None:
@@ -891,6 +992,7 @@ class ReplayController(QObject):
if self._progress is not None:
self._progress.cancelled(frames=report.frames_written)
self._set_recompute_checked(False)
self.render_frame()
return
if not report.complete:
self._analysis_error = report.reason or "incomplete"
@@ -899,6 +1001,10 @@ class ReplayController(QObject):
reason=self._analysis_error, frames=report.frames_written
)
self._set_recompute_checked(False)
# The panel is where the operator is looking, so the reason is put there as
# well as on the progress surface: a strip that says "failed" over a canvas
# that still promises a pass is two surfaces disagreeing about one fact.
self.render_frame()
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
@@ -907,6 +1013,7 @@ class ReplayController(QObject):
self._analysis_error = "unreadable"
if self._progress is not None:
self._progress.failed(reason="unreadable")
self.render_frame()
return
if self._progress is not None:
self._progress.complete(
@@ -949,6 +1056,43 @@ class ReplayController(QObject):
self._actions.recompute.blockSignals(False)
self._toolbar.set_recompute_checked(bool(value))
# -------------------------------------------------------------------- cache
def sweep_cache(self, *, root: str | Path | None = None) -> int:
"""Drop stale and over-budget results from the app's own analysis cache.
The app's only *lifetime* action on the cache, and it belongs at startup: the
results live in the app's own derived-data root, nothing else ever tidies them,
and a directory left by a cancelled or crashed pass is unreadable forever
(``INCOMPLETE``), so it is the cheapest thing to lose. The size cap and the
oldest-first order are :func:`~orisys_app_shared.analysis.cache.evict`'s; what
this adds is the call site and the incomplete sweep.
Returns how many results were removed. Never raises: this runs while the window
is being built, and a cache that cannot be tidied must not stop the app from
opening.
"""
from orisys_app_shared.analysis.cache import ResultCache
keep = []
session = self.results.session
if session is not None:
# Never remove what the operator is looking at, even if a sweep is triggered
# later than startup (a test, a caller).
keep.append(Path(session.directory))
try:
report = ResultCache(root).sweep(keep=keep)
except Exception: # noqa: BLE001 - a cache that cannot be swept is not fatal
logger.warning("could not sweep the analysis cache", exc_info=True)
return 0
if report.removed:
logger.info(
"analysis cache sweep: removed %d result(s) (%d incomplete), %d bytes left",
len(report.removed),
len(report.incomplete),
report.remaining_bytes,
)
return len(report.removed)
def available_result(self):
"""The stored result for the recording that is open, if there is one.
@@ -1064,6 +1208,11 @@ class ReplayController(QObject):
if image is None:
self._image_panel.clear_preview(self._engine.video_message())
return
# Which *absence* this is, when there is nothing behind the picture. The panel
# has one line for it, and the four states need four different sentences: a pass
# that is coming, one that failed or was refused (with its reason), and a frame
# no pass can serve are not the same news as "nothing has run yet".
state, reason = self.result_state()
self._host._show_image_frame(
ImageFrame(
image,
@@ -1073,6 +1222,8 @@ class ReplayController(QObject):
# it (above). Without one there is nothing behind the picture and the
# panel says exactly that.
result_available=session is not None,
result_state=state,
result_reason=reason,
)
)
@@ -354,6 +354,10 @@ class ArrowPanel(QFrame):
if name in ("flow", "depth")
)
note = t(plan.note_key, fields=names)
elif plan.note_reason:
# A line that names a cause ("the pass failed: …") takes the reason the
# producer recorded; a line that does not mention one is unaffected.
note = t(plan.note_key, reason=plan.note_reason)
else:
note = t(plan.note_key)
if modes != self._modes or note != self._plan_note: