Files
orisys-app/apps/studio/docs/replay-analysis-part-b.md
T
ykcheungab cf49b0836e Serve replay playback from a cached analysis result
Replay re-ran the SDK chain on every seek; Part A's pass stores the flow
samples once. Opening a recording now reuses a compatible result or runs the
pass behind a progress/cancel surface, and one cursor with two modes
(recorded CSV, analysed) feeds the charts, the 2D canvas and the 3D pane by
frame id. A threshold, colour or idle-dot change redraws from the stored
frames and writes nothing; a cancelled pass is reported and never played.

Verified: apps/studio/tests 440 items / 0 failed / 18 skipped (15 new, no
existing assertion changed); shared 417 / 0 / 1; autocal 358.
2026-10-04 13:38:03 +08:00

126 lines
9.0 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 记录下来的光流样本与候选网格 |
图表、二维画布、三维面板都读这一个游标,模式由 `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` 与 `valid_mask` | `ImageFrame(source="analysis")` 走窗口唯一的 2D 出口 `_show_image_frame`,箭头、暗点、配色与域边界与实时/重算**同一套代码** |
| 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 |
**画面是重建的,并且是精确重建。** 二维需要的 `[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 在断言之前结束并覆盖了 `复位显示` 的提示;修的是消息落在哪里。
真实 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。
## 15. 已知限制
* 结果里没有力,也没有深度:力曲线始终读记录的 `forces.csv`,深度视图在回放模式下不可用(没有
可显示的数据,面板按空处理而不是画一张假的)。
* 结果覆盖的帧是 pass 走过的那些:录像比结果长时游标可以离开结果范围(面板说"这个分析结果里
没有可显示的帧"),不会静默重复最后一帧。
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析。