From 3c77844ef4b3d42bc859804f477e053b79864a11 Mon Sep 17 00:00:00 2001 From: Ayamullah Khan Date: Sat, 29 Aug 2026 05:55:01 +0000 Subject: [PATCH 1/2] fix(lerobot): disable B-frames so the converter output decodes in full (#250) The converter transcodes each episode window with libx264's default medium preset, which enables B-frames. hflow's write_access_units_to_mp4 remuxes the resulting raw Annex B stream to MP4 with -c copy, and that remux drops the reorder-buffer tail at decode: the muxed file carries 303 samples but only 301 decode, so camera_frame_stats undercounts decoded_frame_count on every healthy episode (both svla cameras measured 301/303 while the source slices decode 303/303 with zero errors). Encode with bframes=0: the identical remux then decodes 303/303. Verified end to end on svla_so101_pickplace @ f641879 ep0 - App.process now records decoded_frame_count 303/303 on both cameras (was 301/303). Regression test: converter output remuxed via write_access_units_to_mp4 must decode every source frame (fails on the old encode, passes now). ruff/format/ty clean; full converter test file 6/6. Signed-off-by: Ayamullah Khan --- examples/lerobot/prepare.py | 4 +- tests/test_lerobot_converter.py | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/examples/lerobot/prepare.py b/examples/lerobot/prepare.py index d49d9585d..d5078b11c 100644 --- a/examples/lerobot/prepare.py +++ b/examples/lerobot/prepare.py @@ -161,7 +161,9 @@ def _transcode_mp4_to_h264(mp4_path: Path, gop_seconds: float, fps: float) -> li "-sc_threshold", "0", "-x264-params", - "aud=1", + # bframes=0: B-frame streams lose their reorder-buffer tail through + # the raw Annex B -> MP4 remux, undercounting decoded_frame_count (#250). + "aud=1:bframes=0", "-f", "h264", "pipe:1", diff --git a/tests/test_lerobot_converter.py b/tests/test_lerobot_converter.py index 46777ece1..64ba937a8 100755 --- a/tests/test_lerobot_converter.py +++ b/tests/test_lerobot_converter.py @@ -7,6 +7,9 @@ import importlib.util import json +import os +import shutil +import subprocess from pathlib import Path import pytest @@ -19,6 +22,14 @@ _DERIVE = prep._derive_numeric_schema _ENCODE = prep._encode_cdr_float32_array +_FFMPEG = shutil.which("ffmpeg") +_FFPROBE = shutil.which("ffprobe") + +pytestmark = pytest.mark.skipif( + _FFMPEG is None or _FFPROBE is None, + reason="system ffmpeg/ffprobe required", +) + def _build_fake_corpus(tmp_path: Path) -> dict: """Synthetic v3 metadata: 4 episodes, 2 cameras, 6-dim state/action.""" @@ -245,3 +256,74 @@ def fake_dl(url: str, dest: Path, **kw: object) -> None: output_dir=tmp_path / "out", camera_key="observation.nope", ) + + +def test_converter_output_remuxes_without_tail_loss( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The converter's stream must decode in full through the hflow remux. + + B-frame H.264 loses its reorder-buffer tail when remuxed from raw Annex + B to MP4, so decoded_frame_count undercounts healthy episodes (#250). + The converter encodes with bframes=0; prove the full chain decodes + every source frame. + """ + + assert _FFMPEG is not None and _FFPROBE is not None + # prepare.py shells out to bare "ffmpeg"/"ffprobe"; make sure it + # resolves to the system binary this test found. + monkeypatch.setenv( + "PATH", + str(Path(_FFMPEG).resolve().parent) + os.pathsep + os.environ.get("PATH", ""), + ) + + source = tmp_path / "source.mp4" + subprocess.run( + [ + _FFMPEG, + "-hide_banner", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=160x120:rate=30:duration=3,format=yuv420p", + "-c:v", + "libx264", + "-preset", + "medium", + "-g", + "30", + "-keyint_min", + "30", + "-sc_threshold", + "0", + str(source), + ], + capture_output=True, + check=True, + ) + + from hflow.video import write_access_units_to_mp4 + + units = prep._transcode_mp4_to_h264(source, gop_seconds=1.0, fps=30.0) + muxed = write_access_units_to_mp4(units, fps=30.0, output=tmp_path / "remux.mp4") + + probe = subprocess.run( + [ + _FFPROBE, + "-v", + "error", + "-count_frames", + "-select_streams", + "v:0", + "-show_entries", + "stream=nb_read_frames", + "-of", + "default=noprint_wrappers=1", + str(muxed), + ], + capture_output=True, + text=True, + check=True, + ) + assert "nb_read_frames=90" in probe.stdout, probe.stdout From 09adc9d18b88db01cd96a75b821cc27d0de8dcd9 Mon Sep 17 00:00:00 2001 From: Kingston Date: Sat, 29 Aug 2026 00:36:17 -0700 Subject: [PATCH 2/2] test: scope the ffmpeg skip to the test that needs it pytestmark applies to the whole module, so on a machine without system ffmpeg the five metadata tests skipped too, and none of those touch ffmpeg. Without ffmpeg on PATH it is now 5 passed, 1 skipped instead of 6 skipped. --- tests/test_lerobot_converter.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_lerobot_converter.py b/tests/test_lerobot_converter.py index 64ba937a8..37bce05d4 100755 --- a/tests/test_lerobot_converter.py +++ b/tests/test_lerobot_converter.py @@ -25,9 +25,11 @@ _FFMPEG = shutil.which("ffmpeg") _FFPROBE = shutil.which("ffprobe") -pytestmark = pytest.mark.skipif( +# Only the remux test below shells out. Scoping this to the module would skip +# the five metadata tests too, and none of those touch ffmpeg at all. +_requires_system_ffmpeg = pytest.mark.skipif( _FFMPEG is None or _FFPROBE is None, - reason="system ffmpeg/ffprobe required", + reason="system ffmpeg/ffprobe required: prepare.py shells out to bare 'ffmpeg'", ) @@ -258,6 +260,7 @@ def fake_dl(url: str, dest: Path, **kw: object) -> None: ) +@_requires_system_ffmpeg def test_converter_output_remuxes_without_tail_loss( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: