mcpField
R
rerun-mp4

rerun-mp4

github.com/rerun-io数据与数据库11.7k53 次安装今天更新
代码仓库

关于 rerun-mp4

用于将单个 MP4 文件导入 Rerun 的读取器技能,面向处理视频数据的开发者。它提供惰性数据块流,可按压缩视频样本和 GOP 组织数据;无法表示的编码或视频片段可使用 asset 模式。

主要功能

  1. 01惰性读取单个 MP4 文件
  2. 02按 GOP 输出压缩视频样本
  3. 03提供 asset 模式作为备用
  4. 04支持指定视频编码与 GOP 大小
  5. 05支持重命名及重新标记时间线

适用场景

  1. 01将相机 MP4 视频导入 Rerun
  2. 02将多个摄像头视频流合并到同一记录
  3. 03为跨传感器对齐重新标记视频时间
  4. 04处理不适用于 VideoStream 的编码或片段

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rerun-io/rerun --skill rerun-mp4

安装后在 Claude Code 中输入/rerun-mp4确认已加载。

详细介绍

Rerun mp4 导入

Mp4Reader 会将一个 .mp4 文件转换为一个惰性分块流,且只写入一个实体: 压缩视频采样,不解码为像素,除非不得已,否则不重新编码。所有配置都在构造函数中完成;stream() 不接受参数。.stream() 之后的流处理机制(筛选、映射、合并、收集、写入)见 rerun-chunk-processing。

API

from rerun.experimental import Mp4Reader, Mp4TranscodeOptions

reader = Mp4Reader(video_path, entity_path="/camera/front")  # mode="stream" by default
stream = reader.stream()  # lazy: nothing is decoded yet

path 之后的所有参数都只能按关键字传入。一个读取器只处理一个文件——处理多台摄像机时,每个文件创建一个读取器,再用 LazyChunkStream.merge(...) 合并它们(见下文)。

两种模式

mode输出适用情况
"stream"(默认)一个 codec 分块、每个 GOP 一个采样分块、一个关键帧标记几乎所有情况——每帧都有时间索引且可查询
"asset"整个文件作为一个 blob,以及一个帧索引codec 无法作为 VideoStream,或源文件从 GOP 中途开始

优先使用 stream 模式;下一节会准确说明它的输出内容。asset 模式会将整个文件作为一个 blob 复制到录制中,因此下游无法在不读取整个资源的情况下访问单帧。它是 VideoStream 无法承载的 codec(mp4v、图像序列 mp4)的后备方案,也是 rerun video.mp4 和内置文件导入器采用的模式。

stream 模式的输出

对于 tests/assets/video/Big_Buck_Bunny_1080_1s_h264_nobframes.mp4(30 帧,一个 GOP),并设置 entity_path="/camera/front",完整输出由三个分块组成:

[0] entity=/camera/front static=True rows=1 timelines=[] cols=['VideoStream:codec']
[1] entity=/camera/front static=False rows=30 timelines=['video'] cols=['VideoStream:sample']
[2] entity=/camera/front static=False rows=1 timelines=['video'] cols=['VideoStream:is_keyframe']
  • 一个静态分块,一行,包含 VideoStream:codec。 它没有时间线——遍历流的代码必须处理这种情况(if chunk.is_static: continue)。
  • 每个 GOP 一个时间分块:包含一个关键帧,以及所有依赖它的采样,直到(但不包括)下一个关键帧。这里只有采样——关键帧标记不是该分块中的一列。
  • 一个尾随的 is_keyframe 标记分块,在每个关键帧对应的时间点包含一行稀疏的 True,没有其他内容。将它与采样分块分开,才能让仅查询关键帧的操作跳过采样负载,也才能被 collect(optimize=…) 视为规范形式。它是每行一个列表,因此读取一行会得到 [True],而不是 True。
  • 时间线 video、duration[ns],其值为视频起始位置开始计算的 mp4 PTS。
  • 所有分块都在同一个实体上。 entity_path=None(默认值)会根据文件的绝对路径推导实体路径,因此从 /data 运行 foo/video.mp4 时,实体路径会是 /data/foo/video.mp4。在任何实际工作流中,都应显式传入 entity_path。

