Instructions to use maximellerbach/omx-multicubes-mujoco with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LeRobot
How to use maximellerbach/omx-multicubes-mujoco with LeRobot:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
File size: 17,853 Bytes
83f121c a2183f4 83f121c a2183f4 83f121c f19469c 83f121c a2183f4 83f121c a2183f4 83f121c a2183f4 83f121c a2183f4 83f121c a2183f4 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c 83f121c f19469c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 | # Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""EnvHub environment: the ROBOTIS OMX multicubes setup of the ``maximellerbach/omx_multicubes`` dataset.
A ROBOTIS OMX arm (ROBOTIS's ``robotis_omx`` MJCF, bundled, Apache-2.0) on a grey board fenced by wooden
slats, with four painted wooden cubes to place in a blue tape square, seen by a top camera and a wrist
camera. The layout, both cameras, the joint zero offsets and the camera delay were fitted to the dataset's
images and joint states.
State and actions use the dataset's units, those of LeRobot's ``omx_follower`` with its default
``use_degrees=False`` and factory calibration: -100..100 for the arm joints (100 = 180 degrees from the
servo center) and 0..100 for the gripper (50 = closed). Actions are absolute position targets at 30 fps.
Each step's reward is the fraction of cubes resting in the square; the episode succeeds (and terminates)
when all four are in, and is truncated after ``episode_length`` steps.
"""
from collections import deque
from functools import partial
from pathlib import Path
from typing import Any
import gymnasium as gym
import mujoco
import numpy as np
from gymnasium import spaces
REPO_ID = "maximellerbach/omx-multicubes-mujoco"
# Not resolved: in the Hugging Face cache, repository files are symlinks into blobs/ named by hash.
HERE = Path(__file__).absolute().parent
# Bundled from ROBOTIS-GIT/robotis_mujoco_menagerie at commit d8344c0dbe7a00208d0301111523dde65efc174a.
ROBOT_MODEL = "robotis_omx/omx.xml"
SCENE_FILE = "omx_multicubes.xml"
MOUNT_SITE = "omx_mount"
JOINTS = ("shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper")
SIM_JOINTS = ("Joint1", "Joint2", "Joint3", "Joint4", "Joint5", "Gripper")
JOINT_NAMES = [f"{joint}.pos" for joint in JOINTS]
# Every dataset episode starts here: shoulder folded back, forearm forward, gripper open.
HOME = (0.0, -50.0, 50.0, 0.0, 0.0, 60.0)
# Fitted on the dataset: the arm's joint angles minus the model's at the same state (radians). The top
# camera fit gives all five; the shoulder gets 4 more degrees from the grasps (the real arm reaches lower
# than its encoders report, through gear backlash and sag), so that replayed dataset grasps close at
# mid-cube height.
JOINT_OFFSETS = (-0.0157, 0.0920, 0.1046, 0.1542, 0.0135)
# The wrist camera on link5 (in the frame where x points along the gripper), fitted on the dataset.
WRIST_CAMERA = {
"pos": [0.0440, -0.0039, 0.0350],
"quat": [0.58949, -0.35461, -0.39399, 0.60953],
"fovy": 77.17,
}
# The real fingers are Fin Ray style: they bend around what they grasp. Holding a cube, the gripper reads
# 52.8 (10 degrees open) in the datasets, closer than the rigid fingers' geometry allows. Each finger's
# collision hull is cut by the plane its inner face reaches at that opening, so the cut faces close
# parallel on the cube there, as the bent real fingers do, instead of pressing into it.
GRIPPER_HOLD = 52.8
# The gripper servo runs in current-limited position mode: an XL330's stall torque (N.m). The armature
# and friction let it follow the datasets' gripper commands as closely as the real one does.
GRIPPER_FORCE = 0.5
GRIPPER_ARMATURE = 0.005
GRIPPER_FRICTIONLOSS = 0.02
FINGER_FRICTION = (1.5, 0.02, 0.002)
# Stiff finger contacts, as for the cubes in the scene: soft ones let the cubes sink and slip.
FINGER_SOLREF = (0.004, 1.0)
FINGER_SOLIMP = (0.95, 0.99, 0.001, 0.5, 2.0)
# The dataset's images lag its joint states by about two frames.
IMAGE_DELAY = 2
# How the LeRobot simulator server describes this environment (ignored in-process).
LEROBOT_SIM = {
"fps": 30,
"control": "position",
"semantics": "omx-follower-range-m100-v1",
"state_names": JOINT_NAMES,
"action_names": JOINT_NAMES,
"gripper_indices": [5],
}
CUBE_BODIES = ("red_cube", "green_cube", "blue_cube", "yellow_cube")
# 2.9 cm cubes, fitted on the dataset's top images.
CUBE_HALF = 0.0145
# One suite per dataset: its cubes, the x, y ranges their centers start in, instruction, episode length.
TASKS: dict[str, dict[str, Any]] = {
# maximellerbach/omx_multicubes: four cubes in a loose cluster on the arm's left.
"omx_multicubes": {
"cubes": CUBE_BODIES,
"region": ((0.09, 0.22), (0.14, 0.28)),
"instruction": "pick all the cubes and place them one by one in the blue square",
"episode_length": 3600,
},
# maximellerbach/omx_pickandplace: the red cube alone, anywhere on the arm's left.
"omx_pickandplace": {
"cubes": ("red_cube",),
"region": ((0.15, 0.33), (0.04, 0.26)),
"instruction": "Pick the red cube and place it in the blue square",
"episode_length": 900,
},
}
# The tape square (outer edges); a cube is in it when its center is inside and it rests on the board.
SQUARE = ((0.108, 0.236), (-0.264, -0.111))
IN_SQUARE_MAX_HEIGHT = 0.03
def repo_root() -> Path:
"""Directory holding the scene and the arm model.
LeRobot's in-process loader downloads ``env.py`` alone, into ``snapshots/<commit>/`` of the Hub
cache; fetch the rest of that same commit. A local checkout or a full snapshot needs nothing.
"""
if (HERE / SCENE_FILE).is_file():
return HERE
from huggingface_hub import snapshot_download
revision = HERE.name if HERE.parent.name == "snapshots" else None
return Path(snapshot_download(REPO_ID, revision=revision))
def finger_contact_vertices(arm: mujoco.MjModel) -> dict[str, np.ndarray]:
"""Each finger's collision mesh vertices in its body frame, cut by the plane of ``GRIPPER_HOLD``.
At that opening the two cut faces are parallel to the gripper's axis, a cube's width apart; the
vertices beyond a finger's plane are moved onto it, so the finger's convex hull ends at the plane.
"""
hold = (GRIPPER_HOLD - 50) * np.pi / 50
pivots = {finger: arm.body(finger).pos for finger in ("link6", "link7")}
center = (pivots["link6"][1] + pivots["link7"][1]) / 2
vertices = {}
# link6 opens toward +y of link5, its mimic link7 toward -y.
for finger, side in (("link6", 1), ("link7", -1)):
body = arm.body(finger).id
geom = next(g for g in range(arm.ngeom) if arm.geom_bodyid[g] == body and arm.geom_contype[g])
mesh = arm.geom_dataid[geom]
start = arm.mesh_vertadr[mesh]
rotation = np.zeros(9)
mujoco.mju_quat2Mat(rotation, arm.geom_quat[geom])
local = (
arm.geom_pos[geom]
+ arm.mesh_vert[start : start + arm.mesh_vertnum[mesh]] @ rotation.reshape(3, 3).T
)
# The finger at the holding opening, in link5's frame.
c, s = np.cos(side * hold), np.sin(side * hold)
opened = np.array([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]])
held = pivots[finger] + local @ opened.T
plane = center + side * CUBE_HALF
held[:, 1] = np.maximum(held[:, 1], plane) if side > 0 else np.minimum(held[:, 1], plane)
vertices[finger] = (held - pivots[finger]) @ opened
return vertices
def build_model(root: Path) -> mujoco.MjModel:
"""Compile the scene with the OMX attached at its mount site, its gripper and the wrist camera on link5."""
spec = mujoco.MjSpec.from_file(str(root / SCENE_FILE))
arm = mujoco.MjSpec.from_file(str(root / ROBOT_MODEL))
# The arm's keyframe would not match the scene's free cubes.
for key in list(arm.keys):
arm.delete(key)
arm.body("link5").add_camera(name="wrist", **WRIST_CAMERA)
for joint in ("Gripper", "Gripper_mimic"):
arm.joint(joint).armature = GRIPPER_ARMATURE
arm.joint(joint).frictionloss = GRIPPER_FRICTIONLOSS
arm.actuator("Gripper").forcerange = [-GRIPPER_FORCE, GRIPPER_FORCE]
contact = finger_contact_vertices(mujoco.MjSpec.from_file(str(root / ROBOT_MODEL)).compile())
for finger, vertices in contact.items():
arm.add_mesh(name=f"{finger}_contact", uservert=vertices.flatten().tolist())
for geom in arm.body(finger).geoms:
if geom.contype or geom.conaffinity:
geom.meshname = f"{finger}_contact"
geom.pos, geom.quat = [0, 0, 0], [1, 0, 0, 0]
geom.friction = FINGER_FRICTION
geom.condim = 4
geom.solref = FINGER_SOLREF
geom.solimp = FINGER_SOLIMP
spec.attach(arm, site=MOUNT_SITE, prefix="")
return spec.compile()
class _World:
"""Compiled model, physics state, renderer and reset randomization of the OMX scene."""
def __init__(self, root: Path, fps: int, height: int, width: int):
self.model = build_model(root)
# The control period must be a whole number of physics steps.
self.substeps = max(1, round(1 / (fps * self.model.opt.timestep)))
self.model.opt.timestep = 1 / (fps * self.substeps)
self.data = mujoco.MjData(self.model)
self.renderer = mujoco.Renderer(self.model, height, width)
self.qadr = np.array([self.model.joint(name).qposadr[0] for name in SIM_JOINTS])
self.mimic = self.model.joint("Gripper_mimic").qposadr[0]
self.actuators = np.array([self.model.actuator(name).id for name in SIM_JOINTS])
self.lo, self.hi = self.model.actuator_ctrlrange[self.actuators].T.copy()
self.cubes = {name: self.model.body(name).id for name in CUBE_BODIES}
self.cube_qadr = {
name: self.model.jnt_qposadr[self.model.body_jntadr[body]] for name, body in self.cubes.items()
}
def reset(
self, rng: np.random.Generator, joints: np.ndarray, joint_noise: float, cubes: tuple[str, ...], region
) -> None:
"""Put the arm at its initial joints, the task's cubes at seeded, non-overlapping poses in ``region``
and the other cubes off the board."""
model, data = self.model, self.data
mujoco.mj_resetData(model, data)
q = self.to_ctrl(joints + rng.uniform(-joint_noise, joint_noise, len(JOINTS)))
data.qpos[self.qadr] = q
data.qpos[self.mimic] = -q[5]
data.ctrl[self.actuators] = q
for k, name in enumerate(n for n in CUBE_BODIES if n not in cubes):
adr = self.cube_qadr[name]
data.qpos[adr : adr + 3] = (1.0 + 0.1 * k, 1.0, CUBE_HALF - 0.02)
placed: list[np.ndarray] = []
for name in cubes:
adr = self.cube_qadr[name]
for _ in range(200):
xy = np.array([rng.uniform(*region[0]), rng.uniform(*region[1])])
if all(np.linalg.norm(xy - other) > 2.9 * CUBE_HALF for other in placed):
break
else:
raise ValueError("Could not place the cubes without overlap")
placed.append(xy)
yaw = rng.uniform(-np.pi, np.pi)
data.qpos[adr : adr + 3] = (*xy, CUBE_HALF)
data.qpos[adr + 3 : adr + 7] = (np.cos(yaw / 2), 0.0, 0.0, np.sin(yaw / 2))
mujoco.mj_forward(model, data)
def to_ctrl(self, values: np.ndarray) -> np.ndarray:
"""Convert the dataset's units to joint radians."""
values = values.astype(np.float64)
ctrl = np.empty(len(JOINTS))
ctrl[:5] = values[:5] * np.pi / 100 + JOINT_OFFSETS
ctrl[5] = (values[5] - 50) * np.pi / 50
return np.clip(ctrl, self.lo, self.hi)
def joint_state(self) -> np.ndarray:
"""Joint positions in the units of ``to_ctrl``."""
q = self.data.qpos[self.qadr]
state = np.empty(len(JOINTS))
state[:5] = (q[:5] - JOINT_OFFSETS) * 100 / np.pi
state[5] = q[5] * 50 / np.pi + 50
return state.astype(np.float32)
def cubes_in_square(self, cubes: tuple[str, ...]) -> int:
"""Number of ``cubes`` resting with their center in the tape square."""
xyz = self.data.xpos[[self.cubes[name] for name in cubes]]
inside = (
(xyz[:, 0] > SQUARE[0][0])
& (xyz[:, 0] < SQUARE[0][1])
& (xyz[:, 1] > SQUARE[1][0])
& (xyz[:, 1] < SQUARE[1][1])
& (xyz[:, 2] < IN_SQUARE_MAX_HEIGHT)
)
return int(inside.sum())
def render(self, camera: str) -> np.ndarray:
self.renderer.update_scene(self.data, camera=camera)
return self.renderer.render().copy()
class OmxMulticubesEnv(gym.Env):
"""OMX joint position control on the multicubes board, observed through the top and wrist cameras."""
metadata = {"render_modes": ["rgb_array"], "render_fps": 30}
def __init__(
self,
task: str = "omx_multicubes",
fps: int = 30,
episode_length: int | None = None,
observation_height: int = 480,
observation_width: int = 640,
instruction: str | None = None,
initial_joints: tuple[float, ...] = HOME,
joint_noise: float = 1.0,
image_delay: int = IMAGE_DELAY,
):
super().__init__()
if task not in TASKS:
raise ValueError(f"Unknown task {task!r}; choose from {sorted(TASKS)}")
self.task = task
self.cube_names = TASKS[task]["cubes"]
self.fps = fps
self._max_episode_steps = episode_length or TASKS[task]["episode_length"]
self._steps = 0
self.height = observation_height
self.width = observation_width
self.instruction = instruction
self.initial_joints = np.asarray(initial_joints, dtype=np.float32)
if self.initial_joints.shape != (len(JOINTS),):
raise ValueError(f"initial_joints needs one value per joint {JOINTS}")
self.joint_noise = joint_noise
# Observations pair the current joint state with the images of `image_delay` steps earlier.
self.image_delay = image_delay
self._images: deque[dict[str, np.ndarray]] = deque(maxlen=image_delay + 1)
self.cameras = ("top", "wrist")
self._world: _World | None = None
image = spaces.Box(0, 255, (self.height, self.width, 3), np.uint8)
self.observation_space = spaces.Dict(
{
"agent_pos": spaces.Box(-200.0, 200.0, (len(JOINTS),), np.float32),
"pixels": spaces.Dict(dict.fromkeys(self.cameras, image)),
}
)
self.action_space = spaces.Box(-200.0, 200.0, (len(JOINTS),), np.float32)
@property
def task_description(self) -> str:
"""The configured instruction, else the dataset's task."""
return self.instruction if self.instruction is not None else TASKS[self.task]["instruction"]
def _ensure_world(self) -> _World:
"""Build the world on first use, so async workers each own their GL context."""
if self._world is None:
self._world = _World(repo_root(), self.fps, self.height, self.width)
return self._world
def render(self, camera: str | None = None) -> np.ndarray:
"""Render one camera now (no delay), the top camera by default."""
return self._ensure_world().render(camera or "top")
def _obs(self, world: _World) -> dict[str, Any]:
self._images.append({camera: world.render(camera) for camera in self.cameras})
return {"agent_pos": world.joint_state(), "pixels": self._images[0]}
def reset(self, seed: int | None = None, **kwargs) -> tuple[dict[str, Any], dict[str, Any]]:
"""Put the arm at its initial pose and the cubes at seeded positions."""
world = self._ensure_world()
super().reset(seed=seed)
world.reset(
self.np_random, self.initial_joints, self.joint_noise, self.cube_names, TASKS[self.task]["region"]
)
self._steps = 0
self._images.clear()
first = {camera: world.render(camera) for camera in self.cameras}
self._images.extend([first] * self.image_delay)
return self._obs(world), {"is_success": False, "cubes_in_square": 0}
def step(self, action: np.ndarray) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
"""Hold the joint targets for one control period."""
world = self._ensure_world()
action = np.asarray(action)
if action.shape != self.action_space.shape:
raise ValueError(f"Expected action of shape {self.action_space.shape}, got {action.shape}")
world.data.ctrl[world.actuators] = world.to_ctrl(action)
mujoco.mj_step(world.model, world.data, nstep=world.substeps)
self._steps += 1
in_square = world.cubes_in_square(self.cube_names)
success = in_square == len(self.cube_names)
truncated = self._steps >= self._max_episode_steps
info = {"is_success": success, "cubes_in_square": in_square}
return self._obs(world), in_square / len(self.cube_names), success, truncated, info
def close(self) -> None:
if self._world is not None:
self._world.renderer.close()
self._world = None
def make_env(n_envs: int = 1, use_async_envs: bool = False, **kwargs: Any) -> dict[str, dict[int, Any]]:
"""EnvHub entry point: one suite per task, ``{task: {0: vector_env}}``; ``kwargs`` override the env's."""
vector_cls = gym.vector.AsyncVectorEnv if use_async_envs else gym.vector.SyncVectorEnv
return {
task: {0: vector_cls([partial(OmxMulticubesEnv, task=task, **kwargs)] * n_envs)} for task in TASKS
}
|