把高频传感器(IMU)数据封装进 MP4:FFmpeg 私有数据轨实践

把高频传感器(IMU)数据封装进 MP4:FFmpeg 私有数据轨实践

这是个很常见的需求:设备上有一路高频传感器在采样,同时有一路视频在录,后期处理时需要让两边严格对上时间轴,而且这个对应关系要跟着文件走,不能靠文件名约定。

最朴素的做法是存成 sidecar 文件:

bash 复制代码
recording.mp4
recording_sensor.csv     # 传感器采样
recording_video_pts.csv  # 每一帧的 PTS

能用,但作为交付格式有三个问题。用户拷贝时很可能只拖 mp4,CSV 留在原地,等到了处理环节才发现数据没了,而设备上那份可能已经被循环覆盖。文件名约定和时间戳零点约定全是隐式契约,换个人换个工具链就断。CSV 里的时间戳是绝对刻度,视频 PTS 是相对首帧的,中间差多少得靠第二个 CSV 反查。

所以更靠谱的做法是把数据直接封装进 MP4,让它成为一个自描述的文件。

下面讲具体怎么做,以及中间踩的坑。

说明:本文用一路 400Hz、每样本 6 路 int16 通道的采样流作为例子,具体是什么传感器不影响讨论。代码片段是便于说明的示意,不是可直接编译的完整实现。


一、整体数据通路

flowchart TD A[传感器] -->|中断| B[打时间戳] B --> C[内核缓冲] C -->|批量 read| D[采集线程] D -->|入队| E[交接队列] E -->|条件变量唤醒| F[封装写入线程] F -->|封装器未就绪| G[预缓存队列] G -.->|就绪后回放| H[攒包] F --> H[攒包] H -->|攒满一批| I[批量写入] K[编码线程] -->|帧 PTS| L[写视频帧] L -->|首帧时记录| M[first_frame_pts] Q[(磁盘标定文件)] -->|读取| P[写元数据] M --> P I --> T1[数据轨] L --> T2[视频轨] P --> T3[元数据轨] T1 --> MP4[(MP4)] T2 --> MP4 T3 --> MP4

图里 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_typecodec_idtime_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_typemeta。任何基于 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 位文件偏移(大文件用)

stscstco 把每个 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、样本数校验)。

四是可观测性。 让异常能被自动发现,而不是等到后期处理阶段才暴露。这一点我们是被坑了之后才补上的。

这四件事的共同点,是把隐式的约定变成显式的、可验证的契约。

如果只留一句话:跨源数据的时间同步,优先找硬件上共用的那个时基,剩下的工作都是保证这个前提不被破坏。

相关推荐
晴天的雨.9921 小时前
[C++算法]盛最多水的容器(双指针算法)
开发语言·c++·算法
布莱克6051 小时前
理解内存泄漏:成因、发现方法与解决策略(C++ 举例)
c语言·c++·内存泄漏
此生决int2 小时前
深入理解C++系列(19)——C++11(上)
开发语言·c++
郝学胜-神的一滴2 小时前
C++20模板元编程 05:吃透变参模板,解锁编译期万能参数能力
服务器·开发语言·c++·windows·vscode
C++ 老炮儿的技术栈12 小时前
我们在设计tcp协议时,要传一个字符串过去,报文:头十长度十内容,是否要把‘\0‘也填入,长度是否包含‘\0‘
开发语言·数据结构·c++·mfc·c
I_belong_to_jesus17 小时前
std::unique_ptr成员函数用法
c++
Tairitsu_H18 小时前
[C++] 拷贝构造还是赋值重载?类默认成员函数细节详解
开发语言·c++·类和对象
汉克老师19 小时前
GESP2026年9月认证C++四级( 第一部分选择题(1~7题)精讲
c++·gesp·小学生·学c++编程
geats人山人海19 小时前
现代c++第二章 字符串
c++