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.
13 KiB
回放分析结果(Part B:从结果回放)
本文是 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 节的接线之后这是常态。