Spaces:
Paused
Paused
Chandra Kiran commited on
Add Isaac Sim RTX renders of finished runs
Browse filesPOST /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 +16 -0
- api/main.py +14 -1
- api/modal_client.py +3 -1
- api/runs.py +24 -0
- requirements-dev.txt +1 -0
- tests/test_jobs.py +43 -0
- tests/test_runs.py +14 -0
- tests/test_usd.py +49 -0
- worker/isaac_render.py +63 -0
- worker/modal_isaac.py +130 -0
- worker/usd_scene.py +200 -0
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"]
|