Skip to content

Commit d9aa5d5

Browse files
freenaanCopilot
andauthored
Added cuRobo-based dataset generator for pick and place IL (#110)
* Added cuRobo-based pick-and-place IL expert demo dataset generator * added more noise to path, cleaned up naming * forgot some files * created new environment for tray pick-and-place, updated readme, general cleanup * Remove cuRobo stack from shared isaac_lab Docker image * reverted names of usd files --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent fca91e5 commit d9aa5d5

29 files changed

Lines changed: 4884 additions & 36 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Dataset schema: generalized pick-and-place, wato_bimanual_arm LEFT arm.
2+
# Produced by autonomy/simulation/pick_place_gen/generate_demos.py (cuRobo
3+
# expert, success-gated episodes). fps = control 50 Hz / record_divisor 2.
4+
robot_id: wato_bimanual_left_sim_v1
5+
repo_id: humanoid/pick_place_bimanual_left
6+
fps: 25
7+
8+
# observation.state AND action are joint positions in this order
9+
# (action = commanded targets, state = measured):
10+
joint_names:
11+
- joint1L
12+
- joint2l
13+
- joint3l
14+
- joint4l
15+
- joint5l
16+
- joint6l
17+
- joint7l
18+
- joint8l
19+
20+
# privileged ground truth recorded per frame as observation.environment_state
21+
# (robot base frame; quaternions wxyz):
22+
env_state_names:
23+
- object_x
24+
- object_y
25+
- object_z
26+
- object_qw
27+
- object_qx
28+
- object_qy
29+
- object_qz
30+
- target_x
31+
- target_y
32+
- target_z
33+
- target_qw
34+
- target_qx
35+
- target_qy
36+
- target_qz
37+
38+
images:
39+
external:
40+
height: 480
41+
width: 640
42+
enabled: true
43+
wrist:
44+
height: 480
45+
width: 640
46+
enabled: true
47+
48+
record:
49+
root: datasets/record_pick_place_bimanual
50+
use_videos: true

autonomy/il/humanoid_il/sim_recorder.py

Lines changed: 176 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,64 @@
1515
from 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+
1870
class 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()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Generalized pick-and-place task for the wato_bimanual_arm (left arm)."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import gymnasium as gym
2+
3+
gym.register(
4+
id="Isaac-PickPlace-BimanualLeft-v0",
5+
entry_point="isaaclab.envs:ManagerBasedRLEnv",
6+
disable_env_checker=True,
7+
kwargs={
8+
"env_cfg_entry_point": (
9+
"HumanoidRLPackage.HumanoidRLSetup.tasks.pick_place_bimanual."
10+
"pick_place_env_cfg:PickPlaceBimanualEnvCfg"
11+
),
12+
},
13+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Configurations for the pick-and-place environment."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""MDP terms for the pick-and-place task (Isaac Lab built-ins + local)."""
2+
from isaaclab.envs.mdp import * # noqa: F401,F403
3+
4+
from .events import * # noqa: F401,F403
5+
from .observations import * # noqa: F401,F403

0 commit comments

Comments
 (0)