From 3c17ff65abb768a3efa189aef23823584f64c4e8 Mon Sep 17 00:00:00 2001 From: TATP-233 Date: Wed, 23 Sep 2026 22:25:05 +0800 Subject: [PATCH] feat: make viser a required dependency and support viser playback on all playback-capable backends - move viser/trimesh into core dependencies (drop the viser extra) - extract the viser viewer loop into visualization/viser_playback.py so train post-playback (training.play_render_mode=viser) and eval share it - cli: route eval --render-mode viser to play_viser.py for any backend with physics-state playback (mujoco/mjwarp/newton/drake/superdex); fail closed with a targeted message for motrix/genesis/isaacgym/isaacsim, which need upstream unisim capability work (unilabsim/unisim#302) - viser scene: filter geom groups to MuJoCo's default visible set (0-2) so group-3 collision markers stay hidden; visible_geom_groups overrides it - update docs (en/zh_CN) and tests --- .../3-evaluation_and_playback.md | 5 +- .../1-training/1-cli_reference.md | 10 +- .../en/2-user_guide/3-backends/0-index.md | 3 +- .../source/en/5-reference/5-support_matrix.md | 3 +- .../3-evaluation_and_playback.md | 2 +- .../1-training/1-cli_reference.md | 7 +- .../zh_CN/2-user_guide/3-backends/0-index.md | 4 +- .../zh_CN/5-reference/5-support_matrix.md | 2 +- pyproject.rocm.toml | 39 +- pyproject.toml | 110 +----- scripts/play_viser.py | 6 +- src/unilab/cli.py | 61 +-- src/unilab/scripts/play_viser.py | 325 +--------------- src/unilab/scripts/train_appo.py | 12 + src/unilab/scripts/train_offpolicy.py | 12 + src/unilab/scripts/train_rsl_rl.py | 11 + src/unilab/training/__init__.py | 2 + src/unilab/training/run.py | 13 + src/unilab/visualization/viser_playback.py | 364 ++++++++++++++++++ src/unilab/visualization/viser_scene.py | 39 +- tests/test_cli.py | 94 +++-- tests/utils/test_viser_scene.py | 52 ++- uv.lock | 14 +- 23 files changed, 647 insertions(+), 543 deletions(-) create mode 100644 src/unilab/visualization/viser_playback.py diff --git a/docs/sphinx/source/en/1-getting_started/3-evaluation_and_playback.md b/docs/sphinx/source/en/1-getting_started/3-evaluation_and_playback.md index 5b3ef9e0d..3045c59e5 100644 --- a/docs/sphinx/source/en/1-getting_started/3-evaluation_and_playback.md +++ b/docs/sphinx/source/en/1-getting_started/3-evaluation_and_playback.md @@ -20,8 +20,9 @@ Render modes: - `interactive` — open viewer window (default on macOS Motrix). - `record` — write MP4 to `runs//playback/`. -- `viser` — serve the rollout in a browser-based viser viewer - (`--sim mujoco` / `--sim mjwarp` only; requires the viser extra). +- `viser` — serve the rollout in a browser-based viser viewer (any backend + with physics-state playback: `mujoco`, `mjwarp`, `newton`, `drake`, + `superdex`). - `none` — skip rendering, just compute metrics. For `--sim mujoco --render-mode interactive`, `uv run eval` launches the dedicated diff --git a/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md b/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md index 387b3d8d5..435a039f0 100644 --- a/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md +++ b/docs/sphinx/source/en/2-user_guide/1-training/1-cli_reference.md @@ -102,10 +102,12 @@ Supported render modes are `auto`, `interactive`, `record`, `viser`, and The MuJoCo interactive mode (`--sim mujoco --render-mode interactive`) routes directly to `play_interactive.py` and always rolls out one environment. -For `--sim mujoco` and `--sim mjwarp`, `--render-mode viser` routes to the -browser-based viser viewer (`play_viser.py`): no local display is required, -and macOS runs it without `mjpython`. It needs the viser extra (`uv sync ---extra viser`); the `viser.*` config group (`viser.port`, `viser.max_envs`, +For backends with physics-state playback (`mujoco`, `mjwarp`, `newton`, +`drake`, `superdex`), `--render-mode viser` serves the rollout through the +browser-based viser viewer: no local display is required, and macOS runs it +without `mjpython`. Eval routes to the standalone `play_viser.py` viewer; +train runs the same viewer as its post-training playback. viser is a required +dependency; the `viser.*` config group (`viser.port`, `viser.max_envs`, `viser.display_mode`, `viser.env_idx`) tunes the server and the scene. ## Demo diff --git a/docs/sphinx/source/en/2-user_guide/3-backends/0-index.md b/docs/sphinx/source/en/2-user_guide/3-backends/0-index.md index 827575184..b038efe07 100644 --- a/docs/sphinx/source/en/2-user_guide/3-backends/0-index.md +++ b/docs/sphinx/source/en/2-user_guide/3-backends/0-index.md @@ -90,7 +90,8 @@ The selected owner YAML sets `training.sim_backend` as an identity field. paths. - `--render-mode record` records without opening an interactive window. - `--render-mode viser` serves the rollout in a browser-based viser viewer on - MuJoCo and mjwarp paths (requires the viser extra). + backends with physics-state playback (MuJoCo, mjwarp, newton, drake, + superdex). - `--render-mode none` disables playback. ```bash diff --git a/docs/sphinx/source/en/5-reference/5-support_matrix.md b/docs/sphinx/source/en/5-reference/5-support_matrix.md index 4fec4b9b8..a9307537e 100644 --- a/docs/sphinx/source/en/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/en/5-reference/5-support_matrix.md @@ -22,8 +22,7 @@ in English. Do not infer support beyond the evidence grade shown below. ## Playback Differences - `mujoco`: `--render-mode auto` exports `play_video.mp4`; `--render-mode - viser` serves the rollout in a browser-based viser viewer (requires the - viser extra). + viser` serves the rollout in a browser-based viser viewer. - `motrix`: `--render-mode auto` opens an interactive renderer window; it does not record a video and is not bound by `play_steps`. - `mjwarp`: supports explicit, finite-step `record` by default, rendered offline diff --git a/docs/sphinx/source/zh_CN/1-getting_started/3-evaluation_and_playback.md b/docs/sphinx/source/zh_CN/1-getting_started/3-evaluation_and_playback.md index a97e95ecb..2f69ef449 100644 --- a/docs/sphinx/source/zh_CN/1-getting_started/3-evaluation_and_playback.md +++ b/docs/sphinx/source/zh_CN/1-getting_started/3-evaluation_and_playback.md @@ -21,7 +21,7 @@ uv run demo dance - `interactive` — 打开查看器窗口(macOS Motrix 上的默认值)。 - `record` — 将 MP4 写入 `runs//playback/`。 - `viser` — 在基于浏览器的 viser viewer 中展示回放 - (仅 `--sim mujoco` / `--sim mjwarp`;需要 viser extra)。 + (支持所有具备 physics-state playback 的后端:`mujoco`、`mjwarp`、`newton`、`drake`、`superdex`)。 - `none` — 跳过渲染,仅计算指标。 当使用 `--sim mujoco --render-mode interactive` 时,`uv run eval` 会直接启动 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md b/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md index a57fab8ed..c2c5ec031 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/1-training/1-cli_reference.md @@ -97,9 +97,10 @@ uv run eval --algo ppo --task go2_joystick_flat --sim motrix --load-run -1 \ (`--sim mujoco --render-mode interactive`)直接路由到 `play_interactive.py`,并始终只 rollout 一个环境。 -`--sim mujoco` 和 `--sim mjwarp` 还支持 `--render-mode viser`:路由到基于浏览器的 -viser viewer(`play_viser.py`),不需要本地显示,在 macOS 上也不需要 `mjpython`。 -该模式需要 viser extra(`uv sync --extra viser`);`viser.*` 配置组 +具备 physics-state playback 的后端(`mujoco`、`mjwarp`、`newton`、`drake`、`superdex`) +还支持 `--render-mode viser`:通过基于浏览器的 viser viewer 展示回放,不需要本地显示, +在 macOS 上也不需要 `mjpython`。eval 会路由到独立的 `play_viser.py` viewer;train 则在 +训练后回放阶段运行同一 viewer。viser 是必选依赖;`viser.*` 配置组 (`viser.port`、`viser.max_envs`、`viser.display_mode`、`viser.env_idx`)用于调整服务器与场景。 ## 演示 diff --git a/docs/sphinx/source/zh_CN/2-user_guide/3-backends/0-index.md b/docs/sphinx/source/zh_CN/2-user_guide/3-backends/0-index.md index 433d29b6c..486c9f01b 100644 --- a/docs/sphinx/source/zh_CN/2-user_guide/3-backends/0-index.md +++ b/docs/sphinx/source/zh_CN/2-user_guide/3-backends/0-index.md @@ -86,8 +86,8 @@ Owner YAML 位置: - `--render-mode auto` 在 MuJoCo 路径上导出 `play_video.mp4`。 - `--render-mode auto` 在 Motrix 路径上打开 Motrix 原生交互式渲染。 - `--render-mode record` 在不打开交互式窗口的情况下录制。 -- `--render-mode viser` 在 MuJoCo 和 mjwarp 路径上通过基于浏览器的 viser - viewer 展示回放(需要 viser extra)。 +- `--render-mode viser` 在具备 physics-state playback 的后端(MuJoCo、mjwarp、 + newton、drake、superdex)上通过基于浏览器的 viser viewer 展示回放。 - `--render-mode none` 禁用回放。 ```bash diff --git a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md index 8e5812c52..ffd1018ad 100644 --- a/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md +++ b/docs/sphinx/source/zh_CN/5-reference/5-support_matrix.md @@ -19,7 +19,7 @@ ## Playback Differences - `mujoco`: `--render-mode auto` 会导出 `play_video.mp4`;`--render-mode viser` - 通过基于浏览器的 viser viewer 展示回放(需要 viser extra) + 通过基于浏览器的 viser viewer 展示回放 - `motrix`: `--render-mode auto` 会打开交互式 renderer 窗口,不录制视频,不受 `play_steps` 限制 - `mjwarp`: 默认仅支持显式、有限步数的 `record`,通过 task owner 的 MuJoCo visual model 离线录制;`--render-mode interactive` 路由到 MuJoCo 交互 viewer(mjwarp 跑物理、MuJoCo 渲染 env[0],强制单 env);`--render-mode viser` 路由到浏览器 viser viewer(按 env 使用 MuJoCo playback model);不支持 `auto` 或 native renderer - `isaacsim`: `auto` 在有 display 时选择 Kit viewer,否则选择 headless RGB camera;当前真实主机仍有 RTX renderer 初始化 blocker,支持等级保持 `Configured` diff --git a/pyproject.rocm.toml b/pyproject.rocm.toml index e66e64fee..0128cabcb 100644 --- a/pyproject.rocm.toml +++ b/pyproject.rocm.toml @@ -23,18 +23,7 @@ classifiers = [ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", - # Physics implementations are provided by the independently released - # unisim-core package. Version 1.5.0 provides the M2 physical entity, - # immutable variant, layout and selected-reset contracts; 1.6.0 adds - # Newton CUDA graph execution; 1.7.2 adds SuperDex physical - # kinematic roots to the portable MJCF profile; and 1.7.3 adds IsaacSim - # mapped-scene contact sensors, bounded PhysX solver configuration and - # mapped reset domain randomization. "unisim-core>=1.7.3", - # RL algorithms and async runtimes live in the independently released - # uni-rl package (distribution name ``unilab-rl``); see pyproject.toml. - # Optional: only APPO, off-policy algorithms, and multi-GPU PPO need it - # (extra ``uni_rl``); the dev group below keeps it installed for CI. "torch==2.11.0", "triton-rocm==3.6.0 ; sys_platform == 'linux' and platform_machine == 'x86_64'", "gymnasium", @@ -57,6 +46,8 @@ dependencies = [ "huggingface_hub>=0.25", "ninja ; sys_platform == 'linux'", "imageio-ffmpeg>=0.6.0", + "viser>=1.0.26", + "trimesh>=3.21.7", ] [project.scripts] @@ -72,36 +63,20 @@ unilab-pull-assets = "unilab.assets.pull:main" drake = [ "drake-uni==0.1.0", ] +# mjbatch is built and version-checked against mujoco==3.11.0; switching +# MuJoCo versions requires an mjbatch rebuild. mujoco = [ - # MuJoCo 3.11 alignment (unilabsim/UniLab#1515): the bound is a - # compatible-release specifier; uv.rocm.lock pins the exact MuJoCo - # version. The MuJoCo physics backend (unisim.backend.mujoco.backend) - # executes on the mjbatch native batch engine, which is built and - # version-checked against mujoco==3.11.0 — switching MuJoCo versions - # requires an mjbatch rebuild, not a UniLab config change. The batch - # engine is the unilabsim mjbatch fork, published on PyPI as - # mjbatch-uni (#1552); its build backend pins mujoco==3.11.0 at build - # time, so isolated builds are correct and no compiler preflight is - # needed. "mujoco~=3.11.0", "mjbatch-uni~=0.2.1", ] -# The Motrix runtime pin lives in the unisim-core ``motrix`` extra, so -# the consumed runtime always matches the version the -# unisim.backend.motrix adapter is tested against. motrix = ["unisim-core[motrix]>=1.7.3"] -viser = ["viser>=1.0.26", "trimesh>=3.21.7"] -# APPO / off-policy (SAC) runners, collectors, IPC, and multi-GPU -# data-parallel PPO launches require the optional uni-rl runtime. PPO -# single-process training and playback run directly on rsl-rl-lib. uni_rl = ["unilab-rl==1.3.2"] [dependency-groups] -# Repo development and CI exercise the APPO/off-policy/multi-GPU paths, so -# the dev environment keeps the optional unilab-rl runtime installed. +# Dev/CI exercises the APPO/off-policy/multi-GPU paths, so the optional +# unilab-rl runtime stays installed in the dev environment. dev = ["pytest", "pytest-cov", "ruff", "mypy", "pyright>=1.1.408", "unilab-rl==1.3.2"] -# ========== ROCm 索引配置 ========== [[tool.uv.index]] name = "pytorch-rocm72" url = "https://download.pytorch.org/whl/rocm7.2" @@ -175,7 +150,7 @@ venv = ".venv" include = ["src/unilab"] exclude = [ "src/unilab/envs/", # lazy optional `mujoco` import; not installed in the pyright env - "src/unilab/visualization/", # direct mujoco C bindings + optional viser deps + "src/unilab/visualization/", # direct mujoco C bindings; not installed in the pyright env ] reportMissingImports = "warning" reportMissingModuleSource = "none" diff --git a/pyproject.toml b/pyproject.toml index 827bae001..f05644878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,11 +3,8 @@ requires = ["uv_build>=0.12.1,<0.13"] build-backend = "uv_build" [tool.uv.build-backend] -# Robot meshes/textures are hosted on Hugging Face (unilabsim/unilab-robots) -# and downloaded into these paths on first use; keep them out of the -# wheel/sdist even when they exist in the source tree. XML and small metadata -# stay packaged. Directories must stay in sync with ROBOT_ASSET_SPECS and -# SUPERDEX_ROBOT_ASSET_SPECS in src/unilab/assets/hub.py. +# Downloaded robot meshes stay out of the wheel; keep in sync with +# ROBOT_ASSET_SPECS in src/unilab/assets/hub.py. source-exclude = [ "/src/unilab/assets/bots", "/src/unilab/assets/robots/allegro_hand/assets", @@ -38,33 +35,11 @@ classifiers = [ requires-python = ">=3.10,<3.14" dependencies = [ "numpy", - # Physics implementations are provided by the independently released - # unisim-core package. Version 1.5.0 provides the M2 physical entity, - # immutable variant, layout and selected-reset contracts; 1.5.1 adds - # get_motion_body_ids on the newton/genesis adapters; 1.6.0 adds Newton - # CUDA graph execution; 1.7.0 adds the portable MJCF scene profile; - # 1.7.1 isolates the IsaacSim worker from host compiled dependencies; - # 1.7.2 adds SuperDex physical kinematic roots to the portable profile; - # and 1.7.3 adds IsaacSim mapped-scene contact sensors, bounded PhysX - # solver configuration, per-entity self-collision and mapped reset - # domain randomization, plus IsaacGym mapped-scene reset randomization. - # 1.7.5 formalizes the physics-state playback rendering contract (layout - # split, mocap playback, conformance coverage; unilabsim/unisim#291). "unisim-core>=1.7.5", - # RL algorithms and async runtimes (APPO/SAC runners, collectors, - # IPC, logging) live in the independently released uni-rl package - # (distribution name ``unilab-rl``), consumed via the injected env - # contract (uni_rl.env_contract.EnvFactory). It is an optional dependency: - # PPO training and playback run directly on rsl-rl-lib; only APPO, - # off-policy algorithms, and multi-GPU data-parallel PPO need it - # (extra ``uni_rl``). Published on PyPI. "numba>=0.67", "prettytable>=3.10", - # torch is a range (not an exact pin) so that published PyPI metadata lets - # ROCm users substitute a rocm build installed from the PyTorch ROCm index - # (install torch there first, then unilab; pip keeps the installed build). - # Repo development and CI stay reproducible: uv.lock pins the tested - # cu128/cu130 builds below via tool.uv.sources. + # A range (not an exact pin) lets ROCm users substitute a ROCm torch + # build; uv.lock pins the tested CUDA builds via tool.uv.sources. "torch>=2.9,<2.12 ; sys_platform == 'linux' and platform_machine == 'aarch64'", "torch>=2.8,<2.12 ; sys_platform != 'linux' or platform_machine != 'aarch64'", "gymnasium", @@ -75,11 +50,7 @@ dependencies = [ "rsl-rl-lib>=5.0.0", "packaging", "mediapy", - # >=2.21 drops the pkg_resources import removed in setuptools>=82, - # which otherwise breaks `uv run tensorboard` at startup. "tensorboard>=2.21.0", - # Unpinned: quadrants (genesis-world 1.3.3 pin) requires setuptools>=77, - # and nothing in this repo imports setuptools/pkg_resources at runtime. "setuptools", "rich", "tqdm", @@ -91,6 +62,8 @@ dependencies = [ "huggingface_hub>=0.25", "ninja ; sys_platform == 'linux'", "imageio-ffmpeg>=0.6.0", + "viser>=1.0.26", + "trimesh>=3.21.7", ] [project.scripts] @@ -109,91 +82,44 @@ Repository = "https://github.com/unilabsim/UniLab" Issues = "https://github.com/unilabsim/UniLab/issues" [project.optional-dependencies] +# Drake C++ is an external toolchain; the native extension is built against +# the local DRAKE_HOME after sync. mujoco provides the playback renderer. drake = [ - # Python runtime for the Drake batch adapter. Drake C++ remains an external - # toolchain and the native extension must be built against the local - # DRAKE_HOME after this extra is synced. "drake-uni==0.1.0", - # Drake playback always uses MuJoCo's native renderer (MuJoCo is never - # stepped). Keep the renderer available from the single Drake install - # path; the MuJoCo physics extension remains in the separate mujoco extra. "mujoco>=3.5", ] +# mjbatch is built and version-checked against mujoco==3.11.0; switching +# MuJoCo versions requires an mjbatch rebuild. mujoco = [ - # MuJoCo 3.11 alignment (unilabsim/UniLab#1515): the mujoco, mjwarp, and - # newton extras share one MuJoCo 3.11 / MuJoCo-Warp 3.11 / warp-lang 1.16.0 - # line and are jointly installable. The `mujoco` bound is a compatible- - # release specifier; `uv.lock` pins the exact MuJoCo version. The MuJoCo - # physics backend (unisim.backend.mujoco.backend) executes on the mjbatch - # native batch engine, which is built and version-checked against - # mujoco==3.11.0 — switching MuJoCo versions requires an mjbatch rebuild, - # not a UniLab config change. "mujoco~=3.11.0", - # The batch engine is the published unilabsim mjbatch fork. 0.2.4 adds the - # streaming VariantPack builder required by unisim-core 1.7.5. "mjbatch-uni~=0.2.4", ] +# Shares the MuJoCo 3.11 / warp-lang 1.16 line with the mujoco/newton extras. mjwarp = [ - # Keep the Warp backend on the same MuJoCo minor line as the host backend. - # ``mujoco-warp`` imports as ``mujoco_warp`` and owns the GPU physics - # implementation; it is deliberately a separate optional extra rather - # than a mode of the ``mujoco`` backend. warp-lang stays an exact pin on - # the 1.16.0 line shared with the newton extra. "mujoco-warp~=3.11.0", "warp-lang==1.16.0", ] newton = [ - # Newton 1.5.1 is coupled to the MuJoCo-Warp 3.11 / Warp 1.16 line, so - # these stay exact pins. They sit on the same 3.11 line as the mujoco and - # mjwarp extras: newton 1.5.1 requires warp-lang>=1.16.0, and mujoco-warp - # 3.11.0 accepts warp-lang>=1.14 and mujoco>=3.9.0, so warp-lang==1.16.0 - # satisfies all three extras in one joint environment. "newton==1.5.1", "mujoco-warp==3.11.0", "mujoco==3.11.0", "warp-lang==1.16.0", - # Newton's MJCF importer loads mesh assets through trimesh on the - # materialization cold path; newton does not declare it as a hard - # dependency, so the extra carries it explicitly. - "trimesh>=3.21.7", - # Native ViewerGL is Newton's default playback renderer (offline record - # and interactive), so keep its GUI stack in the same backend extra. + # ViewerGL playback renderer GUI stack. "pyglet>=2.1.6,<3", "imgui-bundle>=1.92.0", ] -# The Motrix runtime pin lives in the unisim-core ``motrix`` extra, so -# the consumed runtime always matches the version the -# unisim.backend.motrix adapter is tested against. motrix = ["unisim-core[motrix]>=1.7.5"] -genesis = [ - # Genesis imports as ``genesis`` and owns the GPU physics implementation; - # it is a separate optional extra pinned exactly to the probed release - # (scripts/tools/genesis_feasibility/REPORT.md, #1372). - "genesis-world==1.3.3", -] -viser = ["viser>=1.0.26", "trimesh>=3.21.7"] -# APPO / off-policy (SAC) runners, collectors, IPC, and multi-GPU -# data-parallel PPO launches require the optional uni-rl runtime. PPO -# single-process training and playback run directly on rsl-rl-lib. +genesis = ["genesis-world==1.3.3"] uni_rl = ["unilab-rl==1.3.2"] -# SuperDex Physics/Robotics 1.1.0 ships as published wheels; the exact pins and -# the temporary superdex-uni build names are owned by unisim-core's superdex -# extra (they revert to superdex-physics/-robotics once the upstream -# project_superdex release lands). That extra already carries plain `mujoco` -# for MJCF conversion and the offline playback renderer; mjbatch is -# deliberately NOT bound here, since only the MuJoCo physics backend -# (unisim.backend.mujoco.backend) needs it and UniLab guards that backend on -# the `mjbatch` module. The wheels are CPython 3.12/3.13 Linux x86_64 only, -# so the marker keeps `uv lock` resolvable for the other -# required-environments; elsewhere the extra is empty and the CLI reports a -# targeted runtime diagnostic. +# Wheels are CPython 3.12/3.13 Linux x86_64 only; the marker keeps uv lock +# resolvable for the other required-environments. superdex = [ "unisim-core[superdex]>=1.7.5 ; python_version >= '3.12' and sys_platform == 'linux' and platform_machine == 'x86_64'", ] [dependency-groups] -# Repo development and CI exercise the APPO/off-policy/multi-GPU paths, so -# the dev environment keeps the optional unilab-rl runtime installed. +# Dev/CI exercises the APPO/off-policy/multi-GPU paths, so the optional +# unilab-rl runtime stays installed in the dev environment. dev = ["pytest", "pytest-cov", "ruff", "mypy", "pyright>=1.1.408", "unilab-rl==1.3.2"] [[tool.uv.index]] @@ -269,7 +195,7 @@ venv = ".venv" include = ["src/unilab"] exclude = [ "src/unilab/envs/", # lazy optional `mujoco` import; not installed in the pyright env - "src/unilab/visualization/", # direct mujoco C bindings + optional viser deps + "src/unilab/visualization/", # direct mujoco C bindings; not installed in the pyright env ] reportMissingImports = "warning" reportMissingModuleSource = "none" diff --git a/scripts/play_viser.py b/scripts/play_viser.py index ac0d9fe56..25bf4f8ac 100644 --- a/scripts/play_viser.py +++ b/scripts/play_viser.py @@ -2,11 +2,7 @@ Thin wrapper around the packaged viewer ``unilab.scripts.play_viser``; the playback loop, session dispatch, and scene code live there. This script keeps -the direct Hydra entry for source checkouts. - -Prerequisites:: - - uv sync --extra viser +the direct Hydra entry for source checkouts. viser is a required dependency. Usage:: diff --git a/src/unilab/cli.py b/src/unilab/cli.py index cf16f848a..a7d156e53 100644 --- a/src/unilab/cli.py +++ b/src/unilab/cli.py @@ -36,6 +36,11 @@ # Physics backends whose interactive eval runs through the dedicated MuJoCo # viewer script: the selected backend owns the rollout while MuJoCo renders. MUJOCO_VIEWER_PHYSICS_SIMS = frozenset({"mujoco", "mjwarp"}) +# Backends whose upstream unisim adapter does not implement the physics-state +# playback contract the viser viewer renders from (they expose native +# renderers instead). Supporting viser there requires upstream unisim +# capability work, so these sims fail closed with an actionable message. +VISER_UNSUPPORTED_SIMS = frozenset({"motrix", "genesis", "isaacgym", "isaacsim"}) RESERVED_OVERRIDE_KEYS = { "algo", "task", @@ -397,7 +402,7 @@ def _uses_mujoco_interactive_play( return selected_mode is not None and selected_mode.strip().lower() == "interactive" -def _uses_mujoco_viser_play( +def _uses_viser_play( *, mode: str, algo: str, @@ -405,12 +410,15 @@ def _uses_mujoco_viser_play( render_mode: str | None, overrides: Sequence[str], ) -> bool: - """Return whether eval should use the browser-based viser viewer script.""" - if ( - mode != "eval" - or sim not in MUJOCO_VIEWER_PHYSICS_SIMS - or algo not in INTERACTIVE_PLAY_ALGOS - ): + """Return whether eval should use the browser-based viser viewer script. + + Any physics owner whose backend declares physics-state playback is + routable; :data:`VISER_UNSUPPORTED_SIMS` sims are rejected earlier with a + targeted message, and the viewer itself fails closed on the runtime + capability check. + """ + del sim # routing is sim-agnostic; capability gating happens at runtime + if mode != "eval" or algo not in INTERACTIVE_PLAY_ALGOS: return False selected_mode = _override_value(overrides, "training.play_render_mode") or render_mode return selected_mode is not None and selected_mode.strip().lower() == "viser" @@ -442,7 +450,7 @@ def build_command( render_mode=render_mode, overrides=overrides, ) - use_viser_play = _uses_mujoco_viser_play( + use_viser_play = _uses_viser_play( mode=mode, algo=algo, sim=sim, @@ -450,15 +458,17 @@ def build_command( overrides=overrides, ) selected_mode = _override_value(overrides, "training.play_render_mode") or render_mode - if ( - selected_mode is not None - and selected_mode.strip().lower() == "viser" - and not use_viser_play - ): + viser_mode_requested = selected_mode is not None and selected_mode.strip().lower() == "viser" + if viser_mode_requested and sim in VISER_UNSUPPORTED_SIMS: raise SystemExit( - "render mode 'viser' is only supported for eval with a browser-renderable " - f"physics owner (--sim mujoco or --sim mjwarp); got mode={mode}, sim={sim}, " - f"algo={algo}." + f"render mode 'viser' renders through the physics-state playback contract, " + f"which the {sim} backend does not implement in upstream unisim; " + "use --render-mode interactive or record for its native renderer." + ) + if viser_mode_requested and mode == "eval" and not use_viser_play: + raise SystemExit( + f"render mode 'viser' eval requires one of the interactive play algos " + f"({', '.join(sorted(INTERACTIVE_PLAY_ALGOS))}); got algo={algo}." ) if use_interactive_play and find_spec("mujoco") is None: raise SystemExit( @@ -466,19 +476,12 @@ def build_command( "extra. Install it with `pip install unilab[mujoco]` (or `uv sync --extra " "mujoco` in a source checkout)." ) - if use_viser_play: - if find_spec("mujoco") is None: - raise SystemExit( - "viser eval renders MuJoCo playback models and requires the MuJoCo " - "extra. Install it with `pip install unilab[mujoco]` (or `uv sync " - "--extra mujoco` in a source checkout)." - ) - if find_spec("viser") is None: - raise SystemExit( - "viser eval serves the rollout through a browser-based viser viewer and " - "requires the viser extra. Install it with `pip install unilab[viser]` " - "(or `uv sync --extra viser` in a source checkout)." - ) + if viser_mode_requested and find_spec("mujoco") is None: + raise SystemExit( + "viser playback renders MuJoCo playback models and requires the MuJoCo " + "package. Install it with `pip install unilab[mujoco]` (or `uv sync " + "--extra mujoco` in a source checkout)." + ) viewer_script_name: str | None = None if use_interactive_play: viewer_script_name = "play_interactive.py" diff --git a/src/unilab/scripts/play_viser.py b/src/unilab/scripts/play_viser.py index 74d8358bc..b19ca6250 100644 --- a/src/unilab/scripts/play_viser.py +++ b/src/unilab/scripts/play_viser.py @@ -2,188 +2,41 @@ Serves the playback rollout through a browser-based 3D viewer (powered by *viser*) so no local display or GLFW is required. ``--sim`` selects the -physics owner; mjwarp runs the rollout while per-env MuJoCo playback models -drive the rendered scene. +physics owner; any backend declaring the physics-state playback contract +(mujoco, mjwarp, newton, drake, superdex) runs the rollout while per-env +MuJoCo playback models drive the rendered scene. Usage: uv run python -m unilab.scripts.play_viser --algo ppo --task go2_joystick_flat --sim mujoco uv run python -m unilab.scripts.play_viser --algo appo --task go2_joystick_flat --sim mujoco \ viser.port=8080 viser.max_envs=4 -Prerequisites: ``uv sync --extra viser``. - Camera controls (browser): Left-drag - rotate Scroll - zoom Right-drag - pan """ -# pyright: reportAttributeAccessIssue=false, reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false - import sys -import time from collections.abc import Sequence -from pathlib import Path -from typing import Any -import numpy as np -import torch from omegaconf import DictConfig, OmegaConf from unilab.training import ensure_registries -from unilab.visualization.interactive_playback import PlaybackControls, PlayInteractiveArgs -from unilab.visualization.playback_state import ( - PhysicsStateApplier, - assert_physics_state_playback_supported, -) -from unilab.visualization.render_many import get_grid_offsets -from unilab.visualization.viser_scene import ( - VISER_AVAILABLE, - MujocoViserBatchScene, - MujocoViserScene, - build_visible_env_indices, -) +from unilab.visualization.interactive_playback import PlayInteractiveArgs +from unilab.visualization.viser_playback import run_viser_playback_from_cfg ensure_registries() from unilab.scripts.play_interactive import ( _build_play_args, _compose_interactive_config, - _load_mujoco_model_file_for_viewer, _parse_interactive_cli, create_playback_session, ) -if VISER_AVAILABLE: - import viser -else: - viser = None - -import mujoco - - -def _load_env_playback_model(env: Any, env_index: int): - """Resolve the MuJoCo playback model for one env. - - Backends either return an ``MjModel`` directly (mujoco) or a model file - path (mjwarp per-env variants); both are accepted. - """ - model = env.get_playback_model(env_index) - if isinstance(model, mujoco.MjModel): - return model - if isinstance(model, str): - return _load_mujoco_model_file_for_viewer(model) - raise TypeError(f"Expected mujoco.MjModel or model file path for playback, got {type(model)!r}") - - -def _scene_offset(offset_xy: np.ndarray) -> tuple[float, float, float]: - """Convert a 2D grid offset into a 3D scene offset.""" - return (float(offset_xy[0]), float(offset_xy[1]), 0.0) - - -def _build_scene_entries( - server: Any, - env: Any, - *, - mode: str, - selected_visible_idx: int, - visible_env_indices: np.ndarray, - spacing: float, -) -> list[dict[str, Any]]: - """Construct viser scenes and MuJoCo data objects for active env views.""" - entries: list[dict[str, Any]] = [] - if mode == "single": - env_idx = int(visible_env_indices[selected_visible_idx]) - mj_model = _load_env_playback_model(env, env_idx) - entries.append( - { - "slot_idx": selected_visible_idx, - "runtime_env_idx": env_idx, - "model": mj_model, - "data": mujoco.MjData(mj_model), - "applier": PhysicsStateApplier(env, mj_model, env_index=env_idx), - "scene": MujocoViserScene(server, mj_model, name_prefix="/mujoco/single"), - } - ) - return entries - - offsets = get_grid_offsets(len(visible_env_indices), spacing=spacing) - models: list[mujoco.MjModel] = [] - data: list[mujoco.MjData] = [] - appliers: list[PhysicsStateApplier] = [] - for env_idx in visible_env_indices: - model = _load_env_playback_model(env, int(env_idx)) - models.append(model) - data.append(mujoco.MjData(model)) - appliers.append(PhysicsStateApplier(env, model, env_index=int(env_idx))) - - # MuJoCo task instances normally share one model. Viser can then render - # each geom as a batched mesh, reducing per-frame messages from - # O(environments * geoms) to O(geoms). Keep the old scene-per-env path for - # heterogeneous playback models, which cannot share batched geometry. - matching_models = all( - model.ngeom == models[0].ngeom - and np.array_equal(model.geom_type, models[0].geom_type) - and np.array_equal(model.geom_size, models[0].geom_size) - and np.array_equal(model.geom_dataid, models[0].geom_dataid) - and np.array_equal(model.geom_rgba, models[0].geom_rgba) - for model in models[1:] - ) - if matching_models: - return [ - { - "batch": True, - "runtime_env_indices": visible_env_indices.copy(), - "models": models, - "model": models[0], - "data": data, - "appliers": appliers, - "scene": MujocoViserBatchScene( - server, - models, - name_prefix="/mujoco/batch", - position_offsets=np.column_stack( - (np.asarray(offsets, dtype=np.float64), np.zeros(len(offsets))) - ), - render_plane=True, - ), - } - ] - - for local_idx, env_idx in enumerate(visible_env_indices): - env_idx = int(env_idx) - mj_model = models[local_idx] - entries.append( - { - "slot_idx": local_idx, - "runtime_env_idx": env_idx, - "model": mj_model, - "data": mujoco.MjData(mj_model), - "applier": appliers[local_idx], - "scene": MujocoViserScene( - server, - mj_model, - name_prefix=f"/mujoco/env_{local_idx}", - position_offset=_scene_offset(offsets[local_idx]), - render_plane=(local_idx == 0), - ), - } - ) - return entries - - -def _close_scene_entries(entries: list[dict[str, Any]]) -> None: - for entry in entries: - entry["scene"].close() - def play_viser(args: PlayInteractiveArgs, cfg: DictConfig, *, algo: str = "ppo") -> None: - if not VISER_AVAILABLE: - raise SystemExit( - "play_viser requires the viser extra. Install it with " - "`pip install unilab[viser]` (or `uv sync --extra viser` in a source checkout)." - ) - def log(message: str) -> None: print(f"[play_viser] {message}", flush=True) @@ -191,170 +44,14 @@ def log(message: str) -> None: session = create_playback_session(args, cfg, algo=algo, num_envs=num_envs, log=log) if session is None: return - playback_session = session[0] - env = playback_session.env - assert_physics_state_playback_supported(env, entrypoint="play_viser") - - # --- GUI controls -------------------------------------------------------- - max_visible_envs = min(int(OmegaConf.select(cfg, "viser.max_envs", default=16) or 16), num_envs) - env_options = tuple(f"env_{i}" for i in range(max_visible_envs)) - initial_env_idx = int(OmegaConf.select(cfg, "viser.env_idx", default=0) or 0) - initial_env_idx = min(initial_env_idx, max_visible_envs - 1) - initial_mode = str(OmegaConf.select(cfg, "viser.display_mode", default="all") or "all") - if initial_mode not in {"single", "all"}: - initial_mode = "all" - visible_env_indices = build_visible_env_indices(num_envs, max_visible_envs) - - ctrl_dt = env.cfg.ctrl_dt - render_spacing = float( - OmegaConf.select(cfg, "training.render_spacing") or getattr(env.cfg, "render_spacing", 1.0) - ) - - # --- Setup viser server -------------------------------------------------- - port = int(OmegaConf.select(cfg, "viser.port", default=8080) or 8080) - server = viser.ViserServer(port=port) - - with server.gui.add_folder("Controls"): - display_dropdown = server.gui.add_dropdown( - "Display", - options=("all", "single"), - initial_value=initial_mode, - ) - env_dropdown = server.gui.add_dropdown( - "Environment", - options=env_options, - initial_value=env_options[initial_env_idx], - ) - pause_button = server.gui.add_button("Pause / Resume") - step_button = server.gui.add_button("Step") - speed_slider = server.gui.add_slider( - "Speed", - min=0.1, - max=5.0, - step=0.1, - initial_value=1.0, - ) - - controls = PlaybackControls( - paused=bool(getattr(args, "start_paused", False)), + run_viser_playback_from_cfg( + session[0], + cfg, + entrypoint="play_viser", + start_paused=bool(getattr(args, "start_paused", False)), speed=float(getattr(args, "speed", 1.0)), + log=log, ) - speed_slider.value = controls.speed - env_idx = {"value": initial_env_idx} - display_mode = {"value": initial_mode} - scene_entries = { - "value": _build_scene_entries( - server, - env, - mode=display_mode["value"], - selected_visible_idx=env_idx["value"], - visible_env_indices=visible_env_indices, - spacing=render_spacing, - ) - } - - def _rebuild_scenes() -> None: - _close_scene_entries(scene_entries["value"]) - scene_entries["value"] = _build_scene_entries( - server, - env, - mode=display_mode["value"], - selected_visible_idx=env_idx["value"], - visible_env_indices=visible_env_indices, - spacing=render_spacing, - ) - if display_mode["value"] == "single": - runtime_idx = int(visible_env_indices[env_idx["value"]]) - log(f"Showing env_{env_idx['value']} (runtime env {runtime_idx})") - else: - log( - f"Showing {max_visible_envs} env slots mapped to runtime envs " - f"{visible_env_indices.tolist()}" - ) - - @pause_button.on_click - def _on_pause_click(event: Any) -> None: - del event - paused = controls.toggle_pause() - status = "paused" if paused else "resumed" - log(status) - - @step_button.on_click - def _on_step_click(event: Any) -> None: - del event - if not controls.paused: - controls.pause() - log("paused for single-step mode") - controls.request_single_step() - log("single step requested") - - @display_dropdown.on_update - def _on_display_mode_update(event: Any) -> None: - del event - display_mode["value"] = str(display_dropdown.value) - env_dropdown.disabled = display_mode["value"] == "all" - _rebuild_scenes() - - @env_dropdown.on_update - def _on_env_switch(event: Any) -> None: - del event - selected = env_dropdown.value - idx = int(selected.split("_")[1]) - env_idx["value"] = idx - if display_mode["value"] == "single": - _rebuild_scenes() - else: - log(f"Selected env_{idx} (runtime env {int(visible_env_indices[idx])})") - - env_dropdown.disabled = display_mode["value"] == "all" - - playback_session.reset() - - log(f"Server running at http://localhost:{server.get_port()}") - log(f"{num_envs} environment(s) loaded. Open browser to view.") - if display_mode["value"] == "all": - log( - f"Rendering {max_visible_envs} env slots simultaneously from runtime envs " - f"{visible_env_indices.tolist()}." - ) - log("Press Ctrl+C to quit.") - - # --- Main loop ----------------------------------------------------------- - try: - with torch.inference_mode(): - while True: - t0 = time.perf_counter() - - controls.set_speed(float(speed_slider.value)) - playback_session.advance(controls) - - physics_batch = playback_session.physics_state() - for entry in scene_entries["value"]: - if entry.get("batch", False): - for runtime_idx, applier, data in zip( - entry["runtime_env_indices"], - entry["appliers"], - entry["data"], - strict=True, - ): - applier.apply(physics_batch[int(runtime_idx)], data) - entry["scene"].update(entry["data"]) - continue - entry["applier"].apply( - physics_batch[int(entry["runtime_env_idx"])], entry["data"] - ) - entry["scene"].update(entry["data"]) - - # Real-time pacing - target_dt = controls.target_dt(ctrl_dt) - elapsed = time.perf_counter() - t0 - if target_dt - elapsed > 0: - time.sleep(target_dt - elapsed) - - except KeyboardInterrupt: - print("\n[play_viser] Shutting down.") - finally: - _close_scene_entries(scene_entries["value"]) def main(argv: Sequence[str] | None = None) -> None: diff --git a/src/unilab/scripts/train_appo.py b/src/unilab/scripts/train_appo.py index a7763a7bc..628a2fd90 100644 --- a/src/unilab/scripts/train_appo.py +++ b/src/unilab/scripts/train_appo.py @@ -30,6 +30,7 @@ build_run_dir_name, ensure_registries, get_log_root, + is_viser_play_render_mode, resolve_nan_guard_cfg, should_run_playback, ) @@ -268,6 +269,17 @@ def forward(self, obs: torch.Tensor) -> torch.Tensor: verify_input = torch.randn(1, obs_dim, device=device) verify_policy_onnx(export_module, onnx_path, (verify_input,), input_names=["obs"]) + if is_viser_play_render_mode(getattr(cfg.training, "play_render_mode", "auto")): + # Browser-based viser playback renders through the shared MuJoCo + # playback shell; it replaces backend-native playback and records no + # video. Blocks until Ctrl+C. Imported lazily: the viser scene module + # binds the optional `mujoco` package, which non-MuJoCo-shell training + # environments do not install. + from unilab.visualization.viser_playback import run_viser_playback_from_cfg + + run_viser_playback_from_cfg(session, cfg, entrypoint="train_appo play") + return None + with torch.inference_mode(): play_video_path = env.run_playback_mode( play_render_mode=getattr(cfg.training, "play_render_mode", "auto"), diff --git a/src/unilab/scripts/train_offpolicy.py b/src/unilab/scripts/train_offpolicy.py index 2ebfc4e99..e73e6dc03 100644 --- a/src/unilab/scripts/train_offpolicy.py +++ b/src/unilab/scripts/train_offpolicy.py @@ -44,6 +44,7 @@ build_run_dir_name, ensure_registries, get_log_root, + is_viser_play_render_mode, resolve_nan_guard_cfg, should_run_playback, ) @@ -384,6 +385,17 @@ def play_offpolicy(algo_name: str, cfg: DictConfig) -> str | None: elif load_path_dir is not None: print("Skipping ONNX export because training.export_onnx=false.") + if is_viser_play_render_mode(getattr(cfg.training, "play_render_mode", "auto")): + # Browser-based viser playback renders through the shared MuJoCo + # playback shell; it replaces backend-native playback and records no + # video. Blocks until Ctrl+C. Imported lazily: the viser scene module + # binds the optional `mujoco` package, which non-MuJoCo-shell training + # environments do not install. + from unilab.visualization.viser_playback import run_viser_playback_from_cfg + + run_viser_playback_from_cfg(session, cfg, entrypoint=f"train_{algo_name} play") + return None + with torch.inference_mode(): play_video_path = env.run_playback_mode( play_render_mode=getattr(cfg.training, "play_render_mode", "auto"), diff --git a/src/unilab/scripts/train_rsl_rl.py b/src/unilab/scripts/train_rsl_rl.py index b67f52aaf..28655d819 100644 --- a/src/unilab/scripts/train_rsl_rl.py +++ b/src/unilab/scripts/train_rsl_rl.py @@ -45,6 +45,7 @@ ensure_registries, format_play_checkpoint_error, get_log_root, + is_viser_play_render_mode, parse_checkpoint_path, should_run_playback, ) @@ -375,6 +376,16 @@ def play_rsl_rl(cfg: DictConfig, device: str) -> str | None: assert runner is not None runner.export_policy_to_onnx(path=str(load_path_dir)) runner.export_policy_to_jit(path=str(load_path_dir)) + if is_viser_play_render_mode(getattr(cfg.training, "play_render_mode", "auto")): + # Browser-based viser playback renders through the shared MuJoCo + # playback shell; it replaces backend-native playback and records no + # video. Blocks until Ctrl+C. Imported lazily: the viser scene module + # binds the optional `mujoco` package, which non-MuJoCo-shell training + # environments do not install. + from unilab.visualization.viser_playback import run_viser_playback_from_cfg + + run_viser_playback_from_cfg(session, cfg, entrypoint="train_rsl_rl play") + return None num_steps = _resolve_play_num_steps(cfg) output_video = Path(load_path_dir) / "play_video.mp4" playback_mode: str | None = None diff --git a/src/unilab/training/__init__.py b/src/unilab/training/__init__.py index ac96b36a7..32d60e4be 100644 --- a/src/unilab/training/__init__.py +++ b/src/unilab/training/__init__.py @@ -22,6 +22,7 @@ build_run_dir_name, format_play_checkpoint_error, get_log_root, + is_viser_play_render_mode, parse_checkpoint_path, resolve_nan_guard_cfg, should_run_playback, @@ -57,6 +58,7 @@ "get_latest_checkpoint", "get_latest_run", "get_log_root", + "is_viser_play_render_mode", "log_playback_plan", "parse_checkpoint_path", "resolve_checkpoint_path", diff --git a/src/unilab/training/run.py b/src/unilab/training/run.py index fa1f35799..6f4829320 100644 --- a/src/unilab/training/run.py +++ b/src/unilab/training/run.py @@ -104,8 +104,21 @@ def apply_env_nan_guard(env: Any, training_cfg: Any) -> None: ) +def is_viser_play_render_mode(play_render_mode: str | None) -> bool: + """Return whether the configured mode selects the browser-based viser viewer. + + ``viser`` is a UniLab-level playback mode layered on top of the unisim + ``PLAY_RENDER_MODES`` contract; it renders through the shared MuJoCo + playback shell instead of a backend-native renderer, so it never reaches + ``SimBackend.resolve_play_render_plan``. + """ + return play_render_mode is not None and str(play_render_mode).strip().lower() == "viser" + + def should_run_playback(*, play_only: bool, no_play: bool, play_render_mode: str | None) -> bool: """Return whether train/eval should enter playback for the configured mode.""" + if is_viser_play_render_mode(play_render_mode): + return bool(play_only) or not bool(no_play) if normalize_play_render_mode(play_render_mode) == "none": return False return bool(play_only) or not bool(no_play) diff --git a/src/unilab/visualization/viser_playback.py b/src/unilab/visualization/viser_playback.py new file mode 100644 index 000000000..96f8f8d42 --- /dev/null +++ b/src/unilab/visualization/viser_playback.py @@ -0,0 +1,364 @@ +# pyright: reportAttributeAccessIssue=false, reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false +"""Reusable viser (browser-based) playback loop for train and eval entrypoints. + +The viser frontend renders through a MuJoCo playback shell: the physics owner +(any backend declaring ``supports_physics_state_playback``) produces +physics-state snapshots, and per-env MuJoCo playback models drive the rendered +scene. Backends without the upstream unisim physics-state playback contract +fail closed in :func:`assert_physics_state_playback_supported`. + +Both the standalone viewer (``unilab.scripts.play_viser``) and the post-training +playback path (``training.play_render_mode=viser`` in the train scripts) drive +this loop with their own playback sessions. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from typing import Any + +import mujoco +import numpy as np +import torch +import viser +from omegaconf import DictConfig, OmegaConf + +from unilab.visualization.interactive_playback import PlaybackControls, PlaybackSession +from unilab.visualization.playback_state import ( + PhysicsStateApplier, + assert_physics_state_playback_supported, +) +from unilab.visualization.render_many import get_grid_offsets +from unilab.visualization.viser_scene import ( + MujocoViserBatchScene, + MujocoViserScene, + build_visible_env_indices, +) + + +def _load_env_playback_model(env: Any, env_index: int) -> mujoco.MjModel: + """Resolve the MuJoCo playback model for one env. + + Backends either return an ``MjModel`` directly (mujoco) or a model file + path (mjwarp per-env variants); both are accepted. + """ + model = env.get_playback_model(env_index) + if isinstance(model, mujoco.MjModel): + return model + if isinstance(model, str): + if model.lower().endswith(".mjb"): + return mujoco.MjModel.from_binary_path(model) + return mujoco.MjModel.from_xml_path(model) + raise TypeError(f"Expected mujoco.MjModel or model file path for playback, got {type(model)!r}") + + +def _scene_offset(offset_xy: np.ndarray) -> tuple[float, float, float]: + """Convert a 2D grid offset into a 3D scene offset.""" + return (float(offset_xy[0]), float(offset_xy[1]), 0.0) + + +def _build_scene_entries( + server: Any, + env: Any, + *, + mode: str, + selected_visible_idx: int, + visible_env_indices: np.ndarray, + spacing: float, +) -> list[dict[str, Any]]: + """Construct viser scenes and MuJoCo data objects for active env views.""" + entries: list[dict[str, Any]] = [] + if mode == "single": + env_idx = int(visible_env_indices[selected_visible_idx]) + mj_model = _load_env_playback_model(env, env_idx) + entries.append( + { + "slot_idx": selected_visible_idx, + "runtime_env_idx": env_idx, + "model": mj_model, + "data": mujoco.MjData(mj_model), + "applier": PhysicsStateApplier(env, mj_model, env_index=env_idx), + "scene": MujocoViserScene(server, mj_model, name_prefix="/mujoco/single"), + } + ) + return entries + + offsets = get_grid_offsets(len(visible_env_indices), spacing=spacing) + models: list[mujoco.MjModel] = [] + data: list[mujoco.MjData] = [] + appliers: list[PhysicsStateApplier] = [] + for env_idx in visible_env_indices: + model = _load_env_playback_model(env, int(env_idx)) + models.append(model) + data.append(mujoco.MjData(model)) + appliers.append(PhysicsStateApplier(env, model, env_index=int(env_idx))) + + # MuJoCo task instances normally share one model. Viser can then render + # each geom as a batched mesh, reducing per-frame messages from + # O(environments * geoms) to O(geoms). Keep the old scene-per-env path for + # heterogeneous playback models, which cannot share batched geometry. + matching_models = all( + model.ngeom == models[0].ngeom + and np.array_equal(model.geom_type, models[0].geom_type) + and np.array_equal(model.geom_size, models[0].geom_size) + and np.array_equal(model.geom_dataid, models[0].geom_dataid) + and np.array_equal(model.geom_rgba, models[0].geom_rgba) + for model in models[1:] + ) + if matching_models: + return [ + { + "batch": True, + "runtime_env_indices": visible_env_indices.copy(), + "models": models, + "model": models[0], + "data": data, + "appliers": appliers, + "scene": MujocoViserBatchScene( + server, + models, + name_prefix="/mujoco/batch", + position_offsets=np.column_stack( + (np.asarray(offsets, dtype=np.float64), np.zeros(len(offsets))) + ), + render_plane=True, + ), + } + ] + + for local_idx, env_idx in enumerate(visible_env_indices): + env_idx = int(env_idx) + mj_model = models[local_idx] + entries.append( + { + "slot_idx": local_idx, + "runtime_env_idx": env_idx, + "model": mj_model, + "data": mujoco.MjData(mj_model), + "applier": appliers[local_idx], + "scene": MujocoViserScene( + server, + mj_model, + name_prefix=f"/mujoco/env_{local_idx}", + position_offset=_scene_offset(offsets[local_idx]), + render_plane=(local_idx == 0), + ), + } + ) + return entries + + +def _close_scene_entries(entries: list[dict[str, Any]]) -> None: + for entry in entries: + entry["scene"].close() + + +def run_viser_playback( + session: PlaybackSession, + *, + port: int = 8080, + max_visible_envs: int = 16, + env_idx: int = 0, + display_mode: str = "all", + render_spacing: float = 1.0, + start_paused: bool = False, + speed: float = 1.0, + entrypoint: str = "viser playback", + log: Callable[[str], None] = print, +) -> None: + """Serve a playback session through a browser-based viser viewer. + + Blocks until Ctrl+C. The session must satisfy the + :class:`~unilab.visualization.interactive_playback.PlaybackSession` + protocol over an env whose backend declares physics-state playback; + unsupported backends fail closed at startup. + """ + env = session.env + assert_physics_state_playback_supported(env, entrypoint=entrypoint) + + num_envs = int(env.num_envs) + max_visible_envs = min(int(max_visible_envs), num_envs) + env_options = tuple(f"env_{i}" for i in range(max_visible_envs)) + initial_env_idx = min(int(env_idx), max_visible_envs - 1) + initial_mode = display_mode if display_mode in {"single", "all"} else "all" + visible_env_indices = build_visible_env_indices(num_envs, max_visible_envs) + + ctrl_dt = env.cfg.ctrl_dt + + # --- Setup viser server -------------------------------------------------- + server = viser.ViserServer(port=int(port)) + + with server.gui.add_folder("Controls"): + display_dropdown = server.gui.add_dropdown( + "Display", + options=("all", "single"), + initial_value=initial_mode, + ) + env_dropdown = server.gui.add_dropdown( + "Environment", + options=env_options, + initial_value=env_options[initial_env_idx], + ) + pause_button = server.gui.add_button("Pause / Resume") + step_button = server.gui.add_button("Step") + speed_slider = server.gui.add_slider( + "Speed", + min=0.1, + max=5.0, + step=0.1, + initial_value=1.0, + ) + + controls = PlaybackControls(paused=bool(start_paused), speed=float(speed)) + speed_slider.value = controls.speed + selected_env_idx = {"value": initial_env_idx} + selected_mode = {"value": initial_mode} + scene_entries = { + "value": _build_scene_entries( + server, + env, + mode=selected_mode["value"], + selected_visible_idx=selected_env_idx["value"], + visible_env_indices=visible_env_indices, + spacing=render_spacing, + ) + } + + def _rebuild_scenes() -> None: + _close_scene_entries(scene_entries["value"]) + scene_entries["value"] = _build_scene_entries( + server, + env, + mode=selected_mode["value"], + selected_visible_idx=selected_env_idx["value"], + visible_env_indices=visible_env_indices, + spacing=render_spacing, + ) + if selected_mode["value"] == "single": + runtime_idx = int(visible_env_indices[selected_env_idx["value"]]) + log(f"Showing env_{selected_env_idx['value']} (runtime env {runtime_idx})") + else: + log( + f"Showing {max_visible_envs} env slots mapped to runtime envs " + f"{visible_env_indices.tolist()}" + ) + + @pause_button.on_click + def _on_pause_click(event: Any) -> None: + del event + paused = controls.toggle_pause() + status = "paused" if paused else "resumed" + log(status) + + @step_button.on_click + def _on_step_click(event: Any) -> None: + del event + if not controls.paused: + controls.pause() + log("paused for single-step mode") + controls.request_single_step() + log("single step requested") + + @display_dropdown.on_update + def _on_display_mode_update(event: Any) -> None: + del event + selected_mode["value"] = str(display_dropdown.value) + env_dropdown.disabled = selected_mode["value"] == "all" + _rebuild_scenes() + + @env_dropdown.on_update + def _on_env_switch(event: Any) -> None: + del event + selected = env_dropdown.value + idx = int(selected.split("_")[1]) + selected_env_idx["value"] = idx + if selected_mode["value"] == "single": + _rebuild_scenes() + else: + log(f"Selected env_{idx} (runtime env {int(visible_env_indices[idx])})") + + env_dropdown.disabled = selected_mode["value"] == "all" + + session.reset() + + log(f"Server running at http://localhost:{server.get_port()}") + log(f"{num_envs} environment(s) loaded. Open browser to view.") + if selected_mode["value"] == "all": + log( + f"Rendering {max_visible_envs} env slots simultaneously from runtime envs " + f"{visible_env_indices.tolist()}." + ) + log("Press Ctrl+C to quit.") + + # --- Main loop ----------------------------------------------------------- + try: + with torch.inference_mode(): + while True: + t0 = time.perf_counter() + + controls.set_speed(float(speed_slider.value)) + session.advance(controls) + + physics_batch = session.physics_state() + for entry in scene_entries["value"]: + if entry.get("batch", False): + for runtime_idx, applier, data in zip( + entry["runtime_env_indices"], + entry["appliers"], + entry["data"], + strict=True, + ): + applier.apply(physics_batch[int(runtime_idx)], data) + entry["scene"].update(entry["data"]) + continue + entry["applier"].apply( + physics_batch[int(entry["runtime_env_idx"])], entry["data"] + ) + entry["scene"].update(entry["data"]) + + # Real-time pacing + target_dt = controls.target_dt(ctrl_dt) + elapsed = time.perf_counter() - t0 + if target_dt - elapsed > 0: + time.sleep(target_dt - elapsed) + + except KeyboardInterrupt: + print("\n[viser playback] Shutting down.") + finally: + _close_scene_entries(scene_entries["value"]) + + +def run_viser_playback_from_cfg( + session: PlaybackSession, + cfg: DictConfig, + *, + entrypoint: str = "viser playback", + start_paused: bool = False, + speed: float = 1.0, + log: Callable[[str], None] = print, +) -> None: + """Run :func:`run_viser_playback` with viewer options from the Hydra config. + + Reads the shared ``viser.*`` config group (``port``, ``max_envs``, + ``env_idx``, ``display_mode``) and ``training.render_spacing``. + """ + env = session.env + render_spacing = float( + OmegaConf.select(cfg, "training.render_spacing") or getattr(env.cfg, "render_spacing", 1.0) + ) + run_viser_playback( + session, + port=int(OmegaConf.select(cfg, "viser.port", default=8080) or 8080), + max_visible_envs=int(OmegaConf.select(cfg, "viser.max_envs", default=16) or 16), + env_idx=int(OmegaConf.select(cfg, "viser.env_idx", default=0) or 0), + display_mode=str(OmegaConf.select(cfg, "viser.display_mode", default="all") or "all"), + render_spacing=render_spacing, + start_paused=start_paused, + speed=speed, + entrypoint=entrypoint, + log=log, + ) + + +__all__ = ["run_viser_playback", "run_viser_playback_from_cfg"] diff --git a/src/unilab/visualization/viser_scene.py b/src/unilab/visualization/viser_scene.py index 4fc596201..aa733fba6 100644 --- a/src/unilab/visualization/viser_scene.py +++ b/src/unilab/visualization/viser_scene.py @@ -2,12 +2,13 @@ """MuJoCo-to-viser scene adapter for interactive web-based 3D visualization. This module renders MuJoCo scenes via a viser web server, providing browser-based -interactive 3D viewing without requiring a local display or GLFW. It is gated -behind the ``viser`` optional-dependency group and is **not** imported by default. +interactive 3D viewing without requiring a local display or GLFW. viser is a +required UniLab dependency; this module is only imported on playback paths, +which also require the optional ``mujoco`` package for the playback shell. Usage (from ``scripts/play_viser.py``):: - from unilab.visualization.viser_scene import MujocoViserScene, VISER_AVAILABLE + from unilab.visualization.viser_scene import MujocoViserScene """ from __future__ import annotations @@ -18,15 +19,8 @@ import mujoco import numpy as np - -try: - import trimesh - import viser - - VISER_AVAILABLE = True -except ImportError: - VISER_AVAILABLE = False - +import trimesh +import viser # --------------------------------------------------------------------------- # # Rotation helpers (pure numpy, no scipy dependency) # @@ -121,6 +115,12 @@ def _geom_mesh(model: mujoco.MjModel, geom_index: int) -> tuple[np.ndarray, np.n return np.asarray(mesh.vertices), np.asarray(mesh.faces) +# MuJoCo's default scene option (mjvOption.geomgroup) enables geom groups +# 0-2 and hides groups 3-5; match that default so auxiliary geoms such as +# collision-only markers stay hidden. +DEFAULT_VISIBLE_GEOM_GROUPS: tuple[int, ...] = (0, 1, 2) + + def build_visible_env_indices(num_envs: int, visible_envs: int) -> np.ndarray: """Select a stable subset of env indices spread across the full batch. @@ -158,14 +158,14 @@ def __init__( name_prefix: str = "/mujoco", position_offset: tuple[float, float, float] = (0.0, 0.0, 0.0), render_plane: bool = True, + visible_geom_groups: Sequence[int] = DEFAULT_VISIBLE_GEOM_GROUPS, ) -> None: - if not VISER_AVAILABLE: - raise ImportError("viser is not installed. Install with: uv sync --extra viser") self._server: viser.ViserServer = server self._model = model self._name_prefix = name_prefix.rstrip("/") or "/mujoco" self._position_offset = np.asarray(position_offset, dtype=np.float64) self._render_plane = bool(render_plane) + self._visible_geom_groups = frozenset(int(g) for g in visible_geom_groups) self._handles: dict[int, Any] = {} self._build() @@ -205,13 +205,15 @@ def close(self) -> None: # ------------------------------------------------------------------ # def _build(self) -> None: - """Create viser scene nodes for every MuJoCo geom.""" + """Create viser scene nodes for every geom in a visible geom group.""" model = self._model server = self._server server.scene.set_up_direction("+z") for i in range(model.ngeom): + if int(model.geom_group[i]) not in self._visible_geom_groups: + continue geom_type = model.geom_type[i] size = model.geom_size[i] rgba = model.geom_rgba[i] @@ -324,9 +326,8 @@ def __init__( name_prefix: str = "/mujoco/batch", position_offsets: np.ndarray | Sequence[Sequence[float]] | None = None, render_plane: bool = True, + visible_geom_groups: Sequence[int] = DEFAULT_VISIBLE_GEOM_GROUPS, ) -> None: - if not VISER_AVAILABLE: - raise ImportError("viser is not installed. Install with: uv sync --extra viser") if not models: raise ValueError("MujocoViserBatchScene requires at least one model") first = models[0] @@ -338,6 +339,7 @@ def __init__( and np.array_equal(model.geom_size, first.geom_size) and np.array_equal(model.geom_dataid, first.geom_dataid) and np.array_equal(model.geom_rgba, first.geom_rgba) + and np.array_equal(model.geom_group, first.geom_group) ): raise ValueError("Batched MuJoCo models must have matching geom geometry") @@ -353,6 +355,7 @@ def __init__( ) self._position_offsets[:] = offsets self._render_plane = bool(render_plane) + self._visible_geom_groups = frozenset(int(g) for g in visible_geom_groups) self._handles: dict[int, Any] = {} self._grid_handles: list[Any] = [] self._build() @@ -361,6 +364,8 @@ def _build(self) -> None: model = self._models[0] self._server.scene.set_up_direction("+z") for geom_index in range(model.ngeom): + if int(model.geom_group[geom_index]) not in self._visible_geom_groups: + continue geom_type = model.geom_type[geom_index] size = model.geom_size[geom_index] rgba = model.geom_rgba[geom_index] diff --git a/tests/test_cli.py b/tests/test_cli.py index 98f60ef7b..b87389149 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -608,10 +608,8 @@ def test_eval_mujoco_interactive_falls_back_to_sibling_owner( assert "training.play_only=true" in command -def _pretend_viser_is_installed( - monkeypatch: pytest.MonkeyPatch, *, with_viser: bool = True -) -> None: - installed = {"mujoco", "mjbatch"} | ({"viser"} if with_viser else set()) +def _pretend_mujoco_is_installed(monkeypatch: pytest.MonkeyPatch) -> None: + installed = {"mujoco", "mjbatch"} monkeypatch.setattr( cli, "find_spec", @@ -635,7 +633,7 @@ def test_eval_mujoco_viser_routes_to_viser_viewer( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: scripts_dir = _make_mujoco_viser_checkout(tmp_path) - _pretend_viser_is_installed(monkeypatch) + _pretend_mujoco_is_installed(monkeypatch) command = cli.build_command( mode="eval", @@ -669,7 +667,7 @@ def test_eval_mujoco_viser_via_passthrough_override( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: scripts_dir = _make_mujoco_viser_checkout(tmp_path) - _pretend_viser_is_installed(monkeypatch) + _pretend_mujoco_is_installed(monkeypatch) command = cli.build_command( mode="eval", @@ -685,23 +683,44 @@ def test_eval_mujoco_viser_via_passthrough_override( assert "training.play_render_mode=viser" in command -def test_eval_mujoco_viser_requires_viser_package( +def test_eval_newton_viser_routes_to_viser_viewer( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - _make_mujoco_viser_checkout(tmp_path) - _pretend_viser_is_installed(monkeypatch, with_viser=False) + """viser eval is not limited to mujoco/mjwarp: any physics-state-playback + backend routes to the viser viewer script.""" + scripts_dir = _make_mujoco_viser_checkout(tmp_path) + owner_dir = tmp_path / "conf" / "ppo" / "task" / "go2_joystick_flat" + (owner_dir / "newton.yaml").write_text("training:\n sim_backend: newton\n", encoding="utf-8") + installed = {"newton", "mujoco_warp", "warp", "mujoco", "mjbatch"} + monkeypatch.setattr( + cli, + "find_spec", + lambda name: ModuleSpec(name, loader=None) if name in installed else None, + ) - with pytest.raises(SystemExit, match="viser"): - cli.build_command( - mode="eval", - algo="ppo", - task="go2_joystick_flat", - sim="mujoco", - overrides=[], - load_run="-1", - render_mode="viser", - root=tmp_path, - ) + command = cli.build_command( + mode="eval", + algo="ppo", + task="go2_joystick_flat", + sim="newton", + overrides=[], + load_run="-1", + render_mode="viser", + root=tmp_path, + ) + + assert command[1] == str(scripts_dir / "play_viser.py") + assert command[:8] == [ + sys.executable, + str(scripts_dir / "play_viser.py"), + "--algo", + "ppo", + "--task", + "go2_joystick_flat", + "--sim", + "newton", + ] + assert "training.play_render_mode=viser" in command def test_eval_mjwarp_viser_routes_to_viser_viewer( @@ -749,7 +768,7 @@ def test_macos_mujoco_viser_eval_uses_current_python( ) -> None: # The viser viewer is browser-based: no GLFW/Cocoa, so no mjpython. _make_mujoco_viser_checkout(tmp_path) - _pretend_viser_is_installed(monkeypatch) + _pretend_mujoco_is_installed(monkeypatch) monkeypatch.setattr(cli.platform, "system", lambda: "Darwin") command = cli.build_command( @@ -785,22 +804,47 @@ def test_eval_viser_render_mode_rejected_for_motrix( ) -def test_train_viser_render_mode_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _make_mujoco_viser_checkout(tmp_path) - _pretend_viser_is_installed(monkeypatch) +def test_train_viser_render_mode_rejected_for_motrix( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _make_minimal_checkout(tmp_path) + _pretend_motrix_is_installed(monkeypatch) with pytest.raises(SystemExit, match="viser"): cli.build_command( mode="train", algo="ppo", task="go2_joystick_flat", - sim="mujoco", + sim="motrix", overrides=[], render_mode="viser", root=tmp_path, ) +def test_train_viser_render_mode_routes_to_train_script( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Train-mode viser playback runs inside the train script's post-training + playback: the CLI forwards training.play_render_mode=viser unchanged.""" + scripts_dir = _make_mujoco_viser_checkout(tmp_path) + _pretend_mujoco_is_installed(monkeypatch) + + command = cli.build_command( + mode="train", + algo="ppo", + task="go2_joystick_flat", + sim="mujoco", + overrides=[], + render_mode="viser", + root=tmp_path, + ) + + assert command[1] == str(scripts_dir / "train_rsl_rl.py") + assert "task=go2_joystick_flat/mujoco" in command + assert "training.play_render_mode=viser" in command + + def _pretend_mjwarp_is_installed( monkeypatch: pytest.MonkeyPatch, *, with_mujoco: bool = True ) -> None: diff --git a/tests/utils/test_viser_scene.py b/tests/utils/test_viser_scene.py index 370ebdc94..19b207538 100644 --- a/tests/utils/test_viser_scene.py +++ b/tests/utils/test_viser_scene.py @@ -4,10 +4,8 @@ import mujoco import numpy as np -import pytest from unilab.visualization.viser_scene import ( - VISER_AVAILABLE, MujocoViserBatchScene, MujocoViserScene, build_visible_env_indices, @@ -76,7 +74,6 @@ def atomic(self): return nullcontext() -@pytest.mark.skipif(not VISER_AVAILABLE, reason="viser optional dependency is not installed") def test_mujoco_viser_scene_applies_position_offset_and_close() -> None: xml = """ @@ -120,7 +117,6 @@ def test_build_visible_env_indices_spreads_slots_across_full_batch() -> None: np.testing.assert_array_equal(indices, np.arange(0, 64, 4, dtype=np.int32)) -@pytest.mark.skipif(not VISER_AVAILABLE, reason="viser optional dependency is not installed") def test_mujoco_viser_batch_scene_updates_instances() -> None: xml = """ @@ -157,3 +153,51 @@ def test_mujoco_viser_batch_scene_updates_instances() -> None: scene.close() assert handle.removed is True + + +def _grouped_geoms_xml() -> str: + return """ + + + + + + + + + """ + + +def test_mujoco_viser_scene_hides_geom_groups_beyond_mujoco_default() -> None: + # MuJoCo's default mjvOption.geomgroup enables groups 0-2 only; the viser + # scene must match so group-3 collision markers stay hidden. + model = mujoco.MjModel.from_xml_string(_grouped_geoms_xml()) # pyright: ignore[reportAttributeAccessIssue] + + server = _FakeServer() + scene = MujocoViserScene(server, model, render_plane=False) + + assert model.geom_group.tolist() == [2, 3] + assert list(scene._handles) == [0] + assert len(server.scene.handles) == 1 + + +def test_mujoco_viser_scene_visible_geom_groups_override() -> None: + model = mujoco.MjModel.from_xml_string(_grouped_geoms_xml()) # pyright: ignore[reportAttributeAccessIssue] + + server = _FakeServer() + scene = MujocoViserScene(server, model, render_plane=False, visible_geom_groups=(0, 1, 2, 3)) + + assert list(scene._handles) == [0, 1] + + +def test_mujoco_viser_batch_scene_hides_geom_groups_beyond_mujoco_default() -> None: + models = [ + mujoco.MjModel.from_xml_string(_grouped_geoms_xml()), # pyright: ignore[reportAttributeAccessIssue] + mujoco.MjModel.from_xml_string(_grouped_geoms_xml()), # pyright: ignore[reportAttributeAccessIssue] + ] + + server = _FakeServer() + scene = MujocoViserBatchScene(server, models, render_plane=False) + + assert list(scene._handles) == [0] + assert len(server.scene.handles) == 1 diff --git a/uv.lock b/uv.lock index d1c193b46..abee3ce21 100644 --- a/uv.lock +++ b/uv.lock @@ -5146,8 +5146,10 @@ dependencies = [ { name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'win32'" }, { name = "torch", version = "2.9.0+cu130", source = { registry = "https://download-r2.pytorch.org/whl/cu130" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, { name = "tqdm" }, + { name = "trimesh" }, { name = "typing-extensions" }, { name = "unisim-core" }, + { name = "viser" }, { name = "wandb" }, ] @@ -5176,7 +5178,6 @@ newton = [ { name = "mujoco-warp" }, { name = "newton" }, { name = "pyglet" }, - { name = "trimesh" }, { name = "warp-lang" }, ] superdex = [ @@ -5185,10 +5186,6 @@ superdex = [ uni-rl = [ { name = "unilab-rl" }, ] -viser = [ - { name = "trimesh" }, - { name = "viser" }, -] [package.dev-dependencies] dev = [ @@ -5238,19 +5235,18 @@ requires-dist = [ { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.8,<2.12", index = "https://download.pytorch.org/whl/cu128" }, { name = "torch", marker = "sys_platform == 'win32'", specifier = ">=2.8,<2.12", index = "https://download.pytorch.org/whl/cu128" }, { name = "tqdm" }, - { name = "trimesh", marker = "extra == 'newton'", specifier = ">=3.21.7" }, - { name = "trimesh", marker = "extra == 'viser'", specifier = ">=3.21.7" }, + { name = "trimesh", specifier = ">=3.21.7" }, { name = "typing-extensions" }, { name = "unilab-rl", marker = "extra == 'uni-rl'", specifier = "==1.3.2" }, { name = "unisim-core", specifier = ">=1.7.5" }, { name = "unisim-core", extras = ["motrix"], marker = "extra == 'motrix'", specifier = ">=1.7.5" }, { name = "unisim-core", extras = ["superdex"], marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'superdex'", specifier = ">=1.7.5" }, - { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.26" }, + { name = "viser", specifier = ">=1.0.26" }, { name = "wandb" }, { name = "warp-lang", marker = "extra == 'mjwarp'", specifier = "==1.16.0" }, { name = "warp-lang", marker = "extra == 'newton'", specifier = "==1.16.0" }, ] -provides-extras = ["drake", "mujoco", "mjwarp", "newton", "motrix", "genesis", "viser", "uni-rl", "superdex"] +provides-extras = ["drake", "mujoco", "mjwarp", "newton", "motrix", "genesis", "uni-rl", "superdex"] [package.metadata.requires-dev] dev = [