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
    }