LEAP hand: spinning a cube (PPO, MuJoCo Playground)
A policy for the LEAP hand (16 joints, palm up) that turns a cube in its palm about the vertical axis,
trained with PPO (Brax) in MuJoCo Playground's LeapCubeRotateZAxis with random friction, masses and
joint properties, and exported to plain NumPy. Simulation only: not validated on a real hand.
Spinning the cube, slow motion, holding it still, and following a plain-language instruction.
Results
Tested on 128 randomized hands, 10 seconds each:
| measured | goal | |
|---|---|---|
| drops the cube | 2.3 % of episodes | under 10 % |
| keeps the cube in the palm | 97.9 % of the time | |
| spin speed | 0.94 rad/s (1.47 turns in 10 s) | 1.0 rad/s, not reached yet |
In the video: spin for 6 s turns the cube 2.79 rad; holding still (zero action) for 3 s it moves 0.28 rad; no drop.
Following plain language. "spin the cube for five seconds, then hold it still for three seconds": a language model running locally planned it in 1.4 s as spin 5 s, hold 3 s; the hand turned the cube 1.32 rad, then held it within 0.19 rad, no drop. The planner is not part of this model: it only chooses between this policy ("spin") and zero action ("hold").
Spin for five seconds, then hold it still for three seconds.
How it was trained
- Environment: MuJoCo Playground 0.2.0
LeapCubeRotateZAxis, control every 0.05 s, joint targets = default pose + 0.6 × action. - Observation (32 numbers): the 16 joint angles (noisy in training) and the 16 previous actions. The cube's pose and speed were given only to the value network during training (asymmetric actor-critic).
- Network: MLP 512-256-128, swish; output 2 × 16 (mean, scale); action = tanh(mean).
- Training: Brax PPO, 100 M steps, 8192 parallel hands, 4.3 hours on a home server's CPU (AMD Ryzen AI MAX+ 395); Playground's LEAP domain randomizer. Evaluation reward rose from −0.58 to 27.65 and was still rising.
Use
import numpy as np
z = np.load("policy.npz")
n = int(z["n_layers"])
W, b = [z[f"w{i}"] for i in range(n)], [z[f"b{i}"] for i in range(n)]
def act(obs):
"""Deterministic action in [-1, 1] for one observation (Brax PPO inference, no JAX needed)."""
x = (obs - z["obs_mean"]) / z["obs_std"]
for w, bias in zip(W[:-1], b[:-1]):
x = x @ w + bias
x = 0.5 * x * (1 + np.tanh(0.5 * x)) # swish
out = x @ W[-1] + b[-1]
return np.tanh(out[: int(z["action_size"])])
targets = z["default_pose"] + z["action_scale"] * act(obs) # joint position targets
The export keeps only the policy (the normaliser statistics of the 32-number observation and the MLP);
it matches Brax's deterministic inference within 1e-5. Joint order: actuator_names in the file.
Experiments behind these numbers: YauhenBichel/humanoid-lab-experiments.
Limits
Simulation only; spins slower than the goal; one skill (turning about the vertical axis). Not a medical, care or safety-rated device.
Credits: MuJoCo Playground and the LEAP hand model (Apache-2.0). Not affiliated with the LEAP hand authors or Google DeepMind.