Rate this Page

Source code for torchrl.envs.libs.genesis

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

from __future__ import annotations

import importlib

import torch
from tensordict import TensorDict

from torchrl.data.tensor_specs import Categorical, Composite, Unbounded
from torchrl.data.utils import DEVICE_TYPING
from torchrl.envs.common import _EnvPostInit, EnvBase
from torchrl.envs.utils import _classproperty

_has_genesis = importlib.util.find_spec("genesis") is not None

__all__ = ["GenesisEnv", "GenesisWrapper"]


def _genesis_cleanup():
    """Clean up Genesis resources to free memory.

    Call this function to force cleanup of Genesis cached kernels and free up memory.
    This is useful when creating multiple Genesis environments in a long-running process.
    """
    if not _has_genesis:
        return
    import genesis as gs

    gs.destroy()


def _as_tensor(x, *, device, dtype=None) -> torch.Tensor:
    """Coerce a Genesis output to a ``torch.Tensor`` on ``device``."""
    if not isinstance(x, torch.Tensor):
        x = torch.as_tensor(x)
    if dtype is not None and x.dtype != dtype:
        x = x.to(dtype)
    if x.device != torch.device(device):
        x = x.to(device)
    return x


def _broadcast_to(x, shape, *, device, dtype) -> torch.Tensor:
    """Materialize ``x`` as a ``shape``-shaped tensor on ``device``/``dtype``.

    Scalars (or single-element tensors) are broadcast to ``shape``; tensors
    whose element count already matches are reshaped in-place. This lets the
    default, scalar-returning hooks (``_compute_reward``, ``_compute_done``)
    work transparently on batched envs.
    """
    t = _as_tensor(x, device=device, dtype=dtype)
    if t.numel() == 1:
        return torch.full(shape, t.item(), dtype=dtype, device=device)
    return t.reshape(shape)


def _genesis_backend_for_device(device: torch.device):
    """Map a ``torch.device`` to the matching Genesis backend constant."""
    import genesis as gs

    t = device.type
    if t == "cpu":
        return gs.cpu
    if t == "cuda":
        return gs.cuda
    if t == "mps":
        return gs.metal
    raise ValueError(
        f"No Genesis backend maps to torch device '{t}'. "
        f"Supported torch devices: 'cpu', 'cuda', 'mps'."
    )


