Source code for simulusion.envs.mujoco_env

from __future__ import annotations

import importlib
import importlib.util
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any

from simulusion.envs._actions import clip_box_action
from simulusion.envs._contract import finalize_step_info
from simulusion.envs._observations import add_optional_state_space
import gymnasium as gym
import numpy as np
from PIL import Image


from simulusion.envs.gym_env import GymImageEnv
from simulusion.envs.robotics_env import (
    make_gymnasium_env_with_robotics_fallback,
    register_gymnasium_robotics_envs,
)

RewardFn = Callable[[Any, Any, np.ndarray, dict[str, Any]], float]
TerminalFn = Callable[[Any, Any, dict[str, Any]], bool]


def _load_mujoco() -> Any:
    if "mujoco" in sys.modules:
        return sys.modules["mujoco"]
    if importlib.util.find_spec("mujoco") is None:
        raise ImportError(
            "The native MuJoCo bindings are required for MuJoCoImageEnv. "
            "Install them with `pip install mujoco` or `pip install simulusion[mujoco]`."
        )
    return importlib.import_module("mujoco")


def _validate_model_source(
    *,
    xml_path: str | Path | None,
    xml_string: str | None,
    binary_path: str | Path | None,
) -> None:
    sources = [xml_path is not None, xml_string is not None, binary_path is not None]
    if sum(sources) != 1:
        raise ValueError(
            "Provide exactly one of xml_path, xml_string, or binary_path for MuJoCoImageEnv."
        )


def _is_native_model_source(model: str | Path) -> bool:
    model_text = str(model)
    return (
        model_text.lstrip().startswith("<")
        or model_text.endswith((".xml", ".mjb"))
        or Path(model_text).exists()
    )


def _infer_model_source(model: str | Path) -> dict[str, str | Path]:
    model_text = str(model)
    if model_text.lstrip().startswith("<"):
        return {"xml_string": model_text}
    if model_text.endswith(".mjb"):
        return {"binary_path": model}
    return {"xml_path": model}


