IO Descriptors 101#

In this tutorial, we will learn about IO descriptors, what they are, how to export them, and how to add them to your environments. We will use the Anymal-D robot as an example to demonstrate how to export IO descriptors from an environment, and use our own terms to demonstrate how to attach IO descriptors to custom action and observation terms.

What are IO Descriptors?#

Before we dive into IO descriptors, let’s first understand what they are and how they can be useful.

IO descriptors are a way to describe the inputs and outputs of a policy trained using the ManagerBasedRLEnv in Isaac Lab. In other words, they describe the action and observation terms of a policy. This description is used to generate a YAML file that can be loaded in an external tool to run the policies without having to manually input the configuration of the action and observation terms.

In addition to this the IO Descriptors provide the following information: - The parameters of all the joints in the articulation. - Some simulation parameters including the simulation time step, and the policy time step. - For some action and observation terms, it provides the joint names or body names in the same order as they appear in the action/observation terms. - For both the observation and action terms, it provides the terms in the exact same order as they appear in the managers. Making it easy to reconstruct them from the YAML file.

Here is an example of what the action part of the YAML generated from the IO descriptors looks like for the Anymal-D robot:

# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

actions:
- action_type: JointAction
  clip: null
  dtype: torch.float32
  extras:
    description: Joint action term that applies the processed actions to the articulation's
      joints as position commands.
  full_path: isaaclab.envs.mdp.actions.joint_actions.JointPositionAction
  joint_names:
  - LF_HAA
  - LH_HAA
  - RF_HAA
  - RH_HAA
  - LF_HFE
  - LH_HFE
  - RF_HFE
  - RH_HFE
  - LF_KFE
  - LH_KFE
  - RF_KFE
  - RH_KFE
  mdp_type: Action
  name: joint_position_action
  offset:
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.4000000059604645
  - -0.4000000059604645
  - 0.4000000059604645
  - -0.4000000059604645
  - -0.800000011920929
  - 0.800000011920929

Here is an example of what a portion of the observation part of the YAML generated from the IO descriptors looks like for the Anymal-D robot:

    - RH_HFE
    - LF_KFE
    - LH_KFE
    - RF_KFE
    - RH_KFE
observations:
  policy:
  - dtype: torch.float32
    extras:
      axes:
      - X
      - Y
      - Z
      description: Root linear velocity in the asset's root frame.
      modifiers: null
      units: m/s
    full_path: isaaclab.envs.mdp.observations.base_lin_vel
    mdp_type: Observation
    name: base_lin_vel
    observation_type: RootState
    overloads:
      clip: null
      flatten_history_dim: true
      history_length: 0
      scale: null
    shape:
    - 3
  - dtype: torch.float32
    extras:
      axes:
      - X
      - Y
      - Z
      description: Root angular velocity in the asset's root frame.
      modifiers: null
      units: rad/s
    full_path: isaaclab.envs.mdp.observations.base_ang_vel
    mdp_type: Observation
    name: base_ang_vel
    observation_type: RootState
    overloads:
      clip: null
      flatten_history_dim: true
      history_length: 0
      scale: null
    shape:
    - 3
  - dtype: torch.float32
    extras:
      description: 'The joint positions of the asset w.r.t. the default joint positions.
        Note: Only the joints configured in :attr:`asset_cfg.joint_ids` will have
        their positions returned.'
      modifiers: null
      units: rad
    full_path: isaaclab.envs.mdp.observations.joint_pos_rel
    joint_names:
    - LF_HAA
    - LH_HAA
    - RF_HAA
    - RH_HAA
    - LF_HFE
    - LH_HFE
    - RF_HFE
    - RH_HFE
    - LF_KFE
    - LH_KFE
    - RF_KFE
    - RH_KFE
    joint_pos_offsets:
    - 0.0
    - 0.0
    - 0.0
    - 0.0
    - 0.4000000059604645
    - -0.4000000059604645
    - 0.4000000059604645
    - -0.4000000059604645
    - -0.800000011920929
    - 0.800000011920929
    - -0.800000011920929
    - 0.800000011920929
    mdp_type: Observation
    name: joint_pos_rel
    observation_type: JointState
    overloads:
      clip: null

Something to note here is that both the action and observation terms are returned as list of dictionaries, and not a dictionary of dictionaries. This is done to ensure the order of the terms is preserved. Hence, to retrieve the action or observation term, the users need to look for the name key in the dictionaries.

