28e955a817
控制器经 AcquireGate / ImageSink / ShellNotes 与信号接线,采集 tick 用 acquire_presenter 上屏;三维测试证明分析结果已喂 3D 后,重算并入 on_analysis_requested。 验证:apps/studio 下相关 pytest 与 tools/ui_check.py(634 checks)通过。
164 lines
13 KiB
Markdown
164 lines
13 KiB
Markdown
# 回放分析结果(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` 把"跑挂"和"被拒跑"合成一句(原因与动作都不同:一个要修链路,一个要停会话);
|
||
分开是更细的一步,本片没有做。
|