[docs] class GenesisWrapper(EnvBase): """TorchRL wrapper around a Genesis physics scene. Genesis is a torch-native physics engine for general-purpose robotics and embodied AI. This wrapper keeps tensors on-device end-to-end: no numpy round-trips, no gym-style shims. Customization is done by subclassing and overriding the hooks below; ``GenesisWrapper(scene)`` works out of the box on any built scene with sensible defaults, so it's still useful for one-off experiments. Subclass hooks (all optional): * :meth:`_make_obs` -- read observations off ``self._scene`` and return a ``dict[str, Tensor]`` (or a single ``Tensor``). Default: for each entity with DoFs, emit ``{entity.name}_qpos`` and ``{entity.name}_qvel`` via :meth:`get_dofs_position` / :meth:`get_dofs_velocity`. * :meth:`_apply_action` -- push the agent's action into the scene before ``scene.step()`` is called. Default: feed ``action[..., :n_dofs]`` of the first actuated entity as a position target via :meth:`control_dofs_position`. * :meth:`_compute_reward` -- compute the per-step reward. Called once per physics substep when ``frame_skip > 1`` and the results are summed. Default: ``0``. * :meth:`_compute_done` -- compute the truncation flag. Default: ``self._current_step >= self._max_steps``. Args: scene (gs.Scene): a pre-built Genesis scene. max_steps (int, optional): truncation horizon consulted by the default :meth:`_compute_done`. Defaults to ``1000``. frame_skip (int, optional): physics steps per env step. Defaults to ``1``. from_pixels (bool, optional): if ``True``, render a ``pixels`` entry into the observation using the first camera on the scene. Cameras must be added via :meth:`scene.add_camera` **before** :meth:`scene.build` (Genesis cannot add cameras post-build); a missing camera raises :class:`ValueError`. Defaults to ``False``. pixels_key (str, optional): key under which the rendered frame is stored in the returned tensordict. Defaults to ``"pixels"``. device (torch.device, optional): torch device for returned tensors. When ``None`` (the default), inferred from ``gs.device`` — i.e. the device Genesis itself is running on. This avoids a silent per-step copy when the sim is on GPU but the wrapper was defaulting to CPU. batch_size (torch.Size, optional): batch size for the env. If omitted, inferred from ``scene.n_envs`` (``()`` for non-batched scenes, ``(n_envs,)`` for batched scenes). If provided, validated against ``scene.n_envs``; a mismatch raises :class:`ValueError`. allow_done_after_reset (bool, optional): passed through to :class:`~torchrl.envs.EnvBase`. Defaults to ``False``. Examples: Out-of-the-box use on an arbitrary scene:: >>> import genesis as gs >>> from torchrl.envs import GenesisWrapper >>> gs.init(backend=gs.cpu) >>> scene = gs.Scene(show_viewer=False) >>> scene.add_entity(gs.morphs.Plane()) >>> scene.add_entity( ... gs.morphs.MJCF(file="xml/franka_emika_panda/panda.xml") ... ) >>> scene.build() >>> env = GenesisWrapper(scene) >>> td = env.rollout(10) Subclassing for a custom task:: >>> class ReachEnv(GenesisWrapper): ... def __init__(self, scene, target, **kwargs): ... self._franka = scene.entities[1] # set before super().__init__ ... self._target = target ... super().__init__(scene, **kwargs) ... def _make_obs(self): ... return {"qpos": self._franka.get_dofs_position()} ... def _apply_action(self, action): ... self._franka.control_dofs_position(action) ... def _compute_reward(self, action): ... ee = self._franka.get_link("hand").get_pos() ... return -(ee - self._target).norm() """ git_url = "https://github.com/Genesis-Embodied-AI/Genesis" libname = "genesis" # Genesis loops over ``self._frame_skip`` physics substeps in its own # ``_step`` and sums the reward, so frame skipping is implemented natively # and the ``EnvBase`` metaclass must not auto-append a ``FrameSkipTransform``. _has_frame_skip: bool = True _lib = None @_classproperty def lib(cls): if cls._lib is not None: return cls._lib import genesis cls._lib = genesis return genesis def __init__( self, scene=None, max_steps: int = 1000, frame_skip: int = 1, from_pixels: bool = False, pixels_key: str = "pixels", device: DEVICE_TYPING | None = None, batch_size: torch.Size | None = None, allow_done_after_reset: bool = False, ): if scene is None: raise TypeError("GenesisWrapper requires a 'scene' argument.") if not hasattr(scene, "step"): raise TypeError("scene does not have a 'step' method.") if device is None: import genesis as gs device = getattr(gs, "device", None) or "cpu" n_envs = getattr(scene, "n_envs", 0) scene_batch_size = torch.Size([n_envs]) if n_envs else torch.Size([]) if batch_size is None: batch_size = scene_batch_size else: batch_size = torch.Size(batch_size) if batch_size != scene_batch_size: raise ValueError( f"batch_size={tuple(batch_size)} does not match the scene's " f"n_envs={n_envs} (expected batch_size=" f"{tuple(scene_batch_size)}). Rebuild the scene with " f"scene.build(n_envs={batch_size[0] if batch_size else 0}) " f"or drop the batch_size argument to auto-infer." ) super().__init__( device=device, batch_size=batch_size, allow_done_after_reset=allow_done_after_reset, ) self._scene = scene self._max_steps = max_steps self._frame_skip = frame_skip self._current_step = 0 self._from_pixels = from_pixels self._pixels_key = pixels_key self._camera = None if from_pixels: cameras = list(getattr(scene.visualizer, "cameras", [])) if not cameras: raise ValueError( "from_pixels=True but the scene has no camera. Add one " "before building the scene, e.g. " "`scene.add_camera(res=(320, 320))` before " "`scene.build()`. (Cameras cannot be added after build.)" ) self._camera = cameras[0] # Batched scenes need env_separate_rigid=True so cam.render() # returns a (n_envs, H, W, 3) stack instead of env-0 only. if n_envs: ctx = getattr(scene.visualizer, "_context", None) env_sep = bool(getattr(ctx, "env_separate_rigid", False)) rendered_idx = list(getattr(ctx, "rendered_envs_idx", None) or []) if not env_sep or len(rendered_idx) != n_envs: raise ValueError( f"from_pixels=True on a batched scene (n_envs={n_envs}) " f"requires VisOptions(env_separate_rigid=True) and " f"rendered_envs_idx covering all {n_envs} envs. The " f"scene has env_separate_rigid={env_sep}, " f"rendered_envs_idx={rendered_idx}. Construct the scene " f"with " f"`Scene(vis_options=gs.options.VisOptions(env_separate_rigid=True))`." ) self._make_specs() @_classproperty def available_envs(cls): if not _has_genesis: return [] return ["custom_scene"] # ------------------------------------------------------------------ # Subclass hooks # ------------------------------------------------------------------ def _make_obs(self) -> dict[str, torch.Tensor] | torch.Tensor: """Read observations from ``self._scene``. Override in a subclass for task-specific observations. The default emits per-entity DoF position and velocity. """ obs = {} for entity in self._scene.entities: if getattr(entity, "n_dofs", 0) > 0: obs[f"{entity.name}_qpos"] = entity.get_dofs_position() obs[f"{entity.name}_qvel"] = entity.get_dofs_velocity() if not obs: obs["empty_obs"] = torch.zeros(1) return obs def _apply_action(self, action: torch.Tensor) -> None: """Push ``action`` into ``self._scene`` before ``scene.step()``. The default applies ``action[..., :n_dofs]`` of the first entity with DoFs as a joint-position target. Override for velocity / force control or to address multiple entities. """ for entity in self._scene.entities: n = getattr(entity, "n_dofs", 0) if n > 0: entity.control_dofs_position(action[..., :n]) return def _compute_reward(self, action: torch.Tensor) -> torch.Tensor | float | None: """Per-substep reward. Override for task-specific reward. Called once per physics substep (``frame_skip`` times per env step); the wrapper sums the returned values. Return ``None`` to skip the contribution for this substep. The default returns ``0``. """ return 0.0 def _render_pixels(self) -> torch.Tensor: """Render the scene as an RGB tensor. Called when ``from_pixels=True``. The default renders ``self._camera`` (the first camera on the scene) and returns a ``torch.Tensor`` of shape ``(H, W, 3)`` uint8 on ``self.device``. Override for multi-camera / depth / segmentation. """ rgb, _, _, _ = self._camera.render() # Genesis's rasterizer returns arrays with negative strides (image # flipped). torch.from_numpy rejects those, so force contiguity. if rgb.strides[0] < 0 or rgb.strides[1] < 0: rgb = rgb.copy() return torch.from_numpy(rgb).to(self.device) def _compute_done(self) -> torch.Tensor | bool: """Per-env truncation flag. Override for early termination. Returned as ``truncated``; ``terminated`` is always ``False`` unless you override this to return a tensor that you've separately stashed under ``self`` (in which case, override :meth:`_step` too for full control). The default truncates at ``self._max_steps``. """ return self._current_step >= self._max_steps # ------------------------------------------------------------------ # Spec / TD machinery # ------------------------------------------------------------------ def _make_specs(self) -> None: dummy_obs = self._make_obs() if not isinstance(dummy_obs, dict): dummy_obs = {"observation": dummy_obs} obs_entries = {} for k, v in dummy_obs.items(): t = _as_tensor(v, device=self.device) event_shape = t.shape[len(self.batch_size) :] obs_entries[k] = Unbounded( shape=(*self.batch_size, *event_shape), dtype=t.dtype if t.is_floating_point() else torch.float32, device=self.device, ) if self._from_pixels: pix = self._render_pixels() event_shape = pix.shape[len(self.batch_size) :] obs_entries[self._pixels_key] = Unbounded( shape=(*self.batch_size, *event_shape), dtype=pix.dtype, device=self.device, ) self.observation_spec = Composite( **obs_entries, shape=self.batch_size, device=self.device ) self.reward_spec = Unbounded( shape=(*self.batch_size, 1), dtype=torch.float32, device=self.device ) done_spec = Categorical( n=2, shape=(*self.batch_size, 1), dtype=torch.bool, device=self.device ) self.done_spec = Composite( done=done_spec.clone(), truncated=done_spec.clone(), terminated=done_spec.clone(), shape=self.batch_size, device=self.device, ) action_dim = 0 for entity in self._scene.entities: n = getattr(entity, "n_dofs", 0) if n > action_dim: action_dim = n if action_dim == 0: action_dim = 1 self.action_spec = Unbounded( shape=(*self.batch_size, action_dim), dtype=torch.float32, device=self.device, ) def _obs_as_tensordict(self) -> dict[str, torch.Tensor]: obs = self._make_obs() if not isinstance(obs, dict): obs = {"observation": obs} out = {k: _as_tensor(v, device=self.device) for k, v in obs.items()} if self._from_pixels: out[self._pixels_key] = self._render_pixels() return out def _reset(self, tensordict=None, **kwargs): self._current_step = 0 return TensorDict( self._obs_as_tensordict(), batch_size=self.batch_size, device=self.device, ) def _step(self, tensordict): action = tensordict.get(self.action_key) if not isinstance(action, torch.Tensor): action = torch.as_tensor(action, device=self.device) reward_shape = (*self.batch_size, 1) reward = torch.zeros(reward_shape, dtype=torch.float32, device=self.device) for _ in range(self._frame_skip): self._apply_action(action) self._scene.step() self._current_step += 1 r = self._compute_reward(action) if r is not None: reward = reward + _broadcast_to( r, reward_shape, device=self.device, dtype=torch.float32 ) terminated = torch.zeros(reward_shape, dtype=torch.bool, device=self.device) truncated = _broadcast_to( self._compute_done(), reward_shape, device=self.device, dtype=torch.bool ) done = terminated | truncated return TensorDict( { **self._obs_as_tensordict(), "reward": reward, "done": done, "terminated": terminated, "truncated": truncated, }, batch_size=self.batch_size, device=self.device, ) def _set_seed(self, seed: int | None) -> None: if seed is None: return torch.manual_seed(seed) self._current_step = 0 def close(self, *, raise_if_closed: bool = True) -> None: scene = getattr(self, "_scene", None) if scene is not None and hasattr(scene, "destroy"): scene.destroy() super().close(raise_if_closed=raise_if_closed) def __repr__(self) -> str: return ( f"{self.__class__.__name__}(" f"scene={type(self._scene).__name__}, batch_size={self.batch_size})" )
class _GenesisEnvMeta(_EnvPostInit): """Return a ParallelEnv when ``num_workers > 1``.""" def __call__(cls, *args, num_workers: int | None = None, **kwargs): if num_workers is None: num_workers = kwargs.pop("num_workers", 1) else: kwargs.pop("num_workers", None) num_workers = int(num_workers) if num_workers is not None else 1 if cls.__name__ == "GenesisEnv" and num_workers > 1: from torchrl.envs import ParallelEnv env_name = args[0] if len(args) >= 1 else kwargs.get("env_name") task_name = args[1] if len(args) >= 2 else kwargs.get("task_name") env_kwargs = { k: v for k, v in kwargs.items() if k not in ("env_name", "task_name") } def make_env(_env_name=env_name, _task_name=task_name, _kwargs=env_kwargs): return cls(_env_name, _task_name, num_workers=1, **_kwargs) return ParallelEnv(num_workers, make_env, metadata_from_workers=True) return super().__call__(*args, **kwargs)
[docs] class GenesisEnv(GenesisWrapper, metaclass=_GenesisEnvMeta): """Genesis environment built from a named configuration. Args: env_name (str): registered environment name. Currently one of ``'franka_reach'`` or ``'franka_grab'``. task_name (str, optional): task name; unused by the built-in configs. num_workers (int, optional): when ``> 1``, returns a :class:`~torchrl.envs.ParallelEnv` wrapping per-worker Genesis envs. Workers report metadata directly to the parent. Defaults to ``1``. max_steps (int, optional): truncation horizon. Defaults to ``1000``. frame_skip (int, optional): physics steps per env step. Defaults to ``1``. from_pixels (bool, optional): if ``True``, a default camera is added to the scene before it is built and a ``pixels`` entry is added to the observation. Defaults to ``False``. pixels_key (str, optional): key for the rendered frame. Defaults to ``"pixels"``. pixels_res (tuple[int, int], optional): ``(H, W)`` of the auto-added camera. Ignored when ``from_pixels=False``. Defaults to ``(320, 320)``. device (torch.device, optional): torch device. When ``None`` (the default), uses :func:`torch.get_default_device` and calls :func:`genesis.init` with the matching backend (``gs.cpu``, ``gs.cuda``, or ``gs.metal``). If Genesis is already initialized with a different device, raises :class:`RuntimeError`. batch_size (torch.Size, optional): env batch size. Defaults to ``()``. allow_done_after_reset (bool, optional): Defaults to ``False``. For task-specific obs / reward / action, subclass :class:`GenesisWrapper` directly -- see its docstring for the hook list. Examples: >>> from torchrl.envs import GenesisEnv >>> env = GenesisEnv(env_name="franka_reach") >>> td = env.rollout(10) """ _ENV_CONFIGS = { "franka_reach": { "description": "Franka robot reaching task", "default_obs": "joints", }, "franka_grab": { "description": "Franka robot grasping task", "default_obs": "joints", }, } def __init__( self, env_name: str, task_name: str | None = None, num_workers: int | None = None, max_steps: int = 1000, frame_skip: int = 1, from_pixels: bool = False, pixels_key: str = "pixels", pixels_res: tuple[int, int] = (320, 320), device: DEVICE_TYPING | None = None, batch_size: torch.Size | None = None, allow_done_after_reset: bool = False, **scene_kwargs, ): if not _has_genesis: raise ImportError( "genesis python package was not found. " "Please install it with: pip install genesis-world" ) if device is None: device = torch.get_default_device() device = torch.device(device) self._env_name = env_name self._task_name = task_name n_envs = int(batch_size[0]) if batch_size else 0 scene = self._create_scene( env_name, task_name, from_pixels=from_pixels, pixels_res=pixels_res, device=device, n_envs=n_envs, **scene_kwargs, ) super().__init__( scene=scene, max_steps=max_steps, frame_skip=frame_skip, from_pixels=from_pixels, pixels_key=pixels_key, device=device, batch_size=batch_size, allow_done_after_reset=allow_done_after_reset, ) def _create_scene( self, env_name: str, task_name: str | None, from_pixels: bool = False, pixels_res: tuple[int, int] = (320, 320), device: torch.device | None = None, n_envs: int = 0, **kwargs, ): gs = self.lib if not getattr(gs, "_initialized", False): backend = ( _genesis_backend_for_device(device) if device is not None else gs.cpu ) gs.init(backend=backend) elif device is not None and torch.device(gs.device) != device: raise RuntimeError( f"Genesis is already initialized with device {gs.device!r}, " f"but GenesisEnv was asked for device {str(device)!r}. " f"Genesis can only be initialized once per process; either " f"restart Python or pass device={gs.device!r} (or drop the " f"device argument to use the Genesis default)." ) scene_kwargs: dict = {"show_viewer": False} if from_pixels and n_envs: scene_kwargs["vis_options"] = gs.options.VisOptions(env_separate_rigid=True) scene = gs.Scene(**scene_kwargs) if env_name == "franka_reach": scene.add_entity(gs.morphs.Plane()) scene.add_entity(gs.morphs.MJCF(file="xml/franka_emika_panda/panda.xml")) elif env_name == "franka_grab": scene.add_entity(gs.morphs.Plane()) scene.add_entity(gs.morphs.MJCF(file="xml/franka_emika_panda/panda.xml")) scene.add_entity(gs.morphs.Box(size=(0.05, 0.05, 0.05), pos=(0.5, 0, 0.05))) else: raise ValueError( f"Unknown environment: {env_name}. " f"Available environments: {list(self._ENV_CONFIGS.keys())}" ) if from_pixels: scene.add_camera(res=pixels_res) if n_envs: scene.build(n_envs=n_envs) else: scene.build() return scene def __repr__(self) -> str: return ( f"{self.__class__.__name__}(" f"env={self._env_name}, task={self._task_name}, " f"batch_size={self.batch_size})" )