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

Add Isaac Sim RTX renders of finished runs

Browse files

POST /simulate/run/{id}/isaac replays a run in Isaac Sim: worker/usd_scene.py
builds a USD stage from the MuJoCo model with every body's pose time-sampled
from the run (and a camera that tracks the robot like the MuJoCo video), and
Isaac Sim only renders it frame by frame with RTX - no second physics engine, so
both videos show the same motion. Deployed as its own Modal app on an L4
(worker/modal_isaac.py) so its large image never slows the main deploy; limited
to ISAAC_DAILY_LIMIT renders a day (default 10). The USD stage is tested against
MuJoCo's geom positions frame by frame; the Isaac Sim step itself needs the NGC
image and a GPU and has not been run yet.

api/jobs.py CHANGED
@@ -258,5 +258,21 @@ def _auto_review(s: Services, run: dict) -> None:
258
  log.info("automatic review of run %s skipped: %s", run["id"], e)
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  # Follow-ups run once when a job of that kind completes.
262
  ON_COMPLETE: dict[str, list] = {"sim": [_auto_review]}
 
258
  log.info("automatic review of run %s skipped: %s", run["id"], e)
259
 
260
 
261
+ def start_isaac(s: Services, run: dict) -> dict:
262
+ """Render a finished run again with Isaac Sim RTX (a kinematic replay of MuJoCo's motion)."""
263
+ from .runs import new_run_id
264
+
265
+ files = (run.get("output") or {}).get("files") or {}
266
+ if run["status"] != "COMPLETED" or "trajectory" not in files or "scene" not in files:
267
+ raise JobError(422, "only a completed run (with its scene and trajectory) can be rendered")
268
+ mjcf = s.storage.read_text(files["scene"])
269
+ if not mjcf:
270
+ raise JobError(422, "the run's scene file is no longer available")
271
+ job_input = {"mjcf": mjcf, "robot": run.get("robot"), "trajectory_url": s.storage.url(files["trajectory"])}
272
+ return submit(
273
+ s, run["tester"], "isaac", job_input, run_id=new_run_id(), parent_id=run["id"], robot=run.get("robot")
274
+ )
275
+
276
+
277
  # Follow-ups run once when a job of that kind completes.
278
  ON_COMPLETE: dict[str, list] = {"sim": [_auto_review]}
api/main.py CHANGED
@@ -28,7 +28,7 @@ from rag.store import RagStore
28
  from .auth import Allowlist, Tester
29
  from .codegen import DirectBackend, OpenCodeBackend, generate_code
30
  from .config import Settings, get_settings
31
- from .jobs import JobError, month_spend, refresh, start_review
32
  from .jobs import submit as submit_job
33
  from .llm import LLM, ChatClient, LLMError
34
  from .logstore import LogStore