短片通常只有一个 GOP,因此“总共三个分块”是常见结构,并不表示有内容被丢弃。chunk_by_gop=False 会改为每个采样输出一个 Rerun 分块(标记分块不受影响)。这种结构适合调试——每帧一个分块不是理想的存储布局,而且还会让后续优化过程的开销增加 6 倍(下文有测量数据),因此除非你正在检查单个采样,否则请保留默认值。

应根据列而非位置或 is_static 来区分这三种分块:codec 分块是静态分块,采样分块包含 VideoStream:sample,标记分块只包含 VideoStream:is_keyframe。

stream 模式支持的 codec 恰好对应五种 VideoCodec 值:H264、H265、AV1、VP8、VP9。

通过 FFmpeg 转码

VideoStream 目前无法表示 DTS != PTS,因此带有容器级 B 帧重排的 H.264/H.265 源无法直接输出。读取器会自行处理:通过 FFmpeg 使用 -bf 0 重新编码,将结果以分片 mp4 流式传回,并将每个分片转换为一个 GOP 块,因此同一时间只会驻留一个 GOP。这一过程是自动的,在 API 中不可见——输出的结构与干净源相同。但这需要一个 ffmpeg 可执行文件。Asset 模式不受 B 帧影响。

Mp4TranscodeOptions(仅流模式)还可以额外请求转码:

字段效果
output_codec重新编码为另一种 VideoCodec;输出的 VideoStream:codec 会随之变化
gop_size每隔 N 帧强制插入一个关键帧——用于控制查看器中跳转成本的参数
try_gpu尽力使用硬件编码,仅支持 NVENC / VideoToolbox
ffmpeg_override使用此 ffmpeg,而不是 PATH 中的版本

如果请求的 output_codec 与源使用的编码相同,则不会执行任何操作:仍会使用直接路径,不调用 FFmpeg。使用 chunk_by_gop=True 时,gop_size=N 会使除最后一个以外的每个 GOP 块都恰好包含 N 个样本——一个 gop_size=10 的 30 帧片段会得到 10、10、10。实际上,try_gpu 支持 H264/H265,以及较新 NVIDIA GPU 上的 AV1;VP8/VP9 始终回退到软件编码,而且只有在已经进行转码时才会生效。

Mp4Reader(
    video_path,
    entity_path="/camera/front",
    # ~1s GOPs on a 60fps source, so seeking in the viewer stays snappy.
    transcode=Mp4TranscodeOptions(gop_size=64),
).stream()

时间线:mp4 PTS 不是录制的时钟

输出的时间始终是 PTS——从该视频起始处算起的经过纳秒数。在多传感器录制中,这几乎从来不是你想要用于对齐的时间线。

  • timeline_name="real_time" 会重命名时间线。
  • timeline_type="timestamp" 只会将相同的 PTS 值重新标注类型为自 Unix 纪元起算的纳秒数。读取器不会对时间进行偏移,因此单独使用时,视频会显示在 1970 年附近。只有与重新标记步骤配合时,这才有意义。
  • 使用 stream.map(...) 重新标记。 样本按呈现顺序到达(B 帧已移除),因此运行中的游标会将样本 i 映射到 capture_times_ns[i]——但游标必须跳过关键帧标记块,因为该块不包含样本。该块应通过样本已分配的 PTS 进行映射:
SAMPLE_COL = "VideoStream:sample"


def _reindex_to_capture_times(stream, capture_times_ns, timeline_name):
    cursor = 0
    pts_to_time = {}

    def _retag(chunk):
        nonlocal cursor
        if chunk.is_static:  # the codec chunk carries no timeline
            return chunk
        batch = chunk.to_record_batch()
        col_index = batch.schema.get_field_index(timeline_name)
        old_field = batch.schema.field(col_index)
        old_pts = np.asarray(batch.column(col_index).cast(pa.int64()))

        if SAMPLE_COL in batch.schema.names:
            # Clamp in case the decoder yields a slightly different frame count.
            indices = np.clip(np.arange(cursor, cursor + chunk.num_rows), 0, len(capture_times_ns) - 1)
            cursor += chunk.num_rows
            new_times_ns = capture_times_ns[indices]
            pts_to_time.update(zip(old_pts.tolist(), new_times_ns.tolist()))
        else:
            # The sparse keyframe marker, emitted after every sample chunk.
            new_times_ns = np.array([pts_to_time[pts] for pts in old_pts.tolist()], dtype=np.int64)

        times = pa.array(new_times_ns.astype("datetime64[ns]"))
        # `metadata=` is load-bearing — see gotcha 3.
        new_field = pa.field(old_field.name, times.type, nullable=old_field.nullable, metadata=old_field.metadata)
        return Chunk.from_record_batch(batch.set_column(col_index, new_field, times))[0]

    return stream.map(_retag)

