Files
orisys-app/apps/studio/docs/replay-analysis.md
T
ykcheungab 92b2f2fcd7 裸视频可分析:生成工作区、设置提示、逐帧力与按来源选画笔
pass 只认 frames.csv 与 session.json,裸视频此前因此只能播放;现在为它在应用自己的目录里生成
这两份文件并复用既有链路,链路重算的力按帧存入结果并标注为「重算」,box 的空白箭头画布改按帧
来源走共享画笔。
apps/studio/tests 497 项、shared/tests 471 项(2 个已知墙钟失败)、autocal 358 项、ui_check 634 项
均 0 失败,lint_gate 与 doc_lint 无发现;真实 GL 快照见 .scratch/bare-video/snapshots。
2026-10-05 16:57:02 +08:00

167 lines
12 KiB
Markdown
Raw 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 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,
在 4,048 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()` 报出。
## 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`。