@@ -481,6 +481,19 @@ def create_app(services: Services | None = None) -> FastAPI:
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."""
 
28
  from .auth import Allowlist, Tester
29
  from .codegen import DirectBackend, OpenCodeBackend, generate_code
30
  from .config import Settings, get_settings
31
+ from .jobs import JobError, month_spend, refresh, start_isaac, start_review
32
  from .jobs import submit as submit_job
33
  from .llm import LLM, ChatClient, LLMError
34
  from .logstore import LogStore
 
481
  )
482
  return _job_response(request, s, job)
483
 
484
+ @app.post("/simulate/run/{run_id}/isaac", response_model=JobResponse)
485
+ def isaac_render(run_id: str, request: Request, t: Tester = Depends(tester), s: Services = Depends(svc)):
486
+ """Re-render a finished run photorealistically with Isaac Sim (limited to ISAAC_DAILY_LIMIT per day)."""
487
+ run = s.logs.get_run(run_id)
488
+ if not run or run["tester"] != t.name or (run.get("kind") or "sim") != "sim":
489
+ raise HTTPException(status_code=404, detail="no simulation run with that id for this key")
490
+ run = refresh(s, run)
491
+ try:
492
+ job = start_isaac(s, run)
493
+ except JobError as e:
494
+ raise HTTPException(status_code=e.status, detail=str(e)) from e
495
+ return _job_response(request, s, job)
496
+
497
  @app.post("/code/check", response_model=JobResponse)
498
  def code_check(req: CheckRequest, request: Request, t: Tester = Depends(tester), s: Services = Depends(svc)):
499
  """Build, lint, test and launch a generated package in the ROS 2 sandbox. Returns at once; see report_url."""
api/modal_client.py CHANGED
@@ -10,6 +10,8 @@ from .sim_backend import BackendError
10
 
11
  class ModalClient:
12
  name = "modal"
 
 
13
 
14
  def __init__(self, app_name: str, modal_module: ModuleType | None = None):
15
  if modal_module is None:
@@ -26,7 +28,7 @@ class ModalClient:
26
  def submit(self, job_input: dict, execution_timeout_s: int, function: str) -> dict:
27
  """Start the job and return at once. Time limits are set on the deployed functions (see modal_app.py)."""
28
  try:
29
- fn = self._modal.Function.from_name(self.app_name, function)
30
  call = fn.spawn(job_input)
31
  except Exception as e:
32
  raise BackendError(f"Modal could not start {self.app_name}/{function}: {type(e).__name__}: {e}") from e
 
10
 
11
  class ModalClient:
12
  name = "modal"
13
+ # Functions deployed as their own app (worker/modal_isaac.py), so the big image never slows the main deploy.
14
+ APP_SUFFIX = {"isaac_render": "-isaac"}
15
 
16
  def __init__(self, app_name: str, modal_module: ModuleType | None = None):
17
  if modal_module is None:
 
28
  def submit(self, job_input: dict, execution_timeout_s: int, function: str) -> dict:
29
  """Start the job and return at once. Time limits are set on the deployed functions (see modal_app.py)."""
30
  try:
31
+ fn = self._modal.Function.from_name(self.app_name + self.APP_SUFFIX.get(function, ""), function)
32
  call = fn.spawn(job_input)
33
  except Exception as e:
34
  raise BackendError(f"Modal could not start {self.app_name}/{function}: {type(e).__name__}: {e}") from e
api/runs.py CHANGED
@@ -443,3 +443,27 @@ def _export_body(job: dict, storage: Storage | None) -> list[str]:
443
 
444
 
445
  KIND_BODIES["export"] = _export_body
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
 
445
  KIND_BODIES["export"] = _export_body
446
+
447
+
448
+ def _isaac_body(job: dict, storage: Storage | None) -> list[str]:
449
+ output = job.get("output") or {}
450
+ r = output.get("summary") or {}
451
+ files = output.get("files") or {}
452
+ cards = [f'<div class="card"><b>{html.escape(r.get("headline", ""))}</b></div>']
453
+ if storage is not None and "isaac.mp4" in files:
454
+ url = html.escape(storage.url(files["isaac.mp4"]))
455
+ cards.append(
456
+ f'<div class="card"><b>Isaac Sim render</b><video controls autoplay muted loop playsinline>'
457
+ f'<source src="{url}" type="video/mp4"></video></div>'
458
+ )
459
+ cells = [("Renderer", r.get("renderer")), ("Frames", r.get("frames")), ("Length", f"{r.get('seconds')} s")]
460
+ grid = "".join(f"<div><span>{html.escape(k)}</span>{_fmt(v)}</div>" for k, v in cells)
461
+ cards.append(f'<div class="card"><div class="grid">{grid}</div></div>')
462
+ cards.append(
463
+ '<div class="card muted">A replay: MuJoCo simulated the run, Isaac Sim draws the same motion with RTX '
464
+ "lighting (no second physics engine), so the two videos show the same thing.</div>"
465
+ )
466
+ return cards
467
+
468
+
469
+ KIND_BODIES["isaac"] = _isaac_body
requirements-dev.txt CHANGED
@@ -4,3 +4,4 @@
4
  pytest>=8
5
  ruff>=0.6
6
  mcap>=1.1
 
 
4
  pytest>=8
5
  ruff>=0.6
6
  mcap>=1.1
7
+ usd-core>=25.2 # tests for the Isaac Sim replay stage (worker/usd_scene.py)
tests/test_jobs.py CHANGED
@@ -95,3 +95,46 @@ def test_follow_up_jobs_listed_on_parent_report(run_env):
95
  url = client.get(f"/jobs/{run_id}", headers={"X-API-Key": keys_["alice"]}).json()["report_url"]
96
  page = client.get(url.split("testserver")[-1]).text
97
  assert "Follow-up jobs" in page and "Robustness sweep" in page and "sw1" in page
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  url = client.get(f"/jobs/{run_id}", headers={"X-API-Key": keys_["alice"]}).json()["report_url"]
96
  page = client.get(url.split("testserver")[-1]).text
97
  assert "Follow-up jobs" in page and "Robustness sweep" in page and "sw1" in page
98
+
99
+
100
+ def test_isaac_render_endpoint(run_env):
101
+ from dataclasses import replace
102
+
103
+ client, keys_, backend, services, gen_id = run_env
104
+ h = {"X-API-Key": keys_["alice"]}
105
+ run_id = start_run(client, keys_["alice"], gen_id)
106
+ assert client.post(f"/simulate/run/{run_id}/isaac", headers=h).status_code == 422 # not finished yet
107
+ complete(backend, "job-1", run_id)
108
+ out = backend.jobs["job-1"]["output"]["files"]
109
+ out["trajectory"] = f"runs/{run_id}/trajectory.npz"
110
+ out["scene"] = f"runs/{run_id}/scene.xml"
111
+ (services.storage.root / out["scene"]).parent.mkdir(parents=True, exist_ok=True)
112
+ (services.storage.root / out["scene"]).write_text("<mujoco/>")
113
+ r = client.post(f"/simulate/run/{run_id}/isaac", headers=h)
114
+ assert r.status_code == 200 and r.json()["kind"] == "isaac" and backend.functions[-1] == "isaac_render"
115
+ assert backend.submitted[-1]["mjcf"] == "<mujoco/>" and backend.submitted[-1]["trajectory_url"].endswith(".npz")
116
+ services.settings = replace(services.settings, isaac_daily_limit=1)
117
+ assert client.post(f"/simulate/run/{run_id}/isaac", headers=h).status_code == 429
118
+
119
+
120
+ def test_modal_routes_isaac_to_its_own_app():
121
+ from api.modal_client import ModalClient
122
+
123
+ seen = []
124
+
125
+ class Fake:
126
+ class Function:
127
+ @staticmethod
128
+ def from_name(app, fn):
129
+ seen.append((app, fn))
130
+
131
+ class F:
132
+ def spawn(self, _):
133
+ return type("C", (), {"object_id": "fc"})()
134
+
135
+ return F()
136
+
137
+ client = ModalClient("rosdiff-sim", modal_module=Fake)
138
+ client.submit({}, 60, "isaac_render")
139
+ client.submit({}, 60, "sweep")
140
+ assert seen == [("rosdiff-sim-isaac", "isaac_render"), ("rosdiff-sim", "sweep")]
tests/test_runs.py CHANGED
@@ -524,3 +524,17 @@ def test_modal_app_definition(monkeypatch):
524
  assert mod.GPU is None and mod.GL == "osmesa" and mod.app.name == "rosdiff-sim"
525
  assert isinstance(mod.run_simulation, modal.Function)
526
  sys.modules.pop("worker.modal_app", None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  assert mod.GPU is None and mod.GL == "osmesa" and mod.app.name == "rosdiff-sim"
525
  assert isinstance(mod.run_simulation, modal.Function)
526
  sys.modules.pop("worker.modal_app", None)
527
+
528
+
529
+ def test_modal_isaac_app_definition():
530
+ import importlib
531
+ import sys
532
+
533
+ import modal
534
+
535
+ sys.modules.pop("worker.modal_isaac", None)
536
+ mod = importlib.import_module("worker.modal_isaac")
537
+ assert mod.app.name == "rosdiff-sim-isaac" and isinstance(mod.isaac_render, modal.Function)
538
+ main = importlib.import_module("worker.modal_app")
539
+ for fn in ("run_simulation", "sweep", "ros_check_humble", "ros_check_jazzy", "review_video", "export_lerobot"):
540
+ assert isinstance(getattr(main, fn), modal.Function), fn
tests/test_usd.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import mujoco
2
+ import numpy as np
3
+ import pytest
4
+ from conftest import GOOD_SCENE
5
+
6
+ pytest.importorskip("pxr")
7
+ from pxr import Usd, UsdGeom # noqa: E402
8
+
9
+ from api.mjcf_check import prepare_scene_dir # noqa: E402
10
+ from worker.usd_scene import build_stage # noqa: E402
11
+
12
+
13
+ def test_replay_stage_matches_mujoco(tmp_path, menagerie):
14
+ """Every drawn geom's world position in the USD equals MuJoCo's geom_xpos, frame by frame."""
15
+ scene_dir = prepare_scene_dir(GOOD_SCENE, "unitree_g1", menagerie)
16
+ model = mujoco.MjModel.from_xml_path(str(scene_dir / "scene.xml"))
17
+ data = mujoco.MjData(model)
18
+ data.qvel[:] = np.random.default_rng(0).normal(0, 1, model.nv) # tumble things around
19
+ pos, quat, geom_xpos = [], [], []
20
+ for _ in range(20):
21
+ for _ in range(10):
22
+ mujoco.mj_step(model, data)
23
+ pos.append(data.xpos.copy())
24
+ quat.append(data.xquat.copy())
25
+ geom_xpos.append(data.geom_xpos.copy())
26
+ names = [mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, b) or f"body_{b}" for b in range(model.nbody)]
27
+ np.savez(tmp_path / "traj.npz", body_pos=pos, body_quat=quat, body_names=names, fps=30)
28
+
29
+ info = build_stage(scene_dir / "scene.xml", tmp_path / "traj.npz", tmp_path / "scene.usdc", "pelvis")
30
+ assert info["frames"] == 20 and info["geoms_drawn"] >= 3
31
+ stage = Usd.Stage.Open(str(tmp_path / "scene.usdc"))
32
+ checked = 0
33
+ for prim in stage.Traverse():
34
+ name = prim.GetName()
35
+ if not (name.startswith("g") and name[1:].isdigit()):
36
+ continue
37
+ g = int(name[1:])
38
+ if model.geom_type[g] == mujoco.mjtGeom.mjGEOM_PLANE:
39
+ continue
40
+ for i in (0, 9, 19):
41
+ m = UsdGeom.Xformable(prim).ComputeLocalToWorldTransform(Usd.TimeCode(i))
42
+ centre = np.array(m.Transform((0, 0, 0))) if model.geom_type[g] != mujoco.mjtGeom.mjGEOM_MESH else None
43
+ if centre is not None:
44
+ assert np.allclose(centre, geom_xpos[i][g], atol=1e-4), (g, i)
45
+ checked += 1
46
+ assert checked >= 6
47
+ cam = UsdGeom.Xformable(stage.GetPrimAtPath("/World/Camera"))
48
+ eye = np.array(cam.ComputeLocalToWorldTransform(Usd.TimeCode(0)).ExtractTranslation())
49
+ assert eye[2] > pos[0][1][2] # looking down at the robot
worker/isaac_render.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render a replay stage (worker/usd_scene.py) with Isaac Sim's RTX renderer, one PNG per frame.
2
+
3
+ Runs inside Isaac Sim's own Python (the container's /isaac-sim/python.sh), not ours:
4
+
5
+ /isaac-sim/python.sh worker/isaac_render.py --usd scene.usdc --out frames/ --frames 300 --fps 30
6
+
7
+ Nothing is simulated here: the stage already holds every body's pose per frame, so each frame only sets the
8
+ timeline to that frame's time and renders the camera. Standard library + Isaac Sim + Pillow only.
9
+ """
10
+
11
+ import argparse
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ def main(argv):
17
+ p = argparse.ArgumentParser()
18
+ p.add_argument("--usd", required=True)
19
+ p.add_argument("--out", required=True)
20
+ p.add_argument("--frames", type=int, required=True)
21
+ p.add_argument("--fps", type=int, default=30)
22
+ p.add_argument("--width", type=int, default=1280)
23
+ p.add_argument("--height", type=int, default=720)
24
+ p.add_argument("--subframes", type=int, default=4, help="RTX accumulation per frame (quality vs time)")
25
+ args = p.parse_args(argv)
26
+ out = Path(args.out)
27
+ out.mkdir(parents=True, exist_ok=True)
28
+
29
+ from isaacsim import SimulationApp
30
+
31
+ app = SimulationApp({"headless": True, "width": args.width, "height": args.height})
32
+ try:
33
+ import omni.replicator.core as rep
34
+ import omni.timeline
35
+ import omni.usd
36
+ from PIL import Image
37
+
38
+ if not omni.usd.get_context().open_stage(args.usd):
39
+ raise RuntimeError(f"Isaac Sim could not open {args.usd}")
40
+ for _ in range(30): # let the stage, materials and shaders load
41
+ app.update()
42
+ product = rep.create.render_product("/World/Camera", (args.width, args.height))
43
+ rgb = rep.AnnotatorRegistry.get_annotator("rgb")
44
+ rgb.attach([product])
45
+ timeline = omni.timeline.get_timeline_interface()
46
+ timeline.set_current_time(0.0)
47
+ rep.orchestrator.step(rt_subframes=16, delta_time=0.0, pause_timeline=True) # warm-up frame
48
+ for i in range(args.frames):
49
+ timeline.set_current_time(i / args.fps)
50
+ rep.orchestrator.step(rt_subframes=args.subframes, delta_time=0.0, pause_timeline=True)
51
+ data = rgb.get_data()
52
+ Image.fromarray(data[:, :, :3]).save(out / f"f{i:05d}.png")
53
+ if i % 30 == 0:
54
+ print(f"[isaac_render] frame {i}/{args.frames}", flush=True)
55
+ rgb.detach()
56
+ product.destroy()
57
+ finally:
58
+ app.close()
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main(sys.argv[1:]))
worker/modal_isaac.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Isaac Sim renders on Modal (L4, RTX), deployed as its own app so the ~20 GB image never slows down the
2
+ main worker deploy:
3
+
4
+ modal deploy -m worker.modal_isaac # app "<MODAL_APP>-isaac", function isaac_render
5
+
6
+ Isaac Sim's container comes from NVIDIA NGC (ISAAC_IMAGE). If your NGC account needs a login to pull it,
7
+ create a Modal secret with REGISTRY_USERNAME=$oauthtoken and REGISTRY_PASSWORD=<NGC API key> and name it in
8
+ MODAL_NGC_SECRET. Using the container means accepting the NVIDIA Isaac Sim license (ACCEPT_EULA below).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import tempfile
16
+ import time
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ import modal
21
+
22
+ APP_NAME = os.environ.get("MODAL_APP", "rosdiff-sim") + "-isaac"
23
+ ISAAC_IMAGE = os.environ.get("ISAAC_IMAGE", "nvcr.io/nvidia/isaac-sim:5.0.0")
24
+ MENAGERIE_DIR = "/opt/mujoco_menagerie"
25
+ ROBOT_DIRS = ("unitree_g1", "unitree_h1", "unitree_go2", "franka_emika_panda") # api.robots.ROBOTS
26
+ NGC_SECRET = os.environ.get("MODAL_NGC_SECRET")
27
+
28
+ image = (
29
+ modal.Image.from_registry(
30
+ ISAAC_IMAGE, secret=modal.Secret.from_name(NGC_SECRET) if NGC_SECRET else None, add_python="3.11"
31
+ )
32
+ .entrypoint([])
33
+ .env({"ACCEPT_EULA": "Y", "PRIVACY_CONSENT": "Y", "OMNI_KIT_ACCEPT_EULA": "YES", "MENAGERIE_DIR": MENAGERIE_DIR})
34
+ .run_commands(
35
+ "git clone --depth 1 --filter=blob:none --sparse https://github.com/google-deepmind/mujoco_menagerie.git "
36
+ f"{MENAGERIE_DIR} && cd {MENAGERIE_DIR} && git sparse-checkout set {' '.join(ROBOT_DIRS)} && rm -rf .git"
37
+ )
38
+ .pip_install("mujoco==3.14.0", "numpy>=1.26", "usd-core>=25.2", "imageio-ffmpeg==0.6.0")
39
+ .add_local_python_source("api", "worker")
40
+ )
41
+
42
+ app = modal.App(APP_NAME)
43
+
44
+ MAX_SECONDS = 20.0 # longest replay rendered (cost control)
45
+
46
+
47
+ @app.function(image=image, gpu="L4", timeout=1800, max_containers=1, scaledown_window=20)
48
+ def isaac_render(job_input: dict) -> dict:
49
+ import traceback
50
+
51
+ try:
52
+ return _render(job_input)
53
+ except Exception as e:
54
+ return {"error": f"{type(e).__name__}: {e}", "traceback": traceback.format_exc()[-2000:]}
55
+
56
+
57
+ def _render(job_input: dict) -> dict:
58
+ import imageio_ffmpeg
59
+
60
+ from api.mjcf_check import SCENE_FILE, prepare_scene_dir, static_check
61
+ from worker.sim_runner import config_for
62
+ from worker.usd_scene import build_stage
63
+
64
+ started = time.monotonic()
65
+ mjcf, robot = job_input["mjcf"], job_input.get("robot")
66
+ failed = static_check(mjcf, robot)
67
+ if failed:
68
+ raise ValueError(f"scene rejected: {failed.error}")
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ tmp = Path(tmp)
71
+ scene_dir = prepare_scene_dir(mjcf, robot, Path(MENAGERIE_DIR))
72
+ traj = tmp / "trajectory.npz"
73
+ with urllib.request.urlopen(job_input["trajectory_url"], timeout=120) as r: # noqa: S310
74
+ traj.write_bytes(r.read())
75
+ cfg = config_for(robot, 1.0, "hold", render=False)
76
+ info = build_stage(
77
+ scene_dir / SCENE_FILE, traj, tmp / "scene.usdc", cfg.root_body, cfg.camera_distance, MAX_SECONDS
78
+ )
79
+ frames = tmp / "frames"
80
+ cmd = [
81
+ "/isaac-sim/python.sh",
82
+ str(Path(__file__).with_name("isaac_render.py")),
83
+ "--usd",
84
+ str(tmp / "scene.usdc"),
85
+ "--out",
86
+ str(frames),
87
+ "--frames",
88
+ str(info["frames"]),
89
+ "--fps",
90
+ str(info["fps"]),
91
+ ]
92
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1500)
93
+ pngs = sorted(frames.glob("f*.png")) if frames.is_dir() else []
94
+ if len(pngs) < info["frames"]:
95
+ raise RuntimeError(f"Isaac Sim rendered {len(pngs)}/{info['frames']} frames: {proc.stderr[-1500:]}")
96
+ video = tmp / "isaac.mp4"
97
+ subprocess.run(
98
+ [
99
+ imageio_ffmpeg.get_ffmpeg_exe(),
100
+ "-loglevel",
101
+ "error",
102
+ "-framerate",
103
+ str(info["fps"]),
104
+ "-i",
105
+ str(frames / "f%05d.png"),
106
+ "-c:v",
107
+ "libx264",
108
+ "-pix_fmt",
109
+ "yuv420p",
110
+ "-crf",
111
+ "20",
112
+ "-movflags",
113
+ "+faststart",
114
+ str(video),
115
+ ],
116
+ check=True,
117
+ timeout=600,
118
+ )
119
+ summary = {
120
+ "renderer": f"Isaac Sim RTX ({ISAAC_IMAGE.rsplit(':', 1)[-1]})",
121
+ "frames": info["frames"],
122
+ "seconds": round(info["frames"] / info["fps"], 2),
123
+ "geoms_drawn": info["geoms_drawn"],
124
+ "headline": f"{info['frames']} frames rendered with Isaac Sim RTX",
125
+ }
126
+ return {
127
+ "summary": summary,
128
+ "artifacts": {"isaac.mp4": video.read_bytes()},
129
+ "worker_seconds": round(time.monotonic() - started, 1),
130
+ }
worker/usd_scene.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build an animated USD stage from a MuJoCo scene and a run's trajectory, for rendering in Isaac Sim.
2
+
3
+ The stage is a kinematic replay: every MuJoCo body becomes an Xform under /World/Bodies whose world pose is
4
+ time-sampled at the trajectory's frame rate, with its visible geoms (MuJoCo's viewer groups 0-2) as children.
5
+ A camera, time-sampled to follow the robot like the MuJoCo video, sits at /World/Camera. No physics runs in
6
+ Isaac Sim; it only renders what MuJoCo simulated, so both videos show exactly the same motion.
7
+
8
+ Pure `pxr` (usd-core) + mujoco, so it can be built and tested anywhere; Isaac Sim just opens the file.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from pathlib import Path
15
+
16
+ import mujoco
17
+ import numpy as np
18
+
19
+ VISIBLE_GROUPS = (0, 1, 2)
20
+ PLANE_SIZE = 40.0 # metres, for MuJoCo's infinite planes
21
+
22
+
23
+ def _name(s: str) -> str:
24
+ """A valid USD prim name."""
25
+ out = "".join(c if c.isalnum() or c == "_" else "_" for c in s)
26
+ return out if out and not out[0].isdigit() else f"_{out}"
27
+
28
+
29
+ def _quat(q):
30
+ from pxr import Gf
31
+
32
+ return Gf.Quatf(float(q[0]), Gf.Vec3f(float(q[1]), float(q[2]), float(q[3])))
33
+
34
+
35
+ def _material(stage, rgba, cache: dict):
36
+ from pxr import Gf, Sdf, UsdShade
37
+
38
+ key = tuple(round(float(c), 3) for c in rgba)
39
+ if key in cache:
40
+ return cache[key]
41
+ path = f"/World/Looks/m{len(cache)}"
42
+ mat = UsdShade.Material.Define(stage, path)
43
+ shader = UsdShade.Shader.Define(stage, f"{path}/Shader")
44
+ shader.CreateIdAttr("UsdPreviewSurface")
45
+ shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(*key[:3]))
46
+ shader.CreateInput("opacity", Sdf.ValueTypeNames.Float).Set(key[3])
47
+ shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(0.6)
48
+ mat.CreateSurfaceOutput().ConnectToSource(shader.ConnectableAPI(), "surface")
49
+ cache[key] = mat
50
+ return mat
51
+
52
+
53
+ def _geom_prim(stage, model: mujoco.MjModel, g: int, parent: str):
54
+ """One USD prim for MuJoCo geom g, placed in its body's frame. Returns None for types we do not draw."""
55
+ from pxr import Gf, UsdGeom, Vt
56
+
57
+ T = mujoco.mjtGeom
58
+ gtype, size = model.geom_type[g], model.geom_size[g]
59
+ path = f"{parent}/g{g}"
60
+ if gtype == T.mjGEOM_PLANE:
61
+ prim = UsdGeom.Cube.Define(stage, path)
62
+ prim.CreateSizeAttr(1.0)
63
+ sx = size[0] * 2 if size[0] > 0 else PLANE_SIZE
64
+ sy = size[1] * 2 if size[1] > 0 else PLANE_SIZE
65
+ scale = Gf.Vec3f(sx, sy, 0.002)
66
+ offset = Gf.Vec3d(0, 0, -0.001)
67
+ elif gtype == T.mjGEOM_BOX:
68
+ prim = UsdGeom.Cube.Define(stage, path)
69
+ prim.CreateSizeAttr(2.0)
70
+ scale, offset = Gf.Vec3f(*map(float, size)), None
71
+ elif gtype in (T.mjGEOM_SPHERE, T.mjGEOM_ELLIPSOID):
72
+ prim = UsdGeom.Sphere.Define(stage, path)
73
+ prim.CreateRadiusAttr(1.0)
74
+ r = size if gtype == T.mjGEOM_ELLIPSOID else (size[0],) * 3
75
+ scale, offset = Gf.Vec3f(*map(float, r)), None
76
+ elif gtype == T.mjGEOM_CAPSULE:
77
+ prim = UsdGeom.Capsule.Define(stage, path)
78
+ prim.CreateRadiusAttr(float(size[0]))
79
+ prim.CreateHeightAttr(float(2 * size[1]))
80
+ prim.CreateAxisAttr("Z")
81
+ scale, offset = None, None
82
+ elif gtype == T.mjGEOM_CYLINDER:
83
+ prim = UsdGeom.Cylinder.Define(stage, path)
84
+ prim.CreateRadiusAttr(float(size[0]))
85
+ prim.CreateHeightAttr(float(2 * size[1]))
86
+ prim.CreateAxisAttr("Z")
87
+ scale, offset = None, None
88
+ elif gtype == T.mjGEOM_MESH:
89
+ m = int(model.geom_dataid[g])
90
+ va, vn = model.mesh_vertadr[m], model.mesh_vertnum[m]
91
+ fa, fn = model.mesh_faceadr[m], model.mesh_facenum[m]
92
+ prim = UsdGeom.Mesh.Define(stage, path)
93
+ prim.CreatePointsAttr(Vt.Vec3fArray.FromNumpy(model.mesh_vert[va : va + vn].astype(np.float32)))
94
+ prim.CreateFaceVertexCountsAttr(Vt.IntArray([3] * int(fn)))
95
+ prim.CreateFaceVertexIndicesAttr(
96
+ Vt.IntArray.FromNumpy(model.mesh_face[fa : fa + fn].reshape(-1).astype(np.int32))
97
+ )
98
+ prim.CreateSubdivisionSchemeAttr("none")
99
+ scale, offset = None, None
100
+ else: # heightfields, SDFs: not drawn (they are in the MuJoCo video)
101
+ return None
102
+ xf = UsdGeom.Xformable(prim)
103
+ pos = Gf.Vec3d(*map(float, model.geom_pos[g]))
104
+ if offset is not None:
105
+ pos += offset
106
+ xf.AddTranslateOp().Set(pos)
107
+ xf.AddOrientOp().Set(_quat(model.geom_quat[g]))
108
+ if scale is not None:
109
+ xf.AddScaleOp().Set(scale)
110
+ return prim
111
+
112
+
113
+ def _camera_pose(target: np.ndarray, azimuth: float, elevation: float, distance: float):
114
+ """Eye position for MuJoCo's free/tracking camera convention (degrees)."""
115
+ az, el = math.radians(azimuth), math.radians(elevation)
116
+ forward = np.array([math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el)])
117
+ return target - distance * forward
118
+
119
+
120
+ def build_stage(
121
+ scene: Path,
122
+ trajectory: Path,
123
+ out: Path,
124
+ root_body: str | None,
125
+ camera_distance: float = 4.5,
126
+ max_seconds: float | None = None,
127
+ ) -> dict:
128
+ """Write out (a .usda/.usdc) and return a short description of what is in it."""
129
+ from pxr import Gf, Usd, UsdGeom, UsdLux, UsdShade
130
+
131
+ model = mujoco.MjModel.from_xml_path(str(scene))
132
+ traj = np.load(trajectory)
133
+ fps = int(traj["fps"])
134
+ pos, quat = traj["body_pos"], traj["body_quat"]
135
+ names = [str(n) for n in traj["body_names"]]
136
+ if len(names) != model.nbody:
137
+ raise ValueError("the trajectory does not belong to this scene (body count differs)")
138
+ frames = len(pos) if max_seconds is None else min(len(pos), int(max_seconds * fps) + 1)
139
+
140
+ stage = Usd.Stage.CreateNew(str(out)) # .usdc: binary crate file (much smaller than .usda)
141
+ UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z)
142
+ UsdGeom.SetStageMetersPerUnit(stage, 1.0)
143
+ stage.SetTimeCodesPerSecond(fps)
144
+ stage.SetFramesPerSecond(fps)
145
+ stage.SetStartTimeCode(0)
146
+ stage.SetEndTimeCode(frames - 1)
147
+ world = UsdGeom.Xform.Define(stage, "/World")
148
+ stage.SetDefaultPrim(world.GetPrim())
149
+ UsdGeom.Scope.Define(stage, "/World/Looks")
150
+ UsdGeom.Scope.Define(stage, "/World/Bodies")
151
+
152
+ materials: dict = {}
153
+ drawn = 0
154
+ for b in range(model.nbody):
155
+ geoms = [g for g in range(model.ngeom) if model.geom_bodyid[g] == b and model.geom_group[g] in VISIBLE_GROUPS]
156
+ if not geoms:
157
+ continue
158
+ path = f"/World/Bodies/{_name(names[b])}_{b}"
159
+ body = UsdGeom.Xform.Define(stage, path)
160
+ t_op, o_op = body.AddTranslateOp(), body.AddOrientOp()
161
+ if b == 0: # the world body never moves
162
+ t_op.Set(Gf.Vec3d(0, 0, 0))
163
+ o_op.Set(Gf.Quatf(1, 0, 0, 0))
164
+ else:
165
+ for i in range(frames):
166
+ t_op.Set(Gf.Vec3d(*map(float, pos[i, b])), Usd.TimeCode(i))
167
+ o_op.Set(_quat(quat[i, b]), Usd.TimeCode(i))
168
+ for g in geoms:
169
+ prim = _geom_prim(stage, model, g, path)
170
+ if prim is None:
171
+ continue
172
+ rgba = model.mat_rgba[model.geom_matid[g]] if model.geom_matid[g] >= 0 else model.geom_rgba[g]
173
+ UsdShade.MaterialBindingAPI.Apply(prim.GetPrim()).Bind(_material(stage, rgba, materials))
174
+ drawn += 1
175
+
176
+ sun = UsdLux.DistantLight.Define(stage, "/World/Sun")
177
+ sun.CreateIntensityAttr(3000)
178
+ sun.CreateAngleAttr(1.0)
179
+ UsdGeom.Xformable(sun).AddRotateXYZOp().Set(Gf.Vec3f(35, 0, 20))
180
+ dome = UsdLux.DomeLight.Define(stage, "/World/Sky")
181
+ dome.CreateIntensityAttr(600)
182
+
183
+ cam = UsdGeom.Camera.Define(stage, "/World/Camera")
184
+ cam.CreateFocalLengthAttr(18.0)
185
+ cam.CreateClippingRangeAttr(Gf.Vec2f(0.05, 200.0))
186
+ xf = cam.AddTransformOp()
187
+ root = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, root_body) if root_body else -1
188
+ for i in range(frames):
189
+ target = pos[i, root] if root > 0 else np.asarray(model.stat.center)
190
+ distance = camera_distance if root > 0 else 1.5 * float(model.stat.extent)
191
+ eye = _camera_pose(target, 20.0, -22.0, distance)
192
+ view = Gf.Matrix4d().SetLookAt(Gf.Vec3d(*map(float, eye)), Gf.Vec3d(*map(float, target)), Gf.Vec3d(0, 0, 1))
193
+ xf.Set(view.GetInverse(), Usd.TimeCode(i))
194
+ if root <= 0:
195
+ break # static camera
196
+ stage.GetRootLayer().Save()
197
+ return {"frames": frames, "fps": fps, "bodies": model.nbody, "geoms_drawn": drawn, "materials": len(materials)}
198
+
199
+
200
+ __all__ = ["build_stage"]