[docs] def make_mujoco_env_from_config(args: Any, size: tuple[int, int]) -> Any: """Build a MuJoCo image environment from a DreamerConfig-like object.""" native_kwargs = { "seed": args.seed, "size": size, "camera": getattr(args, "mujoco_camera", None), "frame_skip": int(getattr(args, "mujoco_frame_skip", 1)), "reset_noise_scale": float(getattr(args, "mujoco_reset_noise_scale", 0.0)), } if getattr(args, "mujoco_xml_string", None) is not None: return make_mujoco_env(xml_string=args.mujoco_xml_string, **native_kwargs) if getattr(args, "mujoco_binary_path", None) is not None: return make_mujoco_env(binary_path=args.mujoco_binary_path, **native_kwargs) if getattr(args, "mujoco_xml_path", None) is not None: return make_mujoco_env(xml_path=args.mujoco_xml_path, **native_kwargs) gym_kwargs = {} reset_noise_scale = native_kwargs["reset_noise_scale"] if reset_noise_scale != 0.0: gym_kwargs["reset_noise_scale"] = reset_noise_scale return make_mujoco_env( args.env, seed=args.seed, size=size, gym_kwargs=gym_kwargs, )
[docs] class MuJoCoImageEnv: """Native MuJoCo environment adapter for pixel-based world-model training. The adapter uses the low-level ``mujoco`` Python package directly: models are compiled from MJCF XML strings/files or MJB binaries via ``mujoco.MjModel``; simulation state lives in ``mujoco.MjData``; actions are written to ``data.ctrl``; and images are produced with ``mujoco.Renderer``. Observations follow Simulusion's Dreamer-style contract: a dict with required key ``"image"`` containing ``uint8[C, H, W]`` frames. Native MuJoCo models do not define task rewards or episode termination by themselves, so callers can supply ``reward_fn`` and ``terminal_fn`` callbacks. By default, rewards are ``0.0`` and episodes terminate only through external wrappers such as ``TimeLimit``. """ def __init__( self, xml_path: str | Path | None = None, *, xml_string: str | None = None, binary_path: str | Path | None = None, assets: dict[str, bytes] | None = None, seed: int = 0, size: tuple[int, int] = (64, 64), camera: str | int | None = None, reward_fn: RewardFn | None = None, terminal_fn: TerminalFn | None = None, frame_skip: int = 1, reset_noise_scale: float = 0.0, default_control_range: tuple[float, float] = (-1.0, 1.0), include_state: bool = False, ) -> None: _validate_model_source( xml_path=xml_path, xml_string=xml_string, binary_path=binary_path, ) self._mujoco = _load_mujoco() self._size = (int(size[0]), int(size[1])) self._camera = camera self._reward_fn = reward_fn self._terminal_fn = terminal_fn self._frame_skip = max(1, int(frame_skip)) self._reset_noise_scale = float(reset_noise_scale) self._rng = np.random.default_rng(seed) self._include_state = bool(include_state) self._closed = False if xml_string is not None: self.model = self._mujoco.MjModel.from_xml_string(xml_string, assets) elif binary_path is not None: self.model = self._mujoco.MjModel.from_binary_path(str(binary_path)) else: self.model = self._mujoco.MjModel.from_xml_path(str(xml_path)) self.data = self._mujoco.MjData(self.model) height, width = self._size self._renderer = self._mujoco.Renderer(self.model, height=height, width=width) state_space = None if self._include_state: state_dim = int(getattr(self.model, "nq", 0)) + int( getattr(self.model, "nv", 0) ) state_space = gym.spaces.Box( low=-np.inf, high=np.inf, shape=(state_dim,), dtype=np.float32, ) self._observation_space = add_optional_state_space( gym.spaces.Box( low=0, high=255, shape=(3, height, width), dtype=np.uint8, ), state_space=state_space, ) self._action_space = self._build_action_space(default_control_range) self.max_episode_steps = 1000 def _build_action_space( self, default_control_range: tuple[float, float] ) -> gym.spaces.Box: action_dim = int(getattr(self.model, "nu", 0)) if action_dim <= 0: return gym.spaces.Box( low=np.zeros((0,), dtype=np.float32), high=np.zeros((0,), dtype=np.float32), dtype=np.float32, ) ctrlrange = np.asarray( getattr(self.model, "actuator_ctrlrange", []), dtype=np.float32 ) limited = np.asarray( getattr(self.model, "actuator_ctrllimited", []), dtype=bool ) if ctrlrange.shape == (action_dim, 2) and limited.shape[0] == action_dim: default_low, default_high = default_control_range low = np.where(limited, ctrlrange[:, 0], float(default_low)).astype( np.float32 ) high = np.where(limited, ctrlrange[:, 1], float(default_high)).astype( np.float32 ) else: low = np.full( (action_dim,), float(default_control_range[0]), dtype=np.float32 ) high = np.full( (action_dim,), float(default_control_range[1]), dtype=np.float32 ) return gym.spaces.Box(low=low, high=high, dtype=np.float32) @property def observation_space(self) -> gym.spaces.Dict: return self._observation_space @property def action_space(self) -> gym.spaces.Box: return self._action_space def _vector_state(self) -> np.ndarray: return np.concatenate( [ np.asarray(getattr(self.data, "qpos", []), dtype=np.float32).reshape( -1 ), np.asarray(getattr(self.data, "qvel", []), dtype=np.float32).reshape( -1 ), ], axis=0, ) def _build_observation(self) -> dict[str, np.ndarray]: observation = {"image": self._render_chw()} if self._include_state: observation["state"] = self._vector_state().copy() return observation def _render_chw(self) -> np.ndarray: if self._camera is None: self._renderer.update_scene(self.data) else: self._renderer.update_scene(self.data, camera=self._camera) image = np.asarray(self._renderer.render()) if image.shape[:2] != self._size: image = np.asarray( Image.fromarray(image).resize( (self._size[1], self._size[0]), Image.Resampling.BILINEAR ) ) if image.ndim == 2: image = np.repeat(image[..., None], 3, axis=-1) if image.shape[-1] == 4: image = image[..., :3] return image.astype(np.uint8, copy=False).transpose(2, 0, 1).copy()
[docs] def reset(self, seed: int | None = None) -> dict[str, np.ndarray]: if seed is not None: self._rng = np.random.default_rng(seed) self._mujoco.mj_resetData(self.model, self.data) if self._reset_noise_scale > 0.0: if getattr(self.model, "nq", 0): self.data.qpos[:] += self._rng.normal( 0.0, self._reset_noise_scale, size=self.data.qpos.shape ) if getattr(self.model, "nv", 0): self.data.qvel[:] += self._rng.normal( 0.0, self._reset_noise_scale, size=self.data.qvel.shape ) self._mujoco.mj_forward(self.model, self.data) return self._build_observation()
[docs] def step( self, action: Any ) -> tuple[dict[str, np.ndarray], float, bool, dict[str, Any]]: clipped = clip_box_action( action, self.action_space.low, self.action_space.high, ) if clipped.size: self.data.ctrl[:] = clipped self._mujoco.mj_step(self.model, self.data, nstep=self._frame_skip) info = { "action": clipped.astype(np.float32, copy=True), "executed_action": clipped.astype(np.float32, copy=True), "time": float(getattr(self.data, "time", 0.0)), "qpos": np.asarray(getattr(self.data, "qpos", []), dtype=np.float64).copy(), "qvel": np.asarray(getattr(self.data, "qvel", []), dtype=np.float64).copy(), } info["vector_observation"] = self._vector_state().copy() if self._reward_fn is None: reward = 0.0 else: reward = float(self._reward_fn(self.model, self.data, clipped, info)) done = ( bool(self._terminal_fn(self.model, self.data, info)) if self._terminal_fn else False ) info = finalize_step_info( info, done=done, terminated=done, truncated=False, ) return self._build_observation(), reward, done, info
[docs] def render(self) -> Any: return self._render_chw().transpose(1, 2, 0).copy()
[docs] def close(self) -> None: if self._closed: return self._closed = True close = getattr(self._renderer, "close", None) if callable(close): close()
[docs] def make_mujoco_env( model: str | Path | None = None, *, backend: str = "auto", seed: int = 0, size: tuple[int, int] = (64, 64), render_mode: str = "rgb_array", gym_kwargs: dict[str, Any] | None = None, **kwargs: Any, ) -> GymImageEnv | MuJoCoImageEnv: """Create one MuJoCo image environment factory for tasks and MJCF/MJB models. Args: model: Either a Gymnasium MuJoCo task id such as ``"Humanoid-v4"``, an MJCF XML path/string, or an MJB binary path. backend: ``"auto"`` infers native vs Gymnasium task mode. Use ``"native"`` for MJCF/MJB, ``"gymnasium"`` for task ids, or ``"robotics"`` for Gymnasium Robotics registrations. seed: Seed forwarded to the image wrapper. size: Target ``(height, width)`` image size. render_mode: Render mode used for Gymnasium MuJoCo task ids. gym_kwargs: Optional keyword arguments forwarded to ``gymnasium.make`` in task-id mode. Extra ``**kwargs`` are also forwarded there. **kwargs: Native ``MuJoCoImageEnv`` options for MJCF/MJB mode, or environment-constructor options for Gymnasium task-id mode. Returns: A Simulusion image environment returning ``{"image": uint8[C, H, W]}``. """ backend = backend.lower() explicit_native_source = any( key in kwargs for key in ("xml_path", "xml_string", "binary_path") ) use_native = backend in {"native", "mjcf", "mjb"} or ( backend == "auto" and ( explicit_native_source or (model is not None and _is_native_model_source(model)) ) ) if use_native: if model is not None: kwargs.update( {k: v for k, v in _infer_model_source(model).items() if k not in kwargs} ) return MuJoCoImageEnv(seed=seed, size=size, **kwargs) if backend not in { "auto", "gym", "gymnasium", "task", "robotics", "gymnasium_robotics", }: raise ValueError( f"Unknown MuJoCo backend={backend!r}. Use 'auto', 'native', 'gymnasium', or 'robotics'." ) if model is None: raise ValueError( "A Gymnasium MuJoCo environment id, XML path/string, or MJB path is required." ) env_kwargs = dict(gym_kwargs or {}) env_kwargs.update(kwargs) if backend in {"robotics", "gymnasium_robotics"}: register_gymnasium_robotics_envs() env = make_gymnasium_env_with_robotics_fallback( str(model), render_mode=render_mode, gym_kwargs=env_kwargs, ) return GymImageEnv(env, seed=seed, size=size, render_mode=render_mode)