Files
orisys-app/apps/studio/docs/replay-analysis-part-b.md
ykcheungab 28e955a817 把 Studio 主机协议改成单向端口,并让「从视频重算」走分析入口
控制器经 AcquireGate / ImageSink / ShellNotes 与信号接线,采集 tick 用 acquire_presenter 上屏;三维测试证明分析结果已喂 3D 后,重算并入 on_analysis_requested。
验证:apps/studio 下相关 pytest 与 tools/ui_check.py(634 checks)通过。
2026-10-05 14:00:24 +08:00

164 lines
13 KiB
Markdown
Raw Permalink 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()` 回答;「从视频重算」
与打开录像共用 `ReplayController.on_analysis_requested()`(第 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. 打开即分析
打开录像后固定做一件事:**有兼容结果就用,没有就跑**,而且**顺序是硬性的**:读结果是一次
文件读(不开链路、不碰设备、不改工作目录),「一次只跑一条链」管不到它,所以
`on_analysis_requested()` 先问缓存、再问会话。反过来时,只要采集会话还在(暂停也算:设备还在
手里)或预览开着,整个调用就被拒、缓存根本没读 —— 面板对着一份就在磁盘上的结果说"这个录像还
没有分析结果",操作员得停掉会话再重新打开才看得到箭头。
**拒跑是状态,不是过期就消失的状态栏文字。** 真没有结果时才轮到会话判断:此时不跑 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 跑在**本
进程**的一个线程里,启动前要求没有采集会话、没有预览、也没有第二遍分析,并且**本模块从不改
工作目录**。锁只覆盖开链路那一小段,逐帧处理在锁之外,GUI 线程这期间正常刷新。
## 11. 三视图如何被同一个位置喂
| 视图 | 读什么 | 怎么读 |
|---|---|---|
| 图表 | `forces.csv`(记录值),**或结果里重算的力**(裸视频) | `charts.set_cursor(t)` 用同一游标;两条来源在标题上分开命名(`chart.mode_replay` / `chart.mode_recomputed`) |
| 二维画布 | 结果的 `flow`、`depth` 与 `valid_mask` | `ImageFrame` 走窗口唯一的 2D 出口 `_show_image_frame`(录像时间轴上的派生视图带 `source="replay"` 与 `result_available=True`,因此"原始图像"仍在选择器里),箭头、暗点、配色、域边界与深度视图与实时/重算**同一套代码** |
| 三维面板 | 同一帧的 `SdkPacket` | `session.packet(frame)` → `Scene3DView.submit_packet`,走同一个 adapter、域掩码 latch 与有效性求交 |
**裸视频的力是重算的,而且只有这一条。** 没有 `forces.csv` 时,pass 把链路对每一帧算出的力按帧
存进结果(`FRAME_FLAG_FORCE`;Part A 第 1.1 节),曲线读它,标题写 `chart.mode_recomputed`。没有
任何力可画时标题说明原因。
**没有结果时那一行必须说清是四种里的哪一种。** `plan_image()` 只拿到 `ImageFrame`,四种状态
因此由生产者写进帧里(`result_state`/`result_reason`),`ReplayController.result_state()` 是唯一
的判断处;有结果时这一行根本不出现:
| 状态 | 何时 | 那一行 |
|---|---|---|
| `none` | 还没问过缓存(打开录像时 `recording_changed` 的重画) | 点名产生结果的动作(`panels.replay_raw_note`) |
| `running` | pass 在跑 | 正在分析、进度与取消在分析面板上 |
| `failed` | 跑完没留下结果,**或**被拒启动(会话/预览开着) | 带上**自己的原因**(`analysis_error()`):`chain_failed:<frame>`、`FileNotFoundError: …` 或 `analysis.blocked` |
| `impossible` | 没有 `session.json` / 没有 `raw_*.mp4` 分段 | 这里没有能跑的分析,数字只能靠「从视频重算」 |
**深度视图也由结果喂。** `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`(18 项)与 `test_replay_wiring.py`(11 项)跑真实素材,
`shared/tests/*` 覆盖格式、缓存根与清扫。要保住的断言:
* **跳转等于顺序播放**:顺序走过全部 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()` 下。
真实 GL 快照(`QT_QPA_PLATFORM=windows` + `QT_OPENGL=desktop`,真实窗口路径):
| 文件 | 内容 |
|---|---|
| `.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`。裸视频是另一回事(Part A 第 1.1
节):pass 存下链路重算的力,曲线读它。深度有了(第 1、11 节),但**三维面板仍只拿光流**:
`session.packet()` 的 `depth` 依旧是 `None`、`depth_valid` 依旧是 `False` —— 接进三维会改变
三维图形的构成,不在这一片里。
* 结果覆盖的帧是 pass 走过的那些:**结果的第一帧可能晚于录像的起点**(box 链路构造时吃掉前
7 个解码帧,这份结果从 t=0.4162 s 开始),最后一批帧也可能早于录像的终点。游标落在这两段里
时,派生视图按 `frame_id_at()` 的"该时刻或之前、越界夹到端点"给帧,两段没有各自的提示。
* 栅格类显示设置不生效(第 12 节);换栅格就是重跑一遍,那是**另一个键**。被拒的栅格**仍按
结果自己的网格绘制**(帧携带 `arrow_policy`)。
* 一个进程同时只有一遍分析在跑;采集会话运行期间不启动分析 —— 但**读结果不受这条限制**(第 10
节):会话开着时面板照画缓存,新的那一遍要等会话停下、「从视频重算」再点一次。
* "有没有结果"是录像的属性、不是游标的属性:`available_result()` 查的是本录像在
`ResultLibrary` 里的那一份,第 9 节的接线之后这是常态。
* 面板的 `failed` 把"跑挂"和"被拒跑"合成一句(原因与动作都不同:一个要修链路,一个要停会话);
分开是更细的一步,本片没有做。