c1e6d21030
回放按声明的 30 帧加权空载窗口取基线并逐帧保存外推质量,field 模型单独进缓存键,实时入口拒绝。 新增测试覆盖窗口、质量与拒绝路径。全量门禁的 scene3d 两项失败与 studio 访问冲突为既有问题, 已归因并另立 studio-gate-hang。
167 lines
12 KiB
Markdown
167 lines
12 KiB
Markdown
# 回放分析结果(Part A)
|
||
|
||
本文定义 `orisys_app_shared.analysis` 这个包:一次离线分析 pass 存下什么、怎么编码、
|
||
什么时候作废、播放端怎么读。它是 [record-channel.md](record-channel.md) 所描述的记录格式的
|
||
下游消费者。播放端(时间轴、跳转、单步、倍速)是它的续篇
|
||
[replay-analysis-part-b.md](replay-analysis-part-b.md):结果里没有的播放语义都在那一篇。
|
||
|
||
回放过去等于每移动一次光标就重跑一遍 SDK 链路:**每个视频秒 0.48–0.51 秒墙钟**,十分钟录像
|
||
一次约 4.8 分钟。Part A 把这一遍跑一次,只存回放真正要画的东西。
|
||
|
||
格式版本从 1 升到 2:帧记录里多了深度通道。旧结果不会被误读 —— 版本不符直接
|
||
`AnalysisFormatError`,重跑一遍即可。力的通道**没有**再升版本:它追加在记录最后,老读端按原偏移
|
||
仍能读出同一帧,只是没有力(第 1 节末)。
|
||
|
||
## 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,048 B/帧**
|
||
(空深度):`32 + 330×12 + ⌈330/8⌉ + 8 + 6`;十分钟 30 fps 约 70 MB。选这个编码而不是实测的
|
||
另外两种(float16 的 2,022 B/帧、丢掉 `magnitude` 的 4,002 B/帧),是因为往返必须**逐位相等**,
|
||
而 `magnitude` 正是"同一份结果换个阈值也能用"的依据(第 3 节)。`dx`/`dy`/`magnitude` 存
|
||
float32,与实时选择器比对时后者也要转成 float32 才是精确比较。
|
||
|
||
**深度存下来,而且是量化后存的。** 单帧 `depth_map` 336,000 B,十分钟 6.0 GB —— 直接存不可行;
|
||
实测这段素材上它绝大多数帧**恰好全零**(264 帧的八倍速片段里 241 帧,box 录像 33 帧里 32 帧),
|
||
最坏的非空帧也只有 84,000 px 中的 336 px。
|
||
|
||
所以每帧固定记下**深度通道的统计**(是否有通道、
|
||
非零计数、最小/最大值,共 8 B)并附一个深度载荷:空帧只有一个 6 B 的载荷头(声明形状,
|
||
指明"这一帧的深度是精确的零"),非空帧是**按该帧自己的 [min, max] 量化的 uint8 平面**再
|
||
zlib level 6。
|
||
|
||
实测代价:空帧 +14 B,最坏非空帧 486 B;能在往返中引入的最大误差是半个量化步长
|
||
`(max − min) / 510`,实测 6.6e-5(该帧峰值 0.034)。这不到深度视图所用 256 级色标的一步,所以
|
||
重建出来的帧**不可能被染成与原帧不同的颜色**。
|
||
|
||
稀疏存储实测后否决:最坏帧每像素 4 B 索引要 910 B,整幅量化平面只要 486 B —— 几乎全零的
|
||
字节平面在 zlib 下几乎免费。限制在岛内也否决:418 B 对 486 B,每帧省 5.2 B,却要依赖有效性
|
||
掩码 keyframe(box 链路没有),且岛外深度值会静默丢测量。
|
||
|
||
`provenance` 随结果存:算法版本、传感器配置及其内容哈希、标定 id、单位、参考帧 id、首个输出
|
||
帧 id、解码偏移、逐通道的"重算/记录"标记(`flow`、`depth` 重算;`forces` 见第 1.1 节)、
|
||
`synthetic` 与 `synthetic_source`、`complete` 标志与计数。`counts` 另记深度与力通道的计数与实测
|
||
的 `frame_record_bytes`,进度面板和"这一帧为什么是空的"都读它。
|
||
|
||
**帧记录可以再带一份力,追加在最后。** 裸视频没有 `forces.csv`,pass 就把链路算出的力按帧存下:
|
||
`FRAME_FLAG_FORCE` 后接六个 float32 与两个标志位,28 B/帧。版本不变的理由见开头;读数取不到时存
|
||
`NaN`。
|
||
|
||
### 1.1 裸视频的输入:生成的工作区
|
||
|
||
pass 只认一种输入形状(一个目录里的 `frames.csv`、`session.json` 与 `raw_*.mp4`),而形状本身是
|
||
好的:帧身份、时间与链路都来自录制自己的元数据。裸视频因此**不新增第二条代码路径**,而是把这两个
|
||
文件为它*生成*出来(`analysis/bare_video.py`):`frames.csv` 给容器每一帧一行
|
||
(`t_rel_s = index / fps`),`session.json` 给出 `device`/`stats`/`files` 与一个 `synthetic` 块;
|
||
裸视频只有容器时间,所以容器的钟**就是**信号时间(第 5 节)。
|
||
|
||
凡是 `recording_identity` 会哈希的字段都只由**视频与所选配置**决定,不含任何时钟:重开同一条视频
|
||
就是同一个 key;标定进 key 的是**文件内容**的摘要。视频以 `raw_0001.mp4` 放进工作区(能硬链接就
|
||
链接):一个文件就是一段,因此**多文件输入直接拒绝**并列出文件名。工作区写在应用自己的派生数据根
|
||
(`paths.video_workspace_dir()`),**绝不写在操作员视频旁边**;`synthetic` 块被 pass 抄进
|
||
`provenance.synthetic`/`synthetic_source`,重算出来的测量因此不可能被当成记录的测量读。
|
||
|
||
## 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()` 报出。场加权实验模型的键分量与逐帧质量见
|
||
[replay-field-experiment.zh.md](replay-field-experiment.zh.md)。
|
||
|
||
## 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)。
|
||
|
||
**缓存根是应用自己的派生数据目录,绝不是录像目录。** 结果写在
|
||
`%LOCALAPPDATA%\OrisysStudio\analysis-cache\`(`paths.analysis_cache_dir()`),按**键**分目录;
|
||
键与路径无关(`recording_identity` 看会话事实与分段大小,不看目录),所以一份结果对所有副本都
|
||
有效 —— 根放在录像里时,只有"恰好写在它旁边的那一份"能找到它,录像换个地方打开就等于没有结果。
|
||
写进录像目录还有第二个问题:那是操作员的测量数据,不是应用扔可重建文件的地方。旧的
|
||
`for_recording(<recording>/analysis)` 布局**既不读也不迁移**:应用不再往录像里写,也不会去删
|
||
操作员的东西,留在那里的结果就是被忽略(重开一次会重跑一遍)。
|
||
|
||
淘汰与清扫。`evict()` 按容量上限(默认 1 GiB)整份删除:先删不可用的未完成结果,再按时间从旧
|
||
到新,永不触碰调用方 `keep` 的那一份。`sweep()` 在启动时给它一个调用点:先清掉**超过宽限期**
|
||
(默认 1 h)的未完成结果 —— 默认读端永远拒绝它们,但刚被取消的那一遍是有用的证据,所以先留一
|
||
段时间 —— 再按容量上限淘汰。
|
||
|
||
## 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`。
|