Chandra Kiran commited on
Commit
b357d7d
·
unverified ·
1 Parent(s): 6772038

Export runs as LeRobot datasets

Browse files

POST /datasets/export turns finished runs of one robot into a LeRobot dataset:
observation.state and .velocity (the robot's joints), action (the commanded
controls), observation.images.main (the video, 640x360) and the scenario text as
the task. With HF_TOKEN in the Modal secret it is pushed to the Hub as a private
dataset; otherwise the zip is stored with the job. Verified end to end with
lerobot 0.4.4 (write, then load back); CI repeats that with a Go2. Trajectories
now also carry every body's world pose, for replay renders.

.github/workflows/ci.yml CHANGED
@@ -142,3 +142,53 @@ jobs:
142
  path: ros-out
143
  if-no-files-found: warn
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  path: ros-out
143
  if-no-files-found: warn
144
 
145
+ lerobot-export:
146
+ name: LeRobot dataset export
147
+ runs-on: ubuntu-24.04
148
+ steps:
149
+ - uses: actions/checkout@v4
150
+ - uses: actions/setup-python@v5
151
+ with:
152
+ python-version: "3.11"
153
+ - name: Install (CPU PyTorch, LeRobot, the simulator)
154
+ run: |
155
+ sudo apt-get update && sudo apt-get install -y --no-install-recommends libosmesa6 libgl1 ffmpeg
156
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
157
+ pip install lerobot==0.4.4 -r worker/requirements.txt
158
+ - name: Simulate two runs, export them, load the dataset back
159
+ env:
160
+ MUJOCO_GL: osmesa
161
+ run: |
162
+ git clone -q --depth 1 --filter=blob:none --sparse https://github.com/google-deepmind/mujoco_menagerie.git /tmp/men
163
+ git -C /tmp/men sparse-checkout set unitree_go2
164
+ python - <<'EOF'
165
+ from pathlib import Path
166
+ from api.mjcf_check import prepare_scene_dir
167
+ from worker.lerobot_export import run_export_job
168
+ from worker.sim_runner import config_for, simulate
169
+
170
+ scene = """<mujoco><include file="go2.xml"/><worldbody><light pos="0 0 4" dir="0 0 -1"/>
171
+ <geom name="floor" type="plane" size="0 0 0.05"/></worldbody></mujoco>"""
172
+ episodes = []
173
+ for i, controller in enumerate(("hold", "passive")):
174
+ d = prepare_scene_dir(scene, "unitree_go2", Path("/tmp/men"))
175
+ cfg = config_for("unitree_go2", 1.0, controller)
176
+ cfg.width, cfg.height = 320, 180
177
+ simulate(d / "scene.xml", Path(f"/tmp/run{i}"), cfg)
178
+ episodes.append({"run_id": f"r{i}", "task": f"Go2 {controller}",
179
+ "trajectory_url": f"/tmp/run{i}/trajectory.npz", "video_url": f"/tmp/run{i}/video.mp4"})
180
+ out = run_export_job({"repo_id": "ci/rosdiff-go2", "robot": "unitree_go2", "episodes": episodes})
181
+ print(out["summary"])
182
+ assert out["summary"]["episodes"] == 2, out["summary"]
183
+ Path("/tmp/ds.zip").write_bytes(out["artifacts"]["dataset.zip"])
184
+
185
+ import shutil
186
+ shutil.unpack_archive("/tmp/ds.zip", "/tmp/ds")
187
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
188
+ ds = LeRobotDataset("ci/rosdiff-go2", root="/tmp/ds")
189
+ item = ds[len(ds) - 1]
190
+ print({k: tuple(v.shape) if hasattr(v, "shape") else v for k, v in item.items()})
191
+ assert ds.num_episodes == 2 and item["task"] == "Go2 passive"
192
+ assert tuple(item["observation.images.main"].shape) == (3, 360, 640)
193
+ assert tuple(item["observation.state"].shape) == (19,) and tuple(item["action"].shape) == (12,)
194
+ EOF
api/config.py CHANGED
@@ -56,6 +56,7 @@ class Settings:
56
  job_overhead_s: float # billed seconds per job beyond the worker's own timing (start-up, idle shutdown)
