Files
orisys-app/apps/studio/docs/replay-analysis-part-b.md
T
ykcheungab 9e20fd0277 Store the depth channel in an analysis result and serve it to the 2D depth view
A replay could draw arrows but not depth, and the recording view said there was
nothing to draw even when a result existed. Depth is now quantised per frame
(14 B empty, 486 B at the worst frame seen), read from the cache by the same
draw path live uses, and the raw-recording note appears only when no result
exists. Format version 2; see apps/studio/docs/replay-analysis.md.
2026-10-04 14:11:22 +08:00

169 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 回放分析结果(Part B:从结果回放)
本文是 [replay-analysis.md](replay-analysis.md) 的续篇:那一篇定义结果本身,这一篇定义播放端
怎么读它。打开录像即取用或跑出结果,之后播放、拖动、单步与跳转**全部是读文件**。实现分布在
`replay_analysis.py`(结果 → 时间轴与画面)、`analysis_worker.py`(pass 的线程边界与取消)、
`ui/analysis_progress.py`(进度/取消面板)、`replay.py` 与 `replay_controller.py`(一个
游标、两个模式),以及 `app_window.py` 的接线(第 12 节)。各节编号承接 Part A。
## 9. 一个时间轴,两个模式
游标只有一个:`ReplayEngine.position()` 的 `t_rel_s`。模式说明**画面从哪里读**:
| 模式 | 画面来源 | 说明 |
|---|---|---|
| `recording` | 录像自己的 `raw_*.mp4`,按需解码 | 这个画面是原始相机图像:它自己没有光流也没有深度 |
| `analysis` | 存下来的分析结果,按帧读 | 有 pass 记录下来的光流样本、候选网格与深度图 |
模式说的是**画面从哪里读**,而"有没有可画的东西"是另一件事:只要有结果,二维面板的模式
选择器就提供 深度图/箭头(时间轴在录像上时连"原始图像"一起提供),选了派生模式的那些帧
一律从结果读、按窗口唯一的 2D 出口画 —— 绝不会把原始图像当成箭头图交出去。
图表、二维画布、三维面板都读这一个游标,模式由 `ReplayEngine.mode()` 回答;「从视频重算」
因此不再是第三种来源,而是**显式重跑**这一遍的开关(第 10 节)。时间基准是**信号时间**
(Part A 第 5 节),进度、位置、单步与跳转都说它。
**帧身份只来自录像自己的计数器。** 证据录像 id 是 20…52 而非 0…32,所以
`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()`,因此不可能出现两条并行模式。
分析的四条终态消息**都不写状态栏**,只更新进度面板:pass 是异步的,可能在操作员做了别的事
几秒后才结束,一条活得比原因久的状态栏消息会把无关操作说成失败。原因留在面板上,也由
`analysis_error()` 提供。
**并发与工作目录。** 开链路会往当前目录写 2 MB 预览图(Part A 第 6 节),所以 pass 跑在**本
进程**的一个线程里,启动前要求没有采集会话、没有预览、也没有第二遍分析,并且**本模块从不改
工作目录**。锁只覆盖开链路那一小段(`_working_directory` 在 `_OpenChain.open()` 内进出),
逐帧处理在锁之外,GUI 线程这期间正常刷新。关窗时先取消并等待 pass。
## 11. 三视图如何被同一个位置喂
| 视图 | 读什么 | 怎么读 |
|---|---|---|
| 图表 | `forces.csv`(记录值) | `charts.set_cursor(t)` 用同一游标;结果里不存力 |
| 二维画布 | 结果的 `flow`、`depth` 与 `valid_mask` | `ImageFrame(source="analysis")` 走窗口唯一的 2D 出口 `_show_image_frame`,箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** |
| 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 |
**深度视图也由结果喂。** `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 帧全覆盖)。三维面板
直接拿存下来的 `FlowSamples`,所以"两张图是同一个测量"由构造决定。
## 12. 显示设置是读
阈值、配色范围、暗点开关都不在失效键里(Part A 第 3 节),所以改动它们必须**只重画**:阈值
经 `AnalysisReader.samples(policy=…)` 作用在存下来的 `magnitude` 上,配色范围与暗点开关只
存在于绘制调用里。走线是 `Scene3DPanel.settingsChanged` → `on_display_settings()` →
`session.serve_policy()` → `ReplayEngine.redraw()`,重画不移动游标、不写文件。
**栅格类设置被拒绝**:`flow_grid_spacing`、`flow_edge_margin`、`max_arrows`、
`flow_length_margin`、`flow_arrow_scale` 决定候选位置**在哪里**,而记录下来的位置是 pass 当时
测的那些。`serve_policy()` 返回 `False` 并提示哪一项动了,画面继续按结果自己的栅格绘制 —— 画
一份"看起来一样、其实采在别处"的箭头才是真正错误的选项。
`app_window.py` 的接线只有三处,供 Part C 使用:`_analysis_service(recording, cancelled, *,
on_progress)`(`chain_spec_for` 从录像自己的 `session.json` 取链路,因此结果可复现)、
`_show_analysis_frame(packet)`、`_connect_analysis_display(mesh_panel)`(把
`Scene3DPanel.settingsChanged` 接到控制器;三维视图首次使用时才建,用有界的 40 × 250 ms 重试
等它出现)。工具栏、复位与 transport 控件、`display_prefs.py`、`_on_reset_view`/F8 未改。
## 13. 未完成与取消
`AnalysisSession.__init__` 在 `reader.complete` 为假时抛 `ResultPolicyError`;
`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 在断言之前结束并覆盖了 `复位显示` 的提示;修的是消息落在哪里。
**深度那一片加的验收**(同一个文件再 +8 项,另加 `shared/tests/test_analysis_depth.py` 10 项):
* **深度往返**:另跑一遍链路,逐帧比 `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)。断言写死了这三个数。
* **模式切换不写结果**:四个模式各画一次,`scan_results(cache)` 的目录集合不变。
* **"没什么可画"只在该说的时候说**:无结果时 `plan_image` 只给 `recorded` 并带
`replay_raw_note`;有结果时该行消失、四个模式都在,默认仍是原始图像。两种语言各断言一次,
且文案必须点出产生结果的动作。
* **派生模式走结果**:时间轴在录像上、模式选深度时,送到 2D 出口的是**结果那一帧**(含深度),
不是解码出来的视频帧;切回"原始图像"仍是录像自己的画面。
真实 GL 快照(`QT_QPA_PLATFORM=windows` + `QT_OPENGL=desktop`,真实窗口路径、pass 写入
scratch cache、游标停在存下来的帧 id 上):
| 文件 | 内容 |
|---|---|
| `.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),这本身就是
"三个模式画的是三样东西"的证据。
## 15. 已知限制
* 结果里没有力:力曲线始终读记录的 `forces.csv`。深度有了(第 1、11 节),但**三维面板仍只
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False`,
因为把深度接进三维会改变三维图形的构成,而那不在这一片里。二维深度视图是这一片交付的东西。
* 结果覆盖的帧是 pass 走过的那些:录像比结果长时游标可以离开结果范围(面板说"这个分析结果里
没有可显示的帧"),不会静默重复最后一帧。
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析。
* 时间轴与模式选择是两件事,而"有没有结果"是录像的属性、不是游标的属性:
`ReplayController.available_result()` 查的是**本录像**在 `ResultLibrary` 里的那一份,所以
即使 `ReplayEngine.use_recording()` 让引擎不再借用 session(目前 UI 没有第二条路会走到
这个状态),二维面板照样能画箭头和深度,`replay_raw_note` 也不会重新出现。