|
38 | 38 | from pathlib import Path |
39 | 39 | from typing import Any |
40 | 40 |
|
41 | | -import gymnasium as gym # noqa: TC002 — used at runtime inside create_native_env |
42 | 41 | import numpy as np |
43 | 42 |
|
44 | 43 | from roboharness.core.protocol import TaskPhase, TaskProtocol |
45 | | -from roboharness.wrappers import RobotHarnessWrapper, VectorEnvAdapter |
| 44 | +from roboharness.evaluate.lerobot_env import create_native_env |
| 45 | +from roboharness.wrappers import RobotHarnessWrapper |
46 | 46 |
|
47 | 47 | # --------------------------------------------------------------------------- |
48 | 48 | # Constants |
|
65 | 65 | } |
66 | 66 |
|
67 | 67 |
|
68 | | -# --------------------------------------------------------------------------- |
69 | | -# Environment creation via make_env() |
70 | | -# --------------------------------------------------------------------------- |
71 | | - |
72 | | - |
73 | | -def _patch_config_for_headless(env_id: str) -> None: |
74 | | - """Patch the HuggingFace-cached config.yaml for headless (CI) rendering. |
75 | | -
|
76 | | - The lerobot/unitree-g1-mujoco env.py loads config.yaml at import time. |
77 | | - The default config has ``ENABLE_ONSCREEN: true`` which requires GLFW/display. |
78 | | - For headless environments (MUJOCO_GL=osmesa, no DISPLAY), we disable onscreen |
79 | | - rendering so the simulator uses offscreen-only mode. |
80 | | - """ |
81 | | - import os |
82 | | - |
83 | | - has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) |
84 | | - if has_display: |
85 | | - return # Display available, no patching needed |
86 | | - |
87 | | - try: |
88 | | - from huggingface_hub import snapshot_download |
89 | | - |
90 | | - repo_dir = Path(snapshot_download(env_id, repo_type="model")) |
91 | | - except Exception: |
92 | | - return # Can't patch, let make_env handle errors |
93 | | - |
94 | | - config_path = repo_dir / "config.yaml" |
95 | | - if not config_path.exists(): |
96 | | - return |
97 | | - |
98 | | - import yaml |
99 | | - |
100 | | - config = yaml.safe_load(config_path.read_text()) |
101 | | - if config.get("ENABLE_ONSCREEN") is True: |
102 | | - config["ENABLE_ONSCREEN"] = False |
103 | | - config["ENABLE_OFFSCREEN"] = True |
104 | | - config_path.write_text(yaml.dump(config, default_flow_style=False)) |
105 | | - print(" Patched config.yaml: ENABLE_ONSCREEN=false (headless mode)") |
106 | | - |
107 | | - |
108 | | -def create_native_env( |
109 | | - env_id: str = LEROBOT_ENV_ID, |
110 | | - *, |
111 | | - n_envs: int = 1, |
112 | | -) -> gym.Env: |
113 | | - """Create a LeRobot environment, preferring the official ``make_env()`` factory. |
114 | | -
|
115 | | - Strategy (in order): |
116 | | - 1. Try LeRobot's ``make_env()`` — wraps the hub env in ``SyncVectorEnv``. |
117 | | - We unwrap the batch dimension via ``VectorEnvAdapter`` so downstream |
118 | | - wrappers see a standard single-env interface. |
119 | | - 2. Fall back to importing the hub's ``env.py`` directly (works without |
120 | | - the full LeRobot install; avoids the ``SyncVectorEnv`` obs-space |
121 | | - mismatch that the upstream env has). |
122 | | - """ |
123 | | - try: |
124 | | - from huggingface_hub import snapshot_download # noqa: F401 — used below |
125 | | - except ImportError: |
126 | | - print( |
127 | | - "ERROR: huggingface_hub is required for native integration.\n" |
128 | | - "Install with: pip install roboharness[demo,unitree] lerobot" |
129 | | - ) |
130 | | - sys.exit(1) |
131 | | - |
132 | | - # Patch config for headless CI environments before importing env module |
133 | | - _patch_config_for_headless(env_id) |
134 | | - |
135 | | - env = _try_lerobot_make_env(env_id, n_envs=n_envs) |
136 | | - if env is None: |
137 | | - env = _fallback_hub_make_env(env_id, n_envs=n_envs) |
138 | | - |
139 | | - # Add MuJoCo rendering capability — the hub env has a MuJoCo model but |
140 | | - # doesn't expose render_camera(), so the wrapper can't capture screenshots. |
141 | | - _add_mujoco_rendering(env) |
142 | | - |
143 | | - print(f" Env type: {type(env).__name__}") |
144 | | - print(f" Obs space (declared): {env.observation_space}") |
145 | | - print(f" Act space: {env.action_space}") |
146 | | - |
147 | | - return env |
148 | | - |
149 | | - |
150 | | -def _try_lerobot_make_env(env_id: str, *, n_envs: int = 1) -> gym.Env | None: |
151 | | - """Try creating the env via LeRobot's official ``make_env()`` factory. |
152 | | -
|
153 | | - Returns a ``VectorEnvAdapter``-wrapped env on success, or ``None`` if |
154 | | - LeRobot is not installed or ``make_env()`` fails. |
155 | | - """ |
156 | | - try: |
157 | | - from lerobot.common.envs.factory import make_env # type: ignore[import-untyped] |
158 | | - except ImportError: |
159 | | - print(" LeRobot not installed — falling back to hub env import") |
160 | | - return None |
161 | | - |
162 | | - try: |
163 | | - vec_env = make_env(env_id, n_envs=n_envs) |
164 | | - except Exception as exc: |
165 | | - print(f" LeRobot make_env() failed ({exc}) — falling back to hub env import") |
166 | | - return None |
167 | | - |
168 | | - # make_env() wraps in SyncVectorEnv; adapt to standard gym.Env. |
169 | | - env = VectorEnvAdapter(vec_env) |
170 | | - print(" Created via LeRobot make_env() + VectorEnvAdapter") |
171 | | - return env |
172 | | - |
173 | | - |
174 | | -def _fallback_hub_make_env(env_id: str, *, n_envs: int = 1) -> gym.Env: |
175 | | - """Import the hub's ``env.py`` directly (no LeRobot dependency).""" |
176 | | - from huggingface_hub import snapshot_download |
177 | | - |
178 | | - repo_dir = Path(snapshot_download(env_id, repo_type="model")) |
179 | | - sys.path.insert(0, str(repo_dir)) |
180 | | - try: |
181 | | - from env import make_env as hub_make_env # type: ignore[import-not-found] |
182 | | - except ImportError as e: |
183 | | - print(f"ERROR: Failed to import hub env module: {e}") |
184 | | - sys.exit(1) |
185 | | - |
186 | | - env = hub_make_env(n_envs=n_envs) |
187 | | - |
188 | | - # Obs-space shape mismatch (upstream declares (97,) but returns (100,) due to |
189 | | - # floating_base_acc being 6-D not 3-D) is handled automatically by |
190 | | - # RobotHarnessWrapper(auto_fix_obs_space=True). See issue #110. |
191 | | - |
192 | | - print(" Created via direct hub env import (fallback)") |
193 | | - return env |
194 | | - |
195 | | - |
196 | | -def _add_mujoco_rendering( |
197 | | - env: gym.Env, |
198 | | - width: int = 640, |
199 | | - height: int = 480, |
200 | | -) -> None: |
201 | | - """Patch the env to support render_camera() using MuJoCo's renderer. |
202 | | -
|
203 | | - The hub env has a MuJoCo model/data underneath but doesn't expose camera |
204 | | - rendering. We find the model/data, create a mujoco.Renderer, and add |
205 | | - render_camera() + cameras property so RobotHarnessWrapper can capture |
206 | | - multi-view screenshots. |
207 | | - """ |
208 | | - import mujoco |
209 | | - |
210 | | - unwrapped = getattr(env, "unwrapped", env) |
211 | | - |
212 | | - # Find the MuJoCo model and data on the env (attribute names vary by env) |
213 | | - # Search the unwrapped env and one level deeper (e.g. env.sim_env.mj_model |
214 | | - # for the lerobot/unitree-g1-mujoco hub env). |
215 | | - model = None |
216 | | - data = None |
217 | | - search_targets = [unwrapped] |
218 | | - for nested in ("sim_env", "simulator", "sim"): |
219 | | - obj = getattr(unwrapped, nested, None) |
220 | | - if obj is not None: |
221 | | - search_targets.append(obj) |
222 | | - |
223 | | - for target in search_targets: |
224 | | - for attr in ("model", "_model", "mj_model"): |
225 | | - candidate = getattr(target, attr, None) |
226 | | - if candidate is not None and hasattr(candidate, "ncam"): |
227 | | - model = candidate |
228 | | - break |
229 | | - if model is not None: |
230 | | - break |
231 | | - |
232 | | - for target in search_targets: |
233 | | - for attr in ("data", "_data", "mj_data"): |
234 | | - candidate = getattr(target, attr, None) |
235 | | - if candidate is not None and hasattr(candidate, "qpos"): |
236 | | - data = candidate |
237 | | - break |
238 | | - if data is not None: |
239 | | - break |
240 | | - |
241 | | - if model is None or data is None: |
242 | | - print(" Warning: could not find MuJoCo model/data — no screenshots") |
243 | | - return |
244 | | - |
245 | | - renderer = mujoco.Renderer(model, height, width) |
246 | | - camera_names = [model.camera(i).name for i in range(model.ncam)] |
247 | | - |
248 | | - def render_camera(camera_name: str) -> np.ndarray: |
249 | | - if camera_name not in camera_names: |
250 | | - raise ValueError(f"Unknown camera: {camera_name}. Available: {camera_names}") |
251 | | - renderer.update_scene(data, camera=camera_name) |
252 | | - return renderer.render() |
253 | | - |
254 | | - # Patch the unwrapped env so the wrapper detects render_camera capability |
255 | | - unwrapped.render_camera = render_camera # type: ignore[attr-defined] |
256 | | - unwrapped.cameras = camera_names # type: ignore[attr-defined] |
257 | | - # Store model/data for controller state access |
258 | | - unwrapped.mj_model = model # type: ignore[attr-defined] |
259 | | - unwrapped.mj_data = data # type: ignore[attr-defined] |
260 | | - print(f" Added MuJoCo rendering: {len(camera_names)} cameras {camera_names}") |
261 | | - |
262 | | - |
263 | 68 | # --------------------------------------------------------------------------- |
264 | 69 | # Validation |
265 | 70 | # --------------------------------------------------------------------------- |
|
0 commit comments