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