Files
orisys-app/apps/studio/docs/replay-analysis-part-b.md
T
ykcheungab 1aa454311f 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.
2026-10-04 16:04:02 +08:00

175 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 节),进度、位置、单步与跳转都说它。
**应用里的时间轴永远是录像的。** 引擎留在 `recording`:游标、帧 id 与时长都是录像自己的,
结果挂在游标后面当缓存(`available_result()`),`_adopt_session()` 调用 `use_recording()` 保证
这一点。缓存有三个触点,不必先打开「从视频重算」:复用已有结果、pass 完成、选择器选中派生模式
(时间轴不动)。
**帧身份只来自录像自己的计数器。** 证据录像 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 跑在**本
进程**的一个线程里,启动前要求没有采集会话、没有预览、也没有第二遍分析,并且**本模块从不改
工作目录**。锁只覆盖开链路那一小段,逐帧处理在锁之外,GUI 线程这期间正常刷新。
## 11. 三视图如何被同一个位置喂
| 视图 | 读什么 | 怎么读 |
|---|---|---|
| 图表 | `forces.csv`(记录值) | `charts.set_cursor(t)` 用同一游标;结果里不存力 |
| 二维画布 | 结果的 `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`)是两句不同的话,因为它们是两个不同的
事实。
**画面是重建的,并且是精确重建。** 二维需要的 `[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. 显示设置是读
阈值、配色范围、暗点开关都不在失效键里(Part A 第 3 节),所以改动它们必须**只重画**:阈值
经 `AnalysisReader.samples(policy=…)` 作用在存下来的 `magnitude` 上,配色范围与暗点开关只
存在于绘制调用里。走线是 `Scene3DPanel.settingsChanged` → `on_display_settings()` →
`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 当时测的
那些;画面继续按结果自己的栅格绘制,而不是画一份"看起来一样、其实采在别处"的箭头。
`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`)。
* **取消不被当完整**:pass 在第一帧后取消 → 面板报 `cancelled`、时间轴留在 `recording`、目录里
`complete=false` 且默认读端抛 `AnalysisIncompleteError`。
**深度那一片加的验收**(同一文件再 +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 上):
| 文件 | 内容 |
|---|---|
| `.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` 为整窗。
## 15. 已知限制
* 结果里没有力:力曲线始终读记录的 `forces.csv`。深度有了(第 1、11 节),但**三维面板仍只
拿光流**:`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进
三维会改变三维图形的构成,不在这一片里。
* 结果覆盖的帧是 pass 走过的那些:**结果的第一帧可能晚于录像的起点**(box 链路构造时吃掉前
7 个解码帧,这份结果从 t=0.4162 s 开始),最后一批帧也可能早于录像的终点。游标落在这两段里
时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧:起点之前画的是最早那一帧
测量,终点之后画的是最后一帧。两段没有各自的提示,"不会静默重复最后一帧"在本片没有兑现。
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析。
* "有没有结果"是录像的属性、不是游标的属性:`available_result()` 查的是本录像在
`ResultLibrary` 里的那一份,第 9 节的接线之后这是常态。