Rate this Page

Source code for torchrl.envs.transforms.functional

# 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

from typing import Literal

import torch
from tensordict.utils import expand_as_right
from torch import Tensor

__all__ = [
    "cat_frames",
    "rgb_to_grayscale",
]


# copied from torchvision
def _get_image_num_channels(img: Tensor) -> int:
    if img.ndim == 2:
        return 1
    elif img.ndim > 2:
        return img.shape[-3]

    raise TypeError(f"Input ndim should be 2 or more. Got {img.ndim}")


def _assert_channels(img: Tensor, permitted: list[int]) -> None:
    c = _get_image_num_channels(img)
    if c not in permitted:
        raise TypeError(
            f"Input image tensor permitted channel values are {permitted}, but found "
            f"{c} (full shape: {img.shape})"
        )


def rgb_to_grayscale(img: Tensor, num_output_channels: int = 1) -> Tensor:
    """Turns an RGB image into grayscale."""
    if img.ndim < 3:
        raise TypeError(
            "Input image tensor should have at least 3 dimensions, but found"
            "{}".format(img.ndim)
        )
    _assert_channels(img, [3])

    if num_output_channels not in (1, 3):
        raise ValueError("num_output_channels should be either 1 or 3")

    r, g, b = img.unbind(dim=-3)
    l_img = (0.2989 * r + 0.587 * g + 0.114 * b).to(img.dtype)
    l_img = l_img.unsqueeze(dim=-3)

    if num_output_channels == 3:
        return l_img.expand(img.shape)

    return l_img


def _apply_same_padding(dim: int, data: Tensor, done_mask: Tensor) -> Tensor:
    """Replaces frames flagged by ``done_mask`` with the first valid frame of the window.

    This implements the ``padding="same"`` semantics of :func:`cat_frames`: within
    each sliding window, the entries that reach across a trajectory boundary
    (marked by ``done_mask``) are overwritten with the earliest in-trajectory
    frame of that window. ``data`` is the permuted, windowed tensor (the window
    axis already moved to ``data.ndim + dim - 1``); ``done_mask`` is the
    un-permuted boolean mask of shape ``(*batch, time, 1, N)`` (the singleton is
    the done "feature" dim).
    """
    d = data.ndim + dim - 1
    N = done_mask.shape[-1]
    # The out-of-trajectory slots always form a prefix of the window (the
    # oldest frames), so slot ``j`` reads slot ``max(j, num_padded)``: its own
    # value once past the prefix, the first in-trajectory frame otherwise.
    num_padded = done_mask.sum(dim=-1)
    index = torch.maximum(torch.arange(N, device=data.device), num_padded).clamp_max_(
        N - 1
    )
    data = data.movedim(d, -1)
    index = index.reshape(*index.shape[:-1], *(1,) * (data.ndim - index.ndim), N)
    return data.gather(-1, index.expand(data.shape)).movedim(-1, d)


def _cat_frames_windows(
    tensor: Tensor,
    N: int,
    dim: int,
    *,
    padding: Literal["same", "constant"] = "same",
    padding_value: float = 0.0,
    time_dim: int = -1,
    done_mask: Tensor | None = None,
) -> Tensor:
    """Builds the padded, windowed (but **not** flattened) frame stack.

    This is the shared core of :func:`cat_frames`. It returns the unfolded tensor
    with the window axis placed just before the concatenation axis (``dim``), so
    that a caller can either flatten it (as :func:`cat_frames` does) or run an
    extra fixup before flattening (as :class:`~torchrl.envs.transforms.CatFrames`
    does for ``("next", key)`` inputs).

    ``time_dim`` and ``dim`` are both negative and independent: the sliding
    window moves along ``time_dim`` and the resulting ``N`` frames are
    concatenated along ``dim``. ``done_mask`` (if provided) has its time axis at
    position ``time_dim`` and its window axis appended last, i.e. shape
    ``tensor.shape`` (up to ``time_dim``) followed by a trailing ``N``.
    """
    if dim >= 0:
        raise ValueError(
            "dim must be < 0 to accommodate for tensors of different batch-sizes "
            "(since negative dims are batch invariant)."
        )
    if time_dim >= 0:
        raise ValueError("time_dim must be < 0.")
    # absolute index of the time axis within ``tensor``
    time_pos = tensor.ndim + time_dim
    # pad N-1 frames at the start of the trajectory
    idx = [slice(None)] * time_pos + [0]
    data0 = [torch.full_like(tensor[tuple(idx)], padding_value).unsqueeze(time_pos)] * (
        N - 1
    )
    data = torch.cat(data0 + [tensor], time_pos)
    # unfold along time: appends a trailing window axis of size N
    data = data.unfold(time_pos, N, 1)

    # move the trailing window axis to just before the cat axis ``dim``
    data = data.permute(
        *range(0, data.ndim + dim - 1),
        -1,
        *range(data.ndim + dim - 1, data.ndim - 1),
    )

    if padding != "same":
        if done_mask is not None:
            done_mask_expand = done_mask.view(
                *done_mask.shape[: time_pos + 1],
                *(1,) * (data.ndim - 2 - time_pos),
                done_mask.shape[-1],
            )
            # expand_as_right needs the window axis last, so expand before
            # permuting the window axis into place.
            data_win_last = data.movedim(data.ndim + dim - 1, -1)
            done_mask_expand = expand_as_right(done_mask_expand, data_win_last)
            done_mask_expand = done_mask_expand.movedim(-1, data.ndim + dim - 1)
            data = torch.where(done_mask_expand, padding_value, data)
    else:
        if done_mask is not None:
            data = _apply_same_padding(dim, data, done_mask)
    return data


