simscope 0.1.1__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.
- simscope/__init__.py +6 -0
- simscope/__main__.py +8 -0
- simscope/_assets/simscope-app.css +2 -0
- simscope/_assets/simscope-app.js +4311 -0
- simscope/_assets/simscope-player.js +4325 -0
- simscope/_assets/simscope-web.LICENSES.txt +407 -0
- simscope/_icon.py +22 -0
- simscope/_mjviser.py +203 -0
- simscope/annotations.py +1132 -0
- simscope/cli.py +482 -0
- simscope/core.py +257 -0
- simscope/derived.py +697 -0
- simscope/export.py +799 -0
- simscope/highlights.py +947 -0
- simscope/importers.py +874 -0
- simscope/index.py +579 -0
- simscope/io/__init__.py +45 -0
- simscope/io/blockfile.py +938 -0
- simscope/io/cas.py +294 -0
- simscope/io/codecs.py +566 -0
- simscope/io/errors.py +9 -0
- simscope/io/manifest.py +358 -0
- simscope/io/pack.py +563 -0
- simscope/io/scene.py +239 -0
- simscope/isaaclab.py +1460 -0
- simscope/library.py +705 -0
- simscope/mujoco.py +578 -0
- simscope/py.typed +0 -0
- simscope/recorder.py +784 -0
- simscope/server/__init__.py +9 -0
- simscope/server/app.py +149 -0
- simscope/server/blocks.py +191 -0
- simscope/server/jobs.py +166 -0
- simscope/server/routes.py +707 -0
- simscope/server/security.py +218 -0
- simscope/server/state.py +751 -0
- simscope/server/static.py +84 -0
- simscope/transforms.py +147 -0
- simscope-0.1.1.dist-info/METADATA +132 -0
- simscope-0.1.1.dist-info/RECORD +45 -0
- simscope-0.1.1.dist-info/WHEEL +4 -0
- simscope-0.1.1.dist-info/entry_points.txt +3 -0
- simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
- simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
- simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
simscope/mujoco.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""Adapter for MuJoCo, MJX, and MuJoCo Warp.
|
|
2
|
+
|
|
3
|
+
``scene_from_model`` turns a compiled ``MjModel`` into a
|
|
4
|
+
``simscope.core.Scene``. ``poses`` turns simulator state into the
|
|
5
|
+
``[E, B, 7]`` pose array the recorder logs, converting MuJoCo's wxyz
|
|
6
|
+
quaternions to simscope's xyzw. Nothing here imports JAX or Warp: MJX and
|
|
7
|
+
Warp arrays only need to convert through ``np.asarray`` (or ``.numpy()``).
|
|
8
|
+
|
|
9
|
+
``contacts`` turns ``data.contact`` into the ``[1, K, 6]`` arrows frame of
|
|
10
|
+
the ``contacts`` stream (point and force on the robot, both in the world
|
|
11
|
+
frame), and ``add_contacts_stream`` declares that stream on a recorder with
|
|
12
|
+
the ``1 / (m g)`` drawing scale.
|
|
13
|
+
|
|
14
|
+
Importing this module imports ``mujoco``; ``import simscope`` does not.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from simscope import mujoco as smj
|
|
18
|
+
>>> scene = smj.scene_from_model(model)
|
|
19
|
+
>>> frame = smj.poses(data) # [1, n_bodies, 7]
|
|
20
|
+
>>> smj.add_contacts_stream(rec, model, max_contacts=16)
|
|
21
|
+
>>> rec.log(frame, contacts=smj.contacts(model, data, max_contacts=16))
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import collections
|
|
25
|
+
import logging
|
|
26
|
+
import struct
|
|
27
|
+
import zlib
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
import numpy.typing as npt
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import mujoco
|
|
35
|
+
except ImportError as e: # pragma: no cover
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"simscope.mujoco needs the mujoco package: "
|
|
38
|
+
"pip install 'simscope[mujoco]'"
|
|
39
|
+
) from e
|
|
40
|
+
|
|
41
|
+
from simscope import _mjviser, core
|
|
42
|
+
|
|
43
|
+
_LOG = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
_GEOM = mujoco.mjtGeom
|
|
46
|
+
_KINDS: dict[int, core.GeomKind] = {
|
|
47
|
+
int(_GEOM.mjGEOM_BOX): "box",
|
|
48
|
+
int(_GEOM.mjGEOM_SPHERE): "sphere",
|
|
49
|
+
int(_GEOM.mjGEOM_CAPSULE): "capsule",
|
|
50
|
+
int(_GEOM.mjGEOM_CYLINDER): "cylinder",
|
|
51
|
+
int(_GEOM.mjGEOM_ELLIPSOID): "ellipsoid",
|
|
52
|
+
int(_GEOM.mjGEOM_PLANE): "plane",
|
|
53
|
+
int(_GEOM.mjGEOM_MESH): "mesh",
|
|
54
|
+
int(_GEOM.mjGEOM_HFIELD): "mesh",
|
|
55
|
+
}
|
|
56
|
+
_FIRST_COLLISION_GROUP = 3
|
|
57
|
+
"""Geom groups at or above this are hidden by default in MuJoCo and mjviser."""
|
|
58
|
+
|
|
59
|
+
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
60
|
+
_PNG_COLOR_TYPE = {3: 2, 4: 6} # channels -> RGB, RGBA
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def source_info() -> dict[str, str]:
|
|
64
|
+
"""Describes the simulator for a rollout manifest.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
``{"simulator": "mujoco", "version": <mujoco version>}``. MJX and
|
|
68
|
+
MuJoCo Warp are reported as ``mujoco`` too.
|
|
69
|
+
"""
|
|
70
|
+
return {"simulator": "mujoco", "version": mujoco.__version__}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scene_from_model(
|
|
74
|
+
model: mujoco.MjModel, *, collision: bool = True
|
|
75
|
+
) -> core.Scene:
|
|
76
|
+
"""Builds a scene from a compiled MuJoCo model.
|
|
77
|
+
|
|
78
|
+
Bodies keep MuJoCo's ids, so the pose stream from ``poses`` lines up
|
|
79
|
+
with ``Scene.bodies``. Geoms are attached to ``geom_bodyid`` with
|
|
80
|
+
``geom_pos``/``geom_quat`` as their local pose. Mesh vertices are stored
|
|
81
|
+
exactly as ``mjModel`` holds them (already recentered and scaled by the
|
|
82
|
+
compiler), so the geom's local pose alone places them in the world and
|
|
83
|
+
``Geom.scale`` is always 1.
|
|
84
|
+
|
|
85
|
+
Geom kinds: box, sphere, capsule, cylinder, ellipsoid, plane, and mesh
|
|
86
|
+
map one to one with MuJoCo's ``geom_size`` (unused entries zeroed).
|
|
87
|
+
Height fields are tessellated into meshes in the geom frame. Other
|
|
88
|
+
geom types (SDF) are skipped with a single warning.
|
|
89
|
+
|
|
90
|
+
Role rule (group only, as in mjviser, whose viewer shows groups 0-2 by
|
|
91
|
+
default): a geom is ``"collision"`` if its ``geom_group`` is 3 or
|
|
92
|
+
higher, or if its displayed alpha is 0; otherwise it is ``"visual"``.
|
|
93
|
+
``contype``/``conaffinity`` are deliberately ignored, because in plain
|
|
94
|
+
MJCF a default (group 0) geom collides and is also the only thing to
|
|
95
|
+
look at.
|
|
96
|
+
|
|
97
|
+
Color rule (verified against ``mjv_updateScene``): a geom with a
|
|
98
|
+
material takes ``mat_rgba`` unless its own ``rgba`` was set explicitly.
|
|
99
|
+
Materials are deduplicated. Only 2D textures are exported, as PNG;
|
|
100
|
+
cube maps and skyboxes are dropped. A mesh geom keeps its texture only
|
|
101
|
+
if the mesh has texture coordinates. Texture coordinates follow OpenGL
|
|
102
|
+
(``v = 0`` is the bottom of the image) and PNG rows are top-down.
|
|
103
|
+
|
|
104
|
+
MuJoCo's stored mesh normals are not exported; viewers compute them.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
model: A compiled ``mujoco.MjModel``.
|
|
108
|
+
collision: If false, keep only ``"visual"`` geoms.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The scene. Meshes, textures, and materials are deduplicated and
|
|
112
|
+
include only what the kept geoms use.
|
|
113
|
+
"""
|
|
114
|
+
body_names = [
|
|
115
|
+
mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, i) or f"body{i}"
|
|
116
|
+
for i in range(model.nbody)
|
|
117
|
+
]
|
|
118
|
+
parents = model.body_parentid.tolist()
|
|
119
|
+
if parents:
|
|
120
|
+
parents[0] = -1
|
|
121
|
+
masses = model.body_mass.tolist()
|
|
122
|
+
if masses:
|
|
123
|
+
masses[0] = 0.0 # the world body has no mass
|
|
124
|
+
bodies = tuple(
|
|
125
|
+
core.Body(name=name, parent=parent, mass=mass)
|
|
126
|
+
for name, parent, mass in zip(body_names, parents, masses, strict=True)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
types = model.geom_type
|
|
130
|
+
supported = np.isin(types, list(_KINDS))
|
|
131
|
+
supported &= ~((types == _GEOM.mjGEOM_MESH) & (model.geom_dataid < 0))
|
|
132
|
+
_warn_skipped(model, types[~supported])
|
|
133
|
+
|
|
134
|
+
rgba = _mjviser.effective_geom_rgba(model)
|
|
135
|
+
hidden = (model.geom_group >= _FIRST_COLLISION_GROUP) | (rgba[:, 3] == 0)
|
|
136
|
+
keep = supported if collision else supported & ~hidden
|
|
137
|
+
ids = np.flatnonzero(keep)
|
|
138
|
+
|
|
139
|
+
sizes = model.geom_size[ids].astype(np.float64)
|
|
140
|
+
kept_types = types[ids]
|
|
141
|
+
sizes[kept_types == _GEOM.mjGEOM_SPHERE, 1:] = 0.0
|
|
142
|
+
sizes[
|
|
143
|
+
(kept_types == _GEOM.mjGEOM_CAPSULE)
|
|
144
|
+
| (kept_types == _GEOM.mjGEOM_CYLINDER),
|
|
145
|
+
2,
|
|
146
|
+
] = 0.0
|
|
147
|
+
sizes[
|
|
148
|
+
(kept_types == _GEOM.mjGEOM_MESH) | (kept_types == _GEOM.mjGEOM_HFIELD)
|
|
149
|
+
] = 0.0
|
|
150
|
+
positions = model.geom_pos[ids].tolist()
|
|
151
|
+
quats = model.geom_quat[ids][:, [1, 2, 3, 0]].tolist()
|
|
152
|
+
size_rows = sizes.tolist()
|
|
153
|
+
|
|
154
|
+
builder = _SceneBuilder(model, rgba)
|
|
155
|
+
geoms = []
|
|
156
|
+
for k, gid in enumerate(ids.tolist()):
|
|
157
|
+
material, mesh = builder.material_and_mesh(gid)
|
|
158
|
+
geoms.append(
|
|
159
|
+
core.Geom(
|
|
160
|
+
body=int(model.geom_bodyid[gid]),
|
|
161
|
+
kind=_KINDS[int(types[gid])],
|
|
162
|
+
size=tuple(size_rows[k]),
|
|
163
|
+
pos=tuple(positions[k]),
|
|
164
|
+
quat=tuple(quats[k]),
|
|
165
|
+
material=material,
|
|
166
|
+
mesh=mesh,
|
|
167
|
+
role="collision" if hidden[gid] else "visual",
|
|
168
|
+
name=mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, gid)
|
|
169
|
+
or "",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return core.Scene(
|
|
173
|
+
bodies=bodies,
|
|
174
|
+
geoms=tuple(geoms),
|
|
175
|
+
materials=tuple(builder.materials) or (core.Material(),),
|
|
176
|
+
meshes=tuple(builder.meshes),
|
|
177
|
+
textures=tuple(builder.textures),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def poses(
|
|
182
|
+
data_or_xpos: Any,
|
|
183
|
+
xquat: Any | None = None,
|
|
184
|
+
*,
|
|
185
|
+
out: npt.NDArray[np.float32] | None = None,
|
|
186
|
+
) -> npt.NDArray[np.float32]:
|
|
187
|
+
"""Packs body poses into simscope's ``[E, B, 7]`` layout.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
data_or_xpos: Either an object with ``xpos`` and ``xquat``
|
|
191
|
+
attributes (``mujoco.MjData``, or an MJX data object), or the
|
|
192
|
+
body positions ``xpos`` with shape ``[..., B, 3]``. Arrays may be
|
|
193
|
+
numpy, JAX, or Warp; anything with a ``numpy()`` method uses it,
|
|
194
|
+
everything else goes through ``np.asarray``.
|
|
195
|
+
xquat: Body quaternions in wxyz order, shape ``[..., B, 4]``. Must be
|
|
196
|
+
``None`` when ``data_or_xpos`` is a data object.
|
|
197
|
+
out: Optional preallocated float32 C-contiguous ``[E, B, 7]`` array
|
|
198
|
+
to fill and return.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A float32 C-contiguous array ``[E, B, 7]`` of ``xyz`` followed by
|
|
202
|
+
quaternion ``xyzw``. A single ``MjData`` gives ``E = 1``; extra
|
|
203
|
+
leading axes of batched input are flattened into ``E``. The result
|
|
204
|
+
never aliases the input.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ValueError: If shapes are inconsistent, or ``out`` is unsuitable.
|
|
208
|
+
TypeError: If ``xquat`` is missing and the first argument has no
|
|
209
|
+
``xpos``/``xquat``.
|
|
210
|
+
"""
|
|
211
|
+
if xquat is None:
|
|
212
|
+
if not (
|
|
213
|
+
hasattr(data_or_xpos, "xpos") and hasattr(data_or_xpos, "xquat")
|
|
214
|
+
):
|
|
215
|
+
raise TypeError(
|
|
216
|
+
"pass MjData (or an object with xpos and xquat), "
|
|
217
|
+
"or both xpos and xquat arrays"
|
|
218
|
+
)
|
|
219
|
+
xpos_in, xquat_in = data_or_xpos.xpos, data_or_xpos.xquat
|
|
220
|
+
else:
|
|
221
|
+
xpos_in, xquat_in = data_or_xpos, xquat
|
|
222
|
+
xpos = _to_numpy(xpos_in)
|
|
223
|
+
quat = _to_numpy(xquat_in)
|
|
224
|
+
if xpos.ndim < 2 or xpos.shape[-1] != 3:
|
|
225
|
+
raise ValueError(f"xpos must have shape [..., B, 3], got {xpos.shape}")
|
|
226
|
+
if quat.shape[:-1] != xpos.shape[:-1] or quat.shape[-1] != 4:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"xquat must have shape {(*xpos.shape[:-1], 4)}, got {quat.shape}"
|
|
229
|
+
)
|
|
230
|
+
n_bodies = xpos.shape[-2]
|
|
231
|
+
xpos = xpos.reshape(-1, n_bodies, 3)
|
|
232
|
+
quat = quat.reshape(-1, n_bodies, 4)
|
|
233
|
+
shape = (xpos.shape[0], n_bodies, core.POSE_DIM)
|
|
234
|
+
if out is None:
|
|
235
|
+
out = np.empty(shape, dtype=np.float32)
|
|
236
|
+
elif (
|
|
237
|
+
out.shape != shape
|
|
238
|
+
or out.dtype != np.float32
|
|
239
|
+
or not out.flags.c_contiguous
|
|
240
|
+
):
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"out must be a C-contiguous float32 array of shape {shape}"
|
|
243
|
+
)
|
|
244
|
+
out[..., :3] = xpos
|
|
245
|
+
out[..., 3:6] = quat[..., 1:]
|
|
246
|
+
out[..., 6] = quat[..., 0]
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
DEFAULT_MAX_CONTACTS = 16
|
|
251
|
+
"""Contact slots per frame that ``add_contacts_stream`` reserves."""
|
|
252
|
+
|
|
253
|
+
CONTACTS_STREAM = "contacts"
|
|
254
|
+
"""Name of the contacts stream (viewer contracts 7)."""
|
|
255
|
+
|
|
256
|
+
_GRAVITY = 9.81
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def contacts(
|
|
260
|
+
model: mujoco.MjModel,
|
|
261
|
+
data: mujoco.MjData,
|
|
262
|
+
out: npt.NDArray[np.float32] | None = None,
|
|
263
|
+
*,
|
|
264
|
+
max_contacts: int | None = None,
|
|
265
|
+
) -> npt.NDArray[np.float32]:
|
|
266
|
+
"""Packs the active contacts of one ``MjData`` for the contacts stream.
|
|
267
|
+
|
|
268
|
+
Each row is ``[px, py, pz, fx, fy, fz]``: the contact point (m) and the
|
|
269
|
+
force (N) that the world exerts on the robot, both in the world frame.
|
|
270
|
+
MuJoCo's contact force (``mj_contactForce``, rotated out of the contact
|
|
271
|
+
frame) is the force on geom 2, so the sign is flipped when the robot is
|
|
272
|
+
geom 1. The fixed side is the one whose geom belongs to the world body
|
|
273
|
+
(the ground, a wall); if neither geom does, the force on geom 2 is
|
|
274
|
+
used. Unused rows are zero, and so are contacts that carry no force. If
|
|
275
|
+
there are more than ``K`` force-carrying contacts, the ``K`` strongest
|
|
276
|
+
are kept.
|
|
277
|
+
|
|
278
|
+
For batched MJX or MuJoCo Warp data, convert each env to an ``MjData``
|
|
279
|
+
(``mjx.get_data(model, batch)`` returns one per env) and call this once
|
|
280
|
+
per env with ``out=frame[e]``, where ``frame`` is an ``[E, K, 6]``
|
|
281
|
+
array. A fully batched path would need ``mj_contactForce`` on device
|
|
282
|
+
and is left out on purpose.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
model: The compiled model.
|
|
286
|
+
data: Simulator state; contacts are read as of the last step.
|
|
287
|
+
out: Optional float32 C-contiguous buffer to fill (and return),
|
|
288
|
+
shape ``[K, 6]`` or ``[1, K, 6]``. It is zeroed first.
|
|
289
|
+
max_contacts: ``K`` when ``out`` is not given.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
A float32 array ``[1, K, 6]`` (``out`` itself when it has that
|
|
293
|
+
shape, else a view of it), ready for ``Recorder.log``.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
ValueError: If neither ``out`` nor ``max_contacts`` is given, they
|
|
297
|
+
disagree, or ``out`` is unsuitable.
|
|
298
|
+
"""
|
|
299
|
+
if out is None:
|
|
300
|
+
if max_contacts is None or max_contacts < 1:
|
|
301
|
+
raise ValueError("pass out, or max_contacts >= 1")
|
|
302
|
+
out = np.zeros((1, max_contacts, 6), dtype=np.float32)
|
|
303
|
+
else:
|
|
304
|
+
if (
|
|
305
|
+
out.dtype != np.float32
|
|
306
|
+
or not out.flags.c_contiguous
|
|
307
|
+
or out.ndim not in (2, 3)
|
|
308
|
+
or out.shape[-1] != 6
|
|
309
|
+
or (out.ndim == 3 and out.shape[0] != 1)
|
|
310
|
+
):
|
|
311
|
+
raise ValueError(
|
|
312
|
+
"out must be a C-contiguous float32 [K, 6] or [1, K, 6] "
|
|
313
|
+
f"array, got {out.dtype} {out.shape}"
|
|
314
|
+
)
|
|
315
|
+
if max_contacts is not None and max_contacts != out.shape[-2]:
|
|
316
|
+
raise ValueError(
|
|
317
|
+
f"max_contacts={max_contacts} but out has {out.shape[-2]} rows"
|
|
318
|
+
)
|
|
319
|
+
out[...] = 0.0
|
|
320
|
+
rows = out.reshape(-1, 6)
|
|
321
|
+
n = int(data.ncon)
|
|
322
|
+
if n == 0:
|
|
323
|
+
return out.reshape(1, *rows.shape)
|
|
324
|
+
contact = data.contact
|
|
325
|
+
local = np.zeros(6)
|
|
326
|
+
force = np.zeros((n, 3))
|
|
327
|
+
for i in range(n):
|
|
328
|
+
mujoco.mj_contactForce(model, data, i, local)
|
|
329
|
+
force[i] = local[:3]
|
|
330
|
+
frame = np.asarray(contact.frame[:n]).reshape(n, 3, 3)
|
|
331
|
+
world = np.einsum("nji,nj->ni", frame, force) # force on geom 2
|
|
332
|
+
geoms = np.asarray(contact.geom[:n])
|
|
333
|
+
fixed = model.geom_bodyid[geoms] == 0
|
|
334
|
+
world[fixed[:, 1] & ~fixed[:, 0]] *= -1.0 # robot is geom 1
|
|
335
|
+
magnitude = np.einsum("ni,ni->n", world, world)
|
|
336
|
+
live = np.flatnonzero(magnitude > 0.0)
|
|
337
|
+
if len(live) > len(rows):
|
|
338
|
+
live = live[np.argsort(-magnitude[live], kind="stable")[: len(rows)]]
|
|
339
|
+
live.sort()
|
|
340
|
+
rows[: len(live), :3] = np.asarray(contact.pos[:n])[live]
|
|
341
|
+
rows[: len(live), 3:] = world[live]
|
|
342
|
+
return out.reshape(1, *rows.shape)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def contacts_scale(model: mujoco.MjModel) -> float:
|
|
346
|
+
"""Metres of drawn arrow per newton: one body weight draws as 1 m.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
model: The compiled model.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
``1 / (m g)`` with ``m`` the total mass and ``g`` gravity's
|
|
353
|
+
magnitude, or ``1 / (m * 9.81)`` when the model has no gravity, or
|
|
354
|
+
``1.0`` when it has no mass.
|
|
355
|
+
"""
|
|
356
|
+
mass = float(model.body_subtreemass[0])
|
|
357
|
+
g = float(np.linalg.norm(model.opt.gravity)) or _GRAVITY
|
|
358
|
+
return 1.0 / (mass * g) if mass > 0.0 else 1.0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def add_contacts_stream(
|
|
362
|
+
recorder: Any,
|
|
363
|
+
model: mujoco.MjModel,
|
|
364
|
+
*,
|
|
365
|
+
max_contacts: int = DEFAULT_MAX_CONTACTS,
|
|
366
|
+
name: str = CONTACTS_STREAM,
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Declares the contacts stream on a recorder.
|
|
369
|
+
|
|
370
|
+
Call it before the first ``log``, then pass ``contacts(...)`` as the
|
|
371
|
+
``contacts`` keyword of ``log``.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
recorder: A ``simscope.recorder.Recorder``.
|
|
375
|
+
model: The compiled model (its mass sets the drawing scale).
|
|
376
|
+
max_contacts: ``K``, the contact slots per frame.
|
|
377
|
+
name: Stream name.
|
|
378
|
+
"""
|
|
379
|
+
recorder.add_stream(
|
|
380
|
+
name,
|
|
381
|
+
"arrows",
|
|
382
|
+
(max_contacts, 6),
|
|
383
|
+
units="N",
|
|
384
|
+
scale=contacts_scale(model),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _to_numpy(array: Any) -> np.ndarray:
|
|
389
|
+
"""Converts a numpy, JAX, or Warp array to numpy without importing them.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
array: An array-like object.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
A numpy array (a view where possible).
|
|
396
|
+
"""
|
|
397
|
+
if isinstance(array, np.ndarray):
|
|
398
|
+
return array
|
|
399
|
+
to_numpy = getattr(array, "numpy", None)
|
|
400
|
+
if callable(to_numpy):
|
|
401
|
+
return np.asarray(to_numpy())
|
|
402
|
+
return np.asarray(array)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _warn_skipped(model: mujoco.MjModel, skipped: np.ndarray) -> None:
|
|
406
|
+
"""Logs one warning for everything the adapter cannot represent.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
model: The compiled model.
|
|
410
|
+
skipped: The ``geom_type`` of each skipped geom.
|
|
411
|
+
"""
|
|
412
|
+
counts: collections.Counter[str] = collections.Counter()
|
|
413
|
+
for geom_type in skipped.tolist():
|
|
414
|
+
try:
|
|
415
|
+
counts[_GEOM(geom_type).name.removeprefix("mjGEOM_")] += 1
|
|
416
|
+
except ValueError:
|
|
417
|
+
counts[f"type{geom_type}"] += 1
|
|
418
|
+
if model.nflex:
|
|
419
|
+
counts["flex"] = int(model.nflex)
|
|
420
|
+
if counts:
|
|
421
|
+
summary = ", ".join(f"{n} {name}" for name, n in sorted(counts.items()))
|
|
422
|
+
_LOG.warning(
|
|
423
|
+
"simscope.mujoco skipped unsupported geometry: %s", summary
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class _SceneBuilder:
|
|
428
|
+
"""Accumulates deduplicated materials, textures, and meshes for a scene."""
|
|
429
|
+
|
|
430
|
+
def __init__(
|
|
431
|
+
self, model: mujoco.MjModel, rgba: npt.NDArray[np.float32]
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Initializes empty tables.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
model: The compiled model.
|
|
437
|
+
rgba: Displayed color of every geom, ``[ngeom, 4]``.
|
|
438
|
+
"""
|
|
439
|
+
self._model = model
|
|
440
|
+
self._rgba = rgba
|
|
441
|
+
self.materials: dict[core.Material, int] = {}
|
|
442
|
+
self.textures: list[core.Texture] = []
|
|
443
|
+
self.meshes: list[core.Mesh] = []
|
|
444
|
+
self._texture_by_id: dict[int, int | None] = {}
|
|
445
|
+
self._texture_by_png: dict[bytes, int] = {}
|
|
446
|
+
self._mesh_by_key: dict[tuple[str, int, bool], int] = {}
|
|
447
|
+
|
|
448
|
+
def material_and_mesh(self, gid: int) -> tuple[int, int | None]:
|
|
449
|
+
"""Resolves the material and mesh indices of one geom.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
gid: Geom id, of a supported type.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
``(material index, mesh index or None)``.
|
|
456
|
+
"""
|
|
457
|
+
model = self._model
|
|
458
|
+
geom_type = int(model.geom_type[gid])
|
|
459
|
+
matid = int(model.geom_matid[gid])
|
|
460
|
+
texid = _mjviser.texture_id(model, matid)
|
|
461
|
+
has_texture = self._is_supported_texture(texid)
|
|
462
|
+
|
|
463
|
+
mesh = None
|
|
464
|
+
if geom_type == _GEOM.mjGEOM_MESH:
|
|
465
|
+
mesh_id = int(model.geom_dataid[gid])
|
|
466
|
+
has_texture &= int(model.mesh_texcoordnum[mesh_id]) > 0
|
|
467
|
+
mesh = self._mesh(("mesh", mesh_id, has_texture))
|
|
468
|
+
elif geom_type == _GEOM.mjGEOM_HFIELD:
|
|
469
|
+
mesh = self._mesh(
|
|
470
|
+
("hfield", int(model.geom_dataid[gid]), has_texture)
|
|
471
|
+
)
|
|
472
|
+
return self._material(gid, matid, texid if has_texture else -1), mesh
|
|
473
|
+
|
|
474
|
+
def _is_supported_texture(self, texid: int) -> bool:
|
|
475
|
+
"""Tells whether a texture id refers to an exportable 2D texture."""
|
|
476
|
+
if texid < 0:
|
|
477
|
+
return False
|
|
478
|
+
model = self._model
|
|
479
|
+
return bool(
|
|
480
|
+
int(model.tex_type[texid]) == int(mujoco.mjtTexture.mjTEXTURE_2D)
|
|
481
|
+
and int(model.tex_nchannel[texid]) in (1, 3, 4)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def _material(self, gid: int, matid: int, texid: int) -> int:
|
|
485
|
+
"""Returns the deduplicated material index of a geom."""
|
|
486
|
+
model = self._model
|
|
487
|
+
metallic, roughness = 0.0, 1.0
|
|
488
|
+
repeat = (1.0, 1.0)
|
|
489
|
+
texture = None
|
|
490
|
+
if matid >= 0:
|
|
491
|
+
metallic = _unless_negative(model.mat_metallic[matid], metallic)
|
|
492
|
+
roughness = _unless_negative(model.mat_roughness[matid], roughness)
|
|
493
|
+
if texid >= 0:
|
|
494
|
+
texture = self._texture(texid)
|
|
495
|
+
u, v = model.mat_texrepeat[matid].tolist()
|
|
496
|
+
repeat = (u, v)
|
|
497
|
+
r, g, b, a = self._rgba[gid].tolist()
|
|
498
|
+
material = core.Material(
|
|
499
|
+
rgba=(r, g, b, a),
|
|
500
|
+
metallic=metallic,
|
|
501
|
+
roughness=roughness,
|
|
502
|
+
texture=texture,
|
|
503
|
+
texrepeat=repeat,
|
|
504
|
+
)
|
|
505
|
+
return self.materials.setdefault(material, len(self.materials))
|
|
506
|
+
|
|
507
|
+
def _texture(self, texid: int) -> int | None:
|
|
508
|
+
"""Encodes a texture once and returns its deduplicated index."""
|
|
509
|
+
if texid in self._texture_by_id:
|
|
510
|
+
return self._texture_by_id[texid]
|
|
511
|
+
pixels = _mjviser.texture_pixels(self._model, texid)
|
|
512
|
+
index = None
|
|
513
|
+
if pixels is not None:
|
|
514
|
+
png = _encode_png(pixels)
|
|
515
|
+
if png not in self._texture_by_png:
|
|
516
|
+
self._texture_by_png[png] = len(self.textures)
|
|
517
|
+
self.textures.append(
|
|
518
|
+
core.Texture(
|
|
519
|
+
data=png,
|
|
520
|
+
media_type="image/png",
|
|
521
|
+
width=pixels.shape[1],
|
|
522
|
+
height=pixels.shape[0],
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
index = self._texture_by_png[png]
|
|
526
|
+
self._texture_by_id[texid] = index
|
|
527
|
+
return index
|
|
528
|
+
|
|
529
|
+
def _mesh(self, key: tuple[str, int, bool]) -> int:
|
|
530
|
+
"""Builds a mesh or height field once and returns its index."""
|
|
531
|
+
if key in self._mesh_by_key:
|
|
532
|
+
return self._mesh_by_key[key]
|
|
533
|
+
source, asset_id, with_uvs = key
|
|
534
|
+
extract = (
|
|
535
|
+
_mjviser.mesh_arrays if source == "mesh" else _mjviser.hfield_arrays
|
|
536
|
+
)
|
|
537
|
+
vertices, faces, uvs = extract(self._model, asset_id, with_uvs=with_uvs)
|
|
538
|
+
self.meshes.append(core.Mesh(vertices, faces, uvs=uvs))
|
|
539
|
+
self._mesh_by_key[key] = len(self.meshes) - 1
|
|
540
|
+
return self._mesh_by_key[key]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _unless_negative(value: float, default: float) -> float:
|
|
544
|
+
"""Returns ``value`` as a float, or ``default`` if MuJoCo left it unset."""
|
|
545
|
+
return float(value) if value >= 0 else default
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _encode_png(pixels: npt.NDArray[np.uint8]) -> bytes:
|
|
549
|
+
"""Encodes 8-bit RGB or RGBA pixels as a PNG file.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
pixels: A ``[H, W, 3]`` or ``[H, W, 4]`` uint8 array, top row first.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
The bytes of the PNG file, using filter type 0 and one IDAT chunk.
|
|
556
|
+
"""
|
|
557
|
+
height, width, channels = pixels.shape
|
|
558
|
+
raw = np.zeros((height, 1 + width * channels), dtype=np.uint8)
|
|
559
|
+
raw[:, 1:] = pixels.reshape(height, width * channels)
|
|
560
|
+
header = struct.pack(
|
|
561
|
+
">IIBBBBB", width, height, 8, _PNG_COLOR_TYPE[channels], 0, 0, 0
|
|
562
|
+
)
|
|
563
|
+
return (
|
|
564
|
+
_PNG_SIGNATURE
|
|
565
|
+
+ _png_chunk(b"IHDR", header)
|
|
566
|
+
+ _png_chunk(b"IDAT", zlib.compress(raw.tobytes(), 6))
|
|
567
|
+
+ _png_chunk(b"IEND", b"")
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _png_chunk(tag: bytes, payload: bytes) -> bytes:
|
|
572
|
+
"""Frames one PNG chunk with its length and CRC."""
|
|
573
|
+
return (
|
|
574
|
+
struct.pack(">I", len(payload))
|
|
575
|
+
+ tag
|
|
576
|
+
+ payload
|
|
577
|
+
+ struct.pack(">I", zlib.crc32(tag + payload))
|
|
578
|
+
)
|
simscope/py.typed
ADDED
|
File without changes
|