diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml
index cd2255c..c1e037f 100644
--- a/.github/workflows/push.yml
+++ b/.github/workflows/push.yml
@@ -71,7 +71,8 @@ jobs:
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
set -x
- sudo apt-get install -y xvfb libglu1-mesa x11-utils
+ sudo apt-get update
+ sudo apt-get install -y xvfb libglu1-mesa libegl1-mesa-dev x11-utils
uv sync --all-extras
unzip -o -P ${{ secrets.ROM_PASSWORD }} uncompressed_ROMs.zip
uv run python -m stable_retro.import "uncompressed ROMs"
@@ -81,31 +82,31 @@ jobs:
run: |
uv run python -c "import stable_retro; games = stable_retro.data.list_games(); print(f'Total games: {len(games)}'); assert len(games) > 60, 'ROM import failed'"
- - name: Install MacOS test and package dependencies
- if: ${{ matrix.os == 'macos-latest' }}
- run: |
- set -x
- # XQuartz not needed - rendering tests are skipped on macOS (SKIP_RENDER=True)
- # macOS doesn't support headless rendering (osmesa/egl) - only Linux does
- brew install swig libzip
- uv sync --all-extras
- # Fix a bug in retro.data where it tries to load an inexistent version file
- # Only applies to Python 3.10 where stable-retro is installed
- RETRO_DIR="/Users/runner/work/plangym/plangym/.venv/lib/python${{ matrix.python-version }}/site-packages/retro"
- if [ -d "$RETRO_DIR" ] && [ ! -f "$RETRO_DIR/VERSION.txt" ]; then
- echo "0.9.1" > "$RETRO_DIR/VERSION.txt"
- fi
- - name: Import Retro ROMs (macOS Intel)
- if: ${{ matrix.os == 'macos-latest' && runner.arch == 'X64' }}
- run: |
- set -x
- unzip -o -P ${{ secrets.ROM_PASSWORD }} uncompressed_ROMs.zip
- uv run python -m stable_retro.import "uncompressed ROMs"
-
- - name: Verify ROM import success (macOS Intel)
- if: ${{ matrix.os == 'macos-latest' && runner.arch == 'X64' }}
- run: |
- uv run python -c "import stable_retro; games = stable_retro.data.list_games(); print(f'Total games: {len(games)}'); assert len(games) > 60, 'ROM import failed'"
+ - name: Install MacOS test and package dependencies
+ if: ${{ matrix.os == 'macos-latest' }}
+ run: |
+ set -x
+ # XQuartz not needed - rendering tests are skipped on macOS (SKIP_RENDER=True)
+ # macOS doesn't support headless rendering (osmesa/egl) - only Linux does
+ brew install swig libzip
+ uv sync --all-extras
+ # Fix a bug in retro.data where it tries to load an inexistent version file
+ # Only applies to Python 3.10 where stable-retro is installed
+ RETRO_DIR="/Users/runner/work/plangym/plangym/.venv/lib/python${{ matrix.python-version }}/site-packages/retro"
+ if [ -d "$RETRO_DIR" ] && [ ! -f "$RETRO_DIR/VERSION.txt" ]; then
+ echo "0.9.1" > "$RETRO_DIR/VERSION.txt"
+ fi
+ - name: Import Retro ROMs (macOS Intel)
+ if: ${{ matrix.os == 'macos-latest' && runner.arch == 'X64' }}
+ run: |
+ set -x
+ unzip -o -P ${{ secrets.ROM_PASSWORD }} uncompressed_ROMs.zip
+ uv run python -m stable_retro.import "uncompressed ROMs"
+
+ - name: Verify ROM import success (macOS Intel)
+ if: ${{ matrix.os == 'macos-latest' && runner.arch == 'X64' }}
+ run: |
+ uv run python -c "import stable_retro; games = stable_retro.data.list_games(); print(f'Total games: {len(games)}'); assert len(games) > 60, 'ROM import failed'"
- name: Run Pytest on MacOS
if: ${{ matrix.os == 'macos-latest' }}
@@ -202,7 +203,8 @@ jobs:
UV_SYSTEM_PYTHON: 1
run: |
set -x
- sudo apt-get install -y xvfb libglu1-mesa x11-utils
+ sudo apt-get update
+ sudo apt-get install -y xvfb libglu1-mesa libegl1-mesa-dev x11-utils
uv lock
uv pip install -r pyproject.toml --all-extras
uv pip install dist/*.whl
diff --git a/Dockerfile b/Dockerfile
index a90d779..53d3f4a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,7 @@ FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y --no-install-suggests --no-install-recommends \
make cmake git xvfb build-essential curl ssh wget ca-certificates swig \
+ libegl1-mesa-dev libglu1-mesa libgl1-mesa-glx \
python3 python3-dev && \
wget -O - https://bootstrap.pypa.io/get-pip.py | python3
diff --git a/Makefile b/Makefile
index 8576d7e..478a15a 100644
--- a/Makefile
+++ b/Makefile
@@ -9,6 +9,11 @@ ROM_PASSWORD ?= "NO_PASSWORD"
VERSION ?= latest
MUJOCO_PATH?=~/.mujoco
+.PHONY: install-system-deps
+install-system-deps:
+ sudo apt-get update
+ sudo apt-get install -y xvfb libglu1-mesa libegl1-mesa-dev libgl1-mesa-glx x11-utils
+
.PHONY: install-mujoco
install-mujoco:
mkdir ${MUJOCO_PATH}
@@ -73,24 +78,24 @@ test: test-doctest test-parallel test-singlecore test-ray
.PHONY: test-parallel
test-parallel:
- PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
+ DISPLAY= MUJOCO_GL=egl PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 RAY_RUNTIME_ENV_CREATE_WORKING_DIR=0 \
uv run pytest -n $n -s -o log_cli=true -o log_cli_level=info \
--ignore=tests/vectorization/test_ray.py tests
.PHONY: test-ray
test-ray:
- RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 RAY_RUNTIME_ENV_CREATE_WORKING_DIR=0 MUJOCO_GL=egl \
+ DISPLAY= RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 RAY_RUNTIME_ENV_CREATE_WORKING_DIR=0 MUJOCO_GL=egl \
uv run pytest -s -o log_cli=true -o log_cli_level=info tests/vectorization/test_ray.py
.PHONY: test-singlecore
test-singlecore:
- PYTEST_XDIST_AUTO_NUM_WORKERS=1 PYVIRTUALDISPLAY_DISPLAYFD=0 \
+ DISPLAY= MUJOCO_GL=egl PYTEST_XDIST_AUTO_NUM_WORKERS=1 PYVIRTUALDISPLAY_DISPLAYFD=0 \
uv run pytest -s -o log_cli=true -o log_cli_level=info tests/control/test_classic_control.py
.PHONY: test-doctest
test-doctest:
- PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 \
+ DISPLAY= MUJOCO_GL=egl PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 \
uv run pytest --doctest-modules -n $n -s -o log_cli=true -o log_cli_level=info src
# ============ Code Coverage ============
@@ -103,35 +108,35 @@ codecov-parallel: codecov-parallel-1 codecov-parallel-2 codecov-parallel-3 codec
.PHONY: codecov-parallel-1
codecov-parallel-1:
- PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
+ DISPLAY= MUJOCO_GL=egl PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
uv run pytest -n $n -s -o log_cli=true -o log_cli_level=info \
--cov=./ --cov-report=xml:coverage_parallel_1.xml --cov-config=pyproject.toml \
tests/test_core.py tests/test_registry.py tests/test_utils.py
.PHONY: codecov-parallel-2
codecov-parallel-2:
- PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
+ DISPLAY= MUJOCO_GL=egl PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
uv run pytest -n $n -s -o log_cli=true -o log_cli_level=info \
--cov=./ --cov-report=xml:coverage_parallel_2.xml --cov-config=pyproject.toml \
tests/videogames
.PHONY: codecov-parallel-3
codecov-parallel-3:
- PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
+ DISPLAY= MUJOCO_GL=egl PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
uv run pytest -n $n -s -o log_cli=true -o log_cli_level=info \
--cov=./ --cov-report=xml:coverage_parallel_3.xml --cov-config=pyproject.toml \
tests/control
.PHONY: codecov-vectorization
codecov-vectorization:
- PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
+ DISPLAY= MUJOCO_GL=egl PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_CLASSIC_CONTROL=1 SKIP_RENDER=True \
uv run pytest -n 0 -s -o log_cli=true -o log_cli_level=info \
--cov=./ --cov-report=xml:coverage_vectorization.xml --cov-config=pyproject.toml \
tests/vectorization
.PHONY: codecov-singlecore
codecov-singlecore:
- PYTEST_XDIST_AUTO_NUM_WORKERS=1 PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_RENDER=True \
+ DISPLAY= MUJOCO_GL=egl PYTEST_XDIST_AUTO_NUM_WORKERS=1 PYVIRTUALDISPLAY_DISPLAYFD=0 SKIP_RENDER=True \
uv run pytest --doctest-modules -s -o log_cli=true -o log_cli_level=info \
--cov=./ --cov-report=xml --cov-config=pyproject.toml \
tests/control/test_classic_control.py
diff --git a/Makefile.docker b/Makefile.docker
index 66f0fb2..572f8e6 100644
--- a/Makefile.docker
+++ b/Makefile.docker
@@ -82,5 +82,5 @@ install-python-libs:
install-env-deps:
apt-get update
apt-get install -y --no-install-suggests --no-install-recommends \
- libglfw3 libglew-dev libgl1-mesa-glx libosmesa6 xvfb swig
+ libglfw3 libglew-dev libgl1-mesa-glx libegl1-mesa-dev libosmesa6 xvfb swig
diff --git a/README.md b/README.md
index 1188d1f..a8e89af 100644
--- a/README.md
+++ b/README.md
@@ -59,8 +59,12 @@
Ubuntu / Debian
```bash
+# Install all system dependencies for headless rendering (EGL, GLU, X11)
+make install-system-deps
+
+# Or manually:
sudo apt-get update
-sudo apt-get install -y xvfb libglu1-mesa x11-utils
+sudo apt-get install -y xvfb libglu1-mesa libegl1-mesa-dev libgl1-mesa-glx x11-utils
```
For NES environments (nes-py):
@@ -91,8 +95,12 @@ fi
WSL2 (Windows)
```bash
+# Install all system dependencies for headless rendering (EGL, GLU, X11)
+make install-system-deps
+
+# Or manually:
sudo apt-get update
-sudo apt-get install -y xvfb libglu1-mesa x11-utils
+sudo apt-get install -y xvfb libglu1-mesa libegl1-mesa-dev libgl1-mesa-glx x11-utils
```
For GUI rendering, install an X server on Windows (e.g., VcXsrv) or use headless mode.
diff --git a/src/plangym/control/__init__.py b/src/plangym/control/__init__.py
index 6f10a4c..476e1f2 100644
--- a/src/plangym/control/__init__.py
+++ b/src/plangym/control/__init__.py
@@ -1,8 +1,25 @@
-"""Module that contains environments representing control tasks."""
-
-from plangym.control.balloon import BalloonEnv
-from plangym.control.box_2d import Box2DEnv
-from plangym.control.classic_control import ClassicControl
-from plangym.control.dm_control import DMControlEnv
-from plangym.control.lunar_lander import LunarLander
-from plangym.control.mujoco import MujocoEnv
+"""Module that contains environments representing control tasks.
+
+Imports are lazy so that missing optional dependencies (e.g. mujoco, dm-control)
+do not prevent importing unrelated environment classes.
+"""
+
+import importlib
+
+_SUBMODULES = {
+ "BalloonEnv": "plangym.control.balloon",
+ "Box2DEnv": "plangym.control.box_2d",
+ "ClassicControl": "plangym.control.classic_control",
+ "DMControlEnv": "plangym.control.dm_control",
+ "LunarLander": "plangym.control.lunar_lander",
+ "MujocoEnv": "plangym.control.mujoco",
+}
+
+__all__ = list(_SUBMODULES)
+
+
+def __getattr__(name: str):
+ if name in _SUBMODULES:
+ module = importlib.import_module(_SUBMODULES[name])
+ return getattr(module, name)
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/plangym/environment_names.py b/src/plangym/environment_names.py
index db82855..8e6c8a3 100644
--- a/src/plangym/environment_names.py
+++ b/src/plangym/environment_names.py
@@ -43,19 +43,40 @@
"Walker2d-v5",
]
-try:
- import retro.data
+_RETRO = None
+_DM_CONTROL = None
- RETRO = retro.data.list_games()
-except Exception: # pragma: no cover
- RETRO = []
-try:
- from dm_control import suite
+def _load_retro():
+ global _RETRO
+ if _RETRO is None:
+ try:
+ import retro.data
- DM_CONTROL = list(suite.ALL_TASKS)
-except (ImportError, OSError, AttributeError): # pragma: no cover
- DM_CONTROL = []
+ _RETRO = retro.data.list_games()
+ except Exception: # pragma: no cover
+ _RETRO = []
+ return _RETRO
+
+
+def _load_dm_control():
+ global _DM_CONTROL
+ if _DM_CONTROL is None:
+ try:
+ from dm_control import suite
+
+ _DM_CONTROL = list(suite.ALL_TASKS)
+ except (ImportError, OSError, AttributeError): # pragma: no cover
+ _DM_CONTROL = []
+ return _DM_CONTROL
+
+
+def __getattr__(name: str):
+ if name == "RETRO":
+ return _load_retro()
+ if name == "DM_CONTROL":
+ return _load_dm_control()
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
ATARI = [
diff --git a/src/plangym/registry.py b/src/plangym/registry.py
index 3303e4e..f085a06 100644
--- a/src/plangym/registry.py
+++ b/src/plangym/registry.py
@@ -1,10 +1,12 @@
"""Functionality for instantiating the environment by passing the environment id."""
-from plangym.environment_names import ATARI, BOX_2D, CLASSIC_CONTROL, DM_CONTROL, MUJOCO, RETRO
+from plangym.environment_names import ATARI, BOX_2D, CLASSIC_CONTROL, MUJOCO
def get_planenv_class(name, domain_name, state):
"""Return the class corresponding to the environment name."""
+ from plangym.environment_names import DM_CONTROL, RETRO
+
# if name == "MinimalPacman-v0":
# return MinimalPacman
# elif name == "MinimalPong-v0":
diff --git a/src/plangym/scripts/import_retro_roms.py b/src/plangym/scripts/import_retro_roms.py
index 676b888..7cd551d 100644
--- a/src/plangym/scripts/import_retro_roms.py
+++ b/src/plangym/scripts/import_retro_roms.py
@@ -16,7 +16,7 @@
try:
import retro.data
except Exception:
- sys.exit(0)
+ retro = None
flogging.setup()
logger = logging.getLogger("import-roms")
diff --git a/src/plangym/videogames/__init__.py b/src/plangym/videogames/__init__.py
index 8616eab..ba1ec5d 100644
--- a/src/plangym/videogames/__init__.py
+++ b/src/plangym/videogames/__init__.py
@@ -1,18 +1,36 @@
"""Module that contains environments representing video games.
+Imports are lazy so that missing optional dependencies (e.g. ale-py, stable-retro)
+do not prevent importing unrelated environment classes.
+
.. note::
**Python Version Compatibility**:
- ``RetroEnv``: Requires ``stable-retro``, only available on Python 3.10.
"""
+import importlib
+
from plangym import warn_import_error
-from plangym.videogames.atari import AtariEnv
-from plangym.videogames.montezuma import MontezumaEnv
-from plangym.videogames.nes import MarioEnv
-
-try:
- from plangym.videogames.retro import RetroEnv
-except ImportError:
- RetroEnv = None
- warn_import_error("RetroEnv", "stable-retro is only available on Python 3.10.")
+
+_SUBMODULES = {
+ "AtariEnv": "plangym.videogames.atari",
+ "MontezumaEnv": "plangym.videogames.montezuma",
+ "MarioEnv": "plangym.videogames.nes",
+ "RetroEnv": "plangym.videogames.retro",
+}
+
+__all__ = list(_SUBMODULES)
+
+
+def __getattr__(name: str):
+ if name in _SUBMODULES:
+ try:
+ module = importlib.import_module(_SUBMODULES[name])
+ return getattr(module, name)
+ except ImportError:
+ if name == "RetroEnv":
+ warn_import_error("RetroEnv", "stable-retro is only available on Python 3.10.")
+ return None
+ raise
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/tests/control/test_mujoco.py b/tests/control/test_mujoco.py
index 145497f..05b0d35 100644
--- a/tests/control/test_mujoco.py
+++ b/tests/control/test_mujoco.py
@@ -1,4 +1,5 @@
import operator
+import os
import sys
import numpy
@@ -66,6 +67,41 @@ def test_mujoco_attributes(self, env):
assert unwrapped.model.nv > 0 # Has velocity coordinates
+class TestMujocoImageExtraction:
+ """Test that RGB images can be extracted with non-image obs_type."""
+
+ @pytest.mark.skipif(os.getenv("SKIP_RENDER", False), reason="No display in CI.")
+ def test_coords_obs_with_image_extraction(self):
+ """Verify get_image() returns RGB when using obs_type='coords' (default)."""
+ env = MujocoEnv("Ant-v4", render_mode="rgb_array", autoreset=False)
+ _state, obs, _info = env.reset()
+ # Coords observation should be a 1D float array
+ assert obs.ndim == 1
+ assert numpy.issubdtype(obs.dtype, numpy.floating)
+ # get_image() should return a valid RGB image
+ img = env.get_image()
+ assert img.ndim == 3
+ assert img.shape[2] == 3
+ assert img.dtype == numpy.uint8
+ env.close()
+
+ @pytest.mark.skipif(os.getenv("SKIP_RENDER", False), reason="No display in CI.")
+ def test_coords_obs_return_image(self):
+ """Verify return_image=True populates info['rgb'] with obs_type='coords'."""
+ env = MujocoEnv("Ant-v4", render_mode="rgb_array", return_image=True, autoreset=False)
+ _state, obs, info = env.reset()
+ assert "rgb" in info
+ assert info["rgb"].ndim == 3
+ assert info["rgb"].shape[2] == 3
+ # Also check step
+ obs, _reward, _term, _trunc, info = env.step(env.sample_action())
+ assert obs.ndim == 1
+ assert "rgb" in info
+ assert info["rgb"].ndim == 3
+ assert info["rgb"].shape[2] == 3
+ env.close()
+
+
class TestMujocoParallel:
"""Test MujocoEnv parallel execution for planning reliability."""
diff --git a/tests/videogames/test_atari.py b/tests/videogames/test_atari.py
index 56aa484..1a93058 100644
--- a/tests/videogames/test_atari.py
+++ b/tests/videogames/test_atari.py
@@ -47,6 +47,42 @@ def test_get_image(self):
obs = env.get_image()
assert isinstance(obs, numpy.ndarray)
+ def test_ram_obs_with_image_extraction(self):
+ """Verify that RGB images can be extracted when using obs_type='ram'."""
+ env = qbert_ram()
+ _state, obs, _info = env.reset()
+ # RAM observation should be 1D uint8 array of shape (128,)
+ assert obs.ndim == 1
+ assert obs.shape == (128,)
+ assert obs.dtype == numpy.uint8
+ # get_image() should still return a valid RGB image
+ img = env.get_image()
+ assert img.ndim == 3
+ assert img.shape == (210, 160, 3)
+ assert img.dtype == numpy.uint8
+ env.close()
+
+ def test_ram_obs_return_image(self):
+ """Verify return_image=True populates info['rgb'] with obs_type='ram'."""
+ env = AtariEnv(
+ name="ALE/Qbert-v5",
+ obs_type="ram",
+ return_image=True,
+ clone_seeds=False,
+ autoreset=False,
+ )
+ _state, obs, info = env.reset()
+ assert "rgb" in info
+ assert info["rgb"].ndim == 3
+ assert info["rgb"].shape == (210, 160, 3)
+ # Also check step
+ obs, _reward, _term, _trunc, info = env.step(env.sample_action())
+ assert obs.shape == (128,)
+ assert "rgb" in info
+ assert info["rgb"].ndim == 3
+ assert info["rgb"].shape == (210, 160, 3)
+ env.close()
+
def test_n_actions(self, env):
n_actions = env.n_actions
assert isinstance(n_actions, int | np.int64)