Skip to content

Commit 135cf98

Browse files
authored
So101 keyboard (#6876)
# Description Adds the `IsaacContrib-Keyboard-SO101` manager-based reinforcement-learning task under `isaaclab_tasks.contrib.keyboard`. The task trains an SO-101 arm to type target words while preserving the training and evaluation behavior validated by the accompanying checkpoint. The change includes: - Procedurally generated, logo-agnostic keyboard families with varied layouts, geometry, materials, and articulated keys. Both PhysX and Newton use the full keyboard pool rather than a single fixed keyboard. - Typing commands, observations, rewards, terminations, reset-state replay, success-rate curriculum sampling, and visualization markers. - A single RSL-RL actor/critic configuration with a shared MLP encoder for the one-hot typing command. - Physics presets for concrete Isaac Sim PhysX (`isaacsim_physx`, the default), automatic PhysX selection (`physx`), and Newton MJWarp (`newton_mjwarp`). - Focused configuration and architecture tests, plus environment-catalog documentation. Implementation reference: [Emergent Dexterity via Diverse Resets and Large-Scale Reinforcement Learning (OmniReset)](https://arxiv.org/abs/2603.15789). ### Asset dependency The SO-101 USD is hosted separately and is intentionally not included in this PR. Before merge, it should be published under the official IsaacLab Nucleus `Robots/SO101/` directory and resolved through `ISAAC_NUCLEUS_DIR`. ### Training and evaluation The policy can be evaluated with: ```bash ./isaaclab.sh play --task=IsaacContrib-Keyboard-SO101 --num_envs=32 physics=newton_mjwarp --rl_library=rsl_rl ``` Manual checkpoint playback validates that the policy solves the typing task reliably. ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots A task screenshot still needs to be attached and added to the environment catalog at: `docs/source/_static/tasks/manipulation/so101_keyboard.jpg` ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my feature works - [ ] I have added a changelog fragment under `source/<pkg>/changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there
1 parent ed1f83d commit 135cf98

31 files changed

Lines changed: 5240 additions & 10 deletions

docs/source/_static/css/environment-browser.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
["Isaac-Fourbar-Pole-Swingup", "rsl_rl", "newton_kamino", "", ""],
2222
["Isaac-Humanoid-Direct", "rl_games,rsl_rl,skrl", "isaacsim_physx,newton_mjwarp,ovphysx", "", ""],
2323
["Isaac-Humanoid", "rl_games,rsl_rl,skrl,sb3", "isaacsim_physx,newton_mjwarp,ovphysx", "", ""],
24+
["IsaacContrib-Keyboard-SO101", "rsl_rl", "isaacsim_physx,newton_mjwarp,physx", "", ""],
2425
["Isaac-Lift-Cable-Franka", "rsl_rl", "newton_mjwarp_vbd_proxy", "", "ik,joint"],
2526
["Isaac-Lift-Cable-Franka-Camera", "rsl_rl", "newton_mjwarp_vbd_proxy", "isaacsim_rtx,newton_renderer,ovrtx", "ik,joint"],
2627
["Isaac-Lift-Cloth-Franka", "rsl_rl", "isaacsim_physx,newton_mjwarp_vbd_proxy", "", "ik,joint"],
@@ -93,7 +94,7 @@
9394
if (task.includes("Velocity")) {
9495
return "locomotion";
9596
}
96-
if (/Lift|Reach|Reorient|Drawer|Handover/.test(task)) {
97+
if (/Lift|Reach|Reorient|Drawer|Handover|Keyboard/.test(task)) {
9798
return "manipulation";
9899
}
99100
return "classic";
@@ -130,6 +131,7 @@
130131
[/Reorient-Franka/, "tasks/manipulation/franka_lift.jpg"],
131132
[/Reorient-KukaAllegro/, "tasks/manipulation/kuka_allegro_reorient.jpg"],
132133
[/Shadow-Handover/, "tasks/manipulation/shadow_hand_over.jpg"],
134+
[/Keyboard-SO101/, "tasks/manipulation/so101_keyboard.jpg"],
133135
[/AnymalB/, "tasks/locomotion/anymal_b_flat.jpg"],
134136
[/AnymalC/, "tasks/locomotion/anymal_c_flat.jpg"],
135137
[/AnymalD/, "tasks/locomotion/anymal_d_flat.jpg"],

docs/source/overview/environments.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,10 @@ including disabling runtime perturbations used for training.
14131413
- Direct
14141414
- **skrl** (AMP)
14151415
-
1416+
* - IsaacContrib-Keyboard-SO101
1417+
- Manager Based
1418+
- **rsl_rl** (PPO)
1419+
- **physics=** ``isaacsim_physx``, ``newton_mjwarp``
14161420
* - IsaacContrib-Lift-Cube-Franka
14171421
- Manager Based
14181422
- **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO), **sb3** (PPO)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Added
2+
^^^^^
3+
4+
* Added the ``IsaacContrib-Keyboard-SO101`` task with procedural keyboards and PhysX and Newton support.
5+
* Added :func:`~isaaclab_tasks.core.lift.mdp.utils.env_instance_rows`, mapping each environment to the
6+
instance rows of an asset that has several roots per environment.
7+
8+
Changed
9+
^^^^^^^
10+
11+
* Changed :func:`~isaaclab_tasks.core.lift.mdp.utils.get_reset_state` and
12+
:func:`~isaaclab_tasks.core.lift.mdp.utils.set_reset_state` to cover every instance an asset has in an
13+
environment instead of reading and writing the instance row that matches the environment id, which
14+
addressed the wrong instances for assets partitioned into several roots per environment. Serialized
15+
states are unchanged for assets with one instance per environment; for partitioned assets each slice
16+
now repeats per instance, so states captured before this change cannot be restored with it.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""SO-101 keyboard-typing environments.
7+
8+
The MDP implementation is based primarily on OmniReset:
9+
10+
@inproceedings{yin2026emergent,
11+
title={Emergent Dexterity via Diverse Resets and Large-Scale Reinforcement Learning},
12+
author={Patrick Yin and Tyler Westenbroek and Zhengyu Zhang and Joshua Tran and Ignacio Dagnino and Eeshani Shilamkar and Numfor Mbiziwo-Tiapo and Simran Bagaria and Xinlei Liu and Galen Mullins and Andrey Kolobov and Abhishek Gupta},
13+
booktitle={The Fourteenth International Conference on Learning Representations},
14+
year={2026},
15+
url={https://arxiv.org/abs/2603.15789},
16+
}
17+
"""
18+
19+
import gymnasium as gym
20+
21+
from . import agents
22+
23+
##
24+
# Register Gym environments.
25+
##
26+
27+
gym.register(
28+
id="IsaacContrib-Keyboard-SO101",
29+
entry_point="isaaclab.envs:ManagerBasedRLEnv",
30+
disable_env_checker=True,
31+
kwargs={
32+
"env_cfg_entry_point": f"{__name__}.so101_env_cfg:SO101KeyboardEnvCfg",
33+
"rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:SO101PPORunnerCfg",
34+
},
35+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""RSL-RL models for the SO-101 keyboard-typing task."""
7+
8+
from __future__ import annotations
9+
10+
import copy
11+
from typing import Any
12+
13+
import torch
14+
import torch.nn as nn
15+
from rsl_rl.algorithms import PPO
16+
from rsl_rl.models import MLPModel
17+
from rsl_rl.modules import MLP, HiddenState
18+
from rsl_rl.storage import RolloutStorage
19+
from tensordict import TensorDict
20+
21+
22+
class SharedEncoderMLPModel(MLPModel):
23+
"""Encode selected 1-D observation groups before the MLP head."""
24+
25+
def __init__(
26+
self,
27+
obs: TensorDict,
28+
obs_groups: dict[str, list[str]],
29+
obs_set: str,
30+
output_dim: int,
31+
hidden_dims: tuple[int, ...] | list[int] = (256, 256, 256),
32+
activation: str = "elu",
33+
obs_normalization: bool = False,
34+
distribution_cfg: dict | None = None,
35+
encoder_cfg: dict[str, dict[str, Any]] | None = None,
36+
) -> None:
37+
"""Initialize the encoded-observation MLP model."""
38+
if not encoder_cfg:
39+
raise ValueError("At least one encoder configuration must be provided.")
40+
41+
active_obs_groups = obs_groups[obs_set]
42+
encoder_keys = set(encoder_cfg)
43+
if not encoder_keys.issubset(active_obs_groups):
44+
invalid_groups = sorted(encoder_keys - set(active_obs_groups))
45+
raise ValueError(
46+
f"The encoder observation groups {invalid_groups} are not part of the '{obs_set}' observation groups"
47+
f" {active_obs_groups}."
48+
)
49+
50+
self.encoder_obs_groups = [group for group in active_obs_groups if group in encoder_keys]
51+
self.encoder_input_dims = []
52+
for obs_group in self.encoder_obs_groups:
53+
if len(obs[obs_group].shape) != 2:
54+
raise ValueError(
55+
f"The MLP encoders only support 1D observations, got shape {obs[obs_group].shape} for"
56+
f" '{obs_group}'."
57+
)
58+
self.encoder_input_dims.append(obs[obs_group].shape[-1])
59+
60+
encoders = {}
61+
self.encoder_latent_dim = 0
62+
for obs_group, input_dim in zip(self.encoder_obs_groups, self.encoder_input_dims):
63+
group_cfg = dict(encoder_cfg[obs_group])
64+
latent_dim = group_cfg["latent_dim"]
65+
encoders[obs_group] = MLP(
66+
input_dim=input_dim,
67+
output_dim=latent_dim,
68+
hidden_dims=group_cfg["hidden_dims"],
69+
activation=group_cfg.get("activation", "elu"),
70+
last_activation=group_cfg.get("last_activation"),
71+
)
72+
self.encoder_latent_dim += latent_dim
73+
74+
super().__init__(
75+
obs,
76+
obs_groups,
77+
obs_set,
78+
output_dim,
79+
hidden_dims,
80+
activation,
81+
obs_normalization,
82+
distribution_cfg,
83+
)
84+
self.encoders = nn.ModuleDict(encoders)
85+
86+
def get_latent(
87+
self, obs: TensorDict, masks: torch.Tensor | None = None, hidden_state: HiddenState = None
88+
) -> torch.Tensor:
89+
"""Build the model latent from raw observations and encoded groups."""
90+
latents = [self.encoders[group](obs[group]) for group in self.encoder_obs_groups]
91+
if self.obs_groups:
92+
latents.insert(0, super().get_latent(obs))
93+
return torch.cat(latents, dim=-1)
94+
95+
def update_normalization(self, obs: TensorDict) -> None:
96+
"""Update normalization statistics of non-encoded observation groups."""
97+
if self.obs_groups:
98+
super().update_normalization(obs)
99+
100+
def as_jit(self) -> nn.Module:
101+
"""Return a version of the model compatible with Torch JIT export."""
102+
return _TorchSharedEncoderModel(self)
103+
104+
def as_onnx(self, verbose: bool = False) -> nn.Module:
105+
"""Return a version of the model compatible with ONNX export."""
106+
return _OnnxSharedEncoderModel(self, verbose)
107+
108+
def _get_obs_dim(self, obs: TensorDict, obs_groups: dict[str, list[str]], obs_set: str) -> tuple[list[str], int]:
109+
"""Select non-encoded observation groups and compute their total dimension."""
110+
active_obs_groups = obs_groups[obs_set]
111+
raw_obs_groups = []
112+
obs_dim = 0
113+
for obs_group in active_obs_groups:
114+
if len(obs[obs_group].shape) != 2:
115+
raise ValueError(
116+
f"The MLP model only supports 1D observations, got shape {obs[obs_group].shape} for '{obs_group}'."
117+
)
118+
if obs_group not in self.encoder_obs_groups:
119+
raw_obs_groups.append(obs_group)
120+
obs_dim += obs[obs_group].shape[-1]
121+
return raw_obs_groups, obs_dim
122+
123+
def _get_latent_dim(self) -> int:
124+
"""Return the latent dimensionality consumed by the MLP head."""
125+
return self.obs_dim + self.encoder_latent_dim
126+
127+
128+
class SharedEncoderPPO(PPO):
129+
"""Share the actor's observation encoders with the critic."""
130+
131+
def __init__(self, actor: MLPModel, critic: MLPModel, storage: RolloutStorage, **kwargs: Any) -> None:
132+
"""Replace the critic's encoders before PPO registers optimizer parameters."""
133+
if not isinstance(actor, SharedEncoderMLPModel) or not isinstance(critic, SharedEncoderMLPModel):
134+
raise TypeError("SharedEncoderPPO requires SharedEncoderMLPModel actor and critic models.")
135+
if (
136+
actor.encoder_obs_groups != critic.encoder_obs_groups
137+
or actor.encoder_latent_dim != critic.encoder_latent_dim
138+
):
139+
raise ValueError("The actor and critic encoder configurations must match.")
140+
141+
# The actor owns the shared modules so optimizer, checkpoint, and gradient traversal see them once.
142+
del critic.encoders
143+
object.__setattr__(critic, "encoders", actor.encoders)
144+
super().__init__(actor, critic, storage, **kwargs)
145+
146+
147+
class _TorchSharedEncoderModel(nn.Module):
148+
"""Exportable shared-encoder model for TorchScript."""
149+
150+
def __init__(self, model: SharedEncoderMLPModel) -> None:
151+
"""Create a TorchScript-compatible model copy."""
152+
super().__init__()
153+
self.obs_normalizer = copy.deepcopy(model.obs_normalizer)
154+
self.encoders = nn.ModuleList([copy.deepcopy(model.encoders[g]) for g in model.encoder_obs_groups])
155+
self.mlp = copy.deepcopy(model.mlp)
156+
if model.distribution is not None:
157+
self.deterministic_output = model.distribution.as_deterministic_output_module()
158+
else:
159+
self.deterministic_output = nn.Identity()
160+
161+
def forward(self, obs_raw: torch.Tensor, obs_encoded: list[torch.Tensor]) -> torch.Tensor:
162+
"""Run deterministic inference from raw and encoded-group inputs."""
163+
latents = [self.obs_normalizer(obs_raw)]
164+
for i, encoder in enumerate(self.encoders):
165+
latents.append(encoder(obs_encoded[i]))
166+
return self.deterministic_output(self.mlp(torch.cat(latents, dim=-1)))
167+
168+
@torch.jit.export
169+
def reset(self) -> None:
170+
"""Reset recurrent export state."""
171+
pass
172+
173+
174+
class _OnnxSharedEncoderModel(_TorchSharedEncoderModel):
175+
"""Exportable shared-encoder model for ONNX."""
176+
177+
is_recurrent: bool = False
178+
179+
def __init__(self, model: SharedEncoderMLPModel, verbose: bool) -> None:
180+
"""Create an ONNX-compatible model copy."""
181+
super().__init__(model)
182+
self.verbose = verbose
183+
self.encoder_obs_groups = list(model.encoder_obs_groups)
184+
self.encoder_input_dims = list(model.encoder_input_dims)
185+
self.obs_dim_raw = model.obs_dim
186+
187+
def forward(self, obs: torch.Tensor, *obs_encoded: torch.Tensor) -> torch.Tensor:
188+
"""Run deterministic inference for ONNX export."""
189+
return super().forward(obs, list(obs_encoded))
190+
191+
def get_dummy_inputs(self) -> tuple[torch.Tensor, ...]:
192+
"""Return representative dummy inputs for ONNX tracing."""
193+
dummy_raw = torch.zeros(1, self.obs_dim_raw)
194+
dummy_encoded = [torch.zeros(1, dim) for dim in self.encoder_input_dims]
195+
return (dummy_raw, *dummy_encoded)
196+
197+
@property
198+
def input_names(self) -> list[str]:
199+
"""Return ONNX input tensor names."""
200+
return ["obs", *self.encoder_obs_groups]
201+
202+
@property
203+
def output_names(self) -> list[str]:
204+
"""Return ONNX output tensor names."""
205+
return ["actions"]
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
from dataclasses import MISSING
7+
8+
from isaaclab.utils.configclass import configclass
9+
10+
from isaaclab_rl.rsl_rl import (
11+
RslRlMLPModelCfg,
12+
RslRlOnPolicyRunnerCfg,
13+
RslRlPpoAlgorithmCfg,
14+
)
15+
16+
##
17+
# Custom model configurations.
18+
##
19+
20+
21+
@configclass
22+
class SharedEncoderMLPModelCfg(RslRlMLPModelCfg):
23+
"""Configuration for the shared-encoder MLP model."""
24+
25+
class_name: str = "isaaclab_tasks.contrib.keyboard.agents.models:SharedEncoderMLPModel"
26+
"""The model class name. Defaults to :class:`~.models.SharedEncoderMLPModel`."""
27+
28+
@configclass
29+
class EncoderCfg:
30+
"""Configuration for the MLP encoder of a single observation group."""
31+
32+
hidden_dims: list[int] = MISSING
33+
"""The hidden dimensions of the encoder MLP."""
34+
35+
latent_dim: int = MISSING
36+
"""The dimension of the encoder output latent."""
37+
38+
activation: str = "elu"
39+
"""The activation function of the encoder MLP. Defaults to elu."""
40+
41+
last_activation: str | None = "elu"
42+
"""The activation applied to the encoder output latent. Defaults to elu.
43+
44+
If None, the latent is the output of the last linear layer.
45+
"""
46+
47+
encoder_cfg: dict[str, EncoderCfg] = MISSING
48+
"""Mapping from observation group name to the MLP encoder configuration for that group."""
49+
50+
51+
##
52+
# Runner configuration.
53+
##
54+
55+
56+
@configclass
57+
class SO101PPORunnerCfg(RslRlOnPolicyRunnerCfg):
58+
"""RSL-RL configuration for SO-101 keyboard typing with shared observation encoders."""
59+
60+
num_steps_per_env = 32
61+
max_iterations = 15000
62+
save_interval = 250
63+
experiment_name = "so101_keyboard_typing"
64+
obs_groups = {
65+
"actor": ["policy", "proprio", "perception"],
66+
"critic": ["policy", "proprio", "perception"],
67+
}
68+
actor = SharedEncoderMLPModelCfg(
69+
distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(init_std=1.0),
70+
obs_normalization=True,
71+
hidden_dims=[512, 256, 128],
72+
activation="elu",
73+
encoder_cfg={"policy": SharedEncoderMLPModelCfg.EncoderCfg(hidden_dims=[256], latent_dim=64)},
74+
)
75+
critic = SharedEncoderMLPModelCfg(
76+
obs_normalization=True,
77+
hidden_dims=[512, 256, 128],
78+
activation="elu",
79+
encoder_cfg={"policy": SharedEncoderMLPModelCfg.EncoderCfg(hidden_dims=[256], latent_dim=64)},
80+
)
81+
algorithm = RslRlPpoAlgorithmCfg(
82+
class_name="isaaclab_tasks.contrib.keyboard.agents.models:SharedEncoderPPO",
83+
value_loss_coef=1.0,
84+
use_clipped_value_loss=True,
85+
clip_param=0.2,
86+
entropy_coef=0.005,
87+
num_learning_epochs=5,
88+
num_mini_batches=4,
89+
learning_rate=1.0e-4,
90+
schedule="adaptive",
91+
gamma=0.995,
92+
lam=0.90,
93+
desired_kl=0.01,
94+
max_grad_norm=1.0,
95+
)

0 commit comments

Comments
 (0)