env_assembly_bench / isaaclab_env_wrapper.py
lukasskellijs's picture
fix(envhub): unwrap gym wrappers + keep sim alive through settle/step
2a42b0d verified
Raw
History Blame Contribute Delete
6.84 kB
"""VectorEnv wrapper adapting Isaac Lab's GPU-batched env for LeRobot EnvHub."""
from __future__ import annotations
import atexit
import logging
import os
import signal
from contextlib import suppress
from typing import Any
import gymnasium as gym
import numpy as np
import torch
try:
from .errors import IsaacLabArenaError
except ImportError:
from errors import IsaacLabArenaError
def cleanup_isaaclab(env, simulation_app) -> None:
"""Close the gym env and Kit app; ignore signals mid-teardown."""
old_sigint = signal.signal(signal.SIGINT, signal.SIG_IGN)
old_sigterm = signal.signal(signal.SIGTERM, signal.SIG_IGN)
try:
with suppress(Exception):
if env is not None:
env.close()
with suppress(Exception):
if simulation_app is not None:
simulation_app.app.close()
finally:
signal.signal(signal.SIGINT, old_sigint)
signal.signal(signal.SIGTERM, old_sigterm)
class IsaacLabEnvWrapper(gym.vector.AsyncVectorEnv):
"""Isaac Lab already vectorizes on GPU; this exposes an AsyncVectorEnv API."""
metadata = {"render_modes": ["rgb_array"], "render_fps": 15}
_cleanup_in_progress = False
def __init__(
self,
env,
episode_length: int = 500,
task: str | None = None,
render_mode: str | None = "rgb_array",
simulation_app=None,
):
self._env = env
self._num_envs = env.num_envs
self._episode_length = episode_length
self._closed = False
self.render_mode = render_mode
self._simulation_app = simulation_app
self.observation_space = env.observation_space
self.action_space = env.action_space
self.single_observation_space = env.observation_space
self.single_action_space = env.action_space
self.task = task
if hasattr(env, "metadata") and env.metadata:
self.metadata = {**self.metadata, **env.metadata}
atexit.register(self._cleanup)
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
def _signal_handler(self, signum, frame):
if IsaacLabEnvWrapper._cleanup_in_progress:
return
IsaacLabEnvWrapper._cleanup_in_progress = True
logging.info(f"Received signal {signum}, cleaning up...")
self._cleanup()
os._exit(0)
def _check_closed(self):
if self._closed:
raise IsaacLabArenaError()
@property
def unwrapped(self):
return self
@property
def num_envs(self) -> int:
return self._num_envs
@property
def _max_episode_steps(self) -> int:
return self._episode_length
@property
def device(self) -> str:
return getattr(self._env, "device", "cpu")
def reset(
self,
*,
seed: int | list[int] | None = None,
options: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
self._check_closed()
if isinstance(seed, (list, tuple, range)):
seed = seed[0] if len(seed) > 0 else None
obs, info = self._env.reset(seed=seed, options=options)
if "final_info" not in info:
zeros = np.zeros(self._num_envs, dtype=bool)
info["final_info"] = {"is_success": zeros}
return obs, info
def step(
self, actions: np.ndarray | torch.Tensor
) -> tuple[dict, np.ndarray, np.ndarray, np.ndarray, dict]:
self._check_closed()
if isinstance(actions, np.ndarray):
actions = torch.from_numpy(actions).to(self._env.device)
obs, reward, terminated, truncated, info = self._env.step(actions)
reward = reward.cpu().numpy().astype(np.float32)
terminated = terminated.cpu().numpy().astype(bool)
truncated = truncated.cpu().numpy().astype(bool)
is_success = self._get_success(terminated, truncated)
info["final_info"] = {"is_success": is_success}
return obs, reward, terminated, truncated, info
def _get_success(self, terminated: np.ndarray, truncated: np.ndarray) -> np.ndarray:
is_success = np.zeros(self._num_envs, dtype=bool)
if not hasattr(self._env, "termination_manager"):
return is_success & (terminated | truncated)
term_manager = self._env.termination_manager
if not hasattr(term_manager, "get_term"):
return is_success & (terminated | truncated)
success_tensor = term_manager.get_term("success")
if success_tensor is None:
return is_success & (terminated | truncated)
is_success = success_tensor.cpu().numpy().astype(bool)
return is_success & (terminated | truncated)
def call(self, method_name: str, *args, **kwargs) -> list[Any]:
if method_name == "_max_episode_steps":
return [self._episode_length] * self._num_envs
if method_name == "task":
return [self.task] * self._num_envs
if method_name == "render":
return self.render_all()
if hasattr(self._env, method_name):
attr = getattr(self._env, method_name)
result = attr(*args, **kwargs) if callable(attr) else attr
if isinstance(result, list):
return result
return [result] * self._num_envs
raise AttributeError(f"IsaacLab-Arena has no method/attribute '{method_name}'")
def render_all(self) -> list[np.ndarray]:
self._check_closed()
frames = self.render()
if frames is None:
placeholder = np.zeros((480, 640, 3), dtype=np.uint8)
return [placeholder] * self._num_envs
return [frames] * self._num_envs
def render(self) -> np.ndarray | None:
self._check_closed()
if self.render_mode != "rgb_array":
return None
frames = self._env.render() if hasattr(self._env, "render") else None
if frames is None:
return None
if isinstance(frames, torch.Tensor):
frames = frames.cpu().numpy()
return frames[0] if frames.ndim == 4 else frames
def _cleanup(self) -> None:
if self._closed:
return
self._closed = True
IsaacLabEnvWrapper._cleanup_in_progress = True
logging.info("Cleaning up IsaacLab Arena environment...")
cleanup_isaaclab(self._env, self._simulation_app)
def close(self) -> None:
self._cleanup()
@property
def envs(self) -> list[IsaacLabEnvWrapper]:
return [self] * self._num_envs
def __del__(self):
self._cleanup()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._cleanup()
return False