游标使此操作依赖顺序,因此应在合并之前,将重新标记步骤保留在单个读取器的流上。这是 DROID 加载器采用的模式——参见参考资料。

任何假定“每个非静态块都是按顺序排列的样本”的 map/flat_map 都会有同样的问题:它会越过标记块,并使用接下来的时间为其打上时间戳,从而悄悄地使关键帧标记与其样本错位。

将多个摄像头合并到一条录制中

每个文件使用一个读取器,实体路径各不相同,然后合并:

streams = [
    Mp4Reader(path, entity_path=f"/camera/{name}", timeline_name="real_time", timeline_type="timestamp").stream()
    for name, path in cameras.items()
]
(
    LazyChunkStream
    .merge(*streams)
    .collect(optimize=OptimizationProfile.OBJECT_STORE)
    .write_rrd(out_path, application_id=app_id, recording_id=segment_id)
)

OBJECT_STORE 会重新批处理视频 GOP,并原样保留读取器的关键帧标记;无需设置 fix_keyframe=True。如果确实看到 skipping GoP rebatching … is_keyframe data is incorrect,说明上游某处改写了标记(参见注意事项 4)——可将 fix_keyframe=True 作为补救措施,从编码样本重新推导关键帧标记。

阅读器的分块与 optimize 的关系

阅读器和 collect(optimize=…) 对 GOP 的起点 认知一致,但对每个分块包含多少个 GOP 的处理不同,这是有意为之:

  • 阅读器会生成最细粒度、与 GOP 对齐的分区:每个 GOP 恰好一个分块。它无法做得更好,因为它不知道数据将用于哪个配置。
  • 随后,Optimize 会应用大小策略,按配置的 max_bytes / max_rows 合并连续的 GOP。它绝不会把一个 GOP 拆分到多个分块中,因此保留的每个边界都是阅读器已经生成的边界。

因此,Optimize 的分区只是对阅读器分区的纯粹粗化:样本相同、顺序相同,每个优化后的分块都是若干完整阅读器 GOP 的连续序列,关键帧标记保持不变。如果每个 GOP 的大小已经接近配置的预算,两者的结果就完全相同(一个 gop_size=10 的 30 帧片段,无论哪种方式都会得到 3 个分块)。而 GOP 较小时,两者就会明显不同——一个含 12 个 GOP 的片段,在 OBJECT_STORE 下会变成 1 个分块,在 LIVE 下则会变成 4 个。

实际影响是:不要预先合并或重新分块阅读器的输出来“帮助”Optimize。将每个 GOP 独立成块的流交给它,让配置决定即可。如果完全跳过 Optimize(像 DROID 加载器那样直接通过 send_chunks 写入),你存储的就是最细粒度的分区——这没有问题,但分块数量会超过对象存储所需的数量。

对按 GOP 分块的输入运行 Optimize,开销很低

对阅读器的输出运行 Optimize 并非值得避免的冗余操作。对于一个 89 MB / 1800 帧 / 60 个 GOP 的 H.264 文件:

collect()collect(optimize=OBJECT_STORE)差值
chunk_by_gop=True(默认)11.1 ms16.3 ms+5.2 ms(1.5×)
chunk_by_gop=False14.2 ms48.6 ms+34.4 ms(3.4×)

对于与 GOP 对齐的输入,该流程的两个环节开销都不大:

  • 检测 GOP 起点只读取头部。 build_sample_index 会对每个样本运行 detect_gop_start,但只读取每个样本头部的几个字节——不会进行解码。上面整个 Optimize 流程的差值(5.2 ms),还不到用 Python 循环对相同的 1800 个样本调用同一检测器所需的时间(6.0 ms);后者的大部分开销来自 FFI。
  • 重建分块不会复制数据。 chunk_from_gop 会调用 taken(0..n);当索引是从零开始、连续覆盖整个数组时,re_arrow_util::take_array 会返回未复制的数组。每个 GOP 一个分块时,总是如此,因此数 MB 的样本缓冲区不会被重复复制——这就是为何 89 MB 的数据能在 5 ms 内“重建”完成。

