Source code for so101_mvbench.evaluation.video_recorder
"""Async MP4 video recorder for evaluation episodes.
Captures camera frames at a fixed interval and encodes them to MP4
in a background thread (ThreadPoolExecutor). Frames are resized before
encoding to reduce file size.
"""
VERSION = "1.1.0"
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import numpy as np
from so101_mvbench.logging import get_logger
logger = get_logger(__name__)
# Default settings — documented in CSV/JSON output
DEFAULT_FPS = 10
DEFAULT_INTERVAL = 15 # every 15 steps = 0.25s sim-time per frame
DEFAULT_RESIZE = (320, 240)
DEFAULT_SPEEDUP = 2.5 # 10fps × 0.25s/frame = 2.5× real-time
[docs]
class VideoRecorder:
"""Async MP4 recorder for camera streams the policy consumes.
By default (``policy_cams=None``) captures both ``camera_ego`` and
``camera_external`` — for backwards compatibility with debug workflows.
Passing ``policy_cams=list(rename_map.keys())`` filters to only the
cameras the policy actually consumes, so 1-cam policies don't emit a
misleading ``external.mp4`` that was never fed to the model.
"""
[docs]
def __init__(
self,
base_dir: Path | None,
policy_cams: list[str] | None = None,
fps: int = DEFAULT_FPS,
interval: int = DEFAULT_INTERVAL,
resize: tuple[int, int] = DEFAULT_RESIZE,
):
self._base_dir = base_dir
self._policy_cams = set(policy_cams) if policy_cams else None
self._fps = fps
self._interval = interval
self._resize = resize
self._frame_buffers: dict[str, list[np.ndarray]] = {}
self._executor = ThreadPoolExecutor(max_workers=1)
self._futures = []
@property
def settings(self) -> dict:
"""Video settings dict for JSON summary."""
return {
"format": "mp4",
"fps": self._fps,
"capture_interval_steps": self._interval,
"sim_time_per_frame_s": self._interval / 60, # 60Hz effective rate
"playback_speedup": DEFAULT_SPEEDUP,
"resolution": f"{self._resize[0]}x{self._resize[1]}",
"policy_cams": sorted(self._policy_cams) if self._policy_cams else None,
}
@property
def enabled(self) -> bool:
return self._base_dir is not None
[docs]
def capture(self, obs: dict, step: int) -> None:
"""Grab camera frames if step matches interval.
Cameras not in ``self._policy_cams`` are skipped — avoids emitting
MP4s for cameras the policy never consumed.
"""
if not self.enabled or step % self._interval != 0:
return
for cam_key, img_tensor in obs.get("visual", {}).items():
cam_name = cam_key.replace("camera_", "", 1)
if self._policy_cams is not None and cam_name not in self._policy_cams:
continue
if cam_name not in self._frame_buffers:
self._frame_buffers[cam_name] = []
img = img_tensor[0].cpu().numpy()
if img.dtype != np.uint8:
img = (img * 255).clip(0, 255).astype(np.uint8)
self._frame_buffers[cam_name].append(img)
[docs]
def flush(self, ep_dir: Path) -> None:
"""Encode collected frames to MP4 async, then clear buffers."""
if not any(self._frame_buffers.values()):
return
ep_dir.mkdir(parents=True, exist_ok=True)
snapshot = {name: list(frames) for name, frames in self._frame_buffers.items()}
self._futures.append(self._executor.submit(
_encode_videos, snapshot, ep_dir, self._resize, self._fps,
))
for frames in self._frame_buffers.values():
frames.clear()
[docs]
def wait(self) -> None:
"""Wait for all pending encoding jobs."""
if self._futures:
logger.info("Waiting for %d video encoding jobs...", len(self._futures))
for fut in self._futures:
fut.result()
self._executor.shutdown(wait=False)
logger.info("Video encoding complete")
def _encode_videos(
frame_buffers: dict[str, list[np.ndarray]],
out_dir: Path,
resize: tuple[int, int],
fps: int,
) -> None:
"""Encode camera frames to MP4 (runs in background thread)."""
import imageio
from PIL import Image as _PILImg
for cam_name, flist in frame_buffers.items():
if flist:
vid_path = out_dir / f"{cam_name}.mp4"
writer = imageio.get_writer(str(vid_path), fps=fps)
for f in flist:
small = np.array(_PILImg.fromarray(f).resize(resize, _PILImg.LANCZOS))
writer.append_data(small)
writer.close()