(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 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
`result_available`): `arrows_pure`, `arrows_stitched`, `depth`, `recorded`. With a result all
four are offered, even on the 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
(`panels.replay_analysis_unavailable`).
| Mode | Drawn from |
|---|---|
| recorded | the decoded `raw_*.mp4` frame (`RecordingReader.video_frame_at`) |
| arrows / arrows-captured | the cached samples: the grid keyframe's locations plus this frame's float32 `dx`/`dy`/`magnitude` and bit-packed `active` (4,034 B/frame before the depth channel; 4,048 B/frame now) |
| 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) |
| arrows / arrows-captured | the cached samples: the grid keyframe's locations plus this frame's float32 `dx`/`dy`/`magnitude` and bit-packed `active` (4,048 B/frame with the depth channel) |
| 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 (Part A §1) |
Measured: the depth map is exactly all zeros on 241 of 264 frames of the eight-times clip
(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 blank depth view is 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. Measured: the map is exactly all zeros on 32 of the box recording's 33
frames (Part A §1 has the rest).
**A box replay's arrow canvas is painted, by the shared painter.** The box style draws through
the SDK's `orisys.util.draw_arrows`, which sweeps the dense sampling grid; a replay hands it a
*recovered* field, finite only at the stored candidate cells. Measured on one stored box frame:
a dense field through the SDK painter paints 24,440 px, that field in its recovered form 0 px,
the shared painter 1,486 px (1,486–1,964 px over the result's 25 frames with arrows). So the
painter follows **where the frame came from** — a frame read from a stored result
(`SOURCE_ANALYSIS`/`SOURCE_REPLAY`) is drawn per sample, a live or recomputed one keeps the SDK
path (`draw_arrow_display(shared_painter=True)`) — and the box style still owns the
magnification and the idle-dot tint.
A mode needing derived data is served from the result, never faked: with none, that view is not
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`,
offered and the state line names the action. The cursor stays on the recording's own timeline
while the derived views are read from the result — including while the session that recorded it
is still live, because reading a result opens no chain.
Verify: `test_image_backend.py` (the plan and the painter routing),
`::test_a_mode_change_writes_no_analysis`. A backward seek cannot re-derive a frame either: the
chain is forward-only, so it would cost a full re-run (Part A §8).
Derived views cannot be computed from raw data during a scrub: the chain is stateful and
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 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:
`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 belongs to export
rather than the cache. Not implemented.
Results live under `%LOCALAPPDATA%\OrisysStudio\analysis-cache\`
(`paths.analysis_cache_dir()`) — never the user's recording folder or the app's `outputs/` (the
EXE's own directory in a packaged build). 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. A bare video's generated
workspace lives under `…\video-workspaces\<video digest>\`. Implemented: a 1 GiB cap with LRU
eviction and `ResultCache.sweep()` at startup. Not implemented: a manual clear action, the opt-in
wipe-on-exit preference and result export (≈70 MB per ten minutes).
## Owners and status
@@ -165,14 +163,17 @@ rather than the cache. 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 |
| Mode plan, drawing, window wiring | `image_backend.py`, `acquisition/arrows.py`, `app_window.py` | shipped (Part B §12); the painter is chosen by the frame's source |
| A bare video: workspace, setup prompt, per-frame force | `analysis/bare_video.py`, `ui/video_analysis_dialog.py`, `analysis/{model,service,reader}.py`, `force/source.py` | shipped (appended`FRAME_FLAG_FORCE`, format version unchanged) |
| Display-control separation (Part C) | `toolbar.py`, `display_prefs.py`, `app_window.py` | not started (Part B §12) |
## Not confirmed in code
* 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 render cost (≈1.4 ms) and the PNG estimate (180–540 MB per ten minutes) are the owner's
bench; Part B §14's snapshots (4.2–4.9 kB each) suggest the estimate is high.
* The manual clear action, the wipe-on-exit preference and result export have no code in
`orisys-app`; they are decisions.
* A chosen calibration can be applied to the **box** chain only (`Sensor(cal_path=…)`). The
finger runtime opens its sensor without one, so a non-default curve for a finger profile is
refused rather than silently ignored — the SDK has no entry point for it (reported, not
Every sensor configuration the Studio offers is a folder under `apps/studio/config/sensors/`
holding `sensor.json` — the processing configuration. `orisys_app_shared.sensors.bundle_catalog`
scans that folder and the writable user root beside it, and resolves each candidate through the
SDK's `orisys.sensors.resolve_bundle`, so the picker shows what the SDK decided rather than a
second opinion computed in the UI.
`finger/` also carries `manifest.json`, the bundle descriptor. A descriptor states the profile's
identity and its files instead of leaving both to be inferred, which is what lets the bundle keep
its meaning when the folder is moved or renamed.
## The finger descriptor
| Field | Value | Why |
|---|---|---|
| `schema_version` | `1` | The SDK's current descriptor schema (`CURRENT_DESCRIPTOR_SCHEMA`). |
| `profile_id` | `orisys.finger.r01` | The processing profile this folder is: finger design revision `r01`, whose unroll map and camera scale the configuration pins. Never a product-family name. |
| `display_name` | `Finger R01` | Shown in the picker. Excluded from the processing fingerprint. |
| `product_family` | `finger` | What the operator calls it. `finger-mems` is a different family on a different pipeline. |
| `processing_backend` | `uv_unroll` | The pipeline the finger actually runs: `FingerRuntime` builds a UV domain from the export's unroll map, then flow and force. |
| `sensor_json` | `sensor.json` | The configuration, still the only place camera, tracker and contact settings live. |
| `computational_assets.unroll_map` | `assets/30fps-0703/unroll_map_uv_s0.42.npz` | The map the chain loads. `finger_worker` derives that name for itself from the export folder and `camera.source_scale`, so the descriptor and the chain are checked against each other. Required: without it the profile fails. |
| `assets.scene` | `scene/finger-display-1.0.0.oryscene` | The compiled package the 3D pane draws. Visualization only: a missing one never blocks processing. |
Every path is relative to the bundle root, and the SDK refuses an absolute path or a `..` escape
when it parses the descriptor. `assets/visualization/` is deliberately left out of `assets`: it is
offline compiler input, the packaged build strips it, and nothing at runtime reads it.
`sensor.json` declares `"type": "single_camera"` while the descriptor says `uv_unroll`. With a
descriptor present the descriptor decides, and it is the truthful one — the finger path never
builds a single-camera processor. Do not "correct" `type`: it is part of the hashed configuration
(only `name`, `version` and `display_name` are skipped), so editing it would re-identify every
recorded finger pass and change no number.
## Compatibility keys
`name`, `assets_dir` and `viz_pose` are kept beside the descriptor fields — the shape this bundle
had before it carried a descriptor. They are relative too, so they do not weaken rename safety,
and they keep readers that never learned about the descriptor working:
| Key | Reader |
|---|---|
| `assets_dir` | `orisys/configs.resolve_sensor_bundle` → `resolve_sensor_asset_dir`, which `orisys.finger.exports` consults for a bundled export; `acquisition/geometry.py::describe_sensor_geometry`, the device-geometry check the 3D pane runs; `build/studio/build.ps1`, which checks the packaged copy; `orisys-sdk/examples/gui/viewer3d/presets.py`. |
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.