Source code for simulusion.envs.gym_env

from __future__ import annotations

from typing import Any
import warnings

from simulusion.envs._actions import clip_box_action, encode_discrete_action
from simulusion.envs._contract import finalize_step_info
from simulusion.envs._observations import (
    add_optional_state_space,
    flatten_vector_observation,
    infer_state_space_from_observation_space,
)
from simulusion.utils.gym_compat import gym, import_gymnasium
import numpy as np


from PIL import Image


[docs] def make_gym_env(env: Any, **kwargs: Any) -> Any: """Create a GymImageEnv wrapper for generic Gym/Gymnasium environments. Args: env: Either a string environment ID (e.g., "Pendulum-v1") or a pre-built gym environment instance. **kwargs: Additional keyword arguments passed to GymImageEnv, including: - seed (int): Random seed for environment (default: 0) - size (tuple): Target image size as (height, width) (default: (64, 64)) - render_mode (str): Render mode for environment (default: "rgb_array") Returns: GymImageEnv: A wrapper that always returns image observations in the format {"image": (C, H, W)} suitable for pixel-based world models. """ return GymImageEnv(env=env, **kwargs)
[docs] class GymImageEnv: """Gym-like environment wrapper that always returns image observations. This wrapper normalizes diverse environment interfaces to return consistent image-based observations suitable for pixel-based world models like Dreamer. Features: - Supports environment IDs (string) and pre-built environment objects. - For vector observations, uses the environment's own ``render()`` frame, and only synthesizes band images from the vector when rendering fails. - Exposes continuous action spaces mapped to [-1, 1] range. - Converts discrete actions to one-hot vectors. - Returns observations as dicts with required key ``"image"`` and optional key ``"state"`` when ``include_state=True`` and a vector observation is available. Args: env: Either a string environment ID (e.g., "Pendulum-v1") or a pre-built gym environment instance. seed (int): Random seed for environment reset (default: 0). size (tuple): Target image size as (height, width) (default: (64, 64)). render_mode (str): Render mode for environment (default: "rgb_array"). include_state (bool): Include a flattened low-dimensional ``"state"`` key in observations when one can be derived from the underlying env. Attributes: observation_space: Dict space with required ``"image"`` key and optional ``"state"`` key when enabled. action_space: Box space with actions in [-1, 1] range. max_episode_steps: Maximum steps per episode (default: 1000). """ def __init__( self, env: Any, seed: int = 0, size: tuple[int, int] = (64, 64), render_mode: str = "rgb_array", include_state: bool = False, ) -> None: self._size = (int(size[0]), int(size[1])) self._seed = seed self._render_mode = render_mode self._include_state = bool(include_state) self._seed_applied = False self._rng = np.random.default_rng(seed) if isinstance(env, str): self._env = self._make_env_from_id(env, render_mode) else: self._env = env self._last_obs: Any = None self._last_image: Any = None self._warned_band_fallback = False action_space = getattr(self._env, "action_space", None) if action_space is None: raise ValueError("Wrapped environment must define action_space.") self._discrete_n = int(action_space.n) if hasattr(action_space, "n") else None if self._discrete_n is None: low = np.asarray(action_space.low, dtype=np.float32) high = np.asarray(action_space.high, dtype=np.float32) self._action_space = gym.spaces.Box(low=low, high=high, dtype=np.float32) else: self._action_space = gym.spaces.Box( low=-1.0, high=1.0, shape=(self._discrete_n,), dtype=np.float32 ) self._action_space.sample = self._sample_discrete_action self._seed_spaces(seed) self._state_space = ( infer_state_space_from_observation_space( getattr(self._env, "observation_space", None) ) if self._include_state else None ) self._observation_space = add_optional_state_space( gym.spaces.Box( low=0, high=255, shape=(3, self._size[0], self._size[1]), dtype=np.uint8, ), state_space=self._state_space, ) def _make_env_from_id(self, env_id: str, render_mode: str) -> Any: # Prefer gymnasium if available, then fallback to gym. gymnasium = import_gymnasium() if gymnasium is not None: try: return gymnasium.make(env_id, render_mode=render_mode) except ImportError as exc: from simulusion.envs.robotics_env import ( is_moved_mujoco_error, register_gymnasium_robotics_envs, ) if not is_moved_mujoco_error(exc): raise register_gymnasium_robotics_envs() try: return gymnasium.make(env_id, render_mode=render_mode) except TypeError: return gymnasium.make(env_id) except TypeError: return gymnasium.make(env_id) try: return gym.make(env_id, render_mode=render_mode) except TypeError: return gym.make(env_id) @property def observation_space(self) -> gym.spaces.Dict: return self._observation_space @property def action_space(self) -> gym.spaces.Box: return self._action_space @property def max_episode_steps(self) -> int: if ( hasattr(self._env, "_max_episode_steps") and self._env._max_episode_steps is not None ): return int(self._env._max_episode_steps) if ( getattr(self._env, "spec", None) is not None and getattr(self._env.spec, "max_episode_steps", None) is not None ): return int(self._env.spec.max_episode_steps) return 1000 def _seed_spaces(self, seed: int | None) -> None: if seed is None: return for space in ( self._action_space, getattr(self._env, "action_space", None), getattr(self._env, "observation_space", None), ): if space is not None and hasattr(space, "seed"): try: space.seed(seed) except Exception: pass def _sample_discrete_action(self) -> np.ndarray: assert self._discrete_n is not None idx = int(self._rng.integers(0, self._discrete_n)) action = -np.ones((self._discrete_n,), dtype=np.float32) action[idx] = 1.0 return action def _vector_to_image(self, vector: Any) -> np.ndarray: vec: np.ndarray = np.asarray(vector, dtype=np.float32).reshape(-1) if vec.size == 0: return np.zeros((self._size[0], self._size[1], 3), dtype=np.uint8) vmin = float(vec.min()) vmax = float(vec.max()) if vmax > vmin: vec_norm = (vec - vmin) / (vmax - vmin) vec = vec_norm else: vec = np.zeros_like(vec) image = np.zeros((self._size[0], self._size[1], 3), dtype=np.uint8) bands = min(8, vec.size) band_w = max(1, self._size[1] // max(1, bands)) for i in range(bands): start = i * band_w end = min(self._size[1], start + band_w) image[:, start:end, :] = int(255.0 * float(vec[i])) return image def _obs_to_hwc_image(self, obs: Any) -> np.ndarray | None: if isinstance(obs, tuple): obs = obs[0] if isinstance(obs, dict): for key in ("image", "pixels", "rgb", "observation"): if key in obs: candidate = np.asarray(obs[key]) if candidate.ndim in (1, 2, 3): return self._obs_to_hwc_image(candidate) for value in obs.values(): candidate = np.asarray(value) if candidate.ndim in (1, 2, 3): return self._obs_to_hwc_image(candidate) return None arr = np.asarray(obs) if arr.ndim == 1: image = self._vector_to_image(arr) elif arr.ndim == 2: image = np.repeat(arr[..., None], 3, axis=-1) elif arr.ndim == 3: image = arr if image.shape[-1] not in (1, 3, 4) and image.shape[0] in (1, 3, 4): image = image.transpose(1, 2, 0) if image.shape[-1] == 1: image = np.repeat(image, 3, axis=-1) elif image.shape[-1] == 4: image = image[..., :3] else: return None image = np.asarray(image) if image.dtype != np.uint8: image = image.astype(np.float32) if image.size > 0 and image.max() <= 1.0: image = (image * 255.0).clip(0, 255).astype(np.uint8) else: image = image.clip(0, 255).astype(np.uint8) return image def _render_hwc_image(self, last_obs: Any = None) -> np.ndarray | None: frame = None try: frame = self._env.render() except Exception: frame = None if isinstance(frame, tuple): frame = frame[0] if isinstance(frame, np.ndarray): return frame try: frame = self._env.render(mode=self._render_mode) except Exception: frame = None if isinstance(frame, tuple): frame = frame[0] if isinstance(frame, np.ndarray): return frame if last_obs is not None: return self._obs_to_hwc_image(last_obs) return None @staticmethod def _is_vector_observation(obs: Any) -> bool: """True when ``obs`` carries no pixels, only low-dimensional numbers.""" if isinstance(obs, tuple): obs = obs[0] values = obs.values() if isinstance(obs, dict) else (obs,) for value in values: try: if np.asarray(value).ndim >= 2: return False except Exception: continue return True def _rendered_frame(self) -> np.ndarray | None: frame = self._render_hwc_image() if not isinstance(frame, np.ndarray) or frame.ndim not in (2, 3): return None return self._obs_to_hwc_image(frame) def _to_chw_uint8_image(self, obs: Any) -> np.ndarray: image = None if self._is_vector_observation(obs): # A state vector has no pixels of its own. The env's renderer draws # the actual scene (Pendulum's pole, CartPole's cart); painting the # numbers as grey bands is a last resort, and a world model trained # on bands learns nothing a person can look at. image = self._rendered_frame() if image is None and not self._warned_band_fallback: warnings.warn( "GymImageEnv: the environment returns vector observations and " "render() produced no RGB frame, so observations are synthesized " "as grey bands (one per state value). Create the env with " "render_mode='rgb_array' to train on real pixels.", stacklevel=3, ) self._warned_band_fallback = True if image is None: image = self._obs_to_hwc_image(obs) if image is None: image = self._render_hwc_image(last_obs=obs) if image is None: raise RuntimeError( "Failed to obtain an RGB frame from environment observation or render()." ) if image.shape[0] != self._size[0] or image.shape[1] != self._size[1]: image = np.array( Image.fromarray(image).resize( (self._size[1], self._size[0]), Image.Resampling.BILINEAR ) ) return image.transpose(2, 0, 1).copy() def _to_native_action( self, action: Any ) -> tuple[np.ndarray, np.ndarray] | tuple[int, np.ndarray]: if self._discrete_n is None: low = np.asarray(self._env.action_space.low, dtype=np.float32) high = np.asarray(self._env.action_space.high, dtype=np.float32) clipped = clip_box_action(action, low, high) return clipped, clipped.copy() idx, encoded = encode_discrete_action(action, self._discrete_n) return idx, encoded def _build_observation(self, raw_obs: Any, image: np.ndarray) -> dict[str, Any]: observation = {"image": image} vector_observation = flatten_vector_observation(raw_obs) if self._state_space is not None: if vector_observation is None: raise RuntimeError( "include_state=True but no low-dimensional state could be extracted." ) observation["state"] = vector_observation.copy() return observation
[docs] def reset(self, seed: int | None = None) -> dict[str, Any]: if seed is not None: self._seed = int(seed) self._rng = np.random.default_rng(self._seed) self._seed_spaces(self._seed) self._seed_applied = False self._last_obs = None self._last_image = None if not self._seed_applied: try: result = self._env.reset(seed=self._seed) except TypeError: result = self._env.reset() self._seed_applied = True else: result = self._env.reset() obs = result[0] if isinstance(result, tuple) else result self._last_obs = obs image = self._to_chw_uint8_image(obs) self._last_image = image return self._build_observation(obs, image)
[docs] def step(self, action: Any) -> tuple[dict[str, Any], float, bool, dict[str, Any]]: native_action, model_action = self._to_native_action(action) result = self._env.step(native_action) if len(result) == 5: obs, reward, terminated, truncated, info = result done = bool(terminated or truncated) else: obs, reward, done, info = result done = bool(done) truncated = bool(info.get("TimeLimit.truncated", False)) if info else False terminated = bool(done and not truncated) info = finalize_step_info( info, done=done, terminated=terminated, truncated=truncated, ) info["action"] = np.asarray(model_action, dtype=np.float32).copy() info["executed_action"] = ( int(np.asarray(native_action).item()) if np.isscalar(native_action) else np.asarray(native_action, dtype=np.float32).copy() ) vector_observation = flatten_vector_observation(obs) if vector_observation is not None: info["vector_observation"] = vector_observation.copy() self._last_obs = obs image = self._to_chw_uint8_image(obs) self._last_image = image return self._build_observation(obs, image), float(reward), done, info
[docs] def render(self, *args: Any, **kwargs: Any) -> np.ndarray: frame = self._render_hwc_image(last_obs=self._last_obs) if frame is None: if self._last_image is None: raise RuntimeError("No frame available. Call reset() before render().") return self._last_image.transpose(1, 2, 0).copy() return frame
[docs] def close(self) -> None: if hasattr(self._env, "close"): self._env.close()