Source code for gymnasium.wrappers.vector.rendering

"""File for rendering and recording of vector-based environments."""

from __future__ import annotations

import gc
import os
from collections.abc import Callable, Sequence
from copy import deepcopy
from typing import TYPE_CHECKING, Any

import numpy as np

import gymnasium as gym
from gymnasium import error, logger
from gymnasium.core import ActType, ObsType, RenderFrame
from gymnasium.error import DependencyNotInstalled
from gymnasium.logger import warn
from gymnasium.vector import VectorEnv, VectorWrapper
from gymnasium.vector.vector_env import ArrayType

if TYPE_CHECKING:
    import pygame


[docs] class HumanRendering( VectorWrapper[ObsType, ActType, ObsType, ActType, ArrayType], gym.utils.RecordConstructorArgs, ): """Adds support for Human-based Rendering for Vector-based environments.""" ACCEPTED_RENDER_MODES = [ "rgb_array", "rgb_array_list", "depth_array", "depth_array_list", ] screen_size: tuple[int, int] | None scaled_subenv_size: tuple[int, int] | None num_rows: int | None num_cols: int | None window: pygame.Surface | None clock: pygame.time.Clock | None metadata: dict[str, Any] def __init__( self, env: VectorEnv[ObsType, ActType, ArrayType], screen_size: tuple[int, int] | None = None, ): """Constructor for Human Rendering of Vector-based environments. Args: env: The vector environment screen_size: The rendering screen size otherwise the environment sub-env render size is used """ VectorWrapper.__init__(self, env) gym.utils.RecordConstructorArgs.__init__(self, screen_size=screen_size) self.screen_size = screen_size self.scaled_subenv_size, self.num_rows, self.num_cols = None, None, None self.window = None # Has to be initialized before asserts, as self.window is used in auto close self.clock = None if self.env.render_mode not in self.ACCEPTED_RENDER_MODES: raise ValueError( f"Expected env.render_mode to be one of {self.ACCEPTED_RENDER_MODES} but got '{self.env.render_mode}'" ) if "render_fps" not in self.env.metadata: raise ValueError( "The base environment must specify 'render_fps' to be used with the HumanRendering wrapper" ) if "human" not in self.metadata["render_modes"]: self.metadata = deepcopy(self.env.metadata) self.metadata["render_modes"].append("human") @property def render_mode(self) -> str: """Always returns ``'human'``.""" return "human" def step( self, actions: ActType ) -> tuple[ObsType, ArrayType, ArrayType, ArrayType, dict[str, Any]]: """Perform a step in the base environment and render a frame to the screen.""" result = super().step(actions) self._render_frame() return result def reset( self, *, seed: int | None = None, options: dict[str, Any] | None = None, ) -> tuple[ObsType, dict[str, Any]]: """Reset the base environment and render a frame to the screen.""" result = super().reset(seed=seed, options=options) self._render_frame() return result def _render_frame(self): """Fetch the last frame from the base environment and render it to the screen.""" try: import pygame except ImportError as e: raise DependencyNotInstalled( "pygame is not installed, run `pip install gymnasium[classic-control]`" ) from e assert self.env.render_mode is not None if self.env.render_mode.endswith("_last"): subenv_renders = self.env.render() assert isinstance(subenv_renders, list) subenv_renders = subenv_renders[-1] else: subenv_renders = self.env.render() assert subenv_renders is not None assert len(subenv_renders) == self.num_envs assert all(isinstance(render, np.ndarray) for render in subenv_renders), ( f"Expected `env.render()` to return a numpy array, actually returned {[type(render) for render in subenv_renders]}" ) subenv_renders = np.array(subenv_renders, dtype=np.uint8) subenv_renders = np.transpose(subenv_renders, axes=(0, 2, 1, 3)) # shape = (num envs, width, height, channels) if self.screen_size is None: self.screen_size = subenv_renders.shape[1:3] if self.scaled_subenv_size is None: subenv_size = subenv_renders.shape[1:3] width_ratio = subenv_size[0] / self.screen_size[0] height_ratio = subenv_size[1] / self.screen_size[1] num_rows, num_cols = 1, 1 while num_rows * num_cols < self.num_envs: row_ratio = num_rows * height_ratio col_ratio = num_cols * width_ratio if row_ratio == col_ratio: num_rows, num_cols = num_rows + 1, num_cols + 1 elif row_ratio > col_ratio: num_cols += 1 else: num_rows += 1 scaling_factor = min( self.screen_size[0] / (num_cols * subenv_size[0]), self.screen_size[1] / (num_rows * subenv_size[1]), ) assert ( num_cols * subenv_size[0] * scaling_factor == self.screen_size[0] ) or (num_rows * subenv_size[1] * scaling_factor == self.screen_size[1]) self.num_rows: int = num_rows self.num_cols: int = num_cols self.scaled_subenv_size = ( int(subenv_size[0] * scaling_factor), int(subenv_size[1] * scaling_factor), ) assert self.num_rows * self.num_cols >= self.num_envs assert self.scaled_subenv_size[0] * self.num_cols <= self.screen_size[0] assert self.scaled_subenv_size[1] * self.num_rows <= self.screen_size[1] # print(f'{self.num_envs=}, {self.num_rows=}, {self.num_cols=}, {self.screen_size=}, {self.scaled_subenv_size=}') try: import cv2 except ImportError as e: raise DependencyNotInstalled( 'opencv (cv2) is not installed, run `pip install "gymnasium[other]"`' ) from e merged_rgb_array = np.zeros(self.screen_size + (3,), dtype=np.uint8) assert self.num_cols is not None assert self.num_rows is not None cols, rows = np.meshgrid(np.arange(self.num_cols), np.arange(self.num_rows)) for i, col, row in zip(range(self.num_envs), cols.flatten(), rows.flatten()): # noqa: B905 scaled_render = cv2.resize(subenv_renders[i], self.scaled_subenv_size[::-1]) x = col * self.scaled_subenv_size[0] y = row * self.scaled_subenv_size[1] merged_rgb_array[ x : x + self.scaled_subenv_size[0], y : y + self.scaled_subenv_size[1], ] = scaled_render if self.window is None: pygame.display.init() self.window = pygame.display.set_mode(self.screen_size) if self.clock is None: self.clock = pygame.time.Clock() surf = pygame.surfarray.make_surface(merged_rgb_array) self.window.blit(surf, (0, 0)) pygame.event.pump() self.clock.tick(self.metadata["render_fps"]) pygame.display.flip() def close(self, **kwargs: Any) -> None: """Close the rendering window.""" if self.window is not None: import pygame pygame.display.quit() pygame.quit() super().close(**kwargs)
[docs] class RecordVideo( VectorWrapper[ObsType, ActType, ObsType, ActType, np.ndarray], gym.utils.RecordConstructorArgs, ): """Adds support for video recording for Vector-based environments. The class is the same as gymnasium.wrappers.rendering.RecordVideo, but expects multiple frames when rendering the environment (one for each environment of the VectorEnv). Frames are concatenated into one frame such that its aspect ratio is as close as possible to the desired one. Examples - Run 5 environments for 200 timesteps, and save the video every 5 episodes: >>> import os >>> import gymnasium as gym >>> from gymnasium.wrappers.vector import RecordVideo >>> envs = gym.make_vec("CartPole-v1", num_envs=5, render_mode="rgb_array") >>> envs = RecordVideo( ... envs, ... video_folder="save_videos_5envs", ... video_aspect_ratio=(1,1), ... episode_trigger=lambda t: t % 5 == 0, ... ) >>> _ = envs.reset(seed=123) >>> _ = envs.action_space.seed(123) >>> for i in range(200): ... actions = envs.action_space.sample() ... _ = envs.step(actions) >>> envs.close() >>> len(os.listdir("save_videos_5envs")) 2 """ autoreset_mode: gym.vector.AutoresetMode has_autoreset: bool episode_trigger: Callable[[int], bool] | None step_trigger: Callable[[int], bool] | None gc_trigger: Callable[[int], bool] | None disable_logger: bool record_first_only: bool video_aspect_ratio: tuple[int, int] frame_cols: int frame_rows: int video_folder: str frames_per_sec: int name_prefix: str _video_name: str | None video_length: float recording: bool recorded_frames: list[np.ndarray] render_history: list[np.ndarray] step_id: int episode_id: int def __init__( self, env: VectorEnv[ObsType, ActType, np.ndarray], video_folder: str, video_aspect_ratio: tuple[int, int] = (1, 1), record_first_only: bool = False, episode_trigger: Callable[[int], bool] | None = None, step_trigger: Callable[[int], bool] | None = None, video_length: int = 0, name_prefix: str = "rl-video", fps: int | None = None, disable_logger: bool = True, gc_trigger: Callable[[int], bool] | None = lambda episode: True, ): """Wrapper records videos of environment rollouts. Args: env: The environment that will be wrapped video_folder (str): The folder where the recordings will be stored video_aspect_ratio (tuple): the desired aspect ratio of the video concatenating all environments. For example, (1, 1) means an aspect ratio of 1:1, while (16, 9) means 16:9. record_first_only (bool): if True, only the first environment is recorded. episode_trigger: Function that accepts an integer and returns ``True`` iff a recording should be started at this episode step_trigger: Function that accepts an integer and returns ``True`` iff a recording should be started at this step video_length (int): The length of recorded episodes. If 0, entire episodes are recorded. Otherwise, snippets of the specified length are captured name_prefix (str): Will be prepended to the filename of the recordings fps (int): The frame per second in the video. Provides a custom video fps for environment, if ``None`` then the environment metadata ``render_fps`` key is used if it exists, otherwise a default value of 30 is used. disable_logger (bool): Whether to disable moviepy logger or not, default it is disabled gc_trigger: Function that accepts an integer and returns ``True`` iff garbage collection should be performed after this episode Note: For vector environments that use same-step autoreset (see https://farama.org/Vector-Autoreset-Mode for more details) then the final frame of the episode will not be included in the video. """ VectorWrapper.__init__(self, env) gym.utils.RecordConstructorArgs.__init__( self, video_folder=video_folder, episode_trigger=episode_trigger, step_trigger=step_trigger, video_length=video_length, name_prefix=name_prefix, disable_logger=disable_logger, ) if env.render_mode in {None, "human", "ansi"}: raise ValueError( f"Render mode is {env.render_mode}, which is incompatible with RecordVideo. " "Initialize your environment with a render_mode that returns an image, such as rgb_array." ) if episode_trigger is None and step_trigger is None: from gymnasium.utils.save_video import capped_cubic_video_schedule episode_trigger = capped_cubic_video_schedule autoreset_mode = env.metadata.get("autoreset_mode", None) if autoreset_mode is not None: self.autoreset_mode = autoreset_mode else: warn( f"{env} metadata doesn't specify its autoreset mode ({env.metadata!r}), therefore, defaulting to next step." ) self.autoreset_mode = gym.vector.AutoresetMode.NEXT_STEP if self.autoreset_mode == gym.vector.AutoresetMode.SAME_STEP: logger.warn( "Vector environment's autoreset mode is same-step (https://farama.org/Vector-Autoreset-Mode). Recorded episodes will not contain the last frame of the episode." ) self.has_autoreset = False self.episode_trigger = episode_trigger self.step_trigger = step_trigger self.disable_logger = disable_logger self.gc_trigger = gc_trigger self.record_first_only = record_first_only self.video_aspect_ratio = video_aspect_ratio self.frame_cols = -1 self.frame_rows = -1 self.video_folder = os.path.abspath(video_folder) if os.path.isdir(self.video_folder): logger.warn( f"Overwriting existing videos at {self.video_folder} folder " f"(try specifying a different `video_folder` for the `RecordVideo` wrapper if this is not desired)" ) os.makedirs(self.video_folder, exist_ok=True) if fps is None: fps = self.metadata.get("render_fps", 30) self.frames_per_sec = fps self.name_prefix = name_prefix self._video_name = None self.video_length = video_length if video_length != 0 else float("inf") self.recording = False self.recorded_frames = [] self.render_history = [] self.step_id = -1 self.episode_id = -1 try: import moviepy # noqa: F401 except ImportError as e: raise error.DependencyNotInstalled( 'MoviePy is not installed, run `pip install "gymnasium[other]"`' ) from e def _get_concat_frame_shape(self, n_frames, h, w): """Finds the right shape to concatenate frames from all environments into one frame.""" target_video_aspect_ratio = ( self.video_aspect_ratio[0] / self.video_aspect_ratio[1] ) best_rows, best_cols = 1, n_frames min_diff = np.inf for rows in range(1, int(n_frames**0.5) + 1): if n_frames % rows == 0: cols = n_frames // rows total_height = rows * h total_width = cols * w aspect = total_width / total_height diff = abs(aspect - target_video_aspect_ratio) if diff < min_diff: min_diff = diff best_rows, best_cols = rows, cols self.frame_rows = best_rows self.frame_cols = best_cols def _concat_frames(self, frames: np.typing.ArrayLike) -> np.ndarray: """Concatenates a list of frames into one large frame.""" frames = np.array(frames) n_frames, h, w, c = frames.shape grid = np.zeros( (self.frame_rows * h, self.frame_cols * w, c), dtype=frames.dtype ) for idx in range(n_frames): r = idx // self.frame_cols c_ = idx % self.frame_cols grid[r * h : (r + 1) * h, c_ * w : (c_ + 1) * w] = frames[idx] return grid def _capture_frame(self) -> None: assert self.recording, "Cannot capture a frame, recording wasn't started." envs_frame = self.env.render() assert isinstance(envs_frame, Sequence), type(envs_frame) assert len(envs_frame) == self.num_envs if self.record_first_only: envs_frame = [envs_frame[0]] if self.frame_cols == -1 or self.frame_rows == -1: n_frames = len(envs_frame) h, w, c = envs_frame[0].shape self._get_concat_frame_shape(n_frames, h, w) concatenated_envs_frame = self._concat_frames(envs_frame) self.recorded_frames.append(concatenated_envs_frame) def reset( self, *, seed: int | None = None, options: dict[str, Any] | None = None ) -> tuple[ObsType, dict[str, Any]]: """Reset the environment and eventually starts a new recording.""" if options is None or "reset_mask" not in options or options["reset_mask"][0]: self.episode_id += 1 if self.recording and self.video_length == float("inf"): self.stop_recording() if self.episode_trigger and self.episode_trigger(self.episode_id): self.start_recording(f"{self.name_prefix}-episode-{self.episode_id}") obs, info = super().reset(seed=seed, options=options) if self.recording: self._capture_frame() if len(self.recorded_frames) > self.video_length: self.stop_recording() self.has_autoreset = False return obs, info def step( self, actions: ActType ) -> tuple[ObsType, np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]: """Steps through the environment using action, recording observations if :attr:`self.recording`.""" obs, rewards, terminations, truncations, info = self.env.step(actions) self.step_id += 1 if self.autoreset_mode == gym.vector.AutoresetMode.NEXT_STEP: if self.has_autoreset: self.episode_id += 1 if self.recording and self.video_length == float("inf"): self.stop_recording() if self.episode_trigger and self.episode_trigger(self.episode_id): self.start_recording( f"{self.name_prefix}-episode-{self.episode_id}" ) self.has_autoreset = terminations[0] or truncations[0] elif self.autoreset_mode == gym.vector.AutoresetMode.SAME_STEP and ( terminations[0] or truncations[0] ): self.episode_id += 1 if self.recording and self.video_length == float("inf"): self.stop_recording() if self.episode_trigger and self.episode_trigger(self.episode_id): self.start_recording(f"{self.name_prefix}-episode-{self.episode_id}") if self.step_trigger and self.step_trigger(self.step_id): self.start_recording(f"{self.name_prefix}-step-{self.step_id}") if self.recording: self._capture_frame() if len(self.recorded_frames) > self.video_length: self.stop_recording() return obs, rewards, terminations, truncations, info def render(self) -> RenderFrame | list[RenderFrame]: """Compute the render frames as specified by render_mode attribute during initialization of the environment.""" render_out = super().render() if self.recording and isinstance(render_out, list): self.recorded_frames += render_out if len(self.render_history) > 0: tmp_history = self.render_history self.render_history = [] return tmp_history + render_out else: return render_out def close(self, **kwargs: Any) -> None: """Closes the wrapper then the video recorder.""" super().close(**kwargs) if self.recording: self.stop_recording() def start_recording(self, video_name: str) -> None: """Start a new recording. If it is already recording, stops the current recording before starting the new one.""" if self.recording: self.stop_recording() self.recording = True self._video_name = video_name def stop_recording(self) -> None: """Stop current recording and saves the video.""" assert self.recording, "stop_recording was called, but no recording was started" if len(self.recorded_frames) == 0: logger.warn("Ignored saving a video as there were zero frames to save.") else: try: from moviepy.video.io.ImageSequenceClip import ImageSequenceClip except ImportError as e: raise error.DependencyNotInstalled( 'MoviePy is not installed, run `pip install "gymnasium[other]"`' ) from e clip = ImageSequenceClip(self.recorded_frames, fps=self.frames_per_sec) moviepy_logger = None if self.disable_logger else "bar" path = os.path.join(self.video_folder, f"{self._video_name}.mp4") clip.write_videofile(path, logger=moviepy_logger) del clip del self.recorded_frames self.recorded_frames = [] self.recording = False self._video_name = None if self.gc_trigger and self.gc_trigger(self.episode_id): gc.collect() def __del__(self) -> None: """Warn the user in case last video wasn't saved.""" # `__init__` can raise before `recorded_frames` is assigned, and `__del__` # still runs on the half-built wrapper. Reading the attribute directly # raised an AttributeError that Python reports as "Exception ignored in", # on top of the error that actually stopped construction. if getattr(self, "recorded_frames", None): logger.warn("Unable to save last video! Did you call close()?")