Skip to content

Stream camera video frames instead of buffering episodes - #1000

Open
alexmillane wants to merge 1 commit into
mainfrom
alex/feature/incremental_video_encoding
Open

Stream camera video frames instead of buffering episodes#1000
alexmillane wants to merge 1 commit into
mainfrom
alex/feature/incremental_video_encoding

Conversation

@alexmillane

@alexmillane alexmillane commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Encode camera videos incrementally so host RAM no longer scales with episode length.

Detailed description

  • Why: CameraObsVideoRecorder stored every frame of an episode before encoding, costing num_envs × episode_length × H × W × C. For robolab tasks (3× 1280x720 droid cameras, 1000-step episodes, 50Hz) that is 8.3 GB per env per episode.
  • What: frames now stream to a per-(env, camera) FFMPEG_VideoWriter as they arrive.
  • Preformance impact: measured on canned_food_in_bin, peak host RAM drops 8.32 GB → 0.72 GB. Wall clock unchanged.

Before and after experiment.

peak_ram_recording_comparison ram_traces_recording

@alexmillane alexmillane left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self review 1.

Comment on lines +139 to +152
def test_frames_are_streamed_not_buffered(tmp_path):
"""Every frame reaches the encoder as it arrives, and no frame list is retained."""
env = _make_env()
with _patched_writers() as writers:
recorder = CameraObsVideoRecorder(env, video_folder=str(tmp_path))

for _ in range(3):
_configure_step(env)
recorder.step(None)

# One open encoder per (env, camera), each already handed all three frames.
assert len(writers) == len(CAMERAS) * 2
assert all(writer.frames_written == 3 for writer in writers)
assert not hasattr(recorder, "buffers")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superfluous. Remove.

Comment on lines +8 to +9
No Isaac Sim or GPU required. The moviepy encoder is replaced by a stand-in that records
the frames it is handed, so tests run fast and on CPU-only machines.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stand up doesn't actually record, it just tracks how many frames have been passed to be recorded.

assert recorder.buffers[cam][0] == []

# env 1 accumulated 2 frames (neither step was terminal for it)
by_path = {writer.filename: writer for writer in writers}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writer_by_filename.

Comment on lines +20 to +25
Memory note: frames are handed to ffmpeg one at a time and never accumulated, so host RAM
is independent of episode length. What stays resident is one frame per open stream plus
each encoder's internal state; ``num_envs × num_cameras`` encoders run concurrently, each
pinned to a single thread to bound that state. Buffering whole episodes instead costs
``num_envs × L × H × W × C`` bytes — for 10 envs of 1000-step episodes with three
1280×720 cameras that is ~83 GB, against mp4s of a few MB each.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change this to

Memory note: This class uses incremental ffmpeg encoding the avoid storing the raw frames in memory, which uses substantial amounts of RAM.



@dataclass
class _EpisodeVideoWriter:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove leading underscore.

Comment on lines +169 to +171
# The env's counter still names the episode now in progress; it is advanced on reset,
# inside env.step. Sharing the env's index keeps the filename's episode number in
# lockstep with the per-episode results record's ``episode_in_env``.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove.

Comment on lines +178 to +179
# One thread per encoder: frames arrive far slower than a single x264 thread encodes,
# and num_envs x num_cameras encoders run at once, so this bounds their combined state.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to: "We use one thread because frames arrive slower than a single thread is able to encode.

CameraObsVideoRecorder held every frame of an episode in memory before
encoding, costing num_envs x episode_length x H x W x C bytes. Frames are
now written to a per-(env, camera) ffmpeg encoder as they arrive.

Measured on canned_food_in_bin with 3x 1280x720 cameras: peak host RAM at
6 envs drops 60.5 GB -> 13.1 GB, with wall clock unchanged.

