把高频传感器(IMU)数据封装进 MP4:FFmpeg 私有数据轨实践
这是个很常见的需求:设备上有一路高频传感器在采样,同时有一路视频在录,后期处理时需要让两边严格对上时间轴,而且这个对应关系要跟着文件走,不能靠文件名约定。
最朴素的做法是存成 sidecar 文件:
bash
recording.mp4
recording_sensor.csv # 传感器采样
recording_video_pts.csv # 每一帧的 PTS
能用,但作为交付格式有三个问题。用户拷贝时很可能只拖 mp4,CSV 留在原地,等到了处理环节才发现数据没了,而设备上那份可能已经被循环覆盖。文件名约定和时间戳零点约定全是隐式契约,换个人换个工具链就断。CSV 里的时间戳是绝对刻度,视频 PTS 是相对首帧的,中间差多少得靠第二个 CSV 反查。
所以更靠谱的做法是把数据直接封装进 MP4,让它成为一个自描述的文件。
下面讲具体怎么做,以及中间踩的坑。
说明:本文用一路 400Hz、每样本 6 路 int16 通道的采样流作为例子,具体是什么传感器不影响讨论。代码片段是便于说明的示意,不是可直接编译的完整实现。
一、整体数据通路
图里 MP4 有三条轨,其中两条是我们自己加的私有轨:
- 数据轨:传感器采样流,来自采集线程,攒批后写入。
- 元数据轨 :
first_frame_pts(来自视频首帧)+ 标定参数(从磁盘文件读取)。这条轨单独存在的原因在第五节展开。 - 视频轨是编码线程正常写的那条,列出来是为了说明三条轨在同一个容器里交错。
几个设计要点:
- 中断里只做最少的事:取时间戳、唤醒下半部。真正搬数据放到内核线程里做,避免长时间占用中断、影响后续采样中断的响应。
- 采集线程是纯搬运工:一次批量读十几到几十个样本,不做业务判断。
- 封装由单独线程消费:负责把数据喂给封装器,同时处理封装器生命周期和数据生产节奏不一致的问题。
- 元数据的两个来源时机不同 :
first_frame_pts要等视频首帧到达才能确定,标定参数则是启动时就有的。所以元数据不是一开始就写,而是等首帧落盘后再补写(见第五节)。
二、时间基准:让两边共用一个时基
这一节是整个方案的地基。
别用软件时钟
很多驱动的第一反应是这么打时间戳:
c
// 反例
ktime_get_real_ns()
这是 Linux 的软件时钟。它和视频编码器输出的 PTS 是两个独立的源,两者之间有初始偏移;更麻烦的是软件时钟会被 NTP 做频率调整(slew)或阶跃修正(step),因此会相对原始硬件计数器缓慢偏移,甚至直接跳变。想靠软件去估计和补偿这个偏差,是个无底洞。
更好的做法是在中断里直接读 SoC 的全局硬件计数器:
c
static irqreturn_t sensor_irq_handler(int irq, void *dev_id)
{
spin_lock_irqsave(&dev->lock, flags);
dev->last_tick = soc_global_tick(); // SoC 全局硬件计数器
spin_unlock_irqrestore(&dev->lock, flags);
if (dev->enabled) {
schedule_work(&dev->work);
}
return IRQ_HANDLED;
}
关键在于:视频编码器输出的帧 PTS 也用同一个计数器。这样一来,传感器时间戳和视频 PTS 天然同源、同刻度,不存在长期漂移,单位统一成微秒就能直接用。
这是硬件层面给的红利。如果手上的 SoC 没有统一的硬件 tick,退一步的方案是让传感器驱动去读编码器的时基,总之别让两边各读各的时钟。
零点得单独记
同源不等于能直接对齐。传感器从设备上电就开始跑,视频从按下录制才开始,中间差着一个不确定的启动时长。
所以视频首帧到达时把它的 PTS 记下来:
cpp
if (first_frame_pts == kNoPts) {
first_frame_pts = frame->pts_us;
LOG_I("first frame come. keyframe=%d, pts=%llu",
frame->is_keyframe, first_frame_pts);
}
这个值随后写进 MP4 的元数据轨。提取端做一次减法,就得到相对视频起点的微秒时间:
python
relative_us = sample["timestamp_us"] - first_frame_pts
这里有个刻意的选择:视频轨的 PTS 归一化到相对首帧,传感器样本则保留绝对刻度。
两者做法不同,是因为它们的性质不一样。视频是一条展示时间轴,播放器要靠它的时间戳算总时长、画进度条、做 seek,MP4 里 mvhd/tkhd 记录的时长也是从轨内时间戳推出来的。所以视频 PTS 必须从一个接近 0 的值起步,否则进度条会从一个奇怪的偏移开始,时长也可能算错。归一化到相对首帧就是为此。
传感器数据轨不是展示轨,播放器不会去渲染它,也就没有这个约束。反过来,保留绝对刻度还多一层好处:一旦首帧 PTS 丢失(文件损坏、分段切换),数据本身依然是自洽的,只要再补一个零点就能恢复对齐。如果当初也按相对首帧存,这份信息就永久丢了。
批量到达时的多帧递推
还有个细节。中断里拿到的 tick 是"这一批数据到达的时刻",但缓冲区一次可能吐出好几帧,这些帧的时间戳得递推补齐:
c
cur_tick = dev->last_tick; // 中断里取的原始 tick
raw_tick = cur_tick; // 备份一份,后面要拿它推进基准点
count = get_pending_frame_count(dev);
// 用两批之间的实际间隔除以帧数,估一个平均采样间隔
gap = (cur_tick - dev->prev_tick) / count;
if ((gap - dev->nominal_gap) > dev->nominal_gap / 2) {
// 间隔比标称值大了一半以上(比如系统卡顿),钳制回标称间隔,避免误差累积
gap = dev->nominal_gap;
cur_tick -= gap * (count - 1);
} else {
cur_tick = dev->prev_tick + gap;
}
dev->prev_tick = raw_tick; // 基准点推进到本批的原始 tick
do {
msg->timestamp = cur_tick;
cur_tick += gap;
read_one_frame(dev, &msg->data);
} while (--count);
注意那个钳制分支。如果实测间隔比标称值大了一半以上,就认为这次中断被延迟了,直接回退到标称间隔。这是防止一次系统抖动把后续所有时间戳带偏的保险丝。判断条件只覆盖间隔偏大的方向,这一点在写代码时要注意。
硬件时基解决的是基准统一,采样点的微观抖动还是得靠递推加钳制,两件事不能只做一半。
别忘了流水线延迟
还有一类误差容易被忽略:从传感器采样到编码器出包,中间有一条固定延迟,而且这个延迟会随分辨率和帧率变化。传感器在 t 时刻测到的变化,对应的并不是 t 时刻输出的那一帧。
如果这个延迟相对帧间隔不可忽略,就需要做常量补偿。做法是维护一张"工作模式 → 偏移微秒数"的表,在数据入口统一减掉:
cpp
static void on_stream_data(uint8_t *data, uint32_t len, venc_pack_t* pack, const void* ctx)
{
if (camera_exit || !recording) {
return;
}
if (pack) {
// 按当前工作模式查表补偿流水线延迟
pack->pts_us -= kPipelineDelayByMode.at(current_mode_id);
}
...
}
这张表的数值只能通过标定得到,属于实验数据驱动的工程常量,换个模组就得重测,而且不同档位的值可能差出好几倍。
比较合理的做法是把这张表跟模组标定参数放在一起管理,而不是硬编码在业务代码里。
三、样本格式怎么定
定长、原始值、跨语言可校验
cpp
// 单条 20 字节:uint64 时间戳 + 6 路 int16 原始通道
struct __attribute__((packed))
{
uint64_t timestamp_us; // 微秒,与视频共用的时基
int16_t ch[6]; // 原始值,不落浮点
} sample;
static_assert(sizeof(sample) == 20, "sample size mismatch");
有三个地方是刻意的。
只存原始整数值,不存物理量。 如果直接存浮点(换算成物理单位后的值),体积会涨一半以上,而且标定信息一旦更新就无法回溯------已经录好的文件永远只能用旧的换算系数。存原始值加一份标定参数,后期可以任意重新换算。
用 static_assert 把大小锁死。 这个结构体是跨语言契约,一边是 C/C++ 写,一边是脚本读。加了这个断言,将来有人往结构体里加字段导致格式变更,编译期就会报出来,比运行时数据错乱好得多。
__attribute__((packed)) 不能省。 不加的话编译器按 8 字节对齐,结构体从 20 字节变成 24 字节,脚本那边按 20 字节切分解出来全是垃圾。
体积开销可以算一下:400Hz × 20 字节 = 8KB/s。相对于视频码率,这个开销基本可以忽略。
四、造一条私有数据轨
三个字段决定一切
FFmpeg 支持往 MP4 里塞任意数据轨,关键是 codec_type、codec_id、time_base:
cpp
if (embed_enabled)
{
data_stream = avformat_new_stream(out_ctx, nullptr);
if (data_stream)
{
data_stream->id = out_ctx->nb_streams - 1;
data_stream->codecpar->codec_type = AVMEDIA_TYPE_DATA;
data_stream->codecpar->codec_id = AV_CODEC_ID_BIN_DATA;
data_stream->time_base = {1, 1000000}; // 微秒
has_data = true;
}
// 元数据轨:放 first_frame_pts 和标定参数
meta_stream = avformat_new_stream(out_ctx, nullptr);
if (meta_stream)
{
meta_stream->id = out_ctx->nb_streams - 1;
meta_stream->codecpar->codec_type = AVMEDIA_TYPE_DATA;
meta_stream->codecpar->codec_id = AV_CODEC_ID_BIN_DATA;
meta_stream->time_base = {1, 1000000};
}
}
这样写出来的文件,ffprobe 会看到两条 data 类型的流,handler_type 是 meta。任何基于 FFmpeg 的工具都能看出"这里有额外数据",但不会尝试去解码它。不破坏兼容性,信息又是完整的。
攒包再写
如果每个样本都调一次 av_interleaved_write_frame,就是每秒几百次调用,每次只写 20 字节。这个函数内部要做流选择、缓冲、索引维护,还有锁竞争,在资源受限的设备上开销不小。
所以做批量攒包:
cpp
static constexpr uint32_t kBatchSize = 400; // 攒够 400 条写一次
static constexpr size_t kSampleSize = 20;
std::vector<uint8_t> batch; // 攒包缓冲
uint32_t batch_count = 0;
cpp
const uint8_t* p = reinterpret_cast<const uint8_t*>(&sample);
batch.insert(batch.end(), p, p + kSampleSize);
batch_count++;
if (batch_count >= kBatchSize)
{
flush_batch();
}
攒包大小选多少,有个实用技巧:让它对应一个整数的时间单位。采样率 400Hz,那 400 条正好是 1 秒,每个 AVPacket 代表恰好 1 秒的数据。这个语义在后来排障时帮了大忙------一旦发现少了 400 条,立刻能判断是丢了整整一包,而不是随机丢。
写入时用 av_interleaved_write_frame 而不是 av_write_frame:
cpp
void flush_batch()
{
// 调用方必须已持有 mux 锁
if (!has_data || !data_stream || !out_ctx) return;
if (batch_count == 0 || batch.empty()) return;
AVPacket* pkt = av_packet_alloc();
if (av_new_packet(pkt, (int)batch.size()) < 0) {
av_packet_free(&pkt);
return;
}
memcpy(pkt->data, batch.data(), batch.size());
pkt->stream_index = data_stream->index;
pkt->pts = packet_count;
pkt->dts = pkt->pts;
pkt->duration = batch_count;
packet_count += batch_count;
// 标准 MP4 用 av_interleaved_write_frame,才能正确交错并建立索引
av_interleaved_write_frame(out_ctx, pkt);
av_packet_free(&pkt);
batch.clear();
batch_count = 0;
}
这里用 av_interleaved_write_frame 而不是 av_write_frame,区别在交错顺序。前者会把包缓冲起来、按 DTS 重排后再交给封装器,后者直接透传,顺序完全由调用者负责。数据是攒满一批才写的,如果直接透传,很容易写成一堆数据包连续堆叠的文件------播放器顺着文件读下去,会在某个位置突然撞上一大段和画面无关的数据,流式播放尤其难受;跨流交错顺序不对还可能触发非单调 DTS 的报错。
顺带一提,元数据轨那三个包用的是 av_write_frame,这里没有换成交错写入。原因是元数据只在首帧落盘后写一次,总共三个包,写入时交错队列里没有待处理的视频包,顺序问题不存在,透传反而更直接。
pkt->pts 用的是累计样本数,不是真实时间戳。轨道 time_base 是 {1, 1000000},而每个样本的真实时间戳存在包体内的 20 字节里,所以容器层的索引和包体数据是两套独立信息------即使容器索引因为某种原因不准,包体内的绝对时间戳依然可靠。
代价也得说清楚。pts 用累计样本数,意味着这条轨在容器层面呈现的时间跨度是 48000 微秒,也就是 48 毫秒,而视频轨是 120 秒。数据轨的"时长"和真实录制时长严重不符。对于一条不渲染的数据轨,播放器一般不会在意,但这很可能就是 mov 封装器给这条轨写出 edit list 的诱因之一------两条轨的时间跨度差得太远,封装器会试图用编辑列表去对齐。换句话说,这个设计在容错性上的收益,和它在容器层面引入的不一致,是要一起权衡的。
五、元数据轨:让文件自描述
光有数据不够,还得告诉处理端这些数字怎么用。这些信息全部塞进第二条 data 轨,用 pkt->pts 当类型标签:
| pts | 内容 | 作用 |
|---|---|---|
| 0 | first_frame_pts(8 字节 int64) |
时间零点 |
| 1 | 传感器标定参数(JSON) | 采样率、量程、轴变换、内部延迟 |
| 2 | 镜头/几何标定参数(JSON) | 与数据关联的其它参数 |
cpp
void write_metadata()
{
if (meta_written || !meta_stream || !out_ctx) return;
meta_written = true;
AVPacket* pkt = av_packet_alloc();
if (!pkt) return;
// 1) first_frame_pts,8 字节 int64
if (first_frame_pts != kNoPts)
{
av_packet_unref(pkt);
pkt->data = (uint8_t*)&first_frame_pts;
pkt->size = sizeof(int64_t);
pkt->stream_index = meta_stream->index;
pkt->pts = 0; // pts 当类型标签用
pkt->dts = 0;
pkt->duration = 1;
av_write_frame(out_ctx, pkt);
}
// 2) 传感器标定 JSON
// 3) 其它关联参数 JSON
...
}
用 pts 当类型标签 是个很省事的做法:既不用自定义一个 TLV 格式,又能让老的提取脚本通过 pts 一眼认出内容类型。
标定参数里通常需要这些字段:
json
{
"sample_rate": 400,
"time_scale": 0.000001,
"scale": ["<各路原始值到物理量的换算系数>"],
"bias": ["<零偏>"],
"transform": "<传感器坐标系到目标坐标系的轴变换>",
"internal_delay_us": "<器件内部延迟>"
}
注意 internal_delay_us 和前面说的流水线延迟是两回事:前者在器件内部,后者在 SoC 流水线上,如果两者都不可忽略,就要分别补。
标定参数以磁盘为准
写元数据时,标定参数建议优先从磁盘读,内存缓存只作兜底:
cpp
std::string config;
std::ifstream ifs(kConfigPath);
if (ifs.is_open()) {
config.assign(std::istreambuf_iterator<char>(ifs), {});
}
if (config.empty()) {
// 磁盘文件不存在或为空,回退到内存缓存
config = cached_config;
}
为什么要这么绕?因为这些标定文件是可以在设备端被更新或重新生成的(用户重新标定后覆盖)。如果封装器只用启动时缓存的那份,就会出现磁盘上已经是新参数、录出来的文件里写的还是旧参数这种极隐蔽的不一致。
跨生命周期的配置以磁盘为准,内存缓存只做兜底。这条在嵌入式项目里基本都成立。
和分辨率绑定的参数要分开存
一个容易忽略的点:几何标定参数往往和输出分辨率绑定。同一套光学系统,不同分辨率下裁剪缩放后的等效内参是不同的。
六、封装器还没就绪时怎么办
采集是在编码线程启动时开始的,而封装器对象往往要等录制真正开始才创建。中间有个时间窗:数据已经在产出了,但还没有消费者。
直接丢弃的话会丢掉开头几十到几百毫秒的数据,而这段往往正好是最有价值的部分。
解决办法是加一层预缓存:
cpp
void writer_loop()
{
while (!writer_stop)
{
std::vector<sample_t> items;
{
std::unique_lock<std::mutex> lock(queue_mutex);
queue_cond.wait_for(lock, std::chrono::milliseconds(50),
[]{ return !queue.empty() || writer_stop; });
if (writer_stop && queue.empty()) break;
if (queue.empty()) continue;
while (!queue.empty()) {
items.push_back(queue.front());
queue.pop();
}
}
auto* muxer = recorder.get_muxer();
if (!muxer)
{
// 封装器未就绪,先存进预缓存队列
std::lock_guard<std::mutex> lock(precache_mutex);
for (const auto& s : items)
precache.push(s);
continue;
}
// 封装器就绪,先把预缓存的数据回放掉(队列空时无额外开销)
{
std::lock_guard<std::mutex> lock(precache_mutex);
if (!precache.empty())
LOG_I("flushed pre-cached samples, count=%zu", precache.size());
while (!precache.empty())
{
muxer->write_sample(precache.front());
precache.pop();
}
}
for (const auto& s : items)
muxer->write_sample(s);
}
}
用了两个队列。一个是采集线程和写入线程之间的交接队列,另一个是封装器未就绪时的暂存队列。分开是因为两者的锁竞争特征完全不同:前者高频短临界区,后者低频长临界区。
wait_for 用 50ms 超时而不是无限等待,保证没有新数据时线程也能定期检查停止标志退出。
回放的顺序不能反,必须先回放预缓存再写当前批次,否则文件里的时间戳会出现回跳。
队列最好设个上限。没上限的话,如果封装器迟迟不就绪,内存会一直涨。至少也要打条日志盯着实际窗口大小。
生产者消费者生命周期不一致的时候,缓存加回放比阻塞等待稳,但缓存得有边界。
七、停止时的 flush 顺序
这是个顺序错了就丢数据、而且文件看起来完全正常的陷阱。
数据是攒够一批才写一次的。如果录制在攒到一半时停止,剩下的样本还躺在缓冲里。这时候直接 av_write_trailer,moov 会被写出来,文件能正常播放,但最后不足一批的数据凭空消失。提取脚本也不会报错,因为它读的是 moov 里声明的样本表,而声明里本来就没有这批数据。
所以停止前要按顺序做三件事:
cpp
void flush_before_stop()
{
{
std::lock_guard<std::mutex> lock(mux_mutex);
if (!out_ctx || !out_ctx->pb) return;
// 1. 先刷出还没攒满一批的数据
flush_batch();
// 2. 再刷空 FFmpeg 内部交错缓冲区
av_write_frame(out_ctx, nullptr);
avio_flush(out_ctx->pb);
// 3. 唤醒可能卡在音视频同步等待上的音频线程
audio_sync_abort = true;
}
audio_sync_cv.notify_all();
}
顺序是:先把尾巴交给交错器,再刷空交错器内部缓冲,然后才轮到 av_write_trailer 写 moov。
中间那步最容易漏。很多人以为 av_write_frame 之后就落盘了,实际上 FFmpeg 有内部交错缓冲,必须显式 flush。但如果 trailer 之前没把尾巴交给交错器,那就永远写不进去了。
av_write_trailer 不是万能收尾,它只写索引,不负责补数据。 所有攒包逻辑都得有对应的 flush 钩子,而且要保证钩子在 trailer 之前被调用。
八、踩的坑
坑一:mov demuxer 静默丢样本
这是整个项目里最难查的一个问题。
现象:设备录的文件用自己写的脚本读,整条轨少了 400 条,正好是一个包;时间戳上表现为一处约 1 秒的断档。视频音频完全正常,文件也能正常播放。
排查过程:先怀疑采集端丢数据,在入队的地方加了一堆计数日志,发现一条不少。再怀疑攒包逻辑写漏,检查批内计数和累计包计数,也对得上。
那就只能是"写进去了但读不出来"。用 ffprobe -show_packets 数包数,发现比写入的包数少。
根因 :FFmpeg 的 mov demuxer 默认会按 MP4 的 elst(edit list,编辑列表)修改样本索引。这个文件里,elst 恰好导致一个数据样本(400 条记录)被静默丢弃。
这个问题恶劣的地方在于:它不报错、不告警,只是少给你一个包。
解决方案:绕过 demuxer,直接解析 MP4 的样本表重建原始字节。
python
def read_track_raw(path):
"""直接从 stsz/stsc/stco 样本表重建数据轨的原始字节。
刻意不经过 FFmpeg/PyAV 的 mov demuxer。该 demuxer 默认会按 elst
(edit list) 修改样本索引,导致某些数据样本被静默丢弃。
"""
with open(path, "rb") as f:
buf = f.read()
moov = find_box(buf, 0, len(buf), "moov")
if moov is None:
return None
for off, typ, size, hdr in iter_boxes(buf, moov.body_start, moov.end):
if typ != "trak":
continue
mdia = find_box(buf, off + hdr, off + size, "mdia")
hdlr = find_box(buf, mdia.body_start, mdia.end, "hdlr")
# handler_type 位于 fullbox 之后 8 字节
handler = buf[hdlr.body_start + 8 : hdlr.body_start + 12].decode("latin1")
if handler != "meta":
continue
minf = find_box(buf, mdia.body_start, mdia.end, "minf")
stbl = find_box(buf, minf.body_start, minf.end, "stbl")
stsz = find_box(buf, stbl.body_start, stbl.end, "stsz") # 每个样本大小
stsc = find_box(buf, stbl.body_start, stbl.end, "stsc") # chunk 到 sample 的映射
stco = find_box(buf, stbl.body_start, stbl.end, "stco") # chunk 文件偏移
co64 = find_box(buf, stbl.body_start, stbl.end, "co64") # 64 位偏移
...
MP4 的样本表结构其实很清晰:
arduino
moov
└── trak
└── mdia
├── hdlr → handler_type: 'meta' 表示这是元数据轨
└── minf
└── stbl
├── stsz → 每个样本的字节大小
├── stsc → chunk 与 sample 的映射关系
├── stco → chunk 的 32 位文件偏移
└── co64 → chunk 的 64 位文件偏移(大文件用)
按 stsc 和 stco 把每个 chunk 的字节按 stsz 切分再拼起来,就是当初写进去的原始字节流,完全不经过任何 demuxer 的加工。这是绕过 elst 问题的正解。
如果不想自己解析,也有一条退路------用 FFmpeg 但显式忽略 edit list:
python
container = av.open(path, options={"ignore_editlist": "1"})
如果你的项目也在从 MP4 里读自定义数据轨,建议立刻检查这个参数。
结论是:用 FFmpeg 读自己写的私有数据轨时不要信任 demuxer。涉及"数据一条不能少"的场景,直接解析容器样本表最可靠。
坑二:怎么证明一条都没丢
绕过 demuxer 解决了读不出来的问题,但怎么证明它真的读全了?
用交叉校验。moov 的 stsz 记录了这条轨每个 MP4 样本(也就是每个 AVPacket)的字节大小,把它们求和再除以 20,就是应有的记录条数。这个数是从容器结构里直接读出来的,不经过 demuxer,可以当作标准答案。
python
expected = expected_record_count(path) # sum(stsz 里各样本大小) // 20
records = read_records(path)
if len(records) != expected:
msg = f"样本数校验失败: 由 stsz 推算={expected}, 实际={len(records)}, 缺失 {expected - len(records)} 条"
print(f"[ERROR] {msg}")
if strict:
raise SystemExit(f"[ERROR] {msg}")
else:
print(f"[verify] 样本数校验通过: {len(records)}")
给脚本加个 --strict 开关,校验失败直接 exit(1),就能接进产测流程。
"看起来正常"和"确实完整"之间差一个自动化的完整性校验。数据链路的 bug 极少表现为崩溃,绝大多数是少了一点点。
坑三:时间戳异常间隔检测
样本数对了,时间戳本身也可能有问题,比如驱动那边递推逻辑有 bug,或者系统卡顿丢中断。
400Hz 下正常间隔约 2500us,把超过 100ms 的间隔定义为异常:
python
DEFAULT_MIN_GAP_US = 100000 # 正常间隔约 2500us,超过 100ms 视为异常
def check_timestamp_gaps(timestamps, min_gap_us):
"""返回异常间隔列表:(样本索引, 间隔us, 前一时间戳, 后一时间戳)"""
gaps = []
for i in range(len(timestamps) - 1):
gap = timestamps[i + 1] - timestamps[i]
if gap > min_gap_us:
gaps.append((i, gap, timestamps[i], timestamps[i + 1]))
return gaps
异常写进单独的报告文件,控制台按"正常间隔的倍数"打印,看起来比较直观:
scss
[WARN] 发现 2 处时间戳异常间隔 (> 100000us)
idx=1200 gap=500000us (199.3x 正常间隔) prev=... next=...
同时把正常间隔的分布也打出来:
ini
[info] 正常间隔: min=2495us median=2509us max=2560us (n=47998)
这个中位数是判断驱动递推逻辑是否健康的指标。
把"正常"的统计特征也打出来。 只知道哪里异常不够,知道正常值分布才能快速定位偏移类问题。
坑四:两条 data 轨怎么区分
MP4 里有两条 AVMEDIA_TYPE_DATA 轨,ffprobe 看起来一模一样,提取端必须能区分。
最初的方案是内容嗅探:看 packet 大小是不是样本大小的整数倍,或者内容能不能解析成 JSON。能用,但脆弱------万一将来元数据轨加了个大小恰好是整数倍的内容,就误判了。
后来改成用 pts 显式标记类型(就是第五节那张表),内容嗅探只作为读旧文件的兼容路径:
python
# 优先通过 pts 区分类型(新格式)
if pts == 0 and len(data) == 8:
first_frame_pts = struct.unpack("<q", data)[0]
elif pts == 1 and data.startswith(b"{"):
sensor_config_text = ...
elif pts == 2 and data.startswith(b"{"):
geometry_config_text = ...
else:
# 旧格式兼容:退回到内容特征判断
...
自定义格式要显式声明类型,不要依赖内容嗅探。嗅探看起来聪明,但失败模式不可预测,今天能工作不代表下个版本还能工作。
坑五:驱动侧时间戳的阶梯跳变
最初驱动是"每帧都用中断时刻的 tick",结果缓冲区里堆积的多帧拿到了几乎相同的时间戳。表现是导出的 CSV 里时间戳呈阶梯状:几十个样本同一个时间戳,然后跳一下。
修法就是第二节那段递推逻辑,用"上次时间戳加平均间隔"逐帧推进,再对异常间隔做钳制。
这个坑隐蔽的地方在于:它不丢数据,样本数完全正确,只有时间戳是错的。如果没有坑三那个间隔检测,这个问题可能会一直潜伏到后期算法阶段才被发现。
九、怎么验证
一个完整的提取脚本应该输出这些东西:
ini
文件流信息:
流[0]: type=video, codec=hevc, time_base=1/1000000, nb_frames=3600
流[1]: type=audio, codec=aac, time_base=1/48000, nb_frames=5625
流[2]: type=data, codec=bin_data, time_base=1/1000000, nb_frames=120
流[3]: type=data, codec=bin_data, time_base=1/1000000, nb_frames=3
Data 流索引: [2, 3]
数据流索引 : 2
Meta 流索引: 3
[meta] first_frame_pts = 8472910345621
[meta] 传感器标定参数 (152 bytes)
[meta] 几何标定参数 (398 bytes)
[write] samples.csv (48000 samples)
[verify] 样本数校验通过: 48000
[OK] 时间戳间隔无异常(阈值 100000us)
[info] 正常间隔: min=2495us median=2509us max=2560us (n=47999)
上面这个例子里,120 个包 × 每包 400 条 = 48000 条;3600 帧视频在 30fps 下是 120 秒,对应 120 个数据包;音频 5625 个 AAC 帧按每帧 1024 个采样算也是 120 秒。三条轨的时间跨度对得上。
方案最终的数据画像:
| 项目 | 数值 |
|---|---|
| 采样率 | 400Hz |
| 单样本大小 | 20 字节 |
| 单包样本数 | 400(1 秒) |
| 单包大小 | 8000 字节 |
| 数据速率 | 8 KB/s |
| 时间基准 | SoC 全局硬件 tick,与视频 PTS 同源 |
8KB/s 的开销换来单文件自包含、可离线回溯、时间轴严格对齐,这个交换是划算的。
十、小结
回头看,这个需求的技术难点其实不在"怎么把数据写进 MP4"。FFmpeg 的 AVMEDIA_TYPE_DATA 文档写得很清楚,半天就能跑通一个 demo。
真正花时间的地方在别处。
一是时间基准。 让两路数据从一开始就在同一个时钟下,靠的是 SoC 上的全局硬件 tick。这是硬件给的红利,用不上就只能退而求其次。
二是零点的定义。 把绝对时间和相对时间的关系显式地存进文件,而不是靠文件名和目录约定。
三是完整性。 写的时候不丢(flush 顺序、预缓存),读的时候不少(绕过 demuxer、样本数校验)。
四是可观测性。 让异常能被自动发现,而不是等到后期处理阶段才暴露。这一点我们是被坑了之后才补上的。
这四件事的共同点,是把隐式的约定变成显式的、可验证的契约。
如果只留一句话:跨源数据的时间同步,优先找硬件上共用的那个时基,剩下的工作都是保证这个前提不被破坏。