lukasskellijs's picture
fix(envhub): unwrap gym wrappers + keep sim alive through settle/step
43ad684 verified
Raw
History Blame Contribute Delete
13.9 kB
"""Assembly bench EnvHub entry — LeRobot ``make_env`` for Isaac Lab Arena.
Docs: https://huggingface.co/docs/lerobot/en/envhub_isaaclab_arena
Variant selection (Arena ``--task``) is *not* ``cfg.task`` — that field is the
natural-language prompt on ``IsaaclabArenaEnv``. Pass the variant via kwargs:
--env.kwargs='{"variant": "peg_round_M1_loose", "reward": "none"}'
"""
from __future__ import annotations
import importlib.util
import logging
import os
import shutil
import sys
from pathlib import Path
from typing import Any
import gymnasium as gym
import yaml
from huggingface_hub import hf_hub_download, snapshot_download
# Hub id for sibling downloads + full-repo snapshot (assets + package).
HUB_REPO_ID = os.environ.get("ASSEMBLY_BENCH_HUB", "lukasskellijs/env_assembly_bench")
EXAMPLE_ENVS = "example_envs.yaml"
# Default proprio / cameras match assembly_bench/contract.json (DROID 8-D).
_DEFAULT_STATE_KEYS = "joint_pos,gripper_pos,eef_pos,eef_quat"
_DEFAULT_CAMERA_KEYS = "front_cam_rgb,wrist_camera_rgb"
_DEFAULT_STATE_DIM = 15 # 7+1+3+4
_DEFAULT_ACTION_DIM = 8
_DEFAULT_VARIANT = "peg_round_M1_loose"
_DEFAULT_EMBODIMENT = "droid_abs_joint_pos_softmimic"
_MICROWAVE_PROMPT = "Reach out to the microwave and open it." # IsaaclabArenaEnv default
_REPO_ROOT: str | None = None
def _materialize_hub_tree(root: Path) -> Path:
"""Copy a HF snapshot to a real directory (dereference blob symlinks).
Hub stores files as symlinks into ``blobs/<hash>`` (no ``.usd`` suffix). USD /
PhysX open the resolved path and then hang or fail on the extension-less blob.
``symlinks=False`` copies content under the original names.
"""
sample = next((root / "assembly_bench" / "assets").rglob("*.usd"), None)
if sample is None or not sample.is_symlink():
return root
cache = Path(
os.environ.get(
"ASSEMBLY_BENCH_MATERIALIZE_DIR",
Path.home() / ".cache" / "env_assembly_bench" / "materialized",
)
)
dest = cache / root.name
marker = dest / ".materialized"
if marker.is_file():
return dest
logging.info("Materializing Hub snapshot (deref blob symlinks) → %s", dest)
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(root, dest, symlinks=False)
marker.write_text("ok\n", encoding="utf-8")
return dest
def _ensure_repo_root() -> str:
"""Put this EnvHub checkout/snapshot on sys.path so ``assembly_bench`` imports."""
global _REPO_ROOT
if _REPO_ROOT is not None:
return _REPO_ROOT
# absolute() keeps the Hub snapshot path; resolve() follows blob symlinks.
here = Path(__file__).absolute().parent
if (here / "assembly_bench" / "environments" / "assembly" / "assembly.py").is_file():
root = here
else:
# Loaded as a lone hub file — pull the full repo (USDs + package).
root = Path(snapshot_download(repo_id=HUB_REPO_ID))
root = _materialize_hub_tree(root)
_REPO_ROOT = str(root)
# Materialized tree must win over a still-on-path HF snapshot (blob symlinks).
if _REPO_ROOT in sys.path:
sys.path.remove(_REPO_ROOT)
sys.path.insert(0, _REPO_ROOT)
for name in list(sys.modules):
if name == "assembly_bench" or name.startswith("assembly_bench."):
del sys.modules[name]
return _REPO_ROOT
def _download_hub_file(filename: str) -> str:
root = _ensure_repo_root()
local = Path(root) / filename
if local.is_file():
return str(local)
return hf_hub_download(repo_id=HUB_REPO_ID, filename=filename)
def _download_and_import(filename: str):
"""Load a sibling module from the hub snapshot (absolute imports)."""
local_path = _download_hub_file(filename)
module_dir = os.path.dirname(local_path)
if module_dir not in sys.path:
sys.path.insert(0, module_dir)
module_name = filename.replace(".py", "")
with open(local_path) as f:
content = f.read()
content = content.replace("from .errors import", "from errors import")
content = content.replace("from .isaaclab_env_wrapper import", "from isaaclab_env_wrapper import")
spec = importlib.util.spec_from_file_location(module_name, local_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
exec(compile(content, local_path, "exec"), module.__dict__) # noqa: S102
return module
try:
from .errors import IsaacLabArenaCameraKeyError, IsaacLabArenaStateKeyError
from .isaaclab_env_wrapper import IsaacLabEnvWrapper, cleanup_isaaclab
except ImportError:
_errors = _download_and_import("errors.py")
_wrapper = _download_and_import("isaaclab_env_wrapper.py")
IsaacLabEnvWrapper = _wrapper.IsaacLabEnvWrapper
cleanup_isaaclab = _wrapper.cleanup_isaaclab
IsaacLabArenaCameraKeyError = _errors.IsaacLabArenaCameraKeyError
IsaacLabArenaStateKeyError = _errors.IsaacLabArenaStateKeyError
def validate_config(
env,
state_keys: tuple[str, ...],
camera_keys: tuple[str, ...],
cfg_state_dim: int,
cfg_action_dim: int,
) -> None:
"""Check observation keys and dims against the live Isaac Lab managers."""
obs_manager = env.observation_manager
active_terms = obs_manager.active_terms
policy_terms = set(active_terms.get("policy", []))
camera_terms = set(active_terms.get("camera_obs", []))
missing_state = [k for k in state_keys if k not in policy_terms]
if missing_state:
raise IsaacLabArenaStateKeyError(missing_state, policy_terms)
missing_cam = [k for k in camera_keys if k not in camera_terms]
if missing_cam:
raise IsaacLabArenaCameraKeyError(missing_cam, camera_terms)
env_action_dim = env.action_space.shape[-1]
if cfg_action_dim != env_action_dim:
raise ValueError(f"action_dim mismatch: config={cfg_action_dim}, env={env_action_dim}")
policy_dims = obs_manager.group_obs_dim.get("policy", [])
policy_names = active_terms.get("policy", [])
term_dims = dict(zip(policy_names, policy_dims, strict=False))
expected_state_dim = 0
for key in state_keys:
if key in term_dims:
shape = term_dims[key]
dim = 1
for s in shape if isinstance(shape, (tuple, list)) else [shape]:
dim *= s
expected_state_dim += dim
if cfg_state_dim != expected_state_dim:
raise ValueError(
f"state_dim mismatch: config={cfg_state_dim}, "
f"computed={expected_state_dim}. "
f"Term dims: {term_dims}"
)
logging.info(f"Validated: state_keys={state_keys}, camera_keys={camera_keys}")
def resolve_environment_alias(environment: str) -> str:
"""Map ``--env.environment`` alias → dotted class path."""
with open(_download_hub_file(EXAMPLE_ENVS)) as f:
envs_mapping = yaml.safe_load(f)
return f"{envs_mapping['repo']['base_module']}.{envs_mapping['repo']['envs'][environment]}"
def _cfg_get(cfg: Any, name: str, default: Any = None) -> Any:
if cfg is None:
return default
return getattr(cfg, name, default)
def _create_isaaclab_env(config: dict, n_envs: int) -> dict[str, dict[int, gym.vector.VectorEnv]]:
"""Boot Kit, build AssemblyBench via Arena, wrap for EnvHub."""
_ensure_repo_root()
from isaaclab.app import AppLauncher
if config.get("enable_pinocchio", False):
import pinocchio # noqa: F401
config = {**config, "num_envs": n_envs}
# Only Kit-relevant fields — extra Arena/EnvHub keys confuse nothing, but a
# minimal Namespace keeps AppLauncher resolution predictable.
logging.info("Launching IsaacLab simulation app...")
app_launcher = AppLauncher(
headless=bool(config.get("headless", True)),
enable_cameras=bool(config.get("enable_cameras", True)),
device=config.get("device") or "cuda:0",
)
# Package imports need the hub root on path (assets resolve from scene.py).
from assembly_bench.environments.assembly.assembly import make_assembly_env
from assembly_bench.environments.assembly.variants import VARIANTS
variant = config.get("variant") or _DEFAULT_VARIANT
if variant not in VARIANTS:
raise ValueError(f"Unknown variant {variant!r}. Choices: {sorted(VARIANTS)}")
# Confirm the EnvHub alias maps to this package (discoverability / typos).
resolve_environment_alias(config.get("environment") or "assembly_bench")
# Shared factory: Arena CLI defaults + reward/hdr wiring.
raw_env = make_assembly_env(
task=variant,
num_envs=n_envs,
embodiment=config.get("embodiment") or _DEFAULT_EMBODIMENT,
hdr=config.get("hdr") or "asm_machine_shop",
light_intensity=float(config.get("light_intensity") or 1500.0),
reward=config.get("reward") or "none",
)
# Peel Gymnasium wrappers (OrderEnforcing, …) — managers live on the Isaac env.
isaac_env = raw_env.unwrapped if hasattr(raw_env, "unwrapped") else raw_env
# make_assembly_env already registers with render_mode="rgb_array"; Gymnasium's
# OrderEnforcing wrapper exposes render_mode as a read-only property, so don't
# assign on raw_env. IsaacLabEnvWrapper owns the EnvHub-facing value.
render_mode = "rgb_array" if config.get("enable_cameras", False) else None
state_keys = tuple(k.strip() for k in (config.get("state_keys") or "").split(",") if k.strip())
camera_keys = tuple(k.strip() for k in (config.get("camera_keys") or "").split(",") if k.strip())
try:
validate_config(
isaac_env,
state_keys,
camera_keys,
config.get("state_dim", _DEFAULT_STATE_DIM),
config.get("action_dim", _DEFAULT_ACTION_DIM),
)
except (IsaacLabArenaCameraKeyError, IsaacLabArenaStateKeyError, ValueError):
cleanup_isaaclab(raw_env, app_launcher)
raise
except Exception as e:
logging.error(f"Validation failed with unexpected error: {type(e).__name__}: {e}")
cleanup_isaaclab(raw_env, app_launcher)
raise
# NL prompt: cfg.task unless still the Arena microwave default → use variant text.
task_prompt = config.get("task")
if not task_prompt or task_prompt == _MICROWAVE_PROMPT:
task_prompt = VARIANTS[variant].instruction
# 15 Hz control; fall back to the variant's authored duration (seconds).
episode_length = config.get("episode_length")
if episode_length in (None, 300):
episode_length = int(VARIANTS[variant].episode_length_s * 15)
environment = config.get("environment") or "assembly_bench"
wrapped_env = IsaacLabEnvWrapper(
isaac_env,
episode_length=int(episode_length),
task=task_prompt,
render_mode=render_mode,
simulation_app=app_launcher,
)
# Keep the Gymnasium outer wrapper alive: its __del__/close() would otherwise
# tear down the Isaac env as soon as this frame returns (exit 0 mid-smoke).
wrapped_env._gym_root = raw_env
# OrderEnforcing.close() would close the Isaac env on GC; neutralize it.
raw_env.close = lambda *a, **k: None # type: ignore[method-assign]
logging.info(
f"Created: {environment} variant={variant} n_envs={wrapped_env.num_envs} "
f"render_mode={render_mode}"
)
return {environment: {0: wrapped_env}}
def make_env(
n_envs: int = 1,
use_async_envs: bool = False, # noqa: ARG001 — GPU batching; AsyncVectorEnv unused
cfg: Any | None = None,
) -> dict[str, dict[int, gym.vector.VectorEnv]]:
"""EnvHub entry point. ``cfg`` is typically ``IsaaclabArenaEnv`` (or SimpleNamespace)."""
if n_envs < 1:
raise ValueError("`n_envs` must be at least 1")
environment = _cfg_get(cfg, "environment", "assembly_bench")
if environment is None:
raise ValueError("No 'environment' specified (expected 'assembly_bench').")
# IsaaclabArenaEnv.__post_init__ promotes kwargs (variant/reward/hdr) onto cfg.
variant = _cfg_get(cfg, "variant", _DEFAULT_VARIANT)
reward = _cfg_get(cfg, "reward", "none")
hdr = _cfg_get(cfg, "hdr", "asm_machine_shop")
light_intensity = _cfg_get(cfg, "light_intensity", 1500.0)
config = {
"environment": environment,
"embodiment": _cfg_get(cfg, "embodiment", _DEFAULT_EMBODIMENT),
"object": _cfg_get(cfg, "object", None),
"mimic": _cfg_get(cfg, "mimic", False),
"teleop_device": _cfg_get(cfg, "teleop_device", None),
"seed": _cfg_get(cfg, "seed", 42),
"device": _cfg_get(cfg, "device", "cuda:0"),
"disable_fabric": _cfg_get(cfg, "disable_fabric", False),
"enable_cameras": _cfg_get(cfg, "enable_cameras", True),
"headless": _cfg_get(cfg, "headless", True),
"enable_pinocchio": _cfg_get(cfg, "enable_pinocchio", False),
"episode_length": _cfg_get(cfg, "episode_length", None),
"state_dim": _cfg_get(cfg, "state_dim", _DEFAULT_STATE_DIM),
"action_dim": _cfg_get(cfg, "action_dim", _DEFAULT_ACTION_DIM),
"camera_height": _cfg_get(cfg, "camera_height", 720),
"camera_width": _cfg_get(cfg, "camera_width", 1280),
"video": _cfg_get(cfg, "video", False),
"video_length": _cfg_get(cfg, "video_length", 100),
"video_interval": _cfg_get(cfg, "video_interval", 200),
"state_keys": _cfg_get(cfg, "state_keys", _DEFAULT_STATE_KEYS),
"camera_keys": _cfg_get(cfg, "camera_keys", _DEFAULT_CAMERA_KEYS) or "",
"task": _cfg_get(cfg, "task", None),
"variant": variant,
"reward": reward,
"hdr": hdr,
"light_intensity": light_intensity,
}
logging.info(
f"EnvHub make_env: environment={environment}, variant={variant}, n_envs={n_envs}, "
f"headless={config['headless']}, enable_cameras={config['enable_cameras']}"
)
return _create_isaaclab_env(config, n_envs)