Files
2026-07-13 13:05:14 +08:00

181 lines
6.4 KiB
Python

"""
Integration tests for the keyframe-aware video dataloader.
Exercises `RerunMapDataset` + `VideoFrameDecoder` end-to-end against a small
H.264 stream served via `rr.server.Server`, covering both the anchor path
(sibling `is_keyframe` column present) and the heuristic fallback (column
absent from the schema).
"""
from __future__ import annotations
import pathlib
from typing import TYPE_CHECKING
import pytest
import rerun as rr
from rerun.experimental.dataloader import (
DataSource,
Field,
NumericDecoder,
RerunMapDataset,
VideoFrameDecoder,
)
if TYPE_CHECKING:
from pathlib import Path
VIDEO_ASSET = (
pathlib.Path(__file__).parents[3] / "tests" / "assets" / "video" / "Big_Buck_Bunny_1080_1s_h264_nobframes.mp4"
)
def _build_h264_rrd(rrd_path: Path, *, log_is_keyframe: bool) -> list[int]:
"""
Build an RRD with one VideoStream sample per demuxed packet on a sequence timeline.
Returns the list of frame indices that are codec keyframes. When
`log_is_keyframe` is true, also writes a sparse `is_keyframe=True` row on
those indices.
"""
import av
from av.bitstream import BitStreamFilterContext
container = av.open(str(VIDEO_ASSET))
keyframe_indices: list[int] = []
samples: list[bytes] = []
try:
video_stream = container.streams.video[0]
bsf = BitStreamFilterContext("h264_mp4toannexb", video_stream)
def absorb(packet: av.Packet) -> None:
if packet.pts is None or packet.size == 0:
return
if packet.is_keyframe:
keyframe_indices.append(len(samples))
samples.append(bytes(packet))
for packet in container.demux(video_stream):
for out in bsf.filter(packet):
absorb(out)
finally:
container.close()
assert keyframe_indices, "test asset must contain at least one keyframe"
assert keyframe_indices[0] == 0, "test asset's first packet must be a keyframe"
with rr.RecordingStream("rerun_example_test_dataloader_video", recording_id="dataloader-video") as rec:
rec.save(rrd_path)
rec.log("/video", rr.VideoStream(codec=rr.VideoCodec.H264), static=True)
rec.send_columns(
"/video",
indexes=[rr.TimeColumn("frame", sequence=list(range(len(samples))))],
columns=rr.VideoStream.columns(sample=samples),
)
# Companion scalar so tests cover the mixed-decoder query path
# (`prior_keyframe_path` on a non-video decoder must return None, not raise).
rec.send_columns(
"/state",
indexes=[rr.TimeColumn("frame", sequence=list(range(len(samples))))],
columns=rr.Scalars.columns(scalars=[float(i) for i in range(len(samples))]),
)
if log_is_keyframe:
rec.send_columns(
"/video",
indexes=[rr.TimeColumn("frame", sequence=keyframe_indices)],
columns=rr.VideoStream.columns(is_keyframe=[True] * len(keyframe_indices)),
)
return keyframe_indices
@pytest.fixture
def rrd_with_keyframes(tmp_path: Path) -> tuple[Path, list[int]]:
rrd_dir = tmp_path / "with_keyframes"
rrd_dir.mkdir()
keyframes = _build_h264_rrd(rrd_dir / "recording.rrd", log_is_keyframe=True)
return rrd_dir, keyframes
@pytest.fixture
def rrd_without_keyframes(tmp_path: Path) -> tuple[Path, list[int]]:
rrd_dir = tmp_path / "without_keyframes"
rrd_dir.mkdir()
keyframes = _build_h264_rrd(rrd_dir / "recording.rrd", log_is_keyframe=False)
return rrd_dir, keyframes
@pytest.mark.filterwarnings("ignore:The default multiprocessing start method is 'fork':RuntimeWarning")
def test_anchor_path_decodes_mid_gop_target(rrd_with_keyframes: tuple[Path, list[int]]) -> None:
"""
Decode a mid-GOP target with `keyframe_interval=1` — heuristic alone can't satisfy it.
For any non-keyframe target, the heuristic window collapses to a single
sample and decode fails. With the `is_keyframe` anchor, the prefetcher
expands the window back to the prior keyframe and the decode succeeds.
"""
rrd_dir, keyframes = rrd_with_keyframes
target = keyframes[0] + 5
assert target not in keyframes, "target must sit strictly between keyframes"
with rr.server.Server(datasets={"video": rrd_dir}) as server:
ds = server.client().get_dataset("video")
source = DataSource(ds)
dataset = RerunMapDataset(
source,
"frame",
{
"image": Field(
"/video:VideoStream:sample",
decode=VideoFrameDecoder(codec="h264", keyframe_interval=1),
),
"state": Field("/state:Scalars:scalars", decode=NumericDecoder()),
},
)
sample = dataset[target]
assert sample["image"] is not None
assert sample["image"].ndim == 3
assert sample["image"].shape[0] == 3 # (C, H, W)
assert sample["state"] is not None
assert float(sample["state"][0]) == float(target)
@pytest.mark.filterwarnings("ignore:The default multiprocessing start method is 'fork':RuntimeWarning")
def test_heuristic_fallback_when_is_keyframe_column_absent(
rrd_without_keyframes: tuple[Path, list[int]],
) -> None:
"""
Decode succeeds via the heuristic fallback when the anchor column is absent.
The fixture omits `is_keyframe` entirely. `_fetch_prior_keyframes` must
detect that the anchor column is missing from the schema and fall through
to the decoder's heuristic without raising a planner error.
"""
rrd_dir, keyframes = rrd_without_keyframes
target = keyframes[0] + 5
with rr.server.Server(datasets={"video": rrd_dir}) as server:
ds = server.client().get_dataset("video")
source = DataSource(ds)
dataset = RerunMapDataset(
source,
"frame",
{
"image": Field(
"/video:VideoStream:sample",
# Big enough to cover the whole single-GOP stream.
decode=VideoFrameDecoder(codec="h264", keyframe_interval=64),
),
"state": Field("/state:Scalars:scalars", decode=NumericDecoder()),
},
)
sample = dataset[target]
assert sample["image"] is not None
assert sample["image"].ndim == 3
assert sample["image"].shape[0] == 3
assert sample["state"] is not None
assert float(sample["state"][0]) == float(target)