For example, in the following snippet, we are looking at the projected_gravity observation term. The name key is used to identify the term. The full_path key is used to provide an explicit path to the function in Isaac Lab’s source code that is used to compute this term. Some flags like mdp_type and observation_type are also provided, these don’t have any functional impact. They are here to inform the user that this is the category this term belongs to.

      flatten_history_dim: true
      history_length: 0
      scale: null
    shape:
    - 3
  - dtype: torch.float32
    extras:
      axes:
      - X
      - Y
      - Z
      description: Gravity projection on the asset's root frame.
      modifiers: null
      units: m/s^2
    full_path: isaaclab.envs.mdp.observations.projected_gravity
    mdp_type: Observation
    name: projected_gravity
    observation_type: RootState
    overloads:
      clip: null

Exporting IO Descriptors from an Environment#

In this section, we will cover how to export IO descriptors from an environment. Keep in mind that this feature is only available to the manager based RL environments.

If a policy has already been trained using a given configuration, then the IO descriptors can be exported using:

./isaaclab.sh -p scripts/environments/export_io_descriptors.py --task <task_name> --output_dir <output_dir>

For example, if we want to export the IO descriptors for the Anymal-D robot, we can run:

./isaaclab.sh -p scripts/environments/export_io_descriptors.py --task Isaac-Velocity-Flat-Anymal-D-v0 --output_dir ./io_descriptors

When training a policy, it is also possible to request the IO descriptors to be exported at the beginning of the training. This can be done by setting the export_io_descriptors flag in the command line.

./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --export_io_descriptors
./isaaclab.sh -p scripts/reinforcement_learning/sb3/train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --export_io_descriptors
./isaaclab.sh -p scripts/reinforcement_learning/rl_games/train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --export_io_descriptors
./isaaclab.sh -p scripts/reinforcement_learning/skrl/train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --export_io_descriptors

Attaching IO Descriptors to Custom Observation Terms#

In this section, we will cover how to attach IO descriptors to custom observation terms.

Let’s take a look at how we can attach an IO descriptor to a simple observation term:

@generic_io_descriptor(
   units="m/s", axes=["X", "Y", "Z"], observation_type="RootState", on_inspect=[record_shape, record_dtype]
)
def base_lin_vel(env: ManagerBasedEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
    """Root linear velocity in the asset's root frame."""
    # extract the used quantities (to enable type-hinting)
    asset: RigidObject = env.scene[asset_cfg.name]
    return asset.data.root_lin_vel_b

Here, we are defining a custom observation term called base_lin_vel that computes the root linear velocity of the robot. We are also attaching an IO descriptor to this term. The IO descriptor is defined using the @generic_io_descriptor decorator.

The @generic_io_descriptor decorator is a special decorator that is used to attach an IO descriptor to a custom observation term. It takes arbitrary arguments that are used to describe the observation term, in this case we provide extra information that could be useful for the end user:

  • units: The units of the observation term.

  • axes: The axes of the observation term.

  • observation_type: The type of the observation term.

You’ll also notice that there is an on_inspect argument that is provided. This is a list of functions that are used to inspect the observation term. In this case, we are using the record_shape and record_dtype functions to record the shape and dtype of the output of the observation term.

These functions are defined like so:

def record_shape(output: torch.Tensor, descriptor: GenericObservationIODescriptor, **kwargs) -> None:
    """Record the shape of the output tensor.

    Args:
       output: The output tensor.
       descriptor: The descriptor to record the shape to.
       **kwargs: Additional keyword arguments.
    """
    descriptor.shape = (output.shape[-1],)


def record_dtype(output: torch.Tensor, descriptor: GenericObservationIODescriptor, **kwargs) -> None:
    """Record the dtype of the output tensor.

    Args:
       output: The output tensor.
       descriptor: The descriptor to record the dtype to.
       **kwargs: Additional keyword arguments.
    """
    descriptor.dtype = str(output.dtype)

They always take the output tensor of the observation term as the first argument, and the descriptor as the second argument. In the kwargs all the inputs of the observation term are provided. In addition to the on_inspect functions, the decorator will also call call some functions in the background to collect the name, the description, and the full_path of the observation term. Note that adding this decorator does not change the signature of the observation term, so it can be used safely with the observation manager!

Let us now take a look at a more complex example: getting the relative joint positions of the robot.

@generic_io_descriptor(
    observation_type="JointState",
    on_inspect=[record_joint_names, record_dtype, record_shape, record_joint_pos_offsets],
    units="rad",
)
def joint_pos_rel(env: ManagerBasedEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor:
    """The joint positions of the asset w.r.t. the default joint positions.

    Note: Only the joints configured in :attr:`asset_cfg.joint_ids` will have their positions returned.
    """
    # extract the used quantities (to enable type-hinting)
    asset: Articulation = env.scene[asset_cfg.name]
    return asset.data.joint_pos[:, asset_cfg.joint_ids] - asset.data.default_joint_pos[:, asset_cfg.joint_ids]

Similarly to the previous example, we are adding an IO descriptor to a custom observation term with a set of functions that probe the observation term.

To get the name of the joints we can write the following function:

def record_joint_names(output: torch.Tensor, descriptor: GenericObservationIODescriptor, **kwargs) -> None:
    """Record the joint names of the output tensor.

    Expects the `asset_cfg` keyword argument to be set.

    Args:
        output: The output tensor.
        descriptor: The descriptor to record the joint names to.
        **kwargs: Additional keyword arguments.
    """
    asset: Articulation = kwargs["env"].scene[kwargs["asset_cfg"].name]
    joint_ids = kwargs["asset_cfg"].joint_ids
    if joint_ids == slice(None, None, None):
        joint_ids = list(range(len(asset.joint_names)))
    descriptor.joint_names = [asset.joint_names[i] for i in joint_ids]

Note that we can access all the inputs of the observation term in the kwargs dictionary. Hence we can access the asset_cfg, which contains the configuration of the articulation that the observation term is computed on.

To get the offsets, we can write the following function:

def record_joint_pos_offsets(output: torch.Tensor, descriptor: GenericObservationIODescriptor, **kwargs):
 """Record the joint position offsets of the output tensor.

 Expects the `asset_cfg` keyword argument to be set.

 Args:
     output: The output tensor.
     descriptor: The descriptor to record the joint position offsets to.
     **kwargs: Additional keyword arguments.
 """
    asset: Articulation = kwargs["env"].scene[kwargs["asset_cfg"].name]
    ids = kwargs["asset_cfg"].joint_ids
    # Get the offsets of the joints for the first robot in the scene.
    # This assumes that all robots have the same joint offsets.
    descriptor.joint_pos_offsets = asset.data.default_joint_pos[:, ids][0]

With this in mind, you should now be able to attach an IO descriptor to your own custom observation terms! However, before we close this tutorial, let’s take a look at how we can attach an IO descriptor to a custom action term.

Attaching IO Descriptors to Custom Action Terms#

In this section, we will cover how to attach IO descriptors to custom action terms. Action terms are classes that inherit from the managers.ActionTerm class. To add an IO descriptor to an action term, we need to expand upon its ActionTerm.IO_descriptor() property.

By default, the ActionTerm.IO_descriptor() property returns the base descriptor and fills the following fields: - name: The name of the action term. - full_path: The full path of the action term. - description: The description of the action term. - export: Whether to export the action term.

@property
def IO_descriptor(self) -> GenericActionIODescriptor:
    """The IO descriptor for the action term."""
    self._IO_descriptor.name = re.sub(r"([a-z])([A-Z])", r"\1_\2", self.__class__.__name__).lower()
    self._IO_descriptor.full_path = f"{self.__class__.__module__}.{self.__class__.__name__}"
    self._IO_descriptor.description = " ".join(self.__class__.__doc__.split())
    self._IO_descriptor.export = self.export_IO_descriptor
    return self._IO_descriptor

To add more information to the descriptor, we need to override the ActionTerm.IO_descriptor() property. Let’s take a look at an example on how to add the joint names, scale, offset, and clip to the descriptor.

@property
def IO_descriptor(self) -> GenericActionIODescriptor:
    """The IO descriptor of the action term.

    This descriptor is used to describe the action term of the joint action.
    It adds the following information to the base descriptor:
    - joint_names: The names of the joints.
    - scale: The scale of the action term.
    - offset: The offset of the action term.
    - clip: The clip of the action term.

    Returns:
        The IO descriptor of the action term.
    """
    super().IO_descriptor
    self._IO_descriptor.shape = (self.action_dim,)
    self._IO_descriptor.dtype = str(self.raw_actions.dtype)
    self._IO_descriptor.action_type = "JointAction"
    self._IO_descriptor.joint_names = self._joint_names
    self._IO_descriptor.scale = self._scale
    # This seems to be always [4xNum_joints] IDK why. Need to check.
    if isinstance(self._offset, torch.Tensor):
        self._IO_descriptor.offset = self._offset[0].detach().cpu().numpy().tolist()
    else:
        self._IO_descriptor.offset = self._offset
    # FIXME: This is not correct. Add list support.
    if self.cfg.clip is not None:
        if isinstance(self._clip, torch.Tensor):
            self._IO_descriptor.clip = self._clip[0].detach().cpu().numpy().tolist()
        else:
            self._IO_descriptor.clip = self._clip
    else:
        self._IO_descriptor.clip = None
    return self._IO_descriptor

This is it! You should now be able to attach an IO descriptor to your own custom action terms which concludes this tutorial.