Make the cached analysis views reachable over a recording

Replay moved the cursor onto the result's timeline, so the recording's own
picture was not offered at all and ``use_recording()`` had no caller. Adopting a
result now keeps the cursor on the recording, opens the panel on a derived view,
and serves scrubs, playback and display changes from the cache (Part B §9).
Verified: apps/studio 455 items / 0 failed / 18 skipped, shared 427 / 0 / 1,
autocal 358, ui_check 628 checks / 0 failures, lint gate clean.
This commit is contained in:
ykcheungab
2026-10-04 16:04:02 +08:00
parent fbf32b5303
commit 1aa454311f
5 changed files with 649 additions and 72 deletions
+51 -45
View File
@@ -15,14 +15,18 @@
| `recording` | 录像自己的 `raw_*.mp4`,按需解码 | 这个画面是原始相机图像:它自己没有光流也没有深度 |
| `analysis` | 存下来的分析结果,按帧读 | 有 pass 记录下来的光流样本、候选网格与深度图 |
模式说的是**画面从哪里读**,而"有没有可画的东西"是另一件事:只要有结果,二维面板的模式
选择器就提供 深度图/箭头(时间轴在录像上时连"原始图像"一起提供),选了派生模式的那些帧
一律从结果读、按窗口唯一的 2D 出口画 —— 绝不会把原始图像当成箭头图交出去。
模式说的是画面从哪里读,"有没有可画的东西"是另一件事:只要有结果,选择器就提供箭头与深度图
(连"原始图像"一起),派生模式那些帧一律从结果读、按窗口唯一的 2D 出口画。
图表、二维画布、三维面板都读这一个游标,模式由 `ReplayEngine.mode()` 回答;「从视频重算」
因此不再是第三种来源,而是**显式重跑**这一遍的开关(第 10 节)。时间基准是**信号时间**
(Part A 第 5 节),进度、位置、单步与跳转都说它。
**应用里的时间轴永远是录像的。** 引擎留在 `recording`:游标、帧 id 与时长都是录像自己的,
结果挂在游标后面当缓存(`available_result()`),`_adopt_session()` 调用 `use_recording()` 保证
这一点。缓存有三个触点,不必先打开「从视频重算」:复用已有结果、pass 完成、选择器选中派生模式
(时间轴不动)。
**帧身份只来自录像自己的计数器。** 证据录像 id 是 20…52 而非 0…32,所以
`AnalysisSession.frame(frame_id)` 对结果里没有的 id 报 `AnalysisNotFoundError`:把索引当 id
传进去不会被"顺手改成能读的那一帧",那会返回一张看起来完全正确的图。按时间取帧走
@@ -43,15 +47,14 @@
**并发与工作目录。** 开链路会往当前目录写 2 MB 预览图(Part A 第 6 节),所以 pass 跑在**本
进程**的一个线程里,启动前要求没有采集会话、没有预览、也没有第二遍分析,并且**本模块从不改
工作目录**。锁只覆盖开链路那一小段(`_working_directory` 在 `_OpenChain.open()` 内进出),
逐帧处理在锁之外,GUI 线程这期间正常刷新。关窗时先取消并等待 pass。
工作目录**。锁只覆盖开链路那一小段,逐帧处理在锁之外,GUI 线程这期间正常刷新。
## 11. 三视图如何被同一个位置喂
| 视图 | 读什么 | 怎么读 |
|---|---|---|
| 图表 | `forces.csv`(记录值) | `charts.set_cursor(t)` 用同一游标;结果里不存力 |
| 二维画布 | 结果的 `flow`、`depth` 与 `valid_mask` | `ImageFrame(source="analysis")` 走窗口唯一的 2D 出口 `_show_image_frame`,箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** |
| 二维画布 | 结果的 `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 节那幅量化平面重建出来的
@@ -68,20 +71,20 @@
`dx`/`dy` 是**画出来的**位移,已经带上长度余量那一步的缩放:只用前两个因子会让每根箭头短
20%(finger 余量 0.8),330 个样本里 319 个不同、活动集也不同 —— 实测的,不是推导的。余量补
上后,重建流场重新过一遍共享选区器,样本集与存下来的**逐元素相等**(33 帧全覆盖)。三维面板
直接拿存下来的 `FlowSamples`,所以"两张图是同一个测量"由构造决定。
上后,重建流场重新过一遍共享选区器,样本集与存下来的**逐元素相等**(33 帧全覆盖)。
## 12. 显示设置是读
阈值、配色范围、暗点开关都不在失效键里(Part A 第 3 节),所以改动它们必须**只重画**:阈值
经 `AnalysisReader.samples(policy=…)` 作用在存下来的 `magnitude` 上,配色范围与暗点开关只
存在于绘制调用里。走线是 `Scene3DPanel.settingsChanged` → `on_display_settings()` →
`session.serve_policy()` → `ReplayEngine.redraw()`,重画不移动游标、不写文件。
`session.serve_policy()` → `ReplayEngine.redraw()`,重画不移动游标、不写文件。这条走线问的是
`available_result()` 而不是 `analysis_active()`(结果属于录像、游标在录像的时间轴上);被拒的
栅格要由 `policy_divergence()` 报出是哪几项(第 14 节)。
**栅格类设置被拒绝**:`flow_grid_spacing`、`flow_edge_margin`、`max_arrows`、
`flow_length_margin`、`flow_arrow_scale` 决定候选位置**在哪里**,而记录下来的位置是 pass 当时
测的那些。`serve_policy()` 返回 `False` 并提示哪一项动了,画面继续按结果自己的栅格绘制 —— 画
一份"看起来一样、其实采在别处"的箭头才是真正错误的选项。
`flow_length_margin`、`flow_arrow_scale` 决定候选位置在哪里,而记录下来的位置是 pass 当时测的
那些;画面继续按结果自己的栅格绘制,而不是画一份"看起来一样、其实采在别处"的箭头。
`app_window.py` 的接线只有三处,供 Part C 使用:`_analysis_service(recording, cancelled, *,
on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,因此结果可复现)、
@@ -92,36 +95,25 @@ on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,
## 13. 未完成与取消
`AnalysisSession.__init__` 在 `reader.complete` 为假时抛 `ResultPolicyError`;
`ResultLibrary.open(..., allow_incomplete=True)` 也拒绝 ——「可查看」与「可回放」是两个不同的
断言。面板的 `cancelled` 与 `failed` 是两个独立状态,都不会显示成完成;这两种情况下时间轴留
在 `recording` 模式。
`ResultLibrary.open(..., allow_incomplete=True)` 同样拒绝:可查看与可回放是两个断言。面板的
`cancelled` 与 `failed` 是两个独立状态,都不会显示成完成;这两种情况下时间轴留在 `recording`。
## 14. 验收证据
`apps/studio/tests/test_replay_analysis.py`(15 项,真实素材):
* **跳转等于顺序播放**:顺序走过全部 33 帧记下每帧,再按 `time_of(frame_id)` 直接跳到 5 个
采样位置。容差是零:帧 id、`rows/cols/dx/dy/active` 逐元素相等,流场按位相等(非候选格按
构造是 `NaN`,用 `equal_nan` 比较)。跳转精确的原因是给它的时间就是那一帧自己的 `t_rel_s`。
* **显示设置不写结果**:`scan_results(cache)` 的目录集合在改阈值(0.2 与 1e9)、改显示增益、
重复同一栅格前后完全相同。
* **取消不被当完整**:pass 在第一帧后取消 → 面板报 `cancelled`、`analysis_active()` 为假、
引擎停在 `recording` 模式、目录里 `complete=false` 且默认读端抛
`AnalysisIncompleteError`。
* **回归**:`apps/studio/tests` 440 项 / 0 失败 / 18 跳过(原 425 项:新增 15 项,既有断言一条
未改)。把分析终态写进状态栏时,
`test_the_replay_reset_is_the_display_reset_and_never_the_device` 失败过 —— 合成录像的配置
缺失让 pass 在断言之前结束并覆盖了 `复位显示` 的提示;修的是消息落在哪里。
采样位置,帧 id、`rows/cols/dx/dy/active` 与流场逐项相等(非候选格按构造是 `NaN`)。
* **取消不被当完整**:pass 在第一帧后取消 → 面板报 `cancelled`、时间轴留在 `recording`、目录里
`complete=false` 且默认读端抛 `AnalysisIncompleteError`。
**深度那一片加的验收**(同一个文件再 +8 项,另加 `shared/tests/test_analysis_depth.py` 10 项):
**深度那一片加的验收**(同一文件再 +8 项,另加 `shared/tests/test_analysis_depth.py` 10 项;
往返误差与尺寸见 Part A 第 1 节):
* **深度往返**:另跑一遍链路,逐帧比 `depth_map`,误差上界是该帧自己的 `(max − min) / 510`,
在 box 录像(33 帧,1 帧非空、76 px)和 264 帧的 `long_8x.mp4`(241 帧空、23 帧非空、
最坏 336 px)上都是 0 失败;非零计数、min/max 与链路的完全相等。
* **跳转等于顺序播放(深度模式)**:把上面那条测试扩到深度 —— 顺序播放记下每帧渲染出的深度
图,再跳到 5 个位置,帧 id、`depth`、统计与**渲染出的字节**逐项相等。
* **尺寸**:空帧 4,048 B(固定 4,042 + 6 B 载荷头),最坏非空帧 4,534 B(+486 B);
按实测比例 4,090 B/帧,十分钟 70.2 MB(上限 81.6 MB)。断言写死了这三个数。
* **深度往返**:另跑一遍链路逐帧比 `depth_map`,在 box 录像与 264 帧的 `long_8x.mp4` 上都是
0 失败;非零计数、min/max 与链路的完全相等。
* **跳转等于顺序播放(深度模式)**:顺序播放记下每帧渲染出的深度图,再跳到 5 个位置,帧 id、
`depth`、统计与渲染出的字节逐项相等。
* **模式切换不写结果**:四个模式各画一次,`scan_results(cache)` 的目录集合不变。
* **"没什么可画"只在该说的时候说**:无结果时 `plan_image` 只给 `recorded` 并带
`replay_raw_note`;有结果时该行消失、四个模式都在,默认仍是原始图像。两种语言各断言一次,
@@ -142,27 +134,41 @@ scratch cache、游标停在存下来的帧 id 上):
二维画面是候选网格上的暗点阵(该帧活动样本很少),三维是手指岛面加同一套样本点;"同一个样本
集"由上面的逐元素断言保证,快照证明这条链路真的走到了 GL。
**深度那一片的快照**(`.scratch/replay-analysis-a2/snapshots/`,同一个窗口、同一个游标、
同一个模式选择器;时间轴停在录像上,所以"原始图像"是真的相机图,两个派生模式读结果):
**深度那一片的快照**(`.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 个非零)" |
| `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` 为整窗。
## 15. 已知限制
* 结果里没有力:力曲线始终读记录的 `forces.csv`。深度有了(第 1、11 节),但**三维面板仍只
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False`,
因为把深度接进三维会改变三维图形的构成,而那不在这一片里。二维深度视图是这一片交付的东西。
* 结果覆盖的帧是 pass 走过的那些:录像比结果长时游标可以离开结果范围(面板说"这个分析结果里
没有可显示的帧"),不会静默重复最后一帧。
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进
三维会改变三维图形的构成,不在这一片里。
* 结果覆盖的帧是 pass 走过的那些:**结果的第一帧可能晚于录像的起点**(box 链路构造时吃掉前
7 个解码帧,这份结果从 t=0.4162 s 开始),最后一批帧也可能早于录像的终点。游标落在这两段里
时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧:起点之前画的是最早那一帧
测量,终点之后画的是最后一帧。两段没有各自的提示,"不会静默重复最后一帧"在本片没有兑现。
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析。
* 时间轴与模式选择是两件事,而"有没有结果"是录像的属性、不是游标的属性:
`ReplayController.available_result()` 查的是**本录像**在 `ResultLibrary` 里的那一份,所以
即使 `ReplayEngine.use_recording()` 让引擎不再借用 session(目前 UI 没有第二条路会走到
这个状态),二维面板照样能画箭头和深度,`replay_raw_note` 也不会重新出现。
* "有没有结果"是录像的属性、不是游标的属性:`available_result()` 查的是本录像在
`ResultLibrary` 里的那一份,第 9 节的接线之后这是常态。