mujoco-toolbox 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mujoco_toolbox/__init__.py +77 -0
- mujoco_toolbox/assets.py +29 -0
- mujoco_toolbox/builder.py +229 -0
- mujoco_toolbox/controller.py +164 -0
- mujoco_toolbox/utils.py +134 -0
- mujoco_toolbox/wrapper.py +893 -0
- mujoco_toolbox-0.3.4.dist-info/LICENSE +21 -0
- mujoco_toolbox-0.3.4.dist-info/METADATA +150 -0
- mujoco_toolbox-0.3.4.dist-info/RECORD +10 -0
- mujoco_toolbox-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any, TypeAlias
|
|
10
|
+
|
|
11
|
+
import mediapy as media
|
|
12
|
+
import mujoco
|
|
13
|
+
import mujoco.viewer
|
|
14
|
+
import numpy as np
|
|
15
|
+
import trimesh
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .utils import _print_warning
|
|
19
|
+
|
|
20
|
+
assert sys.version_info >= (3, 10), "This code requires Python 3.10.0 or later."
|
|
21
|
+
assert mujoco.__version__ >= "2.0.0", "This code requires MuJoCo 2.0.0 or later."
|
|
22
|
+
|
|
23
|
+
mjModel: TypeAlias = mujoco.MjModel
|
|
24
|
+
mjData: TypeAlias = mujoco.MjData
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Wrapper:
|
|
28
|
+
"""A class to handle MuJoCo simulations."""
|
|
29
|
+
|
|
30
|
+
def __init__(self,
|
|
31
|
+
xml:str,
|
|
32
|
+
duration:int=10,
|
|
33
|
+
data_rate:int=100,
|
|
34
|
+
fps:int=30,
|
|
35
|
+
resolution:tuple[int,int]=(400,300),
|
|
36
|
+
initial_conditions:dict[str, list] | None=None,
|
|
37
|
+
controller:Callable[[mjModel, mjData, Any], None] | None=None,
|
|
38
|
+
*args,
|
|
39
|
+
**kwargs) -> None:
|
|
40
|
+
# xml = "<mujoco></mujoco>" if xml.strip() == "<mujoco/>" else xml
|
|
41
|
+
self._load_model(xml, **kwargs)
|
|
42
|
+
|
|
43
|
+
self.duration = duration
|
|
44
|
+
self.fps = fps
|
|
45
|
+
self.data_rate = data_rate
|
|
46
|
+
self.resolution = resolution # recursively sets width and height
|
|
47
|
+
self.controller = controller
|
|
48
|
+
|
|
49
|
+
# Predefined simulation parameters but can be overridden
|
|
50
|
+
# TODO: Currently Causing Bugs when occluded from XML Code
|
|
51
|
+
self.ts = kwargs.get("ts", self._model.opt.timestep)
|
|
52
|
+
self.gravity = kwargs.get("gravity", self._model.opt.gravity)
|
|
53
|
+
|
|
54
|
+
self._data = mujoco.MjData(self._model)
|
|
55
|
+
self.init_conditions = initial_conditions or {} # MUST BE AFTER DATA INITIALIZATION
|
|
56
|
+
|
|
57
|
+
# Auto-Populate the names of bodies, joints, and actuators
|
|
58
|
+
self._body_names = [self._model.body(i).name for i in range(self._model.nbody)]
|
|
59
|
+
self._geom_names = [self._model.geom(i).name for i in range(self._model.ngeom)]
|
|
60
|
+
self._joint_names = [self._model.joint(i).name for i in range(self._model.njnt)]
|
|
61
|
+
self._actuator_names = [
|
|
62
|
+
self._model.actuator(i).name for i in range(self._model.nu)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def _load_model(self, xml: str, **kwargs: Any) -> None:
|
|
66
|
+
"""Load a MuJoCo model from an XML file or a string."""
|
|
67
|
+
try:
|
|
68
|
+
# Check if the path exists
|
|
69
|
+
if os.path.exists(xml):
|
|
70
|
+
# Convert the XML path to an absolute path
|
|
71
|
+
xml_path = os.path.abspath(xml)
|
|
72
|
+
# Extract and validate file extension
|
|
73
|
+
extension = os.path.splitext(xml_path)[1].lower()[1:]
|
|
74
|
+
|
|
75
|
+
if extension == "xml":
|
|
76
|
+
self._load_xml_file(xml_path)
|
|
77
|
+
elif extension == "urdf":
|
|
78
|
+
self._load_urdf_file(xml_path, **kwargs)
|
|
79
|
+
else:
|
|
80
|
+
msg = f"Unsupported file extension: '{extension}'. Please provide an XML or URDF file."
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
else:
|
|
83
|
+
# If the file doesn't exist, assume it's a string and attempt to load it as XML
|
|
84
|
+
try:
|
|
85
|
+
ET.fromstring(xml) # Try parsing the string as XML
|
|
86
|
+
self._load_xml_string(xml)
|
|
87
|
+
except ET.ParseError:
|
|
88
|
+
msg = f"Model not able to be loaded from the provided XML string: {xml}"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
except FileNotFoundError as e:
|
|
91
|
+
msg = f"Failed to load the MuJoCo model: {e}"
|
|
92
|
+
raise FileNotFoundError(msg) from e
|
|
93
|
+
except ValueError as e:
|
|
94
|
+
msg = f"Invalid value encountered while loading the model: {e}"
|
|
95
|
+
raise ValueError(msg) from e
|
|
96
|
+
except Exception as e:
|
|
97
|
+
msg = f"Unexpected error while loading the MuJoCo model: {e}"
|
|
98
|
+
raise Exception(msg)
|
|
99
|
+
|
|
100
|
+
def _load_xml_file(self, xml: str, **kwargs) -> None:
|
|
101
|
+
"""Load a MuJoCo model from an XML file."""
|
|
102
|
+
self._model = mujoco.MjModel.from_xml_path(xml)
|
|
103
|
+
with open(xml) as f:
|
|
104
|
+
self.xml = f.read()
|
|
105
|
+
template = kwargs.get("template")
|
|
106
|
+
if template:
|
|
107
|
+
try:
|
|
108
|
+
self.xml = self.xml.format(**template)
|
|
109
|
+
except KeyError:
|
|
110
|
+
msg = "Template key error. Ensure the template keys match the placeholders in the XML."
|
|
111
|
+
raise ValueError(msg)
|
|
112
|
+
except Exception:
|
|
113
|
+
msg = "Error formatting XML with template"
|
|
114
|
+
raise ValueError(msg)
|
|
115
|
+
self._model = mujoco.MjModel.from_xml_string(self.xml)
|
|
116
|
+
|
|
117
|
+
def _load_urdf_file(self, urdf_path: str, **kwargs: Any) -> None:
|
|
118
|
+
"""Process and load a URDF file for use with MuJoCo."""
|
|
119
|
+
# BUG: Generate actuators from joints in URDF so the controller can actually work
|
|
120
|
+
|
|
121
|
+
def convert_dae_to_stl(meshdir: str) -> None:
|
|
122
|
+
"""Convert all DAE files in a directory (including subdirectories) to STL."""
|
|
123
|
+
if not os.path.exists(meshdir):
|
|
124
|
+
msg = f"Directory not found: {meshdir}"
|
|
125
|
+
raise FileNotFoundError(msg)
|
|
126
|
+
for filename in os.listdir(meshdir):
|
|
127
|
+
if filename.lower().endswith(".dae"):
|
|
128
|
+
dae_path = os.path.join(meshdir, filename)
|
|
129
|
+
stl_path = os.path.splitext(dae_path)[0] + ".stl"
|
|
130
|
+
try:
|
|
131
|
+
trimesh.load_mesh(dae_path).export(stl_path)
|
|
132
|
+
# logger.info(f"Converted: {os.path.basename(dae_path)} -> {os.path.basename(stl_path)}")
|
|
133
|
+
except Exception:
|
|
134
|
+
msg = f"Error converting {filename}"
|
|
135
|
+
raise ValueError(msg)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
robot = ET.parse(urdf_path).getroot()
|
|
139
|
+
mujoco_tag = ET.Element("mujoco")
|
|
140
|
+
|
|
141
|
+
# Get main meshdir (parent directory for meshes)
|
|
142
|
+
meshdir = kwargs.get("meshdir", "meshes/") # Default as tuple
|
|
143
|
+
if not os.path.isabs(meshdir):
|
|
144
|
+
urdf_dir = os.path.dirname(os.path.abspath(urdf_path))
|
|
145
|
+
meshdir = os.path.join(urdf_dir, meshdir)
|
|
146
|
+
|
|
147
|
+
# Ensure meshdir exists
|
|
148
|
+
if not os.path.exists(meshdir):
|
|
149
|
+
msg = f"Mesh directory not found: {meshdir}"
|
|
150
|
+
raise FileNotFoundError(msg)
|
|
151
|
+
|
|
152
|
+
# If no explicit subdirs are provided, auto-detect subdirectories with STL files
|
|
153
|
+
subdirs = kwargs.get("meshdir_sub")
|
|
154
|
+
if not subdirs:
|
|
155
|
+
subdirs = [os.path.relpath(root, meshdir) for root, _, files in os.walk(meshdir) if any(f.endswith(".stl") for f in files)]
|
|
156
|
+
# logger.info(f"Auto-detected subdirectories: {subdirs}")
|
|
157
|
+
|
|
158
|
+
# Convert relative subdir paths to absolute based on meshdir
|
|
159
|
+
full_meshdirs = [
|
|
160
|
+
os.path.join(meshdir, subdir) if not os.path.isabs(subdir) else subdir
|
|
161
|
+
for subdir in subdirs
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
# Add <compiler> tag with the main meshdir (required for MuJoCo)
|
|
165
|
+
compiler_attrs = {
|
|
166
|
+
"meshdir": meshdir, # Use the main meshdir
|
|
167
|
+
"balanceinertia": kwargs.get("balanceinertia", "true"),
|
|
168
|
+
"discardvisual": "false",
|
|
169
|
+
}
|
|
170
|
+
ET.SubElement(mujoco_tag, "compiler", **compiler_attrs)
|
|
171
|
+
|
|
172
|
+
# Create <asset> tag (required for MuJoCo)
|
|
173
|
+
asset_tag = ET.SubElement(mujoco_tag, "asset")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Process all valid mesh directories
|
|
177
|
+
for full_meshdir in full_meshdirs:
|
|
178
|
+
if os.path.exists(full_meshdir):
|
|
179
|
+
convert_dae_to_stl(full_meshdir) # Convert DAE to STL in the origin directory
|
|
180
|
+
|
|
181
|
+
# Walk through all files in the directory
|
|
182
|
+
for root, _, files in os.walk(full_meshdir):
|
|
183
|
+
for filename in files:
|
|
184
|
+
if filename.lower().endswith(".stl"):
|
|
185
|
+
relative_dir = os.path.relpath(root, meshdir) # Extract subdir name
|
|
186
|
+
mesh_name = f"{os.path.splitext(filename)[0]}_{relative_dir.replace(os.sep, '_')}" # Format: base_visual
|
|
187
|
+
mesh_file_relative = os.path.join(relative_dir, filename)
|
|
188
|
+
ET.SubElement(asset_tag, "mesh", name=mesh_name, file=mesh_file_relative)
|
|
189
|
+
|
|
190
|
+
robot.insert(0, mujoco_tag)
|
|
191
|
+
self.xml = ET.tostring(robot, encoding="unicode").replace(".dae", ".stl")
|
|
192
|
+
self._model = mujoco.MjModel.from_xml_string(self.xml)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
msg = f"Failed to process URDF file: {e}"
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
|
|
197
|
+
def _load_xml_string(self, xml: str) -> None:
|
|
198
|
+
"""Load a MuJoCo model from an XML string."""
|
|
199
|
+
try:
|
|
200
|
+
self._model = mujoco.MjModel.from_xml_string(xml)
|
|
201
|
+
self.xml = xml
|
|
202
|
+
except Exception as e:
|
|
203
|
+
msg = f"Failed to load the MuJoCo model from XML string: {e}"
|
|
204
|
+
raise ValueError(msg)
|
|
205
|
+
|
|
206
|
+
def reload(self) -> "Wrapper":
|
|
207
|
+
"""Reload the model and data objects."""
|
|
208
|
+
# TODO: Move all model loading to Builder Method
|
|
209
|
+
self._model = mujoco.MjModel.from_xml_string(self.xml)
|
|
210
|
+
self._data = mujoco.MjData(self._model)
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
def __str__(self) -> str:
|
|
214
|
+
return self._model.__str__()
|
|
215
|
+
|
|
216
|
+
def __repr__(self) -> str:
|
|
217
|
+
return (
|
|
218
|
+
f"{self.__class__.__name__}(\n"
|
|
219
|
+
f" Duration: {self.duration}s [fps={self.fps}, ts={self.ts:.0e}]\n"
|
|
220
|
+
f" Gravity: {self.gravity},\n"
|
|
221
|
+
f" Resolution: {self._width}W x {self._height}H\n"
|
|
222
|
+
f" Bodies ({self.model.nbody}): {', '.join(self._body_names[:5])}"
|
|
223
|
+
f"{' ...' if len(self._body_names) > 5 else ''}\n"
|
|
224
|
+
f" Joints ({self.model.njnt}): {', '.join(self._joint_names[:5])}"
|
|
225
|
+
f"{' ...' if len(self._joint_names) > 5 else ''}\n"
|
|
226
|
+
f" Actuators ({self.model.nu}): {', '.join(self._actuator_names[:5])}"
|
|
227
|
+
f"{' ...' if len(self._actuator_names) > 5 else ''}\n"
|
|
228
|
+
f" Controller: {self.controller.__name__ if self.controller else None}\n"
|
|
229
|
+
f")"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def __enter__(self):
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
236
|
+
mujoco.set_mjcb_control(None)
|
|
237
|
+
for thread in threading.enumerate():
|
|
238
|
+
if thread is not threading.main_thread():
|
|
239
|
+
thread.join()
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def model(self) -> mjModel:
|
|
243
|
+
"""Read-only property to access the MjModel object."""
|
|
244
|
+
return self._model
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def data(self) -> mjData:
|
|
248
|
+
"""Read-only property to access the MjData single-step object. Use `captured_data` to access the entire simulation data."""
|
|
249
|
+
return self._data
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def captured_data(self) -> dict[str, np.ndarray]:
|
|
253
|
+
"""Read-only property to access the entire captured simulation data."""
|
|
254
|
+
if self._captured_data is None:
|
|
255
|
+
msg = "No simulation data captured yet."
|
|
256
|
+
raise ValueError(msg)
|
|
257
|
+
return self._captured_data.unwrap()
|
|
258
|
+
|
|
259
|
+
@captured_data.deleter
|
|
260
|
+
def captured_data(self) -> None:
|
|
261
|
+
self._captured_data = None
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def frames(self) -> list[np.ndarray]:
|
|
265
|
+
"""Read-only property to access the captured frames."""
|
|
266
|
+
if self._frames is None:
|
|
267
|
+
msg = "No frames captured yet."
|
|
268
|
+
raise ValueError(msg)
|
|
269
|
+
return self._frames
|
|
270
|
+
|
|
271
|
+
@frames.deleter
|
|
272
|
+
def frames(self) -> None:
|
|
273
|
+
# BUG: This is not working as intended
|
|
274
|
+
self._frames.clear()
|
|
275
|
+
import gc
|
|
276
|
+
gc.collect()
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def duration(self) -> float:
|
|
280
|
+
"""Duration of the simulation in seconds."""
|
|
281
|
+
return self._duration
|
|
282
|
+
|
|
283
|
+
@duration.setter
|
|
284
|
+
def duration(self, value) -> None:
|
|
285
|
+
if value < 0:
|
|
286
|
+
msg = "Duration must be greater than zero."
|
|
287
|
+
raise ValueError(msg)
|
|
288
|
+
self._duration = value
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def fps(self) -> float:
|
|
292
|
+
"""Frames per second."""
|
|
293
|
+
return self._fps
|
|
294
|
+
|
|
295
|
+
@fps.setter
|
|
296
|
+
def fps(self, value) -> None:
|
|
297
|
+
if value < 0:
|
|
298
|
+
msg = "FPS must be greater than zero."
|
|
299
|
+
raise ValueError(msg)
|
|
300
|
+
self._fps = value
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def resolution(self) -> tuple[int, int]:
|
|
304
|
+
"""Resolution of the simulation in pixels."""
|
|
305
|
+
return self._resolution
|
|
306
|
+
|
|
307
|
+
@resolution.setter
|
|
308
|
+
def resolution(self, values) -> None:
|
|
309
|
+
if len(values) != 2:
|
|
310
|
+
msg = "Resolution must be a tuple of width and height."
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
if values[0] < 1 or values[1] < 1:
|
|
313
|
+
msg = "Resolution must be at least 1x1 pixels."
|
|
314
|
+
raise ValueError(msg)
|
|
315
|
+
|
|
316
|
+
from . import COMPUTER
|
|
317
|
+
screen_width, screen_height = COMPUTER.RESOLUTION
|
|
318
|
+
|
|
319
|
+
if values[0] > screen_width or values[1] > screen_height:
|
|
320
|
+
msg = "Resolution must be less than the screen resolution."
|
|
321
|
+
raise ValueError(msg)
|
|
322
|
+
|
|
323
|
+
self._resolution = tuple(int(value) for value in values)
|
|
324
|
+
self._width, self._height = self._resolution
|
|
325
|
+
# Match changes to the model's visual settings
|
|
326
|
+
self._model.vis.Global.offwidth = self._width
|
|
327
|
+
self._model.vis.Global.offheight = self._height
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def initial_conditions(self):
|
|
331
|
+
"""Initial conditions for the simulation."""
|
|
332
|
+
return self._initcond
|
|
333
|
+
|
|
334
|
+
@initial_conditions.setter
|
|
335
|
+
def initial_conditions(self, values) -> None:
|
|
336
|
+
if not isinstance(values, dict):
|
|
337
|
+
msg = "Initial conditions must be a dictionary."
|
|
338
|
+
raise ValueError(msg)
|
|
339
|
+
|
|
340
|
+
invalid_keys = [key for key in values if not hasattr(mujoco.MjData(self._model), key)]
|
|
341
|
+
if invalid_keys:
|
|
342
|
+
_SimulationData._get_public_keys(self._data)
|
|
343
|
+
msg = f"Invalid initial condition attributes: {', '.join(invalid_keys)}"
|
|
344
|
+
raise ValueError(msg)
|
|
345
|
+
|
|
346
|
+
self._initcond = values
|
|
347
|
+
|
|
348
|
+
# Apply initial conditions directly when set
|
|
349
|
+
for key, value in values.items():
|
|
350
|
+
if hasattr(self._data, key):
|
|
351
|
+
setattr(self._data, key, value)
|
|
352
|
+
else:
|
|
353
|
+
_print_warning(f"'{key}' is not a valid attribute of MjData.")
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def controller(self) -> Callable[[mjModel, mjData, Any], None] | None:
|
|
357
|
+
"""Controller Function."""
|
|
358
|
+
return self._controller
|
|
359
|
+
|
|
360
|
+
@controller.setter
|
|
361
|
+
def controller(self, func: Callable[[mjModel, mjData, Any], None]) -> None:
|
|
362
|
+
if func is not None and not callable(func):
|
|
363
|
+
msg = "Controller must be a callable function."
|
|
364
|
+
raise ValueError(msg)
|
|
365
|
+
self._controller = func
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def ts(self) -> float:
|
|
369
|
+
"""Timestep of the simulation in seconds."""
|
|
370
|
+
return self._model.opt.timestep
|
|
371
|
+
|
|
372
|
+
@ts.setter
|
|
373
|
+
def ts(self, value) -> None:
|
|
374
|
+
if value <= 0:
|
|
375
|
+
msg = "Timestep must be greater than 0."
|
|
376
|
+
raise ValueError(msg)
|
|
377
|
+
self._model.opt.timestep = value
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def data_rate(self) -> int:
|
|
381
|
+
"""Data rate of the simulation in frames per second."""
|
|
382
|
+
try:
|
|
383
|
+
return self._dr
|
|
384
|
+
except AttributeError:
|
|
385
|
+
_print_warning(f"Use '{self.run.__name__}' first in order to access this value.")
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
@data_rate.setter
|
|
389
|
+
def data_rate(self, value: int) -> None:
|
|
390
|
+
if isinstance(value, float) and not isinstance(value, int):
|
|
391
|
+
value = round(value)
|
|
392
|
+
_print_warning(f"Data rate must be an integer. Rounding to the nearest integer ({value}).")
|
|
393
|
+
if value <= 0:
|
|
394
|
+
msg = "Data rate must be greater than 0."
|
|
395
|
+
raise ValueError(msg)
|
|
396
|
+
# TODO: Check the math on this validation
|
|
397
|
+
# max_ = int(self._duration / self.ts)
|
|
398
|
+
# if value > max_:
|
|
399
|
+
# print_warning(f"Data rate exceeds the simulation steps. Setting to the maximum possible value ({max_}).")
|
|
400
|
+
# value = max_
|
|
401
|
+
self._dr = value
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def gravity(self):
|
|
405
|
+
"""Gravity vector of the simulation."""
|
|
406
|
+
return self._model.opt.gravity
|
|
407
|
+
|
|
408
|
+
@gravity.setter
|
|
409
|
+
def gravity(self, values: list | tuple | np.ndarray) -> None:
|
|
410
|
+
if not isinstance(values, (list, tuple, np.ndarray)) or len(values) != 3:
|
|
411
|
+
msg = "Gravity must be a 3D vector."
|
|
412
|
+
raise ValueError(msg)
|
|
413
|
+
self._model.opt.gravity = np.array(values)
|
|
414
|
+
|
|
415
|
+
def run(self,
|
|
416
|
+
render: bool = False,
|
|
417
|
+
camera: str | None = None,
|
|
418
|
+
interactive: bool = False,
|
|
419
|
+
multi_thread: bool = False) -> "Wrapper":
|
|
420
|
+
"""Run the simulation with optional rendering and controlled data capture.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
render (bool): If True, renders the simulation.
|
|
424
|
+
camera (str): The camera view to render from, defaults to None.
|
|
425
|
+
data_rate (int): How often to capture data, expressed as frames per second.
|
|
426
|
+
interactive (bool): If True, opens an interactive viewer window. (not implemented yet)
|
|
427
|
+
multi_thread (bool): If True, runs the simulation in multi-threaded mode. (not implemented yet)
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
self: The current Wrapper object for method chaining.
|
|
431
|
+
|
|
432
|
+
"""
|
|
433
|
+
# TODO: Integrate interactive mujoco.viewer into this method
|
|
434
|
+
# Eventually rename this to run() and point to sub-methods for different modes
|
|
435
|
+
try:
|
|
436
|
+
mujoco.set_mjcb_control(self._controller) if self._controller else None
|
|
437
|
+
mujoco.mj_resetData(self._model, self._data)
|
|
438
|
+
|
|
439
|
+
# self._frames = [None] * (self.duration * self.fps + 1)
|
|
440
|
+
frames = []
|
|
441
|
+
sim_data = _SimulationData()
|
|
442
|
+
total_steps = int(self._duration / self.ts)
|
|
443
|
+
|
|
444
|
+
# Cache frequently used functions and objects for performance
|
|
445
|
+
mj_step = mujoco.mj_step
|
|
446
|
+
m = self._model
|
|
447
|
+
d = self._data
|
|
448
|
+
dur = self._duration
|
|
449
|
+
|
|
450
|
+
capture_rate = self.data_rate * self.ts
|
|
451
|
+
capture_interval = max(1, int(1.0 / capture_rate))
|
|
452
|
+
render_interval = max(1, int(1.0 / (self._fps * self.ts)))
|
|
453
|
+
|
|
454
|
+
# TODO: Pre-allocate frame length
|
|
455
|
+
# frame_count = 0
|
|
456
|
+
|
|
457
|
+
if multi_thread:
|
|
458
|
+
# num_threads = COMPUTER.CPU_COUNT
|
|
459
|
+
# TODO: Implement multi-threading
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
from . import COMPUTER, MAX_GEOM_SCALAR, PROGRESS_BAR
|
|
463
|
+
from .utils import _EmptyContextManager
|
|
464
|
+
|
|
465
|
+
if PROGRESS_BAR and render:
|
|
466
|
+
if COMPUTER.IDE == "jupyter":
|
|
467
|
+
from tqdm.notebook import tqdm as ProgressBar
|
|
468
|
+
else:
|
|
469
|
+
from tqdm import tqdm as ProgressBar
|
|
470
|
+
else:
|
|
471
|
+
ProgressBar = _EmptyContextManager
|
|
472
|
+
|
|
473
|
+
# PEP Convention - Prevent Naming Conflict
|
|
474
|
+
_ProgressBar = ProgressBar(total=total_steps, desc="Simulation", unit=" step", leave=False)
|
|
475
|
+
_Renderer = mujoco.Renderer(self.model, self._height, self._width, self._geom_names.__len__() * MAX_GEOM_SCALAR)
|
|
476
|
+
|
|
477
|
+
with _ProgressBar as pbar, _Renderer as renderer:
|
|
478
|
+
step = 0
|
|
479
|
+
while d.time < dur:
|
|
480
|
+
mj_step(m, d)
|
|
481
|
+
|
|
482
|
+
# Capture data at the specified rate
|
|
483
|
+
if step % capture_interval == 0:
|
|
484
|
+
sim_data.capture(d)
|
|
485
|
+
|
|
486
|
+
if render and step % render_interval == 0:
|
|
487
|
+
if camera is None:
|
|
488
|
+
renderer.update_scene(d)
|
|
489
|
+
else:
|
|
490
|
+
renderer.update_scene(d, camera)
|
|
491
|
+
# self._frames[frame_count] = renderer.render().copy() # BUG: simulation attempts to write more frames than allocated
|
|
492
|
+
# frame_count += 1
|
|
493
|
+
frames.append(renderer.render().copy())
|
|
494
|
+
|
|
495
|
+
pbar.update(1) if PROGRESS_BAR else None
|
|
496
|
+
step += 1
|
|
497
|
+
|
|
498
|
+
# if verbose:
|
|
499
|
+
# for warning in mujoco.mjtWarning:
|
|
500
|
+
# if d.warning[warning].number > 0:
|
|
501
|
+
# print_warning(f"{warning.name} - {d.warning[warning].number} occurrences")
|
|
502
|
+
# else:
|
|
503
|
+
# if any(d.warning[warning].number > 0 for warning in mujoco.mjtWarning):
|
|
504
|
+
# print_warning("Please check MUJOCO_LOG.txt for more details.")
|
|
505
|
+
|
|
506
|
+
except Exception as e:
|
|
507
|
+
msg = "An error occurred while running the simulation."
|
|
508
|
+
raise RuntimeError(msg) from e
|
|
509
|
+
finally:
|
|
510
|
+
mujoco.set_mjcb_control(None)
|
|
511
|
+
# Expose local variables
|
|
512
|
+
self._captured_data = sim_data
|
|
513
|
+
self._frames = frames
|
|
514
|
+
|
|
515
|
+
return self
|
|
516
|
+
|
|
517
|
+
def _window(self, show_menu: bool =True) -> None:
|
|
518
|
+
"""Open a window to display the simulation in real time."""
|
|
519
|
+
try:
|
|
520
|
+
m = self._model
|
|
521
|
+
d = self._data
|
|
522
|
+
|
|
523
|
+
def key_callback(key) -> bool:
|
|
524
|
+
return key in (27, ord("q")) # 27 = ESC key, 'q' to quit
|
|
525
|
+
|
|
526
|
+
# NOTE: launch_passive may blocking despite docstring saying otherwise
|
|
527
|
+
_Viewer = mujoco.viewer.launch_passive(m, d,
|
|
528
|
+
show_left_ui=show_menu,
|
|
529
|
+
show_right_ui=show_menu,
|
|
530
|
+
key_callback=key_callback)
|
|
531
|
+
with _Viewer as viewer:
|
|
532
|
+
viewer.sync()
|
|
533
|
+
start_time = time.time()
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
while viewer.is_running():
|
|
537
|
+
current_time = time.time()
|
|
538
|
+
dt = current_time - start_time # Time difference between frames
|
|
539
|
+
|
|
540
|
+
mujoco.mj_step(m, d) # Advance simulation by one step
|
|
541
|
+
viewer.sync() # Sync the viewer
|
|
542
|
+
|
|
543
|
+
start_time = current_time # Reset start_time for the next frame
|
|
544
|
+
time.sleep(max(0, 1.0 / self.fps - dt)) # Adjust sleep to match self.fps
|
|
545
|
+
|
|
546
|
+
except KeyboardInterrupt:
|
|
547
|
+
viewer.close()
|
|
548
|
+
except Exception as e:
|
|
549
|
+
msg = "An error occurred while running the simulation."
|
|
550
|
+
raise RuntimeError(msg) from e
|
|
551
|
+
finally:
|
|
552
|
+
mujoco.set_mjcb_control(None)
|
|
553
|
+
|
|
554
|
+
def liveView(self, show_menu: bool = True) -> None:
|
|
555
|
+
"""Open a window to display the simulation in real time."""
|
|
556
|
+
# Run the window in a separate thread
|
|
557
|
+
gui = threading.Thread(target=self._window, kwargs={"show_menu": show_menu})
|
|
558
|
+
gui.start()
|
|
559
|
+
|
|
560
|
+
def _validate_and_extract_frames(
|
|
561
|
+
self,
|
|
562
|
+
frame_idx: int | tuple[int, int] | None = None,
|
|
563
|
+
time_idx: float | tuple[float, float] | None = None,
|
|
564
|
+
) -> list[Any]:
|
|
565
|
+
"""Validate and extract frames based on frame or time indices.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
frame_idx (int or tuple, optional): Single frame index or (start, stop) frame indices.
|
|
569
|
+
time_idx (float or tuple, optional): Single time or (start, end) times in seconds.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
List of frames
|
|
573
|
+
|
|
574
|
+
Raises:
|
|
575
|
+
ValueError: For invalid input parameters.
|
|
576
|
+
|
|
577
|
+
"""
|
|
578
|
+
if not self._frames:
|
|
579
|
+
msg = "No frames captured to render."
|
|
580
|
+
raise ValueError(msg)
|
|
581
|
+
|
|
582
|
+
# Validate input parameters
|
|
583
|
+
if frame_idx is not None and time_idx is not None:
|
|
584
|
+
msg = "Can only specify either frame_idx or time_idx, not both."
|
|
585
|
+
raise ValueError(msg)
|
|
586
|
+
|
|
587
|
+
# If both are None, use all frames
|
|
588
|
+
if frame_idx is None and time_idx is None:
|
|
589
|
+
return self._frames
|
|
590
|
+
|
|
591
|
+
# Handle time index conversion
|
|
592
|
+
if time_idx is not None:
|
|
593
|
+
if isinstance(time_idx, (int, float)):
|
|
594
|
+
frame_idx = self.t2f(time_idx)
|
|
595
|
+
elif isinstance(time_idx, tuple):
|
|
596
|
+
frame_idx = (self.t2f(time_idx[0]), self.t2f(time_idx[1]))
|
|
597
|
+
else:
|
|
598
|
+
msg = "time_idx must be a number or a tuple of numbers."
|
|
599
|
+
raise ValueError(msg)
|
|
600
|
+
|
|
601
|
+
# Convert single index to tuple range
|
|
602
|
+
if isinstance(frame_idx, (int, float)):
|
|
603
|
+
frame_idx = (frame_idx, frame_idx + 1)
|
|
604
|
+
|
|
605
|
+
# Validate frame indices
|
|
606
|
+
start, stop = frame_idx
|
|
607
|
+
max_frames = len(self._frames)
|
|
608
|
+
|
|
609
|
+
if start < 0:
|
|
610
|
+
msg = f"Start index must be non-negative. Got {start}."
|
|
611
|
+
raise ValueError(msg)
|
|
612
|
+
if stop > max_frames:
|
|
613
|
+
msg = f"Stop index must not exceed total frames ({max_frames}). Got {stop}."
|
|
614
|
+
raise ValueError(msg)
|
|
615
|
+
if start >= stop:
|
|
616
|
+
msg = f"Start index ({start}) must be less than stop index ({stop})."
|
|
617
|
+
raise ValueError(msg)
|
|
618
|
+
|
|
619
|
+
# Select subset of frames
|
|
620
|
+
return self._frames[start:stop]
|
|
621
|
+
|
|
622
|
+
def show(
|
|
623
|
+
self,
|
|
624
|
+
title: str | None = None,
|
|
625
|
+
codec: str = "gif",
|
|
626
|
+
frame_idx: int | tuple[int, int] | None = None,
|
|
627
|
+
time_idx: float | tuple[float, float] | None = None,
|
|
628
|
+
) -> None:
|
|
629
|
+
"""Render specific frame(s) as a video or GIF in a window.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
title (str, optional): Title for the rendered media.
|
|
633
|
+
codec (str, optional): Video codec/format. Defaults to "gif".
|
|
634
|
+
frame_idx (int or tuple, optional): Single frame index or (start, stop) frame indices.
|
|
635
|
+
time_idx (float or tuple, optional): Single time or (start, end) times in seconds.
|
|
636
|
+
|
|
637
|
+
Raises:
|
|
638
|
+
ValueError: If no frames are captured or invalid input parameters.
|
|
639
|
+
|
|
640
|
+
"""
|
|
641
|
+
try:
|
|
642
|
+
# Extract frames
|
|
643
|
+
subset_frames = self._validate_and_extract_frames(
|
|
644
|
+
frame_idx=frame_idx,
|
|
645
|
+
time_idx=time_idx,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Show the video
|
|
649
|
+
media.show_video(
|
|
650
|
+
subset_frames,
|
|
651
|
+
fps=1 if len(subset_frames) == 1 else self._fps,
|
|
652
|
+
width=self._width,
|
|
653
|
+
height=self._height,
|
|
654
|
+
codec=codec,
|
|
655
|
+
title=title,
|
|
656
|
+
)
|
|
657
|
+
except Exception as e:
|
|
658
|
+
msg = "Error while showing video subset."
|
|
659
|
+
raise Exception(msg) from e
|
|
660
|
+
|
|
661
|
+
def save(
|
|
662
|
+
self,
|
|
663
|
+
title: str = "render",
|
|
664
|
+
codec: str = "gif",
|
|
665
|
+
frame_idx: int | tuple[int, int] | None = None,
|
|
666
|
+
time_idx: float | tuple[float, float] | None = None,
|
|
667
|
+
) -> str:
|
|
668
|
+
"""Save specific frame(s) as a video or GIF to a file.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
title (str, optional): Filename for the saved media.
|
|
672
|
+
codec (str, optional): Video codec/format. Defaults to "gif".
|
|
673
|
+
frame_idx (int or tuple, optional): Single frame index or (start, stop) frame indices.
|
|
674
|
+
time_idx (float or tuple, optional): Single time or (start, end) times in seconds.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
str: Absolute path to the saved file.
|
|
678
|
+
|
|
679
|
+
Raises:
|
|
680
|
+
ValueError: If no frames are captured or invalid input parameters.
|
|
681
|
+
|
|
682
|
+
"""
|
|
683
|
+
try:
|
|
684
|
+
# Extract frames
|
|
685
|
+
subset_frames = self._validate_and_extract_frames(
|
|
686
|
+
frame_idx=frame_idx,
|
|
687
|
+
time_idx=time_idx,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Ensure the title ends with the correct codec extension
|
|
691
|
+
if not title.endswith(f".{codec}"):
|
|
692
|
+
title += f".{codec}"
|
|
693
|
+
|
|
694
|
+
# Save the video
|
|
695
|
+
media.write_video(
|
|
696
|
+
title,
|
|
697
|
+
subset_frames,
|
|
698
|
+
fps=1 if len(subset_frames) == 1 else self._fps,
|
|
699
|
+
codec=codec,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
return os.path.abspath(title)
|
|
703
|
+
|
|
704
|
+
except Exception as e:
|
|
705
|
+
msg = "Error while saving video subset."
|
|
706
|
+
raise Exception(msg) from e
|
|
707
|
+
|
|
708
|
+
@lru_cache(maxsize=100)
|
|
709
|
+
def t2f(self, t: float) -> int:
|
|
710
|
+
"""Convert time to frame index."""
|
|
711
|
+
return min(
|
|
712
|
+
int(t * self._fps), int(self._duration * self._fps) - 1,
|
|
713
|
+
) # Subtract 1 to convert to 0-based index
|
|
714
|
+
|
|
715
|
+
@lru_cache(maxsize=100)
|
|
716
|
+
def f2t(self, frame: int) -> float:
|
|
717
|
+
"""Convert frame index to time."""
|
|
718
|
+
return frame / self._fps
|
|
719
|
+
|
|
720
|
+
def getBodyData(self, body_name: str, data_name: str | None = None) -> np.ndarray:
|
|
721
|
+
"""Get the data for a specific body in the simulation.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
body_name (str): The name of the body to retrieve data for.
|
|
725
|
+
data_name (str): The name of the data to retrieve.
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
np.ndarray: The data for the specified body.
|
|
729
|
+
|
|
730
|
+
"""
|
|
731
|
+
if body_name not in self._body_names:
|
|
732
|
+
msg = f"Body '{body_name}' not found in the model."
|
|
733
|
+
raise ValueError(msg)
|
|
734
|
+
body_id = self._model.body(body_name).id
|
|
735
|
+
|
|
736
|
+
if data_name is None:
|
|
737
|
+
return self._captured_data.unwrap()[body_id]
|
|
738
|
+
if data_name not in self._captured_data.unwrap():
|
|
739
|
+
msg = f"Data '{data_name}' not found for body '{body_name}'."
|
|
740
|
+
raise ValueError(msg)
|
|
741
|
+
return self._captured_data.unwrap()[body_id][data_name]
|
|
742
|
+
|
|
743
|
+
def getID(self, id: int) -> str:
|
|
744
|
+
"""Get the name of a body given its ID.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
id (int): The ID of the body.
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
str: The name of the body.
|
|
751
|
+
|
|
752
|
+
"""
|
|
753
|
+
if id < 0 or id >= self._model.nbody:
|
|
754
|
+
msg = f"Invalid body ID: {id}"
|
|
755
|
+
raise ValueError(msg)
|
|
756
|
+
return mujoco.mj_id2name(self._model, mujoco.mjtObj.mjOBJ_BODY, id)
|
|
757
|
+
|
|
758
|
+
def saveYAML(self, name="Model") -> None:
|
|
759
|
+
"""Save simulation data to a YAML file.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
name (str): The filename for the YAML file.
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
None
|
|
766
|
+
|
|
767
|
+
"""
|
|
768
|
+
if not name.endswith(".yml"):
|
|
769
|
+
name += ".yml"
|
|
770
|
+
|
|
771
|
+
try:
|
|
772
|
+
# Convert simData's NumPy arrays or lists to a YAML-friendly format
|
|
773
|
+
serialized_data = {
|
|
774
|
+
k: (v.tolist() if isinstance(v, np.ndarray) else v)
|
|
775
|
+
for k, v in self.captured_data.items()
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
with open(name, "w") as f:
|
|
779
|
+
yaml.dump(serialized_data, f, default_flow_style=False)
|
|
780
|
+
|
|
781
|
+
except Exception as e:
|
|
782
|
+
msg = f"Failed to save simulation data to '{name}'"
|
|
783
|
+
raise ValueError(msg) from e
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class _SimulationData:
|
|
787
|
+
"""A class to store and manage simulation data."""
|
|
788
|
+
|
|
789
|
+
__slots__ = ["_d"]
|
|
790
|
+
|
|
791
|
+
def __init__(self) -> None:
|
|
792
|
+
self._d = defaultdict(list)
|
|
793
|
+
|
|
794
|
+
def capture(self, mj_data: mujoco.MjData) -> None:
|
|
795
|
+
"""Capture data from MjData object, storing specified or all public simulation data."""
|
|
796
|
+
from . import CAPTURE_PARAMETERS
|
|
797
|
+
keys = self._get_public_keys(mj_data) if CAPTURE_PARAMETERS == "all" else CAPTURE_PARAMETERS
|
|
798
|
+
|
|
799
|
+
for key in keys:
|
|
800
|
+
try:
|
|
801
|
+
value = getattr(mj_data, key)
|
|
802
|
+
# Check if value is a numpy array and copy if needed
|
|
803
|
+
if isinstance(value, np.ndarray):
|
|
804
|
+
self._d[key].append(value.copy())
|
|
805
|
+
elif np.isscalar(value):
|
|
806
|
+
self._d[key].append(value)
|
|
807
|
+
elif hasattr(value, "copy") and callable(value.copy):
|
|
808
|
+
# Copy if it has a copy method (e.g., MuJoCo's MjArray)
|
|
809
|
+
self._d[key].append(value.copy())
|
|
810
|
+
else:
|
|
811
|
+
self._d[key].append(value)
|
|
812
|
+
except AttributeError:
|
|
813
|
+
pass
|
|
814
|
+
except Exception:
|
|
815
|
+
pass
|
|
816
|
+
|
|
817
|
+
def unwrap(self) -> dict[str, np.ndarray]:
|
|
818
|
+
"""Unwrap the captured simulation data into a structured format with NumPy arrays."""
|
|
819
|
+
unwrapped_data = {}
|
|
820
|
+
|
|
821
|
+
for key, value_list in self._d.items():
|
|
822
|
+
if not value_list: # Skip empty lists
|
|
823
|
+
unwrapped_data[key] = np.array([])
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
# Check if all items in the list have the same shape for array data
|
|
828
|
+
if isinstance(value_list[0], np.ndarray):
|
|
829
|
+
# Check if arrays have consistent shapes
|
|
830
|
+
shapes = [arr.shape for arr in value_list]
|
|
831
|
+
if all(shape == shapes[0] for shape in shapes):
|
|
832
|
+
unwrapped_data[key] = np.stack(value_list)
|
|
833
|
+
else:
|
|
834
|
+
# For arrays with different shapes, keep as a list
|
|
835
|
+
unwrapped_data[key] = value_list
|
|
836
|
+
else:
|
|
837
|
+
# Convert to a NumPy array if it's a list of scalars
|
|
838
|
+
unwrapped_data[key] = np.array(value_list)
|
|
839
|
+
except ValueError:
|
|
840
|
+
unwrapped_data[key] = value_list # Store as a list if conversion fails
|
|
841
|
+
except Exception:
|
|
842
|
+
unwrapped_data[key] = value_list
|
|
843
|
+
|
|
844
|
+
return unwrapped_data
|
|
845
|
+
|
|
846
|
+
@property
|
|
847
|
+
def shape(self) -> dict[str, tuple]:
|
|
848
|
+
"""Return the shape of the captured data."""
|
|
849
|
+
if not self._d:
|
|
850
|
+
return {}
|
|
851
|
+
|
|
852
|
+
shapes = {}
|
|
853
|
+
for key, value_list in self._d.items():
|
|
854
|
+
if not value_list:
|
|
855
|
+
shapes[key] = ()
|
|
856
|
+
continue
|
|
857
|
+
|
|
858
|
+
first_value = value_list[0]
|
|
859
|
+
if isinstance(first_value, np.ndarray):
|
|
860
|
+
shapes[key] = (len(value_list), *first_value.shape)
|
|
861
|
+
elif isinstance(first_value, list):
|
|
862
|
+
shapes[key] = (len(value_list), len(first_value))
|
|
863
|
+
else:
|
|
864
|
+
shapes[key] = (len(value_list),)
|
|
865
|
+
|
|
866
|
+
return shapes
|
|
867
|
+
|
|
868
|
+
def __del__(self) -> None:
|
|
869
|
+
self._d.clear()
|
|
870
|
+
import gc
|
|
871
|
+
|
|
872
|
+
gc.collect()
|
|
873
|
+
|
|
874
|
+
def __len__(self) -> int:
|
|
875
|
+
if not self._d:
|
|
876
|
+
return 0
|
|
877
|
+
# Return the length of one of the data lists (assuming all have same length)
|
|
878
|
+
return len(next(iter(self._d.values())))
|
|
879
|
+
|
|
880
|
+
def __str__(self) -> str:
|
|
881
|
+
return f"{self.__class__.__name__}({len(self)} Step(s) Captured)"
|
|
882
|
+
|
|
883
|
+
def __repr__(self) -> str:
|
|
884
|
+
return self.__str__()
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def _get_public_keys(obj):
|
|
888
|
+
"""Get all public keys of an object."""
|
|
889
|
+
return [
|
|
890
|
+
name
|
|
891
|
+
for name in dir(obj)
|
|
892
|
+
if not name.startswith("_") and not callable(getattr(obj, name))
|
|
893
|
+
]
|