File size: 12,182 Bytes
01171b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Copyright (c) 2026 INSAIT. MIT License (see LICENSE).
"""LADDER RoboLab-Long for LeRobot EnvHub: 44 long-horizon Franka (DROID) tasks on Isaac Sim 6 / Isaac Lab 3.

Each task is one suite of LeRobot's ``{suite: {task_id: env}}``; its 8 episodes run as 8 parallel envs
in one Isaac Lab world. Isaac Sim starts once per process. See README.md.
"""

import os
import re
import sys

REPO_ID = "INSAIT-Institute/LADDER-RoboLab-Long"

_HERE = os.path.dirname(os.path.abspath(__file__))
if not os.path.exists(os.path.join(_HERE, "_ladder_envhub.py")):
    # `make_env("<repo>")` downloads only this file: fetch the rest of the same revision.
    from huggingface_hub import snapshot_download

    _revision = os.path.basename(_HERE)
    _HERE = snapshot_download(REPO_ID, revision=_revision if re.fullmatch(r"[0-9a-f]{40}", _revision) else None)
if _HERE not in sys.path:
    sys.path.insert(0, _HERE)

import atexit  # noqa: E402
import logging  # noqa: E402
from concurrent.futures import ThreadPoolExecutor  # noqa: E402
from functools import partial  # noqa: E402
from typing import Any  # noqa: E402

import _ladder_envhub as lh  # noqa: E402

EPISODES_PER_TASK = 8
CONTROL_HZ = 15
LEROBOT_SIM = lh.lerobot_sim("franka", fps=CONTROL_HZ)


def _add_sources() -> None:
    """Isaac Lab, RoboLab (patched to read its assets from ROBOLAB_ASSET_DIR) and openpi_client, as LADDER pins them."""
    if _Isaac.sources:
        return
    robolab = lh.fetch("robolab_src")
    constants = robolab / "robolab" / "constants.py"
    text = constants.read_text()
    patched = 'ASSET_DIR = os.environ.get("ROBOLAB_ASSET_DIR") or os.path.join(PACKAGE_DIR, "assets")'
    if patched not in text:
        # LADDER's build_support/python/patches/robolab_constants_asset_dir.patch.
        text = text.replace('ASSET_DIR = os.path.join(PACKAGE_DIR, "assets")', patched, 1)
        text = text.replace(
            "    return os.path.join(PACKAGE_DIR, relative_path)",
            '    normalized = relative_path.replace(os.sep, "/")\n'
            '    if normalized.startswith("assets/"):\n'
            '        return os.path.join(ASSET_DIR, *normalized[len("assets/"):].split("/"))\n'
            "    return os.path.join(PACKAGE_DIR, relative_path)",
            1,
        )
        # Atomically: another process may be importing it.
        staged = constants.with_name(f".constants.{os.getpid()}.py")
        staged.write_text(text)
        os.replace(staged, constants)
    if patched not in constants.read_text():
        raise RuntimeError(f"{constants}: LADDER's asset-directory patch no longer applies")
    # The extracted wheel is an installed package on sys.path, with its dist-info (see _ladder_envhub.DOWNLOADS).
    lh.add_to_path(lh.fetch("isaaclab"), robolab, lh.fetch("openpi_src") / "packages" / "openpi-client" / "src")
    os.environ.setdefault("ROBOLAB_ASSET_DIR", str(lh.fetch("robolab_assets") / "assets"))
    _Isaac.sources = True


def task_names() -> list[str]:
    """The suite's task names, from LADDER's own registry."""
    from ladder.components.inference.robots.robolab.composition.specs import SPECS

    return sorted(SPECS)


class _Isaac:
    """Isaac Sim starts once per process and runs on one thread; one task's world is kept at a time."""

    thread = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ladder-isaac")
    sources = False
    app: Any = None
    env: Any = None
    env_cfg: Any = None
    oracle: Any = None
    key: Any = None


