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