File size: 14,137 Bytes
527ac0f
 
 
 
 
f78630a
 
 
 
527ac0f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f78630a
527ac0f
 
 
 
 
2971e95
527ac0f
 
 
0ed6fa7
 
527ac0f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ed6fa7
527ac0f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393

"""Central configuration for the SimEngine."""

import os
import numpy as np
import gymnasium as gym
import mujoco
import numpy as np
from gymnasium import spaces

os.environ["MUJOCO_GL"] = "egl"

# Joint names in correct order (matches real SO-101)
JOINT_NAMES = [
    "shoulder_pan",
    "shoulder_lift",
    "elbow_flex",
    "wrist_flex",
    "wrist_roll",
    "gripper",
]

N_JOINTS = 6  # arm joints (excluding gripper)
N_ACTIONS = 7  # 6 arm joints + 1 gripper

# Joint limits in radians (used by MuJoCo actuators)
JOINT_LIMITS_RAD = {
    "shoulder_pan":  (-np.pi, np.pi),
    "shoulder_lift": (-np.pi / 2, np.pi / 2),
    "elbow_flex":    (-np.pi, 0.0),
    "wrist_flex":    (-np.pi / 2, np.pi / 2),
    "wrist_roll":    (-np.pi, np.pi),
    "gripper":       (0.0, 0.04),  # finger separation in meters
}

# Home position in radians (arm straight up, out of workspace)
HOME_QPOS_RAD = np.array([0.0, 0.0, -0.3, -0.3, 0.0, 0.04])

# Camera settings
CAMERA_WIDTH = 640
CAMERA_HEIGHT = 480
CAMERA_NAMES = ["webcam", "arm_cam"]

# Physics
CONTROL_FREQ = 30  # Hz
PHYSICS_TIMESTEP = 0.002  # seconds
PHYSICS_SUBSTEPS = int(1.0 / CONTROL_FREQ / PHYSICS_TIMESTEP)

# Episode
#MAX_EPISODE_STEPS = 500  # ~17 seconds at 30 Hz

# Task
DEFAULT_TASK = "Pick up the box and place it in the bowl"

# Paths
#BASE_DIR = "/content"  # жёстко задаём корень проекта в Colab
#ASSETS_DIR = os.path.join(BASE_DIR, "assets")
SCENE_XML = os.path.join("so101_scene.xml")

# Проверяем, что сцена существует, иначе будет понятная ошибка
if not os.path.exists(SCENE_XML):
    raise FileNotFoundError(f"Scene XML not found at {SCENE_XML}. Did you clone the repo and check assets/?")

DEFAULT_DATASET_DIR = os.path.join("datasets", "sim_demos")
DEFAULT_OUTPUT_DIR = os.path.join("outputs", "sim_engine")

# Создаём папки, если их нет
os.makedirs(DEFAULT_DATASET_DIR, exist_ok=True)
os.makedirs(DEFAULT_OUTPUT_DIR, exist_ok=True)

# Object positions (meters, relative to world origin at table center)
BOX_DEFAULT_POS = np.array([0.15, 0.0, 0.44])   # on table surface
BOWL_DEFAULT_POS = np.array([-0.15, 0.0, 0.41])  # on table surface
BOWL_RADIUS = 0.05
BOWL_HEIGHT = 0.04

# Python executable (use the venv)
PYTHON = "python3"

class OffscreenRenderer:
    """Renders camera images from MuJoCo scenes."""

    def __init__(self, model: mujoco.MjModel, width: int = 640, height: int = 480):
        self.model = model
        self.width = width
        self.height = height
        self._renderer = mujoco.Renderer(model, height, width)

    def render(self, data: mujoco.MjData, camera_name: str) -> np.ndarray:
        self._renderer.update_scene(data, camera=camera_name)
        return self._renderer.render().copy()

    def close(self):
        if hasattr(self, '_renderer'):
            self._renderer.close()
            

"""Gymnasium environment for SO-101 pick-and-place in MuJoCo."""