def _start_isaac() -> None:
    if _Isaac.app is not None:
        return
    _add_sources()
    # Running this environment accepts the NVIDIA Isaac Sim EULA (see README); Kit asks on stdin otherwise.
    os.environ.setdefault("OMNI_KIT_ACCEPT_EULA", "YES")
    os.environ.setdefault("ACCEPT_EULA", "Y")
    import cv2  # noqa: F401
    from isaaclab.app import AppLauncher

    root = logging.getLogger()
    levels = {handler: handler.level for handler in root.handlers}
    with lh.ignore_signal_in_thread():
        _Isaac.app = AppLauncher(headless=True, enable_cameras=True).app
    for handler, level in levels.items():  # Kit lowers the levels of existing handlers
        handler.setLevel(level)
    # Kit hangs in its own shutdown; leave without it, keeping the exit status.
    atexit.register(
        lambda: (
            sys.stdout.flush(),
            sys.stderr.flush(),
            os._exit(0 if getattr(sys, "last_value", None) is None else 1),
        )
    )
    import carb.settings
    import robolab.constants

    # As LADDER's env_server.py: LADDER scores subgoals itself, and renderer failures stay visible.
    robolab.constants.ENABLE_SUBTASK_PROGRESS_CHECKING = False
    carb.settings.get_settings().set("/persistent/app/usd/muteUsdDiagnostics", False)


