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:
@@ -5,9 +5,9 @@ capability states its requirement, data source and a falsifying check. The resul
|
||||
playback semantics are owned by Part A ([replay-analysis.md](replay-analysis.md)) and Part B
|
||||
([replay-analysis-part-b.md](replay-analysis-part-b.md)).
|
||||
|
||||
Status: Parts A and B shipped at `cf49b08`, the depth channel at `9e20fd0`; the result-aware
|
||||
mode plan and the wiring that reaches it are in the working tree (Part B §9, §14). The cache-root
|
||||
and lifetime policy and the display-control separation (Part C) are decided below, not implemented.
|
||||
Status: Parts A and B shipped at `cf49b08`, the depth channel at `9e20fd0`, the result-aware mode
|
||||
plan and the wiring that reaches it at `1aa4543`. The cache root and the lifetime sweep are
|
||||
implemented (§Cache and export policies); the display-control separation (Part C) is not.
|
||||
|
||||
## 1. Load a video
|
||||
|
||||
@@ -17,10 +17,10 @@ opens as a bare video (`VideoOnlySource`) and plays, with `playback.video_only_o
|
||||
rail and `playback.video_only_notice` in the notes. A file that cannot be decoded is refused
|
||||
with its reason.
|
||||
|
||||
Not available for a bare video: force rows (the charts are empty by construction),
|
||||
`session.json` metadata, and an analysis pass — `recording_directory()` needs a
|
||||
`session.json` and reports `playback.analysis_no_source`; the only route to numbers is
|
||||
rerunning the acquisition pipeline over the video (`on_recompute_toggled`), forward only.
|
||||
Not available for a bare video: force rows (the charts are empty by construction), `session.json`
|
||||
metadata, and an analysis pass — `recording_directory()` needs a `session.json` and reports
|
||||
`playback.analysis_no_source`; the only route to numbers is rerunning the acquisition pipeline over
|
||||
the video (`on_recompute_toggled`), forward only.
|
||||
|
||||
Source: `record/playback.py`, `replay_controller.py`.
|
||||
Verify: `test_ui_smoke.py::test_a_bare_video_plays_even_though_it_is_not_a_recording`;
|
||||
@@ -36,11 +36,10 @@ coalesced at 0.1 s, with `AnalysisProgress` showing written/expected frames and
|
||||
The timeline stays in `recording` mode until the pass commits: a half-written result never
|
||||
reaches the screen.
|
||||
|
||||
The pass is sequential and forward-only: `_pass_frames` walks segments and frames in order,
|
||||
and the chain is stateful (`Sensor.reset()` clears the tracker). Cancelling sets an event; the
|
||||
result keeps its `INCOMPLETE` marker and default readers refuse it (Part A §7). Measured:
|
||||
0.48–0.51 wall-seconds per video-second (Part A §8: 0.5036 s mean against 0.5052 s for the bare
|
||||
chain) — ten minutes is ≈4.8 minutes.
|
||||
The pass is sequential and forward-only: `_pass_frames` walks segments and frames in order, and the
|
||||
chain is stateful (`Sensor.reset()` clears the tracker). Cancelling sets an event; the result keeps
|
||||
its `INCOMPLETE` marker and default readers refuse it (Part A §7). Measured: 0.48–0.51
|
||||
wall-seconds per video-second (Part A §8) — ten minutes is ≈4.8 minutes.
|
||||
|
||||
Source: `analysis/service.py`, `analysis_worker.py`, `ui/analysis_progress.py`.
|
||||
Verify: `test_replay_analysis.py::test_opening_a_recording_with_a_stored_result_runs_no_pass`,
|
||||
@@ -94,8 +93,11 @@ Verify: `test_replay_analysis.py::test_the_3d_pane_is_fed_the_same_frame_the_can
|
||||
The modes come from the frame's own fields and from whether a result exists (`plan_image`,
|
||||
`result_available`): `arrows_pure` (Arrows), `arrows_stitched` (Arrows / Captured), `depth`
|
||||
(Depth map), `recorded` (Recorded image). With a result all four are offered, even on the
|
||||
recording's timeline, defaulting to `recorded`; with none, `recorded` alone plus
|
||||
`panels.replay_raw_note`.
|
||||
recording's timeline, defaulting to `recorded`; with none, `recorded` alone plus one line naming
|
||||
*which* absence it is (`result_state`): no result yet (`panels.replay_raw_note`), a pass running
|
||||
(`panels.replay_analysis_running`), a pass failed or refused with its own reason
|
||||
(`panels.replay_analysis_failed`), or no pass possible at all — a bare video
|
||||
(`panels.replay_analysis_unavailable`).
|
||||
|
||||
| Mode | Drawn from |
|
||||
|---|---|
|
||||
@@ -104,18 +106,26 @@ recording's timeline, defaulting to `recorded`; with none, `recorded` alone plus
|
||||
| depth | a per-frame depth flag, a non-zero count and min/max; only a non-empty map adds a uint8 plane quantised over that frame's range and zlib'd at level 6 (island restriction rejected: 418 B vs 486 B on the worst frame) |
|
||||
|
||||
Measured: the depth map is exactly all zeros on 241 of 264 frames of the eight-times clip
|
||||
(≈91 %; 32 of 33 on the box recording) and never exceeded 336 of 84,000 pixels (0.4 %). A blank
|
||||
depth view is therefore usually correct and must be explainable: `panels.depth_empty` says the
|
||||
frame's depth is all zeros, `panels.depth_unavailable` says the frame has no depth channel at
|
||||
all — different facts, neither drawing the processed image.
|
||||
(32 of 33 on the box recording) and never exceeded 336 of 84,000 pixels (0.4 %). A blank depth view
|
||||
is therefore usually correct and must be explainable: `panels.depth_empty` says the frame's depth is
|
||||
all zeros, `panels.depth_unavailable` says the frame has no depth channel at all — different facts,
|
||||
neither drawing the processed image.
|
||||
|
||||
**A box replay's arrow canvas is empty** (pre-existing): the box style draws through the SDK's
|
||||
`orisys.util.draw_arrows`, while a replay hands it the recovered field, finite only at the stored
|
||||
candidate cells — one dense field paints 24,440 px, its recovered form 0 px, the shared painter
|
||||
1,486 px.
|
||||
|
||||
A mode needing derived data is served from the result, never faked: with none, that view is not
|
||||
offered and `panels.replay_raw_note` names the action that produces a result. The state above is
|
||||
what an operator reaches by opening a recording that has a result: the cursor stays on the
|
||||
recording's own timeline and the panel opens on a derived view, the recorded one still offered.
|
||||
offered and the state line names the action or the reason. The state above is what an operator
|
||||
reaches by opening a recording that has a result: the cursor stays on the recording's own timeline
|
||||
and the panel opens on a derived view, the recorded one still offered — including while the
|
||||
session that recorded it is still live, because reading a result opens no chain.
|
||||
Verify: `test_image_backend.py` (the plan), `test_replay_analysis.py::test_a_derived_mode_is_served_from_the_result_even_on_the_recording_timeline`,
|
||||
`::test_the_empty_depth_note_is_not_the_missing_depth_note`, `test_analysis_depth.py::test_an_empty_map_costs_a_header_and_states_its_shape`,
|
||||
`test_replay_wiring.py::test_the_operator_sees_the_cached_views_over_the_recording`.
|
||||
`::test_the_four_no_result_states_are_four_different_lines`,
|
||||
`::test_the_empty_depth_note_is_not_the_missing_depth_note`,
|
||||
`test_replay_wiring.py::test_the_operator_sees_the_cached_views_over_the_recording`,
|
||||
`::test_a_running_session_does_not_hide_a_stored_result`.
|
||||
|
||||
## Cache and export policies
|
||||
|
||||
@@ -129,33 +139,34 @@ Derived views cannot be computed from raw data during a scrub: the chain is stat
|
||||
forward-only, so a backward seek would need a full re-run at 0.48–0.51 s per video-second; the
|
||||
panel scrubs stored numbers and offers that run separately.
|
||||
|
||||
Cache location. Results belong under an OS-correct derived-data root,
|
||||
`%LOCALAPPDATA%\OrisysStudio\analysis-cache\` — never the user's recording folder or the app's
|
||||
`outputs/`, which in the packaged build is the EXE's own directory and holds the recordings
|
||||
(`paths.recordings_dir()` → `outputs/recordings`). The key is path-independent
|
||||
(`recording_identity` ignores the path, includes session facts and segment sizes), so a copied
|
||||
recording finds its result. Today `ResultCache.for_recording` roots the cache at
|
||||
`<recording>/analysis`; the OS root is decided, not implemented.
|
||||
Cache location. Results live under the OS-correct derived-data root,
|
||||
`%LOCALAPPDATA%\OrisysStudio\analysis-cache\` (`paths.analysis_cache_dir()`) — never the user's
|
||||
recording folder or the app's `outputs/`, which in the packaged build is the EXE's own directory
|
||||
and holds the recordings. The key is path-independent (`recording_identity`), so a copied
|
||||
recording finds its result; a result left under the old `<recording>/analysis` root is ignored —
|
||||
neither read nor deleted (`test_replay_analysis.py::test_a_result_under_the_old_root_is_ignored_not_migrated`).
|
||||
|
||||
Cache lifetime. A size cap with LRU eviction (1 GiB, `DEFAULT_MAX_BYTES`), a startup sweep of
|
||||
stale and incomplete results, a manual "clear analysis cache" action, and wipe-on-exit only as
|
||||
an explicit preference that is off by default — because wiping on exit forces a full re-analysis
|
||||
(≈4.8 minutes per ten minutes) on reopen and does not protect against a crash. Implemented
|
||||
today: `evict()` removes whole results, incomplete first then oldest, protecting the caller's
|
||||
`keep` — with no call site in the app, so there is no sweep, no clear action and no preference.
|
||||
stale and incomplete results, a manual "clear analysis cache" action, and wipe-on-exit only as an
|
||||
explicit preference that is off by default — because wiping on exit forces a full re-analysis
|
||||
(≈4.8 minutes per ten minutes) on reopen and does not protect against a crash. Implemented:
|
||||
`evict()` (incomplete first, then oldest, protecting `keep`) and `ResultCache.sweep()`, which
|
||||
clears incomplete results older than an hour and then applies the cap, called once at startup
|
||||
(`ReplayController.sweep_cache`). Not implemented: the clear action and the wipe-on-exit
|
||||
preference.
|
||||
|
||||
Export. The deliberate way to keep a result is to export it: ≈70 MB per ten minutes, portable
|
||||
because the key ignores where the file sits. A rendered arrow or depth video is a later
|
||||
possibility, belonging to export rather than the cache. Not implemented.
|
||||
because the key ignores where the file sits. A rendered arrow or depth video belongs to export
|
||||
rather than the cache. Not implemented.
|
||||
|
||||
## Owners and status
|
||||
|
||||
| Part | Owner | Status |
|
||||
|---|---|---|
|
||||
| Format, cache, reader, pass | `orisys_app_shared.analysis` | A/B shipped; depth channel uncommitted |
|
||||
| Timeline, cursor, policy, pass thread, progress surface | `replay_analysis.py`, `replay.py`, `replay_controller.py`, `analysis_worker.py`, `ui/analysis_progress.py` | shipped; the depth view and the result-aware mode plan are uncommitted |
|
||||
| Mode plan, drawing, window wiring | `image_backend.py`, `acquisition/arrows.py`, `app_window.py` | shipped (Part B §12 names the seams); the wiring from the operator's open action, and its snapshots, are in the working tree |
|
||||
| Cache root and lifetime | `analysis/cache.py`, app preferences | decided, not implemented |
|
||||
| Format, cache, reader, pass | `orisys_app_shared.analysis` | shipped (`9e20fd0`, cache root and sweep now) |
|
||||
| Timeline, cursor, policy, pass thread, progress surface | `replay_analysis.py`, `replay.py`, `replay_controller.py`, `analysis_worker.py`, `ui/analysis_progress.py` | shipped; the no-result states are part of the panel's line |
|
||||
| Mode plan, drawing, window wiring | `image_backend.py`, `acquisition/arrows.py`, `app_window.py` | shipped (Part B §12 names the seams) |
|
||||
| Cache root and lifetime | `analysis/cache.py`, `paths.py`, `app_window.py` | root and startup sweep implemented; clear action and wipe-on-exit preference are not |
|
||||
| Display-control separation: rail reset as the measurement reference, a separate display baseline, the F8 view reset | `toolbar.py`, `display_prefs.py`, `app_window.py` | not started; Part B §12 records these as unchanged. The pass accepts a `reference_frame` but `_analysis_service` passes none |
|
||||
|
||||
## Not confirmed in code
|
||||
@@ -163,6 +174,5 @@ possibility, belonging to export rather than the cache. Not implemented.
|
||||
* The render cost (≈1.4 ms, idle dots 37 µs) and the PNG estimate (180–540 MB per ten minutes)
|
||||
are the owner's bench, not measurements here; Part B §14's single-frame snapshots (4.2–4.9 kB)
|
||||
suggest the estimate is high, so it should be re-measured.
|
||||
* The OS cache root, startup sweep, clear action, wipe-on-exit preference and result export
|
||||
have no code in `orisys-app`; they are decisions.
|
||||
* The status lines describe the working tree, not `cf49b08`: the depth work is uncommitted.
|
||||
* The manual clear action, the wipe-on-exit preference and result export have no code in
|
||||
`orisys-app`; they are decisions.
|
||||
|
||||
Reference in New Issue
Block a user