Rate this Page

Source code for torchrl.envs.libs.isaac_lab

# 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.
"""Isaac Lab environment wrapper.

This module exposes :class:`IsaacLabWrapper`, a thin specialisation of
:class:`~torchrl.envs.libs.gym.GymWrapper` for Isaac Lab's vectorised
environments. In addition to the auto-reset / spec handling inherited from
the base wrapper, this module surfaces Isaac Lab's per-index reset and
``reset_to`` APIs through the standard torchrl ``_reset`` mask, so that a
caller can reset an arbitrary subset of sub-environments from the
tensordict (and have the transform stack -- ``RewardSum``, ``InitTracker``,
recurrent primers, ``VecNormV2``, ... -- fire correctly on the reset
indices only).
"""
from __future__ import annotations

import importlib.util
from collections.abc import Mapping
from typing import Any, Literal

import torch
from tensordict import NestedKey, TensorDict, TensorDictBase
from torchrl.data.tensor_specs import Bounded, Unbounded
from torchrl.envs.libs.gym import GymWrapper

_has_isaaclab = importlib.util.find_spec("isaaclab") is not None
_has_isaaclab_newton = importlib.util.find_spec("isaaclab_newton") is not None
_has_isaaclab_ov = importlib.util.find_spec("isaaclab_ov") is not None