class SO101PickPlaceEnv(gym.Env):

    metadata = {"render_modes": ["human", "rgb_arrхay"], "render_fps": CONTROL_FREQ}

    def __init__(self, max_episode_steps, render_mode="rgb_array", render_images=True):
        super().__init__()
        self.render_mode = render_mode
        self.render_images = render_images
        self.max_episode_steps = max_episode_steps
       
        # Load MuJoCo model
        self.model = mujoco.MjModel.from_xml_path(SCENE_XML)
        self.model.opt.timestep = PHYSICS_TIMESTEP
        self.data = mujoco.MjData(self.model)

        # Camera renderer
        if self.render_images:
            self._renderer = OffscreenRenderer(
                self.model, CAMERA_WIDTH, CAMERA_HEIGHT
            )
        else:
            self._renderer = None

        # Cache body/joint/actuator IDs
        self._arm_joint_ids = [
            mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, name)
            for name in JOINT_NAMES[:5]  # 5 arm joints
        ]
        self._finger_left_jnt = mujoco.mj_name2id(
            self.model, mujoco.mjtObj.mjOBJ_JOINT, "finger_left"
        )
        self._box_body = mujoco.mj_name2id(
            self.model, mujoco.mjtObj.mjOBJ_BODY, "box"
        )
        self._bowl_body = mujoco.mj_name2id(
            self.model, mujoco.mjtObj.mjOBJ_BODY, "bowl"
        )
        self._ee_site = mujoco.mj_name2id(
            self.model, mujoco.mjtObj.mjOBJ_SITE, "ee_site"
        )

        # Actuator control ranges
        self._ctrl_ranges = self.model.actuator_ctrlrange.copy()

        # Snap-grasp state (kinematic attachment when gripper closes near box)
        self._grasped = False
        self._grasp_offset = np.zeros(3)  # box offset from EE when grasped
        self._grasp_threshold = 0.06  # EE-to-box distance to trigger grasp
        self._gripper_close_threshold = 0.018  # finger qpos below this = closed
        self._box_qpos_adr = self.model.jnt_qposadr[
            mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "box_joint")
        ]
        self._box_dof_adr = self.model.jnt_dofadr[
            mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "box_joint")
        ]

        # Remember initial state for reset
        mujoco.mj_forward(self.model, self.data)
        self._init_qpos = self.data.qpos.copy()
        self._init_qvel = self.data.qvel.copy()

        # Spaces
        self.action_space = spaces.Box(-1.0, 1.0, shape=(7,), dtype=np.float32)

        obs_spaces = {"state": spaces.Box(-np.inf, np.inf, shape=(7,), dtype=np.float32)}
        if self.render_images:
            obs_spaces["images"] = spaces.Dict({
                name: spaces.Box(0, 255, shape=(CAMERA_HEIGHT, CAMERA_WIDTH, 3), dtype=np.uint8)
                for name in CAMERA_NAMES
            })
        self.observation_space = spaces.Dict(obs_spaces)

        self._step_count = 0
        self._viewer = None

    # ------------------------------------------------------------------
    # Gym interface
    # ------------------------------------------------------------------

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)

        # Reset state
        self.data.qpos[:] = self._init_qpos
        self.data.qvel[:] = self._init_qvel
        self.data.ctrl[:] = 0.0

        # Set arm to home position
        for i, jname in enumerate(JOINT_NAMES[:5]):
            jid = self._arm_joint_ids[i]
            qadr = self.model.jnt_qposadr[jid]
            self.data.qpos[qadr] = HOME_QPOS_RAD[i]

        # Open gripper
        fl_qadr = self.model.jnt_qposadr[self._finger_left_jnt]
        self.data.qpos[fl_qadr] = 0.04  # fully open

        # Randomize box position slightly
        box_qpos_adr = self.model.jnt_qposadr[
            mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "box_joint")
        ]
        self.data.qpos[box_qpos_adr:box_qpos_adr + 3] = BOX_DEFAULT_POS + self.np_random.uniform(
            low=[-0.03, -0.03, 0.0], high=[0.03, 0.03, 0.0]
        )
        # Reset box orientation to identity quaternion
        self.data.qpos[box_qpos_adr + 3:box_qpos_adr + 7] = [1, 0, 0, 0]
        # Zero box velocity
        box_dof_adr = self.model.jnt_dofadr[
            mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "box_joint")
        ]
        self.data.qvel[box_dof_adr:box_dof_adr + 6] = 0.0

        # Reset grasp state
        self._grasped = False
        self._grasp_offset = np.zeros(3)

        # Set actuator controls to match initial positions
        for i in range(5):
            jid = self._arm_joint_ids[i]
            qadr = self.model.jnt_qposadr[jid]
            self.data.ctrl[i] = self.data.qpos[qadr]
        self.data.ctrl[5] = 0.04  # left finger open
        self.data.ctrl[6] = 0.04  # right finger open

        # Settle physics
        for _ in range(50):
            mujoco.mj_step(self.model, self.data)

        self._step_count = 0
        obs = self._get_obs()
        return obs, self._get_info()

    def step(self, action):
        action = np.clip(action, -1.0, 1.0).astype(np.float32)

        # Scale action from [-1,1] to actuator control ranges
        # First 5: arm joints, 6th: gripper (controls both fingers)
        for i in range(5):
            lo, hi = self._ctrl_ranges[i]
            self.data.ctrl[i] = lo + (action[i] + 1.0) * 0.5 * (hi - lo)

        # Gripper: action[5] in [-1,1] -> finger separation [0.005, 0.04]
        # Min 0.005 prevents fingers from squeezing box out
        gripper_val = 0.005 + (action[5] + 1.0) * 0.5 * 0.035
        self.data.ctrl[5] = gripper_val  # left finger
        self.data.ctrl[6] = gripper_val  # right finger (coupled but set explicitly)

        # action[6] is the second gripper dim (ignored if coupled, or can be used for asymmetric grip)
        # For simplicity, we use action[5] for both fingers

        # Update snap-grasp (based on gripper action command)
        self._update_grasp(action[5])

        # Step physics
        for _ in range(PHYSICS_SUBSTEPS):
            mujoco.mj_step(self.model, self.data)

        self._step_count += 1
        obs = self._get_obs()
        info = self._get_info()

        reward = self._compute_reward(info)
        terminated = info["success"]
        truncated = self._step_count >= self.max_episode_steps

        return obs, reward, terminated, truncated, info

    def render(self):
        if self.render_mode == "rgb_array" and self._renderer is not None:
            return self._renderer.render(self.data, "webcam")
        elif self.render_mode == "human":
            self._render_human()
        return None

    def close(self):
        if self._renderer is not None:
            self._renderer.close()
        if self._viewer is not None:
            self._viewer.close()

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    def _update_grasp(self, gripper_action):
        ee_pos = self.data.site_xpos[self._ee_site].copy()
        box_pos = self.data.xpos[self._box_body].copy()
        dist = np.linalg.norm(ee_pos - box_pos)

        fl_qadr = self.model.jnt_qposadr[self._finger_left_jnt]
        finger_qpos = self.data.qpos[fl_qadr]
        gripper_closing = gripper_action < -0.5  # action commands close
        gripper_opening = gripper_action > 0.5   # action commands open

        if not self._grasped and gripper_closing and finger_qpos < self._gripper_close_threshold and dist < self._grasp_threshold:
            self._grasp_offset = box_pos - ee_pos
            self._grasped = True
        elif self._grasped and gripper_opening:
            self._grasped = False

        if self._grasped:
            new_box_pos = ee_pos + self._grasp_offset
            self.data.qpos[self._box_qpos_adr:self._box_qpos_adr + 3] = new_box_pos
            self.data.qvel[self._box_dof_adr:self._box_dof_adr + 6] = 0.0

    def _get_obs(self):
        # Joint positions in degrees
        joint_pos = np.zeros(5, dtype=np.float32)
        for i, jid in enumerate(self._arm_joint_ids):
            qadr = self.model.jnt_qposadr[jid]
            joint_pos[i] = np.rad2deg(self.data.qpos[qadr])

        # Gripper opening: 0 (closed) to 100 (open)
        fl_qadr = self.model.jnt_qposadr[self._finger_left_jnt]
        gripper_pos = self.data.qpos[fl_qadr]
        gripper_normalized = np.float32(np.clip(gripper_pos / 0.04 * 100.0, 0, 100))

        state = np.append(joint_pos, gripper_normalized).astype(np.float32)

        obs = {"state": state}
        if self.render_images and self._renderer is not None:
            obs["images"] = {
                name: self._renderer.render(self.data, name)
                for name in CAMERA_NAMES
            }
        return obs

    def _get_info(self):
        box_pos = self.data.xpos[self._box_body].copy()
        bowl_pos = self.data.xpos[self._bowl_body].copy()
        ee_pos = self.data.site_xpos[self._ee_site].copy()

        # Check if box is in the bowl
        horizontal_dist = np.linalg.norm(box_pos[:2] - bowl_pos[:2])
        box_above_bowl_base = box_pos[2] > bowl_pos[2] - 0.01
        box_below_bowl_rim = box_pos[2] < bowl_pos[2] + BOWL_HEIGHT + 0.02
        success = horizontal_dist < BOWL_RADIUS and box_above_bowl_base and box_below_bowl_rim

        return {
            "success": bool(success),
            "box_pos": box_pos,
            "bowl_pos": bowl_pos,
            "ee_pos": ee_pos,
            "ee_to_box_dist": float(np.linalg.norm(ee_pos - box_pos)),
            "box_to_bowl_dist": float(np.linalg.norm(box_pos[:2] - bowl_pos[:2])),
        }

    def _compute_reward(self, info):
        reward = 0.0
        # Shaping: encourage approaching the box
        reward -= 0.1 * info["ee_to_box_dist"]
        # Grasp bonus: box is lifted above table
        if info["box_pos"][2] > 0.47:
            reward += 1.0
            # Shaping: encourage moving box toward bowl
            reward -= 0.1 * info["box_to_bowl_dist"]
        # Placement bonus
        if info["success"]:
            reward += 5.0
        return reward

    def _render_human(self):
        """Open an interactive MuJoCo viewer."""
        if self._viewer is None:
            import mujoco.viewer
            self._viewer = mujoco.viewer.launch_passive(self.model, self.data)
        else:
            self._viewer.sync()

    # ------------------------------------------------------------------
    # Utilities
    # ------------------------------------------------------------------

    def get_ee_pos(self):
        """Get end-effector position in world frame."""
        return self.data.site_xpos[self._ee_site].copy()

    def get_box_pos(self):
        """Get box position in world frame."""
        return self.data.xpos[self._box_body].copy()

    def get_bowl_pos(self):
        """Get bowl position in world frame."""
        return self.data.xpos[self._bowl_body].copy()

    @property
    def arm_joint_ids(self):
        return self._arm_joint_ids

    @property
    def ee_site_id(self):
        return self._ee_site