[docs] def cat_frames( tensor: Tensor, N: int, dim: int, *, padding: Literal["same", "constant"] = "same", padding_value: float = 0.0, time_dim: int = -1, done_mask: Tensor | None = None, ) -> Tensor: r"""Stacks a sliding window of ``N`` successive frames along ``dim``. This is the pure, stateless core of the :class:`~torchrl.envs.transforms.CatFrames` transform (the PyTorch ``F.x`` / ``nn.X`` split): :class:`~torchrl.envs.transforms.CatFrames` delegates its offline / replay-buffer (contiguous trajectory slice) windowing to this function so that the two stay byte-for-byte identical. For every position ``t`` along ``time_dim``, the ``N`` frames ``[t - N + 1, ..., t]`` are concatenated along ``dim``. The first ``N - 1`` positions of a trajectory have fewer than ``N`` real frames; the missing frames are filled according to ``padding``. This matches the offline behavior of :class:`~torchrl.envs.transforms.CatFrames`; see the "Examples" of that class for the online (stateful, per-step) usage. It was first proposed in "Playing Atari with Deep Reinforcement Learning" (https://arxiv.org/abs/1312.5602). Args: tensor (torch.Tensor): the frames to stack. One of its dimensions (``time_dim``) is the time axis along which the sliding window moves; ``dim`` is the (channel/feature) axis along which the ``N`` frames are concatenated. N (int): number of successive frames to concatenate. dim (int): the dimension along which the frames are concatenated. Must be negative so that it is invariant to leading batch dimensions. The size of ``tensor`` along ``dim`` is multiplied by ``N`` in the output. Keyword Args: padding (str, optional): the padding method, one of ``"same"`` or ``"constant"``. With ``"same"`` (default) the first real frame of the trajectory is repeated; with ``"constant"`` the missing frames are filled with ``padding_value``. padding_value (float, optional): the value used to pad when ``padding="constant"``. Defaults to ``0``. time_dim (int, optional): the dimension of ``tensor`` that holds the time axis. Must be negative. Defaults to ``-1``. done_mask (torch.Tensor, optional): an optional boolean mask flagging, for each sliding window, which of its ``N`` positions reach across a trajectory boundary (and must therefore be padded). Its shape is ``(*batch, time, N)`` where ``time`` matches the size of ``tensor`` along ``time_dim``. When ``None`` (default), the input is treated as a single trajectory and only the leading ``N - 1`` start-of-sequence frames are padded. :class:`~torchrl.envs.transforms.CatFrames` builds this mask from the environment ``done`` signal. Returns: torch.Tensor: a tensor identical to ``tensor`` except that its size along ``dim`` is multiplied by ``N`` (the concatenated window) and its dtype / device are preserved. Examples: >>> import torch >>> from torchrl.envs.transforms.functional import cat_frames >>> # a single trajectory of 4 frames, each a length-2 feature vector, >>> # stacked over a window of N=3 along the feature dim (-1). >>> frames = torch.arange(8.0).view(4, 2) >>> frames tensor([[0., 1.], [2., 3.], [4., 5.], [6., 7.]]) >>> out = cat_frames(frames, N=3, dim=-1, time_dim=-2, padding="constant") >>> out.shape torch.Size([4, 6]) >>> out tensor([[0., 0., 0., 0., 0., 1.], [0., 0., 0., 1., 2., 3.], [0., 1., 2., 3., 4., 5.], [2., 3., 4., 5., 6., 7.]]) .. note:: This functional covers the **offline** (contiguous trajectory slice) windowing used by :class:`~torchrl.envs.transforms.CatFrames`. The transform's **online** path (per-:meth:`~torchrl.envs.EnvBase.step` buffer accumulation) is inherently stateful and is not expressed as a pure function. .. seealso:: :class:`~torchrl.envs.transforms.CatFrames`. """ if padding not in ("same", "constant"): raise ValueError( f"padding must be one of 'same' or 'constant', got {padding!r}." ) if time_dim >= 0: raise ValueError("time_dim must be < 0.") if done_mask is None: # Treat the input as a single contiguous trajectory: only the leading # N - 1 start-of-sequence windows have padded (out-of-trajectory) # positions. Window position ``j`` of output step ``t`` reads source # step ``t - (N - 1) + j``; it is padded when that index is < 0. time_pos = tensor.ndim + time_dim time_len = tensor.shape[time_pos] steps = torch.arange(time_len, device=tensor.device) positions = torch.arange(N, device=tensor.device) # shape (time_len, N): True where the source index is before the start mask = positions[None, :] < (N - 1 - steps)[:, None] # broadcast over the leading batch dims (everything before time) and # add a trailing singleton "done feature" dim so the mask matches the # ``(*batch, time, 1, N)`` layout produced from a per-step done signal. shape = [1] * (time_pos + 1) + [1, N] shape[time_pos] = time_len done_mask = ( mask.unsqueeze(-2) .reshape(shape) .expand(*tensor.shape[: time_pos + 1], 1, N) ) data = _cat_frames_windows( tensor, N, dim, padding=padding, padding_value=padding_value, time_dim=time_dim, done_mask=done_mask, ) return data.flatten(data.ndim + dim - 1, data.ndim + dim)