Spaces:
Paused
Paused
Chandra Kiran commited on
Export runs as LeRobot datasets
Browse filesPOST /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 +50 -0
- api/config.py +2 -0
- api/main.py +45 -0
- api/runs.py +31 -0
- tests/test_export.py +45 -0
- tests/test_runs.py +2 -0
- worker/lerobot_export.py +145 -0
- worker/modal_app.py +31 -0
- worker/sim_runner.py +11 -2
.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
|
|
|
|
| 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)
|