57
  isaac_daily_limit: int # Isaac Sim renders per day, across all testers
58
  auto_review: bool # review every finished simulation video with Cosmos Reason
 
59
  foxglove_org: str # optional Foxglove organization slug for the embedded viewer
60
 
61
  @property
@@ -113,5 +114,6 @@ def get_settings() -> Settings:
113
  job_overhead_s=float(_env("JOB_OVERHEAD_S", "45")),
114
  isaac_daily_limit=int(_env("ISAAC_DAILY_LIMIT", "10")),
115
  auto_review=_env("AUTO_REVIEW", "true").lower() in ("1", "true", "yes"),
 
116
  foxglove_org=_env("FOXGLOVE_ORG", ""),
117
  )
 
56
  job_overhead_s: float # billed seconds per job beyond the worker's own timing (start-up, idle shutdown)
57
  isaac_daily_limit: int # Isaac Sim renders per day, across all testers
58
  auto_review: bool # review every finished simulation video with Cosmos Reason
59
+ hf_namespace: str # Hugging Face user/org that dataset exports are pushed to (with the worker's HF_TOKEN)
60
  foxglove_org: str # optional Foxglove organization slug for the embedded viewer
61
 
62
  @property
 
114
  job_overhead_s=float(_env("JOB_OVERHEAD_S", "45")),
115
  isaac_daily_limit=int(_env("ISAAC_DAILY_LIMIT", "10")),
116
  auto_review=_env("AUTO_REVIEW", "true").lower() in ("1", "true", "yes"),
117
+ hf_namespace=_env("HF_NAMESPACE", "rosdiff"),
118
  foxglove_org=_env("FOXGLOVE_ORG", ""),
119
  )
api/main.py CHANGED
@@ -150,6 +150,12 @@ class SweepRequest(BaseModel):
150
  seed: int = 0
151
 
152
 
 
 
 
 
 
 
153
  class CheckRequest(BaseModel):
154
  generation_id: str = Field(..., description="A package from /code/generate (yours)")
155
 
@@ -436,6 +442,45 @@ def create_app(services: Services | None = None) -> FastAPI:
436
  raise HTTPException(status_code=e.status, detail=str(e)) from e
437
  return _job_response(request, s, job)
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  @app.post("/code/check", response_model=JobResponse)
440
  def code_check(req: CheckRequest, request: Request, t: Tester = Depends(tester), s: Services = Depends(svc)):
441
  """Build, lint, test and launch a generated package in the ROS 2 sandbox. Returns at once; see report_url."""
 
150
  seed: int = 0
151
 
152
 
153
+ class ExportRequest(BaseModel):
154
+ run_ids: list[str] = Field(..., min_length=1, max_length=100, description="Your finished runs, one robot")
155
+ name: str = Field(..., pattern=r"^[a-z0-9][a-z0-9-]{1,60}$", description="Dataset name, e.g. g1-table-pushes")
156
+ private: bool = True
157
+
158
+
159
  class CheckRequest(BaseModel):
160
  generation_id: str = Field(..., description="A package from /code/generate (yours)")
161
 
 
442
  raise HTTPException(status_code=e.status, detail=str(e)) from e
443
  return _job_response(request, s, job)
444
 