[docs] class IsaacLabWrapper(GymWrapper): """A wrapper for IsaacLab environments. Args: env (isaaclab.envs.ManagerBasedRLEnv or equivalent): the environment instance to wrap. ``ManagerBasedEnv``, ``ManagerBasedRLEnv``, ``DirectRLEnv`` and ``DirectMARLEnv`` are all supported. categorical_action_encoding (bool, optional): if ``True``, categorical specs will be converted to the TorchRL equivalent (:class:`torchrl.data.Categorical`), otherwise a one-hot encoding will be used (:class:`torchrl.data.OneHot`). Defaults to ``False``. allow_done_after_reset (bool, optional): if ``True``, it is tolerated for envs to be ``done`` just after :meth:`reset` is called. Defaults to ``True``. native_autoreset (bool, optional): if ``True``, keeps Isaac Lab's native auto-reset observations in the collector hot path and avoids the synthetic reset call in :meth:`~torchrl.envs.EnvBase.step_and_maybe_reset`. The terminal ``"next"`` observation remains unavailable and is marked with ``NaN``; the native reset observation is cloned into the next root observation. Defaults to ``False``. from_tiled_camera (bool, optional): if ``True``, reads pixels from an Isaac Lab tiled camera sensor and writes them under ``pixels_key``. This is the recommended headless rendering path. Defaults to ``False``. tiled_camera_name (str, optional): Name of the sensor in ``env.scene.sensors``. Defaults to ``"tiled_camera"``. tiled_camera_data_type (str, optional): Camera data type to read. Defaults to ``"rgb"``. pixels_key (NestedKey, optional): TensorDict key where pixels are written. Defaults to ``"pixels"``. pixels_dtype (torch.dtype, optional): dtype used for the output pixels. If ``torch.uint8`` is requested and the camera returns floating point data, values are scaled from ``[0, 1]`` to ``[0, 255]``. Defaults to ``torch.uint8``. pixels_channels (int, optional): Number of channels to keep from the camera output. Defaults to ``3``. For other arguments, see the :class:`torchrl.envs.GymWrapper` documentation. Refer to `the Isaac Lab doc for installation instructions <https://isaac-sim.github.io/IsaacLab/main/source/setup/installation/pip_installation.html>`_. Per-index reset --------------- Isaac Lab's underlying envs let the caller reset an arbitrary subset of sub-environments without disturbing the others. This wrapper plumbs that capability through the standard torchrl ``"_reset"`` mask: when the tensordict passed to :meth:`reset` carries a partial ``"_reset"`` boolean mask (i.e. neither all ``True`` nor all ``False``), only the masked sub-envs are reset and the others keep their state and ``episode_length_buf``. The transform stack (``RewardSum``, ``InitTracker``, recurrent primers, ``VecNormV2``, ...) fires on the reset rows only, exactly like a normal reset. The per-index reset path is gated on ``native_autoreset=True``: with the default ``native_autoreset=False`` it would conflict with the :class:`~torchrl.envs.transforms.VecGymEnvTransform`-based obs-swap path that :meth:`~torchrl.envs.EnvBase.step_and_maybe_reset` triggers on every "done" row (this would double-reset the affected envs). Set ``native_autoreset=True`` if you want partial-reset semantics. State-based reset ----------------- For deterministic branching from a snapshot, snapshot the scene with :meth:`get_state` and restore it with ``env.reset(td, set_state=True, scene_state=snapshot)`` (or the :meth:`reset_to_state` convenience, which routes through the same path). Manager-based envs only; both work in conjunction with the partial ``"_reset"`` mask. .. note:: The snapshot is passed as the ``scene_state`` *keyword argument* rather than inside the tensordict. For a stateless env the reset state is torch-native (tensordict / ``torch.Tensor`` entries declared in ``state_spec``), so it lives naturally in the tensordict. Isaac Lab's scene state is an opaque, non-torch-native object: carrying it in the tensordict would mean wrapping it as a ``NonTensor`` ``state_spec`` entry and threading it through the transform and step-MDP machinery, whereas a kwarg is simpler and keeps simulator state out of the data path. Example: >>> # This code block ensures that the Isaac app is started in headless mode >>> from scripts_isaaclab.app import AppLauncher >>> import argparse >>> parser = argparse.ArgumentParser(description="Train an RL agent with TorchRL.") >>> AppLauncher.add_app_launcher_args(parser) >>> args_cli, hydra_args = parser.parse_known_args(["--headless"]) >>> app_launcher = AppLauncher(args_cli) >>> # Imports and env >>> import gymnasium as gym >>> import isaaclab_tasks # noqa: F401 >>> from isaaclab_tasks.manager_based.classic.ant.ant_env_cfg import AntEnvCfg >>> from torchrl.envs.libs.isaac_lab import IsaacLabWrapper >>> env = gym.make("Isaac-Ant-v0", cfg=AntEnvCfg()) >>> env = IsaacLabWrapper(env) """ def __init__( self, env: isaaclab.envs.ManagerBasedRLEnv, # noqa: F821 *, categorical_action_encoding: bool = False, allow_done_after_reset: bool = True, convert_actions_to_numpy: bool = False, device: torch.device | None = None, native_autoreset: bool = False, from_tiled_camera: bool = False, tiled_camera_name: str = "tiled_camera", tiled_camera_data_type: str = "rgb", pixels_key: NestedKey = "pixels", pixels_dtype: torch.dtype | None = torch.uint8, pixels_channels: int | None = 3, **kwargs, ): self.from_tiled_camera = from_tiled_camera self.tiled_camera_name = tiled_camera_name self.tiled_camera_data_type = tiled_camera_data_type self.pixels_key = pixels_key self.pixels_dtype = pixels_dtype self.pixels_channels = pixels_channels if device is None: device = torch.device("cuda:0") super().__init__( env, device=device, categorical_action_encoding=categorical_action_encoding, allow_done_after_reset=allow_done_after_reset, convert_actions_to_numpy=convert_actions_to_numpy, **kwargs, ) @staticmethod def add_tiled_camera_config( env_cfg, *, sensor_name: str = "tiled_camera", data_type: str = "rgb", renderer_backend: Literal["isaac_rtx", "newton_warp", "ovrtx"] | None = None, renderer_cfg: object | None = None, width: int = 320, height: int = 240, pos: tuple[float, float, float] = (-7.0, 0.0, 3.0), rot: tuple[float, float, float, float] = (0.9945, 0.0, 0.1045, 0.0), convention: Literal["opengl", "ros", "world"] = "world", focal_length: float = 24.0, focus_distance: float = 400.0, horizontal_aperture: float = 20.955, clipping_range: tuple[float, float] = (0.1, 100.0), render_interval: int | None = None, ): """Attach an Isaac Lab :class:`~isaaclab.sensors.TiledCameraCfg`. This helper mutates an Isaac Lab environment config before the env is instantiated, making headless RGB capture available through :class:`IsaacLabWrapper` with ``from_tiled_camera=True``. Args: env_cfg: Isaac Lab environment config to mutate. sensor_name (str, optional): Name used in ``env.scene.sensors``. Defaults to ``"tiled_camera"``. data_type (str, optional): Camera output data type. Defaults to ``"rgb"``. renderer_backend (str, optional): Renderer backend to use for the tiled camera. ``"isaac_rtx"`` keeps Isaac Lab's default RTX renderer, ``"newton_warp"`` uses the Isaac Lab Newton Warp renderer, and ``"ovrtx"`` uses the OVRTX renderer. Defaults to ``None``, which keeps the Isaac Lab default. renderer_cfg (object, optional): Explicit renderer configuration object to pass to :class:`~isaaclab.sensors.TiledCameraCfg`. This cannot be combined with ``renderer_backend``. width (int, optional): Camera image width. Defaults to ``320``. height (int, optional): Camera image height. Defaults to ``240``. pos (tuple of float, optional): Camera position offset. rot (tuple of float, optional): Camera orientation offset. convention (str, optional): Orientation convention. Defaults to ``"world"``. focal_length (float, optional): Pinhole camera focal length. focus_distance (float, optional): Pinhole camera focus distance. horizontal_aperture (float, optional): Pinhole camera aperture. clipping_range (tuple of float, optional): Near and far clipping planes. render_interval (int, optional): Simulation render interval. If omitted, the existing config value is kept. Returns: The mutated ``env_cfg``. Examples: >>> from isaaclab_tasks.manager_based.classic.ant.ant_env_cfg import AntEnvCfg >>> from torchrl.envs.libs.isaac_lab import IsaacLabWrapper >>> cfg = IsaacLabWrapper.add_tiled_camera_config(AntEnvCfg()) """ if not _has_isaaclab: raise ImportError("Isaac Lab is required to add a tiled camera config.") import isaaclab.sim as sim_utils from isaaclab.sensors import TiledCameraCfg if renderer_backend is not None and renderer_cfg is not None: raise ValueError( "Only one of renderer_backend or renderer_cfg can be provided." ) if renderer_backend == "newton_warp": if not _has_isaaclab_newton: raise ImportError( "Isaac Lab Newton is required to use " "renderer_backend='newton_warp'." ) from isaaclab_newton.renderers import NewtonWarpRendererCfg renderer_cfg = NewtonWarpRendererCfg() elif renderer_backend == "ovrtx": if not _has_isaaclab_ov: raise ImportError( "Isaac Lab OVRTX is required to use renderer_backend='ovrtx'." ) from isaaclab_ov.renderers import OVRTXRendererCfg renderer_cfg = OVRTXRendererCfg() camera_kwargs = {} if renderer_cfg is not None: camera_kwargs["renderer_cfg"] = renderer_cfg camera_cfg = TiledCameraCfg( prim_path="{ENV_REGEX_NS}/Camera", offset=TiledCameraCfg.OffsetCfg( pos=pos, rot=rot, convention=convention, ), data_types=[data_type], spawn=sim_utils.PinholeCameraCfg( focal_length=focal_length, focus_distance=focus_distance, horizontal_aperture=horizontal_aperture, clipping_range=clipping_range, ), width=width, height=height, **camera_kwargs, ) setattr(env_cfg.scene, sensor_name, camera_cfg) if render_interval is not None: env_cfg.sim.render_interval = render_interval return env_cfg def seed(self, seed: int | None): self._set_seed(seed) def _make_specs(self, env, batch_size=None) -> None: super()._make_specs(env, batch_size=batch_size) if self.from_tiled_camera: camera = self._get_tiled_camera() cfg = camera.cfg channels = self.pixels_channels if channels is None: if self.tiled_camera_data_type == "rgb": channels = 3 else: channels = camera.data.output[self.tiled_camera_data_type].shape[-1] dtype = self.pixels_dtype if dtype is None: dtype = camera.data.output[self.tiled_camera_data_type].dtype shape = (*self.batch_size, cfg.height, cfg.width, channels) if dtype == torch.uint8: pixels_spec = Bounded( 0, 255, shape=shape, dtype=dtype, device=self.device ) else: pixels_spec = Unbounded(shape=shape, dtype=dtype, device=self.device) self.observation_spec[self.pixels_key] = pixels_spec obs_keys = frozenset(self.observation_spec.keys(True, True)) self._rename_policy_to_observation = ( "policy" not in obs_keys and "observation" in obs_keys ) def _get_tiled_camera(self): env = self._env.unwrapped scene = env.scene try: return scene.sensors[self.tiled_camera_name] except KeyError as err: raise KeyError( f"Could not find tiled camera sensor {self.tiled_camera_name!r} " "in env.scene.sensors. Add one to the Isaac Lab config before " "calling gym.make, for example with " "IsaacLabWrapper.add_tiled_camera_config(...)." ) from err def _read_tiled_camera_pixels(self) -> torch.Tensor: pixels = self._get_tiled_camera().data.output[self.tiled_camera_data_type] pixels = torch.as_tensor(pixels, device=self.device) if self.pixels_channels is not None: pixels = pixels[..., : self.pixels_channels] if self.pixels_dtype is not None and pixels.dtype != self.pixels_dtype: if self.pixels_dtype == torch.uint8 and pixels.dtype.is_floating_point: pixels = pixels.mul(255).clamp(0, 255) pixels = pixels.to(self.pixels_dtype) return pixels def _add_tiled_camera_pixels(self, observations): if not self.from_tiled_camera: return self._normalize_observation_keys(observations) if isinstance(observations, Mapping): observations = dict(observations) else: observations = {"observation": observations} observations[self.pixels_key] = self._read_tiled_camera_pixels() return self._normalize_observation_keys(observations) def _normalize_observation_keys(self, observations): if ( not self.__dict__.get("_rename_policy_to_observation", False) or not isinstance(observations, Mapping) or "policy" not in observations or "observation" in observations ): return observations observations = dict(observations) observations["observation"] = observations.pop("policy") return observations def _reset_output_transform(self, reset_data): # noqa: F811 observations, info = super()._reset_output_transform(reset_data) return self._add_tiled_camera_pixels(observations), info def _output_transform(self, step_outputs_tuple): # noqa: F811 # IsaacLab will modify the `terminated` and `truncated` tensors # in-place. We clone them here to make sure data doesn't inadvertently get modified. # The variable naming follows torchrl's convention here. observations, reward, terminated, truncated, info = step_outputs_tuple observations = self._add_tiled_camera_pixels(observations) done = terminated | truncated reward = reward.unsqueeze(-1) # to get to (num_envs, 1) return ( observations, reward, terminated.clone(), truncated.clone(), done.clone(), info, ) # ------------------------------------------------------------------ # Isaac Lab env detection # ------------------------------------------------------------------ @staticmethod def _supported_isaac_env_classes( *, include_direct: bool = True ) -> tuple[type, ...]: """Returns the tuple of Isaac Lab env classes this wrapper can bridge. ``ManagerBasedEnv`` and ``ManagerBasedRLEnv`` (subclass) expose ``reset(env_ids=..., seed=..., options=...)`` and ``reset_to``. ``DirectRLEnv`` and ``DirectMARLEnv`` only expose ``_reset_idx``; for those we rebuild the post-reset observation manually. """ from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv if include_direct: return (ManagerBasedEnv, DirectRLEnv, DirectMARLEnv) return (ManagerBasedEnv,) @classmethod def _supports_native_autoreset( cls, env: Any, *, native_autoreset: bool = False ) -> bool: """Return whether ``env`` should receive TorchRL native-autoreset wiring. This is the single source of truth used by :class:`~torchrl.envs.libs.gym._GymAsyncMeta` to decide whether to install the :class:`~torchrl.envs.transforms.VecGymEnvTransform` adapter and register the ``_torchrl_native_autoreset`` flag for an Isaac Lab env (regardless of whether the env was wrapped via :class:`IsaacLabWrapper` or the generic :class:`GymWrapper`). Direct envs are included only when ``native_autoreset=True`` to avoid changing their default reset semantics. """ if not _has_isaaclab: return False return isinstance( env, cls._supported_isaac_env_classes(include_direct=native_autoreset) ) @property def _isaac_unwrapped(self): return self._env.unwrapped @property def _supports_set_state(self) -> bool: # Supports deterministic resets via ``reset(td, set_state=True, # scene_state=...)`` on manager-based envs. The snapshot is honored # through Isaac's ``reset_to`` rather than from tensordict state entries. env = getattr(self, "_env", None) if env is None: return False return hasattr(env.unwrapped, "reset_to") # ------------------------------------------------------------------ # Per-index reset bridge # ------------------------------------------------------------------ def _reset( self, tensordict: TensorDictBase | None = None, **kwargs ) -> TensorDictBase: # Deterministic state-based reset is the single ``set_state=True`` path # (``set_state`` is resolved by ``EnvBase.reset``). The snapshot is passed # as the ``scene_state`` kwarg (from :meth:`get_state`) rather than inside # the tensordict: unlike a stateless env's state (plain tensordict / # ``torch.Tensor`` entries that live in ``state_spec``), Isaac Lab's scene # state is an opaque, non-torch-native object. Forcing it into the # tensordict would require wrapping it as a ``NonTensor`` ``state_spec`` # entry (polluting the input spec and the transform/step-MDP machinery); # a kwarg is simpler and keeps it out of the data path. When set, it # overrides the regular reset path entirely (Isaac Lab's ``reset_to``). set_state = kwargs.pop("set_state", None) scene_state = kwargs.pop("scene_state", None) is_relative = kwargs.pop("is_relative", False) if scene_state is not None and not set_state: raise ValueError( "A `scene_state` snapshot was passed to reset() without " "`set_state=True`. Pass `set_state=True` to deterministically " "reset to the snapshot." ) reset = None if tensordict is not None: reset = tensordict.get("_reset", None) if set_state: if scene_state is None: raise ValueError( "reset(set_state=True) on an Isaac Lab env requires a " "`scene_state` snapshot (obtained from `env.get_state()`)." ) if reset is not None and not reset.any(): return tensordict.exclude("_reset") return self._reset_to_state_at( scene_state, reset=reset, is_relative=is_relative, **kwargs ) if not self._is_batched: return super()._reset(tensordict, **kwargs) if reset is None or reset.all(): # Full reset: defer to GymWrapper / GymLikeEnv. if tensordict is not None and "_reset" in tensordict.keys(): tensordict = tensordict.exclude("_reset") return super()._reset(tensordict, **kwargs) # Per-index reset path. Gated on native_autoreset=True: with # native_autoreset=False, partial-mask reset calls are issued by # EnvBase.maybe_reset on every step that has any "done" row, and # the surrounding VecGymEnvTransform already handles the obs swap # without re-entering Isaac. Firing unwrapped.reset(env_ids=...) # here would double-reset those envs. We keep the historical # no-op semantics in that case so explicit partial resets # remain a feature you opt into via native_autoreset=True. if not self._native_autoreset_enabled: return tensordict.exclude("_reset") if not reset.any(): # Nothing to reset: return a sentinel reset tensordict so the # surrounding _reset_proc_data / _update_during_reset machinery # preserves the incoming state on every sub-env. return tensordict.exclude("_reset") env_ids = self._reset_mask_to_env_ids(reset) obs, info = self._partial_reset(env_ids=env_ids, **kwargs) return self._build_reset_tensordict(obs, info) def _input_td_has_state(self, tensordict: TensorDictBase | None) -> bool: # Isaac Lab honors a ``scene_state`` snapshot passed as a reset kwarg, # never tensordict state entries, so a reset tensordict carrying # observations must not trigger the implicit-state transition warning. return False @property def _native_autoreset_enabled(self) -> bool: """Whether the wrapping pipeline has ``native_autoreset=True``. Set on the instance by :class:`~torchrl.envs.libs.gym._GymAsyncMeta` when the wrapper is constructed via :class:`IsaacLabWrapper(env, native_autoreset=True)`. Mirrored on the surrounding :class:`~torchrl.envs.TransformedEnv` via ``_torchrl_native_autoreset``. """ return self.__dict__.get("_torchrl_native_autoreset", False) def _reset_mask_to_env_ids(self, reset: torch.Tensor) -> torch.Tensor: """Convert a ``_reset`` boolean mask to a 1-D ``env_ids`` tensor.""" # The mask is shaped (num_envs, 1) or (num_envs,) and may live on a # different device than the underlying Isaac env (e.g. CPU in tests). return ( reset.reshape(-1).nonzero(as_tuple=True)[0].to(self._isaac_unwrapped.device) ) def _partial_reset( self, *, env_ids: torch.Tensor, seed: int | None = None, options: dict | None = None, **kwargs, ) -> tuple[Any, dict | None]: """Reset the listed sub-envs without touching the rest.""" from isaaclab.envs import DirectMARLEnv, DirectRLEnv, ManagerBasedEnv unwrapped = self._isaac_unwrapped if isinstance(unwrapped, ManagerBasedEnv): reset_kwargs: dict[str, Any] = {"env_ids": env_ids} if seed is not None: reset_kwargs["seed"] = seed if options is not None: reset_kwargs["options"] = options return unwrapped.reset(**reset_kwargs) if isinstance(unwrapped, (DirectRLEnv, DirectMARLEnv)): # DirectRLEnv.reset() does not accept env_ids -- fall back to the # internal _reset_idx primitive and replay the post-reset bookkeeping # that DirectRLEnv / DirectMARLEnv reset() would normally do. if seed is not None: unwrapped.seed(seed) unwrapped._reset_idx(env_ids) unwrapped.scene.write_data_to_sim() unwrapped.sim.forward() return unwrapped._get_observations(), unwrapped.extras raise TypeError( f"Per-index reset is not supported for Isaac Lab env of type " f"{type(unwrapped).__name__}. Supported bases are ManagerBasedEnv " "(and subclasses), DirectRLEnv and DirectMARLEnv." ) def _build_reset_tensordict(self, obs: Any, info: dict | None) -> TensorDictBase: """Rebuild a torchrl-style reset tensordict from Isaac's (obs, info).""" obs = self._add_tiled_camera_pixels(obs) source = self.read_obs(obs) tensordict_out = TensorDict(source=source, batch_size=self.batch_size) if self.info_dict_reader and info is not None: for info_dict_reader in self.info_dict_reader: out = info_dict_reader(info, tensordict_out) if out is not None: tensordict_out = out if self.device is not None: tensordict_out = tensordict_out.to(self.device) return tensordict_out # ------------------------------------------------------------------ # State-based reset bridge # ------------------------------------------------------------------ def get_state(self) -> Any: """Return the current Isaac Lab scene state. This is exactly what ``InteractiveScene.get_state()`` returns on the underlying env. It can later be passed back via ``env.reset(td, set_state=True, scene_state=...)`` (or the :meth:`reset_to_state` convenience) to deterministically branch back to this checkpoint. Returns: The scene-state dict (as defined by Isaac Lab; not converted). """ return self._isaac_unwrapped.scene.get_state() def reset_to_state( self, state: Any, tensordict: TensorDictBase | None = None, *, is_relative: bool = False, seed: int | None = None, ) -> TensorDictBase: """Deterministically reset to ``state`` (per-index when a ``_reset`` mask is set). ``state`` is the dict format returned by :meth:`get_state` (i.e. ``InteractiveScene.get_state()``). Only manager-based envs are supported (they are the only ones that expose ``reset_to``). Args: state: scene state to restore. tensordict: optional input tensordict. If it contains a ``"_reset"`` mask, only the masked sub-envs are restored; otherwise every sub-env is restored. Keyword Args: is_relative: if ``True``, ``state`` is interpreted relative to the env origin (matches Isaac Lab's ``reset_to(is_relative=True)``). Defaults to ``False``. seed: optional seed forwarded to Isaac's ``reset_to``. Returns: A reset tensordict in torchrl's standard shape. .. note:: This is a thin convenience around the unified deterministic-reset path; it is exactly equivalent to:: env.reset(td, set_state=True, scene_state=state, is_relative=...) If the env is wrapped in a :class:`~torchrl.envs.TransformedEnv` and the transform stack (``RewardSum``, ``InitTracker``, primers, ``VecNormV2``, ...) must fire on the restored rows, call that ``reset(...)`` form on the top-level env so it routes through :meth:`TransformedEnv._reset` and triggers every transform on the way down. """ kwargs: dict[str, Any] = {"scene_state": state, "is_relative": is_relative} if seed is not None: kwargs["seed"] = seed return self.reset(tensordict, set_state=True, **kwargs) def _reset_to_state_at( self, state: Any, *, reset: torch.Tensor | None, is_relative: bool, seed: int | None = None, **kwargs, ) -> TensorDictBase: unwrapped = self._isaac_unwrapped if not hasattr(unwrapped, "reset_to"): raise RuntimeError( f"Isaac Lab env of type {type(unwrapped).__name__} does not " "expose reset_to (only manager-based envs do). For state-based " "reset on Direct envs, branch from the underlying scene state " "by hand." ) if reset is None: env_ids = torch.arange( self.batch_size.numel(), device=unwrapped.device, dtype=torch.long, ) else: env_ids = self._reset_mask_to_env_ids(reset) state = self._index_state_for_env_ids(state, env_ids) reset_to_kwargs: dict[str, Any] = { "env_ids": env_ids, "is_relative": is_relative, } if seed is not None: reset_to_kwargs["seed"] = seed obs, info = unwrapped.reset_to(state, **reset_to_kwargs) return self._build_reset_tensordict(obs, info) def _index_state_for_env_ids(self, state: Any, env_ids: torch.Tensor) -> Any: num_envs = self.batch_size.numel() if isinstance(state, torch.Tensor): if state.shape[:1] == (num_envs,): return state.index_select(0, env_ids.to(state.device)) return state if isinstance(state, Mapping): return { key: self._index_state_for_env_ids(value, env_ids) for key, value in state.items() } if isinstance(state, tuple): return tuple(self._index_state_for_env_ids(item, env_ids) for item in state) if isinstance(state, list): return [self._index_state_for_env_ids(item, env_ids) for item in state] return state