diff --git a/dm_control/_render/__init__.py b/dm_control/_render/__init__.py index 5fdf14d7..d1dd4446 100644 --- a/dm_control/_render/__init__.py +++ b/dm_control/_render/__init__.py @@ -45,6 +45,9 @@ def _import_glfw(): def _import_osmesa(): from dm_control._render.pyopengl.osmesa_renderer import OSMesaContext return OSMesaContext + + +# Import removed. # pylint: enable=g-import-not-at-top @@ -59,6 +62,7 @@ def no_renderer(*args, **kwargs): (constants.GLFW, _import_glfw), (constants.EGL, _import_egl), (constants.OSMESA, _import_osmesa), + # Option removed. ) _NO_RENDERER = ( diff --git a/dm_control/_render/base.py b/dm_control/_render/base.py index 5e15df07..66ea02f6 100644 --- a/dm_control/_render/base.py +++ b/dm_control/_render/base.py @@ -35,6 +35,8 @@ from absl import logging from dm_control._render import executor +import numpy as np + _CURRENT_CONTEXT_FOR_THREAD = collections.defaultdict(lambda: None) _CURRENT_THREAD_FOR_CONTEXT = collections.defaultdict(lambda: None) @@ -147,6 +149,10 @@ def make_current(self): ctx.call(self._platform_make_current) yield ctx + def to_pixels(self, buffer): + """Converts the buffer to pixels.""" + return np.flipud(buffer) + @abc.abstractmethod def _platform_init(self, max_width, max_height): """Performs an implementation-specific context initialization.""" diff --git a/dm_control/_render/constants.py b/dm_control/_render/constants.py index 02846218..4d9cee65 100644 --- a/dm_control/_render/constants.py +++ b/dm_control/_render/constants.py @@ -28,5 +28,6 @@ OSMESA = ('osmesa',) GLFW = ('glfw', 'on', 'enable', 'enabled', 'true', '1', '') EGL = ('egl',) +# Constant removed. NO_RENDERER = ('off', 'disable', 'disabled', 'false', '0') diff --git a/dm_control/_render/pyopengl/egl_renderer.py b/dm_control/_render/pyopengl/egl_renderer.py index e3d4fe33..b9dfa149 100644 --- a/dm_control/_render/pyopengl/egl_renderer.py +++ b/dm_control/_render/pyopengl/egl_renderer.py @@ -103,12 +103,13 @@ def _platform_init(self, unused_max_width, unused_max_height): """Initialization this EGL context.""" num_configs = ctypes.c_long(0) config_size = 1 - config = EGL.EGLConfig() + # ctypes syntax for making an array of length config_size. + configs = (EGL.EGLConfig * config_size)() EGL.eglReleaseThread() EGL.eglChooseConfig( EGL_DISPLAY, EGL_ATTRIBUTES, - ctypes.byref(config), + configs, config_size, num_configs) if num_configs.value < 1: @@ -117,7 +118,7 @@ def _platform_init(self, unused_max_width, unused_max_height): 'desired attributes: {}'.format(EGL_ATTRIBUTES)) EGL.eglBindAPI(EGL.EGL_OPENGL_API) self._context = EGL.eglCreateContext( - EGL_DISPLAY, config, EGL.EGL_NO_CONTEXT, None) + EGL_DISPLAY, configs[0], EGL.EGL_NO_CONTEXT, None) if not self._context: raise RuntimeError('Cannot create an EGL context.') diff --git a/dm_control/autowrap/binding_generator.py b/dm_control/autowrap/binding_generator.py index 7c62e78e..38c81459 100644 --- a/dm_control/autowrap/binding_generator.py +++ b/dm_control/autowrap/binding_generator.py @@ -159,7 +159,14 @@ def parse_hints(self, xmacro_src): parser = header_parsing.XMACRO for tokens, _, _ in parser.scanString(xmacro_src): for xmacro in tokens: + if not ( + xmacro.name.startswith("MJMODEL") + or xmacro.name.startswith("MJDATA") + ): + continue for member in xmacro.members: + if not hasattr(member, "name") or not member.name: + continue # "Squeeze out" singleton dimensions. shape = self.get_shape_tuple(member.dims, squeeze=True) self.hints_dict.update({member.name: shape}) diff --git a/dm_control/autowrap/codegen_util.py b/dm_control/autowrap/codegen_util.py index 0557cd15..81946e55 100644 --- a/dm_control/autowrap/codegen_util.py +++ b/dm_control/autowrap/codegen_util.py @@ -78,14 +78,18 @@ def __setitem__(self, k, v): def macro_struct_name(name, suffix=None): """Converts mjxmacro struct names, e.g. "MJDATA_POINTERS" to "mjdata".""" + if name.startswith("MJMODEL_POINTERS"): + return "mjmodel" if suffix is None: suffix = _MJXMACRO_SUFFIX - return name[:-len(suffix)].lower() + if name.endswith(suffix): + return name[:-len(suffix)].lower() + return name.lower() def is_macro_pointer(name): """Returns True if the mjxmacro struct name contains pointer sizes.""" - return name.endswith(_MJXMACRO_SUFFIX) + return name.endswith(_MJXMACRO_SUFFIX) or name.startswith("MJMODEL_POINTERS") def try_coerce_to_num(s, try_types=(int, float)): diff --git a/dm_control/autowrap/header_parsing.py b/dm_control/autowrap/header_parsing.py index 9c430241..b13802d7 100644 --- a/dm_control/autowrap/header_parsing.py +++ b/dm_control/autowrap/header_parsing.py @@ -78,11 +78,24 @@ def _nested_if_else(if_, pred, else_, endif, match_if_true, match_if_false): return ifelse +def _nested_ifn_else(ifn_, pred, else_, endif, match_if_true, match_if_false): + """Constructs a parser for (possibly nested) if...(else)...endif blocks.""" + ifnelse = pp.Forward() + ifnelse << pp.Group( # pylint: disable=expression-not-assigned + ifn_ + + pred("predicate") + + pp.ZeroOrMore(match_if_true | ifnelse)("if_false") + + pp.Optional(else_ + + pp.ZeroOrMore(match_if_false | ifnelse)("if_true")) + + endif) + return ifnelse + + # Some common string patterns to suppress. # ------------------------------------------------------------------------------ (LPAREN, RPAREN, LBRACK, RBRACK, LBRACE, RBRACE, SEMI, COMMA, EQUAL, FSLASH, BSLASH) = list(map(pp.Suppress, "()[]{};,=/\\")) -X = (pp.Keyword("X") | pp.Keyword("XMJV")).suppress() +X = (pp.Keyword("X") | pp.Keyword("XMJV") | pp.Keyword("XNV")).suppress() EOL = pp.LineEnd().suppress() # Comments, continuation. @@ -160,12 +173,13 @@ def _nested_if_else(if_, pred, else_, endif, match_if_true, match_if_false): pp.delimitedList(XDIM, delim=COMMA)("dims") + RPAREN) +XMACRO_LINE = XMEMBER | NAME XMACRO = pp.Group( pp.Optional(COMMENT("comment")) + DEFINE + NAME("name") + CONTINUATION + - pp.delimitedList(XMEMBER, delim=CONTINUATION)("members")) + pp.delimitedList(XMACRO_LINE, delim=CONTINUATION)("members")) # Type/variable declarations. @@ -189,7 +203,9 @@ def _nested_if_else(if_, pred, else_, endif, match_if_true, match_if_false): UNCOND_DECL = DEF_FLAG | DEF_CONST | TYPE_DECL # Declarations inside (possibly nested) #if(n)def... #else... #endif... blocks. -COND_DECL = _nested_if_else(IFDEF, NAME, ELSE, ENDIF, UNCOND_DECL, UNCOND_DECL) +COND_DECL = _nested_if_else( + IFDEF, NAME, ELSE, ENDIF, UNCOND_DECL, UNCOND_DECL +) | _nested_ifn_else(IFNDEF, NAME, ELSE, ENDIF, UNCOND_DECL, UNCOND_DECL) # Note: this doesn't work for '#if defined(FLAG)' blocks # e.g. "mjtNum gravity[3]; // gravitational acceleration" diff --git a/dm_control/blender/fake_core/bpy.py b/dm_control/blender/fake_core/bpy.py new file mode 100644 index 00000000..46ddac6e --- /dev/null +++ b/dm_control/blender/fake_core/bpy.py @@ -0,0 +1,229 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Fake Blender bpy module.""" + +from __future__ import annotations # postponed evaluation of annotations + +from typing import Any, Collection, Sequence + +from dm_control.blender.fake_core import mathutils + +# pylint: disable=invalid-name +# pylint: disable=missing-class-docstring + + +class WindowManager: + + def progress_begin(self, start: int, end: int): + pass + + def progress_update(self, steps_done: int): + pass + + def progress_end(self): + pass + + +class context: + + @property + def window_manager(self) -> WindowManager: + return WindowManager() + + @staticmethod + def evaluated_depsgraph_get(): + pass + + +class types: + + class Constraint: + + @property + def name(self): + pass + + @property + def owner_space(self): + pass + + class Scene: + pass + + class Object: + + @property + def name(self) -> str: + raise NotImplementedError() + + @property + def parent(self) -> types.Object | None: + pass + + @property + def parent_bone(self) -> types.Bone | None: + pass + + @property + def data(self): + pass + + @property + def pose(self): + pass + + @property + def matrix_world(self) -> mathutils.Matrix: + raise NotImplementedError() + + @matrix_world.setter + def matrix_world(self, _) -> mathutils.Matrix: + raise NotImplementedError() + + def select_set(self, _): + pass + + def to_mesh(self): + pass + + def evaluated_get(self, _) -> types.Object: # pyrefly: ignore[bad-return] + pass + + @property + def mode(self) -> str: + return 'OBJECT' + + @property + def type(self): + pass + + def update_from_editmode(self): + pass + + class Bone: + + @property + def name(self) -> str: + raise NotImplementedError() + + @property + def parent(self) -> types.Bone | None: + pass + + @property + def matrix_local(self) -> mathutils.Matrix: + raise NotImplementedError() + + @property + def matrix(self) -> mathutils.Matrix: + raise NotImplementedError() + + class bpy_struct: + pass + + class Context: + + @property + def scene(self) -> types.Scene: # pyrefly: ignore[bad-return] + pass + + class Light: + + @property + def type(self): + pass + + @property + def use_shadow(self): + pass + + @property + def color(self) -> mathutils.Color: + raise NotImplementedError() + + @property + def linear_attenuation(self): + pass + + @property + def quadratic_attenuation(self): + pass + + class LimitRotationConstraint(Constraint): + pass + + class LimitLocationConstraint(Constraint): + pass + + class Material: + + @property + def name(self) -> str: + raise NotImplementedError() + + @property + def specular_intensity(self): + pass + + @property + def metallic(self): + pass + + @property + def roughness(self) -> float: + raise NotImplementedError() + + @property + def diffuse_color(self) -> Sequence[float]: + raise NotImplementedError() + + class Mesh: + + @property + def name(self) -> str: + raise NotImplementedError() + + def calc_loop_triangles(self): + pass + + @property + def uv_layers(self) -> Any: + raise NotImplementedError() + + @property + def loop_triangles(self) -> Collection[Any]: + raise NotImplementedError() + + @property + def vertices(self) -> Any: + raise NotImplementedError() + + +class ops: + + class object: + + @staticmethod + def select_all(action): + pass + + class export_mesh: + + @classmethod + def stl( + cls, filepath, use_selection, use_mesh_modifiers, axis_forward, axis_up + ): + pass diff --git a/dm_control/blender/fake_core/mathutils.py b/dm_control/blender/fake_core/mathutils.py new file mode 100644 index 00000000..16f0c51b --- /dev/null +++ b/dm_control/blender/fake_core/mathutils.py @@ -0,0 +1,123 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Fake Blender mathutils module.""" +# pylint: disable=invalid-name + +import numpy as np + + +class Color: + """Fake color class.""" + + def __init__(self, coords): + self._coords = coords + + @property + def r(self) -> float: + return self._coords[0] + + @property + def g(self) -> float: + return self._coords[1] + + @property + def b(self) -> float: + return self._coords[2] + + @property + def a(self) -> float: + return self._coords[3] + + +class Vector: + """Fake vector class.""" + + def __init__(self, coords): + self._coords = np.asarray(coords) + + @property + def x(self) -> float: + return self._coords[0] + + @property + def y(self) -> float: + return self._coords[1] + + @property + def z(self) -> float: + return self._coords[2] + + @property + def w(self) -> float: + return self._coords[3] + + def __eq__(self, rhs) -> bool: + if not isinstance(rhs, Vector): + return False + return np.linalg.norm(self._coords - rhs._coords) < 1e-6 # pyrefly: ignore[bad-return] + + def __str__(self) -> str: + return 'Vector({:.2f}, {:.2f}, {:.2f})'.format(self.x, self.y, self.z) + + def __repr__(self) -> str: + return 'Vector({:.2f}, {:.2f}, {:.2f})'.format(self.x, self.y, self.z) + + +class Quaternion: + """Fake quaternion class.""" + + def __init__(self, coords): + self._coords = coords + + @property + def x(self) -> float: + return self._coords[1] + + @property + def y(self) -> float: + return self._coords[2] + + @property + def z(self) -> float: + return self._coords[3] + + @property + def w(self) -> float: + return self._coords[0] + + def __matmul__(self, rhs: Vector) -> Vector: + return Vector((1, 0, 0)) + + +class Matrix: + """Fake matrix class.""" + + def __init__(self, coords): + self._coords = coords + + @classmethod + def Diagonal(cls, _): + return cls((1,)) + + @property + def translation(self) -> Vector: + return Vector((0.0, 0.0, 0.0)) + + def to_quaternion(self) -> Quaternion: + return Quaternion((1.0, 0.0, 0.0, 0.0)) + + def to_scale(self) -> Vector: + return Vector((0.0, 0.0, 0.0)) diff --git a/dm_control/blender/mujoco_exporter/README.md b/dm_control/blender/mujoco_exporter/README.md new file mode 100644 index 00000000..a1470d9c --- /dev/null +++ b/dm_control/blender/mujoco_exporter/README.md @@ -0,0 +1,213 @@ +# Export Mujoco models from Blender + +## Prerequisites + +The MuJoCo exporter works as Blender addon, tested with ver 3.4.1. You can +download Blender from [https://www.blender.org](https://www.blender.org/). + +## Installation + +### Preparing a plugin installer + +The `install.sh` command will deploy the installable version of the plugin to +`./addons/mujoco_model_exporter`. This folder should be zipped: + +```shell +$ cd ./addons && zip -r mujoco_model_exporter.zip mujoco_model_exporter/* +``` + +### Installing the plugin in Blender + +Open Blender and select `Edit/Preferences` menu option. + +![Addon preferences](doc/install_plugin.png "Click the Install button"){style="display:block;margin:auto"} + +Clicking the Install button will open a file selection dialog that will allow +you to select the .zip archive with the plugin. + +The next window will follow, listing the installed plugin and allowing you to +enable it. + +![Enabling a plugin](doc/enable_plugin.png "Check the checkbox next to plugin's name"){style="display:block;margin:auto"} + +Check the checkbox next to its name to enable it. + +## Modelling for Mujoco. + +The plugin was designed to allow the artists to keep using core Blender features +when building models for Mujoco. + +feature | Mujoco | Blender +-------------- | ------------------------- | ----------------------------------- +kinematic tree | hierarchy of Bodies | Armatures or hierarchies of Objects +geometry | Geoms | Meshes +materials | Material + Texture assets | Material + Texture definitions +lighting | Lights | Lights + +### Node naming + +The exporter will copy the names of the bones, meshes and lights. Mujoco joints +will be named after the bone that owns the respective constraint, with a postfix +denoting the degree of freedom they enable. + +### Modelling kinematic trees using Armatures and IK + +Blender Armatures allow to model kinematic capabilities of a Blender model. They +comprise a tree of bones, each of which can be further extended with IK +Constraints. + +Only the bones affected by an IK chain will receive appropriate degrees of +freedom. This means that if you export a model without any IK chains, it will be +exported as a static model. + +#### IK Constraints and bone limits + +IK constraints offer the easiest way to verify how a kinematic chain would +behave in a physical environment. For that reason we chose to use them, along +with IK bone limits, to model Mujoco joints. + +![Select the end effector](doc/end_effector_selection.png "Select the end effector") + +* Select the bone at the end of a kinematic chain, + +![Add IK constraint](doc/add_ik_constraint.png "Add IK constraint") + +* Add an IK Bone Constraint to it. + [This webpage](https://docs.blender.org/manual/en/latest/animation/constraints/tracking/ik_solver.html) + contains detailed instructions. + +![Adding an IK target](doc/ik_target.png "Add an IK target") + +* Add a target bone to the scene and use it as the IK constraint's target. + +![Set the chain length](doc/ik_chain_length.png "Adjust the chain length") + +* Adjust the `Chain Length` value to reflect the number of parent bones that + should become a part of this IK chain. + +![Define bone limits](doc/limits.png "Define bone limits") + +* For each of the bones in the chain, visit the `Bone/Inverse Kinematics` tab + and adjust the locks and bone limits. + +![Bone limits gizmo](doc/gizmo.png "Bone limits gizmo") + +*NOTE*: you can take advantage of a helpful limit visualization gizmo. + +### Armature free joints. + +Armatures form the kinematic trees, so it makes sense to give their roots all 6 +degrees of freedom. + +This behavior can be changed by disabling the `Armature freejoint` export +option. + +### Modelling the geometry + +Mujoco uses parametric geometry comprised of primitive shapes such +as cubes, spheres and capsules. It does support triangle based mesh geometry +however. + +Blender on the other hand deals exclusively in meshes, and even though it +contains a palette of such primitive shapes, these are not parametric. + +#### MSH files + +The exporter therefore exports the geometry as meshes. All meshes referenced by +the scene are exported into Mujoco's native .msh format. + +When this plugin was created, the native .msh format was the only format to +support texture mapping. Since MuJoCo 2.1.2, `.obj` files are supported. The +`.msh` format is expected to be deprecated and removed soon. Until this plugin +is updated to output `.obj` files we recommend all user to convert their `.msh` +files to `.obj` files using [this](https://github.com/google-deepmind/mujoco/blob/main/python/mujoco/msh2obj.py) utility. + +#### Phong lighting model and lack of support for Cycles nodes + +Mujoco implements a forward rendering model with Phong lightning and support for +reflective surfaces. + +[Mujoco material definition](http://www.mujoco.org/book/XMLreference.html#material) +is fixed and limited to: + +* base color (diffuse color) +* specularity coefficient +* smoothness coefficient +* reflectiveness coefficient + +These parameters translate well to Blender's default material definition, which +should be used to model the materials exported to Mujoco. + +Due to the diversity and open-ended nature of Cycle's material nodes, the choice +was made not to support materials defined using them. + +#### Division of meshes that employ multiple materials + +Mujoco renderer supports a single material per mesh, in contrast to Blender's +multi material model. In order to support meshes with multiple materials, the +exporter divides them into submeshes, each of which uses a single material. + +Since Blender materials are applied to faces, and a single face may only have a +single material assigned, there is no risk that the subdivided mesh will exhibit +any overlapping artifacts. + +#### Effect of mesh division on inertia and mass. + +The exported submeshes will have different geometry and volume than the +original. This will affect the mass and inertia of the geom that uses the +exported mesh. + +Please note that Mujoco derives these quantities not from the mesh itself, but +rather from the convex hull calculated for it. The sum of hulls of subdivided +meshes are not guaranteed to be equal to the hull of the original mesh. + +This phenomenon is illustrated on the image below. + +Blender | Mujoco renderer | Mujoco convex hulls +----------------------------- | ------------------------------- | ------------------- +![](doc/convhull_blend.png) | ![](doc/convhull_mj_rend.png) | ![](doc/convhull_mj_hull.png) + +A different material was assigned to 2 out of 6 rectangular faces of the right +cube, causing that mesh to be split into two submeshes. + +While they render well in Mujoco, the Moiré effect visible on the convex hull +comes from two overlapping hulls, each with a different volume. + +#### Double-sided materials + +Double sided materials cause the meshes (or faces) that use them to be exported +with the faces duplicated with the reverse face winding order. This is because +Mujoco renderer does not support any other face culling modes than back face +culling. + +*CAUTION*: Because this operation affects the exported geometry, it may +indirectly affect the physical properties of a geom that references that mesh, +such as its mass and inertia. Please use this feature with caution. + +#### Scaling + +*CAUTION*: If you are using the scaling transform, the exporter will modify your +scene! + +The exporter will by default reset the scaling transform on all bones and meshes +to ensure affine reference frame transformations. + +This operation can be undo'ed after the exporter is completed, but the exporter +doesn't undo it automatically! + +### Textures + +*NOTE*: This feature will be added in the next version of the exporter. + +#### One texture channel + +Mujoco renderer supports a single texture channel. It's therefore advised to use +a single UV map for meshes that are to be exported to Mujoco. + +#### Choice between atlases and individual textures + +Mujoco's fixed rendering pipeline is quite fast, and handles both variants well. +Therefore we leave it up to the artist to decide which it prefers using. + +Please keep in mind that the exporter doesn't export the texture assets. Those +should be copied into the folder with the exported model manually. diff --git a/dm_control/blender/mujoco_exporter/__init__.py b/dm_control/blender/mujoco_exporter/__init__.py new file mode 100644 index 00000000..a33c6292 --- /dev/null +++ b/dm_control/blender/mujoco_exporter/__init__.py @@ -0,0 +1,170 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Blender 3.4 plugin for exporting models to MuJoCo native format.""" + +import contextlib +import os +from xml.dom import minidom + +import bpy +from bpy_extras.io_utils import ExportHelper + +from . import blender_scene +from . import mujoco_assets +from . import mujoco_scene + + +bl_info = { + 'name': 'Export MuJoCo', + 'author': 'The dm_control authors', + 'version': (2, 0), + 'blender': (3, 3, 1), + 'location': 'File > Export > MuJoCo', + 'warning': '', + 'description': 'Export articulated MuJoCo model', + 'doc_url': '', + 'category': 'Import-Export', +} + + +@contextlib.contextmanager +def context_settings_cacher(context: bpy.types.Context): + """Preserves the pose of exported objects and the scene mode.""" + # Cache the mode + prev_mode = context.mode + + # Set the Object mode required by the exporter + bpy.ops.object.mode_set(mode='OBJECT') + + # Set the armatures in their neutral pose + pose_positions = [] + for o in context.scene.objects: + if o.type == 'ARMATURE': + pose_positions.append((o, o.data.pose_position)) + o.data.pose_position = 'REST' + context.view_layer.update() + + try: + yield + finally: + # Restore the poses. + for armature, pose_position in pose_positions: + armature.data.pose_position = pose_position + context.view_layer.update() + + # Restore the mode + bpy.ops.object.mode_set(mode=prev_mode) + + +def apply_scale(): + bpy.ops.object.select_all(action='SELECT') + bpy.ops.object.transform_apply(location=False, scale=True, rotation=False) + bpy.ops.object.select_all(action='DESELECT') + + +class ExportMjcf(bpy.types.Operator, ExportHelper): + """Export to MJCF file format.""" + + bl_idname = 'export_scene.mjcf' + bl_label = 'Export MJCF' + + filename_ext = '.xml' + filter_glob = bpy.props.StringProperty(default='*.xml', options={'HIDDEN'}) + + # Export settings + armature_freejoint: bpy.props.BoolProperty( + name='Armature freejoint', + description='Add a freejoint to armature body', + default=False, + ) + apply_mesh_modifiers: bpy.props.BoolProperty( + name='Apply modifiers', + description='Apply mesh modifiers', + default=False, + ) + + def _export_mjcf(self, context: bpy.types.Context) -> None: + """Converts a Blender scene to Mujoco XML format.""" + # Create a new XML document + xml_doc = minidom.Document() + mujoco = xml_doc.createElement('mujoco') + xml_doc.appendChild(mujoco) + + # Create a list of blender objects, arranged according to their hierarchy, + # where parents precede the children. + blender_objects = blender_scene.map_blender_tree( + context, lambda o: o if o.is_visible else None + ) + # Remove None entries that correspond to invisible objects. + blender_objects = [o for o in blender_objects if o] + + export_settings = self.as_keywords() + + # Build the scene tree + worldbody_el = mujoco_scene.export_to_xml( + doc=xml_doc, + objects=blender_objects, + armature_freejoint=export_settings['armature_freejoint'], + ) + mujoco.appendChild(worldbody_el) + + # Build the asset tree + asset_el = mujoco_assets.export_to_xml( + doc=xml_doc, + objects=blender_objects, + folder=os.path.dirname(export_settings['filepath']), + apply_mesh_modifiers=export_settings['apply_mesh_modifiers'], + ) + mujoco.appendChild(asset_el) + + # Add compiler options that would allow to export small feature meshes. + compiler_el = xml_doc.createElement('compiler') + mujoco.appendChild(compiler_el) + compiler_el.setAttribute('boundmass', '1e-3') + compiler_el.setAttribute('boundinertia', '1e-9') + # TODO(b/266818670): support assets export into subdirectory + # compiler_el.setAttribute('meshdir', 'assets') + + # Write the XML to a file. + with open(export_settings['filepath'], 'w') as file: + file.write(xml_doc.toprettyxml(indent=' ')) + + def execute(self, context: bpy.types.Context): + """Export the scene.""" + with context_settings_cacher(context): + apply_scale() + self._export_mjcf(context) + + return {'FINISHED'} + + +def menu_func_export(self, context: bpy.types.Context): + del context + self.layout.operator(ExportMjcf.bl_idname, text='MuJoCo (.xml)') + + +def register(): + bpy.utils.register_class(ExportMjcf) + bpy.types.TOPBAR_MT_file_export.append(menu_func_export) + + +def unregister(): + bpy.utils.unregister_class(ExportMjcf) + bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) + + +if __name__ == '__main__': + register() diff --git a/dm_control/blender/mujoco_exporter/blender_scene.py b/dm_control/blender/mujoco_exporter/blender_scene.py new file mode 100644 index 00000000..ca50a537 --- /dev/null +++ b/dm_control/blender/mujoco_exporter/blender_scene.py @@ -0,0 +1,428 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Blender scene parsers.""" + +# disable: pytype=strict-none +import dataclasses +import math +from typing import Any, Callable, Sequence, Tuple, cast + +from dm_control.blender.fake_core import bpy +from dm_control.blender.fake_core import mathutils + +_ARMATURE = 'ARMATURE' +_CAMERA = 'CAMERA' +_EMPTY = 'EMPTY' +_LIGHT = 'LIGHT' +_MESH = 'MESH' +_VEC_ZERO = mathutils.Vector((0, 0, 0)) +_OX = mathutils.Vector((1, 0, 0)) +_OY = mathutils.Vector((0, 1, 0)) +_OZ = mathutils.Vector((0, 0, 1)) + + +def _check_that_parent_bone_exists(obj: bpy.types.Object) -> None: + """Checks if the bone the object was supposed to be parented exists.""" + assert obj is not None + if obj.parent is None: + raise ValueError( + 'Armature object "{}" was parented to does not exist'.format(obj.name) + ) + + armature = obj.parent + if armature.type != _ARMATURE: + raise ValueError( + 'Parent of object "{}" - "{}", is not an armature, but a "{}"'.format( + obj.name, armature.name, armature.type + ) + ) + + if obj.parent_bone not in armature.data.bones: + raise ValueError( + 'Object "{}" is parented to a non-existing bone "{}" from ' + 'armature "{}"'.format(obj.name, obj.parent_bone, armature.name) + ) + + +def _check_constraint_in_local_space( + constraint: bpy.types.Constraint, owner: 'ObjectRef' +) -> None: + if constraint and constraint.owner_space != 'LOCAL': + raise ValueError( + 'Constraint "{}" (bone "{}", armature "{}") uses an unsupported ' + 'owner_mode "{}". Only "LOCAL" mode is supported at the ' + 'moment'.format( + type(constraint), + owner.bone_name(), + owner.obj_name(), + constraint.owner_space, + ) + ) + + +def _angle_distance(lhs_deg: float, rhs_deg: float) -> float: + """Calculates distance between two angles, in degrees.""" + x_2 = math.cos(math.radians(lhs_deg)) * math.cos(math.radians(rhs_deg)) + y_2 = math.sin(math.radians(lhs_deg)) * math.sin(math.radians(rhs_deg)) + return math.degrees(math.acos(x_2 + y_2)) + + +@dataclasses.dataclass(frozen=True) +class AffineTransform: + pos: mathutils.Vector + rot: mathutils.Quaternion + + +@dataclasses.dataclass(frozen=True) +class Dof: + """Degree of freedom description.""" + + name: str + axis: mathutils.Vector + limited: bool = False + limits: Tuple[float, float] = (0, 0) + + +@dataclasses.dataclass(frozen=True) +class ObjectRef: + """References a Blender object, be that a scene object or a bone. + + Object reference is hashable and comparable. Equality is based on the names + of the underlying objects. Blender's API guarantees that a combination of + object/bone name will be unique across the scene. We're leveraging that rule. + """ + + native_obj: bpy.types.Object | None + native_bone: bpy.types.Bone | None = None + + def __hash__(self) -> int: + return hash(self.name) + + def __eq__(self, rhs) -> bool: + if not isinstance(rhs, ObjectRef): + return False + return self.name == rhs.name + + @property + def is_none(self) -> bool: + return self.native_bone is None + + @property + def is_armature(self) -> bool: + return ( + self.native_obj # pyrefly: ignore[bad-return] + and self.native_obj.type == _ARMATURE + and not self.native_bone + ) + + @property + def is_bone(self) -> bool: + return bool( + self.native_obj + and self.native_obj.type == _ARMATURE + and self.native_bone + ) + + @property + def is_mesh(self) -> bool: + return self.native_obj and self.native_obj.type == _MESH # pyrefly: ignore[bad-return] + + @property + def is_light(self) -> bool: + return self.native_obj and self.native_obj.type == _LIGHT # pyrefly: ignore[bad-return] + + @property + def is_camera(self) -> bool: + return self.native_obj and self.native_obj.type == _CAMERA # pyrefly: ignore[bad-return] + + @property + def is_empty(self) -> bool: + return self.native_obj and self.native_obj.type == _EMPTY # pyrefly: ignore[bad-return] + + @property + def name(self) -> str: + if not self.native_obj: + return '' + if self.native_bone: + return '{}_{}'.format(self.native_bone.name, self.native_obj.name) + else: + return self.native_obj.name + + def as_light(self) -> bpy.types.Light: + return cast(bpy.types.Light, self.obj_data()) + + def get_local_transform(self) -> AffineTransform: + """Returns a transform wrt. the local reference frame.""" + + def get_bone_local_mtx(bone: bpy.types.Bone) -> mathutils.Matrix: + """Derives a local matrix of an armature bone.""" + assert isinstance(bone, bpy.types.Bone) + if bone.parent: + return bone.parent.matrix_local.inverted() @ bone.matrix_local # pyrefly: ignore[missing-attribute] + else: + return bone.matrix_local + + def get_object_local_mtx(obj: bpy.types.Object) -> mathutils.Matrix: + """Derives a local matrix of an object, such as a mesh or armature.""" + assert isinstance(obj, bpy.types.Object) + if obj.parent: + local_mtx = obj.parent.matrix_world.inverted() @ obj.matrix_world # pyrefly: ignore[missing-attribute] + if obj.parent_bone: + assert obj.parent.type == _ARMATURE + armature = obj.parent + bone = armature.data.bones[obj.parent_bone] + return bone.matrix_local.inverted() @ local_mtx + else: + return local_mtx + else: + return obj.matrix_world + + if self.is_bone: + local_mtx = get_bone_local_mtx(self.native_bone) # pyrefly: ignore[bad-argument-type] + else: + local_mtx = get_object_local_mtx(self.native_obj) # pyrefly: ignore[bad-argument-type] + + rot_quat = local_mtx.to_quaternion() + pos = local_mtx.translation + return AffineTransform(pos, rot_quat) + + @property + def is_visible(self) -> bool: + """Checks if an object is visible.""" + if not self.native_obj: + return False + if hasattr(self.native_obj, 'visible_get'): + return self.native_obj.visible_get() + else: + return True + + @property + def parent(self) -> 'ObjectRef': + """Returns a reference to the parent of this object.""" + if self.native_obj is None: + return ObjectRef(None) + + def bone_parent( + armature: bpy.types.Object, bone: bpy.types.Bone + ) -> 'ObjectRef': + if bone.parent: + # Parent of a child bone is its parent bone in the same armature + assert isinstance(bone.parent, bpy.types.Bone) + return ObjectRef.new_bone(armature, bone.parent) + else: + # Parent of the root bone is the armature. + return ObjectRef.new_object(armature) + + def object_parent(obj: bpy.types.Object) -> 'ObjectRef': + if obj.parent_bone: + # The object is parented to an armature, and the .parent field must + # contain a reference the armature. + assert obj.parent and obj.parent.type == _ARMATURE + _check_that_parent_bone_exists(obj) + armature = obj.parent + parent_bone = armature.data.bones[obj.parent_bone] + return ObjectRef.new_bone(armature, parent_bone) + elif obj.parent: + # The object is parented to another object. + return ObjectRef.new_object(obj.parent) + else: + # This is a root object + return ObjectRef(None) + + if self.native_bone: + return bone_parent(self.native_obj, self.native_bone) + else: + return object_parent(self.native_obj) + + def get_rotation_dofs(self) -> Sequence[Dof]: + """Returns the rotation degrees of freedom in the subtractive mode. + + The method returns the degrees of freedom present from the point of view + of Blender. These are modeled such that in absence of constraints, all + degrees of freedom are present. + + Returns: + A sequence of degree of freedom definitions. + """ + if not self.is_bone: + raise ValueError( + 'Rotation degrees of freedom are defined only for bones.' + ) + assert self.native_obj + assert self.native_obj.type == _ARMATURE + armature = self.native_obj + bone = armature.pose.bones[self.native_bone.name] + if not bone.is_in_ik_chain: + # Bones not in an IK chain don't receive any degrees of freedom. + return [] + + is_locked = [bone.lock_ik_x, bone.lock_ik_y, bone.lock_ik_z] + use_limits = [bone.use_ik_limit_x, bone.use_ik_limit_y, bone.use_ik_limit_z] + limits = [ + (math.degrees(bone.ik_min_x), math.degrees(bone.ik_max_x)), + (math.degrees(bone.ik_min_y), math.degrees(bone.ik_max_y)), + (math.degrees(bone.ik_min_z), math.degrees(bone.ik_max_z)), + ] + axes = [_OX, _OY, _OZ] + names = [ + 'rx_{}'.format(self.name), + 'ry_{}'.format(self.name), + 'rz_{}'.format(self.name), + ] + axis_names = ['X', 'Y', 'Z'] + + def build_dof(idx): + if limits[idx][0] >= limits[idx][1]: + raise ValueError( + 'Bone "{}" uses incorrect IK limits for {} axis. ' + '{} < {} is violated'.format( + self.name, axis_names[idx], limits[idx][0], limits[idx][1] + ) + ) + return Dof( + name=names[idx], + axis=axes[idx], + limited=use_limits[idx], + limits=limits[idx], + ) + + return [build_dof(i) for i in range(3) if not is_locked[i]] + + def obj_data(self) -> bpy.types.Object | None: + if self.native_obj is not None: + return self.native_obj.data + else: + return None + + def obj_name(self) -> str | None: + if self.native_obj is not None: + return self.native_obj.name + else: + return None + + def bone_name(self) -> str | None: + if self.native_bone is not None: + return self.native_bone.name + else: + return None + + @property + def mesh(self) -> bpy.types.Mesh: + """Returns the mesh associated with this object.""" + assert self.is_mesh + return cast(bpy.types.Mesh, self.obj_data()) + + def get_modified_mesh(self) -> bpy.types.Mesh | None: + """Returns a mesh with modifiers applied to it.""" + assert self.is_mesh + assert self.native_obj is not None + if self.native_obj.mode == 'EDIT': + self.native_obj.update_from_editmode() + + # get the modifiers + depsgraph = bpy.context.evaluated_depsgraph_get() + mesh_owner = self.native_obj.evaluated_get(depsgraph) + + return mesh_owner.to_mesh() + + @property + def materials(self) -> Sequence[bpy.types.Material]: + """Returns the materials assigned to this object.""" + data = self.obj_data() + if hasattr(data, 'materials'): + return data.materials + else: + return [] + + @classmethod + def new_object(cls, obj: bpy.types.Object) -> 'ObjectRef': + assert isinstance(obj, bpy.types.Object) or obj is None + return cls(obj) + + @classmethod + def new_bone( + cls, + armature: bpy.types.Object, + bone: bpy.types.Bone | None, + ) -> 'ObjectRef': + assert isinstance(armature, bpy.types.Object) + assert armature.type == _ARMATURE + assert isinstance(bone, bpy.types.Bone) or bone is None + return cls(armature, bone) + + +NoneRef = ObjectRef(None, None) + + +def map_blender_tree( + context: bpy.types.Context, callback: Callable[[ObjectRef], Any] +) -> Sequence[Any]: + """Returns a list of scene objects in the breadth-first order.""" + # Collect all nodes to explore - objects and bones alike + assert context.scene is not None + to_explore = [ObjectRef.new_object(o) for o in context.scene.objects] # pyrefly: ignore[missing-attribute] + armatures = [o for o in context.scene.objects if o.type == 'ARMATURE'] # pyrefly: ignore[missing-attribute] + for armature in armatures: + for bone in armature.data.bones: + to_explore.append(ObjectRef.new_bone(armature, bone)) + + explored = set() + explored.add(NoneRef) + result = [] + + while to_explore: + obj_ref: ObjectRef = to_explore[0] + to_explore = to_explore[1:] + + if obj_ref.parent in explored: + explored.add(obj_ref) + result.append(callback(obj_ref)) + else: + to_explore.append(obj_ref) + + return result + + +def get_material_mesh_pair_name(mesh_name: str, mat_name: str) -> str: + """Build the name for a mesh-material pair.""" + return '{}_{}'.format(mesh_name, mat_name) if mat_name else mesh_name + + +def is_material_mesh_pair_valid(mesh: bpy.types.Mesh, mat_idx: int) -> bool: + """Tests the mesh-material pair whether it contains any geometry.""" + mesh.calc_loop_triangles() + faces = [f for f in mesh.loop_triangles if f.material_index == mat_idx] + return bool(faces) + + +def map_materials( + func: Callable[[bpy.types.Material], Any], + materials: Sequence[bpy.types.Material], +) -> Sequence[Any]: + """Maps a collection of materials, adjusting for empty collections. + + In case of an empty collection, a substitute for a default material is passed + to the mapping callback. + + Args: + func: Mapping callback. + materials: Collection of materials. + + Returns: + An arbitrary collection of data mapped out of the materials collection. + """ + materials = materials or [] + return [func(material) for material in materials] diff --git a/dm_control/blender/mujoco_exporter/blender_scene_test.py b/dm_control/blender/mujoco_exporter/blender_scene_test.py new file mode 100644 index 00000000..81ba49f6 --- /dev/null +++ b/dm_control/blender/mujoco_exporter/blender_scene_test.py @@ -0,0 +1,244 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Tests for blender_scene.py.""" + +import math +from unittest import mock +from absl.testing import absltest +from absl.testing import parameterized +from dm_control.blender.fake_core import bpy +from dm_control.blender.fake_core import mathutils +from dm_control.blender.mujoco_exporter import blender_scene +from dm_control.blender.mujoco_exporter import testing +import numpy as np + + +class AngleDistanceTest(parameterized.TestCase): + + @parameterized.parameters([ + [0, 0, 0], + [90, 90, 0], + [-90, -90, 0], + [-90, 0, 90], + [0, 90, 90], + [-90, 90, 180], + [-180, 180, 0], + [-270, 90, 0], + [-90, 270, 0], + [-90, 360, 90], + ]) + def test_angle_distance(self, lhs, rhs, result): + self.assertAlmostEqual(blender_scene._angle_distance(lhs, rhs), result, 2) + + +class ObjectRefTest(absltest.TestCase): + + def test_hashing_and_comparison(self): + mesh_a = blender_scene.ObjectRef.new_object( + testing.build_mesh_object('mesh_a') + ) + mesh_a_copy = blender_scene.ObjectRef.new_object( + testing.build_mesh_object('mesh_a') + ) + mesh_b = blender_scene.ObjectRef.new_object( + testing.build_mesh_object('mesh_b') + ) + + self.assertEqual(hash(mesh_a), hash(mesh_a_copy)) + self.assertNotEqual(hash(mesh_a), hash(mesh_b)) + + self.assertEqual(mesh_a, mesh_a_copy) + self.assertNotEqual(mesh_a, mesh_b) + + def test_parent_of_root_object_is_noneref(self): + obj_ref = blender_scene.ObjectRef.new_object( + testing.build_mesh_object('mesh') + ) + self.assertEqual(obj_ref.parent, blender_scene.NoneRef) + + def test_parent_of_object_parented_to_another_object(self): + parent_obj = testing.build_mesh_object('parent_obj') + child_obj = testing.build_mesh_object('child_obj', parent=parent_obj) + + parent_ref = blender_scene.ObjectRef.new_object(parent_obj) + child_ref = blender_scene.ObjectRef.new_object(child_obj) + self.assertEqual(child_ref.parent, parent_ref) + + def test_parent_of_object_parented_to_bone(self): + bone = testing.build_bone('bone') + armature = testing.build_armature('armature', bones=[bone]) + child_obj = testing.build_mesh_object( + 'child_obj', parent=armature, parent_bone=bone.name + ) + + parent_ref = blender_scene.ObjectRef.new_bone(armature, bone) + child_ref = blender_scene.ObjectRef.new_object(child_obj) + self.assertEqual(child_ref.parent, parent_ref) + + def test_parent_of_root_bone_is_armature(self): + bone = testing.build_bone('bone') + armature = testing.build_armature('armature', bones=[bone]) + + armature_ref = blender_scene.ObjectRef.new_object(armature) + bone_ref = blender_scene.ObjectRef.new_bone(armature, bone) + self.assertEqual(bone_ref.parent, armature_ref) + + +class BlenderTreeMapperTest(parameterized.TestCase): + + @parameterized.parameters([ + [['obj1']], + [['obj1', 'obj2']], + [['obj2', 'obj1']], + [['obj2', 'obj1', 'obj3']], + ]) + def test_object_without_hierarchy(self, object_names): + context = mock.MagicMock(spec=bpy.types.Context) + context.scene.objects = [ + testing.build_mesh_object(name) for name in object_names + ] + + objects = blender_scene.map_blender_tree(context, lambda x: x) + self.assertEqual([o.native_obj.name for o in objects], object_names) + + @parameterized.parameters([ + # A hierarchy with 2 objects + dict( + parent_child_tuples=[('obj_1', 'obj_1_1')], + expected_order=['obj_1', 'obj_1_1'], + ), + # Shallow hierarchy with 3 objects + dict( + parent_child_tuples=[('obj_1', 'obj_1_1'), ('obj_1', 'obj_1_2')], + expected_order=['obj_1', 'obj_1_1', 'obj_1_2'], + ), + # Deep hierarchy with 3 objects + dict( + parent_child_tuples=[('obj_1', 'obj_1_1'), ('obj_1_1', 'obj_1_2')], + expected_order=['obj_1', 'obj_1_1', 'obj_1_2'], + ), + # 2 hierarchies, 2 objects each + dict( + parent_child_tuples=[('obj_1', 'obj_1_1'), ('obj_2', 'obj_2_1')], + expected_order=['obj_1', 'obj_1_1', 'obj_2', 'obj_2_1'], + ), + ]) + def test_object_with_hierarchy(self, parent_child_tuples, expected_order): + objects = {} + for parent_name, child_name in parent_child_tuples: + parent = objects.get(parent_name, None) + if not parent: + parent = testing.build_mesh_object(parent_name) + objects[parent_name] = parent + + child = testing.build_mesh_object(child_name, parent=parent) + objects[child_name] = child + + context = mock.MagicMock(spec=bpy.types.Context) + context.scene.objects = [o for o in objects.values()] + + object_names = blender_scene.map_blender_tree(context, lambda x: x.name) + self.assertEqual(object_names, expected_order) + + def test_boneless_armature(self): + armature = testing.build_armature('armature') + + context = mock.MagicMock(spec=bpy.types.Context) + context.scene.objects = [armature] + + objects = blender_scene.map_blender_tree(context, lambda x: x) + self.assertLen(objects, 1) + + def test_armature_with_bones(self): + root = testing.build_bone('root') + r_arm = testing.build_bone('r_arm', parent=root) + l_arm = testing.build_bone('l_arm', parent=root) + r_finger = testing.build_bone('r_finger', parent=r_arm) + armature = testing.build_armature( + 'armature', bones=[r_finger, l_arm, root, r_arm] + ) + expected_order = [ + 'armature', + 'root_armature', + 'r_arm_armature', + 'r_finger_armature', + 'l_arm_armature', + ] + + context = mock.MagicMock(spec=bpy.types.Context) + # Randomize the order of objects to test the sorting mechanism. + context.scene.objects = [armature] + + object_names = blender_scene.map_blender_tree(context, lambda x: x.name) + self.assertEqual(object_names, expected_order) + + +class DegreesOfFreedomTest(parameterized.TestCase): + + def test_ik_with_unlimited_dofs_adds_all_dofs(self): + constraint = testing.build_rotation_constraint( + use_limit_x=False, use_limit_y=False, use_limit_z=False + ) + bone = testing.build_bone('bone', constraint=constraint) + armature = testing.build_armature('bone', bones=[bone]) + bone_ref = blender_scene.ObjectRef.new_bone(armature, bone) + + dofs = bone_ref.get_rotation_dofs() + self.assertLen(dofs, 3) + self.assertEqual(dofs[0].axis, mathutils.Vector((1, 0, 0))) + self.assertEqual(dofs[1].axis, mathutils.Vector((0, 1, 0))) + self.assertEqual(dofs[2].axis, mathutils.Vector((0, 0, 1))) + self.assertFalse(dofs[0].limited) + self.assertFalse(dofs[1].limited) + self.assertFalse(dofs[2].limited) + + def test_ik_with_removed_dofs(self): + constraint = testing.build_rotation_constraint( + lock_x=False, + lock_y=True, + lock_z=False, + use_limit_x=True, + use_limit_z=False, + ) + bone = testing.build_bone('bone', constraint=constraint) + armature = testing.build_armature('bone', bones=[bone]) + bone_ref = blender_scene.ObjectRef.new_bone(armature, bone) + + dofs = bone_ref.get_rotation_dofs() + self.assertLen(dofs, 2) + self.assertEqual(dofs[0].axis, mathutils.Vector((1, 0, 0))) + self.assertEqual(dofs[1].axis, mathutils.Vector((0, 0, 1))) + self.assertTrue(dofs[0].limited) + self.assertFalse(dofs[1].limited) + + def test_limiting_dof(self): + constraint = testing.build_rotation_constraint( + use_limit_x=True, min_x=math.radians(-15), max_x=math.radians(30) + ) + bone = testing.build_bone('bone', constraint=constraint) + armature = testing.build_armature('bone', bones=[bone]) + bone_ref = blender_scene.ObjectRef.new_bone(armature, bone) + + dofs = bone_ref.get_rotation_dofs() + self.assertLen(dofs, 3) + self.assertTrue(dofs[0].limited) + self.assertFalse(dofs[1].limited) + self.assertFalse(dofs[2].limited) + np.testing.assert_array_almost_equal(dofs[0].limits, (-15, 30), 1e-2) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_control/blender/mujoco_exporter/doc/add_ik_constraint.png b/dm_control/blender/mujoco_exporter/doc/add_ik_constraint.png new file mode 100644 index 00000000..9b86681a Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/add_ik_constraint.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/convhull_blend.png b/dm_control/blender/mujoco_exporter/doc/convhull_blend.png new file mode 100644 index 00000000..c1a56626 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/convhull_blend.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/convhull_mj_hull.png b/dm_control/blender/mujoco_exporter/doc/convhull_mj_hull.png new file mode 100644 index 00000000..fb10b657 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/convhull_mj_hull.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/convhull_mj_rend.png b/dm_control/blender/mujoco_exporter/doc/convhull_mj_rend.png new file mode 100644 index 00000000..094a8aa9 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/convhull_mj_rend.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/enable_plugin.png b/dm_control/blender/mujoco_exporter/doc/enable_plugin.png new file mode 100644 index 00000000..f82c349a Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/enable_plugin.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/end_effector_selection.png b/dm_control/blender/mujoco_exporter/doc/end_effector_selection.png new file mode 100644 index 00000000..4db770a7 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/end_effector_selection.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/gizmo.png b/dm_control/blender/mujoco_exporter/doc/gizmo.png new file mode 100644 index 00000000..fe9d8762 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/gizmo.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/ik_chain_length.png b/dm_control/blender/mujoco_exporter/doc/ik_chain_length.png new file mode 100644 index 00000000..3629c71d Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/ik_chain_length.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/ik_target.png b/dm_control/blender/mujoco_exporter/doc/ik_target.png new file mode 100644 index 00000000..c73d5c36 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/ik_target.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/install_plugin.png b/dm_control/blender/mujoco_exporter/doc/install_plugin.png new file mode 100644 index 00000000..9a66ebc7 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/install_plugin.png differ diff --git a/dm_control/blender/mujoco_exporter/doc/limits.png b/dm_control/blender/mujoco_exporter/doc/limits.png new file mode 100644 index 00000000..f41f2449 Binary files /dev/null and b/dm_control/blender/mujoco_exporter/doc/limits.png differ diff --git a/dm_control/blender/mujoco_exporter/install.sh b/dm_control/blender/mujoco_exporter/install.sh new file mode 100755 index 00000000..0c71fea8 --- /dev/null +++ b/dm_control/blender/mujoco_exporter/install.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail + +export output_dir="./addons/mujoco_model_exporter" +rm -rf "${output_dir}" +mkdir -p "${output_dir}" + +cp ./*.py "${output_dir}" +find "${output_dir}" -name "*.py" -exec sed -i "s/from dm_control.blender.fake_core //g" "{}" +; +find "${output_dir}" -name "*.py" -exec sed -i "s/from dm_control.blender.mujoco_exporter/from ./g" "{}" +; +echo "Add-on exported to ${output_dir}." +echo "Copy this to Blender ./scripts/addons - see https://docs.blender.org/manual/en/latest/advanced/blender_directory_layout.html" diff --git a/dm_control/blender/mujoco_exporter/mujoco_assets.py b/dm_control/blender/mujoco_exporter/mujoco_assets.py new file mode 100644 index 00000000..6c96453a --- /dev/null +++ b/dm_control/blender/mujoco_exporter/mujoco_assets.py @@ -0,0 +1,175 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Mujoco asset exporters. + +Note about Materials. +Material nodes are not supported, so please use basic Blender materials. +""" + +import os +import struct +from typing import Sequence +from xml.dom import minidom + +from dm_control.blender.fake_core import bpy +from dm_control.blender.mujoco_exporter import blender_scene + +_MESH = 'MESH' + + +class MujocoMesh: + """Mujoco representation of a mesh. + + Mujoco can't handle meshes that reference multiple materials. Therefore + an instance of this class represents a submesh that uses a specific material. + """ + + def __init__(self, mesh: bpy.types.Mesh, material_idx: int, two_sided: bool): + if mesh.uv_layers.active is None: + raise ValueError(f'Mesh {mesh.name} does not have an active UV layer.') + + mesh.calc_loop_triangles() + + indices = [] + for face in mesh.loop_triangles: + if face.material_index == material_idx: + indices.extend([i for i in face.vertices]) + + uv_layer = mesh.uv_layers.active.data + # pylint: disable=g-complex-comprehension + self.vertices = [c for i in indices for c in mesh.vertices[i].co] + self.normals = [c for i in indices for c in mesh.vertices[i].normal] + self.uvs = [c for i in indices for c in uv_layer[i].uv] + # pylint: enable=g-complex-comprehension + self.faces = list(range(len(self.vertices) // 3)) + + if two_sided: + # For two-sided meshes, duplicate the geometry with flipped normals + # and reversed triangle winding order. + base_vertex = len(self.vertices) // 3 + self.vertices += list(self.vertices) + self.normals += [n * -1.0 for n in self.normals] + self.uvs += list(self.uvs) + self.faces += [i + base_vertex for i in reversed(self.faces)] + + def save(self, filepath: str) -> None: + """Save the data in the MSH format.""" + nvertex = len(self.vertices) + nnormal = len(self.normals) + ntexcoord = len(self.uvs) + nface = len(self.faces) + assert nvertex % 3 == 0 + assert nnormal % 3 == 0 + assert ntexcoord % 2 == 0 + assert nface % 3 == 0 + + fmt_msh = '4i{}f{}f{}f{}i'.format(nvertex, nnormal, ntexcoord, nface) + with open(filepath, 'wb') as f: + f.write( + struct.pack( + fmt_msh, + nvertex // 3, + nnormal // 3, + ntexcoord // 2, + nface // 3, + *self.vertices, + *self.normals, + *self.uvs, + *self.faces, + ) + ) + + +def mesh_asset_builder( + doc: minidom.Document, + mesh: bpy.types.Mesh, + materials: Sequence[bpy.types.Material], + folder: str, +) -> Sequence[minidom.Element]: + """Exports a mesh associated with the object and creates an 'asset' node.""" + mat_names = blender_scene.map_materials(lambda m: m.name, materials) + twosidedness = blender_scene.map_materials( + lambda m: not m.use_backface_culling, materials # pyrefly: ignore[missing-attribute] + ) + mat_names = mat_names or [''] + twosidedness = twosidedness or [False] + + elements = [] + for mat_idx, (mat_name, twosided) in enumerate(zip(mat_names, twosidedness)): + el_name = blender_scene.get_material_mesh_pair_name(mesh.name, mat_name) + filename = '{}.msh'.format(el_name) + filepath = os.path.join(folder, filename) + + if blender_scene.is_material_mesh_pair_valid(mesh, mat_idx): + mesh_to_export = MujocoMesh(mesh, mat_idx, twosided) + mesh_to_export.save(filepath) + + el = doc.createElement('mesh') + el.setAttribute('name', el_name) + el.setAttribute('file', filename) + elements.append(el) + return elements + + +def clip01(value): + return min(1.0, max(0.0, value)) + + +def material_asset_builder( + doc: minidom.Document, mat: bpy.types.Material +) -> minidom.Element: + """Builds a material asset node.""" + el = doc.createElement('material') + el.setAttribute('name', mat.name) + # el.setAttribute('texture', '') + el.setAttribute('specular', str(clip01(mat.specular_intensity))) + el.setAttribute('shininess', str(clip01(1.0 - mat.roughness))) + el.setAttribute('reflectance', str(clip01(mat.metallic))) + el.setAttribute('rgba', ' '.join([str(c) for c in mat.diffuse_color])) + return el + + +def export_to_xml( + doc: minidom.Document, + objects: Sequence[blender_scene.ObjectRef], + folder: str, + apply_mesh_modifiers: bool, +) -> minidom.Element: + """converts Blender scene objects to Mujoco assets.""" + asset_el = doc.createElement('asset') + + unique = set() + for obj in objects: + if obj.is_mesh and obj.mesh not in unique: + # Use the base mesh object as a reference, because that's the reference + # that will be shared between objects. + unique.add(obj.mesh) + if apply_mesh_modifiers: + # Make sure to export the mesh with modifiers applied, and if that's not + # possible (it may not be possible and None will be returned), default + # to the base mesh. + mesh_for_export = obj.get_modified_mesh() or obj.mesh + else: + mesh_for_export = obj.mesh + for el in mesh_asset_builder(doc, mesh_for_export, obj.materials, folder): + asset_el.appendChild(el) + + for material in obj.materials: + if material not in unique: + unique.add(material) + asset_el.appendChild(material_asset_builder(doc, material)) + + return asset_el diff --git a/dm_control/blender/mujoco_exporter/mujoco_assets_test.py b/dm_control/blender/mujoco_exporter/mujoco_assets_test.py new file mode 100644 index 00000000..bbf977d3 --- /dev/null +++ b/dm_control/blender/mujoco_exporter/mujoco_assets_test.py @@ -0,0 +1,225 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Tests for mujoco_assets.py.""" + +import collections +from unittest import mock +from xml.dom import minidom + +from absl.testing import absltest +from absl.testing import parameterized +from dm_control.blender.mujoco_exporter import blender_scene +from dm_control.blender.mujoco_exporter import mujoco_assets +from dm_control.blender.mujoco_exporter import testing + +_DEFAULT_SETTINGS = dict(folder='', apply_mesh_modifiers=False) + + +class MujocoAssetsTest(parameterized.TestCase): + + def test_building_mesh_asset(self): + mesh_object = testing.build_mesh_object('mesh') + + xml_root = mock.MagicMock(spec=minidom.Document) + xml_root.createElement = mock.MagicMock(spec=minidom.Element) + + obj = blender_scene.ObjectRef(mesh_object) + with mock.patch(mujoco_assets.__name__ + '.open', mock.mock_open()): + element = mujoco_assets.export_to_xml( + xml_root, [obj], **_DEFAULT_SETTINGS + ) + + xml_root.createElement.assert_any_call('mesh') + element.setAttribute.assert_any_call('name', 'mesh') + # Note there's no folder in the name. + # The asset is assumed to be exported into the same folder as the .xml file. + element.setAttribute.assert_any_call('file', 'mesh.msh') + + def test_exporting_mesh_asset(self): + mesh = testing.build_mesh(name='mesh.001') + mesh_object = testing.build_mesh_object('mesh', mesh=mesh) + xml_root = mock.MagicMock(spec=minidom.Document) + + obj = blender_scene.ObjectRef(mesh_object) + mock_open = mock.mock_open() + with mock.patch(mujoco_assets.__name__ + '.open', mock_open): + mujoco_assets.export_to_xml(xml_root, [obj], '/folder', False) + mock_open.assert_called_once_with('/folder/mesh.001.msh', 'wb') + + def test_exporting_material_asset(self): + material = testing.build_material( + 'mat', color=(0.1, 0.2, 0.3, 0.4), specular=0.5, metallic=0.6 + ) + xml_root = mock.MagicMock(spec=minidom.Document) + + result = mujoco_assets.material_asset_builder(xml_root, material) + result.setAttribute.assert_any_call('name', 'mat') + result.setAttribute.assert_any_call('specular', '0.5') + result.setAttribute.assert_any_call('reflectance', '0.6') + result.setAttribute.assert_any_call('rgba', '0.1 0.2 0.3 0.4') + + @parameterized.parameters([ + [0.0, 1.0], + [0.25, 0.75], + [0.5, 0.5], + [1.0, 0.0], + [5.0, 0.0], + [-5.0, 1.0], + ]) + def test_shininess_is_inverse_of_blender_mat_roughness(self, blender, mujoco): + material = testing.build_material('mat', roughness=blender) + xml_root = mock.MagicMock(spec=minidom.Document) + + result = mujoco_assets.material_asset_builder(xml_root, material) + result.setAttribute.assert_any_call('shininess', str(mujoco)) + + def test_export_unique_asset_instances(self): + num_objects = 10 + shared_material = testing.build_material('mat_1') + shared_mesh = testing.build_mesh('mesh.001', materials=[shared_material]) + + objects = [ + blender_scene.ObjectRef( + testing.build_mesh_object('mesh', mesh=shared_mesh) + ) + for _ in range(num_objects) + ] + xml_root = mock.MagicMock(spec=minidom.Document) + with mock.patch(mujoco_assets.__name__ + '.open', mock.mock_open()): + mujoco_assets.export_to_xml(xml_root, objects, **_DEFAULT_SETTINGS) + + create_element_call_counts = collections.Counter( + [m_[0][0] for m_ in xml_root.createElement.call_args_list] + ) + self.assertEqual(create_element_call_counts['mesh'], 1) + self.assertEqual(create_element_call_counts['material'], 1) + + def test_mesh_with_multiple_materials_split(self): + mat_1 = testing.build_material('mat_1') + mat_2 = testing.build_material('mat_2') + mesh = testing.build_mesh( + 'mesh', + materials=[mat_1, mat_2], + faces=[[0, 1, 2], [3, 4, 5]], + vert_co=[ + [0.1, 0.1, 0.1], + [0.2, 0.2, 0.2], + [0.3, 0.3, 0.3], + [0.4, 0.4, 0.4], + [0.5, 0.5, 0.5], + [0.6, 0.6, 0.6], + ], + mat_ids=[0, 1], + ) + + mesh_obj = blender_scene.ObjectRef( + testing.build_mesh_object('mesh', mesh=mesh) + ) + + with mock.patch(mujoco_assets.__name__ + '.open', mock.mock_open()): + xml_root = mock.MagicMock(spec=minidom.Document) + xml_elem = mock.MagicMock(spec=minidom.Element) + xml_root.createElement.return_value = xml_elem + mujoco_assets.export_to_xml(xml_root, [mesh_obj], **_DEFAULT_SETTINGS) + + # Verify that two mesh elements were created... + create_element_call_counts = collections.Counter( + [m_[0][0] for m_ in xml_root.createElement.call_args_list] + ) + self.assertEqual(create_element_call_counts['mesh'], 2) + + # ...and that their names reflect the mesh-material pairing. + xml_elem.setAttribute.assert_any_call('file', 'mesh_mat_1.msh') + xml_elem.setAttribute.assert_any_call('file', 'mesh_mat_2.msh') + xml_elem.setAttribute.assert_any_call('name', 'mesh_mat_1') + xml_elem.setAttribute.assert_any_call('name', 'mesh_mat_2') + + +class MujocoMeshTests(parameterized.TestCase): + + def test_splitting_mesh_with_multiple_materials_into_submeshes(self): + mat_0 = testing.build_material('mat_0') + mat_1 = testing.build_material('mat_1') + mesh = testing.build_mesh( + 'mesh', + materials=[mat_0, mat_1], + faces=[[0, 1, 2], [3, 4, 5]], + vert_co=[ + [0.1, 0.1, 0.1], + [0.2, 0.2, 0.2], + [0.3, 0.3, 0.3], + [0.4, 0.4, 0.4], + [0.5, 0.5, 0.5], + [0.6, 0.6, 0.6], + ], + mat_ids=[0, 1], + ) + + mesh_mat_0 = mujoco_assets.MujocoMesh(mesh, material_idx=0, two_sided=False) + mesh_mat_1 = mujoco_assets.MujocoMesh(mesh, material_idx=1, two_sided=False) + + self.assertEqual( + mesh_mat_0.vertices, [0.1, 0.1, 0.1, 0.2, 0.2, 0.2, 0.3, 0.3, 0.3] + ) + self.assertEqual( + mesh_mat_1.vertices, [0.4, 0.4, 0.4, 0.5, 0.5, 0.5, 0.6, 0.6, 0.6] + ) + self.assertEqual(mesh_mat_0.faces, [0, 1, 2]) + self.assertEqual(mesh_mat_1.faces, [0, 1, 2]) + + def test_one_sided_mesh(self): + mesh = testing.build_mesh( + 'mesh', + materials=[testing.build_material('mat')], + faces=[[0, 1, 2]], + vert_co=[[0.1, 0.1, 0.1], [0.2, 0.2, 0.2], [0.3, 0.3, 0.3]], + normals=[[0.1, 0.1, 0.1], [0.2, 0.2, 0.2], [0.3, 0.3, 0.3]], + mat_ids=[0], + ) + + mesh = mujoco_assets.MujocoMesh(mesh, material_idx=0, two_sided=False) + self.assertEqual(mesh.faces, [0, 1, 2]) + self.assertEqual( + mesh.vertices, [0.1, 0.1, 0.1, 0.2, 0.2, 0.2, 0.3, 0.3, 0.3] + ) + self.assertEqual( + mesh.normals, [0.1, 0.1, 0.1, 0.2, 0.2, 0.2, 0.3, 0.3, 0.3] + ) + + def test_two_sided_mesh(self): + mesh = testing.build_mesh( + 'mesh', + materials=[testing.build_material('mat')], + faces=[[0, 1, 2]], + vert_co=[[0.1, 0.1, 0.1], [0.2, 0.2, 0.2], [0.3, 0.3, 0.3]], + normals=[[0.1, 0.1, 0.1], [0.2, 0.2, 0.2], [0.3, 0.3, 0.3]], + mat_ids=[0], + ) + + mesh = mujoco_assets.MujocoMesh(mesh, material_idx=0, two_sided=True) + self.assertEqual(mesh.faces, [0, 1, 2, 5, 4, 3]) + self.assertEqual( + mesh.vertices, + [.1, .1, .1, .2, .2, .2, .3, .3, .3, + .1, .1, .1, .2, .2, .2, .3, .3, .3,]) + self.assertEqual( + mesh.normals, + [.1, .1, .1, .2, .2, .2, .3, .3, .3, + -.1, -.1, -.1, -.2, -.2, -.2, -.3, -.3, -.3,]) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_control/blender/mujoco_exporter/mujoco_scene.py b/dm_control/blender/mujoco_exporter/mujoco_scene.py new file mode 100644 index 00000000..e633492c --- /dev/null +++ b/dm_control/blender/mujoco_exporter/mujoco_scene.py @@ -0,0 +1,188 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Mujoco scene element builders and utilities.""" + +from typing import Sequence +from xml.dom import minidom + +from dm_control.blender.fake_core import mathutils +from dm_control.blender.mujoco_exporter import blender_scene + +_ARMATURE = 'ARMATURE' +_MESH = 'MESH' +_VEC_ZERO = mathutils.Vector((0, 0, 0)) +_OZ = mathutils.Vector((0, 0, 1)) + + +def color_to_mjcf(color: mathutils.Color) -> str: + return '{} {} {}'.format(color.r, color.g, color.b) + + +def vec_to_mjcf(vec: mathutils.Vector) -> str: + return '{} {} {}'.format(vec.x, vec.y, vec.z) + + +def quat_to_mjcf(quat: mathutils.Quaternion) -> str: + return '{} {} {} {}'.format(quat.w, quat.x, quat.y, quat.z) + + +def bool_to_mjcf(bool_val: bool): + return 'true' if bool_val else 'false' + + +def body_builder( + doc: minidom.Document, blender_obj: blender_scene.ObjectRef +) -> minidom.Element: + """Builds a mujoco body element.""" + transform = blender_obj.get_local_transform() + + el = doc.createElement('body') + el.setAttribute('name', blender_obj.name) + el.setAttribute('pos', vec_to_mjcf(transform.pos)) + el.setAttribute('quat', quat_to_mjcf(transform.rot)) + return el + + +def light_builder( + doc: minidom.Document, light_obj: blender_scene.ObjectRef +) -> minidom.Element: + """Builds an mjcf element that describes a light.""" + assert light_obj.is_light + light = light_obj.as_light() + + directional = bool_to_mjcf(light.type == 'SUN' or light.type == 'SPOT') + attenuation = '0 {} {}'.format( + light.linear_attenuation, light.quadratic_attenuation + ) + transform = light_obj.get_local_transform() + + el = doc.createElement('light') + el.setAttribute('name', light_obj.name) + el.setAttribute('pos', vec_to_mjcf(transform.pos)) + el.setAttribute('dir', vec_to_mjcf(transform.rot @ _OZ)) + el.setAttribute('directional', directional) + el.setAttribute('castshadow', bool_to_mjcf(light.use_shadow)) + el.setAttribute('diffuse', color_to_mjcf(light.color)) + el.setAttribute('attenuation', attenuation) + return el + + +def mesh_geom_builder( + doc: minidom.Document, mesh_obj: blender_scene.ObjectRef +) -> Sequence[minidom.Element]: + """Builds a mujoco node for a mesh geom.""" + mesh = mesh_obj.mesh + transform = mesh_obj.get_local_transform() + + elements = [] + mat_names = blender_scene.map_materials(lambda m: m.name, mesh_obj.materials) + # It might be the case that the mesh doesn't have any materials assigned. + # We still want to export such geom, but we don't want to make a reference to + # the material in the node. + # So in that case we're using a fake material without a name + mat_names = mat_names or [''] + for mat_idx, mat_name in enumerate(mat_names): + if not blender_scene.is_material_mesh_pair_valid(mesh, mat_idx): + continue + + obj_name = blender_scene.get_material_mesh_pair_name( + mesh_obj.name, mat_name + ) + mesh_name = blender_scene.get_material_mesh_pair_name(mesh.name, mat_name) + + el = doc.createElement('geom') + el.setAttribute('name', obj_name) + el.setAttribute('mesh', mesh_name) + el.setAttribute('pos', vec_to_mjcf(transform.pos)) + el.setAttribute('quat', quat_to_mjcf(transform.rot)) + el.setAttribute('type', 'mesh') + if mat_name: + el.setAttribute('material', mat_name) + + elements.append(el) + + return elements + + +def joint_builder( + doc: minidom.Document, + dof: blender_scene.Dof, + dof_type: str, +) -> minidom.Element: + """Builds a mujoco hinge definition.""" + el = doc.createElement('joint') + el.setAttribute('name', dof.name) + el.setAttribute('type', dof_type) + el.setAttribute('limited', bool_to_mjcf(dof.limited)) + el.setAttribute('pos', vec_to_mjcf(_VEC_ZERO)) + el.setAttribute('axis', vec_to_mjcf(dof.axis)) + el.setAttribute('range', '{} {}'.format(dof.limits[0], dof.limits[1])) + return el + + +def export_to_xml( + doc: minidom.Document, + objects: Sequence[blender_scene.ObjectRef], + armature_freejoint: bool, +) -> minidom.Element: + """Converts Blender scene objects to Mujoco scene tree nodes.""" + root = doc.createElement('worldbody') + parent_elements = {blender_scene.NoneRef: root} + + for obj in objects: + # Build a subtree corresponding to this Blender object. + element = None + if obj.is_armature: + element = body_builder(doc, obj) + if armature_freejoint: + element.appendChild(doc.createElement('freejoint')) + elif obj.is_mesh: + if not obj.parent.is_none and not obj.parent.is_bone: + raise RuntimeError( + 'Mesh "{}" is parented to an object "{}", which is not a bone. ' + 'Only mesh->bone parenting is supported at the moment'.format( + obj.name, obj.parent.name + ) + ) + geom_elements = mesh_geom_builder(doc, obj) + if len(geom_elements) > 1: + # If there's more than one geom, introduce a body to aggregate them + # under a single element. That element will then be associated with this + # blender object should other blender elements be parented to it. + element = doc.createElement('body') + for geom_el in geom_elements: + element.appendChild(geom_el) + elif len(geom_elements) == 1: + # Since there's only one geom, consider it the main element + element = geom_elements[0] + elif obj.is_light: + element = light_builder(doc, obj) + elif obj.is_bone: + element = body_builder(doc, obj) + for dof in obj.get_rotation_dofs(): + element.appendChild(joint_builder(doc, dof, 'hinge')) + + # Inject it into the scene tree. + if element: + parent = obj.parent + if parent: + parent_el = parent_elements[parent] + parent_el.appendChild(element) + parent_elements[obj] = element + else: + root.appendChild(element) + + return root diff --git a/dm_control/blender/mujoco_exporter/mujoco_scene_test.py b/dm_control/blender/mujoco_exporter/mujoco_scene_test.py new file mode 100644 index 00000000..e63868c1 --- /dev/null +++ b/dm_control/blender/mujoco_exporter/mujoco_scene_test.py @@ -0,0 +1,195 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Tests for mujoco_scene.py.""" + +import collections +from unittest import mock +from xml.dom import minidom +from absl.testing import absltest +from absl.testing import parameterized +from dm_control.blender.mujoco_exporter import blender_scene +from dm_control.blender.mujoco_exporter import mujoco_scene +from dm_control.blender.mujoco_exporter import testing + +_DEFAULT_PARAMS = dict(armature_freejoint=False) + + +class ConverterTest(parameterized.TestCase): + + def test_convert_armature(self): + armature = testing.build_armature('armature') + xml_root = mock.MagicMock(spec=minidom.Document) + xml_root.createElement = mock.MagicMock(spec=minidom.Element) + + obj = blender_scene.ObjectRef(armature) + created_element = mujoco_scene.export_to_xml( + xml_root, [obj], **_DEFAULT_PARAMS + ) + + xml_root.createElement.assert_any_call('body') + created_element.setAttribute.assert_any_call('name', 'armature') + + def test_convert_bone_without_constraints_creates_a_body(self): + bone = testing.build_bone('bone') + armature = testing.build_armature('armature', bones=[bone]) + + xml_root = mock.MagicMock(spec=minidom.Document) + xml_root.createElement.side_effect = mock.MagicMock(spec=minidom.Element) + + bone_obj = blender_scene.ObjectRef(armature, bone) + armature_obj = blender_scene.ObjectRef(armature) + created_element = mujoco_scene.export_to_xml( + xml_root, [armature_obj, bone_obj], **_DEFAULT_PARAMS + ) + + xml_root.createElement.assert_any_call('body') + created_element.setAttribute.assert_any_call('name', 'bone_armature') + + @parameterized.parameters([ + # Point light + dict( + args=dict(lin_att=1, quad_att=2, shadow=True, light_type='POINT'), + out=dict(castshadow='true', attenuation='0 1 2', directional='false'), + ), + # Spot light + dict( + args=dict(lin_att=3, quad_att=4, shadow=True, light_type='SPOT'), + out=dict(castshadow='true', attenuation='0 3 4', directional='true'), + ), + # Directional light + dict( + args=dict(lin_att=5, quad_att=6, shadow=True, light_type='SUN'), + out=dict(castshadow='true', attenuation='0 5 6', directional='true'), + ), + # Shadows off + dict( + args=dict(lin_att=7, quad_att=8, shadow=False, light_type='SUN'), + out=dict(castshadow='false', attenuation='0 7 8', directional='true'), + ), + ]) + def test_convert_light(self, args, out): + light = testing.build_light('light', **args) + + xml_root = mock.MagicMock(spec=minidom.Document) + xml_root.createElement = mock.MagicMock(spec=minidom.Element) + + obj = blender_scene.ObjectRef(light) + created_element = mujoco_scene.export_to_xml( + xml_root, [obj], **_DEFAULT_PARAMS + ) + + xml_root.createElement.assert_any_call('light') + created_element.setAttribute.assert_any_call('name', 'light') + for k, v in out.items(): + created_element.setAttribute.assert_any_call(k, v) + + def test_exported_mesh_references_mesh_asset_by_name(self): + mesh = testing.build_mesh('mesh_asset_name') + mesh_obj = testing.build_mesh_object('mesh_object_name', mesh=mesh) + + xml_root = mock.MagicMock(spec=minidom.Document) + xml_root.createElement = mock.MagicMock(spec=minidom.Element) + + obj = blender_scene.ObjectRef(mesh_obj) + created_element = mujoco_scene.export_to_xml( + xml_root, [obj], **_DEFAULT_PARAMS + ) + + xml_root.createElement.assert_any_call('geom') + created_element.setAttribute.assert_any_call('name', 'mesh_object_name') + created_element.setAttribute.assert_any_call('mesh', 'mesh_asset_name') + + def test_mesh_with_no_material_keeps_its_name(self): + mesh = testing.build_mesh('mesh') + mesh_obj = testing.build_mesh_object('mesh_obj', mesh=mesh) + obj = blender_scene.ObjectRef(mesh_obj) + + xml_root = mock.MagicMock(spec=minidom.Document) + xml_elem = mock.MagicMock(spec=minidom.Element) + xml_root.createElement.return_value = xml_elem + mujoco_scene.export_to_xml(xml_root, [obj], **_DEFAULT_PARAMS) + + xml_elem.setAttribute.assert_any_call('name', 'mesh_obj') + xml_elem.setAttribute.assert_any_call('mesh', 'mesh') + + def test_mesh_with_materials_builds_multiple_geoms(self): + mesh = testing.build_mesh( + 'mesh', + materials=[ + testing.build_material('mat_1'), + testing.build_material('mat_2'), + ], + faces=[[0, 1, 2], [3, 4, 5]], + vert_co=[ + [0.1, 0.1, 0.1], + [0.2, 0.2, 0.2], + [0.3, 0.3, 0.3], + [0.4, 0.4, 0.4], + [0.5, 0.5, 0.5], + [0.6, 0.6, 0.6], + ], + mat_ids=[0, 1], + ) + mesh_obj = testing.build_mesh_object('mesh_obj', mesh=mesh) + + xml_root = mock.MagicMock(spec=minidom.Document) + xml_elem = mock.MagicMock(spec=minidom.Element) + xml_root.createElement.return_value = xml_elem + + obj = blender_scene.ObjectRef(mesh_obj) + mujoco_scene.export_to_xml(xml_root, [obj], **_DEFAULT_PARAMS) + + xml_elem.setAttribute.assert_any_call('name', 'mesh_obj_mat_1') + xml_elem.setAttribute.assert_any_call('name', 'mesh_obj_mat_2') + xml_elem.setAttribute.assert_any_call('mesh', 'mesh_mat_1') + xml_elem.setAttribute.assert_any_call('mesh', 'mesh_mat_2') + + def test_empty_submeshes_are_not_exported(self): + mesh = testing.build_mesh( + 'mesh', + materials=[ + testing.build_material('mat_1'), + testing.build_material('mat_2'), + ], + faces=[[0, 1, 2], [3, 4, 5]], + vert_co=[ + [0.1, 0.1, 0.1], + [0.2, 0.2, 0.2], + [0.3, 0.3, 0.3], + [0.4, 0.4, 0.4], + [0.5, 0.5, 0.5], + [0.6, 0.6, 0.6], + ], + mat_ids=[0, 0], + ) # All faces reference material 0 + mesh_obj = testing.build_mesh_object('mesh_obj', mesh=mesh) + + xml_root = mock.MagicMock(spec=minidom.Document) + xml_elem = mock.MagicMock(spec=minidom.Element) + xml_root.createElement.return_value = xml_elem + + obj = blender_scene.ObjectRef(mesh_obj) + mujoco_scene.export_to_xml(xml_root, [obj], **_DEFAULT_PARAMS) + + create_element_call_counts = collections.Counter( + [m_[0][0] for m_ in xml_root.createElement.call_args_list] + ) + self.assertEqual(create_element_call_counts['geom'], 1) + xml_elem.setAttribute.assert_any_call('name', 'mesh_obj_mat_1') + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_control/blender/mujoco_exporter/testing.py b/dm_control/blender/mujoco_exporter/testing.py new file mode 100644 index 00000000..7df4014f --- /dev/null +++ b/dm_control/blender/mujoco_exporter/testing.py @@ -0,0 +1,198 @@ +# Copyright 2023 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""Testing utilities.""" +from typing import Any, Optional, Sequence +from unittest import mock + +from dm_control.blender.fake_core import bpy + + +class FakePropCollection: + """Collection that simulates bpy_prop_collection. + + Armature's bone collection is modeled using it, and the tested code depends + on some of its features - namely the conversion to list produces dict + values instead of its keys, as well as the collection behaving like a dict + otherwise. + + @see + blender_scene.py : map_blender_tree - iterating over armature.data.bones + blender_scene.py : ObjectRef.parent.object_parent - dereferencing bone + by its name armature.data.bones[obj.parent_bone] + """ + + def __init__(self, objects_with_name): + if objects_with_name: + self._keys = [i.name for i in objects_with_name] + else: + self._keys = [] + self._values = objects_with_name or [] + + def __len__(self): + return len(self._values) + + def __getitem__(self, idx): + if isinstance(idx, str): + idx = self._keys.index(idx) + return self._values[idx] + + def __iter__(self): + return iter(self._values) + + def __contains__(self, key): + return key in self._keys + + def keys(self): + return self._keys + + def values(self): + return self._values + + def items(self): + return list(zip(self._keys, self._values)) + + +def build_armature( + name: str, + parent: Optional[Any] = None, + parent_bone: Optional[str] = None, + bones: Optional[Any] = None, +): + """TBD.""" + obj = mock.MagicMock(spec=bpy.types.Object, type='ARMATURE') + obj.name = name + obj.parent = parent + obj.parent_bone = parent_bone + obj.data = mock.MagicMock() + obj.data.bones = FakePropCollection(bones) + obj.pose = mock.MagicMock() + obj.pose.bones = FakePropCollection(bones) + return obj + + +def build_bone(name, parent=None, constraint=None): + """Builds a mock bone.""" + bone = mock.MagicMock(spec=bpy.types.Bone) + bone.name = name + bone.parent = parent + bone.is_in_ik_chain = constraint is not None + bone.lock_ik_x = constraint.lock_x if constraint else False + bone.use_ik_limit_x = constraint.use_limit_x if constraint else False + bone.ik_min_x = constraint.min_x if constraint else 0.0 + bone.ik_max_x = constraint.max_x if constraint else 0.0 + bone.lock_ik_y = constraint.lock_y if constraint else False + bone.use_ik_limit_y = constraint.use_limit_y if constraint else False + bone.ik_min_y = constraint.min_y if constraint else 0.0 + bone.ik_max_y = constraint.max_y if constraint else 0.0 + bone.lock_ik_z = constraint.lock_z if constraint else False + bone.use_ik_limit_z = constraint.use_limit_z if constraint else False + bone.ik_min_z = constraint.min_z if constraint else 0.0 + bone.ik_max_z = constraint.max_z if constraint else 0.0 + return bone + + +def build_rotation_constraint( + lock_x=False, use_limit_x=False, min_x=0, max_x=1, + lock_y=False, use_limit_y=False, min_y=0, max_y=1, + lock_z=False, use_limit_z=False, min_z=0, max_z=1): + """Builds a mock rotation constraint.""" + c = mock.MagicMock() + c.lock_x = lock_x + c.use_limit_x = use_limit_x + c.min_x = min_x + c.max_x = max_x + c.lock_y = lock_y + c.use_limit_y = use_limit_y + c.min_y = min_y + c.max_y = max_y + c.lock_z = lock_z + c.use_limit_z = use_limit_z + c.min_z = min_z + c.max_z = max_z + return c + + +def build_mesh( + name, materials=None, faces=None, vert_co=None, normals=None, mat_ids=None +): + """Builds a mock triangle mesh.""" + materials = materials or [] + faces = faces or [[0, 1, 2]] + vert_co = vert_co or [[0.1, 0.1, 0.1], [0.2, 0.2, 0.2], [0.3, 0.3, 0.3]] + normals = normals or vert_co + mat_ids = mat_ids or [0] * len(faces) + + obj = mock.MagicMock(spec=bpy.types.Mesh) + obj.name = name + obj.materials = materials + obj.uv_layers = mock.MagicMock() + obj.loop_triangles = [ + mock.MagicMock(vertices=f, material_index=i) + for f, i in zip(faces, mat_ids) + ] + obj.vertices = [ + mock.MagicMock(co=c, normal=n) for c, n in zip(vert_co, normals) + ] + + return obj + + +def build_mesh_object( + name: str, + mesh: Optional[Any] = None, + parent: Optional[Any] = None, + parent_bone: Optional[str] = None, +): + """Builds a mock object with a mesh assigned to it.""" + obj = mock.MagicMock(spec=bpy.types.Object, type='MESH') + obj.name = name + obj.parent = parent + obj.parent_bone = parent_bone + obj.data = mesh or build_mesh(name) + return obj + + +def build_light(name, light_type, lin_att, quad_att, shadow): + """Builds a mock light.""" + obj = mock.MagicMock(spec=bpy.types.Object, type='LIGHT') + obj.name = name + obj.parent = None + obj.parent_bone = None + obj.data = mock.MagicMock(spec=bpy.types.Light) + obj.data.type = light_type + obj.data.linear_attenuation = lin_att + obj.data.quadratic_attenuation = quad_att + obj.data.use_shadow = shadow + return obj + + +def build_material( + name: str, + color: Sequence[int] = (1, 1, 1, 1), + specular: float = 0.5, + metallic: float = 0.0, + roughness: float = 0.5, + use_backface_culling: bool = False, +): + """Builds a mock material definition.""" + obj = mock.MagicMock(spec=bpy.types.Material) + obj.name = name + obj.diffuse_color = color + obj.specular_intensity = specular + obj.metallic = metallic + obj.roughness = roughness + obj.use_backface_culling = use_backface_culling + return obj diff --git a/dm_control/composer/entity.py b/dm_control/composer/entity.py index 96c25b95..0a78818f 100644 --- a/dm_control/composer/entity.py +++ b/dm_control/composer/entity.py @@ -480,7 +480,8 @@ def set_pose(self, physics, position=None, quaternion=None): if position is not None: physics.bind(root_joint).qpos[:3] = position if quaternion is not None: - physics.bind(root_joint).qpos[3:] = quaternion + normalised_quaternion = quaternion / np.linalg.norm(quaternion) + physics.bind(root_joint).qpos[3:] = normalised_quaternion else: attachment_frame = mjcf.get_attachment_frame(self.mjcf_model) if attachment_frame is None: @@ -521,7 +522,7 @@ def shift_pose(self, if position is not None: new_position = current_position + position if quaternion is not None: - quaternion = np.array(quaternion, dtype=np.float64, copy=False) + quaternion = np.asarray(quaternion, dtype=np.float64) new_quaternion = _multiply_quaternions(quaternion, current_quaternion) root_joint = mjcf.get_frame_freejoint(self.mjcf_model) if root_joint and rotate_velocity: diff --git a/dm_control/composer/environment.py b/dm_control/composer/environment.py index e3440871..18a50dac 100644 --- a/dm_control/composer/environment.py +++ b/dm_control/composer/environment.py @@ -249,8 +249,9 @@ def _recompile_physics_and_update_observables(self): def _recompile_physics(self): """Creates a new Physics using the latest MJCF model from the task.""" - if getattr(self, '_physics', None): - self._physics.free() + physics = getattr(self, '_physics', None) + if physics: + physics.free() self._physics = mjcf.Physics.from_mjcf_model( self._task.root_entity.mjcf_model) self._physics.legacy_step = self._legacy_step @@ -267,9 +268,10 @@ def physics(self): """Returns a `weakref.ProxyType` pointing to the current `mjcf.Physics`. Note that the underlying `mjcf.Physics` will be destroyed whenever the MJCF - model is recompiled. It is therefore unsafe for external objects to hold a - reference to `environment.physics`. Attempting to access attributes of a - dead `Physics` instance will result in a `ReferenceError`. + model is recompiled or environment.close() is called. It is therefore unsafe + for external objects to hold a reference to `environment.physics`. + Attempting to access attributes of a dead `Physics` instance will result in + a `ReferenceError`. """ return self._physics_proxy @@ -462,6 +464,11 @@ def _substep(self, action): self._physics.step() self._hooks.after_substep(self._physics_proxy, self._random_state) + def close(self): + super().close() + self._physics.free() + self._physics = None + def action_spec(self): """Returns the action specification for this environment.""" return self._task.action_spec(self._physics_proxy) diff --git a/dm_control/composer/initializers/prop_initializer.py b/dm_control/composer/initializers/prop_initializer.py index 1abb375b..b809b31a 100644 --- a/dm_control/composer/initializers/prop_initializer.py +++ b/dm_control/composer/initializers/prop_initializer.py @@ -88,7 +88,7 @@ def __init__(self, velocities are less than this threshold. max_attempts_per_prop: The maximum number of rejection sampling attempts per prop. If a non-colliding pose cannot be found before this limit is - reached, a `RuntimeError` will be raised. + reached, an `EpisodeInitializationError` will be raised. settle_physics: (optional) If True, the physics simulation will be advanced for a few steps to allow the prop positions to settle. min_settle_physics_time: (optional) When `settle_physics` is True, lower @@ -170,8 +170,9 @@ def __call__(self, physics, random_state, ignore_contacts_with_entities=None): subsequently). Raises: - RuntimeError: If `ignore_collisions == False` and a non-colliding prop - pose could not be found within `max_attempts_per_prop`. + EpisodeInitializationError: If `ignore_collisions == False` and a + non-colliding prop pose could not be found within + `max_attempts_per_prop`. """ if ignore_contacts_with_entities is None: ignore_contacts_with_entities = [] @@ -222,9 +223,12 @@ def place_props(): break if not success: - raise RuntimeError(_REJECTION_SAMPLING_FAILED.format( - model_name=prop.mjcf_model.model, - max_attempts=self._max_attempts_per_prop)) + raise composer.EpisodeInitializationError( + _REJECTION_SAMPLING_FAILED.format( + model_name=prop.mjcf_model.model, + max_attempts=self._max_attempts_per_prop, + ) + ) for prop in ignore_contacts_with_entities: self._restore_contact_parameters(physics, prop, cached_contact_params) @@ -237,22 +241,26 @@ def place_and_settle(): # Step physics and check prop states. original_time = physics.data.time - props_isolator = utils.JointStaticIsolator(physics, self._prop_joints) - prop_joints_mj = physics.bind(self._prop_joints) - while physics.data.time - original_time < self._max_settle_physics_time: - with props_isolator: - physics.step() - max_qvel = np.max(np.abs(prop_joints_mj.qvel)) - max_qacc = np.max(np.abs(prop_joints_mj.qacc)) - if (max_qvel < self._max_qvel_tol) and ( - max_qacc < self._max_qacc_tol) and ( - physics.data.time - original_time - ) > self._min_settle_physics_time: - return True - physics.data.time = original_time + try: + props_isolator = utils.JointStaticIsolator(physics, self._prop_joints) + prop_joints_mj = physics.bind(self._prop_joints) + while ( + physics.data.time - original_time < self._max_settle_physics_time + ): + with props_isolator: + physics.step() + max_qvel = np.max(np.abs(prop_joints_mj.qvel)) + max_qacc = np.max(np.abs(prop_joints_mj.qacc)) + if (max_qvel < self._max_qvel_tol) and ( + max_qacc < self._max_qacc_tol) and ( + physics.data.time - original_time + ) > self._min_settle_physics_time: + return True + finally: + physics.data.time = original_time if self._raise_exception_on_settle_failure: - raise RuntimeError( + raise composer.EpisodeInitializationError( _SETTLING_PHYSICS_FAILED.format( max_attempts=self._max_settle_physics_attempts, max_time=self._max_settle_physics_time, diff --git a/dm_control/composer/initializers/prop_initializer_test.py b/dm_control/composer/initializers/prop_initializer_test.py index ac4acc09..dc27d5ca 100644 --- a/dm_control/composer/initializers/prop_initializer_test.py +++ b/dm_control/composer/initializers/prop_initializer_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for prop_initializer.""" - from absl.testing import absltest from absl.testing import parameterized from dm_control import composer @@ -111,7 +109,9 @@ def test_rejection_sampling_failure(self): expected_message = prop_initializer._REJECTION_SAMPLING_FAILED.format( model_name=spheres[1].mjcf_model.model, # Props are placed in order. max_attempts=max_attempts_per_prop) - with self.assertRaisesWithLiteralMatch(RuntimeError, expected_message): + with self.assertRaisesWithLiteralMatch( + composer.EpisodeInitializationError, expected_message + ): prop_placer(physics, random_state=np.random.RandomState(0)) def test_ignore_contacts_with_entities(self): @@ -143,7 +143,9 @@ def test_ignore_contacts_with_entities(self): prop_placer_init(physics, random_state=np.random.RandomState(0)) expected_message = prop_initializer._REJECTION_SAMPLING_FAILED.format( model_name=spheres[0].mjcf_model.model, max_attempts=1) - with self.assertRaisesWithLiteralMatch(RuntimeError, expected_message): + with self.assertRaisesWithLiteralMatch( + composer.EpisodeInitializationError, expected_message + ): prop_placer_seq[0](physics, random_state=np.random.RandomState(0)) # Placing the first sphere should succeed if we ignore contacts involving @@ -160,6 +162,8 @@ def test_ignore_contacts_with_entities(self): def test_settle_physics(self, settle_physics): radius = 0.1 physics, spheres = _make_spheres(num_spheres=2, radius=radius, nconmax=1) + physics_start_time = 1337.0 + physics.data.time = physics_start_time # Only place the first sphere. prop_placer = prop_initializer.PropPlacer( @@ -180,7 +184,10 @@ def test_settle_physics(self, settle_physics): del second_quaternion # Unused. # The sphere that we were not placing should not have moved. - self.assertEqual(second_position[2], 0.) + self.assertEqual(second_position[2], 0.0) + self.assertEqual( + physics.data.time, physics_start_time, 'Physics time should be reset.' + ) @parameterized.parameters([0, 1, 2, 3]) def test_settle_physics_multiple_attempts(self, max_settle_physics_attempts): diff --git a/dm_control/composer/initializers/tcp_initializer.py b/dm_control/composer/initializers/tcp_initializer.py index 011ea9aa..0b8e5787 100644 --- a/dm_control/composer/initializers/tcp_initializer.py +++ b/dm_control/composer/initializers/tcp_initializer.py @@ -131,8 +131,8 @@ def __call__(self, physics, random_state): random_state: An `np.random.RandomState` instance. Raises: - RuntimeError: If a collision-free pose could not be found within - `max_ik_attempts`. + composer.EpisodeInitializationError: If a collision-free pose could not be + found within `max_ik_attempts`. """ if self._hand is not None: target_site = self._hand.tool_center_point @@ -162,6 +162,9 @@ def __call__(self, physics, random_state): # positions and try again with a new target. physics.bind(self._arm.joints).qpos = initial_qpos - raise RuntimeError(_REJECTION_SAMPLING_FAILED.format( - max_rejection_samples=self._max_rejection_samples, - max_ik_attempts=self._max_ik_attempts)) + raise composer.EpisodeInitializationError( + _REJECTION_SAMPLING_FAILED.format( + max_rejection_samples=self._max_rejection_samples, + max_ik_attempts=self._max_ik_attempts, + ) + ) diff --git a/dm_control/composer/initializers/tcp_initializer_test.py b/dm_control/composer/initializers/tcp_initializer_test.py index ba841b7e..002ac8f6 100644 --- a/dm_control/composer/initializers/tcp_initializer_test.py +++ b/dm_control/composer/initializers/tcp_initializer_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for tcp_initializer.""" - import functools from absl.testing import absltest @@ -55,10 +53,10 @@ def assertEntitiesInContact(self, physics, first, second): second_geom_ids = physics.bind( second.mjcf_model.find_all('geom')).element_id contact = physics.data.contact - first_to_second = (np.in1d(contact.geom1, first_geom_ids) & - np.in1d(contact.geom2, second_geom_ids)) - second_to_first = (np.in1d(contact.geom1, second_geom_ids) & - np.in1d(contact.geom2, first_geom_ids)) + first_to_second = (np.isin(contact.geom1, first_geom_ids).ravel() & + np.isin(contact.geom2, second_geom_ids).ravel()) + second_to_first = (np.isin(contact.geom1, second_geom_ids).ravel() & + np.isin(contact.geom2, first_geom_ids).ravel()) touching = contact.dist <= 0 valid_contact = touching & (first_to_second | second_to_first) self.assertTrue(np.any(valid_contact), msg='Entities are not in contact.') @@ -105,7 +103,7 @@ def test_exception_if_hand_colliding_with_fixed_body(self): initializer = make_initializer() with self.assertRaisesWithLiteralMatch( - RuntimeError, + composer.EpisodeInitializationError, tcp_initializer._REJECTION_SAMPLING_FAILED.format( max_rejection_samples=max_rejection_samples, max_ik_attempts=max_ik_attempts)): @@ -145,7 +143,7 @@ def test_exception_if_self_collision(self, with_hand): initializer = make_initializer() with self.assertRaisesWithLiteralMatch( - RuntimeError, + composer.EpisodeInitializationError, tcp_initializer._REJECTION_SAMPLING_FAILED.format( max_rejection_samples=max_rejection_samples, max_ik_attempts=max_ik_attempts)): diff --git a/dm_control/composer/variation/base.py b/dm_control/composer/variation/base.py index 3ff1d6f4..ea3d1fa8 100644 --- a/dm_control/composer/variation/base.py +++ b/dm_control/composer/variation/base.py @@ -91,9 +91,21 @@ def __init__(self, op, variation): self._op = op self._variation = variation + def __eq__(self, other): + if not isinstance(other, _UnaryOperation): + return False + return self._op == other._op and self._variation == other._variation + + def __str__(self): + return f"{self._op.__name__}({self._variation})" + + def __repr__(self): + return f"UnaryOperation({self._op.__name__}({self._variation}))" + def __call__(self, initial_value=None, current_value=None, random_state=None): value = variation_values.evaluate( - self._variation, initial_value, current_value, random_state) + self._variation, initial_value, current_value, random_state + ) return self._op(value) @@ -105,21 +117,56 @@ def __init__(self, op, first, second): self._second = second self._op = op + def __eq__(self, other): + if not isinstance(other, _BinaryOperation): + return False + return ( + self._op == other._op + and self._first == other._first + and self._second == other._second + ) + + def __str__(self): + return f"{self._op.__name__}({self._first}, {self._second})" + + def __repr__(self): + return ( + f"BinaryOperation({self._op.__name__}({self._first!r}," + f" {self._second!r}))" + ) + def __call__(self, initial_value=None, current_value=None, random_state=None): first_value = variation_values.evaluate( - self._first, initial_value, current_value, random_state) + self._first, initial_value, current_value, random_state + ) second_value = variation_values.evaluate( - self._second, initial_value, current_value, random_state) + self._second, initial_value, current_value, random_state + ) return self._op(first_value, second_value) class _GetItemOperation(Variation): + """Returns a single element from the output of a Variation.""" def __init__(self, variation, index): self._variation = variation self._index = index + def __eq__(self, other): + if not isinstance(other, _GetItemOperation): + return False + return self._variation == other._variation and self._index == other._index + + def __str__(self): + return f"{self._variation}[{self._index}]" + + def __repr__(self): + return ( + f"GetItemOperation({self._variation!r}[{self._index}])" + ) + def __call__(self, initial_value=None, current_value=None, random_state=None): value = variation_values.evaluate( - self._variation, initial_value, current_value, random_state) + self._variation, initial_value, current_value, random_state + ) return np.asarray(value)[self._index] diff --git a/dm_control/composer/variation/colors.py b/dm_control/composer/variation/colors.py index 305e66cb..2c6bbe80 100644 --- a/dm_control/composer/variation/colors.py +++ b/dm_control/composer/variation/colors.py @@ -43,6 +43,16 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): variation_values.evaluate([self._r, self._g, self._b, self._alpha], initial_value, current_value, random_state)) + def __eq__(self, other): + if not isinstance(other, RgbVariation): + return False + return ( + self._r == other._r + and self._g == other._g + and self._b == other._b + and self._alpha == other._alpha + ) + class HsvVariation(base.Variation): """Represents a variation in the HSV color space. @@ -62,6 +72,22 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): random_state) return np.asarray(list(colorsys.hsv_to_rgb(h, s, v)) + [alpha]) + def __eq__(self, other): + if not isinstance(other, HsvVariation): + return False + return ( + self._h == other._h + and self._s == other._s + and self._v == other._v + and self._alpha == other._alpha + ) + + def __repr__(self): + return ( + f"HsvVariation(h={self._h}, s={self._s}, v={self._v}, " + f"alpha={self._alpha})" + ) + class GrayVariation(HsvVariation): """Represents a variation in gray level. @@ -73,3 +99,8 @@ class GrayVariation(HsvVariation): def __init__(self, gray_level, alpha=1.0): super().__init__(h=0.0, s=0.0, v=gray_level, alpha=alpha) + + def __repr__(self): + return ( + f"GrayVariation(gray_level={self._v}, alpha={self._alpha})" + ) diff --git a/dm_control/composer/variation/deterministic.py b/dm_control/composer/variation/deterministic.py index f7a7e8dc..a6ad4cba 100644 --- a/dm_control/composer/variation/deterministic.py +++ b/dm_control/composer/variation/deterministic.py @@ -33,6 +33,17 @@ def __init__(self, value): def __call__(self, initial_value=None, current_value=None, random_state=None): return self._value + def __eq__(self, other): + if not isinstance(other, Constant): + return False + return self._value == other._value + + def __str__(self): + return f"{self._value}" + + def __repr__(self): + return f"Constant({self._value!r})" + class Sequence(base.Variation): """Variation representing a fixed sequence of values.""" @@ -52,6 +63,8 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): class Identity(base.Variation): - def __call__(self, initial_value=None, current_value=None, random_state=None): return current_value + + def __eq__(self, other): + return isinstance(other, Identity) diff --git a/dm_control/composer/variation/distributions.py b/dm_control/composer/variation/distributions.py index db0a2ab3..f47e88fc 100644 --- a/dm_control/composer/variation/distributions.py +++ b/dm_control/composer/variation/distributions.py @@ -71,6 +71,22 @@ def __getattr__(self, name): def _callable(self, random_state): raise NotImplementedError + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + return ( + self._args == other._args + and self._kwargs == other._kwargs + and self._single_sample == other._single_sample + ) + + def __repr__(self): + return '{}(args={}, kwargs={}, single_sample={})'.format( + type(self).__name__, + self._args, + self._kwargs, + self._single_sample) + class Uniform(Distribution): __slots__ = () @@ -118,6 +134,17 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): axis /= np.linalg.norm(axis, axis=-1, keepdims=True) return axis + def __eq__(self, other): + if not isinstance(other, UniformPointOnSphere): + return False + return self._single_sample == other._single_sample + + def __repr__(self): + return '{}(single_sample={})'.format( + type(self).__name__, + self._single_sample, + ) + class Normal(Distribution): __slots__ = () @@ -215,3 +242,17 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): self._retain * self._value + random_state.normal(loc=0.0, scale=self._scale)) return self._value + + def __eq__(self, other): + # __eq__ shouldn't be used for this one, because it's stateful. + return id(self) == id(other) + + def __repr__(self): + # include id(self), to make sure that two instances with the same parameters + # don't appear equal in logs. + return '{}(id={}, scale={}, retain={}, value={})'.format( + type(self).__name__, + id(self), + self._scale, + self._retain, + self._value) diff --git a/dm_control/composer/variation/distributions_test.py b/dm_control/composer/variation/distributions_test.py index f0725233..459e20f6 100644 --- a/dm_control/composer/variation/distributions_test.py +++ b/dm_control/composer/variation/distributions_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for distributions.""" - from absl.testing import absltest from absl.testing import parameterized from dm_control.composer.variation import distributions @@ -43,6 +41,10 @@ def testUniform(self): variation(random_state=self._variation_random_state), self._np_random_state.uniform(lower, upper)) + self.assertEqual(variation, distributions.Uniform(low=lower, high=upper)) + self.assertNotEqual(variation, distributions.Uniform(low=upper, high=upper)) + self.assertIn('[2, 3, 4]', repr(variation)) + def testUniformChoice(self): choices = ['apple', 'banana', 'cherry'] variation = distributions.UniformChoice(choices) @@ -51,6 +53,8 @@ def testUniformChoice(self): variation(random_state=self._variation_random_state), self._np_random_state.choice(choices)) + self.assertIn('banana', repr(variation)) + def testUniformPointOnSphere(self): variation = distributions.UniformPointOnSphere() samples = [] @@ -60,8 +64,11 @@ def testUniformPointOnSphere(self): np.testing.assert_approx_equal(np.linalg.norm(sample), 1.0) samples.append(sample) # Make sure that none of the samples are the same. - self.assertLen( - set(np.reshape(np.asarray(samples), -1)), 3 * NUM_ITERATIONS) + self.assertLen(set(np.reshape(np.asarray(samples), -1)), 3 * NUM_ITERATIONS) + self.assertEqual(variation, distributions.UniformPointOnSphere()) + self.assertNotEqual( + variation, distributions.UniformPointOnSphere(single_sample=True) + ) def testNormal(self): loc, scale = 1, 2 @@ -70,6 +77,14 @@ def testNormal(self): self.assertEqual( variation(random_state=self._variation_random_state), self._np_random_state.normal(loc, scale)) + self.assertEqual(variation, distributions.Normal(loc=loc, scale=scale)) + self.assertNotEqual( + variation, distributions.Normal(loc=loc*2, scale=scale) + ) + self.assertEqual( + "Normal(args=(), kwargs={'loc': 1, 'scale': 2}, single_sample=False)", + repr(variation), + ) def testExponential(self): scale = 3 @@ -78,6 +93,14 @@ def testExponential(self): self.assertEqual( variation(random_state=self._variation_random_state), self._np_random_state.exponential(scale)) + self.assertEqual(variation, distributions.Exponential(scale=scale)) + self.assertNotEqual( + variation, distributions.Exponential(scale=scale*2) + ) + self.assertEqual( + "Exponential(args=(), kwargs={'scale': 3}, single_sample=False)", + repr(variation), + ) def testPoisson(self): lam = 4 @@ -86,6 +109,14 @@ def testPoisson(self): self.assertEqual( variation(random_state=self._variation_random_state), self._np_random_state.poisson(lam)) + self.assertEqual(variation, distributions.Poisson(lam=lam)) + self.assertNotEqual( + variation, distributions.Poisson(lam=lam*2) + ) + self.assertEqual( + "Poisson(args=(), kwargs={'lam': 4}, single_sample=False)", + repr(variation), + ) @parameterized.parameters(0, 10) def testBiasedRandomWalk(self, timescale): diff --git a/dm_control/composer/variation/math.py b/dm_control/composer/variation/math.py index acca4468..2b79ffeb 100644 --- a/dm_control/composer/variation/math.py +++ b/dm_control/composer/variation/math.py @@ -26,10 +26,10 @@ class MathOp(base.Variation): """Base MathOp class for applying math operations on variation objects. - Subclasses need to implement `_op`, which takes in a single value and applies - the desired math operation. This operation gets applied to the result of the - evaluated base variation object passed at construction. Structured variation - objects are automatically traversed. + Subclasses need to implement `_callable`, which takes in a single value and + applies the desired math operation. This operation gets applied to the result + of the evaluated base variation object passed at construction. Structured + variation objects are automatically traversed. """ def __init__(self, *args, **kwargs): @@ -54,6 +54,21 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): def _callable(self): pass + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + return ( + self._args == other._args + and self._kwargs == other._kwargs + ) + + def __repr__(self): + return '{}(args={}, kwargs={})'.format( + type(self).__name__, + self._args, + self._kwargs, + ) + class Log(MathOp): diff --git a/dm_control/composer/variation/noises.py b/dm_control/composer/variation/noises.py index cb944550..d84f187c 100644 --- a/dm_control/composer/variation/noises.py +++ b/dm_control/composer/variation/noises.py @@ -39,6 +39,19 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): variation_values.evaluate(self._variation, initial_value, current_value, random_state)) + def __eq__(self, other): + if not isinstance(other, Additive): + return False + return ( + self._variation == other._variation + and self._cumulative == other._cumulative + ) + + def __repr__(self): + return ( + f"Additive(variation={self._variation}, cumulative={self._cumulative})" + ) + class Multiplicative(base.Variation): """A variation that multiplies to an existing value. @@ -58,3 +71,17 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): return base_value * ( variation_values.evaluate(self._variation, initial_value, current_value, random_state)) + + def __eq__(self, other): + if not isinstance(other, Multiplicative): + return False + return ( + self._variation == other._variation + and self._cumulative == other._cumulative + ) + + def __repr__(self): + return ( + f"Multiplicative(variation={self._variation}, " + f"cumulative={self._cumulative})" + ) diff --git a/dm_control/composer/variation/rotations.py b/dm_control/composer/variation/rotations.py index 06a52c96..d242eb54 100644 --- a/dm_control/composer/variation/rotations.py +++ b/dm_control/composer/variation/rotations.py @@ -18,6 +18,7 @@ from dm_control.composer.variation import base from dm_control.composer.variation import variation_values +from dm_control.utils import transformations import numpy as np IDENTITY_QUATERNION = np.array([1., 0., 0., 0.]) @@ -34,6 +35,12 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): np.sqrt(u1) * np.sin(u3), np.sqrt(u1) * np.cos(u3)]) + def __eq__(self, other): + return isinstance(other, UniformQuaternion) + + def __repr__(self): + return "UniformQuaternion()" + class QuaternionFromAxisAngle(base.Variation): """Quaternion variation specified in terms of variations in axis and angle.""" @@ -48,8 +55,20 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): self._axis, initial_value, current_value, random_state) angle = variation_values.evaluate( self._angle, initial_value, current_value, random_state) - sine, cosine = np.sin(angle / 2), np.cos(angle / 2) - return np.array([cosine, axis[0] * sine, axis[1] * sine, axis[2] * sine]) + return transformations.axisangle_to_quat(np.asarray(axis) * angle) + + def __eq__(self, other): + if not isinstance(other, QuaternionFromAxisAngle): + return False + return ( + self._axis == other._axis + and self._angle == other._angle + ) + + def __repr__(self): + return ( + f"QuaternionFromAxisAngle(axis={self._axis}, angle={self._angle})" + ) class QuaternionPreMultiply(base.Variation): @@ -70,8 +89,58 @@ def __call__(self, initial_value=None, current_value=None, random_state=None): q1 = variation_values.evaluate(self._quat, initial_value, current_value, random_state) q2 = current_value if self._cumulative else initial_value - return np.array([ - q1[0]*q2[0] - q1[1]*q2[1] - q1[2]*q2[2] - q1[3]*q2[3], - q1[0]*q2[1] + q1[1]*q2[0] + q1[2]*q2[3] - q1[3]*q2[2], - q1[0]*q2[2] - q1[1]*q2[3] + q1[2]*q2[0] + q1[3]*q2[1], - q1[0]*q2[3] + q1[1]*q2[2] - q1[2]*q2[1] + q1[3]*q2[0]]) + return transformations.quat_mul(np.asarray(q1), np.asarray(q2)) + + def __eq__(self, other): + if not isinstance(other, QuaternionPreMultiply): + return False + return self._quat == other._quat and self._cumulative == other._cumulative + + def __repr__(self): + return ( + f"QuaternionPreMultiply(quat={self._quat}," + f" cumulative={self._cumulative})" + ) + + +class QuaternionRotate(base.Variation): + """Variation that rotates a given vector by the given quaternion. + + The vector can either be an existing value passed at evaluation, or specified + as a separate variation at construction. In the former case, cumulative mode + determines whether to use the current or initial value of the vector. The# + quaternion is always specified by a variation at construction. + """ + + def __init__(self, quat, vec=None, cumulative=False): + self._quat = quat + self._vec = vec + self._cumulative = cumulative + + def __call__(self, initial_value=None, current_value=None, random_state=None): + random_state = random_state or np.random + quat = variation_values.evaluate( + self._quat, initial_value, current_value, random_state + ) + if self._vec is None: + vec = current_value if self._cumulative else initial_value + else: + vec = variation_values.evaluate( + self._vec, initial_value, current_value, random_state + ) + return transformations.quat_rotate(np.asarray(quat), np.asarray(vec)) + + def __eq__(self, other): + if not isinstance(other, QuaternionRotate): + return False + return ( + self._quat == other._quat + and self._vec == other._vec + and self._cumulative == other._cumulative + ) + + def __repr__(self): + return ( + f"QuaternionRotate(quat={self._quat}, vec={self._vec}," + f" cumulative={self._cumulative})" + ) diff --git a/dm_control/composer/variation/variation_broadcaster.py b/dm_control/composer/variation/variation_broadcaster.py new file mode 100644 index 00000000..3f5a1817 --- /dev/null +++ b/dm_control/composer/variation/variation_broadcaster.py @@ -0,0 +1,65 @@ +# Copyright 2024 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +"""A broadcaster that allows sharing of variation values across many callers.""" + +import collections +import weakref + +from dm_control.composer import variation + + +class VariationBroadcaster: + """Allows a variation to be broadcasted to multiple callers. + + This class wraps a `Variation` object and generates multiple proxies that + can be used in place of the wrapped `Variation`. The broadcaster updates its + value in rounds. At the beginning of each round, the broadcaster re-evaluates + the wrapped `Variation` and caches the new value internally. When a proxy + is called, the broadcaster will return this cached value, thus ensuring that + all proxied values are the same. The round ends when all of the proxies have + been called exactly once. It is an error to call any particular proxy more + than once per round. + """ + + def __init__(self, wrapped_variation: variation.Variation): + self._wrapped_variation = wrapped_variation + self._cached_values = weakref.WeakKeyDictionary() + + def get_proxy(self) -> variation.Variation: + """Returns a `Variation` to be used in place of the wrapped `Variation`.""" + new_proxy = _BroadcastedValueProxy(self) + self._cached_values[new_proxy] = collections.deque() + return new_proxy + + def _get_value(self, proxy, random_state): + """Returns the variation value for a proxy owned by this broadcaster.""" + cached_values = self._cached_values[proxy] + if not cached_values: + new_value = variation.evaluate( + self._wrapped_variation, None, None, random_state) + for values in self._cached_values.values(): + values.append(new_value) + return cached_values.popleft() + + +class _BroadcastedValueProxy(variation.Variation): + + def __init__(self, broadcaster): + self._broadcaster = broadcaster + + def __call__(self, initial_value=None, current_value=None, random_state=None): + value = self._broadcaster._get_value(self, random_state) # pylint: disable=protected-access + return value diff --git a/dm_control/composer/variation/variation_broadcaster_test.py b/dm_control/composer/variation/variation_broadcaster_test.py new file mode 100644 index 00000000..0f1faa1e --- /dev/null +++ b/dm_control/composer/variation/variation_broadcaster_test.py @@ -0,0 +1,104 @@ +# Copyright 2024 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ + +from absl.testing import absltest +from dm_control.composer import variation +from dm_control.composer.variation import distributions +from dm_control.composer.variation import variation_broadcaster +import numpy as np + + +class VariationBroadcasterTest(absltest.TestCase): + + def test_can_generate_values(self): + random_state = np.random.RandomState(2348) + expected_values = [random_state.uniform(0, 1) for _ in range(5)] + + random_state = np.random.RandomState(2348) + broadcaster = variation_broadcaster.VariationBroadcaster( + distributions.Uniform(0, 1) + ) + proxy_1 = broadcaster.get_proxy() + proxy_2 = broadcaster.get_proxy() + proxy_3 = broadcaster.get_proxy() + + self.assertEqual( + variation.evaluate(proxy_1, random_state=random_state), + expected_values[0], + ) + self.assertEqual( + variation.evaluate(proxy_2, random_state=random_state), + expected_values[0], + ) + self.assertEqual( + variation.evaluate(proxy_3, random_state=random_state), + expected_values[0], + ) + + self.assertEqual( + variation.evaluate(proxy_1, random_state=random_state), + expected_values[1], + ) + self.assertEqual( + variation.evaluate(proxy_1, random_state=random_state), + expected_values[2], + ) + + self.assertEqual( + variation.evaluate(proxy_2, random_state=random_state), + expected_values[1], + ) + self.assertEqual( + variation.evaluate(proxy_3, random_state=random_state), + expected_values[1], + ) + self.assertEqual( + variation.evaluate(proxy_3, random_state=random_state), + expected_values[2], + ) + + self.assertEqual( + variation.evaluate(proxy_3, random_state=random_state), + expected_values[3], + ) + self.assertEqual( + variation.evaluate(proxy_1, random_state=random_state), + expected_values[3], + ) + self.assertEqual( + variation.evaluate(proxy_2, random_state=random_state), + expected_values[2], + ) + + self.assertEqual( + variation.evaluate(proxy_1, random_state=random_state), + expected_values[4], + ) + self.assertEqual( + variation.evaluate(proxy_2, random_state=random_state), + expected_values[3], + ) + self.assertEqual( + variation.evaluate(proxy_2, random_state=random_state), + expected_values[4], + ) + self.assertEqual( + variation.evaluate(proxy_3, random_state=random_state), + expected_values[4], + ) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_control/composer/variation/variation_test.py b/dm_control/composer/variation/variation_test.py index 7fed9a88..1c611a9f 100644 --- a/dm_control/composer/variation/variation_test.py +++ b/dm_control/composer/variation/variation_test.py @@ -51,12 +51,46 @@ def test_binary_operator(self, name): self.assertEqual( variation.evaluate(func(self.variation_1, self.variation_2)), func(self.value_1, self.value_2)) + self.assertEqual( + func(self.variation_1, self.variation_2), + func(self.variation_1, self.variation_2), + ) + self.assertNotEqual( + func(self.variation_1, self.variation_2), + func(self.variation_1, self.variation_1), + ) + + def test_binary_operator_str(self): + self.assertEqual( + 'add(3, 5)', + str( + self.variation_1 + self.variation_2 + ), + ) + self.assertEqual( + 'BinaryOperation(add(Constant(3), Constant(5)))', + repr( + self.variation_1 + self.variation_2 + ), + ) def test_getitem(self): value = deterministic.Constant(np.array([4, 5, 6, 7, 8])) np.testing.assert_array_equal( variation.evaluate(value[[3, 1]]), [7, 5]) + self.assertEqual( + '[4 5 6 7 8][3]', + str( + value[3] + ), + ) + self.assertEqual( + 'GetItemOperation(Constant(array([4, 5, 6, 7, 8]))[3])', + repr( + value[3] + ), + ) if __name__ == '__main__': absltest.main() diff --git a/dm_control/composer/variation/variation_values.py b/dm_control/composer/variation/variation_values.py index eaa68269..97adb89d 100644 --- a/dm_control/composer/variation/variation_values.py +++ b/dm_control/composer/variation/variation_values.py @@ -25,7 +25,7 @@ def evaluate(structure, *args, **kwargs): structure: An arbitrarily nested structure of callables or constant values. By "structures", we mean lists, tuples, namedtuples, or dicts. *args: Positional arguments passed to each callable in `structure`. - **kwargs: Keyword arguments passed to each callable in `structure. + **kwargs: Keyword arguments passed to each callable in `structure`. Returns: The same nested structure, with each callable replaced by the value returned diff --git a/dm_control/entities/props/duplo/duplo2x4.xml b/dm_control/entities/props/duplo/duplo2x4.xml index afca4a4d..77f5811e 100644 --- a/dm_control/entities/props/duplo/duplo2x4.xml +++ b/dm_control/entities/props/duplo/duplo2x4.xml @@ -1,4 +1,7 @@ + diff --git a/dm_control/locomotion/mocap/trajectory.py b/dm_control/locomotion/mocap/trajectory.py index 6d070acd..c09739e1 100644 --- a/dm_control/locomotion/mocap/trajectory.py +++ b/dm_control/locomotion/mocap/trajectory.py @@ -118,7 +118,7 @@ def prop_field(timestep, i=i, field_name=field_name): self._create_all_items(self._dict) for k in self._dict: # make trajectory immutable by default - self._dict[k].flags.writeable = False # pytype: disable=attribute-error + self._dict[k].flags.writeable = False return {k: v[self._start_step:self._end_step] for k, v in self._dict.items()} @@ -130,8 +130,8 @@ def _create_single_item(self, get_field_in_timestep): values = get_field_in_timestep(timestep) if i == 0: array = np.empty((len(self._proto.timesteps),) + values.shape) - array[i, :] = values - return array + array[i, :] = values # pyrefly: ignore[unbound-name] + return array # pyrefly: ignore[unbound-name] def _create_all_items(self, dictionary): for key, value in dictionary.items(): diff --git a/dm_control/locomotion/mocap/walkers.py b/dm_control/locomotion/mocap/walkers.py index a5fdbab9..afa29bd2 100644 --- a/dm_control/locomotion/mocap/walkers.py +++ b/dm_control/locomotion/mocap/walkers.py @@ -92,6 +92,6 @@ def add_marker_sites(self, walker, size=0.01, rgba=(0., 0., 1., .3), pos = np.zeros(3) sites.append( body.add( - 'site', name=str(marker_info.name), pos=pos, dclass=mocap_class)) + 'site', name=str(marker_info.name), pos=pos, dclass=mocap_class)) # pyrefly: ignore[unbound-name] walker.list_of_site_names = [site.name for site in sites] return sites diff --git a/dm_control/locomotion/props/target_sphere.py b/dm_control/locomotion/props/target_sphere.py index 85ace41a..c42715ca 100644 --- a/dm_control/locomotion/props/target_sphere.py +++ b/dm_control/locomotion/props/target_sphere.py @@ -58,7 +58,7 @@ def _build(self, self._material = self._mjcf_root.asset.add( 'material', name='target_sphere', texture=self._texture) self._geom = self._mjcf_root.worldbody.add( - 'geom', type='sphere', name='geom', gap=2*radius, + 'geom', type='sphere', name='geom', margin=-2*radius, gap=2*radius, pos=[0, 0, height_above_ground], size=[radius], material=self._material) self._geom_id = -1 self._activated = False @@ -157,7 +157,7 @@ def _build(self, self._material = self._mjcf_root.asset.add( 'material', name='target_sphere_init', texture=self._texture_initial) self._geom = self._mjcf_root.worldbody.add( - 'geom', type='sphere', name='geom', gap=2*radius, + 'geom', type='sphere', name='geom', margin=-2*radius, gap=2*radius, pos=[0, 0, height_above_ground], size=[radius], material=self._material) self._geom_id = -1 diff --git a/dm_control/locomotion/soccer/README.md b/dm_control/locomotion/soccer/README.md index 3c10841f..e4e22ab4 100644 --- a/dm_control/locomotion/soccer/README.md +++ b/dm_control/locomotion/soccer/README.md @@ -3,14 +3,13 @@ This submodule contains the components and environment used in the following works. -* [Emergent Coordination through Competition][boxhead]. -* [From Motor Control to Team Play in Simulated Humanoid Football][humanoid]. +* [Emergent Coordination through Competition][boxhead] + ([dynamic team play](https://www.youtube.com/watch?v=8nU35D8vAlo), + [defensive team play](https://www.youtube.com/watch?v=-gFQqB8L_mI)). -# ![soccer](soccer.png) +* [From Motor Control to Team Play in Simulated Humanoid Football][humanoid]. -## Installation and requirements - -See [dm_control](../../../README.md#installation-and-requirements) for instructions. +![soccer](soccer.png) ## Quickstart @@ -50,9 +49,9 @@ while not timestep.last(): ## Rewards -The environment provides a reward of +1 to each player when their team -scores a goal, -1 when their team concedes a goal, or 0 if neither team scored -on the current timestep. +The environment provides a reward of +1 to each player when their team scores a +goal, -1 when their team concedes a goal, or 0 if neither team scored on the +current timestep. In addition to the sparse reward returned the environment, the player observations also contain various environment statistics that may be used to @@ -75,5 +74,5 @@ after `time_limit` with a discount factor of `1.0`. To visualize an example environment instance using the `dm_control` interactive viewer, execute `dm_control/locomotion/soccer/explore.py`. -[boxhead]: https://sites.google.com/corp/view/emergent-coordination/home +[boxhead]: http://arxiv.org/abs/1902.07151 [humanoid]: https://arxiv.org/abs/2105.12196 diff --git a/dm_control/locomotion/soccer/camera.py b/dm_control/locomotion/soccer/camera.py index 6a3f1f85..4e6932a4 100644 --- a/dm_control/locomotion/soccer/camera.py +++ b/dm_control/locomotion/soccer/camera.py @@ -104,16 +104,16 @@ def _get_target_camera_pose(self, entity_positions): def initialize_episode(self, entity_positions): """Begin the episode with the camera set to its target pose.""" target_pose = self._get_target_camera_pose(entity_positions) - self._camera.set_pose(*target_pose) + self._camera.set_pose(*target_pose) # pyrefly: ignore[missing-attribute] def after_step(self, entity_positions): """Move camera toward its target poses.""" target_pose = self._get_target_camera_pose(entity_positions) - cur_pose = self._camera.get_pose() + cur_pose = self._camera.get_pose() # pyrefly: ignore[missing-attribute] smoothing_update_speed = self._smoothing_update_speed filtered_pose = [ target_val * smoothing_update_speed + \ current_val * (1 - smoothing_update_speed) for target_val, current_val in zip(target_pose, cur_pose) ] - self._camera.set_pose(*filtered_pose) + self._camera.set_pose(*filtered_pose) # pyrefly: ignore[missing-attribute] diff --git a/dm_control/locomotion/soccer/humanoid.py b/dm_control/locomotion/soccer/humanoid.py index e066b5b4..6ad70da1 100644 --- a/dm_control/locomotion/soccer/humanoid.py +++ b/dm_control/locomotion/soccer/humanoid.py @@ -108,7 +108,7 @@ class Visual(enum.Enum): GEOM = 1 JERSEY = 2 - def _build(self, # pytype: disable=signature-mismatch # overriding-parameter-count-checks + def _build(self, # pyrefly: ignore[bad-override] visual, marker_rgba, walker_id=None, @@ -167,12 +167,12 @@ def _build(self, # pytype: disable=signature-mismatch # overriding-parameter-c tracking_light.remove() if visual == Humanoid.Visual.JERSEY: - shirt_number = walker_id + 1 + shirt_number = walker_id + 1 # pyrefly: ignore[unsupported-operation] self._mjcf_root.asset.add( 'texture', name='skin', type='2d', - file=os.path.join(_ASSETS_PATH, f'{team}_{walker_id + 1:02d}.png')) + file=os.path.join(_ASSETS_PATH, f'{team}_{walker_id + 1:02d}.png')) # pyrefly: ignore[unbound-name, unsupported-operation] self._mjcf_root.asset.add('material', name='skin', texture='skin') self._mjcf_root.asset.add( 'skin', diff --git a/dm_control/locomotion/tasks/reference_pose/tracking.py b/dm_control/locomotion/tasks/reference_pose/tracking.py index 3c28287b..f6fd4eb9 100644 --- a/dm_control/locomotion/tasks/reference_pose/tracking.py +++ b/dm_control/locomotion/tasks/reference_pose/tracking.py @@ -81,7 +81,7 @@ def _strip_reference_prefix(dictionary: Mapping[Text, Any], The dictionary with the modified keys and original values (and unchanged keys specified by keep_prefixes). """ - keep_prefixes = keep_prefixes or [] + keep_prefixes = keep_prefixes or [] # pyrefly: ignore[bad-assignment] new_dictionary = dict() for key in dictionary: if key.startswith(prefix): @@ -89,7 +89,7 @@ def _strip_reference_prefix(dictionary: Mapping[Text, Any], # note that this will not copy the underlying array. new_dictionary[key_without_prefix] = dictionary[key] else: - for keep_prefix in keep_prefixes: + for keep_prefix in keep_prefixes: # pyrefly: ignore[not-iterable] if key.startswith(keep_prefix): new_dictionary[key] = dictionary[key] @@ -266,10 +266,10 @@ def __init__( self._reset_reward_channels() def _strip_reference_prefix(self): - self._clip_reference_features = _strip_reference_prefix( # pytype: disable=wrong-arg-types + self._clip_reference_features = _strip_reference_prefix( self._clip_reference_features, 'walker/', - keep_prefixes=self._prop_prefixes) + keep_prefixes=self._prop_prefixes) # pyrefly: ignore[bad-argument-type] positions = [] quaternions = [] @@ -315,7 +315,7 @@ def _load_reference_data(self, ref_path, proto_modifier, clip_id, start_step=clip_start_step, end_step=_MAX_END_STEP) for clip_id, clip_start_step in zip( - self._dataset.ids, self._dataset.start_steps) + self._dataset.ids, self._dataset.start_steps) # pyrefly: ignore[bad-argument-type] ] # infer clip end steps to set sampling distribution self._dataset.end_steps = tuple(clip.end_step for clip in self._all_clips) @@ -395,7 +395,7 @@ def _get_possible_starts(self): self._start_probabilities = [] dataset = self._dataset for clip_number, (start, end, weight) in enumerate( - zip(dataset.start_steps, dataset.end_steps, dataset.weights)): + zip(dataset.start_steps, dataset.end_steps, dataset.weights)): # pyrefly: ignore[bad-argument-type] # length - required lookahead - minimum number of steps last_possible_start = end - self._max_ref_step - self._min_steps @@ -458,8 +458,8 @@ def _get_clip_to_track(self, random_state: np.random.RandomState): logging.info('Loading clip %s', clip_id) self._all_clips[self._current_clip_index] = self._loader.get_trajectory( clip_id, - start_step=self._dataset.start_steps[self._current_clip_index], - end_step=self._dataset.end_steps[self._current_clip_index], + start_step=self._dataset.start_steps[self._current_clip_index], # pyrefly: ignore[unsupported-operation] + end_step=self._dataset.end_steps[self._current_clip_index], # pyrefly: ignore[unsupported-operation] zero_out_velocities=False) self._current_clip = self._all_clips[self._current_clip_index] self._clip_reference_features = self._current_clip.as_dict() @@ -469,9 +469,9 @@ def _get_clip_to_track(self, random_state: np.random.RandomState): # clip_start_step:clip_end_step. However start_step is in # [clip_start_step:clip_end_step]. Hence we subtract clip_start_step to # obtain a valid index for the reference features. - self._time_step = start_step - self._dataset.start_steps[ + self._time_step = start_step - self._dataset.start_steps[ # pyrefly: ignore[unsupported-operation] self._current_clip_index] - self._current_start_time = (start_step - self._dataset.start_steps[ + self._current_start_time = (start_step - self._dataset.start_steps[ # pyrefly: ignore[unsupported-operation] self._current_clip_index]) * self._current_clip.dt self._last_step = len( self._clip_reference_features['joints']) - self._max_ref_step - 1 @@ -488,7 +488,7 @@ def initialize_episode(self, physics: 'mjcf.Physics', physics, self._walker, props=self._props) self._walker_features_prev = self._walker_features.copy() - self._walker_joints = np.array(physics.bind(self._walker.mocap_joints).qpos) # pytype: disable=attribute-error + self._walker_joints = np.array(physics.bind(self._walker.mocap_joints).qpos) # compute initial error self._compute_termination_error() @@ -779,14 +779,14 @@ def _update_ghost(self, physics: 'mjcf.Physics'): def action_spec(self, physics: 'mjcf.Physics'): """Action spec of the walker only.""" - ctrl = physics.bind(self._walker.actuators).ctrl # pytype: disable=attribute-error + ctrl = physics.bind(self._walker.actuators).ctrl shape = ctrl.shape dtype = ctrl.dtype minimum = [] maximum = [] for actuator in self._walker.actuators: - if physics.bind(actuator).ctrllimited: # pytype: disable=attribute-error - ctrlrange = physics.bind(actuator).ctrlrange # pytype: disable=attribute-error + if physics.bind(actuator).ctrllimited: + ctrlrange = physics.bind(actuator).ctrlrange minimum.append(ctrlrange[0]) maximum.append(ctrlrange[1]) else: @@ -797,7 +797,7 @@ def action_spec(self, physics: 'mjcf.Physics'): dtype=dtype, minimum=np.asarray(minimum, dtype=dtype), maximum=np.asarray(maximum, dtype=dtype), - name='\t'.join(actuator.full_identifier # pytype: disable=attribute-error + name='\t'.join(actuator.full_identifier for actuator in self._walker.actuators)) @property @@ -879,7 +879,7 @@ def __init__( termination_error_threshold=termination_error_threshold, prop_termination_error_threshold=prop_termination_error_threshold, min_steps=min_steps, - dataset=dataset, + dataset=dataset, # pyrefly: ignore[bad-argument-type] reward_type=reward_type, physics_timestep=physics_timestep, always_init_at_clip_start=always_init_at_clip_start, @@ -903,7 +903,7 @@ def after_step(self, physics: 'mjcf.Physics', random_state): self._walker_features = utils.get_features( physics, self._walker, props=self._props) # features for default error - self._walker_joints = np.array(physics.bind(self._walker.mocap_joints).qpos) # pytype: disable=attribute-error + self._walker_joints = np.array(physics.bind(self._walker.mocap_joints).qpos) self._current_reference_features = { k: v[self._time_step].copy() @@ -956,7 +956,7 @@ def __init__(self, def _get_clip_to_track(self, random_state: np.random.RandomState): self._current_clip_index = (self._current_clip_index + 1) % self._num_clips - start_step = self._dataset.start_steps[self._current_clip_index] + start_step = self._dataset.start_steps[self._current_clip_index] # pyrefly: ignore[unsupported-operation] clip_id = self._dataset.ids[self._current_clip_index] logging.info('Showing clip %d of %d, clip id %s', self._current_clip_index+1, self._num_clips, clip_id) @@ -966,21 +966,21 @@ def _get_clip_to_track(self, random_state: np.random.RandomState): logging.info('Loading clip %s', clip_id) self._all_clips[self._current_clip_index] = self._loader.get_trajectory( clip_id, - start_step=self._dataset.start_steps[self._current_clip_index], - end_step=self._dataset.end_steps[self._current_clip_index], + start_step=self._dataset.start_steps[self._current_clip_index], # pyrefly: ignore[unsupported-operation] + end_step=self._dataset.end_steps[self._current_clip_index], # pyrefly: ignore[unsupported-operation] zero_out_velocities=False) self._current_clip = self._all_clips[self._current_clip_index] - self._clip_reference_features = self._current_clip.as_dict() + self._clip_reference_features = self._current_clip.as_dict() # pyrefly: ignore[missing-attribute] self._clip_reference_features = _strip_reference_prefix( self._clip_reference_features, 'walker/') # The reference features are already restricted to # clip_start_step:clip_end_step. However start_step is in # [clip_start_step:clip_end_step]. Hence we subtract clip_start_step to # obtain a valid index for the reference features. - self._time_step = start_step - self._dataset.start_steps[ + self._time_step = start_step - self._dataset.start_steps[ # pyrefly: ignore[unsupported-operation] self._current_clip_index] - self._current_start_time = (start_step - self._dataset.start_steps[ - self._current_clip_index]) * self._current_clip.dt + self._current_start_time = (start_step - self._dataset.start_steps[ # pyrefly: ignore[unsupported-operation] + self._current_clip_index]) * self._current_clip.dt # pyrefly: ignore[missing-attribute] self._last_step = len( self._clip_reference_features['joints']) - self._max_ref_step - 1 logging.info('Mocap %s at step %d with remaining length %d.', clip_id, diff --git a/dm_control/locomotion/tasks/reference_pose/tracking_test.py b/dm_control/locomotion/tasks/reference_pose/tracking_test.py index 8ba42e35..83a08828 100644 --- a/dm_control/locomotion/tasks/reference_pose/tracking_test.py +++ b/dm_control/locomotion/tasks/reference_pose/tracking_test.py @@ -215,7 +215,9 @@ def test_prop_factory(self): # Test that props go to the expected location on reset. for ref_key, obs_key in zip(REFERENCE_PROP_KEYS, PROP_OBSERVATION_KEYS): - np.testing.assert_array_equal(observation[ref_key], observation[obs_key]) + np.testing.assert_array_almost_equal( + observation[ref_key], observation[obs_key] + ) def test_ghost_prop(self): task = tracking.MultiClipMocapTracking( @@ -242,7 +244,7 @@ def test_ghost_prop(self): np.squeeze(observation[key]) for key in REFERENCE_PROP_KEYS) np.testing.assert_array_equal(np.array(ghost_pos), goal_pos + GHOST_OFFSET) - np.testing.assert_array_equal(ghost_quat, goal_quat) + np.testing.assert_array_almost_equal(ghost_quat, goal_quat) def test_disable_props(self): task = tracking.MultiClipMocapTracking( diff --git a/dm_control/locomotion/walkers/rescale.py b/dm_control/locomotion/walkers/rescale.py index b3ccf413..f1a5226e 100644 --- a/dm_control/locomotion/walkers/rescale.py +++ b/dm_control/locomotion/walkers/rescale.py @@ -21,6 +21,8 @@ def rescale_subtree(body, position_factor, size_factor): """Recursively rescales an entire subtree of an MJCF model.""" for child in body.all_children(): + if child.tag == 'sensor': + continue if getattr(child, 'fromto', None) is not None: new_pos = position_factor * 0.5 * (child.fromto[3:] + child.fromto[:3]) new_size = size_factor * 0.5 * (child.fromto[3:] - child.fromto[:3]) diff --git a/dm_control/mjcf/README.md b/dm_control/mjcf/README.md index ea4287f6..056f4174 100644 --- a/dm_control/mjcf/README.md +++ b/dm_control/mjcf/README.md @@ -22,7 +22,7 @@ collision occurs. ```python from dm_control import mjcf -class Arm(object): +class Arm: def __init__(self, name): self.mjcf_model = mjcf.RootElement(model=name) @@ -38,7 +38,7 @@ class Arm(object): self.forearm.add('geom', name='forearm', type='capsule', pos=[0, 0, -0.15], size=[0.045, 0.15]) -class UpperBody(object): +class UpperBody: def __init__(self): self.mjcf_model = mjcf.RootElement() @@ -430,10 +430,10 @@ ensure that two models do not become subtly "incompatible". For example: ```python model_1 = mjcf.RootElement() -model_1.compiler.angle = 'radians' +model_1.compiler.angle = 'radian' model_2 = mjcf.RootElement() -model_2.compiler.angle = 'degrees' +model_2.compiler.angle = 'degree' model_1.attach(model_2) # Error! ``` @@ -446,14 +446,14 @@ become problematic is: model_1 = mjcf.RootElement() model_2 = mjcf.RootElement() -model_2.compiler.angle = 'degrees' +model_2.compiler.angle = 'degree' model_1.attach(model_2) # No error, but all angles in model_1 are now wrong! ``` Here, `model_1` assumes MuJoCo's default angle unit of radians. Since it does not explicitly assign a value to `compiler.angle`, PyMJCF does not detect a -conflict with `angle=degrees` in `model_2`. All angles in `model_1` are now +conflict with `angle=degree` in `model_2`. All angles in `model_1` are now incorrectly interpreted as degrees. ### Elements outside of `` @@ -462,7 +462,7 @@ All children of non-worldbody elements, e.g. actuators or tendons, are automatically merged in to appropriate places when a model is attached. Named elements are prefixed as previously described. -## Common gotchas {#common-gotchas} +## Common gotchas ### Use `foo.dclass`, not ~~`foo.class`~~ diff --git a/dm_control/mjcf/__init__.py b/dm_control/mjcf/__init__.py index 5c5f18b0..13ddba07 100644 --- a/dm_control/mjcf/__init__.py +++ b/dm_control/mjcf/__init__.py @@ -29,6 +29,7 @@ from dm_control.mjcf.parser import from_file from dm_control.mjcf.parser import from_path from dm_control.mjcf.parser import from_xml_string +from dm_control.mjcf.parser import from_zip from dm_control.mjcf.physics import Physics diff --git a/dm_control/mjcf/attribute.py b/dm_control/mjcf/attribute.py index adbc1ba2..c2ae0b2e 100644 --- a/dm_control/mjcf/attribute.py +++ b/dm_control/mjcf/attribute.py @@ -231,6 +231,8 @@ def to_xml_string(self, prefix_root=None, return util.to_native_string(out.getvalue())[:-1] # Strip trailing space. def _check_shape(self, array): + if array.ndim == 0: + array = array.reshape(1) actual_length = array.shape[0] if len(array.shape) > 1: raise ValueError('Expect one-dimensional array: got {}'.format(array)) @@ -317,7 +319,9 @@ def reference_namespace(self): return self._reference_namespace def _assign(self, value): - if not isinstance(value, (base.Element, str)): + if not isinstance(value, (base.Element, str)) and not ( + isinstance(value, np.ndarray) and value.dtype.kind == 'U' + ): raise ValueError( 'Expect a string or `mjcf.Element` value: got {}'.format(value)) elif not value: @@ -330,6 +334,8 @@ def _assign(self, value): raise ValueError(_INVALID_REFERENCE_TYPE.format( valid_type=self.reference_namespace, actual_type=value_namespace)) + elif isinstance(value, np.ndarray): + value = value.item() self._value = value def _before_clear(self): @@ -424,7 +430,7 @@ def __init__(self, extension, prefix=''): def __eq__(self, other): return self.get_vfs_filename() == other.get_vfs_filename() - def get_vfs_filename(self): + def get_vfs_filename(self, filename_with_hash=True): """Returns the name of the asset file as registered in MuJoCo's VFS.""" # Hash the contents of the asset to get a unique identifier. hash_string = hashlib.sha1(util.to_binary_string(self.contents)).hexdigest() @@ -435,7 +441,10 @@ def get_vfs_filename(self): if raw_length > constants.MAX_VFS_FILENAME_LENGTH: trim_amount = raw_length - constants.MAX_VFS_FILENAME_LENGTH prefix = prefix[:-trim_amount] - filename = '-'.join([prefix, hash_string]) + if filename_with_hash: + filename = '-'.join([prefix, hash_string]) + else: + filename = prefix else: filename = hash_string @@ -511,24 +520,38 @@ def _get_asset_from_path(self, path): _, basename = os.path.split(path) filename, extension = os.path.splitext(basename) + # Construct the path to the asset file (relative to the model directory), + # prefixed by the appropriate base path, if specified. + base_path = None + if self._parent.namescope.has_identifier( + constants.BASEPATH, self._path_namespace + ): + base_path = self._parent.namescope.get( + constants.BASEPATH, self._path_namespace + ) + elif self._path_namespace in ( + constants.TEXTUREDIR_NAMESPACE, + constants.MESHDIR_NAMESPACE, + ) and self._parent.namescope.has_identifier( + constants.BASEPATH, constants.ASSETDIR_NAMESPACE + ): + base_path = self._parent.namescope.get( + constants.BASEPATH, constants.ASSETDIR_NAMESPACE + ) + full_path = ( + os.path.normpath(os.path.join(base_path, path)) if base_path else path + ) + # Look in the dict of pre-loaded assets before checking the filesystem. - try: + if path in self._parent.namescope.assets: contents = self._parent.namescope.assets[path] - except KeyError: - # Construct the full path to the asset file, prefixed by the path to the - # model directory, and by `meshdir` or `texturedir` if appropriate. - path_parts = [] + elif full_path in self._parent.namescope.assets: + contents = self._parent.namescope.assets[full_path] + else: if self._parent.namescope.model_dir: - path_parts.append(self._parent.namescope.model_dir) - try: - base_path = self._parent.namescope.get(constants.BASEPATH, - self._path_namespace) - path_parts.append(base_path) - except KeyError: - pass - path_parts.append(path) - full_path = os.path.join(*path_parts) # pylint: disable=no-value-for-parameter + full_path = os.path.join(self._parent.namescope.model_dir, full_path) contents = resources.GetResource(full_path) + if self._parent.tag == constants.SKIN: return SkinAsset(contents=contents, parent=self._parent, extension=extension, prefix=filename) @@ -551,6 +574,7 @@ def to_xml_string(self, prefix_root=None, **kwargs): """Returns the asset filename as it will appear in the generated XML.""" del prefix_root # Unused if self._value is not None: - return self._value.get_vfs_filename() + filename_with_hash = kwargs.get('filename_with_hash', True) + return self._value.get_vfs_filename(filename_with_hash) else: return None diff --git a/dm_control/mjcf/attribute_test.py b/dm_control/mjcf/attribute_test.py index f604b8b7..82310a68 100644 --- a/dm_control/mjcf/attribute_test.py +++ b/dm_control/mjcf/attribute_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for `dm_control.mjcf.attribute`.""" - import contextlib import hashlib import os @@ -455,6 +453,19 @@ def testFileFromAssetsDict(self): contents=contents, extension=extension, prefix=prefix) self.assertEqual(text_file.file, expected_value) + def testFileFromAssetsDictWithBasepath(self): + prefix = 'fake_filename' + extension = '.whatever' + path = 'invalid/../path/' + prefix + extension + contents = 'Fake contents' + assets = {os.path.normpath(os.path.join(ASSETS_DIR, path)): contents} + mujoco = element.RootElement(assets=assets) + mujoco.files.text_path = ASSETS_DIR + text_file = mujoco.files.add('text', file=path) + expected_value = attribute.Asset( + contents=contents, extension=extension, prefix=prefix) + self.assertEqual(text_file.file, expected_value) + def testFileExceptions(self): mujoco = self._mujoco text_file = mujoco.files.add('text') diff --git a/dm_control/mjcf/constants.py b/dm_control/mjcf/constants.py index 1898fc28..27285d39 100644 --- a/dm_control/mjcf/constants.py +++ b/dm_control/mjcf/constants.py @@ -39,6 +39,11 @@ TENDON = 'tendon' WORLDBODY = 'worldbody' +# Path namespaces. +MESHDIR_NAMESPACE = 'mesh' +TEXTUREDIR_NAMESPACE = 'texture' +ASSETDIR_NAMESPACE = 'asset' + MJDATA_TRIGGERS_DIRTY = [ 'qpos', 'qvel', 'act', 'ctrl', 'qfrc_applied', 'xfrc_applied'] MJMODEL_DOESNT_TRIGGER_DIRTY = [ @@ -63,9 +68,7 @@ 'body_iquat': ('simple', 'sameframe'), } -# This is the actual upper limit on VFS filename length, despite what it says -# in the header file (100) or the error message (99). -MAX_VFS_FILENAME_LENGTH = 98 +MAX_VFS_FILENAME_LENGTH = 998 # The prefix used in the schema to denote reference_namespace that are defined # via another attribute. diff --git a/dm_control/mjcf/element.py b/dm_control/mjcf/element.py index 6c800ed1..7fb0134d 100644 --- a/dm_control/mjcf/element.py +++ b/dm_control/mjcf/element.py @@ -32,7 +32,7 @@ import numpy as np -_raw_property = property # pylint: disable=invalid-name +_raw_property = property # pylint: disable=invalid-name,used-before-assignment _UNITS = ('K', 'M', 'G', 'T', 'P', 'E') @@ -751,7 +751,7 @@ def all_children(self): def to_xml(self, prefix_root=None, debug_context=None, *, precision=constants.XML_DEFAULT_PRECISION, - zero_threshold=0): + zero_threshold=0, filename_with_hash=True): """Generates an etree._Element corresponding to this MJCF element. Args: @@ -766,6 +766,8 @@ def to_xml(self, prefix_root=None, debug_context=None, quantities. zero_threshold: (optional) When outputting XML, floating point quantities whose absolute value falls below this threshold will be treated as zero. + filename_with_hash: (optional) A boolean, whether to append a hash string + to the name of a file when it is registered in MuJoCo's VFS. Returns: An etree._Element object. @@ -773,18 +775,22 @@ def to_xml(self, prefix_root=None, debug_context=None, prefix_root = prefix_root or self.namescope xml_element = etree.Element(self._spec.name) self._attributes_to_xml(xml_element, prefix_root, debug_context, - precision=precision, zero_threshold=zero_threshold) + precision=precision, zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash) self._children_to_xml(xml_element, prefix_root, debug_context, - precision=precision, zero_threshold=zero_threshold) + precision=precision, zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash) return xml_element def _attributes_to_xml(self, xml_element, prefix_root, debug_context=None, - *, precision, zero_threshold): + *, precision, zero_threshold, filename_with_hash): del debug_context # Unused. for attribute_name, attribute in self._attributes.items(): - attribute_value = attribute.to_xml_string(prefix_root, - precision=precision, - zero_threshold=zero_threshold) + attribute_value = attribute.to_xml_string( + prefix_root, + precision=precision, + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash) if attribute_name == self._spec.identifier and attribute_value is None: xml_element.set(attribute_name, self.full_identifier) elif attribute_value is None: @@ -793,11 +799,12 @@ def _attributes_to_xml(self, xml_element, prefix_root, debug_context=None, xml_element.set(attribute_name, attribute_value) def _children_to_xml(self, xml_element, prefix_root, debug_context=None, - *, precision, zero_threshold): + *, precision, zero_threshold, filename_with_hash): for child in self.all_children(): child_xml = child.to_xml(prefix_root, debug_context, precision=precision, - zero_threshold=zero_threshold) + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash) if (child_xml.attrib or len(child_xml) # pylint: disable=g-explicit-length-test or child.spec.repeated or child.spec.on_demand): xml_element.append(child_xml) @@ -811,7 +818,7 @@ def to_xml_string(self, prefix_root=None, self_only=False, pretty_print=True, debug_context=None, *, precision=constants.XML_DEFAULT_PRECISION, - zero_threshold=0): + zero_threshold=0, filename_with_hash=True): """Generates an XML string corresponding to this MJCF element. Args: @@ -830,13 +837,16 @@ def to_xml_string(self, prefix_root=None, quantities. zero_threshold: (optional) When outputting XML, floating point quantities whose absolute value falls below this threshold will be treated as zero. + filename_with_hash: (optional) A boolean, whether to append a hash string + to the name of a file when it is registered in MuJoCo's VFS. Returns: A string. """ xml_element = self.to_xml(prefix_root, debug_context, precision=precision, - zero_threshold=zero_threshold) + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash) if self_only and len(xml_element) > 0: # pylint: disable=g-explicit-length-test etree.strip_elements(xml_element, '*') xml_element.text = '...' @@ -1073,10 +1083,12 @@ def prefixed_identifier(self, prefix_root=None): def to_xml(self, prefix_root=None, debug_context=None, *, precision=constants.XML_DEFAULT_PRECISION, - zero_threshold=0): + zero_threshold=0, + filename_with_hash=True): xml_element = (super().to_xml(prefix_root, debug_context, precision=precision, - zero_threshold=zero_threshold)) + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash)) xml_element.set('name', self.prefixed_identifier(prefix_root)) return xml_element @@ -1104,10 +1116,12 @@ class _AttachmentFrameChild(_ElementImpl): def to_xml(self, prefix_root=None, debug_context=None, *, precision=constants.XML_DEFAULT_PRECISION, - zero_threshold=0): + zero_threshold=0, + filename_with_hash=True): xml_element = (super().to_xml(prefix_root, debug_context, precision=precision, - zero_threshold=zero_threshold)) + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash)) if self.spec.namespace is not None: if self.name: name = (self._parent.prefixed_identifier(prefix_root) + @@ -1147,18 +1161,22 @@ def all_children(self): def to_xml(self, prefix_root=None, debug_context=None, *, precision=constants.XML_DEFAULT_PRECISION, - zero_threshold=0): + zero_threshold=0, + filename_with_hash=True): prefix_root = prefix_root or self.namescope xml_element = (super().to_xml(prefix_root, debug_context, precision=precision, - zero_threshold=zero_threshold)) + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash)) if isinstance(self._parent, RootElement): root_default = etree.Element(self._spec.name) root_default.append(xml_element) for attachment in self._attachments.values(): - attachment_xml = attachment.to_xml(prefix_root, debug_context, - precision=precision, - zero_threshold=zero_threshold) + attachment_xml = attachment.to_xml( + prefix_root, debug_context, + precision=precision, + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash) for attachment_child_xml in attachment_xml: root_default.append(attachment_child_xml) xml_element = root_default @@ -1173,12 +1191,14 @@ class _ActuatorElement(_ElementImpl): def _children_to_xml(self, xml_element, prefix_root, debug_context=None, *, precision=constants.XML_DEFAULT_PRECISION, - zero_threshold=0): + zero_threshold=0, + filename_with_hash=True): debug_comments = {} for child in self.all_children(): child_xml = child.to_xml(prefix_root, debug_context, precision=precision, - zero_threshold=zero_threshold) + zero_threshold=zero_threshold, + filename_with_hash=filename_with_hash) if debugging.debug_mode() and debug_context: debug_comment = debug_context.register_element_for_debugging(child) debug_comments[child_xml] = debug_comment diff --git a/dm_control/mjcf/export_with_assets_as_zip.py b/dm_control/mjcf/export_with_assets_as_zip.py index 2b9ace05..08fcc82e 100644 --- a/dm_control/mjcf/export_with_assets_as_zip.py +++ b/dm_control/mjcf/export_with_assets_as_zip.py @@ -43,6 +43,9 @@ def export_with_assets_as_zip(mjcf_model, out_dir, model_name=None, quantities. zero_threshold: (optional) When outputting XML, floating point quantities whose absolute value falls below this threshold will be treated as zero. + + Returns: + The full path to the .zip file. """ if model_name is None: @@ -57,6 +60,8 @@ def export_with_assets_as_zip(mjcf_model, out_dir, model_name=None, if not os.path.exists(out_dir): os.makedirs(out_dir) - with zipfile.ZipFile(os.path.join(out_dir, zip_name), 'w') as zip_file: + file_name = os.path.join(out_dir, zip_name) + with zipfile.ZipFile(file_name, 'w') as zip_file: for filename, contents in files_to_zip.items(): zip_file.writestr(os.path.join(model_name, filename), contents) + return file_name diff --git a/dm_control/mjcf/export_with_assets_test.py b/dm_control/mjcf/export_with_assets_test.py index 442bb7cf..1cc6b31e 100644 --- a/dm_control/mjcf/export_with_assets_test.py +++ b/dm_control/mjcf/export_with_assets_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for `dm_control.mjcf.export_with_assets`.""" - import os from absl import flags from absl.testing import absltest @@ -27,6 +25,9 @@ _ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'test_assets') _TEST_MODEL_WITH_ASSETS = os.path.join(_ASSETS_DIR, 'model_with_assets.xml') +_TEST_MODEL_WITH_ASSETDIR = os.path.join(_ASSETS_DIR, 'model_with_assetdir.xml') +_TEST_MODEL_WITH_SEPARATORS = os.path.join(_ASSETS_DIR, + 'model_with_separators.xml') _TEST_MODEL_WITHOUT_ASSETS = os.path.join(_ASSETS_DIR, 'lego_brick.xml') @@ -42,6 +43,7 @@ class ExportWithAssetsTest(parameterized.TestCase): @parameterized.named_parameters( ('with_assets', _TEST_MODEL_WITH_ASSETS, 'mujoco_with_assets.xml'), + ('with_assetdir', _TEST_MODEL_WITH_ASSETDIR, 'mujoco_with_assetdir.xml'), ('without_assets', _TEST_MODEL_WITHOUT_ASSETS, 'mujoco.xml'),) def test_export_model(self, xml_path, out_xml_name): """Save processed MJCF model.""" @@ -70,9 +72,15 @@ def test_export_model(self, xml_path, out_xml_name): self.assertDictEqual(out_dir_contents, assets) # Check that we can construct an MjModel instance using the path to the - # output file. - from_exported = wrapper.MjModel.from_xml_path( - os.path.join(out_dir, out_xml_name)) + # output file. Load relative to `out_dir` so `modelfiledir` is empty, + # matching `from_xml_string` below when resolved paths are stored in + # `mjModel.paths`. + old_cwd = os.getcwd() + try: + os.chdir(out_dir) + from_exported = wrapper.MjModel.from_xml_path(out_xml_name) + finally: + os.chdir(old_cwd) # Check that this model is identical to one constructed directly from # `mjcf_model`. @@ -87,6 +95,15 @@ def test_default_model_filename(self): expected_name = mjcf_model.model + '.xml' self.assertTrue(os.path.isfile(os.path.join(out_dir, expected_name))) + def test_model_with_separators(self): + out_dir = self.create_tempdir().full_path + mjcf_model = mjcf.from_path( + _TEST_MODEL_WITH_SEPARATORS, escape_separators=True + ) + mjcf.export_with_assets(mjcf_model, out_dir, out_file_name=None) + expected_name = mjcf_model.model + '.xml' + self.assertTrue(os.path.isfile(os.path.join(out_dir, expected_name))) + def test_exceptions(self): out_dir = self.create_tempdir().full_path mjcf_model = mjcf.from_path(_TEST_MODEL_WITH_ASSETS) diff --git a/dm_control/mjcf/parser.py b/dm_control/mjcf/parser.py index c710f179..681f0293 100644 --- a/dm_control/mjcf/parser.py +++ b/dm_control/mjcf/parser.py @@ -15,13 +15,16 @@ """Functions for parsing XML into an MJCF object model.""" +import io import os import sys +import zipfile from dm_control.mjcf import constants from dm_control.mjcf import debugging from dm_control.mjcf import element from lxml import etree +# Copybara placeholder for internal file handling dependency. from dm_control.utils import io as resources @@ -106,6 +109,60 @@ def from_path(path, escape_separators=False, resolve_references=True, assets=assets) +def from_zip(path, model_file='model.xml', escape_separators=False, + resolve_references=True): + """Parses a zipped XML file into an MJCF object model. + + Args: + path: A path to a zip file containing an MJCF model and its assets. + model_file: If the zip contains multiple XML files, specify the name of the + main model file. Ignored if the zip only contains one XML file. + escape_separators: (optional) A boolean, whether to replace '/' characters + in element identifiers. If `False`, any '/' present in the XML causes a + ValueError to be raised. + resolve_references: (optional) A boolean indicating whether the parser + should attempt to resolve reference attributes to a corresponding element. + + Returns: + An `mjcf.RootElement`. + + Raises: + ValueError: If: + - the path does not point to a zip file + - the zip file contains no XML files + - the zip file contains more than one XML file and none of them have the + name specified in `model_file`. + """ + contents = resources.GetResource(path) + if not zipfile.is_zipfile(io.BytesIO(contents)): + raise ValueError(f'File {path} is not a zip file.') + with zipfile.ZipFile(io.BytesIO(contents), 'r') as zf: + xml_files = [f for f in zf.namelist() if f.endswith('.xml')] + if not xml_files: + raise ValueError(f'No XML file found in {path}.') + elif len(xml_files) > 1: + model_files = [f for f in xml_files if f == model_file] + if not model_files: + raise ValueError( + f'Multiple XML files found in {path}, but none named {model_file}.' + ) + xml_path = model_files[0] + else: + xml_path = xml_files[0] + xml_string = zf.read(xml_path) + + model_dir = os.path.dirname(xml_path) + assets = { + os.path.relpath(name, model_dir): zf.read(name) + for name in zf.namelist() + if not (name.endswith(os.path.sep) or name == xml_path) + } + + xml_root = etree.fromstring(xml_string) + return _parse(xml_root, escape_separators, + resolve_references=resolve_references, assets=assets) + + def _parse(xml_root, escape_separators=False, model_dir='', resolve_references=True, assets=None): """Parses a complete MJCF model from an XML. @@ -198,12 +255,19 @@ def _parse_children(xml_element, mjcf_element, escape_separators=False): if escape_separators: attributes = {} for name, value in xml_child.attrib.items(): - new_value = value.replace( - constants.PREFIX_SEPARATOR_ESCAPE, - constants.PREFIX_SEPARATOR_ESCAPE * 2) - new_value = new_value.replace( - constants.PREFIX_SEPARATOR, constants.PREFIX_SEPARATOR_ESCAPE) - attributes[name] = new_value + # skip flipping the slash for fields that may contain paths, like + # custom text and asset file. + if name in ['data', 'file', 'meshdir', 'assetdir', 'texturedir', + 'content_type', 'fileleft', 'fileright', 'fileback', + 'filefront', 'plugin', 'key', 'value']: + attributes[name] = value + else: + new_value = value.replace( + constants.PREFIX_SEPARATOR_ESCAPE, + constants.PREFIX_SEPARATOR_ESCAPE * 2) + new_value = new_value.replace( + constants.PREFIX_SEPARATOR, constants.PREFIX_SEPARATOR_ESCAPE) + attributes[name] = new_value else: attributes = dict(xml_child.attrib) if child_spec.repeated or child_spec.on_demand: @@ -213,7 +277,7 @@ def _parse_children(xml_element, mjcf_element, escape_separators=False): mjcf_child.set_attributes(**attributes) except: # pylint: disable=bare-except err_type, err, traceback = sys.exc_info() - raise err_type( + raise err_type( # pylint: disable=raise-missing-from f'Line {xml_child.sourceline}: error while parsing element ' f'<{xml_child.tag}>: {err}').with_traceback(traceback) _parse_children(xml_child, mjcf_child, escape_separators) diff --git a/dm_control/mjcf/physics.py b/dm_control/mjcf/physics.py index 89930e35..8922bbf3 100644 --- a/dm_control/mjcf/physics.py +++ b/dm_control/mjcf/physics.py @@ -92,7 +92,8 @@ def _get_attributes(size_names, strip_prefixes): _ATTRIBUTES = { - 'actuator': _get_attributes(['na', 'nu'], strip_prefixes=['actuator']), + 'actuator': _get_attributes(['na', 'nu', 'nactuator', 'nout'], + strip_prefixes=['actuator']), 'body': _get_attributes(['nbody'], strip_prefixes=['body']), 'mocap_body': _get_attributes(['nbody', 'nmocap'], strip_prefixes=['body']), 'camera': _get_attributes(['ncam'], strip_prefixes=['cam']), @@ -512,7 +513,7 @@ def forward(self): super().forward() self._dirty = False - def bind(self, mjcf_elements): + def bind(self, mjcf_elements) -> Binding: """Creates a binding between this `Physics` instance and `mjcf.Element`s. The binding allows for easier interaction with the `Physics` data structures @@ -609,7 +610,7 @@ def bind(self, mjcf_elements): ValueError: If `mjcf_elements` cannot be bound to this Physics. """ if mjcf_elements is None: - return None + raise ValueError('mjcf_elements is None.') # To reduce overhead from processing MJCF elements and making new bindings, # we cache and reuse existing Binding objects. The cheapest version of @@ -625,13 +626,11 @@ def bind(self, mjcf_elements): # `mjcf_elements` is not iterable. cache_key = mjcf_elements - needs_new_binding = False try: - binding = self._bindings[cache_key] + return self._bindings[cache_key] except KeyError: # This means `mjcf_elements` is hashable, so we use it as cache key. namespace, named_index = names_from_elements(mjcf_elements) - needs_new_binding = True except TypeError: # This means `mjcf_elements` is unhashable, fallback to caching by name. namespace, named_index = names_from_elements(mjcf_elements) @@ -644,14 +643,12 @@ def bind(self, mjcf_elements): cache_key = (namespace, named_index) try: - binding = self._bindings[cache_key] + return self._bindings[cache_key] except KeyError: - needs_new_binding = True - - if needs_new_binding: - binding = Binding(weakref.proxy(self), namespace, named_index) - self._bindings[cache_key] = binding + pass + binding = Binding(weakref.proxy(self), namespace, named_index) + self._bindings[cache_key] = binding return binding diff --git a/dm_control/mjcf/physics_test.py b/dm_control/mjcf/physics_test.py index 0820cde4..e2c4a608 100644 --- a/dm_control/mjcf/physics_test.py +++ b/dm_control/mjcf/physics_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for `dm_control.mjcf.physics`.""" - import copy import os import pickle @@ -579,48 +577,30 @@ def test_error_when_pickling_synchronizing_array_wrapper(self): mjcf_physics._PICKLING_NOT_SUPPORTED.format(type=type(xpos_view))): pickle.dumps(xpos_view) - def test_plugins_elasticity(self): - root = mjcf.RootElement() - root.extension.add('plugin', plugin='mujoco.elasticity.cable') - - # Replicate example in mujoco/model/plugin/elasticity/cable.xml - composite = root.worldbody.add( - 'composite', - type='cable', - curve='s', - count=[41, 1, 1], - size=[1, 0, 0], - offset=[-0.3, 0, 0.6], - initial='none', - ) - plugin = composite.add('plugin', plugin='mujoco.elasticity.cable') - plugin.add('config', key='twist', value='1e7') - plugin.add('config', key='bend', value='4e6') - plugin.add('config', key='vmax', value='0.05') - composite.add('joint', kind='main', damping=0.015) - composite.geom.type = 'capsule' - composite.geom.size = [0.005, 0, 0] - composite.geom.rgba = [0.8, 0.2, 0.1, 1] - composite.geom.condim = 1 - - physics = mjcf.Physics.from_mjcf_model(root) - physics.step() - def test_plugins_sdf(self): root = mjcf.RootElement() + root.option.sdf_iterations = 10 + root.option.sdf_initpoints = 40 + extension = root.extension.add('plugin', plugin='mujoco.sdf.torus') instance = extension.add('instance', name='torus') instance.add('config', key='radius1', value='0.35') instance.add('config', key='radius2', value='0.15') - # Replicate example in mujoco/model/plugin/elasticity/torus.xml + # Replicate example in mujoco/model/plugin/sdf/torus.xml mesh = root.asset.add('mesh', name='torus') mesh.add('plugin', instance='torus') + # Test we can add SDF geom to the worldbody. + worldbody_geom = root.worldbody.add( + 'geom', type='sdf', mesh='torus', rgba=[.2, .2, .8, 1]) + worldbody_geom.add('plugin', instance='torus') + + # Test we can add SDF geom to a body. body = root.worldbody.add('body', pos=[-1, 0, 3.8]) body.add('freejoint') - geom = body.add('geom', type='sdf', mesh='torus', rgba=[.2, .2, .8, 1]) - geom.add('plugin', instance='torus') + body_geom = body.add('geom', type='sdf', mesh='torus', rgba=[.2, .2, .8, 1]) + body_geom.add('plugin', instance='torus') physics = mjcf.Physics.from_mjcf_model(root) physics.step() diff --git a/dm_control/mjcf/schema.py b/dm_control/mjcf/schema.py index 8bd8cc53..3059a57b 100644 --- a/dm_control/mjcf/schema.py +++ b/dm_control/mjcf/schema.py @@ -160,8 +160,10 @@ def _parse_attribute(attribute_xml): else: try: attribute_callable = _SCALAR_TYPE_MAP[attribute_type] - except KeyError: - raise ValueError('Invalid attribute type: {}'.format(attribute_type)) + except KeyError as exc: + raise ValueError( + 'Invalid attribute type: {}'.format(attribute_type) + ) from exc return AttributeSpec( name=name, type=attribute_callable, required=required, @@ -215,7 +217,7 @@ def _attachment_frame_spec(is_world_attachment): body_spec = MUJOCO.children['worldbody'].children['body'] # 'name' and 'childclass' attributes are excluded. for attrib_name in ( - 'mocap', 'pos', 'quat', 'axisangle', 'xyaxes', 'zaxis', 'euler'): + 'mocap', 'pos', 'quat', 'axisangle', 'xyaxes', 'zaxis', 'euler', 'user'): frame_spec.attributes[attrib_name] = copy.deepcopy( body_spec.attributes[attrib_name]) @@ -258,4 +260,3 @@ def override_schema(schema_xml_path): MUJOCO = parse_schema(schema_xml_path) FINDABLE_NAMESPACES = frozenset( collect_namespaces(MUJOCO).union(_ADDITIONAL_FINDABLE_NAMESPACES)) - diff --git a/dm_control/mjcf/schema.xml b/dm_control/mjcf/schema.xml index 4afe663b..11aea4eb 100644 --- a/dm_control/mjcf/schema.xml +++ b/dm_control/mjcf/schema.xml @@ -1,3 +1,7 @@ + @@ -5,92 +9,109 @@ - + - + - + - - - + - - - + + + + + + + + + - - + + - + - - - - - + + + + + - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + - - - + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -98,549 +119,291 @@ - - - + + + - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + - - - - - + + + + + - - - - + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - + + + + + + + + + + + + - - + - - - - - - - + + + + + + + + + - + - - - - + + + + - - - + + - + - - - + + + + + - - + + - - - + + + + + - + - + - - - - - + + + + + + + - - - - - - + + + + + + + - + @@ -651,29 +414,35 @@ - - - - - - - - - + + + + + + + + + + + + - - + + + - + - - - - - + + + + + + + @@ -685,370 +454,380 @@ - + + + + - + + - + + + + + - - - + - - + + + + + + + + - + + + + - - - - - - - + + + + + + + + + + - + - - + + + + + + + - + + + - - - - - + - - + + + + + + + + - + + + - + + - + - - + + + + + + + - + + + - - + - - + + + + + + + + - + + + + + + + + + + + + + + + + + + + + - + - + + + + + + - + + + - - + - - + + + + + + + - + + + - - + - + + + - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - + - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + - - + + + - + - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - - - + + - + + + - - - - - - - - - - + - - - - - - - - - - - + + + + + + + + + + - + - - - + + - - - - - - - - - + - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - + - - - - - - + + + + - - - + + - + - - - + + + + + - - + + - - - + + + + + - + - + - - - + - - - - - - + + + + + + + - + - - - - - - - - + + + + + + + - + @@ -1057,261 +836,460 @@ - + - - - - - - - - - - - + + + + + + + + + + + + - - + + + - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - + + + + + - - - + + + + + + + - - - - - - - - - - - - - - - - - + + + + - - @@ -1319,23 +1297,27 @@ - + - + - - - - - - + + + + + + + + + + - + @@ -1344,55 +1326,58 @@ + - - - - + + + + - - - + + - + - - - + + + + + - - + + - - - + + + + + - + - + - - + @@ -1404,256 +1389,398 @@ - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + + @@ -1667,39 +1794,57 @@ - - - - - - - - - - + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - + + + + + + + + + @@ -1708,22 +1853,22 @@ + + + - - - - - - - - + + + + + @@ -1736,23 +1881,103 @@ - - - - - + + + + + - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1762,25 +1987,29 @@ - - - - + + + + - + + + - - - - + + + + + + @@ -1788,10 +2017,10 @@ - - - - + + + + @@ -1800,10 +2029,10 @@ - - - - + + + + @@ -1812,82 +2041,22 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - + + + + + - - - - - - - - @@ -1898,30 +2067,39 @@ - - - - + + + + + + + - + + + - + - - - - - + - - - - - - + + + + + + + + + + + + + + @@ -1931,21 +2109,26 @@ - - + + + + + + - + + + - + - - + - + + - @@ -1953,23 +2136,31 @@ - - + + + + + + + - + + + - + - - + - + + - + + @@ -1977,21 +2168,26 @@ - - + + + + + + - + + + - + - - + - + + - @@ -2000,24 +2196,53 @@ - - + + + + + + + + - + + + - + - - + - + + + + + + + + + + + + + + + + + + + + + + + @@ -2025,20 +2250,25 @@ - + + + + + - + + + - + - - + - + + - @@ -2047,21 +2277,26 @@ - - + + + + + + - + + + - + - - + - + + - @@ -2073,21 +2308,26 @@ - - + + + + + + - + + + - + - - + - - + + @@ -2103,11 +2343,14 @@ - + + + - - + + + @@ -2115,33 +2358,39 @@ - - - - - + + + + + + + + + - - - + + + - + - - + + + + + - - + @@ -2154,361 +2403,669 @@ - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + + + + + + + + + + + + + + + + + + - + + - + + + + + - + + + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + + + + + + + + + + + + + + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - + - + + + + + - - - - + + + + - + + + + + - - - - + + + + - + + + + + - - - - + + + + - + + + + + - - - - + + + + - + + + + + - - - - + + + + - + + + + + - - - - + + + + - + + + + + - - - - + + + + - + + + + + - - + + - + + + + + - - + + - + + + + + - + - + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + - - - + + + + + + @@ -2517,14 +3074,16 @@ - + + + - + @@ -2532,6 +3091,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2541,9 +3131,10 @@ - + + diff --git a/dm_control/mjcf/schema_test.py b/dm_control/mjcf/schema_test.py new file mode 100644 index 00000000..1eda0e8c --- /dev/null +++ b/dm_control/mjcf/schema_test.py @@ -0,0 +1,191 @@ +# Copyright 2026 The dm_control Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Validates dm_control's schema.xml against dm_control itself.""" + +import os +import xml.etree.ElementTree as ET + +from absl.testing import absltest +from dm_control import mjcf +from dm_control.mjcf import schema + +_SCHEMA_PATH = os.path.join(os.path.dirname(__file__), 'schema.xml') + +# attributes whose defaults are not settable through PyMJCF descriptors +# (multi-token strings PyMJCF types more strictly than the runtime) +_DEFAULT_SWEEP_SKIP = set() + + +class SchemaTest(absltest.TestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.spec = schema.parse_schema(_SCHEMA_PATH) + + def test_namespace_universe_is_closed(self): + """Every reference namespace is populated by some identified element.""" + namespaces = schema.collect_namespaces(self.spec) + dangling = [] + + def visit(spec, seen): + if id(spec) in seen: + return + seen.add(id(spec)) + for attr_spec in spec.attributes.values(): + kwargs = getattr(attr_spec, 'other_kwargs', {}) or {} + ns = kwargs.get('reference_namespace') + if ns and not ns.startswith('attrib:') and ns not in namespaces: + dangling.append(f'{spec.name}.{attr_spec.name} -> {ns}') + for child in spec.children.values(): + visit(child, seen) + + visit(self.spec, set()) + self.assertEqual(dangling, []) + + def test_singleton_api_surface(self): + """The attribute-vs-list-view surface PyMJCF users rely on.""" + root = mjcf.RootElement(model='m') + # singletons are elements, directly usable + root.option.timestep = 0.001 + root.compiler.angle = 'radian' + root.visual.map.znear = 0.01 + self.assertTrue(hasattr(root.worldbody, 'add')) + # the top-level default is a singleton... + root.default.geom.margin = 0.001 + # ...whose nested defaults are repeated + root.default.add('default', dclass='a') + root.default.add('default', dclass='b') + # repeated elements are list views + root.worldbody.add('body', name='b1') + root.worldbody.add('body', name='b2') + self.assertLen(root.worldbody.body, 2) + + def test_full_pymjcf_exercise(self): + """Build, class, attach twice, bind, compile, round-trip.""" + arm = mjcf.RootElement(model='arm') + arm.default.joint.damping = 2.0 + strong = arm.default.add('default', dclass='strong') + strong.joint.damping = 5.0 + upper = arm.worldbody.add('body', name='upper') + upper.add('joint', name='shoulder', type='ball') + upper.add('geom', name='upper_geom', type='capsule', size=[.05], + fromto=[0, 0, 0, 0, 0, -.3]) + lower = upper.add('body', name='lower', pos=[0, 0, -.3]) + elbow = lower.add('joint', name='elbow', type='hinge', axis=[0, 1, 0], + dclass='strong') + lower.add('geom', name='lower_geom', type='capsule', size=[.04], + fromto=[0, 0, 0, 0, 0, -.25]) + tip = lower.add('site', name='tip', pos=[0, 0, -.25]) + arm.actuator.add('position', name='act', joint=elbow, kp=10) + arm.sensor.add('jointpos', name='sense', joint=elbow) + arm.contact.add('exclude', name='ex', body1='upper', body2='lower') + + hand = mjcf.RootElement(model='hand') + hand.worldbody.add('geom', name='palm', type='box', + size=[.02, .02, .02]) + tip.attach(hand) + + scene = mjcf.RootElement(model='scene') + scene.worldbody.add('geom', name='floor', type='plane', size=[1, 1, .1]) + scene.worldbody.add('light', pos=[0, 0, 3]) + scene.attach(arm) + + physics = mjcf.Physics.from_mjcf_model(scene) + self.assertEqual(physics.model.nu, 1) + self.assertGreater(physics.model.nq, 4) + # binding resolves through two attachment levels + self.assertEqual( + scene.find_all('sensor')[0].joint.full_identifier, 'arm/elbow') + bound = physics.bind(scene.find_all('joint')) + self.assertEqual(bound.qpos.shape[0], physics.model.nq) + # serialization is deterministic; PyMJCF output is not re-parseable by + # design (the '/' scope markers reject), so the parser direction is + # exercised on ordinary MJCF instead + self.assertEqual(scene.to_xml_string(), scene.to_xml_string()) + plain = mjcf.from_xml_string(""" + + + + + + + + + + + + """) + self.assertEqual(plain.find('geom', 'g').dclass.dclass, 'soft') + plain_physics = mjcf.Physics.from_mjcf_model(plain) + self.assertEqual(plain_physics.model.nu, 1) + + def test_every_declared_default_validates(self): + """Setting each attribute to its declared default passes validation. + + The schema file carries default= metadata; PyMJCF's descriptors + validate on assignment, so this sweeps every keyword set, arity and + numeric type through dm_control's own machinery. + """ + tree = ET.parse(_SCHEMA_PATH) + + def constructible(spec_node, path): + """Depth-first: yield (path, element node) below construction roots.""" + for child in spec_node.findall('./children/element'): + name = child.get('name') + yield path + (name,), child + yield from constructible(child, path + (name,)) + + root = mjcf.RootElement(model='sweep') + checked, failures = 0, [] + for path, node in constructible(tree.getroot(), ()): + for attr in node.findall('./attributes/attribute'): + default = attr.get('default') + if default is None: + continue + if (path[-1], attr.get('name')) in _DEFAULT_SWEEP_SKIP: + continue + # walk to the element, constructing along the way where possible + parent = root + ok = True + for tag in path: + existing = getattr(parent, tag, None) + if isinstance(existing, mjcf.Element) or ( + existing is not None + and type(existing).__name__ == '_ElementImpl'): + parent = existing + continue + try: + parent = parent.add(tag) + except Exception: # pylint: disable=broad-exception-caught + ok = False + break + if not ok: + continue + try: + setattr(parent, attr.get('name'), default) + checked += 1 + except Exception as e: # pylint: disable=broad-exception-caught + failures.append(f'{"/".join(path)}@{attr.get("name")}=' + f'{default!r}: {e}') + self.assertGreater(checked, 200) + if failures: + self.fail(f'{len(failures)} defaults rejected by PyMJCF:\n' + + '\n'.join(failures[:20])) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_control/mjcf/test_assets/model_with_assetdir.xml b/dm_control/mjcf/test_assets/model_with_assetdir.xml new file mode 100644 index 00000000..ff2ef00c --- /dev/null +++ b/dm_control/mjcf/test_assets/model_with_assetdir.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dm_control/mjcf/test_assets/model_with_assetdir.zip b/dm_control/mjcf/test_assets/model_with_assetdir.zip new file mode 100644 index 00000000..43b204ae Binary files /dev/null and b/dm_control/mjcf/test_assets/model_with_assetdir.zip differ diff --git a/dm_control/mjcf/test_assets/model_with_separators.xml b/dm_control/mjcf/test_assets/model_with_separators.xml new file mode 100644 index 00000000..4e35ea5c --- /dev/null +++ b/dm_control/mjcf/test_assets/model_with_separators.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dm_control/mjcf/test_assets/robot_arm.xml b/dm_control/mjcf/test_assets/robot_arm.xml index 6ebf82ad..500ae4c1 100644 --- a/dm_control/mjcf/test_assets/robot_arm.xml +++ b/dm_control/mjcf/test_assets/robot_arm.xml @@ -141,6 +141,8 @@ + + diff --git a/dm_control/mjcf/xml_validation_test.py b/dm_control/mjcf/xml_validation_test.py index cf35220e..58a0ad42 100644 --- a/dm_control/mjcf/xml_validation_test.py +++ b/dm_control/mjcf/xml_validation_test.py @@ -25,9 +25,10 @@ _ARENA_XML = os.path.join(ASSETS_DIR, 'arena.xml') _LEGO_BRICK_XML = os.path.join(ASSETS_DIR, 'lego_brick.xml') _ROBOT_XML = os.path.join(ASSETS_DIR, 'robot_arm.xml') +_ZIPPED_MODEL = os.path.join(ASSETS_DIR, 'model_with_assetdir.zip') -def validate(xml_string): +def validate(xml_string, assets=None): """Validates that an XML string is a valid MJCF. Validation is performed by constructing Mujoco model from the string. @@ -36,9 +37,10 @@ def validate(xml_string): Args: xml_string: XML string to validate + assets: Optional dict of assets to use for the model. """ - mjmodel = wrapper.MjModel.from_xml_string(xml_string) + mjmodel = wrapper.MjModel.from_xml_string(xml_string, assets) wrapper.MjData(mjmodel) @@ -61,6 +63,10 @@ def testXmlAttach(self): # validate validate(arena.to_xml_string()) + def testXmlFromZip(self): + model = parser.from_zip(_ZIPPED_MODEL) + validate(model.to_xml_string(), model.get_assets()) + if __name__ == '__main__': absltest.main() diff --git a/dm_control/mujoco/__init__.py b/dm_control/mujoco/__init__.py index e7dbbb42..6353686a 100644 --- a/dm_control/mujoco/__init__.py +++ b/dm_control/mujoco/__init__.py @@ -18,6 +18,7 @@ from dm_control.mujoco.engine import action_spec from dm_control.mujoco.engine import Camera +from dm_control.mujoco.engine import CameraMatrices from dm_control.mujoco.engine import MovableCamera from dm_control.mujoco.engine import Physics from dm_control.mujoco.engine import TextOverlay diff --git a/dm_control/mujoco/engine.py b/dm_control/mujoco/engine.py index afe0cf35..3ed93928 100644 --- a/dm_control/mujoco/engine.py +++ b/dm_control/mujoco/engine.py @@ -232,35 +232,57 @@ def render( camera._scene.free() # pylint: disable=protected-access return image - def get_state(self): + def get_state(self, sig=None): """Returns the physics state. + Args: + sig: Optional integer, if specified then the returned array corresponds to + the state obtained by calling `mj_getState` with `sig`. + Returns: NumPy array containing full physics simulation state. """ - return np.concatenate(self._physics_state_items()) + if sig is None: + return np.concatenate(self._physics_state_items()) + else: + retval = np.empty(mujoco.mj_stateSize(self.model.ptr, sig), np.float64) + mujoco.mj_getState(self.model.ptr, self.data.ptr, retval, sig) + return retval - def set_state(self, physics_state): + def set_state(self, physics_state, sig=None): """Sets the physics state. Args: physics_state: NumPy array containing the full physics simulation state. + sig: Optional integer, if specified then physics_state is passed directly + to `mj_setState` with `sig`. Raises: ValueError: If `physics_state` has invalid size. """ - state_items = self._physics_state_items() - - expected_shape = (sum(item.size for item in state_items),) - if expected_shape != physics_state.shape: - raise ValueError('Input physics state has shape {}. Expected {}.'.format( - physics_state.shape, expected_shape)) - - start = 0 - for state_item in state_items: - size = state_item.size - np.copyto(state_item, physics_state[start:start + size]) - start += size + if sig is None: + state_items = self._physics_state_items() + + expected_shape = (sum(item.size for item in state_items),) + if expected_shape != physics_state.shape: + raise ValueError( + 'Input physics state has shape {}. Expected {}.'.format( + physics_state.shape, expected_shape + ) + ) + + start = 0 + for state_item in state_items: + size = state_item.size + np.copyto(state_item, physics_state[start:start + size]) + start += size + else: + mujoco.mj_setState( + self.model.ptr, + self.data.ptr, + np.asarray(physics_state, np.float64), + sig, + ) def copy(self, share_model=False): """Creates a copy of this `Physics` instance. @@ -417,7 +439,8 @@ def free(self): with self._contexts_lock: if self._contexts: self._free_rendering_contexts() - del self._data + if hasattr(self, '_data'): + del self._data @classmethod def from_model(cls, model): @@ -920,7 +943,7 @@ def render( image = self._rgb_buffer # The first row in the buffer is the bottom row of pixels in the image. - return np.flipud(image) + return self._physics.contexts.gl.to_pixels(image) def select(self, cursor_position): """Returns bodies and geoms visible at given coordinates in the frame. diff --git a/dm_control/mujoco/engine_test.py b/dm_control/mujoco/engine_test.py index 0d9ff7cf..5471eee0 100644 --- a/dm_control/mujoco/engine_test.py +++ b/dm_control/mujoco/engine_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for `engine`.""" - import copy import pickle @@ -202,6 +200,10 @@ def testRenderFlags(self): np.all(disabled == enabled), msg='Images are identical regardless of whether wireframe is enabled.') + def testFreeIdempotent(self): + self._physics.free() + self._physics.free() + @parameterized.parameters(((0.5, 0.5), (1, 3)), # pole ((0.5, 0.1), (0, 0)), # ground ((0.9, 0.9), (None, None)), # sky @@ -368,7 +370,7 @@ def testNamedViews(self): self.assertEqual(0., self._physics.time()) self.assertEqual(0.01, self._physics.timestep()) - def testSetGetPhysicsState(self): + def testSetGetPhysicsStateLegacy(self): physics_state = self._physics.get_state() # qpos, qvel, act @@ -382,38 +384,49 @@ def testSetGetPhysicsState(self): np.testing.assert_allclose(new_physics_state, self._physics.get_state()) + def testSetGetPhysicsState(self): + actual_physics_state = self._physics.get_state( + sig=mujoco.mjtState.mjSTATE_FULLPHYSICS.value + ) + expected_physics_state = np.zeros(mujoco.mj_stateSize( + self._physics.model.ptr, mujoco.mjtState.mjSTATE_FULLPHYSICS.value + )) + mujoco.mj_getState(self._physics.model.ptr, self._physics.data.ptr, + expected_physics_state, + mujoco.mjtState.mjSTATE_FULLPHYSICS.value) + np.testing.assert_array_equal(expected_physics_state, actual_physics_state) + + new_physics_state = np.random.random_sample(expected_physics_state.shape) + self._physics.set_state( + new_physics_state, sig=mujoco.mjtState.mjSTATE_FULLPHYSICS.value + ) + np.testing.assert_array_equal( + new_physics_state, + self._physics.get_state(sig=mujoco.mjtState.mjSTATE_FULLPHYSICS.value), + ) + def testSetGetPhysicsStateWithPlugin(self): - # Model copied from mujoco/test/plugin/elasticity/elasticity_test.cc - model_with_cable_plugin = """ + model_with_pid_plugin = """ - """ - physics = engine.Physics.from_xml_string(model_with_cable_plugin) + physics = engine.Physics.from_xml_string(model_with_pid_plugin) physics_state = physics.get_state() # qpos, qvel, act, plugin_state @@ -526,7 +539,10 @@ def assert_warning(): @parameterized.named_parameters( ('_copy', lambda x: x.copy()), ('_deepcopy', copy.deepcopy), - ('_pickle_and_unpickle', lambda x: pickle.loads(pickle.dumps(x))), + ( + '_pickle_and_unpickle', + lambda x: pickle.loads(pickle.dumps(x)), # pylint: disable=g-unsafe-pickle-load + ), ) def testCopyOrPicklePhysics(self, func): for _ in range(10): @@ -550,7 +566,10 @@ def testCopyOrPicklePhysics(self, func): @parameterized.named_parameters( ('_copy', lambda x: x.copy()), - ('_pickle_and_unpickle', lambda x: pickle.loads(pickle.dumps(x))), + ( + '_pickle_and_unpickle', + lambda x: pickle.loads(pickle.dumps(x)), # pylint: disable=g-unsafe-pickle-load + ), ) def testSuppressErrorsAfterCopyOrPicklePhysics(self, func): # Regression test for a problem that used to exist where diff --git a/dm_control/mujoco/index.py b/dm_control/mujoco/index.py index 09687d46..76e534cc 100644 --- a/dm_control/mujoco/index.py +++ b/dm_control/mujoco/index.py @@ -219,11 +219,17 @@ def _get_size_name_to_element_names(model): # For example, the element names for "nv" axis come from "njnt". for size_name, address_field_name in _RAGGED_ADDRS.items(): donor = 'n' + address_field_name.split('_')[0] - if donor == 'nactuator': - donor = 'nu' if donor in size_name_to_element_names: size_name_to_element_names[size_name] = size_name_to_element_names[donor] + # nu (controls) and nout (force outputs) inherit actuator names from + # nactuator. For SISO models nu == nactuator == nout; for MIMO models this + # gives each control/output the name of its owning actuator. + if 'nactuator' in size_name_to_element_names: + for alias in ('nu', 'nout'): + size_name_to_element_names[alias] = ( + size_name_to_element_names['nactuator']) + # Mocap bodies are a special subset of bodies. mocap_body_names = [None] * model.nmocap for body_id, body_name in enumerate(size_name_to_element_names['nbody']): @@ -300,7 +306,14 @@ def _is_name_pointer(field_name): def _get_size_name(field_name, struct_name='mjmodel'): # Look up size name in metadata. - return sizes.array_sizes[struct_name][field_name][0] + try: + return sizes.array_sizes[struct_name][field_name][0] + except KeyError: + # Special handling required for name pointers in mjModel. + if _is_name_pointer(field_name): + return 'n' + field_name.split('_')[1][:-3] + else: + raise def _validate_key_item(key_item): @@ -355,7 +368,7 @@ def convert_key_item(self, key_item): elif isinstance(key_item, (list, np.ndarray)): # Cast lists to numpy arrays. - key_item = np.array(key_item, copy=False) + key_item = np.asarray(key_item) original_shape = key_item.shape # We assume that either all or none of the items in the array are strings diff --git a/dm_control/mujoco/index_test.py b/dm_control/mujoco/index_test.py index 4f13d8aa..3eaeae53 100644 --- a/dm_control/mujoco/index_test.py +++ b/dm_control/mujoco/index_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for index.""" - import collections from absl.testing import absltest @@ -34,10 +32,10 @@ FIELD_REPR = { 'act': ('FieldIndexer(act):\n' '(empty)'), - 'qM': ('FieldIndexer(qM):\n' - '0 [ 0 ]\n' - '1 [ 1 ]\n' - '2 [ 2 ]'), + 'qLD': ('FieldIndexer(qLD):\n' + '0 [ 0 ]\n' + '1 [ 1 ]\n' + '2 [ 2 ]'), 'sensordata': ('FieldIndexer(sensordata):\n' '0 accelerometer [ 0 ]\n' '1 accelerometer [ 1 ]\n' @@ -52,6 +50,12 @@ '4 mocap2 [ 12 13 14 ]'), } +# A test below accesses all attributes of np.ndarray, but accessing some of them +# triggers an exception under numpy>=2.0. +_DEPRECATED_NDARRAY_ATTRIBUTES = { + 'itemset', 'newbyteorder', 'ptp' +} + class MujocoIndexTest(parameterized.TestCase): @@ -264,7 +268,7 @@ def testBadIndexItems(self, bad_index_item, exception_regexp): with self.assertRaisesRegex(IndexError, exception_regexp): _ = indexer[expression] - @parameterized.parameters('act', 'qM', 'sensordata', 'xpos') # field name + @parameterized.parameters('act', 'qLD', 'sensordata', 'xpos') # field name def testFieldIndexerRepr(self, field_name): indexer = getattr(self._data_indexers, field_name) @@ -291,6 +295,7 @@ def testBuildIndexersForEdgeCases(self, xml_string): (name, name) for name in dir(np.ndarray) if not name.startswith('_') # Exclude 'private' attributes and name not in ('ctypes', 'flat') # Can't compare via identity/equality + and name not in _DEPRECATED_NDARRAY_ATTRIBUTES ]) # pylint: enable=undefined-variable def testFieldIndexerDelegatesNDArrayAttributes(self, name): diff --git a/dm_control/mujoco/render_test.py b/dm_control/mujoco/render_test.py index 878ca5d2..f1babc66 100644 --- a/dm_control/mujoco/render_test.py +++ b/dm_control/mujoco/render_test.py @@ -29,6 +29,10 @@ DEBUG_IMAGE_DIR = os.environ.get('TEST_UNDECLARED_OUTPUTS_DIR', absltest.get_default_test_tmpdir()) +# TODO(nimrod): Reduce the tolerance if we can figure out why the tests +# fail. +_RMS_TOLERANCE = 80.0 + # Context creation with GLFW is not threadsafe. if _render.BACKEND == 'glfw': # On Linux we are able to create a GLFW window in a single thread that is not @@ -49,7 +53,9 @@ class RenderTest(parameterized.TestCase): calls_per_thread=CALLS_PER_THREAD) def test_render(self, sequence): for expected, actual in zip(sequence.iter_load(), sequence.iter_render()): - image_utils.assert_images_close(expected, actual) + image_utils.assert_images_close( + expected, actual, tolerance=_RMS_TOLERANCE + ) @decorators.run_threaded(num_threads=NUM_THREADS, calls_per_thread=CALLS_PER_THREAD) @@ -65,10 +71,15 @@ def test_render_multiple_physics_per_thread(self): humanoid_frames.append(humanoid_frame) for expected, actual in zip(cartpole.iter_load(), cartpole_frames): - image_utils.assert_images_close(expected, actual) + + image_utils.assert_images_close( + expected, actual, tolerance=_RMS_TOLERANCE + ) for expected, actual in zip(humanoid.iter_load(), humanoid_frames): - image_utils.assert_images_close(expected, actual) + image_utils.assert_images_close( + expected, actual, tolerance=_RMS_TOLERANCE + ) @decorators.run_threaded(num_threads=NUM_THREADS, calls_per_thread=1) def test_repeatedly_create_and_destroy_rendering_contexts(self): diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_000.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_000.png index 713e3846..2be3bcf0 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_000.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_001.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_001.png index f0821a1c..ed6cd531 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_001.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_002.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_002.png index 0a4d932a..972b3c54 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_002.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_003.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_003.png index 2d4732db..1a947fac 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_003.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_004.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_004.png index 066e6122..4e023fa8 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_004.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_005.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_005.png index 2a4bbdb8..89534896 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_005.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_006.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_006.png index e0fc06cb..0ffbd8ae 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_006.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_007.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_007.png index cb61cb1e..0bfacd79 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_007.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_008.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_008.png index fb5491a9..56443e80 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_008.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_009.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_009.png index e10ca18c..82644f2c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_009.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_010.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_010.png index 213f9107..e98da76f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_010.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_011.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_011.png index 8d94957d..c43e583d 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_011.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_012.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_012.png index a7848556..881498ae 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_012.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_013.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_013.png index 3d8b6a76..89557d75 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_013.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_014.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_014.png index 4c80ef2d..542933a9 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_014.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_015.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_015.png index 6f259f94..9c8a2725 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_015.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_016.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_016.png index 850a94c6..49ef0272 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_016.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_017.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_017.png index 6f391e95..d9f60db3 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_017.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_018.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_018.png index b9e58793..cad6d447 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_018.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_019.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_019.png index 206f9c3e..58e2a092 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_019.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_hardware/frame_019.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_000.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_000.png index 92b64b2a..08a39725 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_000.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_001.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_001.png index 7e0e6e55..befbba45 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_001.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_002.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_002.png index bb07aaa9..965d205d 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_002.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_003.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_003.png index eff50b15..158f6962 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_003.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_004.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_004.png index 48b5e9db..41ee1254 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_004.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_005.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_005.png index 2ae9f212..5b206d5b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_005.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_006.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_006.png index 6928ac60..ad29a206 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_006.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_007.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_007.png index 87b57944..cd21aa53 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_007.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_008.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_008.png index f7cbc3ee..972c6998 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_008.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_009.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_009.png index d97e98c8..b3850dbe 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_009.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_010.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_010.png index 1d553b95..00f5ada0 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_010.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_011.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_011.png index e97f3f24..bb5b16b1 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_011.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_012.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_012.png index f2af5515..7edd7f65 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_012.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_013.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_013.png index 2ec877e3..0b1495ca 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_013.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_014.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_014.png index 31c4b331..e1ee34e1 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_014.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_015.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_015.png index 9ce60f12..68579cec 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_015.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_016.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_016.png index ffabf312..30c52c43 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_016.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_017.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_017.png index 02204cdf..af582f8b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_017.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_018.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_018.png index bfb5a32a..60df4f85 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_018.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_019.png b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_019.png index a19c9e36..e16e467c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_019.png and b/dm_control/mujoco/testing/assets/frames/cartpole_seed_0_camera_0_320x240_software/frame_019.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_000.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_000.png index dc42c646..b694c9f7 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_000.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_001.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_001.png index be526cdb..b39607e1 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_001.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_002.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_002.png index 41eb126e..9700bdb0 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_002.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_003.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_003.png index 9e2a9439..422cc9bc 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_003.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_004.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_004.png index 1e70d83b..3a59a33b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_004.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_005.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_005.png index 9a04b6ed..4f7ec634 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_005.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_006.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_006.png index a317f91a..158faa3a 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_006.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_007.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_007.png index 90e1efbe..c4e2791c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_007.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_008.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_008.png index 19af6b92..52f77e97 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_008.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_009.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_009.png index 6af061cb..3d3b91cd 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_009.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_010.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_010.png index 3955fe06..44d94e53 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_010.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_011.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_011.png index 4b087d8c..f29d5bd6 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_011.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_012.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_012.png index bf5ef5c5..fef1c098 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_012.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_013.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_013.png index b672743f..b63f202d 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_013.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_014.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_014.png index e9b42342..0488a52b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_014.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_015.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_015.png index dd08652e..01d3c8a5 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_015.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_016.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_016.png index 51745857..fcc8efee 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_016.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_017.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_017.png index c2b0c9b1..f7f53125 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_017.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_018.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_018.png index d8a247c5..629b97a4 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_018.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_019.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_019.png index 168e99b0..30988d80 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_019.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware/frame_019.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_000.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_000.png index a82648ce..ad954046 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_000.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_001.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_001.png index 6f8692e2..8bdac38b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_001.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_002.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_002.png index 4e0c0953..8ecb9d00 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_002.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_003.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_003.png index 796c1f8a..0d302374 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_003.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_004.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_004.png index a1ed7446..35256f01 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_004.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_005.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_005.png index 82346970..e92ce9e3 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_005.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_006.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_006.png index 1042482b..663f1b6f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_006.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_007.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_007.png index 25691e6d..e8020522 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_007.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_008.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_008.png index 3170d909..a9ed3454 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_008.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_009.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_009.png index 41bac4c4..7f5a4bc1 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_009.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_010.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_010.png index a9af23c7..3d6baa1b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_010.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_011.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_011.png index 6d33c8b5..396fe5db 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_011.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_012.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_012.png index ddb87d40..ab3bdde6 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_012.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_013.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_013.png index 3d2035b4..e598a640 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_013.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_014.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_014.png index f03da961..a366371b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_014.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_015.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_015.png index a6457c73..92527a1f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_015.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_016.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_016.png index 54048d44..6d935c7f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_016.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_017.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_017.png index c5d29ab9..25354c83 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_017.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_018.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_018.png index 742fdc6c..b3203907 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_018.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_019.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_019.png index d85013f1..0dc9dd56 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_019.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_hardware_reflection_False_shadow_False/frame_019.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_000.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_000.png index 9b90b7fd..d2c899e4 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_000.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_001.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_001.png index f9a58b90..a6782e0c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_001.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_002.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_002.png index 7e6c94d6..309d219f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_002.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_003.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_003.png index d03dbd65..95f05463 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_003.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_004.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_004.png index a0b85709..24a6debd 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_004.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_005.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_005.png index a37285a7..e24674ca 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_005.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_006.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_006.png index b7d53aa5..2bf8eddd 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_006.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_007.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_007.png index cdfa37c7..b227e3a7 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_007.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_008.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_008.png index 31257855..9a60accf 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_008.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_009.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_009.png index e7cbad46..f4e006bb 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_009.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_010.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_010.png index d3aaeaf0..854116b5 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_010.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_011.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_011.png index 1fd01bb0..1d0da5bb 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_011.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_012.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_012.png index 8219ff8a..f3ac87a9 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_012.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_013.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_013.png index 00fe42a3..8c2c04df 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_013.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_014.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_014.png index fcec938d..78ffc66a 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_014.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_015.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_015.png index 7f95ea81..693b4af2 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_015.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_016.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_016.png index a2417db9..fe0a1317 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_016.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_017.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_017.png index 2fdea0aa..894e6603 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_017.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_018.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_018.png index 8a5b7afd..e47d0440 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_018.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_019.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_019.png index ed6108fe..9cd207eb 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_019.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software/frame_019.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_000.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_000.png index 784b2b2d..d6c041ec 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_000.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_001.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_001.png index e5ad70d6..2018b027 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_001.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_002.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_002.png index 91dbda1c..c6a43a70 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_002.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_003.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_003.png index e2ee9731..16bddc97 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_003.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_004.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_004.png index 1db18840..5d34f178 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_004.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_005.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_005.png index 219e8ce0..a5dbbc1f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_005.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_006.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_006.png index 1f064611..68a4e02c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_006.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_007.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_007.png index 86185870..36ba4a48 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_007.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_008.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_008.png index e6c36640..71ee634c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_008.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_009.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_009.png index a25cf1c8..e4761262 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_009.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_010.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_010.png index 9e8b3c10..8d55e88e 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_010.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_011.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_011.png index 5f5f1371..3dc59194 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_011.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_012.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_012.png index 161c29c3..886e419c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_012.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_013.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_013.png index 9b71a45a..66bcb14d 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_013.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_014.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_014.png index b2c2b4e5..d64e91d9 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_014.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_015.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_015.png index a928f36b..d4777720 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_015.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_016.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_016.png index eab43ef5..f76fc0e2 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_016.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_017.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_017.png index f9bb6d06..5da866d5 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_017.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_018.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_018.png index 03177dfb..1d457b06 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_018.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_019.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_019.png index 04017889..14cadd45 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_019.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_0_240x320_software_reflection_False_shadow_False/frame_019.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_000.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_000.png index 543cdb68..07dc68ec 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_000.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_001.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_001.png index a8621191..fa4c25bb 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_001.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_002.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_002.png index 3caa305a..8d85e676 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_002.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_003.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_003.png index 327965b6..09e45240 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_003.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_004.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_004.png index 65f884bf..8007256b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_004.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_005.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_005.png index 52801101..9d3a9c55 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_005.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_006.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_006.png index 52afe79c..f91bb05c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_006.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_007.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_007.png index 3f6b7778..9bd1ffd7 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_007.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_008.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_008.png index 4456b495..0a6afee1 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_008.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_009.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_009.png index daea2ed4..1aedb053 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_009.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_010.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_010.png index a4f31c2f..e3eb1fad 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_010.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_011.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_011.png index a7a54ece..74bd51da 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_011.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_012.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_012.png index 434380ad..cbe75c47 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_012.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_013.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_013.png index 68e5a7e8..46aba828 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_013.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_014.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_014.png index ffe409ff..9212cbf8 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_014.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_015.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_015.png index 826c2a78..439a6788 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_015.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_016.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_016.png index 17d30e06..0b594214 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_016.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_017.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_017.png index 03fa4c3a..d83f0106 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_017.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_018.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_018.png index 7dba069c..314d717d 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_018.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_019.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_019.png index b59fada5..3ed2fe27 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_019.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_hardware/frame_019.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_000.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_000.png index 9a7a8809..b3e7dba1 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_000.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_000.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_001.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_001.png index 23fd0182..22deb2b2 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_001.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_001.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_002.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_002.png index 56d47254..065ed6af 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_002.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_002.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_003.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_003.png index 1ca8c9cb..86a32827 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_003.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_003.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_004.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_004.png index 9cb4a31d..a41d98ca 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_004.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_004.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_005.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_005.png index 3db431e5..8e9c58c2 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_005.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_005.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_006.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_006.png index 64dc27ef..b4722d9f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_006.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_006.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_007.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_007.png index 0c2c4b37..1d50a729 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_007.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_007.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_008.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_008.png index 36b50a05..6d3c5f0b 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_008.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_008.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_009.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_009.png index cda4b113..9e138569 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_009.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_009.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_010.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_010.png index 4a14e3e5..a9ece421 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_010.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_010.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_011.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_011.png index 0cfdf244..f8d3f5f5 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_011.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_011.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_012.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_012.png index 3d5afbe3..fe1ec34c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_012.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_012.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_013.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_013.png index 4dd62794..e5a5fe39 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_013.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_013.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_014.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_014.png index 58b0f10e..3b444c73 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_014.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_014.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_015.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_015.png index 9893f08c..a2fecb5f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_015.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_015.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_016.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_016.png index 005b5187..520f668c 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_016.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_016.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_017.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_017.png index c42c23ab..f92efec1 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_017.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_017.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_018.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_018.png index f4511dcc..c9b9bb5f 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_018.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_018.png differ diff --git a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_019.png b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_019.png index 792dd97c..a091934d 100644 Binary files a/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_019.png and b/dm_control/mujoco/testing/assets/frames/humanoid_seed_0_camera_head_track_64x64_software/frame_019.png differ diff --git a/dm_control/mujoco/testing/image_utils_test.py b/dm_control/mujoco/testing/image_utils_test.py index 0b42085c..d2401d70 100644 --- a/dm_control/mujoco/testing/image_utils_test.py +++ b/dm_control/mujoco/testing/image_utils_test.py @@ -13,8 +13,6 @@ # limitations under the License. # ============================================================================ -"""Tests for image_utils.""" - import os from absl.testing import absltest @@ -31,8 +29,9 @@ class ImageUtilsTest(parameterized.TestCase): @parameterized.parameters( dict(frame_index1=0, frame_index2=0, expected_rms=0.0), - dict(frame_index1=0, frame_index2=1, expected_rms=23.214), - dict(frame_index1=0, frame_index2=9, expected_rms=55.738)) + dict(frame_index1=0, frame_index2=1, expected_rms=22.285), + dict(frame_index1=0, frame_index2=9, expected_rms=55.559), + ) def test_compute_rms(self, frame_index1, frame_index2, expected_rms): # Force loading of the software rendering reference images regardless of the # actual GL backend, since these should match the expected RMS values. @@ -51,7 +50,8 @@ def test_assert_images_close(self): image2 = random_state.randint(0, 255, size=(64, 64, 3), dtype=np.uint8) image_utils.assert_images_close(image1, image1, tolerance=0) with self.assertRaisesRegex( # pylint: disable=g-error-prone-assert-raises - image_utils.ImagesNotCloseError, 'RMS error exceeds tolerance'): + image_utils.ImagesNotCloseError, 'RMS error exceeds tolerance' + ): image_utils.assert_images_close(image1, image2) def test_save_images_on_failure(self): @@ -66,8 +66,9 @@ def test_save_images_on_failure(self): def func(): raise image_utils.ImagesNotCloseError(message, image1, image2) - with self.assertRaisesRegex(image_utils.ImagesNotCloseError, - '{}.*'.format(message)): + with self.assertRaisesRegex( + image_utils.ImagesNotCloseError, '{}.*'.format(message) + ): func() def validate_saved_file(name, expected_contents): diff --git a/dm_control/mujoco/tutorial.ipynb b/dm_control/mujoco/tutorial.ipynb index 385133ce..e577e4c2 100644 --- a/dm_control/mujoco/tutorial.ipynb +++ b/dm_control/mujoco/tutorial.ipynb @@ -1,10 +1,10 @@ { "cells": [ { - "cell_type": "markdown", "metadata": { "id": "MpkYHwCqk7W-" }, + "cell_type": "markdown", "source": [ "# **MuJoCo** tutorial with `dm_control` Python bindings\n", "\n", @@ -20,52 +20,50 @@ ] }, { - "cell_type": "markdown", "metadata": { "id": "_UbO9uhtBSX5" }, + "cell_type": "markdown", "source": [ - "\u003e \u003cp\u003e\u003csmall\u003e\u003csmall\u003eCopyright 2021 The dm_control Authors.\u003c/small\u003e\u003c/p\u003e\n", - "\u003e \u003cp\u003e\u003csmall\u003e\u003csmall\u003eLicensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at \u003ca href=\"http://www.apache.org/licenses/LICENSE-2.0\"\u003ehttp://www.apache.org/licenses/LICENSE-2.0\u003c/a\u003e.\u003c/small\u003e\u003c/small\u003e\u003c/p\u003e\n", - "\u003e \u003cp\u003e\u003csmall\u003e\u003csmall\u003eUnless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.\u003c/small\u003e\u003c/small\u003e\u003c/p\u003e" + ">