445
+ @app.post("/datasets/export", response_model=JobResponse)
446
+ def export_dataset(req: ExportRequest, request: Request, t: Tester = Depends(tester), s: Services = Depends(svc)):
447
+ """Turn finished runs into a LeRobot dataset (state, velocity, action, video, task) on the Hugging Face Hub."""
448
+ episodes, robots = [], set()
449
+ for run_id in dict.fromkeys(req.run_ids):
450
+ run = s.logs.get_run(run_id)
451
+ if not run or run["tester"] != t.name or (run.get("kind") or "sim") != "sim":
452
+ raise HTTPException(status_code=404, detail=f"no simulation run {run_id} for this key")
453
+ run = refresh(s, run)
454
+ files = (run.get("output") or {}).get("files") or {}
455
+ if run["status"] != "COMPLETED" or "trajectory" not in files:
456
+ raise HTTPException(status_code=422, detail=f"run {run_id} is not finished or has no trajectory")
457
+ robots.add(run.get("robot"))
458
+ task = ""
459
+ if run.get("generation_id"):
460
+ task = ((s.logs.get(run["generation_id"]) or {}).get("request") or {}).get("description", "")
461
+ episodes.append(
462
+ {
463
+ "run_id": run_id,
464
+ "task": task,
465
+ "trajectory_url": s.storage.url(files["trajectory"]),
466
+ "video_url": s.storage.url(files["video"]) if "video" in files else None,
467
+ }
468
+ )
469
+ if len(robots) > 1:
470
+ raise HTTPException(status_code=422, detail="all runs in one dataset must use the same robot")
471
+ robot = robots.pop()
472
+ repo_id = f"{s.settings.hf_namespace}/rosdiff-{req.name}"
473
+ job = _submit(
474
+ s,
475
+ t.name,
476
+ "export",
477
+ {"repo_id": repo_id, "robot": robot, "episodes": episodes, "private": req.private},
478
+ run_id=new_run_id(),
479
+ robot=robot,
480
+ params={"repo_id": repo_id, "runs": [e["run_id"] for e in episodes]},
481
+ )
482
+ return _job_response(request, s, job)
483
+
484
  @app.post("/code/check", response_model=JobResponse)
485
  def code_check(req: CheckRequest, request: Request, t: Tester = Depends(tester), s: Services = Depends(svc)):
486
  """Build, lint, test and launch a generated package in the ROS 2 sandbox. Returns at once; see report_url."""
api/runs.py CHANGED
@@ -412,3 +412,34 @@ def _review_body(job: dict, storage: Storage | None) -> list[str]:
412
 
413
 
414
  KIND_BODIES["review"] = _review_body
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
 
413
 
414
  KIND_BODIES["review"] = _review_body