class RoboLabLong(lh.LadderVectorEnv):
    suite = "robolab"
    robot = "franka"
    default_image_size = (224, 224)
    episodes_per_task = EPISODES_PER_TASK
    fps = CONTROL_HZ

    @classmethod
    def task_budget(cls, task: str) -> int:
        from ladder.components.inference.robots.robolab.composition.specs import SPECS

        return int(SPECS[task].episode_length_s * CONTROL_HZ)

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self._env = None
        self._raw = None
        self._converter = None
        # LeRobot's server builds an env (one env of the first task) before it reports ready. Isaac Sim starts here,
        # and the first env of a process builds its world here too: that compiles the shaders and loads the assets
        # every task shares, which on a new machine or container would otherwise fall in a client's first open.
        # A client's own batch size gets its world on its first reset.
        self._fresh = _Isaac.thread.submit(self._start).result()

    def _start(self) -> bool:
        _start_isaac()
        return _Isaac.env is None and self._build()

    def goal_sources(self) -> list[str | None] | None:
        # The object each goal places (a relay's legs are separate goals), as LADDER's privileged.py names it.
        return [
            next((geometry[0] for _, geometry in reversed(list(goal.legs)) if geometry is not None), None)
            for goal in _Isaac.oracle or []
        ]

    def _backend_reset(self, episode_ids: list[int]) -> list[Any]:
        return _Isaac.thread.submit(self._reset).result()

    def _backend_step(self, actions: list[Any]) -> list[Any]:
        if _Isaac.env is not self._env:
            raise RuntimeError("Another task's world replaced this one: run one task at a time (--env.max_parallel_tasks=1)")
        return _Isaac.thread.submit(self._step, actions).result()

    def _build(self) -> bool:
        """Build this task's world at this batch size, unless the process holds it; True if it was built."""
        from ladder.components.inference.robots.robolab.composition.specs import task_class_name, task_target
        from ladder.components.inference.robots.robolab.oracle import build_oracle
        from robolab.core.environments.runtime import create_env
        from robolab.registrations.droid.auto_env_registrations_jointpos import auto_register_droid_envs

        self._class_name = task_class_name(self.task)
        key = (self._class_name, self.num_envs)
        if _Isaac.key == key:
            return False
        if _Isaac.env is not None:
            _Isaac.env.close()
            _Isaac.env = _Isaac.key = None
        auto_register_droid_envs(task_dirs=None, task=[task_target(self.task)], cameras=None)
        # The defaults of LADDER's env_server.py (RoboLab's add_common_eval_args).
        _Isaac.env, _Isaac.env_cfg = create_env(
            self._class_name,
            device="cuda:0",
            num_envs=self.num_envs,
            instruction_type="default",
            policy="ladder_runner",
            renderer="realtime",
            rendering_mode="balanced",
        )
        _Isaac.oracle = build_oracle(getattr(_Isaac.env.cfg, "subtasks", None), _Isaac.env_cfg.instruction)
        _Isaac.key = key
        if int(_Isaac.env.max_episode_length) != self._max_episode_steps:
            logging.getLogger(__name__).warning(
                "%s: Isaac Lab budget %d steps, expected %d", self.task, _Isaac.env.max_episode_length,
                self._max_episode_steps,
            )
        return True

    def _reset(self) -> list[Any]:
        from ladder.components.inference.robots.robolab.droid_conversions import DroidJointposConverter

        # As LADDER's env_server.py: a new world is reset as it is, a kept one first unfreezes its finished envs.
        if not self._build() and not self._fresh:
            _Isaac.env.reset_eval_state()
        self._fresh = False
        self._env = _Isaac.env
        self._env.reset()
        self._raw, _ = self._env.reset()  # RoboLab's own double reset (robolab.eval.episode.run_episode)
        # DroidJointposConverter takes (width, height), as LADDER's --image-size.
        self._converter = DroidJointposConverter(num_envs=self.num_envs, image_size=(self._w, self._h))
        return self._observations()

    def _step(self, actions: list[Any]) -> list[Any]:
        import omni.kit.app
        import omni.timeline
        import torch

        joints = self._raw["proprio_obs"]["arm_joint_pos"].detach().cpu().numpy()
        commands = self._converter.actions_to_jointpos(actions, joints)
        timeline, kit = omni.timeline.get_timeline_interface(), omni.kit.app.get_app()
        while not timeline.is_playing():
            kit.update()
        self._raw, _, _, _, _ = self._env.step(torch.as_tensor(commands, device=self._env.device))
        return self._observations()

    def _observations(self) -> list[Any]:
        images, proprio = self._raw["image_obs"], self._raw["proprio_obs"]
        results = self._env.get_env_results()
        active = set(self._env.active_env_ids)
        # During a step, _step_count is not yet incremented; LADDER's server counts the step first.
        out_of_time = self._step_count + 1 >= int(self._env.max_episode_length)
        achieved = None
        if _Isaac.oracle:
            achieved = [[bool(g.check(self._env, env_id=i)) for g in _Isaac.oracle] for i in range(self.num_envs)]
        observations = []
        for i in range(self.num_envs):
            success = bool(results[i]["success"])
            metadata = {
                "env_name": self._class_name,
                "robot_name": "franka",
                "instruction": _Isaac.env_cfg.instruction,
                "done": success,
                "truncated": out_of_time or i not in active,
                "oracle": {"achieved": achieved[i] if achieved is not None else [success]},
            }
            observations.append(
                self._converter.observation_dict(
                    env_i=i,
                    main_image=images["over_shoulder_left_camera"][i].detach().cpu().numpy(),
                    wrist_image=images["wrist_cam"][i].detach().cpu().numpy(),
                    joints=proprio["arm_joint_pos"][i].detach().cpu().numpy(),
                    gripper_position=proprio["gripper_pos"][i].detach().cpu().numpy(),
                    metadata=metadata,
                )
            )
        return observations


def vector_env_fns(
    task: str = "",
    n_envs: int = EPISODES_PER_TASK,
    **options: Any,
) -> dict[str, dict[int, Any]]:
    """One factory per task, ``{task: {0: factory}}``: every task, or the comma-separated ``task``.

    LeRobot builds only the tasks it runs (``--env.task``), so listing all 44 costs nothing.
    """
    names = lh.select_tasks(task, task_names())
    _add_sources()  # download before the server reports ready
    return {name: {0: partial(RoboLabLong, name, n_envs, **options)} for name in names}


def make_env(n_envs: int = EPISODES_PER_TASK, use_async_envs: bool = False, task: str = "", **options: Any):
    """One task as a batched vector env (``--env.use_native_batch``); LeRobot otherwise uses ``vector_env_fns``."""
    name = lh.one_task(task, task_names())
    return {name: {0: vector_env_fns(name, n_envs, **options)[name][0]()}}