chunk_by_gop=False 恰好会放弃这条快速路径:此时每个 GOP 会跨越 30 个源分块,因此 chunk_from_gop 必须对它们执行 concat_and_sort,样本字节也确实会被复制。同一文件上,LIVE 的开销也高于 OBJECT_STORE(+13.2 ms,而非 +5.2 ms),因为它的分块预算小得多,会尝试无法完成的压缩——GOP 永远不会被拆分。

在合并之前为每个摄像头重新标记——上面的重新标记会用游标遍历分块,因此必须按顺序传入同一视频的分块。

注意事项

  1. 错误会在首次拉取时出现,而不是在构造时出现。 构造函数只会检查文件是否存在并验证参数;stream() 会构建惰性管道,也会成功。编解码器支持、关键帧布局和 FFmpeg 是否可用,都会在流运行时检查,因此应对消费(to_chunks()、send_chunks、迭代)添加错误处理,而不是对 Mp4Reader(…) 调用添加。
  2. VideoCodec 的五种取值之外的编解码器,无法作为 VideoStream 使用。 mp4v(MPEG-4 Part 2)是你在机器人数据集中实际会遇到的类型;它会引发 RuntimeError: MP4 error: MP4 demux: Video track uses unsupported codec "mp4v"。资源模式会接受该文件,但只会部分处理:会发出二进制块,而 VideoFrameReference 索引块会跳过并发出警告,因为帧时间戳也无法读取——因此你只能得到字节数据,没有时间线。跳过该摄像头,并将这一事实记录为录制属性(DROID 加载器就是这样做的),通常比得到一个没有时间线的二进制块更好;这两种做法都比因一个摄像头而导致整个片段失败要好。
  3. 重写时间列的 map/flat_map 必须保留字段元数据。 未设置 metadata=old_field.metadata 的 pa.field(...) 会丢弃 rerun:kind: 'index',重建后的块会悄悄变成静态——没有错误、没有时间线,样本也完全不会落在时间线上。
  4. 末尾标记块具有时间属性,但不包含样本,因此任何以 not chunk.is_static 为条件的逐块逻辑都会将它当作样本处理。应改用 VideoStream:sample 列作为条件。参见上方的重标记。
  5. 资源模式受 Arrow 的 i32 偏移量限制,大小上限约为 2 GiB,并且会将文件字节复制到 RRD 中。
  6. mode="asset" 不接受 chunk_by_gop=False 和 transcode=,会引发 ValueError——这些选项仅适用于流模式。
  7. 单独使用 timeline_type="timestamp" 不会产生任何偏移。若未进行重标记,视频会停留在 epoch。
  8. 读取器输出的是压缩样本,而不是像素。 缩略图、CLIP 嵌入或其他需要 RGB 的内容,必须另行解码文件(使用 OpenCV、PyAV);VideoStream 块无法提供这些内容。
  9. 每次调用 stream() 都会从头重新解码文件,每次终端调用都会重新运行管道。需要多次遍历时,只调用一次 collect()。
  10. 在任何 map/flat_map 中都要显式处理静态编解码器块——它没有时间线,也没有样本,盲目索引时间列会导致失败。
  11. 首个关键帧之前的样本在流模式下会被拒绝(解码器无法从 GOP 中间开始);此类文件请使用资源模式。

参考资料

  • 标准示例:rerun_py/tests/integration/test_mp4_reader.py(包含两种模式、chunk_by_gop、实体路径、timeline_type、转码转换和错误案例)。
  • Rust 核心代码:crates/data_flow/re_mp4_reader/(GOP/转码路径见 stream.rs,二进制块与索引路径见 asset.rs),其中 crates/data_flow/re_mp4_reader/tests/stream.rs 覆盖了编解码器组合和 GOP 间隔。
  • rerun-chunk-processing(流和 lens 机制)、rerun-data-model(录制中的视频、校准数据和缩略图应存放的位置)。

评论

支持 Markdown