Update Wall-X to 1.1.0 (#104)

This commit is contained in:
Starrick Liu
2026-06-15 11:40:00 +08:00
committed by GitHub
parent e23a586846
commit 72834e7de5
200 changed files with 33916 additions and 16771 deletions
+8
View File
@@ -0,0 +1,8 @@
"""Environment registrations for harrix."""
_LIBERO_IMPORT_ERROR = None
try:
from wall_x._vendor.harrix.envs import libero # noqa: F401
except ModuleNotFoundError as exc:
_LIBERO_IMPORT_ERROR = exc
+119
View File
@@ -0,0 +1,119 @@
"""Base environment abstraction.
Two execution granularities are supported:
- Episode-level ``run_episode``: caller supplies a predict callback and the env
owns the full episode loop.
- Chunk-level ``reset_episode`` + ``execute_chunk``: caller runs the model
between chunks and feeds action chunks back to the env.
Subclasses must implement the chunk-level primitives. The default episode loop
is built on top of those primitives.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Callable
import numpy as np
from wall_x._vendor.harrix.eval_config import EvalConfig
class BaseEnv(ABC):
@abstractmethod
def __init__(self, cfg: EvalConfig, worker_id: int) -> None:
"""Perform env-specific setup."""
@classmethod
@abstractmethod
def enumerate_episodes(cls, cfg: EvalConfig) -> list[tuple]:
"""Return episode ids to seed JobState before env instances are built.
JobState treats the returned tuples as opaque ids.
"""
@property
@abstractmethod
def robot_spec(self) -> dict:
"""Return robot metadata for driver/checkpoint validation.
Fields:
- dof_layout: dict[str, int]
- cam_names: list[str]
- norm_key: str
"""
# ---- chunk-level primitives ----
@abstractmethod
def reset_episode(self, ep_id: tuple) -> dict:
"""Start a new episode and return the first fresh observation.
Returns {"obs": dict, "instruction": str, "task_desc": str (optional)}.
"""
@abstractmethod
def execute_chunk(self, actions: np.ndarray) -> dict:
"""Execute one action chunk with shape ``(H, action_dim)``.
Returns {"obs": dict, "done": bool, "steps": int}.
"""
def shutdown(self) -> None:
"""Release resources. Subclasses may override."""
# ---- episode-level default implementation ----
def run_episode(
self,
ep_id: tuple,
predict: Callable[[dict, str, int], np.ndarray],
) -> dict:
"""Episode loop built from reset, predict, and execute_chunk.
``predict(observation, instruction, step)`` returns one action chunk.
The env remains unaware of the model transport.
"""
import time
from wall_x._vendor.harrix.envs.libero_common import encode_raw_obs
t_ep_start = time.time()
initial = self.reset_episode(ep_id)
obs = initial["obs"]
instruction = initial["instruction"]
task_desc = initial.get("task_desc", "")
max_rounds = self._max_infer_rounds()
success = False
steps_total = 0
for round_idx in range(max_rounds):
encoded = encode_raw_obs(obs)
chunk = predict(encoded, instruction, round_idx)
result = self.execute_chunk(chunk)
obs = result["obs"]
steps_total += result["steps"]
if result["done"]:
success = True
break
self.finalize_episode(success)
return {
"success": bool(success),
"steps": steps_total,
"elapsed_sec": round(time.time() - t_ep_start, 3),
"task_desc": task_desc,
}
def finalize_episode(self, success: bool) -> None:
"""Hook for env-specific cleanup after an episode (e.g. save rollouts)."""
def _max_infer_rounds(self) -> int:
"""Return the maximum number of model chunks for one episode."""
raise NotImplementedError(
f"{type(self).__name__} must override _max_infer_rounds when using "
"the default run_episode implementation"
)
+342
View File
@@ -0,0 +1,342 @@
"""LIBERO environment implementation.
The chunk-level API mirrors standard LIBERO rollout semantics:
- reset_episode enables rendering, sets initial state, and performs warmup steps.
- execute_chunk may skip intermediate image rendering but re-enables rendering
before returning an observation.
"""
from __future__ import annotations
import gc
import logging
import os
from typing import Optional
import numpy as np
from wall_x._vendor.harrix.envs.base import BaseEnv
from wall_x._vendor.harrix.envs.libero_common import (
get_rollout_frame,
model_action_to_libero_env,
save_rollout_video,
)
from wall_x._vendor.harrix.envs.libero_sim import (
create_libero_engine,
find_image_observables,
get_instruction,
get_libero_dummy_action,
get_task_suite,
load_initial_states,
pick_initial_state,
resolve_task_info,
set_render_enabled,
)
from wall_x._vendor.harrix.envs.registry import register_env
from wall_x._vendor.harrix.eval_config import EvalConfig
logger = logging.getLogger(__name__)
@register_env("libero")
class LiberoEnv(BaseEnv):
def __init__(self, cfg: EvalConfig, worker_id: int) -> None:
self._cfg = cfg
self._libero_cfg = cfg.env.libero
self._worker_id = worker_id
self._seed = cfg.env.seed
self._task_suite_name = self._libero_cfg.task_suite_name
# Task-suite metadata.
self._task_suite = get_task_suite(self._task_suite_name)
self._num_tasks = self._task_suite.n_tasks
self._custom_initial_states = load_initial_states(
self._libero_cfg.initial_states_path
)
# Robosuite engine, lazily rebuilt on task changes.
self._libero_env = None
self._current_task_id: Optional[int] = None
self._rebuild_env_per_episode = self._libero_cfg.rebuild_env_per_episode
# Render-skip state.
self._skip_intermediate_render = self._libero_cfg.skip_intermediate_render
self._force_render_task_ids = set(
self._libero_cfg.force_render_task_indices or []
)
self._effective_skip_render = self._skip_intermediate_render
self._image_obs: list = []
self._chunk_granular_render_toggle = (
self._libero_cfg.chunk_granular_render_toggle
)
self._render_enabled_state = False
# Optional bit-alignment dump for debugging.
self._bit_dump_dir = os.environ.get("WALLX_BIT_DUMP_DIR", "").strip() or None
if self._bit_dump_dir:
os.makedirs(self._bit_dump_dir, exist_ok=True)
rollout_dir = (self._libero_cfg.rollout_dir or "").strip()
if not rollout_dir:
rollout_dir = os.environ.get("WALLX_ROLLOUT_DIR", "").strip()
if rollout_dir and os.environ.get("WALLX_DISABLE_ROLLOUT", "0") == "1":
rollout_dir = ""
self._rollout_dir = rollout_dir or None
self._rollout_fps = int(self._libero_cfg.rollout_fps)
if self._rollout_dir:
worker_subdir = f"worker{worker_id}" if cfg.runtime.num_workers > 1 else ""
self._rollout_dir = os.path.join(
self._rollout_dir,
self._task_suite_name,
worker_subdir,
)
os.makedirs(self._rollout_dir, exist_ok=True)
logger.info("Rollout MP4 saving enabled: %s", self._rollout_dir)
# Per-episode state consumed by execute_chunk.
self._current_ep: Optional[tuple] = None
self._current_task_desc: str = ""
self._current_instruction: str = ""
self._chunk_counter_in_ep: int = 0
self._last_obs_for_dump: Optional[dict] = None
self._replay_images: list[np.ndarray] = []
self._rollout_saved = False
# ---- BaseEnv API ----
@classmethod
def enumerate_episodes(cls, cfg: EvalConfig) -> list[tuple]:
libero_cfg = cfg.env.libero
suite = libero_cfg.task_suite_name
if libero_cfg.task_indices is not None:
task_indices = [int(x) for x in libero_cfg.task_indices]
else:
ts = get_task_suite(suite)
task_indices = list(range(ts.n_tasks))
eps = []
for tid in task_indices:
for epi in range(libero_cfg.num_trials_per_task):
eps.append((suite, tid, epi))
return eps
@property
def robot_spec(self) -> dict:
"""Return robot spec for driver/adapter validation."""
from wall_x._vendor.harrix.utils.train_config import (
load_train_config_with_ckpt_overlay,
)
train_cfg = load_train_config_with_ckpt_overlay(
self._cfg.model.train_config_path,
self._cfg.model.checkpoint_path,
)
return {
"dof_layout": train_cfg.get("dof_config", {}),
"cam_names": list(self._cfg.model.cam_names),
"norm_key": self._cfg.model.norm_key,
}
def _max_infer_rounds(self) -> int:
return self._libero_cfg.max_infer_times
def reset_episode(self, ep_id: tuple) -> dict:
suite, task_id, ep_idx = ep_id
if suite != self._task_suite_name:
raise ValueError(
f"env bound to suite={self._task_suite_name}, got ep with suite={suite}"
)
need_rebuild = self._rebuild_env_per_episode or self._current_task_id != task_id
if need_rebuild:
self._rebuild_env(task_id)
task_desc, default_states = resolve_task_info(self._task_suite, task_id)
init_state = pick_initial_state(
self._libero_cfg.initial_states_path,
self._custom_initial_states,
task_desc,
default_states,
ep_idx,
)
if not need_rebuild:
self._libero_env.reset()
obs = self._libero_env.set_init_state(init_state)
if obs is None:
raise RuntimeError("set_init_state returned None")
set_render_enabled(self._image_obs, True)
self._render_enabled_state = True
dummy_action = get_libero_dummy_action()
for _ in range(10):
obs, _, _, _ = self._libero_env.step(dummy_action)
self._current_ep = (task_id, ep_idx)
self._current_task_desc = task_desc
self._current_instruction = get_instruction(task_desc)
self._chunk_counter_in_ep = 0
self._last_obs_for_dump = obs
self._begin_rollout_capture(obs)
return {
"obs": obs,
"instruction": self._current_instruction,
"task_desc": task_desc,
}
def execute_chunk(self, actions: np.ndarray) -> dict:
actions = np.asarray(actions, dtype=np.float32)
H = actions.shape[0]
if self._bit_dump_dir and self._current_ep is not None:
self._dump_chunk_npz(
(self._task_suite_name, *self._current_ep),
self._chunk_counter_in_ep,
self._last_obs_for_dump or {},
actions,
)
self._chunk_counter_in_ep += 1
skip_render = self._chunk_skip_render()
# Disable rendering at the chunk start when render-skip is enabled.
if skip_render and self._image_obs:
if self._render_enabled_state:
set_render_enabled(self._image_obs, False)
self._render_enabled_state = False
last_obs = None
done = False
steps = 0
for step_idx in range(H):
# Re-enable rendering before the final step to return a fresh image.
if (
skip_render
and self._image_obs
and step_idx == H - 1
and not self._render_enabled_state
):
set_render_enabled(self._image_obs, True)
self._render_enabled_state = True
action = model_action_to_libero_env(actions[step_idx].reshape(-1))
obs, _, done_flag, _ = self._libero_env.step(action)
last_obs = obs
steps += 1
self._append_rollout_frame(obs)
if bool(done_flag):
done = True
# Do not add an extra simulator step on early success; just make
# sure future rendering is enabled.
if skip_render and self._image_obs and not self._render_enabled_state:
set_render_enabled(self._image_obs, True)
self._render_enabled_state = True
break
self._last_obs_for_dump = last_obs
return {"obs": last_obs, "done": done, "steps": steps}
def finalize_episode(self, success: bool) -> None:
self._save_episode_rollout(success)
def shutdown(self) -> None:
if self._libero_env is not None:
try:
self._libero_env.close()
except Exception:
pass
self._libero_env = None
# ---- internals ----
def _chunk_skip_render(self) -> bool:
"""Skip intermediate renders unless rollout MP4 saving needs every frame."""
return self._effective_skip_render and self._rollout_dir is None
def _begin_rollout_capture(self, obs: dict | None) -> None:
self._replay_images = []
self._rollout_saved = False
if self._rollout_dir and obs is not None:
if self._image_obs and not self._render_enabled_state:
set_render_enabled(self._image_obs, True)
self._render_enabled_state = True
self._replay_images.append(get_rollout_frame(obs))
def _append_rollout_frame(self, obs: dict | None) -> None:
if self._rollout_dir and obs is not None:
self._replay_images.append(get_rollout_frame(obs))
def _save_episode_rollout(self, success: bool) -> None:
if (
not self._rollout_dir
or self._rollout_saved
or not self._replay_images
or self._current_ep is None
):
return
task_id, ep_idx = self._current_ep
try:
mp4_path = save_rollout_video(
self._rollout_dir,
self._replay_images,
task_id=task_id,
episode_idx=ep_idx,
success=success,
task_description=self._current_task_desc,
fps=self._rollout_fps,
)
self._rollout_saved = True
if mp4_path:
logger.info("Saved rollout MP4: %s", mp4_path)
except Exception as exc:
logger.warning(
"Failed to save rollout MP4 for task%d ep%d: %s",
task_id,
ep_idx,
exc,
)
def _rebuild_env(self, task_id: int) -> None:
if self._libero_env is not None:
try:
self._libero_env.close()
except Exception:
pass
self._libero_env = None
gc.collect()
self._libero_env = create_libero_engine(
task_id=task_id,
task_suite_name=self._task_suite_name,
resolution=256,
seed=self._seed,
)
self._current_task_id = task_id
self._image_obs = find_image_observables(self._libero_env)
self._effective_skip_render = (
self._skip_intermediate_render
and task_id not in self._force_render_task_ids
)
def _dump_chunk_npz(
self, ep_id, chunk_idx: int, raw_obs: dict, chunk_actions: np.ndarray
) -> None:
_, task_id, ep_idx = ep_id
path = os.path.join(
self._bit_dump_dir, f"t{task_id}_ep{ep_idx}_c{chunk_idx}.npz"
)
fields = {"action_chunk": np.asarray(chunk_actions, dtype=np.float32)}
for k in (
"robot0_eef_pos",
"robot0_eef_quat",
"robot0_gripper_qpos",
"agentview_image",
"robot0_eye_in_hand_image",
):
v = raw_obs.get(k)
if v is not None:
fields[k] = np.asarray(v)
np.savez_compressed(path, **fields)
+359
View File
@@ -0,0 +1,359 @@
"""Env-adapter IO helpers for single-arm LIBERO tasks.
Env code extracts a compact ndarray payload from raw LIBERO observations.
Adapters then build proprioception, masks, and 7-dof right-arm action chunks
from that payload. This module intentionally depends only on NumPy so adapter
processes can import it without importing robosuite or LIBERO.
"""
from __future__ import annotations
import math
import os
import numpy as np
# Fallback single-arm LIBERO dof_config when train_config does not provide one.
_LIBERO_FALLBACK_DOF_CONFIG = {
"follow_right_ee_cartesian_pos": 3,
"follow_right_ee_rotation": 3,
"follow_right_gripper": 1,
}
_LIBERO_FALLBACK_AGENT_POS_CONFIG = dict(_LIBERO_FALLBACK_DOF_CONFIG)
_VIRTUAL_TAIL_KEYS = frozenset(("action_padding",))
def _resolve_dof_config(train_config: dict) -> dict:
return (
train_config.get("dof_config")
or train_config.get("task", {}).get("dof_config")
or _LIBERO_FALLBACK_DOF_CONFIG
)
def _resolve_agent_pos_config(train_config: dict) -> dict:
return (
train_config.get("agent_pos_config")
or train_config.get("task", {}).get("agent_pos_config")
or _LIBERO_FALLBACK_AGENT_POS_CONFIG
)
def _move_virtual_keys_to_tail(layout: dict) -> dict:
head = {k: v for k, v in layout.items() if k not in _VIRTUAL_TAIL_KEYS}
tail = {k: v for k, v in layout.items() if k in _VIRTUAL_TAIL_KEYS}
return {**head, **tail}
def _effective_agent_pos_config(train_config: dict, state_values: dict) -> dict:
config = _move_virtual_keys_to_tail(dict(_resolve_agent_pos_config(train_config)))
gripper_key = next(
(
key
for key in config
if key.replace("follow_", "").replace("master_", "") == "right_gripper"
),
None,
)
if gripper_key is None:
return config
old_dim = int(config[gripper_key])
target_dim = None
override = os.environ.get("WALLX_LIBERO_STATE_GRIPPER_DIM")
if override:
target_dim = int(override)
elif os.environ.get("WALLX_LIBERO_AUTO_STATE_GRIPPER_DIM", "1") != "0":
norm_dim = int(train_config.get("_libero_proprio_norm_dim") or 0)
real_dim = sum(v for k, v in config.items() if k not in _VIRTUAL_TAIL_KEYS)
if norm_dim == real_dim + 1:
target_dim = old_dim + 1
available_dim = state_values["right_gripper"].shape[1]
if target_dim is None or target_dim == old_dim or target_dim > available_dim:
return config
config[gripper_key] = target_dim
delta = target_dim - old_dim
if "action_padding" in config:
config["action_padding"] = max(0, int(config["action_padding"]) - delta)
return config
def _build_right_arm_state_values(obs_ndarrays: dict) -> dict[str, np.ndarray]:
"""Bare-key state tensors for single-arm LIBERO proprio construction."""
rot3 = np.asarray(obs_ndarrays["eef_axisangle"], dtype=np.float32).reshape(1, 3)
values: dict[str, np.ndarray] = {
"right_ee_cartesian_pos": np.asarray(
obs_ndarrays["eef_pos"], dtype=np.float32
).reshape(1, 3),
"right_ee_rotation": rot3,
"right_gripper": np.asarray(obs_ndarrays["gripper"], dtype=np.float32).reshape(
1, -1
),
}
from wall_x._vendor.x2robot_utils.geometry import euler_to_matrix_zyx_6d_nb
rot6d = euler_to_matrix_zyx_6d_nb(rot3.astype(np.float64)).reshape(1, 6)
values["right_ee_rotation_6D"] = rot6d.astype(np.float32)
return values
# Auxiliary action keys that should be masked out for single-arm LIBERO.
_DOF_MASK_ZERO_KEYS = frozenset(
(
"follow_left_ee_cartesian_pos",
"follow_left_ee_rotation",
"follow_left_ee_rotation_6D",
"follow_left_gripper",
"head_actions",
"height",
"velocity_decomposed",
"action_padding",
)
)
# ============================================================
# LIBERO raw observation decoding helpers.
# ============================================================
def _get_libero_image(obs: dict) -> np.ndarray:
"""Return the third-person camera image, rotated to match preprocessing."""
return obs["agentview_image"][::-1, ::-1]
def get_rollout_frame(obs: dict) -> np.ndarray:
"""Return one RGB frame for rollout MP4 saving."""
return np.asarray(_get_libero_image(obs), dtype=np.uint8)
def _get_libero_wrist_image(obs: dict) -> np.ndarray:
"""Return the wrist camera image, rotated to match preprocessing."""
return obs["robot0_eye_in_hand_image"][::-1, ::-1]
def _quat2axisangle(quat) -> np.ndarray:
"""Convert an xyzw quaternion to a 3D axis-angle vector."""
if quat[3] > 1.0:
quat[3] = 1.0
elif quat[3] < -1.0:
quat[3] = -1.0
den = np.sqrt(1.0 - quat[3] * quat[3])
if math.isclose(den, 0.0):
return np.zeros(3)
return (quat[:3] * 2.0 * math.acos(quat[3])) / den
# ============================================================
# Env-side to adapter-side observation encoding.
# ============================================================
def encode_raw_obs(raw_obs: dict) -> dict:
"""Extract the minimal ndarray payload from a raw LIBERO observation.
The payload contains three 1-D state arrays and two rotated image arrays.
"""
if "agentview_image" not in raw_obs:
raise KeyError(
"agentview_image missing in raw_obs; render-skip may have returned "
"a stale observation"
)
return {
"eef_pos": np.asarray(raw_obs["robot0_eef_pos"], dtype=np.float32),
"eef_axisangle": np.asarray(
_quat2axisangle(raw_obs["robot0_eef_quat"]), dtype=np.float32
),
"gripper": np.asarray(raw_obs["robot0_gripper_qpos"], dtype=np.float32),
"face_view": _get_libero_image(raw_obs),
"wrist_view": _get_libero_wrist_image(raw_obs),
}
# ============================================================
# Adapter-side proprioception and mask construction.
# ============================================================
def encode_proprio(
obs_ndarrays: dict,
train_config: dict,
action_horizon: int,
) -> dict:
"""Convert a single-arm LIBERO ndarray payload into model input fields.
Returned fields include proprioception, agent_pos_mask, dof_mask,
face_view, and right_wrist_view.
"""
state_values = _build_right_arm_state_values(obs_ndarrays)
agent_pos_config = _effective_agent_pos_config(train_config, state_values)
dof_config = _move_virtual_keys_to_tail(dict(_resolve_dof_config(train_config)))
propri_parts: list[np.ndarray] = []
mask_parts: list[np.ndarray] = []
for key, dim in agent_pos_config.items():
bare = key.replace("follow_", "").replace("master_", "")
if bare in state_values:
v = state_values[bare]
if bare == "right_gripper" and v.shape[1] > dim:
v = v[:, :dim]
if v.shape[1] != dim:
raise ValueError(
f"agent_pos_config[{key!r}]={dim} does not match "
f"observation dimension {v.shape[1]}"
)
propri_parts.append(v)
mask_parts.append(np.ones((1, dim), dtype=np.float32))
else:
propri_parts.append(np.zeros((1, dim), dtype=np.float32))
mask_parts.append(np.zeros((1, dim), dtype=np.float32))
# (1, 1, D)
proprioception = np.concatenate(propri_parts, axis=1)[None]
agent_pos_mask = np.concatenate(mask_parts, axis=1)[None]
# dof_mask: (1, T, D_action)
total_dof = sum(dof_config.values())
dof_mask = np.ones((1, action_horizon, total_dof))
start = 0
for key, dim in dof_config.items():
if key in _DOF_MASK_ZERO_KEYS:
dof_mask[:, :, start : start + dim] = 0
start += dim
return {
"proprioception": proprioception.astype(np.float32),
"agent_pos_mask": agent_pos_mask.astype(np.float32),
"dof_mask": dof_mask,
"face_view": obs_ndarrays["face_view"],
"right_wrist_view": obs_ndarrays["wrist_view"],
}
# ============================================================
# Adapter-side action decoding.
# ============================================================
def decode_chunk(predict_action: np.ndarray, train_config: dict) -> np.ndarray:
"""Extract a 7-dof right-arm chunk from model action output."""
if predict_action.ndim == 3:
predict_action = predict_action[0]
dof_config = _resolve_dof_config(train_config)
slices: dict[str, slice] = {}
start = 0
for key, dim in dof_config.items():
bare = key.replace("follow_", "").replace("master_", "")
slices[bare] = slice(start, start + dim)
start += dim
pos = predict_action[:, slices["right_ee_cartesian_pos"]]
grip = predict_action[:, slices["right_gripper"]]
if "right_ee_rotation_6D" in slices:
from wall_x._vendor.x2robot_utils.geometry import so3_to_euler_zyx_batch_nb
rot6d = np.asarray(
predict_action[:, slices["right_ee_rotation_6D"]], dtype=np.float64
)
rot = so3_to_euler_zyx_batch_nb(rot6d).astype(np.float32)
elif "right_ee_rotation" in slices:
rot = predict_action[:, slices["right_ee_rotation"]]
else:
raise KeyError(
"dof_config has no right-arm rotation slice "
f"(keys={list(slices.keys())})"
)
return np.concatenate([pos, rot, grip], axis=1)
def gripper_model_to_libero_osc(grip_2d: np.ndarray) -> float:
"""Map model gripper output to robosuite OSC_POSE gripper command in [-1, 1]."""
g = np.asarray(grip_2d, dtype=np.float64).reshape(-1)
if g.size == 0:
raise ValueError("empty gripper action")
cmd = float(g[0])
if os.environ.get("WALLX_LIBERO_GRIPPER_BINARIZE", "0") == "1":
if abs(cmd) < 1e-6:
cmd = -1.0
else:
cmd = float(np.sign(cmd))
if os.environ.get("WALLX_LIBERO_INVERT_GRIPPER", "0") == "1":
cmd *= -1.0
return cmd
def _sanitize_task_description(task_description: str, max_len: int = 50) -> str:
return (
task_description.lower()
.replace(" ", "_")
.replace("\n", "_")
.replace(".", "_")[:max_len]
)
def save_rollout_video(
rollout_dir: str,
rollout_images: list[np.ndarray],
*,
task_id: int,
episode_idx: int,
success: bool,
task_description: str,
fps: int = 30,
) -> str | None:
"""Save an MP4 replay of one LIBERO episode."""
if not rollout_images:
return None
import imageio
os.makedirs(rollout_dir, exist_ok=True)
task_slug = _sanitize_task_description(task_description)
mp4_path = os.path.join(
rollout_dir,
f"task{task_id}_ep{episode_idx}--success={int(success)}--{task_slug}.mp4",
)
writer = imageio.get_writer(mp4_path, fps=fps, macro_block_size=1)
try:
for img in rollout_images:
writer.append_data(np.asarray(img, dtype=np.uint8))
finally:
writer.close()
return mp4_path
def model_action_to_libero_env(action: np.ndarray) -> np.ndarray:
"""Convert model output to the robosuite OSC_POSE 7D action.
Internal LIBERO evaluation passes the model's 7D chunk directly to
``env.step``. Keep that as the public default; conversion modes remain
available only for ablations through environment variables.
"""
from scipy.spatial.transform import Rotation as R
a = np.asarray(action, dtype=np.float64).reshape(-1)
if a.size not in (7, 8):
raise ValueError(f"expected 7D or 8D model action, got shape {a.shape}")
pos_delta = a[:3]
rot_mode = os.environ.get("WALLX_LIBERO_ROT_MODE", "direct").strip()
if rot_mode == "euler_zyx_to_rotvec":
rot_aa = R.from_euler("zyx", a[3:6]).as_rotvec()
elif rot_mode == "direct":
rot_aa = a[3:6]
else:
raise ValueError(
"WALLX_LIBERO_ROT_MODE must be 'euler_zyx_to_rotvec' or 'direct', "
f"got {rot_mode!r}"
)
grip = gripper_model_to_libero_osc(a[6:8] if a.size == 8 else a[6:7])
return np.concatenate([pos_delta, rot_aa, np.array([grip], dtype=np.float64)])
+181
View File
@@ -0,0 +1,181 @@
"""LIBERO benchmark and robosuite engine helpers.
This module may import robosuite and LIBERO. Adapter-side code should use
``libero_common.py`` instead, which only depends on NumPy.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Optional
import numpy as np
from robosuite.wrappers import VisualizationWrapper
logger = logging.getLogger(__name__)
# ============================================================
# One-time side effect: auto-create ~/.libero/config.yaml so importing LIBERO
# does not trigger an interactive prompt.
# ============================================================
def _ensure_libero_config() -> None:
import yaml as _yaml
libero_config_path = os.environ.get(
"LIBERO_CONFIG_PATH", os.path.expanduser("~/.libero")
)
config_file = os.path.join(libero_config_path, "config.yaml")
if not os.path.exists(config_file):
os.makedirs(libero_config_path, exist_ok=True)
import libero.libero as _libero_pkg
benchmark_root = os.path.dirname(os.path.abspath(_libero_pkg.__file__))
default_paths = {
"benchmark_root": benchmark_root,
"bddl_files": os.path.join(benchmark_root, "./bddl_files"),
"init_states": os.path.join(benchmark_root, "./init_files"),
"datasets": os.path.join(benchmark_root, "../datasets"),
"assets": os.path.join(benchmark_root, "./assets"),
}
with open(config_file, "w") as f:
_yaml.dump(default_paths, f)
logger.info("Auto-created LIBERO config: %s", config_file)
_ensure_libero_config()
# ============================================================
# task-suite entry point
# ============================================================
def get_task_suite(task_suite_name: str):
"""Load a LIBERO task suite."""
from libero.libero import benchmark
return benchmark.get_benchmark_dict()[task_suite_name]()
# ============================================================
# actions
# ============================================================
def get_libero_dummy_action() -> list[float]:
"""Return the 7-dof dummy action used for episode warmup."""
return [0, 0, 0, 0, 0, 0, -1]
# ============================================================
# robosuite engine factory
# ============================================================
def create_libero_engine(
task_id: int,
task_suite_name: str,
resolution: int = 256,
seed: int = 7,
) -> Any:
"""Construct one LIBERO robosuite engine."""
from libero.libero import get_libero_path
from libero.libero.envs import OffScreenRenderEnv
task_suite = get_task_suite(task_suite_name)
task = task_suite.get_task(task_id)
task_bddl_file = os.path.join(
get_libero_path("bddl_files"), task.problem_folder, task.bddl_file
)
env = OffScreenRenderEnv(
bddl_file_name=task_bddl_file,
camera_heights=resolution,
camera_widths=resolution,
)
# The seed still affects object poses even when an initial state is fixed.
env.seed(seed)
env.env = VisualizationWrapper(env.env)
env.env.set_visualization_setting(setting="grippers", visible=False)
return env
# ============================================================
# task metadata / initial states
# ============================================================
def load_initial_states(initial_states_path: str) -> Optional[dict]:
"""Load custom initial states, or return None for suite defaults."""
if initial_states_path == "DEFAULT":
return None
with open(initial_states_path, "r") as f:
return json.load(f)
def resolve_task_info(task_suite, task_id: int) -> tuple[str, Any]:
"""Return ``(task_desc, default_initial_states)`` for one task id."""
num_tasks = task_suite.n_tasks
if task_id < 0 or task_id >= num_tasks:
raise ValueError(f"invalid task_id={task_id}, num_tasks={num_tasks}")
task = task_suite.get_task(task_id)
return task.language, task_suite.get_task_init_states(task_id)
def pick_initial_state(
initial_states_path: str,
custom_initial_states: Optional[dict],
task_desc: str,
default_states: Any,
episode_idx: int,
) -> np.ndarray:
"""Pick one initial state from suite defaults or a custom states file."""
if initial_states_path == "DEFAULT":
if default_states is None:
raise ValueError("default states missing for DEFAULT mode")
return default_states[episode_idx]
if custom_initial_states is None:
raise ValueError(f"custom initial states not loaded for {initial_states_path}")
key = task_desc.replace(" ", "_")
ep_key = f"demo_{episode_idx}"
record = custom_initial_states[key][ep_key]
if not record["success"]:
raise ValueError(f"expert demo failed for {ep_key}")
return np.array(record["initial_state"])
def get_instruction(task_desc: str) -> str:
"""Return the instruction text for a LIBERO task description."""
return task_desc
# ============================================================
# render-skip: directly assign obs._enabled to avoid set_enabled() side effects.
# ============================================================
def find_image_observables(env) -> list:
"""Find image observables along the env.env wrapper chain."""
cur = env
seen = set()
while cur is not None and id(cur) not in seen:
seen.add(id(cur))
if hasattr(cur, "_observables") and isinstance(cur._observables, dict):
return [
obs
for obs in cur._observables.values()
if getattr(obs, "modality", None) == "image"
]
cur = getattr(cur, "env", None)
return []
def set_render_enabled(image_obs_list, enabled: bool) -> None:
for obs in image_obs_list:
obs._enabled = enabled
+53
View File
@@ -0,0 +1,53 @@
"""Environment registry and factory."""
from __future__ import annotations
from typing import Type
from wall_x._vendor.harrix.eval_config import EvalConfig
from wall_x._vendor.harrix.envs.base import BaseEnv
_REGISTRY: dict[str, Type[BaseEnv]] = {}
def register_env(name: str):
def deco(cls: Type[BaseEnv]):
if name in _REGISTRY:
raise ValueError(
f"env {name!r} already registered (cls={_REGISTRY[name].__name__})"
)
_REGISTRY[name] = cls
return cls
return deco
def _get_class(cfg: EvalConfig) -> Type[BaseEnv]:
t = cfg.env.type
cls = _REGISTRY.get(t)
if cls is None:
if t == "libero":
import wall_x._vendor.harrix.envs as _envs
exc = getattr(_envs, "_LIBERO_IMPORT_ERROR", None)
if exc is not None:
raise RuntimeError(
"LIBERO evaluation dependencies are not installed. "
"Install LIBERO/robosuite and their simulator dependencies "
"before using env.type='libero'."
) from exc
raise ValueError(f"unknown env type={t!r}, registered: {sorted(_REGISTRY)}")
return cls
def build_env(cfg: EvalConfig, worker_id: int) -> BaseEnv:
return _get_class(cfg)(cfg, worker_id)
def enumerate_episodes_for(cfg: EvalConfig) -> list[tuple]:
return _get_class(cfg).enumerate_episodes(cfg)
def registered_envs() -> list[str]:
return sorted(_REGISTRY)