|
| 1 | +"""LIBERO passthrough — expose all 130 LIBERO tasks through RoboVerse. |
| 2 | +
|
| 3 | +LIBERO (*Lifelong Robot Learning*, Liu et al. 2023) tasks are robosuite / MuJoCo |
| 4 | +environments defined by BDDL task files across five suites: |
| 5 | +``libero_spatial`` (10), ``libero_object`` (10), ``libero_goal`` (10), |
| 6 | +``libero_10`` (10, a.k.a. LIBERO-Long) and ``libero_90`` (90). Together they are |
| 7 | +the 130-task LIBERO benchmark (``libero_90`` + ``libero_10`` = LIBERO-100). |
| 8 | +
|
| 9 | +This module registers each task under a ``Libero/<suite>__<task_name>`` gym id |
| 10 | +with a *lazy* entry point: registration does NOT import LIBERO (so it is safe |
| 11 | +even when LIBERO's heavy deps — ``robosuite==1.4.0`` / ``bddl==1.0.1`` / |
| 12 | +``robomimic==0.2.0`` / ``numpy<1.24`` — are absent from the active env), and the |
| 13 | +underlying env is only built when the env is actually made. |
| 14 | +
|
| 15 | +LIBERO must be importable in the active env. Because its pins (numpy<1.24, |
| 16 | +robosuite, mujoco) conflict with the default RoboVerse env, install it in a |
| 17 | +**dedicated** conda env:: |
| 18 | +
|
| 19 | + git clone https://github.com/Lifelong-Robot-Learning/LIBERO.git |
| 20 | + cd LIBERO && pip install -e . |
| 21 | +
|
| 22 | +Until then, registration registers nothing (the ids only become available once |
| 23 | +LIBERO is importable) and :func:`make_libero_env` raises a clear, actionable |
| 24 | +error. Once installed, the factory runs the **native** LIBERO env |
| 25 | +(``OffScreenRenderEnv``) via the exact code path from LIBERO's README / lifelong |
| 26 | +eval → **1:1 by construction**, no cross-sim porting. |
| 27 | +
|
| 28 | +Note: |
| 29 | + LIBERO uses the *legacy* gym step API — ``env.step(action)`` returns the |
| 30 | + 4-tuple ``(obs, reward, done, info)`` and ``env.reset()`` returns ``obs`` |
| 31 | + (not gymnasium's 5-tuple / ``(obs, info)``). This is preserved deliberately |
| 32 | + for bitwise fidelity with the original simulator; downstream 1:1 demo replay |
| 33 | + and the LIBERO benchmark eval both rely on the native interface. Prefer |
| 34 | + :func:`make_libero_env` (returns the raw native env) for replay / eval; the |
| 35 | + gym registration exists primarily for discovery (``list_passthrough_envs``). |
| 36 | +""" |
| 37 | + |
| 38 | +from __future__ import annotations |
| 39 | + |
| 40 | +import os |
| 41 | +from typing import Any |
| 42 | + |
| 43 | +from loguru import logger as log |
| 44 | + |
| 45 | +#: The five standard LIBERO suites, in canonical order (name -> n_tasks shown for |
| 46 | +#: reference only; the authoritative counts come from the benchmark dict). |
| 47 | +LIBERO_SUITES: tuple[str, ...] = ( |
| 48 | + "libero_spatial", |
| 49 | + "libero_object", |
| 50 | + "libero_goal", |
| 51 | + "libero_10", |
| 52 | + "libero_90", |
| 53 | +) |
| 54 | + |
| 55 | +#: Separator between suite and task name inside a gym id. A single ``/`` already |
| 56 | +#: denotes the gym namespace, so the suite/task split uses ``__`` (both halves |
| 57 | +#: stay valid ``[\w:-]+`` gym-name characters and the split is reversible). |
| 58 | +_SEP = "__" |
| 59 | + |
| 60 | + |
| 61 | +def _ensure_imported(): |
| 62 | + """Import LIBERO's benchmark module (raises if LIBERO is not installed).""" |
| 63 | + import libero # noqa: F401 |
| 64 | + from libero.libero import benchmark |
| 65 | + |
| 66 | + return benchmark |
| 67 | + |
| 68 | + |
| 69 | +def discover_libero_tasks() -> dict[str, list[str]]: |
| 70 | + """Return ``{suite_name: [task_name, ...]}`` from LIBERO's benchmark dict. |
| 71 | +
|
| 72 | + Uses the authoritative ``benchmark.get_benchmark_dict()`` ordering (so task |
| 73 | + index ``i`` matches ``suite.get_task(i)`` / ``get_task_init_states(i)`` / |
| 74 | + ``get_task_demonstration(i)``). Returns ``{}`` if LIBERO is not importable, |
| 75 | + which keeps :func:`register_libero_passthrough` safe in envs without LIBERO. |
| 76 | + """ |
| 77 | + try: |
| 78 | + benchmark = _ensure_imported() |
| 79 | + except Exception: |
| 80 | + return {} |
| 81 | + bdict = benchmark.get_benchmark_dict() |
| 82 | + out: dict[str, list[str]] = {} |
| 83 | + for name in LIBERO_SUITES: |
| 84 | + if name not in bdict: |
| 85 | + continue |
| 86 | + try: |
| 87 | + suite = bdict[name]() |
| 88 | + out[name] = list(suite.get_task_names()) |
| 89 | + except Exception as e: # pragma: no cover - defensive |
| 90 | + log.debug(f"[libero_passthrough] could not enumerate suite {name!r}: {e}") |
| 91 | + return out |
| 92 | + |
| 93 | + |
| 94 | +def make_libero_env( |
| 95 | + benchmark_name: str, |
| 96 | + task_id: int, |
| 97 | + *, |
| 98 | + camera_heights: int = 128, |
| 99 | + camera_widths: int = 128, |
| 100 | + seed: int | None = None, |
| 101 | + init_state_index: int | None = None, |
| 102 | + **env_kwargs: Any, |
| 103 | +): |
| 104 | + """Build the native LIBERO ``OffScreenRenderEnv`` for ``(suite, task_id)``. |
| 105 | +
|
| 106 | + This is the canonical 1:1 construction path (matches LIBERO's README and |
| 107 | + ``libero/lifelong`` eval): resolve the task's BDDL file, build the offscreen |
| 108 | + env, optionally seed + reset + apply a stored init state. Returns the raw |
| 109 | + native env (legacy gym 4-tuple ``step`` API). |
| 110 | +
|
| 111 | + Args: |
| 112 | + benchmark_name: One of :data:`LIBERO_SUITES` (e.g. ``"libero_spatial"``). |
| 113 | + task_id: Task index within the suite (``0 <= task_id < suite.n_tasks``). |
| 114 | + camera_heights: Offscreen RGB camera height (LIBERO default 128). |
| 115 | + camera_widths: Offscreen RGB camera width (LIBERO default 128). |
| 116 | + seed: If given, ``env.seed(seed)`` before reset (LIBERO determinism). |
| 117 | + init_state_index: If given, ``env.set_init_state(init_states[idx])`` after |
| 118 | + reset, where ``init_states = suite.get_task_init_states(task_id)`` — |
| 119 | + the exact init-state path the benchmark eval uses. |
| 120 | + **env_kwargs: Extra kwargs forwarded to ``OffScreenRenderEnv`` (e.g. |
| 121 | + ``camera_depths=True``). A gym-injected ``render_mode`` is ignored |
| 122 | + (LIBERO is always offscreen). |
| 123 | +
|
| 124 | + Returns: |
| 125 | + The native LIBERO ``OffScreenRenderEnv`` instance. |
| 126 | +
|
| 127 | + Raises: |
| 128 | + RuntimeError: If LIBERO is not importable in the active env. |
| 129 | + """ |
| 130 | + env_kwargs.pop("render_mode", None) # gym.make may inject this; LIBERO is offscreen-only |
| 131 | + try: |
| 132 | + from libero.libero import benchmark, get_libero_path |
| 133 | + from libero.libero.envs import OffScreenRenderEnv |
| 134 | + except Exception as e: |
| 135 | + raise RuntimeError( |
| 136 | + "LIBERO is not importable in the active env. Install it in a dedicated conda env " |
| 137 | + "(it pins numpy<1.24 / robosuite==1.4.0 / bddl==1.0.1 / robomimic==0.2.0, which " |
| 138 | + "conflict with the default RoboVerse env):\n" |
| 139 | + " git clone https://github.com/Lifelong-Robot-Learning/LIBERO.git\n" |
| 140 | + " cd LIBERO && pip install -e .\n" |
| 141 | + f"Original import error: {type(e).__name__}: {e}" |
| 142 | + ) from e |
| 143 | + |
| 144 | + bdict = benchmark.get_benchmark_dict() |
| 145 | + if benchmark_name not in bdict: |
| 146 | + raise KeyError(f"Unknown LIBERO suite {benchmark_name!r}; available: {sorted(bdict.keys())}") |
| 147 | + suite = bdict[benchmark_name]() |
| 148 | + n = suite.get_num_tasks() |
| 149 | + if not (0 <= task_id < n): |
| 150 | + raise IndexError(f"task_id {task_id} out of range for suite {benchmark_name!r} (n_tasks={n})") |
| 151 | + |
| 152 | + task = suite.get_task(task_id) |
| 153 | + bddl_file = os.path.join(get_libero_path("bddl_files"), task.problem_folder, task.bddl_file) |
| 154 | + if not os.path.isfile(bddl_file): |
| 155 | + raise FileNotFoundError(f"BDDL file for {benchmark_name}/{task.name} not found: {bddl_file}") |
| 156 | + |
| 157 | + env_args = {"bddl_file_name": bddl_file, "camera_heights": camera_heights, "camera_widths": camera_widths} |
| 158 | + env_args.update(env_kwargs) |
| 159 | + env = OffScreenRenderEnv(**env_args) |
| 160 | + if seed is not None: |
| 161 | + env.seed(seed) |
| 162 | + env.reset() |
| 163 | + if init_state_index is not None: |
| 164 | + init_states = suite.get_task_init_states(task_id) |
| 165 | + env.set_init_state(init_states[init_state_index]) |
| 166 | + return env |
| 167 | + |
| 168 | + |
| 169 | +def _make_libero_env(benchmark_name: str, task_id: int, **kwargs: Any): |
| 170 | + """Gym entry-point shim (positional-suite + task_id via registered kwargs).""" |
| 171 | + return make_libero_env(benchmark_name, task_id, **kwargs) |
| 172 | + |
| 173 | + |
| 174 | +def register_libero_passthrough(prefix: str = "Libero/") -> list[str]: |
| 175 | + """Register every LIBERO task as ``Libero/<suite>__<task_name>`` (lazy). |
| 176 | +
|
| 177 | + Idempotent. Returns the registered ids. Safe when LIBERO is absent (returns |
| 178 | + ``[]`` — the ids only become discoverable once LIBERO is importable, since |
| 179 | + the suite/task enumeration is authoritative and comes from LIBERO itself). |
| 180 | + """ |
| 181 | + from gymnasium.envs.registration import register, registry |
| 182 | + |
| 183 | + tasks = discover_libero_tasks() |
| 184 | + if not tasks: |
| 185 | + log.debug( |
| 186 | + "[libero_passthrough] LIBERO not importable; no tasks registered " |
| 187 | + "(ids become available once LIBERO is installed in this env)" |
| 188 | + ) |
| 189 | + return [] |
| 190 | + |
| 191 | + registered: list[str] = [] |
| 192 | + failed: list[tuple[str, str]] = [] |
| 193 | + for bench_name, task_names in tasks.items(): |
| 194 | + for task_id, task_name in enumerate(task_names): |
| 195 | + ns_id = f"{prefix}{bench_name}{_SEP}{task_name}" |
| 196 | + if ns_id in registry: |
| 197 | + registered.append(ns_id) |
| 198 | + continue |
| 199 | + try: |
| 200 | + register( |
| 201 | + id=ns_id, |
| 202 | + entry_point="roboverse_pack.tasks.libero._passthrough:_make_libero_env", |
| 203 | + kwargs={"benchmark_name": bench_name, "task_id": task_id}, |
| 204 | + # The native LIBERO env is legacy-gym (4-tuple step); keep gym's |
| 205 | + # wrappers out of the way so the passthrough stays bitwise-1:1. |
| 206 | + disable_env_checker=True, |
| 207 | + order_enforce=False, |
| 208 | + ) |
| 209 | + registered.append(ns_id) |
| 210 | + except Exception as e: |
| 211 | + failed.append((ns_id, str(e))) |
| 212 | + if failed: |
| 213 | + log.debug(f"[libero_passthrough] {len(failed)} registration failures (first: {failed[0]})") |
| 214 | + log.info(f"[libero_passthrough] registered {len(registered)} LIBERO tasks under {prefix!r}") |
| 215 | + return registered |
| 216 | + |
| 217 | + |
| 218 | +# Registering on import mirrors the other ``roboverse_pack.tasks.*`` passthroughs. |
| 219 | +# The ``libero`` package ``__init__`` auto-imports every submodule, so importing |
| 220 | +# ``roboverse_pack.tasks.libero`` triggers this. It is a safe no-op when LIBERO is |
| 221 | +# not installed (``discover_libero_tasks`` returns ``{}``). |
| 222 | +try: |
| 223 | + register_libero_passthrough() |
| 224 | +except Exception as _e: # pragma: no cover - registration must never break import |
| 225 | + log.debug(f"[libero_passthrough] auto-registration skipped: {_e}") |
0 commit comments