1515from humanoid_il .episode_keys import EpisodeFlags , EpisodeKeyboard
1616
1717
18+ def _encode_video_frames_subprocess (
19+ imgs_dir : Path | str ,
20+ video_path : Path | str ,
21+ fps : int ,
22+ vcodec : str = "libsvtav1" ,
23+ pix_fmt : str = "yuv420p" ,
24+ g : int | None = 2 ,
25+ crf : int | None = 30 ,
26+ overwrite : bool = False ,
27+ ** _ignored : Any ,
28+ ) -> None :
29+ """Drop-in replacement for lerobot's encode_video_frames using the ffmpeg CLI.
30+
31+ lerobot encodes videos in-process through PyAV/SVT-AV1, which leaks
32+ ~0.6 GB of native memory per encoded episode when running inside Isaac
33+ Sim (measured; the leak eventually freezes the host). Encoding in a
34+ short-lived ffmpeg subprocess produces identical output (same codec and
35+ parameters) while the leaked memory dies with the child process.
36+ """
37+ video_path = Path (video_path )
38+ if video_path .exists () and not overwrite :
39+ return
40+ video_path .parent .mkdir (parents = True , exist_ok = True )
41+ cmd = [
42+ "ffmpeg" , "-y" , "-loglevel" , "error" ,
43+ "-framerate" , str (fps ),
44+ "-i" , str (Path (imgs_dir ) / "frame-%06d.png" ),
45+ "-c:v" , vcodec ,
46+ "-pix_fmt" , pix_fmt ,
47+ ]
48+ if vcodec == "libsvtav1" :
49+ cmd += ["-preset" , "12" ]
50+ if g is not None :
51+ cmd += ["-g" , str (g )]
52+ if crf is not None :
53+ cmd += ["-crf" , str (crf )]
54+ cmd .append (str (video_path ))
55+ subprocess .run (cmd , check = True , capture_output = True )
56+
57+
58+ def _install_subprocess_video_encoder () -> None :
59+ """Route lerobot's video encoding through the ffmpeg CLI (idempotent).
60+
61+ lerobot offers no encoder hook, so the module-level symbol is replaced.
62+ Covers both the sequential path and the per-camera worker processes
63+ (which resolve the same module attribute after fork).
64+ """
65+ import lerobot .datasets .lerobot_dataset as lerobot_dataset
66+
67+ lerobot_dataset .encode_video_frames = _encode_video_frames_subprocess
68+
69+
1870class SimLeRobotRecorder :
1971 """Buffer frames in GPU tensors, flush to a LeRobot dataset asynchronously.
2072
73+ Episodes travel through a fixed pool of pinned CPU slots (constant
74+ memory, at most _NUM_CPU_SLOTS episodes in flight).
75+
2176 Designed for Isaac Sim where observations are already on-device — batching
2277 the PCIe transfer to one copy-per-episode avoids per-frame overhead.
2378
@@ -49,6 +104,8 @@ def __init__(
49104 instance_id_seg : bool = False ,
50105 num_episodes : int | None = None ,
51106 buffer_capacity_s : float = 120.0 ,
107+ robot_type : str = "so101_follower" ,
108+ extra_features : dict [str , list [str ]] | None = None ,
52109 ) -> None :
53110 self .fps = fps
54111 self .save_mp4 = save_mp4
@@ -61,6 +118,9 @@ def __init__(
61118 self .dataset_root = Path (dataset_root )
62119 self .task_name = task_name
63120 self .num_episodes = num_episodes
121+ self .robot_type = robot_type
122+ # extra float32 vector features: {feature_name: [component names]}
123+ self .extra_features = dict (extra_features or {})
64124 self .num_recorded_episodes = 0
65125
66126 self ._capacity = int (buffer_capacity_s * fps )
@@ -71,8 +131,11 @@ def __init__(
71131 self ._rgb_bufs : dict [str , torch .Tensor ] = {}
72132 self ._depth_bufs : dict [str , torch .Tensor ] = {}
73133 self ._seg_bufs : dict [str , torch .Tensor ] = {}
134+ self ._extra_bufs : dict [str , torch .Tensor ] = {}
74135
75136 self ._episode_queue : queue .Queue = queue .Queue ()
137+ # reusable pinned CPU episode slots; see _allocate_cpu_slots
138+ self ._free_slots : queue .Queue = queue .Queue ()
76139 self ._stop_event = threading .Event ()
77140 self ._processor_thread = threading .Thread (
78141 target = self ._async_processor , daemon = True
@@ -150,12 +213,24 @@ def _build_features(self) -> dict[str, Any]:
150213 "shape" : (spec ["height" ], spec ["width" ], 3 ),
151214 "names" : ["height" , "width" , "channels" ],
152215 }
216+ for name , comp_names in self .extra_features .items ():
217+ features [name ] = {
218+ "dtype" : "float32" ,
219+ "fps" : self .fps ,
220+ "shape" : (len (comp_names ),),
221+ "names" : list (comp_names ),
222+ }
153223 return features
154224
225+ _NUM_CPU_SLOTS = 2
226+
155227 def init_dataset (self ) -> None :
156228 """Create or re-open the LeRobot dataset on disk."""
157229 from lerobot .datasets .lerobot_dataset import LeRobotDataset
158230
231+ _install_subprocess_video_encoder ()
232+ if self ._free_slots .empty ():
233+ self ._allocate_cpu_slots ()
159234 root = self .dataset_root
160235 if root .exists ():
161236 try :
@@ -172,7 +247,7 @@ def init_dataset(self) -> None:
172247 fps = self .fps ,
173248 features = self ._build_features (),
174249 root = root ,
175- robot_type = "so101_follower" ,
250+ robot_type = self . robot_type ,
176251 )
177252 print (f"[INFO]: Created new dataset at { root } " )
178253
@@ -194,6 +269,10 @@ def _allocate_buffers(self) -> None:
194269 self ._seg_bufs [name ] = torch .zeros (
195270 (cap , h , w , 3 ), dtype = torch .uint8 , device = dev
196271 )
272+ for name , comp_names in self .extra_features .items ():
273+ self ._extra_bufs [name ] = torch .zeros (
274+ (cap , len (comp_names )), dtype = torch .float32 , device = dev
275+ )
197276
198277 @staticmethod
199278 def _as_tensor (
@@ -212,6 +291,7 @@ def push_frame_to_buffer(
212291 visual_buffers : dict [str , np .ndarray | torch .Tensor ],
213292 depth_buffers : dict [str , np .ndarray | torch .Tensor ] | None = None ,
214293 instance_id_seg_buffers : dict [str , np .ndarray | torch .Tensor ] | None = None ,
294+ extras : dict [str , np .ndarray | torch .Tensor ] | None = None ,
215295 ) -> None :
216296 """Push one timestep of data into the GPU buffers."""
217297 if self ._current_frame >= self ._capacity :
@@ -239,41 +319,73 @@ def push_frame_to_buffer(
239319 instance_id_seg_buffers [name ], torch .uint8 , self .device
240320 )
241321
322+ for name in self .extra_features :
323+ if extras is None or name not in extras :
324+ raise KeyError (f"extra feature '{ name } ' missing from extras" )
325+ self ._extra_bufs [name ][i ] = self ._as_tensor (
326+ extras [name ], torch .float32 , self .device
327+ )
328+
242329 self ._current_frame += 1
243330
331+ def _allocate_cpu_slots (self ) -> None :
332+ """Preallocate reusable pinned CPU episode slots (once per session).
333+
334+ Copying episodes into fresh pageable CPU memory every save leaked
335+ ~0.6 GB of RSS per episode inside Isaac Sim (the freed pages were
336+ never returned to the OS), eventually freezing the host. Two pinned
337+ slots, allocated once and reused, keep memory constant and make the
338+ device-to-host DMA faster. Two slots also bound how many episodes
339+ can be in flight — save_episode() blocks when both are busy.
340+ """
341+ dim = len (self .joint_names )
342+ cap = self ._capacity
343+ for _ in range (self ._NUM_CPU_SLOTS ):
344+ slot : dict [str , Any ] = {
345+ "total_frames" : 0 ,
346+ "action" : torch .empty ((cap , dim ), dtype = torch .float32 , pin_memory = True ),
347+ "observation" : torch .empty ((cap , dim ), dtype = torch .float32 , pin_memory = True ),
348+ "rgb" : {}, "depth" : {}, "seg" : {}, "extras" : {},
349+ }
350+ for name , spec in self .cameras .items ():
351+ h , w = spec ["height" ], spec ["width" ]
352+ slot ["rgb" ][name ] = torch .empty ((cap , h , w , 3 ), dtype = torch .uint8 , pin_memory = True )
353+ if self .depth :
354+ slot ["depth" ][name ] = torch .empty ((cap , h , w , 1 ), dtype = torch .float32 , pin_memory = True )
355+ if self .instance_id_seg :
356+ slot ["seg" ][name ] = torch .empty ((cap , h , w , 3 ), dtype = torch .uint8 , pin_memory = True )
357+ for name , comp_names in self .extra_features .items ():
358+ slot ["extras" ][name ] = torch .empty (
359+ (cap , len (comp_names )), dtype = torch .float32 , pin_memory = True
360+ )
361+ self ._free_slots .put (slot )
362+
244363 def save_episode (self ) -> None :
245- """Batch-copy the current episode to CPU and enqueue for async saving."""
246- print ("[INFO]: Copying episode to CPU..." )
364+ """Copy the episode into a reusable pinned CPU slot and enqueue it.
365+
366+ Blocks while both CPU slots are in flight (writer backpressure).
367+ """
368+ if self ._action_buf is None :
369+ print ("[WARN]: save_episode called with no buffered frames, skipping" )
370+ return
371+ if self ._free_slots .empty ():
372+ print ("[INFO]: Waiting for a free episode slot (writer catching up)..." )
373+ slot = self ._free_slots .get ()
374+
247375 n = self ._current_frame
248- episode : dict [str , Any ] = {
249- "total_frames" : n ,
250- "action" : (
251- self ._action_buf [:n ].cpu ().numpy ().copy ()
252- if self ._action_buf is not None
253- else np .zeros ((0 , len (self .joint_names )), dtype = np .float32 )
254- ),
255- "observation" : (
256- self ._obs_buf [:n ].cpu ().numpy ().copy ()
257- if self ._obs_buf is not None
258- else np .zeros ((0 , len (self .joint_names )), dtype = np .float32 )
259- ),
260- "rgb" : {
261- name : self ._rgb_bufs [name ][:n ].cpu ().numpy ().copy ()
262- for name in self .cameras
263- },
264- }
265- if self .depth :
266- episode ["depth" ] = {
267- name : self ._depth_bufs [name ][:n ].cpu ().numpy ().copy ()
268- for name in self .cameras
269- }
270- if self .instance_id_seg :
271- episode ["seg" ] = {
272- name : self ._seg_bufs [name ][:n ].cpu ().numpy ().copy ()
273- for name in self .cameras
274- }
376+ slot ["total_frames" ] = n
377+ slot ["action" ][:n ].copy_ (self ._action_buf [:n ])
378+ slot ["observation" ][:n ].copy_ (self ._obs_buf [:n ])
379+ for name in self .cameras :
380+ slot ["rgb" ][name ][:n ].copy_ (self ._rgb_bufs [name ][:n ])
381+ if self .depth :
382+ slot ["depth" ][name ][:n ].copy_ (self ._depth_bufs [name ][:n ])
383+ if self .instance_id_seg :
384+ slot ["seg" ][name ][:n ].copy_ (self ._seg_bufs [name ][:n ])
385+ for name in self .extra_features :
386+ slot ["extras" ][name ][:n ].copy_ (self ._extra_bufs [name ][:n ])
275387
276- self ._episode_queue .put (episode )
388+ self ._episode_queue .put (slot )
277389 self ._clear_buffers ()
278390 print ("[INFO]: Episode queued for saving." )
279391
@@ -288,6 +400,7 @@ def _clear_buffers(self) -> None:
288400 self ._rgb_bufs = {}
289401 self ._depth_bufs = {}
290402 self ._seg_bufs = {}
403+ self ._extra_bufs = {}
291404 self ._current_frame = 0
292405
293406 def _async_processor (self ) -> None :
@@ -303,9 +416,34 @@ def _async_processor(self) -> None:
303416 except Exception as exc :
304417 print (f"[ERROR]: Episode processing failed: { exc } " )
305418 finally :
419+ self ._free_slots .put (episode ) # recycle the pinned slot
306420 self ._episode_queue .task_done ()
421+ self ._trim_native_heap ()
422+
423+ @staticmethod
424+ def _trim_native_heap () -> None :
425+ """Return freed glibc heap pages to the OS.
426+
427+ Each episode moves ~0.7 GB of frames through this thread; inside
428+ Isaac Sim (many threads, many malloc arenas) glibc retains the freed
429+ pages indefinitely, growing RSS by ~0.6 GB per saved episode until
430+ the host runs out of memory. malloc_trim(0) after each episode
431+ returns them (measured: flat RSS with, linear growth without).
432+ """
433+ import ctypes
434+
435+ try :
436+ ctypes .CDLL ("libc.so.6" ).malloc_trim (0 )
437+ except OSError :
438+ pass # non-glibc platform: nothing to trim
307439
308440 def _process_episode (self , episode : dict [str , Any ]) -> None :
441+ """Write one episode (a pinned CPU slot) to the LeRobot dataset.
442+
443+ Tensor rows handed to add_frame are views into the slot; lerobot
444+ copies/encodes everything before this method returns, after which
445+ the slot is recycled by the caller.
446+ """
309447 from lerobot .datasets .lerobot_dataset import LeRobotDataset
310448
311449 n = episode ["total_frames" ]
@@ -317,15 +455,17 @@ def _process_episode(self, episode: dict[str, Any]) -> None:
317455 }
318456 for name in self .cameras :
319457 frame [f"observation.images.{ name } " ] = episode ["rgb" ][name ][i ]
458+ for name in self .extra_features :
459+ frame [name ] = episode ["extras" ][name ][i ]
320460 self .dataset .add_frame (frame )
321461
322462 if self .save_mp4 :
323463 for name in self .cameras :
324- self ._save_rgb_video (episode ["rgb" ][name ][:n ], name )
325- if self .depth and "depth" in episode :
326- self ._save_depth_video (episode ["depth" ][name ][:n ], name )
327- if self .instance_id_seg and "seg" in episode :
328- self ._save_seg_video (episode ["seg" ][name ][:n ], name )
464+ self ._save_rgb_video (episode ["rgb" ][name ][:n ]. numpy () , name )
465+ if self .depth and episode [ "depth" ] :
466+ self ._save_depth_video (episode ["depth" ][name ][:n ]. numpy () , name )
467+ if self .instance_id_seg and episode [ "seg" ] :
468+ self ._save_seg_video (episode ["seg" ][name ][:n ]. numpy () , name )
329469
330470 self .dataset .save_episode ()
331471 self .dataset .finalize ()
0 commit comments