Signed-off-by: alex <amillane@nvidia.com>
@alexmillane
alexmillane force-pushed the alex/feature/incremental_video_encoding branch from 78173ad to 443177f Compare August 1, 2026 14:38
@alexmillane
alexmillane marked this pull request as ready for review August 1, 2026 20:50
@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR replaces per-episode raw camera-frame buffering with incremental encoding through one ffmpeg writer per environment and camera, substantially reducing peak host memory.

  • Opens each episode’s encoder lazily when its first recordable frame arrives.
  • Finalizes writers at environment termination or truncation.
  • Closes and deletes files belonging to partial episodes during wrapper shutdown.
  • Updates unit tests to verify streaming, writer lifecycle, episode naming, and partial-file removal.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

Episode counters are sampled when each current episode’s writer opens, completed streams are finalized on reset, partial streams are removed on shutdown, and the updated tests cover the principal lifecycle transitions.

Important Files Changed

Filename Overview
isaaclab_arena/video/camera_observation_video_recorder.py Replaces frame lists and end-of-episode bulk encoding with lazily opened per-stream ffmpeg writers, including completion and partial-episode cleanup.
isaaclab_arena/tests/test_camera_observation_video_recorder.py Reworks recorder tests around a writer stand-in and adds direct assertions for incremental frame delivery and encoder lifecycle behavior.

Sequence Diagram

sequenceDiagram
  participant Env
  participant Recorder as CameraObsVideoRecorder
  participant Writer as FFMPEG_VideoWriter
  Env->>Recorder: step() observations
  alt Environment is active
    Recorder->>Writer: lazily open episode writer
    Recorder->>Writer: write_frame(frame)
  else Environment reset
    Recorder->>Writer: close and finalize completed episode
  end
  alt Wrapper closes during partial episode
    Recorder->>Writer: close
    Recorder->>Recorder: delete partial mp4
  end
Loading

Reviews (1): Last reviewed commit: "Stream camera frames to the encoder inst..." | Re-trigger Greptile

height, width, _ = frame.shape
# We use one thread because frames arrive slower than a single thread is able to encode.
episode_writer = EpisodeVideoWriter(
writer=FFMPEG_VideoWriter(path, size=(width, height), fps=self.fps, threads=1),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Concurrent encoders now scale with num_envs

Nice RAM win. One thing worth sanity-checking: we now keep a live ffmpeg process open per (env, camera) for the whole episode, so peak concurrent encoders is num_envs × num_cameras — whereas before we ran at most one encode at a time, at reset. At the 6-env measurement that's 18 processes; a larger sweep (e.g. 64 envs × 3 cams) is ~192 persistent subprocesses/pipes. Since the threads=1 note says frames arrive slowly, CPU is presumably fine — have you confirmed the process/FD count holds up at the higher env counts you'll actually run?

@arena-review-bot

Copy link
Copy Markdown
Contributor

🤖 Isaac Lab-Arena Review Bot

Summary

This PR switches CameraObsVideoRecorder from buffering an entire episode of raw frames to streaming each frame straight into a per-(env, camera) FFMPEG_VideoWriter, cutting peak host RAM dramatically (measured 8.32 GB → 0.72 GB on canned_food_in_bin). The change lands in the right layer, preserves the episode-index lockstep (now derived at encoder-open time instead of the old -1-at-flush), correctly reads the counter via self.unwrapped, and keeps partial-episode deletion on close(). The tests were rewritten cleanly to validate streaming (frames reach the encoder as they arrive, partial files removed, per-env episode numbering) plus a real-ffmpeg smoke test. Solid, well-scoped change.

Findings

🔵 Improvement — camera_observation_video_recorder.py:175 — Streaming keeps num_envs × num_cameras ffmpeg processes open for the whole episode (vs. one encode at a time before). Likely fine given the single-thread/low-CPU note, but worth confirming the process/FD count holds at the larger env counts you will actually run.

Test Coverage

Good. Tests are CPU-only (no Isaac Sim), so the inner/outer sim pattern does not apply; the moviepy encoder is replaced by a counting stand-in and there is a skipif(ffmpeg missing) real-encode test. New behavior is covered: frames stream rather than buffer, partial episodes leave no file, empty episodes still advance the index, and post-reset frames are excluded. The streaming rewrite is a behavior-preserving refactor and the suite tracks it closely.

Verdict

Ship it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants