设备接入与数据基础设施:从裸硬件到自描述的数据体系
已上线面对一个只能往 SD 卡存数据的裸硬件设备,先设计了完整的接入方案让它成为生产体系中的一等公民;随后借着设备端 H.264 编码性能瓶颈的机会,主导重构了整套视频数据基础设施——从设备侧的 Chunk 切片、Closed GOP 规范,到云端的 Remux、Manifest 数据抽象层、标定服务,再到多 Chunk 无缝播放和最终 MP4 交付格式。两个项目连起来,是一次从"让硬件跑通"到"让数据体系可持续演进"的完整推进。
iPhone 一直是采集系统的主力设备,流程比较成熟。后来硬件团队研发出一款新的自研双目采集设备,到手时硬件侧只完成了两件最基础的能力:能采集视频数据,数据能暂存在设备内存卡。至于设备怎么联网、怎么知道当前用户是谁、怎么知道自己该采集什么项目、怎么和手机协同、怎么把数据上传到云端、怎么进入后续的数据处理流水线——这些全都没有。
我的任务是把这个"能采数据的硬件",完整接入已有的数据生产系统。我是整个接入方案的统一 Owner + 平台侧的主要实现者 + 跨端联调的负责人:协议设计和修改方向由我主导,硬件团队按协议实现设备侧,我完成手机端和平台侧的全部适配代码,客户端、后端和硬件三方联调都在我这里收口。
而当双目设备真正开始跑起来之后,一个更深的问题浮出水面——设备端 H.264 编码把硬件压到瓶颈,连续录制几份之后就上传失败、死机。这不只是"优化一下设备端"的事情,它逼着我把整个视频数据的存储、处理、消费和交付链路都重新想了一遍。第二个项目就此展开。
章一:设备接入
配网方案:二维码取代设备发现
处境
设备启动之后,它自己没有任何联网能力,也不知道该连到哪个 Wi-Fi 上。传统的思路是让手机 App 扫描附近的 Wi-Fi 热点,把候选设备列出来让用户选,然后再进行配网。但在 iPhone 上,这条路走不通——iOS 对 Wi-Fi 扫描和发现的权限控制非常严格,App 不能主动扫描附近的 Wi-Fi 热点,也没法把周围的设备热点罗列出来。
桌上的选项
一条路是硬刚 iOS 权限,尝试各种边缘方案去做设备发现,比如通过 Bonjour/mDNS 或者其他更隐蔽的方式。这条路即便勉强跑通,用户体验和稳定性都很难保证,而且随着 iOS 版本升级随时可能被封掉。另一条路是绕开设备发现这件事本身,用一个物理入口直接承担"我是谁、怎么连上我"的信息——参考小米一系列智能硬件的配网思路,用二维码。
我的选择
我选了二维码方案。具体做法是:设备启动之后释放自己的 Wi-Fi AP 热点,SSID 和密码固定,把这份 Wi-Fi 连接信息生成二维码印在设备机身上。用户用 iPhone 扫码,手机解析出设备热点的 SSID 和密码后主动连接设备热点。手机连上设备热点之后,继续向设备下发一整套运行配置:手机设备信息、当前登录用户的信息、认证 Token、手机当前连接的真实 Wi-Fi 名称和密码(让设备也能联网)、当前运行环境(测试还是生产、国内还是海外)、用户当前的角色和权限。下发完成之后,设备就同时获得了网络、身份、权限和运行环境——一个原本完全不知道"自己是谁"的硬件,变成了可以接入云端体系的设备。之后设备用收到的 Wi-Fi 信息连接真正的网络,手机端轮询监听设备状态,直到确认设备已经正常联网。
代价
二维码需要提前印在设备机身上,设备一旦出厂,AP 热点的 SSID 和密码就固定了;如果未来要改,需要走硬件迭代。
结果
配网这一步从此没再出过问题。二维码把"设备发现"和"网络下发"两件事合成了一个动作,用户体验清晰,工程实现也简单,不用和 iOS 权限系统纠缠。
回看
这件事让我认识到,在平台约束面前,最省力的做法往往不是绕开约束,而是换一种交互入口把约束变得无关。小米走过的路是有价值的先例。
通信架构:服务端中转 vs MQTT 直连
处境
设备联网之后,手机和设备需要持续同步状态——手机要知道设备现在处于什么状态、有没有在录制、录到了多少;设备要知道当前任务是什么、要不要开始工作、什么时候停。这个双端状态同步的架构怎么设计,是整个通信层最核心的决策。
桌上的选项
一条路是让手机和设备直接通信,通过 MQTT 建立完整的双端交互协议,中间不经过服务端。理论上这是最直接的方案,延迟最低。但真要保证这种双端通信的稳定性、状态同步、异常恢复、协议完整性和双端一致性,系统复杂度会快速上升——两端各自都要维护完整的状态机,任何一端挂掉之后另一端要能感知、要能恢复,还要处理 MQTT Broker 的部署和可靠性问题。另外一个现实问题是功耗,双端保持长连接对设备的电量并不友好。
另一条路是让服务端来做中转:手机和设备各自把自己的状态(当前任务、心跳、设备状态、采集状态)同步到服务端,服务端把数据存进 Redis;每次某一端心跳过来的时候,服务端把对端的最新信息一起带回去。两端只需要面对服务端这一个通信对手,各自的协议实现都被简化。
我的选择
我选了服务端中转。放弃了理论上更直接的端到端方案,接受大约 1 秒左右的状态传播延迟——手机拿到新任务后设备大约 1 秒能感知,设备开始采集后手机大约 1 秒能感知,手机端可以同步显示设备当前的录制状态。对数据采集场景来说,1 秒的延迟完全够用。换来的是通信层的极大简化:两端都只面对服务端,任何一端的异常都通过"心跳停了"这个统一信号感知,恢复逻辑也统一。
代价
状态同步不是实时的,有约 1 秒的延迟;所有通信都要经过服务端,服务端如果挂了,双端就完全失联。
结果
通信层上线之后基本没有再出过生产故障。1 秒的延迟对采集场景没有任何影响,整个协议层的复杂度被压到了一个可维护的水平。
回看
这个决策比较典型地体现了一个判断:不追求理论上最直接的方案,而是根据业务的真实容忍度选一个足够可靠、复杂度更低的方案。工程里的很多决策都不是"哪个方案更牛",而是"哪个方案在当前约束下更值得维护"。
协议层的两个 Bug
配网和通信框架搭起来之后,真正让我对这套体系有更深理解的,是接下来遇到的两个协议层 Bug——一个是上线前发现的,一个是上线后暴露的。它们本质上都不是简单的 Coding Bug,而是状态模型和协议设计层面的问题。
Bug 1:心跳频率导致录制状态传播延迟
第一个 Bug 是在临上线前的测试场景发现的。平时设备状态一切正常,各种状态都能一致性上报,但一旦设备开始录制或者完成录制,我们经常拿不到状态信息——不是完全拿不到,是等特别特别久才能拿到,这让整个手机端的录制状态展示变得严重滞后。
问题定位过程走了几步。第一时间是在服务端把接口暴露出来,用一个高频请求持续拉取某台特定设备的状态。同时在设备端调试模式下把每次触发心跳的时间点也打出来,然后把这两份数据对齐,绘制了一张心跳同步图——设备端每次触发心跳的时候,我这一侧就能拿到一份新的状态信息,两条时间线放在一起看,异常就一目了然。
看到图之后才发现,AI 在协议设计阶段自己做了一个"优化性设计",我们评审的时候没有检查到:为了保证设备性能,AI 把心跳频率按状态分了三档——空闲状态每秒钟上报一次,录制状态每 7 秒钟上报一次,上传文件的时候每 10 秒钟上报一次。这个设计的初衷是"设备忙的时候少发心跳省资源",但直接后果就是设备进入录制状态之后,服务端要等 7 秒才能拿到下一次心跳,也就是说手机端要等最多 7 秒才能感知"设备开始录制了"。这段时间里的录制状态是完全不可见的。
修法很直接:同时修改设备端和服务端的协议实现,把心跳频率拉回一个合理的稳定值。改完之后录制状态的同步问题就消失了。
这次调试过程留下一个印象:当协议层出现异常现象的时候,光看代码是没用的,必须把可观测性建立起来——把两端的行为在时间轴上对齐,异常自己就会浮出来。
Bug 2:单向心跳导致多手机连接状态错乱
第二个 Bug 是上线之后暴露出来的。最初的心跳设计是单向的——只有设备端向服务端上报心跳,手机端不管这一套。这在单用户单设备的场景下没问题,但真实的生产环境里经常出现多手机连一台设备的场景。
具体是这样:手机 A 之前配网连过设备,手机 B 后来也配网连过设备。B 拿着设备开始录制,设备端按照 B 的 Token、B 的用户身份去干活,一切正常。但手机 A 那一边根本不知道设备已经和 B 相连了——它还认为自己是设备当前的所有者,还在展示设备的录制状态,把 B 录制的数据当成自己的。
问题的根源是原始状态模型里只有一个方向:设备 → 服务端 → 手机。缺少了"设备当前实际绑定的是哪台手机"这个概念,也缺少了"手机自己是否还在线、是否还认为自己是所有者"的双端一致性判断。
修法从两个维度展开。第一维是把心跳做成双向的:设备端上报心跳的时候,把自己当前连接的手机信息一起报出来;手机端也开始上报心跳,报出自己当前连接的设备信息。有了这两个信号,服务端就能做一件之前做不到的事——判断设备当前真正的所有者是谁。一旦双端某一端提出说切换设备或者删除设备,另一端就能自动感知并断开连接;手机关机之后心跳停止,设备端也能通过服务端确认手机已经离线。
第二维是这次修复里更有工程价值的部分:把"连接状态"和"历史数据的归属"彻底解耦。想象一个场景,手机 A 连着设备录了一段数据,数据还在设备本地没上传完,这时候手机 B 接管了设备。如果简单地"设备属于谁,数据就以谁的身份上传",A 之前录的那份数据就会挂着 B 的身份传上去,数据归属直接错乱。所以在协议层加了一条硬要求:设备端每一份数据在生成的时候,就锁定它当时应当使用的 Token 和身份配置,这份配置跟着数据走,长期保存。哪怕后续设备切换了新用户,甚至切换过好几轮,这份数据仍然会以它被录制时的原始身份完成上传。
一句话:当前设备属于谁 ≠ 历史数据应该以谁的身份上传。
回看这两个 Bug
两个 Bug 都不是简单的代码错误,而是状态模型层面没想清楚的问题。第一个让我认识到 AI 在辅助设计的时候会自己做一些"隐式优化",评审的时候必须逐条检查;第二个让我意识到一个状态模型是不是完整,很多时候要放到真实的多用户多设备场景下才能验证。这两个问题都通过增加观测手段找到了根因,然后推动两端一起修改协议。
硬件形态的反向设计
真正跑起来之后,我不只是在处理软件问题,也在观察设备本身在采集场景里的表现。这段观察最后反过来影响了硬件的形态设计。
最初的硬件形态是头戴式摄像头 + 腰挂主控主板。这个方案在实验室里没问题,但在真实的采集场景里,采集员做各种手部和关节动作的时候,线束会被身体动作干扰或者遮挡,直接影响采集稳定性。
我的第一个建议是把主控挪到头顶。但真正推理这个方案的时候发现一个大问题:电池。电池随着主控一起放到头顶,就会遇到我们平时戴 Pico 和 VR 头显时的经典困扰——太重了,长时间戴在头上会对颈部和头部产生巨大压力,采集员根本没办法长时间工作。这个方案 pass。
最终给硬件团队的建议是三条:
第一,主控主板和电池同时用背带背到背部正中间,这个位置在采集员做各种操作的时候不会产生任何干扰。
第二,头戴式设备的线束从后脑勺位置伸出,而不是从额头位置伸出。前置线束会在采集员视野里晃来晃去,后置从后脑勺出线就完全避开了视觉干扰。
第三,把启动按钮和状态指示灯从主控上转移到头戴摄像头附近。这一条有两个好处:一方面采集员可以随时通过视野内的观测确认采集设备当前状态;另一方面——这一条其实是采集合规的硬要求——市场上对采集数据的要求是采集时双手必须在视野范围之内,如果按钮放在腰上或背上,采集员按按钮的时候手会短暂地离开视野,这会直接影响数据的合规性。把按钮挪到摄像头附近之后,采集员按按钮的动作本身就发生在视野里。
这三条建议后来硬件团队都实现了。改完之后他们主动给我反馈说,这些建议非常有效。
这段经历留下的判断是:软硬件协同不只是"让硬件接进系统",还包括在真实业务场景里反向影响硬件形态。硬件团队做出来的第一版设备往往只考虑了功能可实现性,是不是真正适合业务场景需要软件侧和运营侧的人在实际使用中给反馈。
技术架构:设备接入完整链路
把章一涉及的所有环节串在一起,设备从开机到数据入库的完整链路如下:
┌─────────────┐
│ 双目设备开机 │ 只有摄像头 + 内存卡,无网络、无身份
└──────┬──────┘
│ ① 释放 Wi-Fi AP 热点(SSID/密码固定,印在机身二维码上)
▼
┌─────────────┐
│ iPhone 扫码 │ 解析 QR → 连接设备 AP 热点
└──────┬──────┘
│ ② 向设备下发运行配置
│ · 真实 Wi-Fi SSID + 密码
│ · 用户身份 + Token
│ · 运行环境(测试/生产、国内/海外)
│ · 角色与权限
▼
┌─────────────┐
│ 设备联网 │ 用收到的 Wi-Fi 信息连真实网络
└──────┬──────┘
│ ③ 手机轮询确认设备联网成功
▼
┌──────────────────────────────────────┐
│ 双端状态同步(服务端中转) │
│ │
│ 手机 ──心跳+状态──▶ 服务端/Redis │
│ │ │
│ 设备 ──心跳+状态──▶ 服务端/Redis │
│ │ │
│ 每次心跳返回时,服务端把对端信息带回 │
│ 延迟 ~1s,双端感知对方状态 │
│ │
│ Bug 修复后演进为双向心跳: │
│ · 设备上报:自身状态 + 当前连接手机 │
│ · 手机上报:自身状态 + 当前连接设备 │
│ · 连接状态 ≠ 数据归属 │
│ (数据锁定录制时的 Token,不随切换变) │
└──────────────────────────────────────┘
│ ④ 手机领取任务 → 设备感知 → 开始采集
▼
┌─────────────┐
│ 数据上传 │ 复用 iPhone 已有的 Pre-sign 上传链路
│ │ 设备 → 请求预签名 URL → 上传 → 回调确认
└──────┬──────┘
│ ⑤ 数据进入云端,与 iPhone 数据走同一套后续流程
▼
┌─────────────┐
│ 生产流水线 │ 质检 → 审核 → 标注 → 入库
└─────────────┘
团队规模:我 + 1 位软件同事,硬件 Leader + 实习生,共 4 人。我负责协议设计 + 平台侧全部实现 + 跨端联调;硬件团队按协议实现设备侧。部署规模:25 台测试机 + 5-10 台样机,运行约 1.5 个月,累计几千小时采集,国内和马来西亚均在生产。
设备接入这一层跑通之后,第二个更深的问题浮出来了:设备端在真实使用场景下的性能瓶颈。这个问题最终推着我把整套视频数据基础设施都重新设计了一遍。
章二:视频数据基础设施
设备与云端的职责重置:把编码搬下设备
处境
自研双目设备产生的是 H.264 裸流,但云端整个体系是基于 MP4 构建的——页面预览、手机端预览、浏览器播放对 H.264 裸流的支持都比较差,必须是 MP4 才能顺畅使用。最初的方案是让设备端自己完成 H.264 到 MP4 的封装再上传,逻辑上最简单,也不用改云端。
跑了一段时间之后,客户端和硬件那边持续反馈同一类问题:设备在连续采集场景下会累积性地变慢。具体的症状我印象很深——当采集员连续录制大概 3 份数据(每份两到五分钟)的时候,视频上传的进度就会变得特别差;录到第 7、8 份甚至第 9、10 份的时候,设备对操作的响应会极度延迟,视频传不上来,严重的时候机器直接死机。这个问题不是偶发,是每一台设备连续使用之后都会出现。
端侧工程师同步过来的定位结论是:设备一边录制,一边把 H.264 压成 MP4,一边把 MP4 往云端上传,三件事都吃 CPU,硬件算力跟不上。原本 H.264 裸流直接上传是很快的,是中间那道 MP4 封装/编码把设备算力拉到瓶颈的。
桌上的选项
一条路是继续在设备侧优化——换更高效的编码器、做更精细的资源调度,或者上更好的芯片。这条路的问题是投入产出比很差,硬件迭代周期长,而且随着采集时长增加瓶颈迟早还会回来。另一条路是重新划分设备和云端的职责边界:设备只做它必须做的事(生成 H.264 裸流),把 MP4 封装这件事整体搬到云端去做。设备的算力是硬约束,云端的算力是弹性资源,把计算压力从边缘搬到云端,是一件很自然的事。
我的选择
我选了后者,并且把这件事做成了完整的重构。设备端不再做 MP4 封装,只负责按 30 秒切片产生 H.264 数据,然后按 2 分钟组成一个 Chunk 上传。云端在数据进来之后跑一次 Remux——不是重新编码,只是把 H.264 裸流做一次 MP4 容器封装(加个头和尾),产物再入库。所有数据只有经过 Remux 之后才能正式进入后续流程。
为什么是 2 分钟一个 Chunk?这个数字不是拍脑袋,也不是实验验证的最优值,而是从既有运营经验里估出来的一个工程取值。在之前的车端场景里,我们已经预料到视频不能整片大规模上传,需要以分片形式上传,车端一般是 5 分钟一份。但车端的 5 分钟其实太大,一个短动作实际上只需要十几秒到几十秒,5 分钟会让一个短动作淹没在大量无关帧里。机器人的动作周期不一样,可能或长或短,很多时候完成一个完整动作的时长比车端更长,所以 2 分钟对于机器人的典型动作来说是一个更合适的粒度——大部分完整动作都能被完整地收纳在一个 2 分钟的包里。设备产出仍然按 30 秒切片(这一层由硬件团队实现),Chunk 层把 4 个 30 秒切片组合起来构成一个 2 分钟的业务单位。
代价
云端需要承担新的一整套 Remux 处理链路,包括 Workflow 编排、失败重试、状态更新、旧文件清理。2 分钟这个粒度是工程估值,不是实验证明的最优值——目前没有出现过明显的动作跨 Chunk 问题,但也没有严格数据支撑说它是最好的。Manifest 里显式记录 Chunk 时长这件事给未来留了余地:Chunk 粒度以后可以调整而不影响上层数据模型。
结果
改完之后连续录制的性能问题彻底消失了。之前录 7、8 份就死机的场景没再出现过。设备的发热、采集延迟、CPU 占用都恢复到了正常水平。
回看
这件事的本质不是"H.264 vs MP4",而是设备和云端的职责边界应该重新划。设备的算力是硬约束,任何非必要的计算都不应该放到设备侧。这个判断在之后的很多决策里都反复被印证。
Manifest:数据抽象层与在线格式演进
处境
在这次重构之前,整个数据体系其实没有一个严格的数据文件定义。iPhone 采集的数据是什么样、老的硬件设备采集的数据是什么样,大家都是凭印象在"知道"——固定位置、固定文件名,代码里到处是"去某个路径读某个文件"的硬编码。这套做法一直能跑,是因为设备形态和数据格式一直没变。但现在设备变了,数据格式也变了(H.264 Chunk 是新东西),如果不建立一个统一的抽象层,新旧数据在上层就会分裂成两套完全不同的读取逻辑。
而且更棘手的是,历史数据已经有几万小时了,都是旧的 MP4 格式,散落在存储里,被质检、播放器、后续处理各种业务链路持续消费。不可能因为要上新格式,就把这些历史数据全部重新切片处理一遍。
桌上的选项
一条路是做一次性的数据迁移,把历史几万小时全部按新格式重新切片、重新组织。工程量巨大,而且这段时间里生产还在继续,迁移窗口很难切出来。另一条路是让上层业务代码在读取的时候做"新旧兼容"——判断这份数据是新格式还是旧格式,走不同的分支。这样迁移成本低,但等于把复杂度永久留在了所有消费端。
我的选择
我引入了一个叫 Manifest 的东西作为数据抽象层。Manifest 定义清楚几件事:这份数据里是否有 Chunk、Chunk 里都有哪些文件(左视频、右视频、音频、陀螺仪等等)、每个文件的标准名称和 key、每个文件在存储里的相对路径。上层业务从此不再"去某个固定位置找某个文件",而是先读 Manifest,再根据"我要找什么类型的数据"从 Manifest 里查出对应的相对路径,然后去取。
有了这个抽象层,历史数据兼容就有了一个非常干净的做法:不重切老数据,而是给老数据补一份 Manifest。旧数据被描述成"一个只有一个 Chunk 的数据集",Manifest 里各个文件类型都以引用的形式指向原有的文件位置。新数据则是"多个 Chunk 的数据集",每个 Chunk 里包含标准的视频、音频、陀螺仪文件。上层看过来完全统一。
代价
引入了一层抽象,所有消费端都必须走 Manifest 才能拿到数据,不能再直接猜路径。这也意味着 Manifest 本身的稳定性和向后兼容性变成了整个数据体系的一个硬约束——它一旦要改,所有消费方都要跟着改。
结果
Manifest 后来成为了整个数据体系的标准入口。除了播放器和数据处理脚本之外,质检、流水线、标注等所有下游能力都通过 Manifest 找数据。新旧数据在消费层完全统一,历史几万小时数据不需要迁移就自然进入了新体系。
需要诚实说一句:目前采集到训练的直接链路还没有完全打通,Manifest 覆盖了从采集到标注的这段,但训练那一环还没接上。这一段等未来标注和训练流水线补齐之后再说。
回看
这件事让我意识到,在做数据格式演进的时候,比"选什么格式"更重要的是引入一层抽象把物理格式和消费逻辑解耦。有了 Manifest,格式本身的演进就变成了可持续的事——未来还想改,只要抽象层的语义不变,物理层可以再改一版而不影响上层。
技术架构:视频数据管道与 Manifest 数据模型
章二涉及的所有组件串在一起,从设备采集到最终交付的完整数据管道如下:
设备端(算力硬约束,只做最小必要工作)
┌────────────────────────────────┐
│ 摄像头 → H.264 裸流 │
│ │ │
│ ▼ │
│ 30s 切片(硬件团队实现) │
│ │ │
│ ▼ │
│ 4 × 30s → 组成 2min Chunk │
│ (业务粒度,基于动作周期经验取值) │
│ │ │
│ ▼ │
│ Pre-sign 上传 → 回调确认 │
└────────────┬───────────────────┘
│
云端(弹性算力,承担所有非实时计算)
▼
┌────────────────────────────────┐
│ ① Remux(FFmpeg) │
│ H.264 Chunk → MP4 容器封装 │
│ 不重编码,只加头尾 │
│ 可靠性: │
│ · 上传完成以设备回调为准 │
│ · 确定性任务:正常输入必成功 │
│ · 损坏输入重试无用,最多 3 次 │
│ │
│ ② Calibration 绑定 │
│ 标定服务查询当前设备标定版本 │
│ 快照固化到数据 → 数据自描述 │
│ fallback:设备记录 Git commit │
│ │
│ ③ Manifest 生成 │
│ 定义数据的标准入口 │
└────────────┬───────────────────┘
▼
┌────────────────────────────────┐
│ Manifest 数据模型 │
│ ┌──────────────────────────┐ │
│ │ manifest.json │ │
│ │ ├─ chunks: [ │ │
│ │ │ { │ │
│ │ │ index: 0, │ │
│ │ │ duration: "2min", │ │
│ │ │ files: { │ │
│ │ │ left_video: → 相对路径 │
│ │ │ right_video: → 相对路径 │
│ │ │ audio: → 相对路径 │
│ │ │ gyroscope: → 相对路径 │
│ │ │ } │ │
│ │ │ }, ... │ │
│ │ │ ] │ │
│ │ ├─ calibration: → 快照 │ │
│ │ └─ device_meta: ... │ │
│ └──────────────────────────┘ │
│ │
│ 旧数据兼容: │
│ MP4 历史数据 → 补 Manifest │
│ 描述为"单 Chunk 数据集" │
│ 引用指向原文件,不重切 │
└────────────┬───────────────────┘
▼
┌────────────────────────────────┐
│ 下游消费(统一通过 Manifest) │
│ · 自动质检流水线 │
│ · 人工质检 │
│ · 数据标注 │
│ · 多 Chunk 无缝播放器 │
│ · (训练链路尚未接入) │
└────────────┬───────────────────┘
│
▼ 仅在交付阶段
┌────────────────────────────────┐
│ MP4 合并 │
│ 多个 Chunk → 完整 MP4 │
│ → LiRobot Dataset → 客户 │
│ 生产格式(Chunk)≠ 交付格式(MP4)│
│ 中间环节不承担 MP4 组装成本 │
└────────────────────────────────┘
Closed GOP 是这条管道的编码层基础——没有它,Chunk 级切片和局部处理都不成立。
Closed GOP:从 iOS 脱敏教训到设备规范
Closed GOP 这件事严格说不是这次重构里新提出来的想法,而是把之前一次踩坑的经验前置到了新设备的设计阶段。
最早遇到这个问题是在 iOS 采集阶段。当时做视频脱敏测试,场景是这样的:一个 50 分钟的长视频里,可能只有 1 到 2 帧需要脱敏(比如某个人脸的短暂出现)。理想的做法是只处理那几帧所在的一小段视频,然后拼回去。但实际测试的时候发现,iOS 采集出来的视频用的是 Open GOP,一个 GOP 依赖前后 GOP 的参考帧,没办法独立解码,也就没办法只对某一段单独重新编码。最后只能对整个 50 分钟的 MP4 做一次全量重编码。这个计算成本完全不可接受——为了几帧脱敏,重算 50 分钟的视频,一份数据下来算力开销是原视频的几十倍。
这个教训被记住了。在自研双目设备设计的时候,我在编码规范上主动向硬件团队提了两条硬要求。
第一条,GOP 间隔必须固定——15 帧或者 16 帧一个 GOP,不允许出现动态 GOP。原因是后续的视频切割和处理都需要稳定的边界,如果 GOP 长度飘忽不定,切片逻辑就变得极其复杂。
第二条,必须使用 Closed GOP,保证每个 GOP 可以独立解码。这样一来,未来任何一段视频都可以在 GOP 边界上被切出来单独处理,然后再拼回去,不需要整片重编码。
我给硬件团队详细解释了这两条要求背后的原因和收益——不只是"我要",而是"为什么必须这样"。他们在设备的第一版就把这两条实现了。
代价主要是存储体积略有增加(Closed GOP 因为不能跨 GOP 参考帧,同样内容下体积稍微大一点),但换来的是后续切片、局部脱敏、局部重编码等所有基于分段处理的能力——这个 tradeoff 在长期看是完全值得的。
Closed GOP 在这里不是一个编码参数调整,而是一个数据基础设施层面的约束。它当下解决的是能否切片的问题,实际上给未来所有"局部处理"的场景都打下了基础。
云端 Remux 与可靠性设计
Remux 阶段本身在技术上不复杂,但整套链路的可靠性设计有一些值得说明的判断。
技术上,从 H.264 裸流封装成 MP4,本质就是给数据加一个 MP4 的容器结构(前后加头和尾),FFmpeg 已经有非常成熟的能力,没必要重新造轮子。我做的是把这个能力组织成一条可靠的生产链路:设备上传完成 → 拉取 H.264 → FFmpeg Remux → 生成 MP4 → 上传新 MP4 → 更新数据状态 → 删除旧 H.264。
第一个可靠性设计的点在"上传完成"的定义上。设备端上传的时候数据是分段传的,服务端这一侧不做任何猜测——我只认设备主动调用"完成上传"回调接口这一个信号,只有这个信号来了才认为这份数据是完整的、可以进入 Remux。如果设备只传了一部分就断了、没调回调,那这份数据在服务端就是"不完整"状态,Remux 根本不会启动。我不接受任何设备传了文件但不给确认的场景。
第二个可靠性设计的点在失败重试上。Remux 是一个非常典型的确定性任务——输入正常,一定会成功;输入不正常(H.264 文件损坏、格式和预期不一致),重试也不会成功。基于这个判断,我用 Flyte 上层封装的 Workflow 机制做了一层基础的重试兜底:一个 Workflow 在执行的时候如果超时会重试,最多重试 3 次,重试间隔相对较长。超过 3 次就进入失败,不做无限重试。实际线上跑下来,失败的原因基本上都是输入文件本身的问题,不存在那种"重试几次就好了"的暂态故障。
这两个决策的共同点是明确失败边界——什么样的失败值得系统层面兜底,什么样的失败应该直接暴露给上游。不做无限重试,也不接受不完整的数据进入处理,链路本身就变得可预测。
标定服务与数据自描述
处境
每台双目设备都有自己的标定参数,而且标定会有版本变化——有出厂标定,也有后续的重新标定;不同设备之间的标定不一样,同一台设备在不同时间的标定也不一样。原来标定数据是直接放在 Git 仓库里的,用的时候去拉。问题是数据下游(算法、处理、训练)在消费的时候,怎么知道某一份数据应该配哪个版本的标定?
桌上的选项
一条路是让设备端自己管理完整的标定文件——设备录一份数据的时候,把当时用的标定文件一起打包上传。这条路听起来很直接,但实际上很成问题:设备端往往拿不到一个特别标准完整的标定,让设备端去管标定文件的版本、更新、同步,很容易出错,而且出厂标定和重新标定两种场景下设备端的行为还不一样。生产侧的复杂度和错乱风险都会很高。
另一条路是从消费侧解决——建一个标定服务作为标定版本的权威源,然后在数据入库的时候把当时应该使用的标定固化到数据里。设备端不管标定文件,服务端一次性把"这份数据 + 应该使用的标定"绑定在一起。
我的选择
我做了第二条路。建了一个 Calibration Service:同步 Git 仓库里所有的标定文件,建立标定索引,加载到服务内存里,可以根据设备和数据信息查询到对应版本的标定。然后在数据流水线里加了一个关键动作——每份数据在刚上传的时候,Calibration Service 会立即把当时应当使用的标定拉下来,和数据一起保存。
这个设计的核心是数据自描述:后续的算法、处理、训练流程只需要信任数据自身携带的标定,不需要每次运行时都去查历史版本。Calibration Service 承担的其实是历史版本追溯能力,作为一个保底和审计机制存在。日常流程中,数据已经是自包含的完整单元。
我也考虑了故障场景:如果 Calibration Service 暂时不可用、而此时标定文件恰好又发生了修改,数据可能拿到错误的标定甚至拿不到标定。所以进一步提出了一个 fallback 方案——要求设备端在采集数据时把自己当时使用的标定的 Git commit 号也记录下来,作为可靠的兜底追溯依据。这一条作为 TODO 留给了硬件团队后续补齐,目前还没有完全实现。
代价
每份数据都会包含一份标定快照,存储上有一些冗余。设备端记录 Git commit 这一条 fallback 目前是未完成状态。
结果
标定和数据错配的问题在流程上被消灭了。下游流程再也不需要问"这份数据当时应该用哪个版本的标定"这样的问题,数据自身就带着答案。
回看
这件事的判断核心是把复杂度放在正确的位置——设备侧不适合管标定,那就不让设备管;数据消费侧不应该承担版本查询的负担,那就在数据入库的时候一次性固化。Calibration Service 是保底,日常主流程是数据自描述。
多 Chunk 无缝播放器
底层从一个完整视频文件变成了多个连续的 MP4 Chunk,物理形态变了,但用户播放视频时不应该感知到这个变化。
最简单的方案是让播放器逐个 Chunk 播——播完一个再加载下一个。实际体验很差:每个 Chunk 切换的瞬间都有明显的停顿,进度条也是"一段一段"的,用户看到的就是一个"卡卡的视频"。
于是我重新设计了一层播放器能力:自定义进度条对应的是整份数据的总时长,而不是单个 Chunk 的时长;当前 Chunk 快播完的时候,浏览器提前预加载下一个 Chunk;当前 Chunk 结束的瞬间,直接切换到已经预加载好的下一个视频。用户看到的是一个完整的连续播放体验,感受不到底层其实是多个 MP4 文件在拼接。
物理上是多个 MP4,用户体验上是一个完整视频。Web 和 iOS 都实现了同样的思路——iOS 上这部分的工作量其实不大,主要是对应的多分片视频预览播放器,方案和浏览器端是类似的。
生产格式与交付格式分离
最终客户是具身智能公司,他们的数据标准是 LiRobot,明确要求交付给他们的视频格式必须是 MP4。这带来一个问题:如果整个生产链路里所有的数据都必须以完整 MP4 的形式存在,那每次处理(质检、脱敏、标注等等)都要面对整段视频,处理成本会非常高。
我把数据生命周期拆成了两个阶段。
生产阶段里,数据始终以 Chunk 的形式保存和处理:采集 → H.264 Chunk → 上传 → 云端 Remux → MP4 Chunk 存储 → 质检 → 脱敏 → 其他数据处理,全部在 Chunk 状态下完成。所有的中间处理都只操作 Chunk,不涉及大文件。
只有到交付阶段,真正需要给客户交付的时候,才把所有相关 Chunk 合并成一份完整的 MP4,构建 LiRobot 数据集给客户。
这件事的判断是:生产格式为效率服务,交付格式为客户标准服务,不为了满足最终交付要求而在中间环节反复承担昂贵的视频处理成本。这个决策的收益在数据量小的时候不明显,但随着数据量增长,成本差异会指数级放大。
落地与团队
整套视频基础设施在大约 1.5 到 2 个月的时间内完成上线。上线不是一次性大切换,而是通过统一上线 → 分阶段 → 分批次处理历史数据的方式逐步推进的。
上线之后,公司所有的采集设备统一采用了这套体系。旧的上传链路彻底停用,不再有新数据从旧路径进来。历史几万小时数据通过 Manifest 兼容,不需要重新切片迁移。设备端不再承担 MP4 处理压力,原来连续录制之后出现的上传恶化、严重延迟、死机等问题此后没有再出现。Chunk / Manifest 数据体系正式成为后续质检、流水线、标注等能力的数据基础。
团队规模很小:软件侧是我加 1 位下属,硬件侧是硬件团队的 Leader 加一位实习生,一共 4 个人。我负责整体方案设计、Chunk 粒度和文件格式设计、Meta 和 Manifest 数据模型设计、生产/交付格式分离设计、Closed GOP 技术要求,也负责云端数据处理、Remux 流程、Calibration Service、Calibration 处理流水线、Web 播放器的实际开发;硬件团队负责设备端 Chunk 生成、固定 GOP、Closed GOP 等设备侧能力;下属参与部分平台侧的开发。
双目设备当前的部署规模:25 台手皮测试机 + 5 到 10 台后续样机,已经实际运行大约一个半月,累计采集大约几千小时(这个数字是我当前的记忆值,没有精确统计),国内和马来西亚的生产环境均在运行。双目数据进入云端之后与 iPhone 数据走完全一致的后续生产流程。
回看两个项目
这两个项目连起来看,本质是一次从"让硬件跑起来"到"让数据体系可持续演进"的完整推进。
设备接入这一层证明的是端到端落地能力和跨端方案推进能力——面对一个只能往 SD 卡存数据的裸硬件,把配网、身份、通信、上传的完整链路串起来,让它成为生产体系里的一等公民;MQTT vs 服务端中转的决策展示了在多个可行方案里基于实际约束做架构取舍的判断;两个协议层 Bug 的定位和修复展示了从异常现象到状态模型根因的问题定位能力;硬件形态的反向设计展示了从真实业务场景反向影响硬件设计的能力。
数据基础设施这一层证明的是系统架构能力和面向未来的基础设施投资意识——不是解决了一个 H.264 问题,而是借这个具体的性能问题重新定义了设备、云端、数据处理、消费、交付的职责边界;Manifest 的引入把物理格式和消费逻辑解耦,让数据格式可以持续演进;Closed GOP 的前置规范让当下的 Chunk 切片和未来的局部处理能力都有了基础;生产格式和交付格式分离让数据量增长后的成本不会失控。
两个项目共同证明的一件事是:边界还没定义清楚但必须往前走的问题,到我手里能变成跑得起来的东西。
iPhone 一直是采集系统的主力设备,流程比较成熟。后来硬件团队研发出一款新的自研双目采集设备,到手时硬件侧只完成了两件最基础的能力:能采集视频数据,数据能暂存在设备内存卡。至于设备怎么联网、怎么知道当前用户是谁、怎么知道自己该采集什么项目、怎么和手机协同、怎么把数据上传到云端、怎么进入后续的数据处理流水线——这些全都没有。
我的任务是把这个"能采数据的硬件",完整接入已有的数据生产系统。我是整个接入方案的统一 Owner + 平台侧的主要实现者 + 跨端联调的负责人:协议设计和修改方向由我主导,硬件团队按协议实现设备侧,我完成手机端和平台侧的全部适配代码,客户端、后端和硬件三方联调都在我这里收口。
而当双目设备真正开始跑起来之后,一个更深的问题浮出水面——设备端 H.264 编码把硬件压到瓶颈,连续录制几份之后就上传失败、死机。这不只是"优化一下设备端"的事情,它逼着我把整个视频数据的存储、处理、消费和交付链路都重新想了一遍。第二个项目就此展开。
章一:设备接入
配网方案:二维码取代设备发现
处境
设备启动之后,它自己没有任何联网能力,也不知道该连到哪个 Wi-Fi 上。传统的思路是让手机 App 扫描附近的 Wi-Fi 热点,把候选设备列出来让用户选,然后再进行配网。但在 iPhone 上,这条路走不通——iOS 对 Wi-Fi 扫描和发现的权限控制非常严格,App 不能主动扫描附近的 Wi-Fi 热点,也没法把周围的设备热点罗列出来。
桌上的选项
一条路是硬刚 iOS 权限,尝试各种边缘方案去做设备发现,比如通过 Bonjour/mDNS 或者其他更隐蔽的方式。这条路即便勉强跑通,用户体验和稳定性都很难保证,而且随着 iOS 版本升级随时可能被封掉。另一条路是绕开设备发现这件事本身,用一个物理入口直接承担"我是谁、怎么连上我"的信息——参考小米一系列智能硬件的配网思路,用二维码。
我的选择
我选了二维码方案。具体做法是:设备启动之后释放自己的 Wi-Fi AP 热点,SSID 和密码固定,把这份 Wi-Fi 连接信息生成二维码印在设备机身上。用户用 iPhone 扫码,手机解析出设备热点的 SSID 和密码后主动连接设备热点。手机连上设备热点之后,继续向设备下发一整套运行配置:手机设备信息、当前登录用户的信息、认证 Token、手机当前连接的真实 Wi-Fi 名称和密码(让设备也能联网)、当前运行环境(测试还是生产、国内还是海外)、用户当前的角色和权限。下发完成之后,设备就同时获得了网络、身份、权限和运行环境——一个原本完全不知道"自己是谁"的硬件,变成了可以接入云端体系的设备。之后设备用收到的 Wi-Fi 信息连接真正的网络,手机端轮询监听设备状态,直到确认设备已经正常联网。
代价
二维码需要提前印在设备机身上,设备一旦出厂,AP 热点的 SSID 和密码就固定了;如果未来要改,需要走硬件迭代。
结果
配网这一步从此没再出过问题。二维码把"设备发现"和"网络下发"两件事合成了一个动作,用户体验清晰,工程实现也简单,不用和 iOS 权限系统纠缠。
回看
这件事让我认识到,在平台约束面前,最省力的做法往往不是绕开约束,而是换一种交互入口把约束变得无关。小米走过的路是有价值的先例。
通信架构:服务端中转 vs MQTT 直连
处境
设备联网之后,手机和设备需要持续同步状态——手机要知道设备现在处于什么状态、有没有在录制、录到了多少;设备要知道当前任务是什么、要不要开始工作、什么时候停。这个双端状态同步的架构怎么设计,是整个通信层最核心的决策。
桌上的选项
一条路是让手机和设备直接通信,通过 MQTT 建立完整的双端交互协议,中间不经过服务端。理论上这是最直接的方案,延迟最低。但真要保证这种双端通信的稳定性、状态同步、异常恢复、协议完整性和双端一致性,系统复杂度会快速上升——两端各自都要维护完整的状态机,任何一端挂掉之后另一端要能感知、要能恢复,还要处理 MQTT Broker 的部署和可靠性问题。另外一个现实问题是功耗,双端保持长连接对设备的电量并不友好。
另一条路是让服务端来做中转:手机和设备各自把自己的状态(当前任务、心跳、设备状态、采集状态)同步到服务端,服务端把数据存进 Redis;每次某一端心跳过来的时候,服务端把对端的最新信息一起带回去。两端只需要面对服务端这一个通信对手,各自的协议实现都被简化。
我的选择
我选了服务端中转。放弃了理论上更直接的端到端方案,接受大约 1 秒左右的状态传播延迟——手机拿到新任务后设备大约 1 秒能感知,设备开始采集后手机大约 1 秒能感知,手机端可以同步显示设备当前的录制状态。对数据采集场景来说,1 秒的延迟完全够用。换来的是通信层的极大简化:两端都只面对服务端,任何一端的异常都通过"心跳停了"这个统一信号感知,恢复逻辑也统一。
代价
状态同步不是实时的,有约 1 秒的延迟;所有通信都要经过服务端,服务端如果挂了,双端就完全失联。
结果
通信层上线之后基本没有再出过生产故障。1 秒的延迟对采集场景没有任何影响,整个协议层的复杂度被压到了一个可维护的水平。
回看
这个决策比较典型地体现了一个判断:不追求理论上最直接的方案,而是根据业务的真实容忍度选一个足够可靠、复杂度更低的方案。工程里的很多决策都不是"哪个方案更牛",而是"哪个方案在当前约束下更值得维护"。
协议层的两个 Bug
配网和通信框架搭起来之后,真正让我对这套体系有更深理解的,是接下来遇到的两个协议层 Bug——一个是上线前发现的,一个是上线后暴露的。它们本质上都不是简单的 Coding Bug,而是状态模型和协议设计层面的问题。
Bug 1:心跳频率导致录制状态传播延迟
第一个 Bug 是在临上线前的测试场景发现的。平时设备状态一切正常,各种状态都能一致性上报,但一旦设备开始录制或者完成录制,我们经常拿不到状态信息——不是完全拿不到,是等特别特别久才能拿到,这让整个手机端的录制状态展示变得严重滞后。
问题定位过程走了几步。第一时间是在服务端把接口暴露出来,用一个高频请求持续拉取某台特定设备的状态。同时在设备端调试模式下把每次触发心跳的时间点也打出来,然后把这两份数据对齐,绘制了一张心跳同步图——设备端每次触发心跳的时候,我这一侧就能拿到一份新的状态信息,两条时间线放在一起看,异常就一目了然。
看到图之后才发现,AI 在协议设计阶段自己做了一个"优化性设计",我们评审的时候没有检查到:为了保证设备性能,AI 把心跳频率按状态分了三档——空闲状态每秒钟上报一次,录制状态每 7 秒钟上报一次,上传文件的时候每 10 秒钟上报一次。这个设计的初衷是"设备忙的时候少发心跳省资源",但直接后果就是设备进入录制状态之后,服务端要等 7 秒才能拿到下一次心跳,也就是说手机端要等最多 7 秒才能感知"设备开始录制了"。这段时间里的录制状态是完全不可见的。
修法很直接:同时修改设备端和服务端的协议实现,把心跳频率拉回一个合理的稳定值。改完之后录制状态的同步问题就消失了。
这次调试过程留下一个印象:当协议层出现异常现象的时候,光看代码是没用的,必须把可观测性建立起来——把两端的行为在时间轴上对齐,异常自己就会浮出来。
Bug 2:单向心跳导致多手机连接状态错乱
第二个 Bug 是上线之后暴露出来的。最初的心跳设计是单向的——只有设备端向服务端上报心跳,手机端不管这一套。这在单用户单设备的场景下没问题,但真实的生产环境里经常出现多手机连一台设备的场景。
具体是这样:手机 A 之前配网连过设备,手机 B 后来也配网连过设备。B 拿着设备开始录制,设备端按照 B 的 Token、B 的用户身份去干活,一切正常。但手机 A 那一边根本不知道设备已经和 B 相连了——它还认为自己是设备当前的所有者,还在展示设备的录制状态,把 B 录制的数据当成自己的。
问题的根源是原始状态模型里只有一个方向:设备 → 服务端 → 手机。缺少了"设备当前实际绑定的是哪台手机"这个概念,也缺少了"手机自己是否还在线、是否还认为自己是所有者"的双端一致性判断。
修法从两个维度展开。第一维是把心跳做成双向的:设备端上报心跳的时候,把自己当前连接的手机信息一起报出来;手机端也开始上报心跳,报出自己当前连接的设备信息。有了这两个信号,服务端就能做一件之前做不到的事——判断设备当前真正的所有者是谁。一旦双端某一端提出说切换设备或者删除设备,另一端就能自动感知并断开连接;手机关机之后心跳停止,设备端也能通过服务端确认手机已经离线。
第二维是这次修复里更有工程价值的部分:把"连接状态"和"历史数据的归属"彻底解耦。想象一个场景,手机 A 连着设备录了一段数据,数据还在设备本地没上传完,这时候手机 B 接管了设备。如果简单地"设备属于谁,数据就以谁的身份上传",A 之前录的那份数据就会挂着 B 的身份传上去,数据归属直接错乱。所以在协议层加了一条硬要求:设备端每一份数据在生成的时候,就锁定它当时应当使用的 Token 和身份配置,这份配置跟着数据走,长期保存。哪怕后续设备切换了新用户,甚至切换过好几轮,这份数据仍然会以它被录制时的原始身份完成上传。
一句话:当前设备属于谁 ≠ 历史数据应该以谁的身份上传。
回看这两个 Bug
两个 Bug 都不是简单的代码错误,而是状态模型层面没想清楚的问题。第一个让我认识到 AI 在辅助设计的时候会自己做一些"隐式优化",评审的时候必须逐条检查;第二个让我意识到一个状态模型是不是完整,很多时候要放到真实的多用户多设备场景下才能验证。这两个问题都通过增加观测手段找到了根因,然后推动两端一起修改协议。
硬件形态的反向设计
真正跑起来之后,我不只是在处理软件问题,也在观察设备本身在采集场景里的表现。这段观察最后反过来影响了硬件的形态设计。
最初的硬件形态是头戴式摄像头 + 腰挂主控主板。这个方案在实验室里没问题,但在真实的采集场景里,采集员做各种手部和关节动作的时候,线束会被身体动作干扰或者遮挡,直接影响采集稳定性。
我的第一个建议是把主控挪到头顶。但真正推理这个方案的时候发现一个大问题:电池。电池随着主控一起放到头顶,就会遇到我们平时戴 Pico 和 VR 头显时的经典困扰——太重了,长时间戴在头上会对颈部和头部产生巨大压力,采集员根本没办法长时间工作。这个方案 pass。
最终给硬件团队的建议是三条:
第一,主控主板和电池同时用背带背到背部正中间,这个位置在采集员做各种操作的时候不会产生任何干扰。
第二,头戴式设备的线束从后脑勺位置伸出,而不是从额头位置伸出。前置线束会在采集员视野里晃来晃去,后置从后脑勺出线就完全避开了视觉干扰。
第三,把启动按钮和状态指示灯从主控上转移到头戴摄像头附近。这一条有两个好处:一方面采集员可以随时通过视野内的观测确认采集设备当前状态;另一方面——这一条其实是采集合规的硬要求——市场上对采集数据的要求是采集时双手必须在视野范围之内,如果按钮放在腰上或背上,采集员按按钮的时候手会短暂地离开视野,这会直接影响数据的合规性。把按钮挪到摄像头附近之后,采集员按按钮的动作本身就发生在视野里。
这三条建议后来硬件团队都实现了。改完之后他们主动给我反馈说,这些建议非常有效。
这段经历留下的判断是:软硬件协同不只是"让硬件接进系统",还包括在真实业务场景里反向影响硬件形态。硬件团队做出来的第一版设备往往只考虑了功能可实现性,是不是真正适合业务场景需要软件侧和运营侧的人在实际使用中给反馈。
设备接入这一层跑通之后,第二个更深的问题浮出来了:设备端在真实使用场景下的性能瓶颈。这个问题最终推着我把整套视频数据基础设施都重新设计了一遍。
章二:视频数据基础设施
设备与云端的职责重置:把编码搬下设备
处境
自研双目设备产生的是 H.264 裸流,但云端整个体系是基于 MP4 构建的——页面预览、手机端预览、浏览器播放对 H.264 裸流的支持都比较差,必须是 MP4 才能顺畅使用。最初的方案是让设备端自己完成 H.264 到 MP4 的封装再上传,逻辑上最简单,也不用改云端。
跑了一段时间之后,客户端和硬件那边持续反馈同一类问题:设备在连续采集场景下会累积性地变慢。具体的症状我印象很深——当采集员连续录制大概 3 份数据(每份两到五分钟)的时候,视频上传的进度就会变得特别差;录到第 7、8 份甚至第 9、10 份的时候,设备对操作的响应会极度延迟,视频传不上来,严重的时候机器直接死机。这个问题不是偶发,是每一台设备连续使用之后都会出现。
端侧工程师同步过来的定位结论是:设备一边录制,一边把 H.264 压成 MP4,一边把 MP4 往云端上传,三件事都吃 CPU,硬件算力跟不上。原本 H.264 裸流直接上传是很快的,是中间那道 MP4 封装/编码把设备算力拉到瓶颈的。
桌上的选项
一条路是继续在设备侧优化——换更高效的编码器、做更精细的资源调度,或者上更好的芯片。这条路的问题是投入产出比很差,硬件迭代周期长,而且随着采集时长增加瓶颈迟早还会回来。另一条路是重新划分设备和云端的职责边界:设备只做它必须做的事(生成 H.264 裸流),把 MP4 封装这件事整体搬到云端去做。设备的算力是硬约束,云端的算力是弹性资源,把计算压力从边缘搬到云端,是一件很自然的事。
我的选择
我选了后者,并且把这件事做成了完整的重构。设备端不再做 MP4 封装,只负责按 30 秒切片产生 H.264 数据,然后按 2 分钟组成一个 Chunk 上传。云端在数据进来之后跑一次 Remux——不是重新编码,只是把 H.264 裸流做一次 MP4 容器封装(加个头和尾),产物再入库。所有数据只有经过 Remux 之后才能正式进入后续流程。
为什么是 2 分钟一个 Chunk?这个数字不是拍脑袋,也不是实验验证的最优值,而是从既有运营经验里估出来的一个工程取值。在之前的车端场景里,我们已经预料到视频不能整片大规模上传,需要以分片形式上传,车端一般是 5 分钟一份。但车端的 5 分钟其实太大,一次完整的驾驶动作实际上只需要十几秒到几十秒,5 分钟会让一个短动作淹没在大量无关帧里。机器人的动作周期不一样,可能或长或短,很多时候完成一个完整动作的时长比车端更长,所以 2 分钟对于机器人的典型动作来说是一个更合适的粒度——大部分完整动作都能被完整地收纳在一个 2 分钟的包里。设备产出仍然按 30 秒切片(这一层由硬件团队实现),Chunk 层把 4 个 30 秒切片组合起来构成一个 2 分钟的业务单位。
代价
云端需要承担新的一整套 Remux 处理链路,包括 Workflow 编排、失败重试、状态更新、旧文件清理。2 分钟这个粒度是工程估值,不是实验证明的最优值——目前没有出现过明显的动作跨 Chunk 问题,但也没有严格数据支撑说它是最好的。Manifest 里显式记录 Chunk 时长这件事给未来留了余地:Chunk 粒度以后可以调整而不影响上层数据模型。
结果
改完之后连续录制的性能问题彻底消失了。之前录 7、8 份就死机的场景没再出现过。设备的发热、采集延迟、CPU 占用都恢复到了正常水平。
回看
这件事的本质不是"H.264 vs MP4",而是设备和云端的职责边界应该重新划。设备的算力是硬约束,任何非必要的计算都不应该放到设备侧。这个判断在之后的很多决策里都反复被印证。
Manifest:数据抽象层与在线格式演进
处境
在这次重构之前,整个数据体系其实没有一个严格的数据文件定义。iPhone 采集的数据是什么样、老的硬件设备采集的数据是什么样,大家都是凭印象在"知道"——固定位置、固定文件名,代码里到处是"去某个路径读某个文件"的硬编码。这套做法一直能跑,是因为设备形态和数据格式一直没变。但现在设备变了,数据格式也变了(H.264 Chunk 是新东西),如果不建立一个统一的抽象层,新旧数据在上层就会分裂成两套完全不同的读取逻辑。
而且更棘手的是,历史数据已经有几万小时了,都是旧的 MP4 格式,散落在存储里,被质检、播放器、后续处理各种业务链路持续消费。不可能因为要上新格式,就把这些历史数据全部重新切片处理一遍。
桌上的选项
一条路是做一次性的数据迁移,把历史几万小时全部按新格式重新切片、重新组织。工程量巨大,而且这段时间里生产还在继续,迁移窗口很难切出来。另一条路是让上层业务代码在读取的时候做"新旧兼容"——判断这份数据是新格式还是旧格式,走不同的分支。这样迁移成本低,但等于把复杂度永久留在了所有消费端。
我的选择
我引入了一个叫 Manifest 的东西作为数据抽象层。Manifest 定义清楚几件事:这份数据里是否有 Chunk、Chunk 里都有哪些文件(左视频、右视频、音频、陀螺仪等等)、每个文件的标准名称和 key、每个文件在存储里的相对路径。上层业务从此不再"去某个固定位置找某个文件",而是先读 Manifest,再根据"我要找什么类型的数据"从 Manifest 里查出对应的相对路径,然后去取。
有了这个抽象层,历史数据兼容就有了一个非常干净的做法:不重切老数据,而是给老数据补一份 Manifest。旧数据被描述成"一个只有一个 Chunk 的数据集",Manifest 里各个文件类型都以引用的形式指向原有的文件位置。新数据则是"多个 Chunk 的数据集",每个 Chunk 里包含标准的视频、音频、陀螺仪文件。上层看过来完全统一。
代价
引入了一层抽象,所有消费端都必须走 Manifest 才能拿到数据,不能再直接猜路径。这也意味着 Manifest 本身的稳定性和向后兼容性变成了整个数据体系的一个硬约束——它一旦要改,所有消费方都要跟着改。
结果
Manifest 后来成为了整个数据体系的标准入口。除了播放器和数据处理脚本之外,质检、流水线、标注等所有下游能力都通过 Manifest 找数据。新旧数据在消费层完全统一,历史几万小时数据不需要迁移就自然进入了新体系。
需要诚实说一句:目前采集到训练的直接链路还没有完全打通,Manifest 覆盖了从采集到标注的这段,但训练那一环还没接上。这一段等未来标注和训练流水线补齐之后再说。
回看
这件事让我意识到,在做数据格式演进的时候,比"选什么格式"更重要的是引入一层抽象把物理格式和消费�具体解耦。有了 Manifest,格式本身的演进就变成了可持续的事——未来还想改,只要抽象层的语义不变,物理层可以再改一版而不影响上层。
Closed GOP:从 iOS 脱敏教训到设备规范
Closed GOP 这件事严格说不是这次重构里新提出来的想法,而是把之前一次踩坑的经验前置到了新设备的设计阶段。
最早遇到这个问题是在 iOS 采集阶段。当时做视频脱敏测试,场景是这样的:一个 50 分钟的长视频里,可能只有 1 到 2 帧需要脱敏(比如某个人脸的短暂出现)。理想的做法是只处理那几帧所在的一小段视频,然后拼回去。但实际测试的时候发现,iOS 采集出来的视频用的是 Open GOP,一个 GOP 依赖前后 GOP 的参考帧,没办法独立解码,也就没办法只对某一段单独重新编码。最后只能对整个 50 分钟的 MP4 做一次全量重编码。这个计算成本完全不可接受——为了几帧脱敏,重算 50 分钟的视频,一份数据下来算力开销是原视频的几十倍。
这个教训被记住了。在自研双目设备设计的时候,我在编码规范上主动向硬件团队提了两条硬要求。
第一条,GOP 间隔必须固定——15 帧或者 16 帧一个 GOP,不允许出现动态 GOP。原因是后续的视频切割和处理都需要稳定的边界,如果 GOP 长度飘忽不定,切片逻辑就变得极其复杂。
第二条,必须使用 Closed GOP,保证每个 GOP 可以独立解码。这样一来,未来任何一段视频都可以在 GOP 边界上被切出来单独处理,然后再拼回去,不需要整片重编码。
我给硬件团队详细解释了这两条要求背后的原因和收益——不只是"我要",而是"为什么必须这样"。他们在设备的第一版就把这两条实现了。
代价主要是存储体积略有增加(Closed GOP 因为不能跨 GOP 参考帧,同样内容下体积稍微大一点),但换来的是后续切片、局部脱敏、局部重编码等所有基于分段处理的能力——这个 tradeoff 在长期看是完全值得的。
Closed GOP 在这里不是一个编码参数调整,而是一个数据基础设施层面的约束。它当下解决的是能否切片的问题,实际上给未来所有"局部处理"的场景都打下了基础。
云端 Remux 与可靠性设计
Remux 阶段本身在技术上不复杂,但整套链路的可靠性设计有一些值得说明的判断。
技术上,从 H.264 裸流封装成 MP4,本质就是给数据加一个 MP4 的容器结构(前后加头和尾),FFmpeg 已经有非常成熟的能力,没必要重新造轮子。我做的是把这个能力组织成一条可靠的生产链路:设备上传完成 → 拉取 H.264 → FFmpeg Remux → 生成 MP4 → 上传新 MP4 → 更新数据状态 → 删除旧 H.264。
第一个可靠性设计的点在"上传完成"的定义上。设备端上传的时候数据是分段传的,服务端这一侧不做任何猜测——我只认设备主动调用"完成上传"回调接口这一个信号,只有这个信号来了才认为这份数据是完整的、可以进入 Remux。如果设备只传了一部分就断了、没调回调,那这份数据在服务端就是"不完整"状态,Remux 根本不会启动。我不接受任何设备传了文件但不给确认的场景。
第二个可靠性设计的点在失败重试上。Remux 是一个非常典型的确定性任务——输入正常,一定会成功;输入不正常(H.264 文件损坏、格式和预期不一致),重试也不会成功。基于这个判断,我用 Flyte 上层封装的 Workflow 机制做了一层基础的重试兜底:一个 Workflow 在执行的时候如果超时会重试,最多重试 3 次,重试间隔相对较长。超过 3 次就进入失败,不做无限重试。实际线上跑下来,失败的原因基本上都是输入文件本身的问题,不存在那种"重试几次就好了"的暂态故障。
这两个决策的共同点是明确失败边界——什么样的失败值得系统层面兜底,什么样的失败应该直接暴露给上游。不做无限重试,也不接受不完整的数据进入处理,链路本身就变得可预测。
标定服务与数据自描述
处境
每台双目设备都有自己的标定参数,而且标定会有版本变化——有出厂标定,也有后续的重新标定;不同设备之间的标定不一样,同一台设备在不同时间的标定也不一样。原来标定数据是直接放在 Git 仓库里的,用的时候去拉。问题是数据下游(算法、处理、训练)在消费的时候,怎么知道某一份数据应该配哪个版本的标定?
桌上的选项
一条路是让设备端自己管理完整的标定文件——设备录一份数据的时候,把当时用的标定文件一起打包上传。这条路听起来很直接,但实际上很成问题:设备端往往拿不到一个特别标准完整的标定,让设备端去管标定文件的版本、更新、同步,很容易出错,而且出厂标定和重新标定两种场景下设备端的行为还不一样。生产侧的复杂度和错乱风险都会很高。
另一条路是从消费侧解决——建一个标定服务作为标定版本的权威源,然后在数据入库的时候把当时应该使用的标定固化到数据里。设备端不管标定文件,服务端一次性把"这份数据 + 应该使用的标定"绑定在一起。
我的选择
我做了第二条路。建了一个 Calibration Service:同步 Git 仓库里所有的标定文件,建立标定索引,加载到服务内存里,可以根据设备和数据信息查询到对应版本的标定。然后在数据流水线里加了一个关键动作——每份数据在刚上传的时候,Calibration Service 会立即把当时应当使用的标定拉下来,和数据一起保存。
这个设计的核心是数据自描述:后续的算法、处理、训练流程只需要信任数据自身携带的标定,不需要每次运行时都去查历史版本。Calibration Service 承担的其实是历史版本追溯能力,作为一个保底和审计机制存在。日常流程中,数据已经是自包含的完整单元。
我也考虑了故障场景:如果 Calibration Service 暂时不可用、而此时标定文件恰好又发生了修改,数据可能拿到错误的标定甚至拿不到标定。所以进一步提出了一个 fallback 方案——要求设备端在采集数据时把自己当时使用的标定的 Git commit 号也记录下来,作为可靠的兜底追溯依据。这一条作为 TODO 留给了硬件团队后续补齐,目前还没有完全实现。
代价
每份数据都会包含一份标定快照,存储上有一些冗余。设备端记录 Git commit 这一条 fallback 目前是未完成状态。
结果
标定和数据错配的问题在流程上被消灭了。下游流程再也不需要问"这份数据当时应该用哪个版本的标定"这样的问题,数据自身就带着答案。
回看
这件事的判断核心是把复杂度放在正确的位置——设备侧不适合管标定,那就不让设备管;数据消费侧不应该承担版本查询的负担,那就在数据入库的时候一次性固化。Calibration Service 是保底,日常主流程是数据自描述。
多 Chunk 无缝播放器
底层从一个完整视频文件变成了多个连续的 MP4 Chunk,物理形态变了,但用户播放视频时不应该感知到这个变化。
最简单的方案是让播放器逐个 Chunk 播——播完一个再加载下一个。实际体验很差:每个 Chunk 切换的瞬间都有明显的停顿,进度条也是"一段一段"的,用户看到的就是一个"卡卡的视频"。
于是我重新设计了一层播放器能力:自定义进度条对应的是整份数据的总时长,而不是单个 Chunk 的时长;当前 Chunk 快播完的时候,浏览器提前预加载下一个 Chunk;当前 Chunk 结束的瞬间,直接切换到已经预加载好的下一个视频。用户看到的是一个完整的连续播放体验,感受不到底层其实是多个 MP4 文件在拼接。
物理上是多个 MP4,用户体验上是一个完整视频。Web 和 iOS 都实现了同样的思路——iOS 上这部分的工作量其实不大,主要是对应的多分片视频预览播放器,方案和浏览器端是类似的。
生产格式与交付格式分离
最终客户是具身智能公司,他们的数据标准是 LiRobot,明确要求交付给他们的视频格式必须是 MP4。这带来一个问题:如果整个生产链路里所有的数据都必须以完整 MP4 的形式存在,那每次处理(质检、脱敏、标注等等)都要面对整段视频,处理成本会非常高。
我把数据生命周期拆成了两个阶段。
生产阶段里,数据始终以 Chunk 的形式保存和处理:采集 → H.264 Chunk → 上传 → 云端 Remux → MP4 Chunk 存储 → 质检 → 脱敏 → 其他数据处理,全部在 Chunk 状态下完成。所有的中间处理都只操作 Chunk,不涉及大文件。
只有到交付阶段,真正需要给客户交付的时候,才把所有相关 Chunk 合并成一份完整的 MP4,构建 LiRobot 数据集给客户。
这件事的判断是:生产格式为效率服务,交付格式为客户标准服务,不为了满足最终交付要求而在中间环节反复承担昂贵的视频处理成本。这个决策的收益在数据量小的时候不明显,但随着数据量增长,成本差异会指数级放大。
落地与团队
整套视频基础设施在大约 1.5 到 2 个月的时间内完成上线。上线不是一次性大切换,而是通过统一上线 → 分阶段 → 分批次处理历史数据的方式逐步推进的。
上线之后,公司所有的采集设备统一采用了这套体系。旧的上传链路彻底停用,不再有新数据从旧路径进来。历史几万小时数据通过 Manifest 兼容,不需要重新切片迁移。设备端不再承担 MP4 处理压力,原来连续录制之后出现的上传恶化、严重延迟、死机等问题此后没有再出现。Chunk / Manifest 数据体系正式成为后续质检、流水线、标注等能力的数据基础。
团队规模很小:软件侧是我加 1 位下属,硬件侧是硬件团队的 Leader 加一位实习生,一共 4 个人。我负责整体方案设计、Chunk 粒度和文件格式设计、Meta 和 Manifest 数据模型设计、生产/交付格式分离设计、Closed GOP 技术要求,也负责云端数据处理、Remux 流程、Calibration Service、Calibration 处理流水线、Web 播放器的实际开发;硬件团队负责设备端 Chunk 生成、固定 GOP、Closed GOP 等设备侧能力;下属参与部分平台侧的开发。
双目设备当前的部署规模:25 台手皮测试机 + 5 到 10 台后续样机,已经实际运行大约一个半月,累计采集大约几千小时(这个数字是我当前的记忆值,没有精确统计),国内和马来西亚的生产环境均在运行。双目数据进入云端之后与 iPhone 数据走完全一致的后续生产流程。
回看两个项目
这两个项目连起来看,本质是一次从"让硬件跑起来"到"让数据体系可持续演进"的完整推进。
设备接入这一层证明的是端到端落地能力和跨端方案推进能力——面对一个只能往 SD 卡存数据的裸硬件,把配网、身份、通信、上传的完整链路串起来,让它成为生产体系里的一等公民;MQTT vs 服务端中转的决策展示了在多个可行方案里基于实际约束做架构取舍的判断;两个协议层 Bug 的定位和修复展示了从异常现象到状态模型根因的问题定位能力;硬件形态的反向设计展示了从真实业务场景反向影响硬件设计的能力。
数据基础设施这一层证明的是系统架构能力和面向未来的基础设施投资意识——不是解决了一个 H.264 问题,而是借这个具体的性能问题重新定义了设备、云端、数据处理、消费、交付的职责边界;Manifest 的引入把物理格式和消费逻辑解耦,让数据格式可以持续演进;Closed GOP 的前置规范让当下的 Chunk 切片和未来的局部处理能力都有了基础;生产格式和交付格式分离让数据量增长后的成本不会失控。
两个项目共同证明的一件事是:边界还没定义清楚但必须往前走的问题,到我手里能变成跑得起来的东西。