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:
@@ -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",
|
||||
|
||||
@@ -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": "记录",
|
||||
|
||||
@@ -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` 把"跑挂"和"被拒跑"合成一句(原因与动作都不同:一个要修链路,一个要停会话);
|
||||
分开是更细的一步,本片没有做。
|
||||
|
||||
@@ -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. 读端
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user