Copyright 2021 The dm_control Authors.

\n", + ">

Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.

\n", + ">

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

" ] }, { - "cell_type": "markdown", "metadata": { "id": "aThGKGp0cD76" }, + "cell_type": "markdown", "source": [ "This notebook provides an overview tutorial of the **MuJoCo** physics simulator, using the `dm_control` Python bindings. It is similar to the notebook in `dm_control/tutorial.ipynb`, but focuses on teaching MuJoCo itself, rather than the additional features provided by the Python package. \n", "\n", - "**A Colab runtime with GPU acceleration is required.** If you're using a CPU-only runtime, you can switch using the menu \"Runtime \u003e Change runtime type\"." + "**A Colab runtime with GPU acceleration is required.** If you're using a CPU-only runtime, you can switch using the menu \"Runtime > Change runtime type\"." ] }, { - "cell_type": "markdown", "metadata": { "id": "YkBQUjm6gbGF" }, + "cell_type": "markdown", "source": [ "" ] }, { - "cell_type": "markdown", "metadata": { "id": "YvyGCsgSCxHQ" }, + "cell_type": "markdown", "source": [ "### Installing `dm_control` on Colab" ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "IbZxYDxzoz5R" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Run to install MuJoCo and `dm_control`\n", "import distutils.util\n", @@ -93,7 +91,7 @@ "\"\"\")\n", "\n", "print('Installing dm_control...')\n", - "!pip install -q dm_control\u003e=1.0.16\n", + "!pip install -q dm_control>=1.0.51\n", "\n", "# Configure dm_control to use the EGL rendering backend (requires GPU)\n", "%env MUJOCO_GL=egl\n", @@ -112,13 +110,15 @@ "else:\n", " del suite, pixels\n", "print('dm_control installation succeeded.')" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "wtDN43hIJh2C" }, + "cell_type": "markdown", "source": [ "# Imports\n", "\n", @@ -126,12 +126,10 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "T5f4w3Kq2X14" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title All `dm_control` imports required for this tutorial\n", "\n", @@ -165,15 +163,15 @@ "\n", "# Manipulation\n", "from dm_control import manipulation" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "gKc1FNhKiVJX" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Other imports and helper functions\n", "\n", @@ -235,23 +233,25 @@ "# Seed numpy's global RNG so that cell outputs are deterministic. We also try to\n", "# use RandomState instances that are local to a single cell wherever possible.\n", "np.random.seed(42)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "jZXz9rPYGA-Y" }, + "cell_type": "markdown", "source": [ "# Model definition, compilation and rendering\n", "\n" ] }, { - "cell_type": "markdown", "metadata": { "id": "MRBaZsf1d7Gb" }, + "cell_type": "markdown", "source": [ "We begin by describing some basic concepts of the [MuJoCo](http://mujoco.org/) physics simulation library, but recommend the [official documentation](http://mujoco.org/book/) for details.\n", "\n", @@ -259,69 +259,67 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "ZS2utl59ZTsr" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title A static model {vertical-output: true}\n", "\n", "static_model = \"\"\"\n", - "\u003cmujoco\u003e\n", - " \u003cworldbody\u003e\n", - " \u003clight name=\"top\" pos=\"0 0 1\"/\u003e\n", - " \u003cgeom name=\"red_box\" type=\"box\" size=\".2 .2 .2\" rgba=\"1 0 0 1\"/\u003e\n", - " \u003cgeom name=\"green_sphere\" pos=\".2 .2 .2\" size=\".1\" rgba=\"0 1 0 1\"/\u003e\n", - " \u003c/worldbody\u003e\n", - "\u003c/mujoco\u003e\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n", "\"\"\"\n", "physics = mujoco.Physics.from_xml_string(static_model)\n", "pixels = physics.render()\n", "PIL.Image.fromarray(pixels)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "p4vPllljTJh8" }, + "cell_type": "markdown", "source": [ "`static_model` is written in MuJoCo's XML-based [MJCF](http://www.mujoco.org/book/modeling.html) modeling language. The `from_xml_string()` method invokes the model compiler, which instantiates the library's internal data structures. These can be accessed via the `physics` object, see below." ] }, { - "cell_type": "markdown", "metadata": { "id": "MdUF2UYmR4TA" }, + "cell_type": "markdown", "source": [ "## Adding DOFs and simulating, advanced rendering\n", "This is a perfectly legitimate model, but if we simulate it, nothing will happen except for time advancing. This is because this model has no degrees of freedom (DOFs). We add DOFs by adding **joints** to bodies, specifying how they can move with respect to their parents. Let us add a hinge joint and re-render, visualizing the joint axis." ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "R7zokzd_yeEg" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title A child body with a joint { vertical-output: true }\n", "\n", "swinging_body = \"\"\"\n", - "\u003cmujoco\u003e\n", - " \u003cworldbody\u003e\n", - " \u003clight name=\"top\" pos=\"0 0 1\"/\u003e\n", - " \u003cbody name=\"box_and_sphere\" euler=\"0 0 -30\"\u003e \n", - " \u003cjoint name=\"swing\" type=\"hinge\" axis=\"1 -1 0\" pos=\"-.2 -.2 -.2\"/\u003e\n", - " \u003cgeom name=\"red_box\" type=\"box\" size=\".2 .2 .2\" rgba=\"1 0 0 1\"/\u003e\n", - " \u003cgeom name=\"green_sphere\" pos=\".2 .2 .2\" size=\".1\" rgba=\"0 1 0 1\"/\u003e\n", - " \u003c/body\u003e\n", - " \u003c/worldbody\u003e\n", - "\u003c/mujoco\u003e\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n", "\"\"\"\n", "physics = mujoco.Physics.from_xml_string(swinging_body)\n", "# Visualize the joint axis.\n", @@ -329,13 +327,15 @@ "scene_option.flags[enums.mjtVisFlag.mjVIS_JOINT] = True\n", "pixels = physics.render(scene_option=scene_option)\n", "PIL.Image.fromarray(pixels)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "INOGGV0PTQus" }, + "cell_type": "markdown", "source": [ "The things that move (and which have inertia) are called *bodies*. The body's child `joint` specifies how that body can move with respect to its parent, in this case `box_and_sphere` with respect to the `worldbody`. \n", "\n", @@ -345,12 +345,10 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "Z_57VMUDpGrj" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Making a video {vertical-output: true}\n", "\n", @@ -364,28 +362,30 @@ "# Simulate and display video.\n", "frames = []\n", "physics.reset() # Reset state and time\n", - "while physics.data.time \u003c duration:\n", + "while physics.data.time < duration:\n", " physics.step()\n", - " if len(frames) \u003c physics.data.time * framerate:\n", + " if len(frames) < physics.data.time * framerate:\n", " pixels = physics.render(scene_option=scene_option)\n", " frames.append(pixels)\n", "display_video(frames, framerate)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "yYvS1UaciMX_" }, + "cell_type": "markdown", "source": [ "Note how we collect the video frames. Because physics simulation timesteps are generally much smaller than framerates (the default timestep is 2ms), we don't render after each step." ] }, { - "cell_type": "markdown", "metadata": { "id": "nQ8XOnRQx7T1" }, + "cell_type": "markdown", "source": [ "## Rendering options\n", "\n", @@ -393,12 +393,10 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "AQITZiIgx7T2" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Enable transparency and frame visualization {vertical-output: true}\n", "\n", @@ -407,15 +405,15 @@ "scene_option.flags[enums.mjtVisFlag.mjVIS_TRANSPARENT] = True\n", "pixels = physics.render(scene_option=scene_option)\n", "PIL.Image.fromarray(pixels)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "PDDgY48vx7T6" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Depth rendering {vertical-output: true}\n", "\n", @@ -424,19 +422,19 @@ "# Shift nearest values to the origin.\n", "depth -= depth.min()\n", "# Scale by 2 mean distances of near rays.\n", - "depth /= 2*depth[depth \u003c= 1].mean()\n", + "depth /= 2*depth[depth <= 1].mean()\n", "# Scale to [0, 255]\n", "pixels = 255*np.clip(depth, 0, 1)\n", "PIL.Image.fromarray(pixels.astype(np.uint8))" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "PNwiIrgpx7T8" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Segmentation rendering {vertical-output: true}\n", "\n", @@ -450,15 +448,15 @@ "geom_ids = geom_ids / geom_ids.max()\n", "pixels = 255*geom_ids\n", "PIL.Image.fromarray(pixels.astype(np.uint8))\n" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "uCJQlv3cQcJQ" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Projecting from world to camera coordinates {vertical-output: true}\n", "\n", @@ -467,7 +465,7 @@ "box_mat = physics.named.data.geom_xmat['red_box'].reshape(3, 3)\n", "box_size = physics.named.model.geom_size['red_box']\n", "offsets = np.array([-1, 1]) * box_size[:, None]\n", - "xyz_local = np.stack(itertools.product(*offsets)).T\n", + "xyz_local = np.stack(list(itertools.product(*offsets))).T\n", "xyz_global = box_pos[:, None] + box_mat @ xyz_local\n", "\n", "# Camera matrices multiply homogenous [x, y, z, 1] vectors.\n", @@ -491,15 +489,15 @@ "ax.imshow(pixels)\n", "ax.plot(x, y, '+', c='w')\n", "ax.set_axis_off()" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "R_jkaXd_9Eiu" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Adding arbitrary 3D geometry {vertical-output: true}\n", "\n", @@ -514,17 +512,16 @@ "\n", "def add_visual_capsule(scene, point1, point2, radius, rgba):\n", " \"\"\"Adds one capsule to an mjvScene.\"\"\"\n", - " if scene.ngeom \u003e= scene.maxgeom:\n", + " if scene.ngeom >= scene.maxgeom:\n", " return\n", " scene.ngeom += 1 # increment ngeom\n", - " # initialise a new capsule, add it to the scene using mjv_makeConnector\n", + " # initialise a new capsule, add it to the scene using mjv_connector\n", " mujoco.mjv_initGeom(scene.geoms[scene.ngeom-1],\n", " mujoco.mjtGeom.mjGEOM_CAPSULE, np.zeros(3),\n", " np.zeros(3), np.zeros(9), rgba.astype(np.float32))\n", - " mujoco.mjv_makeConnector(scene.geoms[scene.ngeom-1],\n", - " mujoco.mjtGeom.mjGEOM_CAPSULE, radius,\n", - " point1[0], point1[1], point1[2],\n", - " point2[0], point2[1], point2[2])\n", + " mujoco.mjv_connector(scene.geoms[scene.ngeom-1],\n", + " mujoco.mjtGeom.mjGEOM_CAPSULE, radius,\n", + " point1, point2)\n", "\n", " # traces of time, position and speed\n", "times = []\n", @@ -534,7 +531,7 @@ "\n", "def scene_callback(physics, scn):\n", " \"\"\"Draw position trace, speed modifies width and colours.\"\"\"\n", - " if len(positions) \u003e 1:\n", + " if len(positions) > 1:\n", " for i in range(len(positions)-1):\n", " rgba=np.array((np.clip(speeds[i]/10, 0, 1),\n", " np.clip(1-speeds[i]/10, 0, 1),\n", @@ -550,96 +547,98 @@ "# Simulate and display video.\n", "frames = []\n", "physics.reset() # Reset state and time\n", - "while physics.data.time \u003c duration:\n", + "while physics.data.time < duration:\n", " # append data to the traces\n", " positions.append(physics.named.data.geom_xpos[\"green_sphere\"].copy())\n", " times.append(physics.data.time)\n", " speeds.append(get_geom_speed(physics, \"green_sphere\"))\n", " physics.step()\n", - " if len(frames) \u003c physics.data.time * framerate:\n", + " if len(frames) < physics.data.time * framerate:\n", " camera = mujoco.Camera(physics, max_geom=10000,\n", " scene_callback=scene_callback)\n", " pixels = camera.render()\n", " frames.append(pixels)\n", "display_video(frames, framerate)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "gf9h_wi9weet" }, + "cell_type": "markdown", "source": [ "# MuJoCo basics and named indexing" ] }, { - "cell_type": "markdown", "metadata": { "id": "NCcZxrDDB1Cj" }, + "cell_type": "markdown", "source": [ "## `mjModel`\n", "MuJoCo's `mjModel`, encapsulated in `physics.model`, contains the *model description*, including the default initial state and other fixed quantities which are not a function of the state, e.g. the positions of geoms in the frame of their parent body. The (x, y, z) offsets of the `box` and `sphere` geoms, relative their parent body `box_and_sphere` are given by `model.geom_pos`:" ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "wx8NANvOF8g1" }, - "outputs": [], + "cell_type": "code", "source": [ "physics.model.geom_pos" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "v9O1VNdmHn_K" }, + "cell_type": "markdown", "source": [ "Docstrings of attributes provide short descriptions." ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "8_0MwaeYHn_N" }, - "outputs": [], + "cell_type": "code", "source": [ "help(type(physics.model).geom_pos)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "Wee5ATLtIQn_" }, + "cell_type": "markdown", "source": [ "The `model.opt` structure contains global quantities like" ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "BhzbZIfDIU2-" }, - "outputs": [], + "cell_type": "code", "source": [ "print('timestep', physics.model.opt.timestep)\n", "print('gravity', physics.model.opt.gravity)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "t5hY0fyXFLcf" }, + "cell_type": "markdown", "source": [ "## `mjData`\n", "`mjData`, encapsulated in `physics.data`, contains the **state** and quantities that depend on it. The state is made up of time, generalized positions and generalised velocities. These are respectively `data.time`, `data.qpos` and `data.qvel`. \n", @@ -648,41 +647,41 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "acwZtDwp9mQU" }, - "outputs": [], + "cell_type": "code", "source": [ "print(physics.data.time, physics.data.qpos, physics.data.qvel)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "7YlmcLcA-WQu" }, + "cell_type": "markdown", "source": [ "`physics.data` also contains **functions of the state**, for example the cartesian positions of objects in the world frame. The (x, y, z) positions of our two geoms are in `data.geom_xpos`:" ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "CPwDcAQ0-uUE" }, - "outputs": [], + "cell_type": "code", "source": [ "print(physics.data.geom_xpos)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "Z0UodCxS_v49" }, + "cell_type": "markdown", "source": [ "## Named indexing\n", "\n", @@ -690,92 +689,90 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "cLARcaK6-xCU" }, - "outputs": [], + "cell_type": "code", "source": [ "print(physics.named.data.geom_xpos)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "wgXOUZNZHIx6" }, + "cell_type": "markdown", "source": [ "Note how `model.geom_pos` and `data.geom_xpos` have similar semantics but very different meanings." ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "-cW61ClRHS8a" }, - "outputs": [], + "cell_type": "code", "source": [ "print(physics.named.model.geom_pos)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "-lQ0AChVASMv" }, + "cell_type": "markdown", "source": [ "Name strings can be used to index **into** the relevant quantities, making code much more readable and robust." ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "Rj4ad9fQAnFZ" }, - "outputs": [], + "cell_type": "code", "source": [ "physics.named.data.geom_xpos['green_sphere', 'z']" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "axr_p6APAzFn" }, + "cell_type": "markdown", "source": [ "Joint names can be used to index into quantities in joint space (beginning with the letter `q`):" ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "hluF9aDG9O1W" }, - "outputs": [], + "cell_type": "code", "source": [ "physics.named.data.qpos['swing']" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "3IhfyD2Q1pjv" }, + "cell_type": "markdown", "source": [ "We can mix NumPy slicing operations with named indexing. As an example, we can set the color of the box using its name (`\"red_box\"`) as an index into the rows of the `geom_rgba` array. " ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "f5vVUullUvWH" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title Changing colors using named indexing{vertical-output: true}\n", "\n", @@ -783,22 +780,24 @@ "physics.named.model.geom_rgba['red_box', :3] = random_rgb\n", "pixels = physics.render()\n", "PIL.Image.fromarray(pixels)" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "elzPPdq-KhLI" }, + "cell_type": "markdown", "source": [ "Note that while `physics.model` quantities will not be changed by the engine, we can change them ourselves between steps." ] }, { - "cell_type": "markdown", "metadata": { "id": "22ENjtVuhwsm" }, + "cell_type": "markdown", "source": [ "## Setting the state with `reset_context()`\n", "\n", @@ -806,12 +805,10 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "WBPprCtWgXFN" }, - "outputs": [], + "cell_type": "code", "source": [ "physics.named.data.qpos['swing'] = np.pi\n", "print('Without reset_context, spatial positions are not updated:',\n", @@ -820,13 +817,15 @@ " physics.named.data.qpos['swing'] = np.pi\n", "print('After reset_context, positions are up-to-date:',\n", " physics.named.data.geom_xpos['green_sphere', ['z']])" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "SHppAOjvSupc" }, + "cell_type": "markdown", "source": [ "## Free bodies: the self-inverting \"tippe-top\"\n", "\n", @@ -834,91 +833,89 @@ ] }, { - "cell_type": "code", - "execution_count": null, "metadata": { "id": "xasXQpVMjIwA" }, - "outputs": [], + "cell_type": "code", "source": [ "#@title The \"tippe-top\" model{vertical-output: true}\n", "\n", "tippe_top = \"\"\"\n", - "\u003cmujoco model=\"tippe top\"\u003e\n", - " \u003coption integrator=\"RK4\"/\u003e\n", - "\n", - " \u003casset\u003e\n", - " \u003ctexture name=\"grid\" type=\"2d\" builtin=\"checker\" rgb1=\".1 .2 .3\" \n", - " rgb2=\".2 .3 .4\" width=\"300\" height=\"300\"/\u003e\n", - " \u003cmaterial name=\"grid\" texture=\"grid\" texrepeat=\"8 8\" reflectance=\".2\"/\u003e\n", - " \u003c/asset\u003e\n", - "\n", - " \u003cworldbody\u003e\n", - " \u003cgeom size=\".2 .2 .01\" type=\"plane\" material=\"grid\"/\u003e\n", - " \u003clight pos=\"0 0 .6\"/\u003e\n", - " \u003ccamera name=\"closeup\" pos=\"0 -.1 .07\" xyaxes=\"1 0 0 0 1 2\"/\u003e\n", - " \u003cbody name=\"top\" pos=\"0 0 .02\"\u003e\n", - " \u003cfreejoint/\u003e\n", - " \u003cgeom name=\"ball\" type=\"sphere\" size=\".02\" /\u003e\n", - " \u003cgeom name=\"stem\" type=\"cylinder\" pos=\"0 0 .02\" size=\"0.004 .008\"/\u003e\n", - " \u003cgeom name=\"ballast\" type=\"box\" size=\".023 .023 0.005\" pos=\"0 0 -.015\" \n", - " contype=\"0\" conaffinity=\"0\" group=\"3\"/\u003e\n", - " \u003c/body\u003e\n", - " \u003c/worldbody\u003e\n", - " \n", - " \u003ckeyframe\u003e\n", - " \u003ckey name=\"spinning\" qpos=\"0 0 0.02 1 0 0 0\" qvel=\"0 0 0 0 1 200\" /\u003e\n", - " \u003c/keyframe\u003e\n", - "\u003c/mujoco\u003e\n", + "\n", + " \n", "\"\"\"\n", "physics = mujoco.Physics.from_xml_string(tippe_top)\n", "PIL.Image.fromarray(physics.render(camera_id='closeup'))" - ] + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", "metadata": { "id": "bvHlr6maJYIG" }, + "cell_type": "markdown", "source": [ "Note several new features of this model definition:\n", - "0. The free joint is added with the `\u003cfreejoint/\u003e` clause, which is similar to `\u003cjoint type=\"free\"/\u003e`, but prohibits unphysical attributes like friction or stiffness.\n", - "1. We use the `\u003coption/\u003e` clause to set the integrator to the more accurate Runge Kutta 4th order.\n", - "2. We define the floor's grid material inside the `\u003casset/\u003e` clause and reference it in the floor geom. \n", + "0. The free joint is added with the `` clause, which is similar to ``, but prohibits unphysical attributes like friction or stiffness.\n", + "1. We use the `