rerun-mp4 について
MP4ファイルを1つRerunに取り込むためのリーダースキルです。圧縮動画サンプルをGOP単位で扱う遅延チャンクストリームを生成し、VideoStreamで表現できないコーデックやクリップにはアセットモードを使えます。
主な機能
- 01単一MP4ファイルの遅延読み込み
- 02GOP単位の圧縮動画サンプル出力
- 03フォールバック用アセットモード
- 04出力コーデックとGOPサイズの設定
- 05動画タイムラインの名前変更と再タグ付け
活用シーン
- 01カメラのMP4映像をRerunに取り込む
- 02複数カメラの動画を1つの記録に統合
- 03センサー間の時刻合わせに動画時刻を再タグ付け
- 04VideoStreamに適さないコーデックやクリップの処理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rerun-io/ rerun --skill rerun-mp4インストール後、Claude Code で/rerun-mp4を入力して読み込まれたか確認します。
詳細
Rerun の mp4 取り込み
Mp4Reader は、1 つの .mp4 ファイルを 1 つのエンティティ上の遅延チャンクストリームに変換します。
圧縮されたビデオサンプルを扱い、ピクセルへのデコードは行わず、必要な場合を除いて再エンコードもしません。設定はすべてコンストラクターで行い、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 より後のすべてのパラメーターはキーワード専用です。1 つのリーダーで扱えるファイルは 1 つです。複数のカメラを扱う場合は、ファイルごとにリーダーを作成し、LazyChunkStream.merge(...) でマージしてください(下記参照)。
2 つのモード
mode | 出力されるもの | 使用する場面 |
|---|---|---|
"stream"(デフォルト) | コーデックチャンク、GOP ごとのサンプルチャンク、キーフレームマーカー | ほとんどの場合 — すべてのフレームに時間が割り当てられ、クエリ可能です |
"asset" | ファイル全体を 1 つの BLOB として、さらにフレームインデックス | コーデックを VideoStream にできない場合、またはソースが GOP の途中から始まる場合 |
通常はストリームモードを使用してください。次のセクションで、出力される内容を詳しく説明します。アセットモードではファイル全体を 1 つの BLOB として記録にコピーするため、後続処理でアセット全体を使わずに単一のフレームを確認することはできません。VideoStream で扱えないコーデック(mp4v、画像シーケンスの mp4)に対するフォールバックであり、rerun video.mp4 と組み込みのファイルインポーターもこのモードを使用します。
ストリームモードの出力内容
tests/assets/video/Big_Buck_Bunny_1080_1s_h264_nobframes.mp4(30 フレーム、1 GOP)を entity_path="/camera/front" で読み込む場合、出力全体は 3 つのチャンクになります。
[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']
- 1 行を含む静的チャンクが 1 つで、
VideoStream:codecを保持します。 タイムラインはありません。ストリームを走査するコードでは、これに対応する必要があります(if chunk.is_static: continue)。 - GOP ごとに時間チャンクが 1 つです。キーフレームと、それに依存するすべてのサンプル(次のキーフレームは含みません)が入ります。ここに入るのはサンプルのみで、キーフレームフラグは列に含まれません。
- 末尾に
is_keyframeマーカーチャンクが 1 つあり、各キーフレームの時刻にスパースなTrueの行を保持し、それ以外は何も保持しません。マーカーをサンプルチャンクから分離することで、キーフレームのみのクエリでサンプルのペイロードをスキップでき、collect(optimize=…)が正規形式として受け入れられるようになります。行ごとのリストなので、読み戻した行はTrueではなく[True]になります。 - タイムラインは
video、duration[ns]の値は動画の開始からの mp4 PTS です。 - すべてのチャンクが同じエンティティ上にあります。
entity_path=None(デフォルト)の場合、ファイルの絶対パスから導出されるため、/dataから実行したfoo/video.mp4は/data/foo/video.mp4になります。実際のパイプラインではentity_pathを明示的に指定してください。
短いクリップは 1 つの GOP であることが多いため、「合計 3 チャンク」は一般的な形であり、何かが欠落したという意味ではありません。chunk_by_gop=False にすると、代わりにサンプルごとに 1 つの Rerun チャンクが出力されます(マーカーチャンクには影響しません)。これはデバッグ用の形式です。フレームごとにチャンクを分けるのはストレージレイアウトとして適切ではなく、後続の最適化処理も 6× 高コストになります(以下で測定)。個々のサンプルを調べる場合を除き、デフォルトのままにしてください。
位置や is_static ではなく、列によって 3 種類を区別してください。コーデックチャンクは静的で、サンプルチャンクには VideoStream:sample が含まれ、マーカーチャンクには VideoStream:is_keyframe のみが含まれます。
ストリームモードでサポートされるコーデックは、VideoCodec の次の 5 つの値のみです。H264、H265、AV1、VP8、VP9。
FFmpeg によるトランスコード
VideoStream はまだ DTS != PTS をモデル化できないため、コンテナレベルで B フレームの並べ替えが行われる H.264/H.265 ソースは、直接出力できません。リーダーがこれを独自に処理します。-bf 0 を指定して FFmpeg で再エンコードし、結果をフラグメント化された mp4 としてストリーミングで返し、各フラグメントを 1 つの GOP チャンクに変換します。そのため、一度にメモリ上に保持される GOP は 1 つだけです。これは自動的に行われ、API からは見えません。出力形式はクリーンなソースと同じです。ffmpeg 実行ファイルが必要です。アセットモードは B フレームの影響を受けません。
Mp4TranscodeOptions(ストリームモードのみ)では、さらにトランスコードを 要求 できます。
| フィールド | 効果 |
|---|---|
output_codec | 別の VideoCodec に再エンコードします。出力される VideoStream:codec に反映されます |
gop_size | N フレームごとにキーフレームを強制します。ビューアーでのシークコストを調整する設定です |
try_gpu | ハードウェアエンコードをベストエフォートで使用します。NVENC / VideoToolbox のみです |
ffmpeg_override | PATH 上のものではなく、この ffmpeg を使用します |
ソースですでに使われている output_codec を要求しても、何も行われません。FFmpeg を使わない直接処理のままです。chunk_by_gop=True の場合、gop_size=N を指定すると、最後以外のすべての GOP チャンクがちょうど N サンプルを保持します。たとえば、30 フレームのクリップで gop_size=10 を指定すると、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 には、同じ問題があります。マーカーチャンクを進めてしまい、次に来る時刻をそのチャンクに割り当てるため、キーフレームマーカーが対応するサンプルから知らないうちにずれてしまいます。
複数のカメラを 1 つの録画にまとめる
ファイルごとにリーダーを 1 つ用意し、エンティティパスをそれぞれ別にしてからマージします。
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 はエンコード済みサンプルからマーカーを再導出するための回避策になります。
reader のチャンクと optimize のチャンクの関係
reader と collect(optimize=…) はGOP の開始位置については一致しますが、1 つのチャンクを共有する GOP の数については一致しません。これは意図的な設計です。
- reader はGOP に合わせた最も細かい分割を出力します。GOP ごとにチャンクが 1 つずつです。データがどのプロファイル向けかは reader には分からないため、これ以上細かくすることはできません。
- その後 optimize がサイズポリシーを適用し、プロファイルの
max_bytes/max_rowsに達するまで連続する GOP を結合します。GOP を複数のチャンクに分割することはないため、維持される境界はすべて reader がすでに出力したものです。
したがって、optimize の分割は reader の分割を単に粗くしたものです。サンプルの順序は同じで、最適化後の各チャンクは reader の GOP 全体が連続したものであり、キーフレームのマーカーも変更されません。各 GOP がすでにプロファイルの予算に近い場合は、両者の結果は同じになります(gop_size=10 の 30 フレームのクリップなら、どちらも 3 チャンクです)。GOP が小さい場合は大きく異なり、12 GOP のクリップは OBJECT_STORE では 1 チャンク、LIVE では 4 チャンクになります。
実用上の結論は、optimize の「助け」になるよう reader の出力を事前に結合したり、再チャンクしたりしないことです。GOP ごとのストリームをそのまま渡し、プロファイルに判断させてください。また、optimize を完全に省略して send_chunks に直接書き込む場合(DROID ローダーのように)は、最も細かい分割のまま保存されます。これは正しい動作ですが、オブジェクトストレージが望むよりチャンク数が多くなります。
GOP 単位に分割された入力なら optimize の処理は軽量です
reader の出力に対して 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)は、同じ 1800 サンプルに対して Python ループで同じ検出処理を呼び出す時間(6.0 ms)より短く、その大半は FFI のオーバーヘッドです。 - チャンクの再構築では何もコピーされません。
chunk_from_gopはtaken(0..n)を呼び出し、re_arrow_util::take_arrayは、インデックスが配列全体にわたってゼロから連続している場合、配列をコピーせずに返します。GOP ごとにチャンクが 1 つであれば、常にこの条件を満たすため、数 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 の有無はストリームの実行時に確認されるため、Mp4Reader(…)の呼び出しではなく、消費処理(to_chunks()、send_chunks、反復処理)をラップしてください。 - 5 つの
VideoCodec値に含まれないコーデックはVideoStreamにできません。mp4v(MPEG-4 Part 2)は、ロボットのデータセットで実際に遭遇するコーデックです。これを使うとRuntimeError: MP4 error: MP4 demux: Video track uses unsupported codec "mp4v"が発生します。アセットモードではファイルを受け入れますが、一部の処理に限られます。フレームのタイムスタンプも読み取れないため、blob チャンクは出力されますが、VideoFrameReferenceのインデックスチャンクは警告とともにスキップされます。つまり、バイト列は得られてもタイムラインは得られません。そのカメラをスキップして、記録プロパティにその事実を記録する(DROID ローダーの方式)のは、通常、タイムラインのない blob より適切です。どちらを選んでも、カメラ 1 台のためにエピソード全体の処理を失敗させるよりはましです。 - 時間列を書き換える
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"を指定するだけでは、何もシフトされません。リタグ付けをしないと、動画はエポックに配置されます。- リーダーが出力するのは圧縮サンプルであり、ピクセルではありません。 サムネイル、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、blob とインデックスの処理はasset.rs)。crates/data_flow/re_mp4_reader/tests/stream.rsではコーデックの組み合わせと GOP の間隔を確認しています。 rerun-chunk-processing(ストリーム/レンズの仕組み)、rerun-data-model(記録内で動画、キャリブレーション、サムネイルを配置する場所)。