File size: 13,925 Bytes
1e09dca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d40936
1e09dca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d40936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e09dca
 
 
 
 
 
d9123b7
 
1e09dca
2d40936
1e09dca
 
2d40936
 
 
 
 
 
 
 
 
 
 
1e09dca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43ad684
 
1e09dca
43ad684
 
 
1e09dca
 
 
 
 
 
43ad684
1e09dca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43ad684
1e09dca
 
 
 
 
43ad684
 
 
 
 
1e09dca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
"""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)