Rate this Page

Source code for torchrl.render.video

# 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.util
import math
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any, Literal

import numpy as np
import torch

from torchrl.record.loggers.common import _write_video

_has_pil = importlib.util.find_spec("PIL") is not None

_PIL_ERROR = (
    "Writing image frames or GIF artifacts requires Pillow. When running TorchRL "
    "from this repository with uv, use `uv run --extra rendering <command>` so "
    "Pillow is installed in the command environment. Otherwise install it with "
    "`pip install pillow`."
)

__all__ = [
    "compose_frame_grid",
    "encode_gif",
    "encode_video",
    "normalize_frame",
    "normalize_frame_output",
    "write_png",
]


[docs] def normalize_frame(frame: Any) -> np.ndarray: """Converts a tensor-like image into an ``H x W x 3`` uint8 array.""" if ( hasattr(frame, "data") and not torch.is_tensor(frame) and not isinstance(frame, np.ndarray) ): frame = frame.data if torch.is_tensor(frame): frame = frame.detach().cpu().numpy() frame = np.asarray(frame) if frame.ndim == 0: raise ValueError("A rendered frame must have at least two dimensions.") while frame.ndim > 3: frame = frame[0] if frame.ndim == 2: frame = frame[..., None] if frame.ndim != 3: raise ValueError(f"Expected a 2D or 3D frame, got shape {frame.shape}.") if frame.shape[0] in (1, 3) and frame.shape[-1] != 3: frame = np.moveaxis(frame, 0, -1) if frame.shape[-1] == 1: frame = np.repeat(frame, 3, axis=-1) elif frame.shape[-1] == 4: frame = frame[..., :3] elif frame.shape[-1] != 3: raise ValueError(f"Expected 1, 3, or 4 frame channels, got {frame.shape[-1]}.") if frame.dtype == np.uint8: return np.ascontiguousarray(frame) if np.issubdtype(frame.dtype, np.floating): max_value = float(np.nanmax(frame)) if frame.size else 0.0 if max_value <= 1.0: frame = frame * 255.0 frame = np.nan_to_num(frame, nan=0.0, posinf=255.0, neginf=0.0) frame = np.clip(frame, 0, 255).astype(np.uint8) return np.ascontiguousarray(frame)
[docs] def normalize_frame_output(output: Any) -> dict[str, np.ndarray]: """Normalizes renderer output into named RGB frames.""" if output is None: return {} if isinstance(output, Mapping): return { str(name): normalize_frame(frame) for name, frame in output.items() if frame is not None } if isinstance(output, (list, tuple)): return { f"camera_{index}": normalize_frame(frame) for index, frame in enumerate(output) if frame is not None } return {"default": normalize_frame(output)}
[docs] def compose_frame_grid( frames: Sequence[np.ndarray], layout: Literal["single", "grid", "horizontal", "vertical"] = "grid", ) -> np.ndarray: """Composes multiple frames into one RGB image. Args: frames: Frames to compose. layout: ``"grid"`` (and ``"single"``) tile the frames into a near-square grid, ``"horizontal"`` composes one row, and ``"vertical"`` composes one column. """ if not frames: raise ValueError("Cannot compose an empty frame grid.") normalized = [normalize_frame(frame) for frame in frames] height = max(frame.shape[0] for frame in normalized) width = max(frame.shape[1] for frame in normalized) count = len(normalized) if layout == "horizontal": cols = count elif layout == "vertical": cols = 1 else: cols = math.ceil(math.sqrt(count)) rows = math.ceil(count / cols) grid = np.zeros((rows * height, cols * width, 3), dtype=np.uint8) for index, frame in enumerate(normalized): row = index // cols col = index % cols padded = _pad_frame(frame, height, width) grid[ row * height : (row + 1) * height, col * width : (col + 1) * width ] = padded return grid
[docs] def encode_video( frames: Sequence[np.ndarray], path: str | Path, fps: float, *, video_codec: str | None = None, ) -> Path: """Encodes RGB frames as an MP4 using TorchRL's torchcodec writer.""" path = Path(path) if not frames: raise ValueError("Cannot encode a video with no frames.") array = np.stack([normalize_frame(frame) for frame in frames], axis=0) tensor = torch.as_tensor(array, dtype=torch.uint8) kwargs: dict[str, Any] = {"fps": fps} if video_codec is not None: kwargs["video_codec"] = video_codec _write_video(str(path), tensor, **kwargs) return path
[docs] def encode_gif(frames: Sequence[np.ndarray], path: str | Path, fps: float) -> Path: """Encodes RGB frames as an animated GIF using Pillow.""" if not _has_pil: raise ModuleNotFoundError(_PIL_ERROR) from PIL import Image path = Path(path) if not frames: raise ValueError("Cannot encode a GIF with no frames.") images = [Image.fromarray(normalize_frame(frame)) for frame in frames] duration = max(int(round(1000.0 / fps)), 1) images[0].save( path, save_all=True, append_images=images[1:], duration=duration, loop=0, ) return path
[docs] def write_png(frame: Any, path: str | Path) -> Path: """Writes one RGB frame as a PNG file using Pillow.""" if not _has_pil: raise ModuleNotFoundError(_PIL_ERROR) from PIL import Image path = Path(path) Image.fromarray(normalize_frame(frame)).save(path) return path
def _pad_frame(frame: np.ndarray, height: int, width: int) -> np.ndarray: if frame.shape[0] == height and frame.shape[1] == width: return frame padded = np.zeros((height, width, 3), dtype=np.uint8) padded[: frame.shape[0], : frame.shape[1]] = frame return padded