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
+6 -2
View File
@@ -223,7 +223,10 @@
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses",
"analysis_pipeline": "Analysis result (one finished pass)",
"analysis_pipeline_tip": "This frame's arrows come from a stored analysis result rather than a chain running now; changing the threshold only changes which stored samples count as active.",
"analysis_empty": "the analysis result holds no frame here"
"analysis_empty": "the analysis result holds no frame here",
"replay_analysis_running": "this recording is being analysed: there are no arrows or depth to draw until it finishes. Progress and Cancel are on the analysis panel.",
"replay_analysis_failed": "there is no analysis result to draw: {reason}. Only the raw camera image can be shown here; once that is dealt with, Recompute from the video runs the pass again by hand.",
"replay_analysis_unavailable": "this is not a recording that can be analysed (its folder has no session.json, or no raw_*.mp4 segment): no pass can run, so this panel shows the raw camera image only. For forces and deformation from this video use View > Recompute forces from the video."
},
"chart": {
"fn": "FN",
@@ -292,7 +295,8 @@
"analysis_cancelled": "the analysis was cancelled: no complete result was written, so no playback is served",
"analysis_no_source": "this recording has no video segment to analyse",
"analysis_busy": "an analysis is already running: wait for it, or cancel it",
"analysis_grid_mismatch": "the current display settings moved the sample grid ({keys}); the stored candidate locations cannot serve it, so the replay keeps drawing the result's own grid"
"analysis_grid_mismatch": "the current display settings moved the sample grid ({keys}); the stored candidate locations cannot serve it, so the replay keeps drawing the result's own grid",
"analysis_no_chain": "this recording does not say which sensor and configuration produced it ({error}), so no result can be read and no pass can be run"
},
"record": {
"label": "Record",
+6 -2
View File
@@ -223,7 +223,10 @@
"mesh3d_reset_tip": "复位视角:回到场景自带的取景",
"analysis_pipeline": "分析结果(已跑完的一遍)",
"analysis_pipeline_tip": "这一帧的箭头来自存下来的分析结果,不是此刻在跑的链路;换阈值只改变哪些样本算「有效」,不会重算。",
"analysis_empty": "分析结果里没有这一帧"
"analysis_empty": "分析结果里没有这一帧",
"replay_analysis_running": "正在分析这个录像:跑完之前这里没有箭头/深度可画,进度与取消在分析面板上。",
"replay_analysis_failed": "没有可画的分析结果:{reason}。这里只能显示原始相机图像;处理完之后可以点「由视频重算」再跑一遍。",
"replay_analysis_unavailable": "这不是一条可分析的录像(同目录没有 session.json,或没有 raw_*.mp4 分段):没有能跑的分析,本面板只能显示原始相机图像。要从这段视频得到力值/形变,请用 视图 ▸ 从视频重算力值。"
},
"chart": {
"fn": "FN",
@@ -292,7 +295,8 @@
"analysis_cancelled": "分析已取消:未写出完整结果,因此不提供回放",
"analysis_no_source": "这段录制没有可用于分析的视频分段",
"analysis_busy": "分析进行中:先等待或取消当前这一遍",
"analysis_grid_mismatch": "当前显示设置改了采样网格({keys}),存下来的候选位置无法服务它:回放仍按结果自己的网格绘制"
"analysis_grid_mismatch": "当前显示设置改了采样网格({keys}),存下来的候选位置无法服务它:回放仍按结果自己的网格绘制",
"analysis_no_chain": "这条录像没有说明用哪套传感器与配置录下来({error}),所以既读不出结果也重算不了"
},
"record": {
"label": "记录",
+67 -81
View File
@@ -24,26 +24,31 @@
**应用里的时间轴永远是录像的。** 引擎留在 `recording`:游标、帧 id 与时长都是录像自己的,
结果挂在游标后面当缓存(`available_result()`),`_adopt_session()` 调用 `use_recording()` 保证
这一点。缓存有三个触点,不必先打开「从视频重算」:复用已有结果、pass 完成、选择器选中派生模式
(时间轴不动)。
这一点。缓存有三个触点,都不必先打开「从视频重算」:复用结果、pass 完成、选中派生模式。
**帧身份只来自录像自己的计数器。** 证据录像 id 是 20…52 而非 0…32,所以
`AnalysisSession.frame(frame_id)` 对结果里没有的 id 报 `AnalysisNotFoundError`:把索引当 id
传进去不会被"顺手改成能读的那一帧",那会返回一张看起来完全正确的图。按时间取帧走
`frame_id_at(t)`,取**该时刻或之前**的那一帧 —— 与顺序播放经过的是同一帧。
`AnalysisSession.frame(frame_id)` 对结果里没有的 id 报 `AnalysisNotFoundError`:把索引当 id 传
进去不会被"顺手改成能读的那一帧"。按时间取帧走 `frame_id_at(t)`,取**该时刻或之前**的那一帧
—— 与顺序播放经过的是同一帧。
## 10. 打开即分析
打开录像后固定做一件事:**有兼容结果就用,没有就跑**。`stored_result()` 命中是一次文件读,
面板显示"已有结果,无需重算";没有命中就在**工作线程**上跑 pass,`Progress` 按 0.1 s 合流后
经 Qt 信号回到 GUI 线程,面板显示 `已写/预计`、分段号与已用时间。pass 期间时间轴留在
`recording` 模式,完成后才切到 `analysis` —— 半截结果永远不上屏。面板的取消按钮置一次取消
事件,服务在检查点停下,目录带 `INCOMPLETE` 标记留下,**不提供回放**(第 13 节)。「从视频
重算」语义不变,与打开路径走同一个 `on_analysis_requested()`,因此不可能出现两条并行模式。
打开录像后固定做一件事:**有兼容结果就用,没有就跑**,而且**顺序是硬性的**:读结果是一次
文件读(不开链路、不碰设备、不改工作目录),「一次只跑一条链」管不到它,所以
`on_analysis_requested()` 先问缓存、再问会话。反过来时,只要采集会话还在(暂停也算:设备还在
手里)或预览开着,整个调用就被拒、缓存根本没读 —— 面板对着一份就在磁盘上的结果说"这个录像还
没有分析结果",操作员得停掉会话再重新打开才看得到箭头。
分析的四条终态消息**都不写状态栏**,只更新进度面板:pass 是异步的,可能在操作员做了别的事
几秒后才结束,一条活得比原因久的状态栏消息会把无关操作说成失败。原因留在面板上,也由
`analysis_error()` 提供。
**拒跑是状态,不是过期就消失的状态栏文字。** 真没有结果时才轮到会话判断:此时不跑 pass(开
链路要独占工作目录),`analysis.blocked` 作为原因记下来,2D 面板据此把话说明白(第 11 节)。
`stored_result()` 命中就是一次文件读("已有结果,无需重算");没命中才在工作线程上跑 pass,
`Progress` 按 0.1 s 合流后经 Qt 信号回到 GUI 线程。pass 期间时间轴留在 `recording`,完成后才切
到 `analysis` —— 半截结果永远不上屏;取消按钮置一次取消事件,目录带 `INCOMPLETE` 标记留下,
**不提供回放**(第 13 节)。「从视频重算」与打开路径走同一个 `on_analysis_requested()`。
分析的四条终态消息**都不写状态栏**,只更新进度面板:pass 是异步的,可能在操作员做了别的事几秒
后才结束,一条活得比原因久的状态栏消息会把无关操作说成失败。原因留在面板上,也由
`analysis_error()` 与 `result_state()` 提供:**面板那一行和进度条说的是同一件事**。
**并发与工作目录。** 开链路会往当前目录写 2 MB 预览图(Part A 第 6 节),所以 pass 跑在**本
进程**的一个线程里,启动前要求没有采集会话、没有预览、也没有第二遍分析,并且**本模块从不改
@@ -57,21 +62,29 @@
| 二维画布 | 结果的 `flow`、`depth` 与 `valid_mask` | `ImageFrame` 走窗口唯一的 2D 出口 `_show_image_frame`(录像时间轴上的派生视图带 `source="replay"` 与 `result_available=True`,因此"原始图像"仍在选择器里),箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** |
| 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 |
**深度视图也由结果喂。** `AnalysedFrame.depth` 就是第 1 节那幅量化平面重建出来的
`float32` 图(空帧是形状正确的全零),`depth_valid` 因此是 `True` 而不像以前那样恒为
`False`;深度着色、色标锁定、空/缺数据的区分全在窗口既有的那条路径上,回放没有第二条绘制
代码。"这一帧深度是精确的零"由面板说出来(`panels.depth_empty`,带 `{pixels}`),与
"这一帧根本没有深度通道"(`panels.depth_unavailable`)是两句不同的话,因为它们是两个不同的
事实。
**没有结果时那一行必须说清是四种里的哪一种。** `plan_image()` 只拿到 `ImageFrame`,四种状态
因此由生产者写进帧里(`result_state`/`result_reason`),`ReplayController.result_state()` 是唯一
的判断处;有结果时这一行根本不出现:
**画面是重建的,并且是精确重建。** 二维需要的 `[H,W,2]` 流场是把存下来的 `dx`/`dy` 写回
**记录下来的候选位置**、其余格子填 `NaN`(选区器把非有限值当作非法,邻居格子因此不可能贡献
出 pass 没记录过的样本)。写回要除以
`DISPLACEMENT_PX_PER_FLOW × flow_arrow_scale × flow_length_margin`。
| 状态 | 何时 | 那一行 |
|---|---|---|
| `none` | 还没问过缓存(打开录像时 `recording_changed` 的重画) | 点名产生结果的动作(`panels.replay_raw_note`) |
| `running` | pass 在跑 | 正在分析、进度与取消在分析面板上 |
| `failed` | 跑完没留下结果,**或**被拒启动(会话/预览开着) | 带上**自己的原因**(`analysis_error()`):`chain_failed:<frame>`、`FileNotFoundError: …` 或 `analysis.blocked` |
| `impossible` | 没有 `session.json` / 没有 `raw_*.mp4` 分段 | 这里没有能跑的分析,数字只能靠「从视频重算」 |
`dx`/`dy` 是**画出来的**位移,已经带上长度余量那一步的缩放:只用前两个因子会让每根箭头短
20%(finger 余量 0.8),330 个样本里 319 个不同、活动集也不同 —— 实测的,不是推导的。余量补
上后,重建流场重新过一遍共享选区器,样本集与存下来的**逐元素相等**(33 帧全覆盖)。
**深度视图也由结果喂。** `AnalysedFrame.depth` 就是第 1 节那幅量化平面重建出来的 `float32` 图
(空帧是形状正确的全零),`depth_valid` 因此是 `True` 而不像以前恒为 `False`;着色、色标锁定、
空/缺数据的区分全在窗口既有的那条路径上,回放没有第二条绘制代码。"这一帧深度是精确的零"
(`panels.depth_empty`,带 `{pixels}`)与"这一帧根本没有深度通道"(`panels.depth_unavailable`)
是两句不同的话,因为它们是两个不同的事实。
**画面是重建的,并且是精确重建。** 二维要的 `[H,W,2]` 流场是把存下来的 `dx`/`dy` 写回**记录
下来的候选位置**、其余格子填 `NaN`(选区器把非有限值当作非法,邻居格子因此贡献不出 pass 没记
录过的样本),除以 `DISPLACEMENT_PX_PER_FLOW × flow_arrow_scale × flow_length_margin`:
`dx`/`dy` 是**画出来的**位移,已带上长度余量那一步的缩放,只用前两个因子会让每根箭头短 20%
(finger 余量 0.8,330 个样本里 319 个不同)。余量补上后,重建流场重过一遍共享选区器,样本集
与存下来的**逐元素相等**(33 帧全覆盖)。
## 12. 显示设置是读
@@ -100,75 +113,48 @@ on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,
## 14. 验收证据
`apps/studio/tests/test_replay_analysis.py`(15 项,真实素材):
`apps/studio/tests/test_replay_analysis.py`(18 项)与 `test_replay_wiring.py`(11 项)跑真实素材,
`shared/tests/*` 覆盖格式、缓存根与清扫。要保住的断言:
* **跳转等于顺序播放**:顺序走过全部 33 帧记下每帧,再按 `time_of(frame_id)` 直接跳到 5 个
采样位置,帧 id、`rows/cols/dx/dy/active` 与流场逐项相等(非候选格按构造是 `NaN`)。
* **取消不被当完整**:pass 在第一帧后取消 → 面板报 `cancelled`、时间轴留在 `recording`、目录里
`complete=false` 且默认读端抛 `AnalysisIncompleteError`。
* **跳转等于顺序播放**:顺序走过全部 33 帧后按 `time_of(frame_id)` 跳到 5 个位置,帧 id、
`rows/cols/dx/dy/active` 与流场逐项相等;深度模式同款,往返误差见 Part A 第 1 节。
* **取消不被当完整**:第一帧后取消 → 面板报 `cancelled`、时间轴留在 `recording`、目录
`complete=false`、默认读端抛 `AnalysisIncompleteError`。
* **模式切换与显示设置都不写结果**:四个模式各画一次、改一次阈值、改一次被拒的栅格,
`scan_results(cache)` 的目录集合都不变。
* **"没什么可画"只在该说的时候说**:无结果时只给 `recorded` 并带一句说明、文案点名产生结果的
动作(两种语言);有结果时该行消失、四个模式都在。
* **会话开着也照读缓存**:会话/预览三种在场状态下,带结果的录像一打开就是 `reused`、像素等于
存下来的深度图(把守卫顺序改回去,控制器与窗口两级都失败)。
* **四种状态各一句**:`none` → `running`(真跑一遍)→ `failed`(链路配置不存在的真失败,原因
来自 pass 自己)→ `impossible`(裸视频);窗口层再断言三句面板文案本身。
* **录像目录一字未改**:`_entry_set(recording)`(相对路径 + 大小)在 open+analyze 前后相等、
`analysis/` 不存在、结果落在 `paths.analysis_cache_dir()` 下。
**深度那一片加的验收**(同一文件再 +8 项,另加 `shared/tests/test_analysis_depth.py` 10 项;
往返误差与尺寸见 Part A 第 1 节):
* **深度往返**:另跑一遍链路逐帧比 `depth_map`,在 box 录像与 264 帧的 `long_8x.mp4` 上都是
0 失败;非零计数、min/max 与链路的完全相等。
* **跳转等于顺序播放(深度模式)**:顺序播放记下每帧渲染出的深度图,再跳到 5 个位置,帧 id、
`depth`、统计与渲染出的字节逐项相等。
* **模式切换不写结果**:四个模式各画一次,`scan_results(cache)` 的目录集合不变。
* **"没什么可画"只在该说的时候说**:无结果时 `plan_image` 只给 `recorded` 并带
`replay_raw_note`;有结果时该行消失、四个模式都在,默认仍是原始图像。两种语言各断言一次,
且文案必须点出产生结果的动作。
* **派生模式走结果**:时间轴在录像上、模式选深度时,送到 2D 出口的是**结果那一帧**(含深度),
不是解码出来的视频帧;切回"原始图像"仍是录像自己的画面。
真实 GL 快照(`QT_QPA_PLATFORM=windows` + `QT_OPENGL=desktop`,真实窗口路径、pass 写入
scratch cache、游标停在存下来的帧 id 上):
真实 GL 快照(`QT_QPA_PLATFORM=windows` + `QT_OPENGL=desktop`,真实窗口路径):
| 文件 | 内容 |
|---|---|
| `.scratch/replay-analysis-b/snapshots/analysis-frame-21-2d.png` | 帧 21(t=0.0641 s)二维画布,308×331 |
| `.scratch/replay-analysis-b/snapshots/analysis-frame-21-3d.png` | 同一帧三维面板,676×610 |
| `.scratch/replay-analysis-b/snapshots/analysis-frame-36-2d.png` | 帧 36(t=0.9601 s)二维画布,308×331 |
| `.scratch/replay-analysis-b/snapshots/analysis-frame-36-3d.png` | 同一帧三维面板,676×594 |
二维画面是候选网格上的暗点阵(该帧活动样本很少),三维是手指岛面加同一套样本点;"同一个样本
集"由上面的逐元素断言保证,快照证明这条链路真的走到了 GL。
**深度那一片的快照**(`.scratch/replay-analysis-a2/snapshots/`,同一个窗口、同一个游标、同一个
选择器;"原始图像"是真的相机图,两个派生模式读结果):
| 文件 | 内容 |
|---|---|
| `analysis-finger-frame-33-2d-raw.png` / `-arrows.png` / `-depth.png` | finger 链路帧 33,深度非零 76 px(峰值 0.0242) |
| `analysis-box-frame-27-2d-raw.png` / `-arrows.png` / `-depth.png` | box 链路帧 27,`depth_map` 365×365 全零,面板显示"本帧深度全为 0(133225 px 中 0 个非零)" |
同一帧三张图的大小不同(raw 96.6 kB 是相机图,arrows 4.9 kB,depth 4.2 kB),这本身就是
"三个模式画的是三样东西"的证据。
**接线那一片的验收**(`apps/studio/tests/test_replay_wiring.py`,7 项,真实录像 + 真实 pass;录像
先复制到临时目录、结果写进它自己的 cache,窗口因此复用而不开链路):操作员路径断言引擎仍在
`recording`、`available_result()` 非空、选择器给出"原始图像"与三个派生模式、屏上是复用的结果而
「从视频重算」是关的(改前报 `'analysis' == 'recording'`;选择器里当时没有"原始图像")。
箭头与深度的字节分别与存下来的候选/`active` 集合和 `depth_map_to_bgr(存的 depth, …)` 相等
(这份 box 结果每帧全零,面板用 `panels.depth_empty` 说明);跳转、单步、真定时器播放(0.4 s)
与 pass 完成四条都在录像时间轴上读结果。另两项:改阈值只重画、改栅格被拒但画面照画(改前抛
`TypeError`)。
**接线那一片的快照**(`.scratch/replay-reachable/snapshots/`,真实 GL、真窗口、没有手工调用
`use_recording()`):`wiring-operator-frame-40-2d-{raw,arrows,depth}.png` 是同一帧的三个模式
(86 个活动样本;深度全零),`-playback-2d-arrows.png` 是播放到该时刻抓的,`-window.png` 为整窗。
| `.scratch/replay-analysis-b/snapshots/analysis-frame-{21,36}-2d.png` / `-3d.png` | 帧 21/36 的二维画布与三维面板 |
| `.scratch/replay-analysis-a2/snapshots/analysis-{finger,box}-frame-*-2d-{raw,arrows,depth}.png` | 两条链路的同一帧三种模式(box 深度全零) |
| `.scratch/replay-reachable/snapshots/wiring-operator-frame-40-2d-*.png`、`-window.png` | 操作员路径:同一帧三个模式、整窗 |
| `.scratch/result-visible/snapshots/*.png` | 本次:会话开着时的三种模式、四种面板文案、整窗 |
## 15. 已知限制
* 结果里没有力:力曲线始终读记录的 `forces.csv`。深度有了(第 1、11 节),但**三维面板仍只
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进
三维会改变三维图形的构成,不在这一片里。
* **box 的箭头画布在回放里是空的**(本片实测,非本次引入):box 的绘制路径吃不下只有候选格
有限的恢复场,同一帧密流场画 24,440 px、恢复场 0 px;见 [replay-panel.md](replay-panel.md) 第 5 节。
* 结果覆盖的帧是 pass 走过的那些:**结果的第一帧可能晚于录像的起点**(box 链路构造时吃掉前
7 个解码帧,这份结果从 t=0.4162 s 开始),最后一批帧也可能早于录像的终点。游标落在这两段里
时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧:起点之前画的是最早那一帧
测量,终点之后画的是最后一帧。两段没有各自的提示,"不会静默重复最后一帧"在本片没有兑现。
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析 —— 但**读结果不受这条限制**(第 10
节):会话开着时面板照画缓存,只是新的那一遍要等会话停下、「从视频重算」再点一次。
* "有没有结果"是录像的属性、不是游标的属性:`available_result()` 查的是本录像在
`ResultLibrary` 里的那一份,第 9 节的接线之后这是常态。
* 面板的 `failed` 把"跑挂"和"被拒跑"合成一句(原因与动作都不同:一个要修链路,一个要停会话);
分开是更细的一步,本片没有做。
+14 -2
View File
@@ -120,8 +120,20 @@ provenance 记 `reference_frame_id`(跟踪器被清空的那一帧)与 `firs
绝不会被默认当作有效结果。一帧的链路调用失败就结束整遍,而不是把过期或空测量写进缓存。
写入分块:帧先攒在内存里,满了整块落到 `frames-*.bin.part` 再原子改名,内存上限是
`chunk_frames × 每帧记录大小`(256 帧约 1.0 MB)。淘汰按明确的容量上限整份删除结果(默认
1 GiB),先删不可用的未完成结果,再按时间从旧到新,永不触碰调用方 `keep` 的那一份。
`chunk_frames × 每帧记录大小`(256 帧约 1.0 MB)。
**缓存根是应用自己的派生数据目录,绝不是录像目录。** 结果写在
`%LOCALAPPDATA%\OrisysStudio\analysis-cache\`(`paths.analysis_cache_dir()`),按**键**分目录;
键与路径无关(`recording_identity` 看会话事实与分段大小,不看目录),所以一份结果对所有副本都
有效 —— 根放在录像里时,只有"恰好写在它旁边的那一份"能找到它,录像换个地方打开就等于没有结果。
写进录像目录还有第二个问题:那是操作员的测量数据,不是应用扔可重建文件的地方。旧的
`for_recording(<recording>/analysis)` 布局**既不读也不迁移**:应用不再往录像里写,也不会去删
操作员的东西,留在那里的结果就是被忽略(重开一次会重跑一遍)。
淘汰与清扫。`evict()` 按容量上限(默认 1 GiB)整份删除:先删不可用的未完成结果,再按时间从旧
到新,永不触碰调用方 `keep` 的那一份。`sweep()` 在启动时给它一个调用点:先清掉**超过宽限期**
(默认 1 h)的未完成结果 —— 默认读端永远拒绝它们,但刚被取消的那一遍是有用的证据,所以先留一
段时间 —— 再按容量上限淘汰。
## 8. 读端
+54 -44
View File
@@ -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.
@@ -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.
"""
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:
+178 -1
View File
@@ -35,6 +35,10 @@ from orisys_app_shared.acquisition.image_backend import (
DEPTH,
DERIVED_MODES,
RAW_NOTE_KEY,
RESULT_FAILED,
RESULT_IMPOSSIBLE,
RESULT_NONE,
RESULT_RUNNING,
SOURCE_ANALYSIS,
SOURCE_REPLAY,
ImageFrame,
@@ -498,7 +502,9 @@ class _Engine:
return self._reader
def directory(self):
return self._reader.directory
# ``None`` for a bare video, exactly as ``ReplayEngine.directory`` answers: the
# controller's "is there anything here a pass could read?" is this call.
return None if self._reader is None else self._reader.directory
def video_path(self):
return None
@@ -743,6 +749,177 @@ def test_the_controller_redraws_on_a_display_change_and_runs_no_pass(pass_root):
# ------------------------------------------------------------ analysis on open
def test_a_running_session_does_not_hide_a_stored_result(pass_root):
"""A result on disk is served while a session is open: reading it opens no chain.
The defect this pins: ``on_analysis_requested`` asked "is a session or a preview
open?" *before* it asked "is a result already stored?". Serving a result is a file
read — no SDK chain, no device, no working directory — so the one-chain-at-a-time
rule never applied to it, but the guard refused the whole call anyway. The operator
who recorded something and opened it while the acquisition session was still live
(a paused session keeps the device, so it counts) was shown "this recording has no
analysis result" over a recording whose arrows were sitting in the cache, and had to
stop the session and reopen the recording to see them.
"""
_require_inputs()
calls: list[str] = []
def factory(recording: Path, cancelled, on_progress):
calls.append(str(recording))
return _service(pass_root)
for live in ("running", "preview", "winding_down"):
calls.clear()
host = _Host(service_factory=factory)
if live == "running":
host._running = True
elif live == "preview":
host._preview_open = True
else:
host._session_active = True
panel = _ImagePanel()
controller, host, engine, parts = _controller(host=host, image_panel=panel)
assert controller.load_recording(RECORDING) is True
assert controller.available_result() is not None, (
f"a cached result was not served while a session was {live}"
)
assert not controller.analysis_running(), "a pass ran over a stored result"
assert calls == [str(RECORDING)], "the stored result was not even looked for"
assert parts[4].states[-1] == "reused", "the surface did not report the reuse"
assert controller.analysis_error() == ""
# What reaches the 2D sink is the cached measurement, not "there is nothing".
host.frames.clear()
panel.set_view_mode(DEPTH)
controller.render_frame()
drawn = host.frames[-1]
assert drawn.result_available is True
assert drawn.depth is not None, "the depth view was not served from the cache"
assert plan_image(drawn).note_key == "", "the panel still says it has no result"
def test_a_pass_is_refused_while_a_session_is_open_and_says_so(tmp_path, qt_app):
"""With nothing cached, the refusal is *state*: the panel states it, with the reason.
The pass really cannot run next to a session (one SDK chain and one working directory
at a time), so the fix is not to run it anyway — it is to stop the refusal from being
a silent no-op whose only trace is a status line that expires. Before this, the four
states below were one line, and three of them were wrong.
"""
_require_inputs()
empty = tmp_path / "cache"
empty.mkdir()
def factory(recording: Path, cancelled, on_progress):
return _service(empty, cancelled=cancelled, on_progress=on_progress)
host = _Host(service_factory=factory)
host._running = True
panel = _ImagePanel()
controller, host, engine, parts = _controller(host=host, image_panel=panel)
assert controller.load_recording(RECORDING) is True
assert not controller.analysis_running(), "a pass was started next to a session"
assert parts[4].states == [], "the progress surface claimed a pass"
state, reason = controller.result_state()
assert state == RESULT_FAILED, f"the refusal was reported as {state!r}"
assert reason == t("analysis.blocked") and reason, "the refusal lost its reason"
assert controller.analysis_error() == reason
host.frames.clear()
controller.render_frame()
drawn = host.frames[-1]
assert drawn.result_available is False
plan = plan_image(drawn)
assert plan.note_key == "panels.replay_analysis_failed"
assert plan.note_reason == reason, "the line the panel draws lost the reason"
assert plan.note_key != RAW_NOTE_KEY, (
"the panel promised a pass that was refused before it started"
)
assert plan.available_modes == ("recorded",)
def test_the_four_no_result_states_are_four_different_lines(tmp_path, qt_app):
"""No result yet, running, failed (with its reason), impossible — all four reachable.
Each is a different fact and a different next action, so each is a different line:
a pass is coming, a pass is going, a pass is not coming and here is why, or no pass
can ever serve this frame. ``none`` is what the panel draws for a recording whose
analysis has not been asked for yet — the redraw the engine's own ``recording_changed``
makes inside ``load_recording``, before ``on_analysis_requested`` runs. ``failed`` is
reached by a pass that really fails (a chain whose configuration file is absent),
because the reason on the line has to be the pass's own, not one a test invented.
"""
_require_inputs()
empty = tmp_path / "cache"
empty.mkdir()
def working(recording: Path, cancelled, on_progress):
return _service(empty, cancelled=cancelled, on_progress=on_progress)
def broken(recording: Path, cancelled, on_progress):
# A chain the SDK cannot open: the configuration file does not exist. This is the
# shape of every "the pass ran and failed" the operator can meet — a moved
# install, a config edited away — and the reason it produces is the pass's own.
return AnalysisService(
RECORDING,
chain=ChainSpec(sensor_type="box", config_ref=tmp_path / "absent-sensor.json"),
cache=ResultCache(empty),
cancelled=cancelled,
on_progress=on_progress,
)
# none: the recording is open, and nothing has asked for a pass yet.
host = _Host(service_factory=working)
panel = _ImagePanel()
controller, host, engine, parts = _controller(host=host, image_panel=panel)
assert engine.open_recording(RECORDING) is True
assert controller.result_state() == (RESULT_NONE, "")
host.frames.clear()
controller.render_frame()
assert plan_image(host.frames[-1]).note_key == RAW_NOTE_KEY
# running: the pass is in flight and the panel says so instead of promising one.
controller.on_analysis_requested()
assert controller.analysis_running(), "no pass was started"
assert controller.result_state() == (RESULT_RUNNING, "")
host.frames.clear()
controller.render_frame()
assert plan_image(host.frames[-1]).note_key == "panels.replay_analysis_running"
assert controller.wait_for_analysis(120)
_flush()
# failed: a pass that ended without a result carries its own reason to the panel.
failing = _Host(service_factory=broken)
failing_panel = _ImagePanel()
failed_ctl, failing, _, failed_parts = _controller(
host=failing, image_panel=failing_panel
)
assert failed_ctl.load_recording(RECORDING) is True
assert failed_ctl.wait_for_analysis(120), "the failing pass never finished"
_flush()
state, reason = failed_ctl.result_state()
assert reason, "a failed pass left no reason to show"
assert failed_parts[4].states[-1] == "failed", failed_parts[4].states
if state != RESULT_FAILED: # a box chain that somehow opened: then it has a result
pytest.fail(f"the failed pass is reported as {state!r}")
failing.frames.clear()
failed_ctl.render_frame()
plan = plan_image(failing.frames[-1])
assert plan.note_key == "panels.replay_analysis_failed"
assert plan.note_reason == reason, "the line the panel draws lost the reason"
# impossible: a bare video has no session metadata, so no pass can serve it at all.
bare = tmp_path / "clip.mp4"
bare.write_bytes(b"\0" * 8)
engine._reader = None
controller._toolbar.set_video_path(str(bare))
assert controller.recording_directory() is None
assert controller.result_state() == (RESULT_IMPOSSIBLE, "")
host.frames.clear()
controller.render_frame()
assert plan_image(host.frames[-1]).note_key == "panels.replay_analysis_unavailable"
def test_opening_a_recording_with_a_stored_result_runs_no_pass(pass_root):
"""Analysis on open reuses a compatible result: no progress, no pass."""
_require_inputs()
+248 -13
View File
@@ -36,6 +36,7 @@ from orisys_app_shared.acquisition.image_backend import (
DEPTH,
DERIVED_MODES,
RECORDED,
plan_image,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import DisplaySettings
@@ -87,8 +88,14 @@ def _copy_recording(destination: Path) -> Path:
return destination
def _run_the_pass(recording: Path):
"""One real pass, with the chain the *application* derives from the recording."""
def _run_the_pass(recording: Path, root: Path | None = None):
"""One real pass, with the chain the *application* derives from the recording.
``root`` names the cache explicitly because the app's own root is the user's
derived-data directory: a test must never write there, and the window it then opens
must find the result, so the root is patched to a temporary one for the whole test.
"""
from orisys_app_shared.analysis.cache import ResultCache
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
from orisys_app_shared.record import RecordingReader
@@ -97,23 +104,76 @@ def _run_the_pass(recording: Path):
chain = chain_spec_for(reader.session())
finally:
reader.close()
return AnalysisService(recording, chain=chain).run()
cache = None if root is None else ResultCache(root)
return AnalysisService(recording, chain=chain, cache=cache).run()
@pytest.fixture(scope="module")
def stored_recording(tmp_path_factory) -> Path:
"""A copy of the evidence recording whose own cache already holds a result."""
def cache_root(tmp_path_factory):
"""The analysis cache for this module's tests, in place of the user's own.
The app resolves its cache root at construction from
``paths.analysis_cache_dir()``, so pointing that at a temporary directory is enough
for the *window* to write and read there — which is what keeps a test from sweeping
or filling the real ``%LOCALAPPDATA%`` cache, and what makes "the window found the
stored result" a real statement rather than a coincidence of two roots.
"""
from orisys_app_shared import paths as paths_module
root = tmp_path_factory.mktemp("replay-wiring-cache") / "analysis-cache"
root.mkdir(parents=True)
patch = pytest.MonkeyPatch()
patch.setattr(paths_module, "analysis_cache_dir", lambda: root)
yield root
patch.undo()
@pytest.fixture(scope="module")
def stored_recording(tmp_path_factory, cache_root) -> Path:
"""A copy of the evidence recording whose cache already holds a result."""
_require_inputs()
recording = _copy_recording(tmp_path_factory.mktemp("replay-wiring") / "recording")
outcome = _run_the_pass(recording)
outcome = _run_the_pass(recording, cache_root)
assert outcome.complete, outcome.reason
assert outcome.frames_written > 0
return recording
@pytest.fixture
def window(make_window, qt_app):
"""The application, in the replay view, as the operator meets it."""
def window(make_window, qt_app, cache_root):
"""The application, in the replay view, as the operator meets it.
It shares the module's cache root, so a test may pre-place a result (or reuse the one
``stored_recording`` wrote) and have the window find it — which is the whole point of
a root that is not inside the recording: the same recording is the same result.
"""
return _replay_window(make_window)
@pytest.fixture
def fresh_cache(tmp_path, monkeypatch):
"""A cache root of this test's own.
A copy of the recording *is* the same recording to the cache (the key carries the
identity, not the path), so a test that wants a pass to run needs a root nothing has
written to — otherwise it would be served another test's result, which is correct
behaviour and the wrong fixture.
"""
from orisys_app_shared import paths as paths_module
root = tmp_path / "analysis-cache"
root.mkdir(exist_ok=True)
monkeypatch.setattr(paths_module, "analysis_cache_dir", lambda: root)
return root
@pytest.fixture
def own_window(make_window, qt_app, fresh_cache):
"""A window whose analysis cache belongs to this test alone."""
return _replay_window(make_window)
def _replay_window(make_window):
window = make_window(sensor_type="box", source_mode="camera")
window.resize(1440, 880)
window.show()
@@ -194,6 +254,20 @@ def _cells(rows, cols) -> set:
return {(int(row), int(col)) for row, col in zip(np.asarray(rows), np.asarray(cols))}
def _entry_set(directory: Path) -> set:
"""Every entry under ``directory``, as (relative path, size).
The owner's constraint is about the *recording*, so it is asserted on the recording's
own file set: a result written beside the footage changes it, and a size is included
so an in-place rewrite cannot pass as "the same entries".
"""
found = set()
for path in Path(directory).rglob("*"):
relative = str(path.relative_to(directory))
found.add((relative, -1 if path.is_dir() else path.stat().st_size))
return found
def _active_cells(samples) -> set:
return {
(int(row), int(col))
@@ -388,14 +462,16 @@ def test_playback_draws_the_cached_frame_not_the_video(
)
def test_a_finished_pass_puts_the_cache_on_screen(tmp_path, window, monkeypatch):
def test_a_finished_pass_puts_the_cache_on_screen(tmp_path, own_window, monkeypatch, fresh_cache):
"""The other trigger: the pass completes and the panel serves what it stored.
Nothing is pre-placed here, so the window runs the pass over the copy (a box chain:
a couple of seconds for this recording) and the *completion* is what has to leave the
derived view on screen — with the cursor still on the recording.
"""
window = own_window
recording = _copy_recording(tmp_path / "recording")
before = _entry_set(recording)
_sink_frames(window, monkeypatch)
_open(window, recording, monkeypatch)
@@ -424,6 +500,65 @@ def test_a_finished_pass_puts_the_cache_on_screen(tmp_path, window, monkeypatch)
"the finished result is not what the panel draws"
)
# The owner's constraint, asserted where it can actually be broken: the recording
# directory came back exactly as it was, and the result went to the app's own cache.
assert _entry_set(recording) == before, (
"opening and analysing a recording wrote inside the recording folder"
)
assert not (recording / "analysis").exists()
assert Path(session.directory).parent == Path(fresh_cache), (
f"the result was not written to the app's cache root: {session.directory}"
)
def test_a_running_session_does_not_hide_a_stored_result(
stored_recording, window, monkeypatch
):
"""A live session must not cost the operator the cached views.
The operator's own sequence: record something, and open the recording while the
acquisition session is still live (paused sessions keep the device, so they count).
Reading a stored result opens no SDK chain, so the session is no reason to refuse it —
and refusing it left the panel saying "this recording has no analysis result" over a
recording whose arrows were in the cache, which is the report this pins. Before the
fix: the note was shown and the mode selector offered ``recorded`` alone.
"""
import ui_check
window._ensure_workers = lambda: None
stuck = ui_check._StuckWorker()
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
QApplication.processEvents()
assert window._running and window._session_active, "no session was started"
captured = _sink_frames(window, monkeypatch)
_open(window, stored_recording, monkeypatch)
session = window.replay_ctl.available_result()
assert session is not None, (
"a running session hid a stored result the app did not have to compute"
)
assert window.analysis_progress.state() == "reused", "a pass ran beside a session"
assert window.replay.mode() == MODE_RECORDING
assert window.arrow_panel.note_label.text() == "", (
"the panel says it has no result while it is drawing one"
)
_set_mode(window, DEPTH)
frame_id = session.frame_id_at(window.replay.position())
expected = _expected_depth(window, session.frame(frame_id))
assert expected is not None
assert np.array_equal(_panel_bgr(window), expected), (
"the cache was not what the panel drew"
)
assert captured[-1].result_available is True
window.replay_ctl.stop_activity()
stuck.release()
def test_the_mode_selector_is_the_trigger_for_a_derived_view(
stored_recording, window, monkeypatch
@@ -455,7 +590,7 @@ def test_the_mode_selector_is_the_trigger_for_a_derived_view(
def test_the_display_settings_still_redraw_from_the_cache(
stored_recording, window, monkeypatch
stored_recording, window, monkeypatch, cache_root
):
"""A threshold change is a read: it redraws the stored frame and writes nothing."""
from orisys_app_shared.analysis.cache import scan_results
@@ -465,7 +600,6 @@ def test_the_display_settings_still_redraw_from_the_cache(
assert session is not None
_set_mode(window, ARROW_PURE)
cache_root = Path(stored_recording) / "analysis"
before = {entry.directory for entry in scan_results(cache_root)}
captured = _sink_frames(window, monkeypatch)
window.replay_ctl.on_display_settings(DisplaySettings(flow_on_px=0.9))
@@ -482,7 +616,7 @@ def test_the_display_settings_still_redraw_from_the_cache(
def test_a_setting_that_moves_the_grid_is_still_refused(
stored_recording, window, monkeypatch
stored_recording, window, monkeypatch, cache_root
):
"""The stored candidate locations cannot be re-gridded; the frame is drawn anyway."""
from orisys_app_shared.analysis.cache import scan_results
@@ -492,7 +626,6 @@ def test_a_setting_that_moves_the_grid_is_still_refused(
assert session is not None
_set_mode(window, ARROW_PURE)
before_threshold = session.threshold()
cache_root = Path(stored_recording) / "analysis"
before = {entry.directory for entry in scan_results(cache_root)}
captured = _sink_frames(window, monkeypatch)
@@ -505,3 +638,105 @@ def test_a_setting_that_moves_the_grid_is_still_refused(
assert captured, "the frame was not redrawn from the result's own grid"
assert captured[-1].frame_id == session.frame_id_at(window.replay.position())
assert {entry.directory for entry in scan_results(cache_root)} == before
# --------------------------------------------------- what the panel says without one
def test_the_panel_says_the_pass_is_running(tmp_path, own_window, monkeypatch):
"""While the pass runs, the line must not tell the operator to start one.
The old line promised "opening a recording runs a pass" *during* the pass, which is
worse than useless on a ten-minute recording: it reads as "nothing is happening".
"""
from orisys_app_shared.acquisition.image_backend import RESULT_RUNNING
from orisys_app_shared.i18n import t
window = own_window
recording = _copy_recording(tmp_path / "recording")
captured = _sink_frames(window, monkeypatch)
_open(window, recording, monkeypatch)
assert window.replay_ctl.analysis_running(), "the pass finished before it was observed"
assert window.replay_ctl.result_state() == (RESULT_RUNNING, "")
assert captured[-1].result_state == RESULT_RUNNING
assert window.arrow_panel.note_label.text() == t("panels.replay_analysis_running"), (
"the panel did not say the pass is running"
)
assert window.replay_ctl.wait_for_analysis(180.0)
QApplication.processEvents()
assert window.arrow_panel.note_label.text() == "", (
"the completed pass left a note behind"
)
def test_the_panel_states_a_failed_pass_with_its_reason(
tmp_path, own_window, monkeypatch, fresh_cache
):
"""A pass that ends without a result says so, with the pass's own reason.
Reached through the window's own open action: only the chain is made unopenable (a
configuration file that is not there — the shape of a moved install), so the worker
thread, the completion slot and the panel are all the real ones. The check that
mattered in the report is the last one: the panel and the progress surface agree.
"""
from orisys_app_shared.acquisition.image_backend import (
RAW_NOTE_KEY,
RESULT_FAILED,
)
from orisys_app_shared.analysis.cache import ResultCache
from orisys_app_shared.analysis.service import AnalysisService, ChainSpec
from orisys_app_shared.i18n import t
window = own_window
def broken_service(recording, cancelled=None, *, on_progress=None):
return AnalysisService(
recording,
chain=ChainSpec(sensor_type="box", config_ref=tmp_path / "absent-sensor.json"),
cache=ResultCache(fresh_cache),
cancelled=cancelled,
on_progress=on_progress,
)
monkeypatch.setattr(window, "_analysis_service", broken_service)
recording = _copy_recording(tmp_path / "recording")
_open(window, recording, monkeypatch)
assert window.replay_ctl.wait_for_analysis(180.0), "the failing pass never finished"
QApplication.processEvents()
state, reason = window.replay_ctl.result_state()
assert reason, "a failed pass left no reason to show"
assert state == RESULT_FAILED, f"the failed pass is reported as {state!r}"
assert window.analysis_progress.state() == "failed"
assert window.arrow_panel.note_label.text() == t(
"panels.replay_analysis_failed", reason=reason
), "the panel's line did not carry the pass's reason"
assert reason in window.arrow_panel.note_label.text()
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text(), (
"the panel promised the automatic pass that had just failed"
)
def test_the_panel_says_a_bare_video_cannot_be_analysed(tmp_path, own_window, monkeypatch):
"""No session metadata: no pass exists to offer, and the line must not name one."""
from orisys_app_shared.acquisition.image_backend import (
RAW_NOTE_KEY,
RESULT_IMPOSSIBLE,
)
from orisys_app_shared.i18n import t
window = own_window
recording = _copy_recording(tmp_path / "recording")
clip = tmp_path / "clip.mp4"
shutil.copy2(recording / "raw_0001.mp4", clip)
captured = _sink_frames(window, monkeypatch)
assert window.replay_ctl.load_video(clip) is True
QApplication.processEvents()
assert window.replay.is_video_only(), "the clip did not open as a bare video"
assert window.replay_ctl.result_state() == (RESULT_IMPOSSIBLE, "")
assert window.arrow_panel.note_label.text() == t(
"panels.replay_analysis_unavailable"
)
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text()
assert plan_image(captured[-1]).note_key == "panels.replay_analysis_unavailable"
@@ -68,6 +68,31 @@ RAW_NOTE_KEY = "panels.replay_raw_note"
#: line says which field is absent instead of the view looking broken.
MISSING_NOTE_KEY = "panels.frame_missing_note"
#: The four states of "this recording has no result to draw from", which one line under
#: the canvas has to tell apart. They are facts about the *analysis*, not about the
#: frame: the same recorded picture carries all four, and the operator's next action is
#: different in each. A single "no result yet" line was true in only the first of them,
#: and its promise ("opening a recording runs a pass") is false in the other three.
RESULT_NONE = "none"
#: A pass is in flight: the result is coming, so waiting is the action.
RESULT_RUNNING = "running"
#: A pass ended without a result, or was refused before it started. ``result_reason``
#: carries why — never a bare "failed", because "the chain failed on frame 41" and "a
#: session is running so no chain was opened" send the operator to different places.
RESULT_FAILED = "failed"
#: No pass can produce one for this frame at all: a bare video has no session metadata
#: and no analysis source, so the only way to numbers is the recompute pass.
RESULT_IMPOSSIBLE = "impossible"
RESULT_STATES = (RESULT_NONE, RESULT_RUNNING, RESULT_FAILED, RESULT_IMPOSSIBLE)
#: The line each state shows. The first is the oldest and the only one whose wording
#: may promise the automatic pass, because it is the only state in which one is coming.
RESULT_NOTE_KEYS = {
RESULT_RUNNING: "panels.replay_analysis_running",
RESULT_FAILED: "panels.replay_analysis_failed",
RESULT_IMPOSSIBLE: "panels.replay_analysis_unavailable",
}
#: Sources whose frames carry derived fields.
DERIVED_SOURCES = (SOURCE_LIVE, SOURCE_RECOMPUTE, SOURCE_ANALYSIS)
@@ -193,6 +218,13 @@ class ImageFrame:
#: that needs derived data is served from the result. ``False`` means no result — the
#: only case in which "a recording stores only the raw camera image" is the truth.
result_available: bool = False
#: Which of the four no-result states the producer is in (see :data:`RESULT_STATES`),
#: and why. Read only when there is no result to draw from; ``""`` means "the
#: producer did not say", which is read as :data:`RESULT_NONE` — the state whose line
#: names the pass that produces one, and the only wording that was ever true before
#: the states were told apart.
result_state: str = ""
result_reason: str = ""
def has_flow(self) -> bool:
if self.flow is None:
@@ -224,6 +256,10 @@ class ImagePlan:
#: :attr:`note_fields` is non-empty the line takes it as ``fields``.
note_key: str = ""
note_fields: tuple[str, ...] = ()
#: The cause a ``note_key`` that asks for one is formatted with (``{reason}``). Kept
#: apart from :attr:`note_fields`, which names the *fields* a view is missing: a
#: failure's own words are not a field name and must not be looked up as one.
note_reason: str = ""
def accepts(self, mode: str) -> bool:
return str(mode) in self.available_modes
@@ -266,7 +302,9 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
The raw-recording note is therefore an *absence* statement and appears only in the
one case where it is true: no result, nothing to draw, and the panel says which
action produces one.
action produces one. **Which** absence it is comes from ``frame.result_state`` — a
pass that is running, one that failed or was refused with a reason, and a frame no
pass can serve at all are three different absences, and each has its own line.
"""
derived = str(frame.source or SOURCE_LIVE) in DERIVED_SOURCES
missing: list[str] = []
@@ -275,6 +313,9 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
if not frame.has_depth():
missing.append("depth")
if not derived and not frame.result_available:
state = str(frame.result_state or RESULT_NONE)
note_key = RESULT_NOTE_KEYS.get(state, RAW_NOTE_KEY)
reason = str(frame.result_reason or "")
return ImagePlan(
available_modes=(RECORDED,),
default_mode=RECORDED,
@@ -283,7 +324,10 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
frame_id=int(frame.frame_id),
t_rel_s=float(frame.t_rel_s),
stale=bool(frame.stale),
note_key=RAW_NOTE_KEY,
note_key=note_key,
# Only the failed/refused line takes the reason: a state that has none must
# not render an empty placeholder into a sentence that names a cause.
note_reason=reason if note_key == RESULT_NOTE_KEYS[RESULT_FAILED] else "",
)
if not derived:
# A recording with a result waiting behind it. ``RECORDED`` stays the default —
+167 -9
View File
@@ -18,6 +18,17 @@ properties matter more than speed:
Storage is not free, so :func:`evict` removes whole results against a stated size cap.
It only ever deletes a direct child of the cache root that carries a ``meta.json``, so
a wrong path cannot turn into a recursive delete of something else.
**The root is the app's own derived-data directory, never the recording's folder.**
Results used to be written to ``<recording>/analysis``, which put the app's own
rebuildable data inside the operator's measurement — and, because the *root* is
path-dependent while the *key* is not, made a result invisible as soon as the recording
was opened from anywhere else (a copy, another machine, the same session on a different
drive letter). ``paths.analysis_cache_dir()`` is the owner's decision: one root per user
under ``%LOCALAPPDATA%\\OrisysStudio\\analysis-cache``, addressed by the result key
alone. A result written under the old root is **ignored, not migrated and not deleted**:
the app never writes into a recording folder again, and a leftover ``analysis``
directory is left exactly as the operator's own tooling left it.
"""
from __future__ import annotations
@@ -36,6 +47,7 @@ from typing import Any
import numpy as np
from orisys_app_shared import paths as paths_module
from orisys_app_shared.analysis.model import (
ANALYSIS_FORMAT,
ANALYSIS_FORMAT_VERSION,
@@ -59,14 +71,18 @@ from orisys_app_shared.analysis.model import (
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_INCOMPLETE_GRACE_S",
"DEFAULT_MAX_BYTES",
"INCOMPLETE_MARKER",
"LEGACY_RESULT_DIRNAME",
"META_NAME",
"AnalysisWriter",
"ResultCache",
"ResultSummary",
"SweepReport",
"evict",
"scan_results",
"sweep",
]
#: The file that declares a finished result. Its presence is not enough — the
@@ -90,6 +106,18 @@ MAX_CHUNK_FRAMES = 4096
#: holds roughly fourteen of them; the operator can lower it per call.
DEFAULT_MAX_BYTES = 1 << 30
#: How long an unfinished result is left alone by :func:`sweep`. A directory is written
#: only while a pass is running in *this* process, and the sweep runs at startup before
#: any pass exists, but a second copy of the app (or a build script) can share the root,
#: and deleting a directory another process is writing would turn its pass into a
#: mysterious failure. An hour is far longer than any pass here and still removes the
#: garbage of the previous session.
DEFAULT_INCOMPLETE_GRACE_S = 3600.0
#: Where a result used to live: ``<recording>/analysis``. Named so the old location can
#: be *recognised* (and reported), never so it can be written to again.
LEGACY_RESULT_DIRNAME = RESULT_DIRNAME
#: Above this many remembered grid digests the writer stops deduplicating and gives a
#: deviating frame its own keyframe. Correct either way; the map exists to keep the
#: common case (one grid for the whole recording) at one keyframe.
@@ -242,6 +270,101 @@ def evict(
return removed
@dataclass(frozen=True)
class SweepReport:
"""What one :func:`sweep` did: the garbage it removed, and what is left."""
removed: list[Path]
#: The subset of :attr:`removed` that was unreadable by construction (an incomplete
#: pass). Reported apart because removing these is never news, while removing a
#: *complete* result is the size cap doing its job and worth seeing.
incomplete: list[Path]
freed_bytes: int
remaining_bytes: int
@property
def kept(self) -> bool:
return not self.removed
def sweep(
root: str | Path,
*,
max_bytes: int = DEFAULT_MAX_BYTES,
keep: Iterable[str | Path] = (),
incomplete_grace_s: float = DEFAULT_INCOMPLETE_GRACE_S,
now: float | None = None,
dry_run: bool = False,
) -> SweepReport:
"""Tidy one cache root: unfinished results first, then the size cap.
Two passes, in this order, because they answer different questions:
1. **Garbage.** A result that is not complete is refused by every default reader
(Part A §7), so nothing can ever use it again; it is removed once it is older than
``incomplete_grace_s``. A cancelled pass is *inspectable*, which is why the grace
period exists rather than "delete on sight": the evidence of how far a pass got is
worth keeping for a while.
2. **Size.** :func:`evict` then removes whole results, incomplete first and oldest
first, until the total fits ``max_bytes`` — never touching anything in ``keep``,
and only ever a direct child of ``root`` carrying a ``meta.json``.
Returns what it removed and what is left, so a caller can log a real number instead
of "swept". Never raises for a root that does not exist: the first run of an app has
no cache, and that is not an error.
"""
base = Path(root)
protected = {Path(item).resolve() for item in keep}
if not base.is_dir():
return SweepReport(removed=[], incomplete=[], freed_bytes=0, remaining_bytes=0)
moment = time.time() if now is None else float(now)
grace = max(0.0, float(incomplete_grace_s))
start_bytes = sum(item.bytes_used for item in scan_results(base))
removed: list[Path] = []
incomplete: list[Path] = []
for item in scan_results(base):
if item.complete:
continue
if item.directory.resolve() in protected:
continue
age = moment - float(item.modified or 0.0)
if item.modified and age < grace:
continue
if not (item.directory / META_NAME).is_file():
# Same rule as ``evict``: only a result directory is a candidate, so a wrong
# root cannot turn into a recursive delete of something else.
continue
if item.directory.parent.resolve() != base.resolve():
continue
removed.append(item.directory)
incomplete.append(item.directory)
if not dry_run:
shutil.rmtree(item.directory, ignore_errors=False)
evicted = evict(base, max_bytes=max_bytes, keep=keep, dry_run=dry_run)
for path in evicted:
if path not in removed:
removed.append(path)
if removed:
logger.info(
"analysis cache sweep: %s %d result(s) (%d incomplete)",
"would remove" if dry_run else "removed",
len(removed),
len(incomplete),
)
# Measured after the fact rather than subtracted: ``evict`` decides what fits, and a
# second measurement is the only honest answer to "what is left" (a dry run leaves
# everything, which is what its number says).
remaining = (
start_bytes if dry_run else sum(item.bytes_used for item in scan_results(base))
)
return SweepReport(
removed=removed,
incomplete=incomplete,
freed_bytes=max(0, start_bytes - remaining),
remaining_bytes=remaining,
)
class AnalysisWriter:
"""Append-only writer for one result directory.
@@ -570,22 +693,36 @@ class AnalysisWriter:
class ResultCache:
"""The results belonging to one recording, addressed by key.
"""The stored results, addressed by key under the app's own derived-data root.
``root`` is usually ``<recording>/analysis``: a result then travels with the
recording it describes, and deleting the recording deletes its cache. Nothing here
writes to the current working directory — that matters because opening a *box*
``Sensor`` drops a ~2 MB ``config/compound_eye.png`` relative to it (see
``service.py``).
``root`` defaults to :func:`~orisys_app_shared.paths.analysis_cache_dir` —
``%LOCALAPPDATA%\\OrisysStudio\\analysis-cache`` — because a result is the app's
rebuildable data and must not be written into the operator's recording folder. The
*key* is what identifies a result (it ignores the recording's path), so the root is
shared by every recording and a copied recording finds the same result.
A directory left under the old ``<recording>/analysis`` root is not read and not
touched: see the module docstring.
"""
def __init__(self, root: str | Path, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
self.root = Path(root)
def __init__(self, root: str | Path | None = None, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
# Resolved at construction through the module, so a caller (or a test) can point
# the app's derived-data root elsewhere without patching this class.
self.root = (
Path(paths_module.analysis_cache_dir()) if root is None else Path(root)
)
self.max_bytes = int(max_bytes)
@classmethod
def for_recording(cls, recording_dir: str | Path, **kwargs: Any) -> ResultCache:
return cls(Path(recording_dir) / RESULT_DIRNAME, **kwargs)
"""The cache a recording's results live in.
The recording is named because that is how every caller thinks about it, and
because the *key* — which is what actually identifies the result — is built from
the recording's own metadata. It no longer decides the root.
"""
del recording_dir # the identity is in the key, not in the folder layout
return cls(**kwargs)
def directory(self, key: ResultKey) -> Path:
return self.root / key.digest
@@ -621,5 +758,26 @@ class ResultCache:
) -> list[Path]:
return evict(self.root, max_bytes=self.max_bytes, keep=keep, dry_run=dry_run)
def sweep(
self,
*,
keep: Iterable[str | Path] = (),
incomplete_grace_s: float = DEFAULT_INCOMPLETE_GRACE_S,
now: float | None = None,
dry_run: bool = False,
) -> SweepReport:
"""Tidy this root: unfinished results first, then the size cap.
The startup call site for the app's own cache; see :func:`sweep`.
"""
return sweep(
self.root,
max_bytes=self.max_bytes,
keep=keep,
incomplete_grace_s=incomplete_grace_s,
now=now,
dry_run=dry_run,
)
def total_bytes(self) -> int:
return sum(item.bytes_used for item in scan_results(self.root))
+37
View File
@@ -121,6 +121,43 @@ def recordings_dir() -> Path:
return path
#: Directory name of the product's per-user data, under the OS's own root for it.
DERIVED_DIR_NAME = "OrisysStudio"
#: Subdirectory holding the offline analysis results.
ANALYSIS_CACHE_DIRNAME = "analysis-cache"
def derived_data_dir() -> Path:
"""The OS-correct per-user root for **derived** (rebuildable) data.
Windows: ``%LOCALAPPDATA%\\OrisysStudio`` (falling back to
``%USERPROFILE%\\AppData\\Local`` when the variable is unset — a service or a
stripped environment, where the fallback is still the right directory). Everywhere
else: ``$XDG_CACHE_HOME/OrisysStudio``, or ``~/.cache/OrisysStudio``.
Deliberately *not* :func:`outputs_dir`: in a packaged build that is the EXE's own
directory, and it holds the recordings. Anything the app can rebuild belongs in the
user's own derived-data area, where a reinstall cannot destroy it and where writing
it needs no permission on the installation.
"""
name = DERIVED_DIR_NAME
if os.name == "nt":
local = os.environ.get("LOCALAPPDATA", "").strip()
base = (
Path(local)
if local
else Path(os.environ.get("USERPROFILE", str(Path.home()))) / "AppData" / "Local"
)
return base / name
cache = os.environ.get("XDG_CACHE_HOME", "").strip()
return (Path(cache) if cache else Path.home() / ".cache") / name
def analysis_cache_dir() -> Path:
"""Where the offline analysis results live: the app's own root, never a recording."""
return derived_data_dir() / ANALYSIS_CACHE_DIRNAME
def preference_path() -> Path:
return app_root() / "preference.json"
+141
View File
@@ -32,11 +32,14 @@ import numpy as np
import pytest
from orisys_app_shared.analysis import model as analysis_model
from orisys_app_shared.analysis.cache import (
DEFAULT_INCOMPLETE_GRACE_S,
INCOMPLETE_MARKER,
LEGACY_RESULT_DIRNAME,
META_NAME,
ResultCache,
evict,
scan_results,
sweep,
)
from orisys_app_shared.analysis.model import (
FRAME_HEADER,
@@ -624,6 +627,144 @@ def test_eviction_removes_whole_results_against_the_cap(tmp_path):
assert kept.is_dir()
# ------------------------------------------------- cache root and lifetime policy
def _write_result(root: Path, name: str, *, complete: bool, modified: float, blob: int = 4096):
"""One result directory as the writer leaves it, with a chosen age."""
directory = root / name
directory.mkdir(parents=True)
(directory / META_NAME).write_text(
json.dumps({"complete": complete, "key": name, "format": "x"}), encoding="utf-8"
)
(directory / "blob.bin").write_bytes(b"\0" * blob)
if not complete:
(directory / INCOMPLETE_MARKER).write_text("{}", encoding="utf-8")
for path in directory.rglob("*"):
os.utime(path, (modified, modified))
os.utime(directory, (modified, modified))
return directory
def test_the_cache_root_is_the_app_s_own_and_never_the_recording_s(tmp_path):
"""Results go to the app's derived-data root; a recording folder is never written.
This is the owner's constraint made checkable: the recording a result describes is
an input, and an input directory must come back byte-identical. It is also what makes
the cache findable at all — the *key* is path-independent, so one root for every
recording serves a copy exactly as it serves the original, while a root inside the
recording only ever served the one folder it was written beside.
"""
from orisys_app_shared import paths as paths_module
assert paths_module.analysis_cache_dir() == (
paths_module.derived_data_dir() / paths_module.ANALYSIS_CACHE_DIRNAME
)
assert paths_module.ANALYSIS_CACHE_DIRNAME in str(paths_module.analysis_cache_dir())
# The recording's own folder is not the root, and ``for_recording`` does not put it
# back: the recording is named for the key, not for the layout.
recording = tmp_path / "recording"
recording.mkdir()
cache = ResultCache.for_recording(recording)
assert cache.root != recording / LEGACY_RESULT_DIRNAME
assert not str(cache.root).startswith(str(recording))
assert cache.directory(result_key(
recording="r", sensor_config="s", calibration="c"
)).parent == cache.root
# …and the root it defaults to is the OS one, resolved at construction so a caller
# can point it elsewhere without patching this class.
assert ResultCache().root == paths_module.analysis_cache_dir()
def test_a_result_under_the_old_root_is_ignored_not_migrated(tmp_path):
"""A leftover ``<recording>/analysis`` neither breaks an open nor gets touched.
The old root is a *recording the operator owns*, so the app may neither read it
(the path is not the identity) nor delete it (it is not the app's data). A result
for the same input under the app's own root is what is served, and the old directory
is found exactly as it was left.
"""
from orisys_app_shared.analysis.model import result_key
recording = tmp_path / "recording"
legacy = recording / LEGACY_RESULT_DIRNAME
legacy.mkdir(parents=True)
key = result_key(recording="r", sensor_config="s", calibration="c")
(legacy / key.digest).mkdir()
(legacy / key.digest / META_NAME).write_text(
json.dumps({"complete": True, "key": key.digest}), encoding="utf-8"
)
before = sorted(str(path.relative_to(recording)) for path in recording.rglob("*"))
app_root = tmp_path / "app-cache"
app_root.mkdir()
cache = ResultCache(app_root)
assert cache.exists(key) is False, "a result beside the recording answered for it"
_write_result(app_root, key.digest, complete=True, modified=1_600_000_000)
assert cache.exists(key) is True
# A sweep of the app root does not reach into the recording: the old result and the
# recording's own files are all still there, untouched.
assert sweep(app_root, max_bytes=0) is not None
assert legacy.is_dir()
assert sorted(
str(path.relative_to(recording)) for path in recording.rglob("*")
) == before
def test_the_sweep_removes_unfinished_results_but_only_after_the_grace(tmp_path):
"""Garbage first: an unreadable result goes once it is old, and not before.
An incomplete result can never be served (Part A §7), so it is the cheapest thing to
lose — but it is also the only evidence of how far a cancelled pass got, which is why
the age test exists rather than "delete on sight".
"""
root = tmp_path / "cache"
root.mkdir()
now = 1_700_000_000.0
stale = _write_result(root, "stale", complete=False, modified=now - 7200)
fresh = _write_result(root, "fresh", complete=False, modified=now - 30)
finished = _write_result(root, "finished", complete=True, modified=now - 7200)
report = sweep(root, incomplete_grace_s=DEFAULT_INCOMPLETE_GRACE_S, now=now)
assert report.removed == [stale], "the sweep took something it should not have"
assert report.incomplete == [stale]
assert stale.exists() is False
assert fresh.is_dir(), "a pass that may still be running lost its directory"
assert finished.is_dir(), "a readable result was removed while it fits the cap"
assert report.freed_bytes > 0
def test_the_sweep_protects_what_is_in_use_and_honours_the_cap(tmp_path):
"""Then the cap: whole results, oldest first, and never the one being served."""
root = tmp_path / "cache"
root.mkdir()
now = 1_700_000_000.0
made = [
_write_result(root, f"result-{index}", complete=True, modified=now - 100 + index)
for index in range(3)
]
kept = made[2]
size = sum(path.stat().st_size for path in kept.rglob("*") if path.is_file())
# A cap that fits only the protected result: both others must go, and the protected
# one must survive even when the arithmetic would prefer it (it is the newest).
report = sweep(root, max_bytes=size, keep=[kept], now=now)
assert kept.is_dir()
assert made[0].exists() is False and made[1].exists() is False
assert set(report.removed) == {made[0], made[1]}
assert report.incomplete == []
assert report.remaining_bytes <= size + 64
# A dry run removes nothing and reports what would go.
again = _write_result(root, "result-3", complete=True, modified=now)
plan = sweep(root, max_bytes=1, keep=[kept], now=now, dry_run=True)
assert again.is_dir() and kept.is_dir()
assert set(plan.removed) == {again}
# A root that does not exist is not an error: the first run of an app has no cache.
missing = sweep(tmp_path / "absent", now=now)
assert missing.removed == [] and missing.remaining_bytes == 0
# ------------------------------------------------------------------ incompleteness gate
def test_a_result_without_metadata_is_never_served(tmp_path):
"""The only thing that opens is a directory whose metadata says it finished."""