415
+
416
+
417
+ def _export_body(job: dict, storage: Storage | None) -> list[str]:
418
+ output = job.get("output") or {}
419
+ r = output.get("summary") or {}
420
+ files = output.get("files") or {}
421
+ link = ""
422
+ if r.get("hub_url"):
423
+ link = f' · <a href="{html.escape(r["hub_url"])}">open on Hugging Face</a>'
424
+ cards = [f'<div class="card"><b>{html.escape(r.get("headline", ""))}</b>{link}</div>']
425
+ cells = [
426
+ ("Dataset", r.get("repo_id")),
427
+ ("Episodes", r.get("episodes")),
428
+ ("Frames", r.get("frames")),
429
+ ("FPS", r.get("fps")),
430
+ ("Runs", ", ".join(r.get("runs") or [])),
431
+ ]
432
+ grid = "".join(f"<div><span>{html.escape(k)}</span>{_fmt(v)}</div>" for k, v in cells)
433
+ cards.append(f'<div class="card"><div class="grid">{grid}</div></div>')
434
+ repo = html.escape(r.get("repo_id", "<repo_id>"))
435
+ load = f'LeRobotDataset("{repo}")' if r.get("hub_url") else f'LeRobotDataset("{repo}", root="path/to/unzipped")'
436
+ cards.append(
437
+ '<div class="card"><b>Use it</b><pre>pip install lerobot\n'
438
+ f"from lerobot.datasets.lerobot_dataset import LeRobotDataset\nds = {load}\n"
439
+ "ds[0].keys() # observation.state, observation.velocity, action, observation.images.main, task</pre></div>"
440
+ )
441
+ cards.extend(file_links(files, storage))
442
+ return cards
443
+
444
+
445
+ KIND_BODIES["export"] = _export_body
tests/test_export.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from conftest import complete
2
+
3
+ from worker.lerobot_export import features
4
+
5
+
6
+ def finished_run(client, backend, h, gen_id, n):
7
+ run_id = client.post("/simulate/run", json={"generation_id": gen_id}, headers=h).json()["run_id"]
8
+ complete(backend, f"job-{n}", run_id)
9
+ backend.jobs[f"job-{n}"]["output"]["files"]["trajectory"] = f"runs/{run_id}/trajectory.npz"
10
+ return run_id
11
+
12
+
13
+ def test_export_endpoint(run_env):
14
+ client, keys_, backend, services, gen_id = run_env
15
+ h = {"X-API-Key": keys_["alice"]}
16
+ runs = [finished_run(client, backend, h, gen_id, n) for n in (1, 2)]
17
+ r = client.post("/datasets/export", json={"run_ids": runs, "name": "g1-crates"}, headers=h)
18
+ assert r.status_code == 200, r.text
19
+ assert r.json()["kind"] == "export" and backend.functions[-1] == "export_lerobot"
20
+ sent = backend.submitted[-1]
21
+ assert sent["repo_id"] == "rosdiff/rosdiff-g1-crates" and sent["robot"] == "unitree_g1" and sent["private"]
22
+ assert [e["run_id"] for e in sent["episodes"]] == runs
23
+ assert sent["episodes"][0]["task"] == "G1 near a slope and a crate"
24
+ assert sent["episodes"][0]["trajectory_url"].endswith("trajectory.npz")
25
+
26
+
27
+ def test_export_rejects_unfinished_or_foreign_runs(run_env):
28
+ client, keys_, backend, services, gen_id = run_env
29
+ h = {"X-API-Key": keys_["alice"]}
30
+ pending = client.post("/simulate/run", json={"generation_id": gen_id}, headers=h).json()["run_id"]
31
+ r = client.post("/datasets/export", json={"run_ids": [pending], "name": "x1"}, headers=h)
32
+ assert r.status_code == 422 and "not finished" in r.text
33
+ r = client.post("/datasets/export", json={"run_ids": [pending], "name": "x1"}, headers={"X-API-Key": keys_["bob"]})
34
+ assert r.status_code == 404
35
+ assert (
36
+ client.post("/datasets/export", json={"run_ids": [pending], "name": "Bad Name"}, headers=h).status_code == 422
37
+ )
38
+
39
+
40
+ def test_features():
41
+ f = features(["a", "b"], 2, ["m"], with_video=True)
42
+ assert f["observation.state"]["shape"] == (2,) and f["action"]["names"] == ["m"]
43
+ assert f["observation.images.main"]["dtype"] == "video" and "observation.images.main" not in features(
44
+ ["a"], 1, [], with_video=False
45
+ )
tests/test_runs.py CHANGED
@@ -34,6 +34,8 @@ class TestWorker:
34
  }
35
  traj = np.load(storage.root / "runs/abc123/trajectory.npz")
36
  assert traj["qpos"].shape[0] == len(traj["time"]) == 16 and traj["ctrl"].shape == (16, 1)
 
 
37
  assert (
38
  list(traj["joint_names"]) == [f"floating_base_joint[{i}]" for i in range(7)] + ["left_knee_joint"]
39
  and int(traj["fps"]) == 30
 
34
  }
35
  traj = np.load(storage.root / "runs/abc123/trajectory.npz")
36
  assert traj["qpos"].shape[0] == len(traj["time"]) == 16 and traj["ctrl"].shape == (16, 1)
