mujoco-toolbox 0.2.2__tar.gz → 0.3.3__tar.gz
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-0.2.2 → mujoco_toolbox-0.3.3}/PKG-INFO +40 -23
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/README.md +12 -13
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/mujoco_toolbox/__init__.py +28 -18
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/mujoco_toolbox/assets.py +1 -1
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/mujoco_toolbox/builder.py +49 -11
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/mujoco_toolbox/controller.py +7 -7
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/mujoco_toolbox/utils.py +14 -5
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/mujoco_toolbox/wrapper.py +236 -180
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/pyproject.toml +40 -31
- {mujoco_toolbox-0.2.2 → mujoco_toolbox-0.3.3}/LICENSE +0 -0
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: mujoco-toolbox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: A modern package to handle MuJoCo environments
|
|
5
|
-
License: MIT
|
|
6
|
-
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Michael Gross
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
Keywords: MuJoCo,mujoco,robotics,simulation,toolbox,wrapper
|
|
7
27
|
Author: MGross21
|
|
8
28
|
Author-email: MGross21@TODO.com
|
|
9
29
|
Requires-Python: >=3.10
|
|
10
|
-
Classifier: Development Status ::
|
|
30
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
31
|
Classifier: Intended Audience :: Developers
|
|
12
32
|
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
33
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
18
34
|
Classifier: Topic :: Software Development :: Libraries
|
|
19
35
|
Requires-Dist: colorama
|
|
20
36
|
Requires-Dist: ffmpeg
|
|
@@ -29,9 +45,10 @@ Requires-Dist: screeninfo
|
|
|
29
45
|
Requires-Dist: tqdm
|
|
30
46
|
Requires-Dist: trimesh
|
|
31
47
|
Project-URL: Bug Tracker, https://github.com/MGross21/mujoco-toolbox/issues
|
|
32
|
-
Project-URL:
|
|
33
|
-
Project-URL:
|
|
34
|
-
Project-URL:
|
|
48
|
+
Project-URL: Documentation, https://github.com/MGross21/mujoco-toolbox
|
|
49
|
+
Project-URL: Homepage, https://github.com/MGross21/mujoco-toolbox
|
|
50
|
+
Project-URL: Repository, https://github.com/MGross21/mujoco-toolbox
|
|
51
|
+
Project-URL: changelog, https://github.com/MGross21/mujoco-toolbox/blob/main/CHANGELOG.md
|
|
35
52
|
Description-Content-Type: text/markdown
|
|
36
53
|
|
|
37
54
|

|
|
@@ -97,27 +114,26 @@ ffmpeg -version
|
|
|
97
114
|
|
|
98
115
|
</details>
|
|
99
116
|
|
|
100
|
-
##
|
|
117
|
+
## Example Script
|
|
118
|
+
|
|
119
|
+
*Bare minimum to run MuJoCo simulation and display result*
|
|
101
120
|
|
|
102
121
|
```python
|
|
103
122
|
import mujoco_toolbox as mjtb
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
mjtb.CAPTURE_PARAMETERS = "all" # MjData Simulation Parameters (Default = ['time', 'qpos', 'qvel', 'act', 'qacc', 'xpos', 'xquat', 'xmat', 'ctrl', 'sensordata'])
|
|
107
|
-
|
|
108
|
-
# Example Code
|
|
109
|
-
mjtb.Wrapper("path/to/your/xml").runSim(render=True).renderMedia()
|
|
124
|
+
mjtb.Wrapper("path/to/your/xml").run(render=True).save()
|
|
110
125
|
```
|
|
111
126
|
|
|
112
127
|
## Pre-Made Controllers
|
|
113
128
|
|
|
114
129
|
```python
|
|
115
|
-
from mujoco_toolbox import
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
from mujoco_toolbox.controller import (
|
|
131
|
+
cosine_controller,
|
|
132
|
+
random_controller,
|
|
133
|
+
live_controller,
|
|
134
|
+
sine_controller,
|
|
135
|
+
step_controller,
|
|
136
|
+
)
|
|
121
137
|
|
|
122
138
|
# Wrapper can use custom controllers as well!
|
|
123
139
|
```
|
|
@@ -131,3 +147,4 @@ realTimeController
|
|
|
131
147
|
### URDF
|
|
132
148
|
|
|
133
149
|

|
|
150
|
+
|
|
@@ -61,27 +61,26 @@ ffmpeg -version
|
|
|
61
61
|
|
|
62
62
|
</details>
|
|
63
63
|
|
|
64
|
-
##
|
|
64
|
+
## Example Script
|
|
65
|
+
|
|
66
|
+
*Bare minimum to run MuJoCo simulation and display result*
|
|
65
67
|
|
|
66
68
|
```python
|
|
67
69
|
import mujoco_toolbox as mjtb
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
mjtb.CAPTURE_PARAMETERS = "all" # MjData Simulation Parameters (Default = ['time', 'qpos', 'qvel', 'act', 'qacc', 'xpos', 'xquat', 'xmat', 'ctrl', 'sensordata'])
|
|
71
|
-
|
|
72
|
-
# Example Code
|
|
73
|
-
mjtb.Wrapper("path/to/your/xml").runSim(render=True).renderMedia()
|
|
71
|
+
mjtb.Wrapper("path/to/your/xml").run(render=True).save()
|
|
74
72
|
```
|
|
75
73
|
|
|
76
74
|
## Pre-Made Controllers
|
|
77
75
|
|
|
78
76
|
```python
|
|
79
|
-
from mujoco_toolbox import
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
from mujoco_toolbox.controller import (
|
|
78
|
+
cosine_controller,
|
|
79
|
+
random_controller,
|
|
80
|
+
live_controller,
|
|
81
|
+
sine_controller,
|
|
82
|
+
step_controller,
|
|
83
|
+
)
|
|
85
84
|
|
|
86
85
|
# Wrapper can use custom controllers as well!
|
|
87
86
|
```
|
|
@@ -94,4 +93,4 @@ realTimeController
|
|
|
94
93
|
|
|
95
94
|
### URDF
|
|
96
95
|
|
|
97
|
-

|
|
96
|
+

