Replay re-ran the SDK chain on every seek: 0.48-0.51 wall-seconds per video second, about 4.8 minutes for a ten-minute recording. The new orisys_app_shared.analysis package runs that pass once and stores the flow samples a replay draws - 4,034 B per frame at the shipped finger grid instead of the chain's 1,176,000 B; the result contract is in apps/studio/docs/replay-analysis.md. Verified: shared/tests 416 passed / 1 skipped (15 new) and apps/studio/tests unchanged at 425 items / 0 failed / 18 skipped; tools/lint_gate.py clean; a 264-frame pass costs 0.5036 wall-s per video-s against 0.5052 for the bare chain measured the same day.
8.2 KiB
回放分析结果(Part A)
本文定义 orisys_app_shared.analysis 这个包:一次离线分析 pass 存下什么、怎么编码、
什么时候作废、播放端怎么读。它是 record-channel.md 所描述的记录格式的
下游消费者。播放端(时间轴、跳转、单步、倍速)是它的续篇
replay-analysis-part-b.md:结果里没有的播放语义都在那一篇。
回放过去等于每移动一次光标就重跑一遍 SDK 链路:每个视频秒 0.48–0.51 秒墙钟,十分钟录像 一次约 4.8 分钟。Part A 把这一遍跑一次,只存回放真正要画的东西。
1. 结果模型
原始输出每帧 1,176,000 B(flow 672,000 + depth_map 336,000 + valid_mask 84,000 +
灰度 img 84,000),30 fps 十分钟 21.2 GB —— 不可缓存。结果里没有这些字段,只有共享选择器
select_flow_samples 选出的样本。
每帧记录(frame_id、t_rel_s、网格 keyframe 编号、候选数、深度统计、dx、dy、
magnitude、位打包 active、掩码摘要)在 shipped finger 网格下是 4,034 B/帧:
24 + 330×12 + ⌈330/8⌉ + 8;十分钟 30 fps 约 70 MB。选这个编码而不是实测的另外两种
(float16 的 2,022 B/帧、丢掉 magnitude 的 4,002 B/帧),是因为往返必须逐位相等,而
magnitude 正是"同一份结果换个阈值也能用"的依据(第 3 节)。dx/dy/magnitude 存
float32,与实时选择器比对时后者也要转成 float32 才是精确比较。
不存的:力(forces.csv 已是图表读的内容,180.9 B/帧);深度图只存每帧一个
depth_nonzero 计数(4 B)加汇总统计 —— 这段素材上深度几乎全零(264 帧中 241 帧全零,最大
336 px / 84,000),统计记下来"深度视图是空的"才有解释。
provenance 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
帧 id、解码偏移、逐通道的"重算/记录"标记(flow、depth 重算,forces 记录)、complete
标志与计数。
2. Keyframe:候选位置与有效掩码
330 个候选位置在整段录像上逐字节相同(两个视频、327 帧共用一个摘要),所以它是
keyframe,只存一次(int16 rows + int16 cols 共 1,320 B,规则网格 zlib 后 132 B);
有效掩码同样静态(327 帧中 0 帧变化),也存 keyframe(位打包 10,500 B,zlib level 6 后
687 B)。两者加两个 36 B 的 blob 头,keyframes.bin 一共 891 B。
每帧仍带 8 字节掩码摘要:某一帧的掩码若真的变了,读端能发现,而不是拿错误的掩码把这一帧
静默重建出来。测试用 Step 0 记录的 blake2b-16 摘要 d76349a3b901aea50915eb1308901c2e
校验存回来的掩码,所以 keyframe 是被验证过的副本。
box 链路没有 valid_mask,max_arrows=400 的上限因此按幅值排序,位置集会逐帧移动。格式对此
只有一条代码路径:每个位置集都是 keyframe,按摘要去重。finger 一段录像一个;box 的 33 帧
录像 26 个,每帧 1,600 B —— 正好等于把位置内联存储的代价。
3. 失效键
键是五个分量的摘要(model.result_key):录像身份、算法版本、传感器配置、标定、参考帧;
目录名就是这个摘要。
显示设置刻意不在键里。 阈值(flow_on_px/flow_off_px)变化时读端从存下来的
magnitude 重新推导 active,不需要重建结果;只有采样网格(flow_grid_spacing、
flow_edge_margin、max_arrows)变化才会移动候选位置,那份策略记在 provenance 里供比对而
不塞进键里。SDK 版本同样不在键里(补丁版本不该让键变化),它记在 provenance 的 versions,
由 AnalysisReader.stale_reasons() 报出。
4. 参考帧规则
Sensor.reset() 清空光流跟踪器,所以重置在指定的那一帧处理之前施加:参考帧本身就是新参考
的第一帧,位移恰好为零。实测在真实录像第 36 帧重置后该帧 |flow|.mean() = 0.000000000,而
上一帧是 0.5354;第 36 帧之前的每一帧与不重置的那一遍逐字节相同。
provenance 记 reference_frame_id(跟踪器被清空的那一帧)与 first_output_frame_id(结果
第一帧);没有显式重置时两者相等,所以这个字段永远不是"未知",reference_is_explicit 区分
两种情况。
帧 id 是采集会话自己的计数器,不从 0 或 1 开始:证据里的 box 录像是 20…52。所以请求一个
录像里不存在的参考帧会拒绝运行(ValueError,不留下目录),而不是默默按"没有重置"跑完
—— 那会存下一个与要求不符的 reference_frame_id。
5. 两个时间基准
视频时间 ≠ 信号时间。 容器 33 帧 @30 fps 是 1.100 s,这 33 帧对应的信号是 1.920 s(实测
16.664 fps)。t_rel_s 来自 frames.csv,是信号时间,也是读端 frame_at() 使用的唯一
时间轴;容器时长永远不派生自它,也不与它互相换算。有序段索引由 session.json 的
files.video、stats.segments、prereoll_segment 加 RecordingReader.segments() 的
raw_*.mp4 排序推导;两者不一致时 pass 不修复:声明了却缺失的段让结果 complete=false。
6. box 的偏移陷阱
在视频上构造 box Sensor 时,拼接探测悄悄吃掉最前面的 7 个已解码帧(33 帧视频只剩 26 帧
可处理,调用方读到的第一帧是视频第 8 帧);finger 链路吃掉 0 帧。偏移不是猜的:构造后直接读
底层 capture 的 CAP_PROP_POS_FRAMES(box = 7,finger = 0),记进 provenance 的
decode_order_offsets。因此帧身份只来自录像自己的 frames.csv:可读帧 d 对应 in_video
行 d + offset,frame_id、t_rel_s、segment 全部取自该行。后备项(后端报不出位置时按常量
7)把 decode_order_offset_source 记成 declared。计数把"少掉的帧"分开记:skipped(被偏移
吃掉的索引行)、unmapped(解码出来却没有索引行)、frames_short(有索引行却没有解码帧)。
构造 box Sensor 还会往进程当前目录写约 2 MB 的 config/compound_eye.png,所以每次开
链路都在临时工作目录里、由进程级锁串行化:这是本设计唯一无法消除的全局副作用,SDK 没有提供
改写这个路径的入口。
7. 完整性与淘汰
结果目录里有一个显式 INCOMPLETE 标记,从写入第一个字节起存在,直到元数据声明完成之后才
删除。默认读端只接受同时满足"元数据 complete=true"、"provenance 也 complete=true"、
"没有 INCOMPLETE 标记"的目录,三者在读取时互相校验,不一致直接报格式错误。
取消、SDK 失败、任意异常走同一条路:写完已完成的块与 keyframe,写上 complete=false 和停止
原因,保留标记 —— 被中断的一遍是可查看的(allow_incomplete=True 能读到已写完的帧),但
绝不会被默认当作有效结果。一帧的链路调用失败就结束整遍,而不是把过期或空测量写进缓存。
写入分块:帧先攒在内存里,满了整块落到 frames-*.bin.part 再原子改名,内存上限是
chunk_frames × 每帧记录大小(256 帧约 1.0 MB)。淘汰按明确的容量上限整份删除结果(默认
1 GiB),先删不可用的未完成结果,再按时间从旧到新,永不触碰调用方 keep 的那一份。
8. 读端
AnalysisReader 按帧 id 和按时间戳随机访问:meta.json 的块清单定位块,块尾偏移表定位帧,
一次读一帧记录。seek 不需要重跑 pass,也不需要录像、SDK 或 Qt —— 测试把结果目录整体复制到
别处再读,逐帧比对相同。这一遍不比裸链路贵:264 帧 / 8.8 s 上三次计时均值 0.5036 墙钟秒每
视频秒,同一天用 Step 0 自己的 bench_chain.py 重测裸链路为 0.5052。
两个建议给 SDK(本文档不实施):给外部帧模式一个公开的逐帧读取方法,替代
Sensor._read_frame;给拼接预览路径一个可配置目标,替代相对当前目录的
config/compound_eye.png。