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