|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
A toolbox for working with MuJoCo simulations. This package provides various utilities and controllers to facilitate
|
|
4
4
|
the simulation process.
|
|
5
5
|
|
|
6
|
-
Modules
|
|
6
|
+
Modules
|
|
7
7
|
--------
|
|
8
|
-
- Wrapper
|
|
9
|
-
- Controller
|
|
10
|
-
- Builder
|
|
8
|
+
- `Wrapper`: Contains the Wrapper class for interfacing with MuJoCo.
|
|
9
|
+
- `Controller`: Includes sineController, cosineController, and randomController for controlling simulations.
|
|
10
|
+
- `Builder`: Contains the Builder class for creating and merging MuJoCo models.
|
|
11
|
+
- `assets`: Contains pre-defined assets for building MuJoCo models.
|
|
11
12
|
|
|
12
|
-
Constants
|
|
13
|
+
Constants
|
|
13
14
|
----------
|
|
14
15
|
- CAPTURE_PARAMETERS: List of MjData fields to capture during simulation.
|
|
15
16
|
|
|
@@ -19,13 +20,19 @@ This package is still under development. Report any issues to https://github.com
|
|
|
19
20
|
|
|
20
21
|
"""
|
|
21
22
|
|
|
22
|
-
from .assets import WORLD_ASSETS,
|
|
23
|
+
from .assets import WORLD_ASSETS, glovebox
|
|
23
24
|
from .builder import Builder
|
|
24
|
-
from .controller import
|
|
25
|
-
|
|
25
|
+
from .controller import (
|
|
26
|
+
cosine_controller,
|
|
27
|
+
live_controller,
|
|
28
|
+
random_controller,
|
|
29
|
+
sine_controller,
|
|
30
|
+
step_controller,
|
|
31
|
+
)
|
|
32
|
+
from .utils import _Platform
|
|
26
33
|
from .wrapper import Wrapper
|
|
27
34
|
|
|
28
|
-
__version__ = "0.
|
|
35
|
+
__version__ = "0.3.3"
|
|
29
36
|
__author__ = "Michael Gross"
|
|
30
37
|
__github_repo__ = "mujoco-toolbox"
|
|
31
38
|
__license__ = "MIT"
|
|
@@ -35,18 +42,18 @@ __all__ = [
|
|
|
35
42
|
"COMPUTER",
|
|
36
43
|
"WORLD_ASSETS",
|
|
37
44
|
"Builder",
|
|
38
|
-
"GloveBox",
|
|
39
45
|
"Wrapper",
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
+
"cosine_controller",
|
|
47
|
+
"glovebox",
|
|
48
|
+
"live_controller",
|
|
49
|
+
"random_controller",
|
|
50
|
+
"sine_controller",
|
|
51
|
+
"step_controller",
|
|
46
52
|
]
|
|
47
53
|
|
|
48
54
|
COMPUTER = _Platform() # Singleton Instance
|
|
49
55
|
MAX_GEOM_SCALAR: int = 2 # Scalar value for mujoco.Renderer.max_geom
|
|
56
|
+
PROGRESS_BAR: bool = False # Enable/Disable progress bar
|
|
50
57
|
CAPTURE_PARAMETERS = [ # MjData default fields to capture during simulation
|
|
51
58
|
"time",
|
|
52
59
|
"qpos",
|
|
@@ -60,8 +67,11 @@ CAPTURE_PARAMETERS = [ # MjData default fields to capture during simulation
|
|
|
60
67
|
"sensordata",
|
|
61
68
|
]
|
|
62
69
|
|
|
63
|
-
if __version__.
|
|
64
|
-
|
|
70
|
+
if __version__.startswith("0"):
|
|
71
|
+
from .utils import _print_warning
|
|
72
|
+
_print_warning(
|
|
65
73
|
f"{__package__} (v{__version__}) is still under development.",
|
|
66
74
|
f"Report any issues to https://github.com/MGross21/{__github_repo__}/issues",
|
|
67
75
|
)
|
|
76
|
+
del _print_warning
|
|
77
|
+
del _Platform
|
|
@@ -3,11 +3,44 @@ import xml.etree.ElementTree as ET
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class Builder:
|
|
6
|
-
|
|
6
|
+
"""Builder class for loading, merging, and saving MuJoCo models from XML strings or file paths.
|
|
7
|
+
|
|
8
|
+
Attributes:
|
|
9
|
+
xml (str): XML string of the model.
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
>>> # Load a model
|
|
13
|
+
>>> builder1 = Builder("path/to/model1.xml")
|
|
14
|
+
>>> # Merge two models from two object instances
|
|
15
|
+
>>> builder2 = Builder("path/to/model2.xml")
|
|
16
|
+
>>> merged_builder = builder1 + builder2
|
|
17
|
+
>>> merged_builder.save("path/to/merged_model.xml")
|
|
18
|
+
>>> # Merge two models from two file paths
|
|
19
|
+
>>> builder = Builder("path/to/model1.xml", "path/to/model2.xml")
|
|
20
|
+
>>> builder.save("path/to/merged_model.xml")
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args: str) -> None:
|
|
7
25
|
"""Initialize with an XML string or a file path. Input is required."""
|
|
8
|
-
|
|
26
|
+
for arg in args:
|
|
27
|
+
if not isinstance(arg, str):
|
|
28
|
+
msg = "Input must be an XML string or a file path"
|
|
29
|
+
raise TypeError(msg)
|
|
30
|
+
if len(args) == 0:
|
|
31
|
+
msg = "Input is required to initialize the Builder"
|
|
32
|
+
raise ValueError(msg)
|
|
33
|
+
if len(args) > 1:
|
|
34
|
+
# Create a new Builder instance for each additional argument and merge them
|
|
35
|
+
self.tree, self.root = self._load_model(args[0])
|
|
36
|
+
for additional_arg in args[1:]:
|
|
37
|
+
additional_builder = Builder(additional_arg)
|
|
38
|
+
self.__add__(additional_builder)
|
|
39
|
+
else:
|
|
40
|
+
xml_input = args[0]
|
|
41
|
+
self.tree, self.root = self._load_model(xml_input)
|
|
9
42
|
|
|
10
|
-
def
|
|
43
|
+
def _load_model(self, xml_input):
|
|
11
44
|
"""Load a MuJoCo model from a file or XML string."""
|
|
12
45
|
if not isinstance(xml_input, str):
|
|
13
46
|
msg = "Input must be either an XML string or a file path"
|
|
@@ -22,7 +55,7 @@ class Builder:
|
|
|
22
55
|
|
|
23
56
|
# If it's a URDF (robot tag), wrap it in mujoco
|
|
24
57
|
if root.tag == "robot":
|
|
25
|
-
return self.
|
|
58
|
+
return self._wrap_urdf_in_mujoco(tree)
|
|
26
59
|
|
|
27
60
|
# If root is not mujoco, wrap it
|
|
28
61
|
if root.tag != "mujoco":
|
|
@@ -42,7 +75,7 @@ class Builder:
|
|
|
42
75
|
|
|
43
76
|
# Handle URDF files
|
|
44
77
|
if xml_input.lower().endswith(".urdf"):
|
|
45
|
-
return self.
|
|
78
|
+
return self._wrap_urdf_in_mujoco(ET.parse(xml_input))
|
|
46
79
|
|
|
47
80
|
# Parse regular XML file
|
|
48
81
|
try:
|
|
@@ -60,7 +93,7 @@ class Builder:
|
|
|
60
93
|
msg = f"Invalid XML file: {xml_input}"
|
|
61
94
|
raise ValueError(msg)
|
|
62
95
|
|
|
63
|
-
def
|
|
96
|
+
def _wrap_urdf_in_mujoco(self, urdf_input):
|
|
64
97
|
"""Wrap a URDF inside <mujoco> tags for MuJoCo compatibility.
|
|
65
98
|
Input can be a file path, ElementTree, or XML string.
|
|
66
99
|
"""
|
|
@@ -101,7 +134,7 @@ class Builder:
|
|
|
101
134
|
# Create a new tree with the <mujoco> root
|
|
102
135
|
return ET.ElementTree(mujoco_elem), mujoco_elem
|
|
103
136
|
|
|
104
|
-
def
|
|
137
|
+
def _merge_tags(self, tag_name, root_1, root_2) -> None:
|
|
105
138
|
"""Merge a specific tag (e.g., worldbody, asset) from two models."""
|
|
106
139
|
section_1 = root_1.find(tag_name)
|
|
107
140
|
section_2 = root_2.find(tag_name)
|
|
@@ -125,7 +158,7 @@ class Builder:
|
|
|
125
158
|
model_root = other.root
|
|
126
159
|
elif isinstance(other, str):
|
|
127
160
|
# If the input is a string, load as XML
|
|
128
|
-
_, model_root = self.
|
|
161
|
+
_, model_root = self._load_model(other)
|
|
129
162
|
else:
|
|
130
163
|
msg = "Addition only supported between Builder objects or with XML strings/files"
|
|
131
164
|
raise TypeError(msg)
|
|
@@ -136,7 +169,7 @@ class Builder:
|
|
|
136
169
|
|
|
137
170
|
# Merge all relevant sections
|
|
138
171
|
for section in sections_to_merge:
|
|
139
|
-
self.
|
|
172
|
+
self._merge_tags(section, self.root, model_root)
|
|
140
173
|
|
|
141
174
|
# Return self to allow for method chaining
|
|
142
175
|
return self
|
|
@@ -147,7 +180,7 @@ class Builder:
|
|
|
147
180
|
return self
|
|
148
181
|
return self.__add__(other)
|
|
149
182
|
|
|
150
|
-
def save(self, file_path) ->
|
|
183
|
+
def save(self, file_path) -> "os.path":
|
|
151
184
|
"""Save the merged model to a file."""
|
|
152
185
|
if self.tree is not None:
|
|
153
186
|
# Format the XML with proper indentation before saving
|
|
@@ -156,6 +189,7 @@ class Builder:
|
|
|
156
189
|
else:
|
|
157
190
|
msg = "No model loaded. Cannot save."
|
|
158
191
|
raise ValueError(msg)
|
|
192
|
+
return os.path.abspath(file_path)
|
|
159
193
|
|
|
160
194
|
def _indent_xml(self, elem, level=0) -> None:
|
|
161
195
|
"""Add proper indentation to make the XML file more readable."""
|
|
@@ -185,7 +219,11 @@ class Builder:
|
|
|
185
219
|
"""Return the XML string of the model."""
|
|
186
220
|
return self.__str__()
|
|
187
221
|
|
|
222
|
+
def __len__(self) -> int:
|
|
223
|
+
"""Return the number of elements in the model."""
|
|
224
|
+
return len(self.root) if self.root is not None else 0
|
|
225
|
+
|
|
188
226
|
@property
|
|
189
227
|
def xml(self):
|
|
190
|
-
"""
|
|
228
|
+
"""XML string of the model."""
|
|
191
229
|
return str(self)
|
|
@@ -29,7 +29,7 @@ def _apply_control(model, data, value, joint=None, axis=None, delay=0) -> None:
|
|
|
29
29
|
data.qpos[axis] = value
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def
|
|
32
|
+
def step_controller(model, data, **kwargs) -> None:
|
|
33
33
|
"""A step controller for the simulation.
|
|
34
34
|
|
|
35
35
|
Args:
|
|
@@ -57,7 +57,7 @@ def stepController(model, data, **kwargs) -> None:
|
|
|
57
57
|
_apply_control(model, data, amplitude, joint=joint, axis=axis, delay=delay)
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
def
|
|
60
|
+
def sine_controller(model, data, **kwargs) -> None:
|
|
61
61
|
"""A simple sine wave controller for the simulation.
|
|
62
62
|
|
|
63
63
|
Args:
|
|
@@ -85,7 +85,7 @@ def sineController(model, data, **kwargs) -> None:
|
|
|
85
85
|
_apply_control(model, data, value, joint=joint, delay=delay)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def
|
|
88
|
+
def cosine_controller(model, data, **kwargs) -> None:
|
|
89
89
|
"""A simple cosine wave controller for the simulation.
|
|
90
90
|
|
|
91
91
|
Args:
|
|
@@ -113,7 +113,7 @@ def cosineController(model, data, **kwargs) -> None:
|
|
|
113
113
|
_apply_control(model, data, value, joint=joint, delay=delay)
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
def
|
|
116
|
+
def random_controller(model, data, **kwargs) -> None:
|
|
117
117
|
"""A random controller for the simulation.
|
|
118
118
|
|
|
119
119
|
Args:
|
|
@@ -141,7 +141,7 @@ def randomController(model, data, **kwargs) -> None:
|
|
|
141
141
|
value = amplitude * random.rand()
|
|
142
142
|
_apply_control(model, data, value, joint=joint, axis=axis, delay=delay)
|
|
143
143
|
|
|
144
|
-
def
|
|
144
|
+
def live_controller(model, data, **kwargs) -> None:
|
|
145
145
|
"""A real-time controller for the simulation.
|
|
146
146
|
|
|
147
147
|
Args:
|
|
@@ -151,12 +151,12 @@ def realTimeController(model, data, **kwargs) -> None:
|
|
|
151
151
|
None
|
|
152
152
|
|
|
153
153
|
"""
|
|
154
|
-
from .utils import
|
|
154
|
+
from .utils import _print_warning
|
|
155
155
|
for key, value in kwargs.get("controller_params", {}).items():
|
|
156
156
|
if hasattr(data, key):
|
|
157
157
|
setattr(data, key, value)
|
|
158
158
|
else:
|
|
159
|
-
|
|
159
|
+
_print_warning(f"'{key}' is not a valid attribute of MjData. Skipping...")
|
|
160
160
|
|
|
161
161
|
# untested
|
|
162
162
|
# if hasattr(data, 'control'):
|
|
@@ -20,7 +20,7 @@ def timer(func):
|
|
|
20
20
|
|
|
21
21
|
return time_wrapper
|
|
22
22
|
|
|
23
|
-
def
|
|
23
|
+
def _print_warning(*args, prefix: bool = True) -> None:
|
|
24
24
|
"""Prints the first argument as an orange warning message and prints the other arguments in regular text, all on the same line.
|
|
25
25
|
|
|
26
26
|
Args:
|
|
@@ -49,7 +49,7 @@ def print_warning(*args, prefix: bool = True) -> None:
|
|
|
49
49
|
print(f"\033[38;5;214m{message}\033[0m", end=" ") # Orange ANSI escape code
|
|
50
50
|
print(*args[1:])
|
|
51
51
|
|
|
52
|
-
def
|
|
52
|
+
def _print_success(*args, prefix: bool = True) -> None:
|
|
53
53
|
"""Prints the first argument as a green success message and prints the other arguments in regular text, all on the same line.
|
|
54
54
|
|
|
55
55
|
Args:
|
|
@@ -94,12 +94,12 @@ class _Platform:
|
|
|
94
94
|
def __repr__(self) -> str:
|
|
95
95
|
return (
|
|
96
96
|
f"Operating System: {self.OS}\n"
|
|
97
|
+
f"Architecture: {self.ARCHITECTURE}\n"
|
|
97
98
|
f"Processor: {self.PROCESSOR}\n"
|
|
98
99
|
f"Machine Type: {self.MACHINE}\n"
|
|
100
|
+
f"CPU Count: {self.CPU_COUNT}\n"
|
|
99
101
|
f"Python Version: {self.PYTHON_VERSION}\n"
|
|
100
102
|
f"IDE: {self.IDE}\n"
|
|
101
|
-
f"CPU Count: {self.CPU_COUNT}\n"
|
|
102
|
-
f"Architecture: {self.ARCHITECTURE}\n"
|
|
103
103
|
f"Python Implementation: {self.PYTHON_IMPLEMENTATION}\n"
|
|
104
104
|
f"Resolution (disp0): {self.RESOLUTION}\n"
|
|
105
105
|
f"GUI Enabled: {self.GUI_ENABLED}"
|
|
@@ -121,5 +121,14 @@ class _Platform:
|
|
|
121
121
|
monitor = get_monitors()[0]
|
|
122
122
|
return (monitor.width, monitor.height), True
|
|
123
123
|
except Exception:
|
|
124
|
-
|
|
124
|
+
_print_warning("Detected headless operation. Disabling GUI...")
|
|
125
125
|
return (1920, 1080), False
|
|
126
|
+
|
|
127
|
+
class _EmptyContextManager:
|
|
128
|
+
__slots__ = ()
|
|
129
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
130
|
+
pass
|
|
131
|
+
def __enter__(self):
|
|
132
|
+
pass
|
|
133
|
+
def __exit__(self, _exc_type, _exc_value, _traceback):
|
|
134
|
+
pass
|
|
@@ -4,10 +4,10 @@ import threading
|
|
|
4
4
|
import time
|
|
5
5
|
import xml.etree.ElementTree as ET
|
|
6
6
|
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
from functools import lru_cache
|
|
8
|
-
from typing import Any,
|
|
9
|
+
from typing import Any, TypeAlias
|
|
9
10
|
|
|
10
|
-
import matplotlib.pyplot as plt
|
|
11
11
|
import mediapy as media
|
|
12
12
|
import mujoco
|
|
13
13
|
import mujoco.viewer
|
|
@@ -15,7 +15,7 @@ import numpy as np
|
|
|
15
15
|
import trimesh
|
|
16
16
|
import yaml
|
|
17
17
|
|
|
18
|
-
from .utils import
|
|
18
|
+
from .utils import _print_warning
|
|
19
19
|
|
|
20
20
|
assert sys.version_info >= (3, 10), "This code requires Python 3.10.0 or later."
|
|
21
21
|
assert mujoco.__version__ >= "2.0.0", "This code requires MuJoCo 2.0.0 or later."
|
|
@@ -27,17 +27,23 @@ mjData: TypeAlias = mujoco.MjData
|
|
|
27
27
|
class Wrapper:
|
|
28
28
|
"""A class to handle MuJoCo simulations."""
|
|
29
29
|
|
|
30
|
-
def __init__(self,
|
|
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:
|
|
31
40
|
# xml = "<mujoco></mujoco>" if xml.strip() == "<mujoco/>" else xml
|
|
32
|
-
if initialConditions is None:
|
|
33
|
-
initialConditions = {}
|
|
34
|
-
assert xml is not None, "XML file or string is required to initialize the Wrapper."
|
|
35
41
|
self._load_model(xml, **kwargs)
|
|
36
42
|
|
|
37
43
|
self.duration = duration
|
|
38
44
|
self.fps = fps
|
|
45
|
+
self.data_rate = data_rate
|
|
39
46
|
self.resolution = resolution # recursively sets width and height
|
|
40
|
-
self.init_conditions = initialConditions
|
|
41
47
|
self.controller = controller
|
|
42
48
|
|
|
43
49
|
# Predefined simulation parameters but can be overridden
|
|
@@ -46,6 +52,7 @@ class Wrapper:
|
|
|
46
52
|
self.gravity = kwargs.get("gravity", self._model.opt.gravity)
|
|
47
53
|
|
|
48
54
|
self._data = mujoco.MjData(self._model)
|
|
55
|
+
self.init_conditions = initial_conditions or {} # MUST BE AFTER DATA INITIALIZATION
|
|
49
56
|
|
|
50
57
|
# Auto-Populate the names of bodies, joints, and actuators
|
|
51
58
|
self._body_names = [self._model.body(i).name for i in range(self._model.nbody)]
|
|
@@ -58,11 +65,10 @@ class Wrapper:
|
|
|
58
65
|
def _load_model(self, xml: str, **kwargs: Any) -> None:
|
|
59
66
|
"""Load a MuJoCo model from an XML file or a string."""
|
|
60
67
|
try:
|
|
61
|
-
# Convert the XML path to an absolute path
|
|
62
|
-
xml_path = os.path.abspath(xml)
|
|
63
|
-
|
|
64
68
|
# Check if the path exists
|
|
65
|
-
if os.path.exists(
|
|
69
|
+
if os.path.exists(xml):
|
|
70
|
+
# Convert the XML path to an absolute path
|
|
71
|
+
xml_path = os.path.abspath(xml)
|
|
66
72
|
# Extract and validate file extension
|
|
67
73
|
extension = os.path.splitext(xml_path)[1].lower()[1:]
|
|
68
74
|
|
|
@@ -72,33 +78,24 @@ class Wrapper:
|
|
|
72
78
|
self._load_urdf_file(xml_path, **kwargs)
|
|
73
79
|
else:
|
|
74
80
|
msg = f"Unsupported file extension: '{extension}'. Please provide an XML or URDF file."
|
|
75
|
-
raise ValueError(
|
|
76
|
-
msg,
|
|
77
|
-
)
|
|
78
|
-
# If the file doesn't exist, assume it's a string and attempt to load it as XML
|
|
79
|
-
elif "<mujoco>" in xml or "<mujoco/>" in xml or "<robot>" in xml:
|
|
80
|
-
self._load_xml_string(xml)
|
|
81
|
+
raise ValueError(msg)
|
|
81
82
|
else:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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)
|
|
87
90
|
except FileNotFoundError as e:
|
|
88
91
|
msg = f"Failed to load the MuJoCo model: {e}"
|
|
89
92
|
raise FileNotFoundError(msg) from e
|
|
90
|
-
|
|
91
93
|
except ValueError as e:
|
|
92
94
|
msg = f"Invalid value encountered while loading the model: {e}"
|
|
93
|
-
raise ValueError(
|
|
94
|
-
msg,
|
|
95
|
-
) from e
|
|
96
|
-
|
|
95
|
+
raise ValueError(msg) from e
|
|
97
96
|
except Exception as e:
|
|
98
97
|
msg = f"Unexpected error while loading the MuJoCo model: {e}"
|
|
99
|
-
raise Exception(
|
|
100
|
-
msg,
|
|
101
|
-
)
|
|
98
|
+
raise Exception(msg)
|
|
102
99
|
|
|
103
100
|
def _load_xml_file(self, xml: str, **kwargs) -> None:
|
|
104
101
|
"""Load a MuJoCo model from an XML file."""
|
|
@@ -119,6 +116,7 @@ class Wrapper:
|
|
|
119
116
|
|
|
120
117
|
def _load_urdf_file(self, urdf_path: str, **kwargs: Any) -> None:
|
|
121
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
|
|
122
120
|
|
|
123
121
|
def convert_dae_to_stl(meshdir: str) -> None:
|
|
124
122
|
"""Convert all DAE files in a directory (including subdirectories) to STL."""
|
|
@@ -241,13 +239,13 @@ class Wrapper:
|
|
|
241
239
|
thread.join()
|
|
242
240
|
|
|
243
241
|
@property
|
|
244
|
-
def model(self) ->
|
|
242
|
+
def model(self) -> mjModel:
|
|
245
243
|
"""Read-only property to access the MjModel object."""
|
|
246
244
|
return self._model
|
|
247
245
|
|
|
248
246
|
@property
|
|
249
|
-
def data(self) ->
|
|
250
|
-
"""Read-only property to access the MjData single-step object."""
|
|
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."""
|
|
251
249
|
return self._data
|
|
252
250
|
|
|
253
251
|
@property
|
|
@@ -272,13 +270,14 @@ class Wrapper:
|
|
|
272
270
|
|
|
273
271
|
@frames.deleter
|
|
274
272
|
def frames(self) -> None:
|
|
273
|
+
# BUG: This is not working as intended
|
|
275
274
|
self._frames.clear()
|
|
276
275
|
import gc
|
|
277
|
-
|
|
278
276
|
gc.collect()
|
|
279
277
|
|
|
280
278
|
@property
|
|
281
279
|
def duration(self) -> float:
|
|
280
|
+
"""Duration of the simulation in seconds."""
|
|
282
281
|
return self._duration
|
|
283
282
|
|
|
284
283
|
@duration.setter
|
|
@@ -290,6 +289,7 @@ class Wrapper:
|
|
|
290
289
|
|
|
291
290
|
@property
|
|
292
291
|
def fps(self) -> float:
|
|
292
|
+
"""Frames per second."""
|
|
293
293
|
return self._fps
|
|
294
294
|
|
|
295
295
|
@fps.setter
|
|
@@ -301,6 +301,7 @@ class Wrapper:
|
|
|
301
301
|
|
|
302
302
|
@property
|
|
303
303
|
def resolution(self) -> tuple[int, int]:
|
|
304
|
+
"""Resolution of the simulation in pixels."""
|
|
304
305
|
return self._resolution
|
|
305
306
|
|
|
306
307
|
@resolution.setter
|
|
@@ -326,27 +327,33 @@ class Wrapper:
|
|
|
326
327
|
self._model.vis.Global.offheight = self._height
|
|
327
328
|
|
|
328
329
|
@property
|
|
329
|
-
def
|
|
330
|
+
def initial_conditions(self):
|
|
331
|
+
"""Initial conditions for the simulation."""
|
|
330
332
|
return self._initcond
|
|
331
333
|
|
|
332
|
-
@
|
|
333
|
-
def
|
|
334
|
+
@initial_conditions.setter
|
|
335
|
+
def initial_conditions(self, values) -> None:
|
|
334
336
|
if not isinstance(values, dict):
|
|
335
337
|
msg = "Initial conditions must be a dictionary."
|
|
336
338
|
raise ValueError(msg)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
]
|
|
339
|
+
|
|
340
|
+
invalid_keys = [key for key in values if not hasattr(mujoco.MjData(self._model), key)]
|
|
340
341
|
if invalid_keys:
|
|
341
|
-
|
|
342
|
+
_SimulationData._get_public_keys(self._data)
|
|
342
343
|
msg = f"Invalid initial condition attributes: {', '.join(invalid_keys)}"
|
|
343
|
-
raise ValueError(
|
|
344
|
-
|
|
345
|
-
)
|
|
344
|
+
raise ValueError(msg)
|
|
345
|
+
|
|
346
346
|
self._initcond = values
|
|
347
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
|
+
|
|
348
355
|
@property
|
|
349
|
-
def controller(self) ->
|
|
356
|
+
def controller(self) -> Callable[[mjModel, mjData, Any], None] | None:
|
|
350
357
|
"""Controller Function."""
|
|
351
358
|
return self._controller
|
|
352
359
|
|
|
@@ -359,6 +366,7 @@ class Wrapper:
|
|
|
359
366
|
|
|
360
367
|
@property
|
|
361
368
|
def ts(self) -> float:
|
|
369
|
+
"""Timestep of the simulation in seconds."""
|
|
362
370
|
return self._model.opt.timestep
|
|
363
371
|
|
|
364
372
|
@ts.setter
|
|
@@ -370,17 +378,18 @@ class Wrapper:
|
|
|
370
378
|
|
|
371
379
|
@property
|
|
372
380
|
def data_rate(self) -> int:
|
|
381
|
+
"""Data rate of the simulation in frames per second."""
|
|
373
382
|
try:
|
|
374
383
|
return self._dr
|
|
375
384
|
except AttributeError:
|
|
376
|
-
|
|
385
|
+
_print_warning(f"Use '{self.run.__name__}' first in order to access this value.")
|
|
377
386
|
return None
|
|
378
387
|
|
|
379
388
|
@data_rate.setter
|
|
380
|
-
def data_rate(self, value) -> None:
|
|
389
|
+
def data_rate(self, value: int) -> None:
|
|
381
390
|
if isinstance(value, float) and not isinstance(value, int):
|
|
382
391
|
value = round(value)
|
|
383
|
-
|
|
392
|
+
_print_warning(f"Data rate must be an integer. Rounding to the nearest integer ({value}).")
|
|
384
393
|
if value <= 0:
|
|
385
394
|
msg = "Data rate must be greater than 0."
|
|
386
395
|
raise ValueError(msg)
|
|
@@ -393,23 +402,21 @@ class Wrapper:
|
|
|
393
402
|
|
|
394
403
|
@property
|
|
395
404
|
def gravity(self):
|
|
405
|
+
"""Gravity vector of the simulation."""
|
|
396
406
|
return self._model.opt.gravity
|
|
397
407
|
|
|
398
408
|
@gravity.setter
|
|
399
|
-
def gravity(self, values) -> None:
|
|
400
|
-
if len(values) != 3:
|
|
409
|
+
def gravity(self, values: list | tuple | np.ndarray) -> None:
|
|
410
|
+
if not isinstance(values, (list, tuple, np.ndarray)) or len(values) != 3:
|
|
401
411
|
msg = "Gravity must be a 3D vector."
|
|
402
412
|
raise ValueError(msg)
|
|
403
|
-
self._model.opt.gravity = values
|
|
404
|
-
|
|
405
|
-
def _setInitialConditions(self) -> None:
|
|
406
|
-
for key, value in self._initcond.items():
|
|
407
|
-
if hasattr(self._data, key):
|
|
408
|
-
setattr(self._data, key, value)
|
|
409
|
-
else:
|
|
410
|
-
print_warning(f"'{key}' is not a valid attribute of MjData.")
|
|
413
|
+
self._model.opt.gravity = np.array(values)
|
|
411
414
|
|
|
412
|
-
def
|
|
415
|
+
def run(self,
|
|
416
|
+
render: bool = False,
|
|
417
|
+
camera: str | None = None,
|
|
418
|
+
interactive: bool = False,
|
|
419
|
+
multi_thread: bool = False) -> "Wrapper":
|
|
413
420
|
"""Run the simulation with optional rendering and controlled data capture.
|
|
414
421
|
|
|
415
422
|
Args:
|
|
@@ -428,26 +435,19 @@ class Wrapper:
|
|
|
428
435
|
try:
|
|
429
436
|
mujoco.set_mjcb_control(self._controller) if self._controller else None
|
|
430
437
|
mujoco.mj_resetData(self._model, self._data)
|
|
431
|
-
self._setInitialConditions()
|
|
432
438
|
|
|
433
439
|
# self._frames = [None] * (self.duration * self.fps + 1)
|
|
434
440
|
frames = []
|
|
435
|
-
sim_data =
|
|
441
|
+
sim_data = _SimulationData()
|
|
436
442
|
total_steps = int(self._duration / self.ts)
|
|
437
443
|
|
|
438
444
|
# Cache frequently used functions and objects for performance
|
|
439
445
|
mj_step = mujoco.mj_step
|
|
440
446
|
m = self._model
|
|
441
447
|
d = self._data
|
|
442
|
-
h = self._height
|
|
443
|
-
w = self._width
|
|
444
448
|
dur = self._duration
|
|
445
|
-
num_geoms = self._geom_names.__len__()
|
|
446
449
|
|
|
447
|
-
|
|
448
|
-
# May be desired to specify in object creation
|
|
449
|
-
self._dr = data_rate
|
|
450
|
-
capture_rate = data_rate * self.ts
|
|
450
|
+
capture_rate = self.data_rate * self.ts
|
|
451
451
|
capture_interval = max(1, int(1.0 / capture_rate))
|
|
452
452
|
render_interval = max(1, int(1.0 / (self._fps * self.ts)))
|
|
453
453
|
|
|
@@ -459,19 +459,22 @@ class Wrapper:
|
|
|
459
459
|
# TODO: Implement multi-threading
|
|
460
460
|
pass
|
|
461
461
|
|
|
462
|
-
from . import COMPUTER, MAX_GEOM_SCALAR
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
465
470
|
else:
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
mujoco.Renderer(m, h, w, num_geoms * MAX_GEOM_SCALAR) as renderer,
|
|
474
|
-
):
|
|
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:
|
|
475
478
|
step = 0
|
|
476
479
|
while d.time < dur:
|
|
477
480
|
mj_step(m, d)
|
|
@@ -489,7 +492,7 @@ class Wrapper:
|
|
|
489
492
|
# frame_count += 1
|
|
490
493
|
frames.append(renderer.render().copy())
|
|
491
494
|
|
|
492
|
-
|
|
495
|
+
pbar.update(1) if PROGRESS_BAR else None
|
|
493
496
|
step += 1
|
|
494
497
|
|
|
495
498
|
# if verbose:
|
|
@@ -511,143 +514,196 @@ class Wrapper:
|
|
|
511
514
|
|
|
512
515
|
return self
|
|
513
516
|
|
|
514
|
-
def _window(self) -> None:
|
|
517
|
+
def _window(self, show_menu: bool =True) -> None:
|
|
515
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
|
|
516
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
|
|
517
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)
|
|
518
553
|
|
|
519
554
|
def liveView(self, show_menu: bool = True) -> None:
|
|
520
555
|
"""Open a window to display the simulation in real time."""
|
|
521
|
-
#
|
|
556
|
+
# Run the window in a separate thread
|
|
557
|
+
gui = threading.Thread(target=self._window, kwargs={"show_menu": show_menu})
|
|
558
|
+
gui.start()
|
|
522
559
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if key in (27, ord("q")): # 27 = ESC key, 'q' to quit
|
|
530
|
-
return True
|
|
531
|
-
return False
|
|
532
|
-
|
|
533
|
-
# NOTE: launch_passive is blocking despite docstring saying otherwise
|
|
534
|
-
with mujoco.viewer.launch_passive(m, d,
|
|
535
|
-
show_left_ui=show_menu,
|
|
536
|
-
show_right_ui=show_menu,
|
|
537
|
-
key_callback=key_callback) as viewer:
|
|
538
|
-
viewer.sync()
|
|
539
|
-
start_time = time.time()
|
|
540
|
-
|
|
541
|
-
try:
|
|
542
|
-
while viewer.is_running():
|
|
543
|
-
current_time = time.time()
|
|
544
|
-
dt = current_time - start_time # Time difference between frames
|
|
545
|
-
|
|
546
|
-
mujoco.mj_step(m, d) # Advance simulation by one step
|
|
547
|
-
viewer.sync() # Sync the viewer
|
|
548
|
-
|
|
549
|
-
start_time = current_time # Reset start_time for the next frame
|
|
550
|
-
# Sleep to match real-time simulation speed
|
|
551
|
-
time.sleep(max(0, 0.01 - dt)) # Adjust sleep to match real-time
|
|
552
|
-
except KeyboardInterrupt:
|
|
553
|
-
viewer.close()
|
|
554
|
-
except Exception as e:
|
|
555
|
-
msg = "An error occurred while running the simulation."
|
|
556
|
-
raise RuntimeError(msg) from e
|
|
557
|
-
finally:
|
|
558
|
-
mujoco.set_mjcb_control(None)
|
|
559
|
-
|
|
560
|
-
viewer = threading.Thread(target=window)
|
|
561
|
-
viewer.start()
|
|
562
|
-
|
|
563
|
-
def renderFrame(self, t=0, frame=0, title=None) -> Optional[str]:
|
|
564
|
-
"""Render a specific frame as an image.
|
|
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.
|
|
565
566
|
|
|
566
567
|
Args:
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
title (str): The title of the rendered frame.
|
|
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
570
|
|
|
571
571
|
Returns:
|
|
572
|
-
|
|
572
|
+
List of frames
|
|
573
|
+
|
|
574
|
+
Raises:
|
|
575
|
+
ValueError: For invalid input parameters.
|
|
573
576
|
|
|
574
577
|
"""
|
|
575
578
|
if not self._frames:
|
|
576
579
|
msg = "No frames captured to render."
|
|
577
580
|
raise ValueError(msg)
|
|
578
581
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
msg,
|
|
583
|
-
)
|
|
584
|
-
|
|
585
|
-
if frame and t:
|
|
586
|
-
msg = "Can only specify singular time or frame parameter"
|
|
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."
|
|
587
585
|
raise ValueError(msg)
|
|
588
586
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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]))
|
|
592
597
|
else:
|
|
593
|
-
|
|
598
|
+
msg = "time_idx must be a number or a tuple of numbers."
|
|
599
|
+
raise ValueError(msg)
|
|
594
600
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
plt.show()
|
|
601
|
+
# Convert single index to tuple range
|
|
602
|
+
if isinstance(frame_idx, (int, float)):
|
|
603
|
+
frame_idx = (frame_idx, frame_idx + 1)
|
|
599
604
|
|
|
600
|
-
|
|
601
|
-
|
|
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}."
|
|
602
611
|
raise ValueError(msg)
|
|
603
|
-
|
|
604
|
-
msg = f"
|
|
612
|
+
if stop > max_frames:
|
|
613
|
+
msg = f"Stop index must not exceed total frames ({max_frames}). Got {stop}."
|
|
605
614
|
raise ValueError(msg)
|
|
606
|
-
|
|
607
|
-
msg = f"
|
|
608
|
-
raise
|
|
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]
|
|
609
621
|
|
|
610
|
-
def
|
|
611
|
-
|
|
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.
|
|
612
630
|
|
|
613
631
|
Args:
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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.
|
|
617
639
|
|
|
618
640
|
"""
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
624
649
|
media.show_video(
|
|
625
|
-
|
|
626
|
-
fps=self._fps,
|
|
650
|
+
subset_frames,
|
|
651
|
+
fps=1 if len(subset_frames) == 1 else self._fps,
|
|
627
652
|
width=self._width,
|
|
628
653
|
height=self._height,
|
|
629
654
|
codec=codec,
|
|
630
655
|
title=title,
|
|
631
656
|
)
|
|
632
|
-
|
|
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.
|
|
633
681
|
|
|
634
|
-
|
|
635
|
-
|
|
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
|
+
)
|
|
636
689
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
640
695
|
media.write_video(
|
|
641
|
-
title
|
|
642
|
-
|
|
643
|
-
fps=self._fps,
|
|
696
|
+
title,
|
|
697
|
+
subset_frames,
|
|
698
|
+
fps=1 if len(subset_frames) == 1 else self._fps,
|
|
644
699
|
codec=codec,
|
|
645
700
|
)
|
|
646
|
-
else:
|
|
647
|
-
msg = f"Unsupported codec '{codec}'. Supported codecs are {', '.join(available_codecs)}"
|
|
648
|
-
raise ValueError(msg)
|
|
649
701
|
|
|
650
|
-
|
|
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
|
|
651
707
|
|
|
652
708
|
@lru_cache(maxsize=100)
|
|
653
709
|
def t2f(self, t: float) -> int:
|
|
@@ -661,7 +717,7 @@ class Wrapper:
|
|
|
661
717
|
"""Convert frame index to time."""
|
|
662
718
|
return frame / self._fps
|
|
663
719
|
|
|
664
|
-
def getBodyData(self, body_name: str, data_name:
|
|
720
|
+
def getBodyData(self, body_name: str, data_name: str | None = None) -> np.ndarray:
|
|
665
721
|
"""Get the data for a specific body in the simulation.
|
|
666
722
|
|
|
667
723
|
Args:
|
|
@@ -727,7 +783,7 @@ class Wrapper:
|
|
|
727
783
|
raise ValueError(msg) from e
|
|
728
784
|
|
|
729
785
|
|
|
730
|
-
class
|
|
786
|
+
class _SimulationData:
|
|
731
787
|
"""A class to store and manage simulation data."""
|
|
732
788
|
|
|
733
789
|
__slots__ = ["_d"]
|
|
@@ -1,48 +1,53 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["poetry-core"]
|
|
2
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
3
3
|
build-backend = "poetry.core.masonry.api"
|
|
4
4
|
|
|
5
|
-
[
|
|
5
|
+
[project]
|
|
6
6
|
name = "mujoco-toolbox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "A modern package to handle MuJoCo environments"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
license = "
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
11
|
packages = [{ include = "mujoco_toolbox" }]
|
|
12
|
-
authors = ["MGross21
|
|
13
|
-
keywords = ["MuJoCo", "robotics", "simulation", "toolbox", "wrapper"]
|
|
12
|
+
authors = [{ name = "MGross21", email = "MGross21@TODO.com" }]
|
|
13
|
+
keywords = ["MuJoCo", "mujoco", "robotics", "simulation", "toolbox", "wrapper"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Programming Language :: Python :: 3.10",
|
|
19
19
|
"Topic :: Software Development :: Libraries"
|
|
20
20
|
]
|
|
21
|
+
requires-python = ">=3.10"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"mujoco",
|
|
24
|
+
"numpy",
|
|
25
|
+
"scipy",
|
|
26
|
+
"matplotlib",
|
|
27
|
+
"pyyaml",
|
|
28
|
+
"mediapy",
|
|
29
|
+
"tqdm",
|
|
30
|
+
"ipywidgets",
|
|
31
|
+
"ffmpeg",
|
|
32
|
+
"trimesh",
|
|
33
|
+
"screeninfo",
|
|
34
|
+
"colorama"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# [project.extras]
|
|
38
|
+
# beautified = ["colorama","ipywidgets","tqdm"]
|
|
39
|
+
# gym = ["gym"]
|
|
21
40
|
|
|
22
|
-
[
|
|
41
|
+
[project.urls]
|
|
23
42
|
homepage = "https://github.com/MGross21/mujoco-toolbox"
|
|
24
43
|
repository = "https://github.com/MGross21/mujoco-toolbox"
|
|
25
44
|
documentation = "https://github.com/MGross21/mujoco-toolbox"
|
|
45
|
+
changelog = "https://github.com/MGross21/mujoco-toolbox/blob/main/CHANGELOG.md"
|
|
26
46
|
"Bug Tracker" = "https://github.com/MGross21/mujoco-toolbox/issues"
|
|
27
47
|
|
|
28
|
-
[tool.poetry.dependencies]
|
|
29
|
-
python = ">=3.10"
|
|
30
|
-
mujoco = "*"
|
|
31
|
-
numpy = "*"
|
|
32
|
-
scipy = "*"
|
|
33
|
-
matplotlib = "*"
|
|
34
|
-
pyyaml = "*"
|
|
35
|
-
mediapy = "*"
|
|
36
|
-
tqdm = "*"
|
|
37
|
-
ipywidgets = "*"
|
|
38
|
-
ffmpeg = "*"
|
|
39
|
-
trimesh = "*"
|
|
40
|
-
screeninfo = "*"
|
|
41
|
-
colorama = "*"
|
|
42
|
-
|
|
43
48
|
[tool.poetry.group.dev.dependencies]
|
|
44
49
|
pytest = "*"
|
|
45
|
-
ruff = "^0.
|
|
50
|
+
ruff = "^0.11.2"
|
|
46
51
|
mypy = "^1.15.0"
|
|
47
52
|
pytypes = "^1.0b10"
|
|
48
53
|
black = "^25.1.0"
|
|
@@ -54,14 +59,14 @@ pylint = "^3.3.4"
|
|
|
54
59
|
[tool.semantic_release]
|
|
55
60
|
version_variables = [
|
|
56
61
|
"mujoco_toolbox/__init__.py:__version__",
|
|
57
|
-
"pyproject.toml:
|
|
62
|
+
"pyproject.toml:project.version",
|
|
58
63
|
"CITATION.cff:version"
|
|
59
64
|
]
|
|
60
|
-
branch = "main"
|
|
65
|
+
branch = "main" # Branch to track for triggering the release process
|
|
61
66
|
changelog_file = "CHANGELOG.md"
|
|
62
67
|
build_command = "pip install poetry && poetry build"
|
|
63
|
-
dist_path = "dist/"
|
|
64
|
-
create_pr = true
|
|
68
|
+
dist_path = "dist/" # Directory where the distribution files will be stored
|
|
69
|
+
create_pr = true # Option to create a pull request when a new release version is created
|
|
65
70
|
pr_branch_prefix = "release/"
|
|
66
71
|
upload_to_pypi = true
|
|
67
72
|
upload_to_release = true
|
|
@@ -72,8 +77,12 @@ commit_message = "chore(release): {version} [skip ci]"
|
|
|
72
77
|
commit_author = "semantic-release <semantic-release@example.com>"
|
|
73
78
|
|
|
74
79
|
[tool.semantic_release.plugins]
|
|
75
|
-
commit_analyzer = "@semantic-release/commit-analyzer"
|
|
76
|
-
release_notes_generator = "@semantic-release/release-notes-generator"
|
|
80
|
+
commit_analyzer = "@semantic-release/commit-analyzer" # Analyzes commit messages to determine version bumps
|
|
81
|
+
release_notes_generator = "@semantic-release/release-notes-generator" # Generates release notes based on commit messages
|
|
82
|
+
changelog = "@semantic-release/changelog" # Handles changelog generation
|
|
83
|
+
git = "@semantic-release/git" # Commits changes (version bump, changelog) back to the repo
|
|
84
|
+
github = "@semantic-release/github" # Creates GitHub releases (tags) and adds release notes
|
|
85
|
+
pypi = "@semantic-release/pypi" # Uploads the distribution to PyPI
|
|
77
86
|
|
|
78
87
|
[tool.semantic_release.commit_analyzer]
|
|
79
88
|
preset = "angular"
|
|
@@ -111,7 +120,7 @@ preset_config = {types = [
|
|
|
111
120
|
]}
|
|
112
121
|
|
|
113
122
|
[tool.ruff]
|
|
114
|
-
line-length =
|
|
123
|
+
line-length = 79 # PEP8 line length
|
|
115
124
|
|
|
116
125
|
[tool.ruff.lint]
|
|
117
126
|
# select = ["E", "F", "W", "C90"] # Select error, warning, and flake8 rules
|
|
File without changes
|