37
+ nbody = len(traj["body_names"])
38
+ assert traj["body_pos"].shape == (16, nbody, 3) and traj["body_quat"].shape == (16, nbody, 4)
39
  assert (
40
  list(traj["joint_names"]) == [f"floating_base_joint[{i}]" for i in range(7)] + ["left_knee_joint"]
41
  and int(traj["fps"]) == 30
worker/lerobot_export.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export simulation runs as a LeRobot dataset (Hugging Face's format for robot learning data).
2
+
3
+ Each run becomes one episode:
4
+ observation.state the robot's joint positions (its free joint first, for mobile robots)
5
+ observation.velocity the robot's joint velocities
6
+ action the controls sent to the actuators (what the hold controller commanded)
7
+ observation.images.main the run's video, downscaled
8
+ task the scenario description the scene was generated from
9
+
10
+ The dataset is pushed to the Hugging Face Hub as a private dataset when HF_TOKEN is set (Modal secret
11
+ rosdiff-hf); otherwise it comes back as a zip stored next to the job. Load it with
12
+ `LeRobotDataset("<repo_id>")`, or from the unzipped folder with `LeRobotDataset("<repo_id>", root=...)`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import tempfile
20
+ import time
21
+ import urllib.request
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+ IMAGE_WIDTH, IMAGE_HEIGHT = 640, 360
27
+ MAX_ZIP_BYTES = 200_000_000
28
+
29
+
30
+ def _fetch(src: str, dest: Path) -> Path:
31
+ if src.startswith(("https://", "http://")):
32
+ with urllib.request.urlopen(src, timeout=300) as r: # noqa: S310 (presigned R2 links)
33
+ dest.write_bytes(r.read())
34
+ else:
35
+ shutil.copy(src.removeprefix("file://"), dest)
36
+ return dest
37
+
38
+
39
+ def _video_frames(path: Path, count: int) -> list[np.ndarray]:
40
+ import imageio_ffmpeg
41
+
42
+ reader = imageio_ffmpeg.read_frames(
43
+ str(path), output_params=["-vf", f"scale={IMAGE_WIDTH}:{IMAGE_HEIGHT}"], pix_fmt="rgb24"
44
+ )
45
+ meta = next(reader)
46
+ w, h = meta.get("size", (IMAGE_WIDTH, IMAGE_HEIGHT))
47
+ frames = [np.frombuffer(f, np.uint8).reshape(h, w, 3) for f in reader]
48
+ if not frames:
49
+ raise ValueError("the run's video has no frames")
50
+ # The video and the trajectory are captured at the same instants; pad or trim if an encoder dropped one.
51
+ frames = frames[:count]
52
+ while len(frames) < count:
53
+ frames.append(frames[-1])
54
+ return frames
55
+
56
+
57
+ def features(joint_names: list[str], dof: int, actuator_names: list[str], with_video: bool) -> dict:
58
+ f = {
59
+ "observation.state": {"dtype": "float32", "shape": (len(joint_names),), "names": joint_names},
60
+ "observation.velocity": {"dtype": "float32", "shape": (dof,), "names": None},
61
+ "action": {"dtype": "float32", "shape": (len(actuator_names),), "names": actuator_names},
62
+ }
63
+ if with_video:
64
+ f["observation.images.main"] = {
65
+ "dtype": "video",
66
+ "shape": (IMAGE_HEIGHT, IMAGE_WIDTH, 3),
67
+ "names": ["height", "width", "channels"],
68
+ }
69
+ return f
70
+
71
+
72
+ def build_dataset(episodes: list[dict], repo_id: str, root: Path, robot: str | None, with_video: bool = True):
73
+ """episodes: [{"trajectory": Path, "video": Path | None, "task": str}] -> LeRobotDataset written to root."""
74
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
75
+
76
+ first = np.load(episodes[0]["trajectory"])
77
+ joint_names = [str(n) for n in first["joint_names"]]
78
+ actuator_names = [str(n) for n in first["actuator_names"]]
79
+ fps = int(first["fps"])
80
+ dof = int(first["qvel"].shape[1])
81
+ with_video = with_video and all(e.get("video") for e in episodes)
82
+ ds = LeRobotDataset.create(
83
+ repo_id=repo_id,
84
+ fps=fps,
85
+ features=features(joint_names, dof, actuator_names, with_video),
86
+ root=root,
87
+ robot_type=robot or "none",
88
+ use_videos=with_video,
89
+ vcodec="h264",
90
+ )
91
+ for ep in episodes:
92
+ traj = np.load(ep["trajectory"])
93
+ if [str(n) for n in traj["joint_names"]] != joint_names or int(traj["fps"]) != fps:
94
+ raise ValueError("all runs in one dataset must use the same robot (same joints) and frame rate")
95
+ n = len(traj["time"])
96
+ images = _video_frames(ep["video"], n) if with_video else None
97
+ for i in range(n):
98
+ frame = {
99
+ "observation.state": traj["qpos"][i].astype(np.float32),
100
+ "observation.velocity": traj["qvel"][i].astype(np.float32),
101
+ "action": traj["ctrl"][i].astype(np.float32),
102
+ "task": ep.get("task") or "simulation run",
103
+ }
104
+ if with_video:
105
+ frame["observation.images.main"] = images[i]
106
+ ds.add_frame(frame)
107
+ ds.save_episode()
108
+ ds.finalize()
109
+ return ds
110
+
111
+
112
+ def run_export_job(job_input: dict) -> dict:
113
+ """Worker entry point: download the runs, build the dataset, push it (or zip it)."""
114
+ started = time.monotonic()
115
+ repo_id = job_input["repo_id"]
116
+ with tempfile.TemporaryDirectory() as tmp:
117
+ tmp = Path(tmp)
118
+ episodes = []
119
+ for i, ep in enumerate(job_input["episodes"]):
120
+ traj = _fetch(ep["trajectory_url"], tmp / f"traj_{i}.npz")
121
+ video = _fetch(ep["video_url"], tmp / f"video_{i}.mp4") if ep.get("video_url") else None
122
+ episodes.append({"trajectory": traj, "video": video, "task": ep.get("task", "")})
123
+ root = tmp / "dataset"
124
+ ds = build_dataset(episodes, repo_id, root, job_input.get("robot"))
125
+ summary = {
126
+ "repo_id": repo_id,
127
+ "episodes": ds.meta.total_episodes,
128
+ "frames": ds.meta.total_frames,
129
+ "fps": ds.fps,
130
+ "features": sorted(ds.features),
131
+ "runs": [ep.get("run_id") for ep in job_input["episodes"]],
132
+ }
133
+ artifacts = {}
134
+ if os.environ.get("HF_TOKEN"):
135
+ ds.push_to_hub(private=job_input.get("private", True), tags=["rosdiff", "mujoco", "simulation"])
136
+ summary["hub_url"] = f"https://huggingface.co/datasets/{repo_id}"
137
+ summary["headline"] = f"{summary['episodes']} episode(s), {summary['frames']} frames → {repo_id} (private)"
138
+ else:
139
+ archive = shutil.make_archive(str(tmp / "dataset"), "zip", root)
140
+ size = Path(archive).stat().st_size
141
+ if size > MAX_ZIP_BYTES:
142
+ raise ValueError(f"dataset zip is {size / 1e6:.0f} MB; set HF_TOKEN to push it to the Hub instead")
143
+ artifacts["dataset.zip"] = Path(archive).read_bytes()
144
+ summary["headline"] = f"{summary['episodes']} episode(s), {summary['frames']} frames (zip; no HF_TOKEN)"
145
+ return {"summary": summary, "artifacts": artifacts, "worker_seconds": round(time.monotonic() - started, 1)}
worker/modal_app.py CHANGED
@@ -189,3 +189,34 @@ class Reviewer:
189
  def review_video(job_input: dict) -> dict:
190
  """Entry point the API spawns; hands the job to the (GPU, model-loaded) Reviewer."""
191
  return Reviewer().review.remote(job_input)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  def review_video(job_input: dict) -> dict:
190
  """Entry point the API spawns; hands the job to the (GPU, model-loaded) Reviewer."""
191
  return Reviewer().review.remote(job_input)
192
+
193
+
194
+ # --------------------------------------------------------------------------- LeRobot dataset export (CPU)
195
+
196
+ lerobot_image = (
197
+ modal.Image.debian_slim(python_version="3.11")
198
+ .apt_install("ffmpeg")
199
+ .pip_install("lerobot==0.4.4", "imageio-ffmpeg==0.6.0")
200
+ .add_local_python_source("worker")
201
+ )
202
+
203
+
204
+ @app.function(
205
+ image=lerobot_image,
206
+ cpu=2.0,
207
+ memory=4096,
208
+ timeout=900,
209
+ max_containers=1,
210
+ scaledown_window=20,
211
+ secrets=[modal.Secret.from_name(os.environ.get("MODAL_HF_SECRET", "rosdiff-hf"))],
212
+ )
213
+ def export_lerobot(job_input: dict) -> dict:
214
+ """Runs -> LeRobot dataset, pushed to the Hub with the secret's HF_TOKEN (else returned as a zip)."""
215
+ import traceback
216
+
217
+ from worker.lerobot_export import run_export_job
218
+
219
+ try:
220
+ return run_export_job(job_input)
221
+ except Exception as e:
222
+ return {"error": f"{type(e).__name__}: {e}", "traceback": traceback.format_exc()[-2000:]}
worker/sim_runner.py CHANGED
@@ -10,7 +10,8 @@ Outputs in OUT_DIR:
10
  /state (JSON: time, root height, fallen flag, contacts)
11
  video.mp4 H.264 render from a camera tracking the robot (skipped if rendering is unavailable)
12
  video.webm the same video as VP9, for browsers without H.264
13
- trajectory.npz the robot's joint positions/velocities and the controls at `fps` (for dataset export)
 
14
  summary.json what happened: duration, real-time factor, fall detection, warnings
15
  """
16
 
@@ -369,7 +370,7 @@ def simulate(scene: Path, out_dir: Path, config: RunConfig) -> dict:
369
 
370
  scene_ch.log(_scene_update(model), log_time=0)
371
  traj_q, traj_v, traj_names = _robot_state_index(model, root)
372
- traj: dict[str, list] = {"time": [], "qpos": [], "qvel": [], "ctrl": []}
373
  steps = max(1, int(math.ceil(config.duration_s / model.opt.timestep)))
374
  frame_dt = 1.0 / config.fps
375
  next_frame = 0.0
@@ -391,6 +392,8 @@ def simulate(scene: Path, out_dir: Path, config: RunConfig) -> dict:
391
  traj["qpos"].append(data.qpos[traj_q].copy())
392
  traj["qvel"].append(data.qvel[traj_v].copy())
393
  traj["ctrl"].append(data.ctrl.copy())
 
 
394
  if videos:
395
  try:
396
  renderer.update_scene(data, cam)
@@ -427,6 +430,12 @@ def simulate(scene: Path, out_dir: Path, config: RunConfig) -> dict:
427
  actuator_names=np.asarray(
428
  [mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, a) or f"actuator_{a}" for a in range(model.nu)]
429
  ),
 
 
 
 
 
 
430
  fps=np.asarray(config.fps),
431
  )
432
  _close_videos(videos)
 
10
  /state (JSON: time, root height, fallen flag, contacts)
11
  video.mp4 H.264 render from a camera tracking the robot (skipped if rendering is unavailable)
12
  video.webm the same video as VP9, for browsers without H.264
13
+ trajectory.npz at `fps`: the robot's joint positions/velocities and the controls (dataset export), and
14
+ every body's world pose (replay renders)
15
  summary.json what happened: duration, real-time factor, fall detection, warnings
16
  """
17
 
 
370
 
371
  scene_ch.log(_scene_update(model), log_time=0)
372
  traj_q, traj_v, traj_names = _robot_state_index(model, root)
373
+ traj: dict[str, list] = {"time": [], "qpos": [], "qvel": [], "ctrl": [], "body_pos": [], "body_quat": []}
374
  steps = max(1, int(math.ceil(config.duration_s / model.opt.timestep)))
375
  frame_dt = 1.0 / config.fps
376
  next_frame = 0.0
 
392
  traj["qpos"].append(data.qpos[traj_q].copy())
393
  traj["qvel"].append(data.qvel[traj_v].copy())
394
  traj["ctrl"].append(data.ctrl.copy())
395
+ traj["body_pos"].append(data.xpos.astype(np.float32))
396
+ traj["body_quat"].append(data.xquat.astype(np.float32))
397
  if videos:
398
  try:
399
  renderer.update_scene(data, cam)
 
430
  actuator_names=np.asarray(
431
  [mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, a) or f"actuator_{a}" for a in range(model.nu)]
432
  ),
433
+ body_pos=np.asarray(traj["body_pos"], np.float32), # world pose of every body (for replay renders)
434
+ body_quat=np.asarray(traj["body_quat"], np.float32),
435
+ body_names=np.asarray(
436
+ [mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, b) or f"body_{b}" for b in range(model.nbody)]
437
+ ),
438
+ body_parents=np.asarray(model.body_parentid, np.int32),
439
  fps=np.asarray(config.fps),
440
  )
441
  _close_videos(videos)