diff --git a/isaaclab_arena/assets/asset_registry.py b/isaaclab_arena/assets/asset_registry.py index 33bb69acc..dfc0aa39a 100644 --- a/isaaclab_arena/assets/asset_registry.py +++ b/isaaclab_arena/assets/asset_registry.py @@ -215,6 +215,7 @@ def ensure_assets_registered(): if not _assets_registered: # Import modules to trigger asset registration via decorators import isaaclab_arena.assets.background_library # noqa: F401 + import isaaclab_arena.assets.dexsuite_assets # noqa: F401 import isaaclab_arena.assets.device_library # noqa: F401 import isaaclab_arena.assets.hdr_image_library # noqa: F401 import isaaclab_arena.assets.object_library # noqa: F401 diff --git a/isaaclab_arena/assets/dexsuite_assets.py b/isaaclab_arena/assets/dexsuite_assets.py new file mode 100644 index 000000000..7304eea0c --- /dev/null +++ b/isaaclab_arena/assets/dexsuite_assets.py @@ -0,0 +1,115 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Dexsuite-aligned procedural assets (table + lift object) for Newton / Arena environments.""" + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObjectCfg +from isaaclab.sim import CuboidCfg, RigidBodyMaterialCfg + +from isaaclab_arena.assets.object import Object +from isaaclab_arena.assets.object_base import ObjectType +from isaaclab_arena.assets.register import register_asset +from isaaclab_arena.utils.pose import Pose + + +# Kinematic table from Isaac Lab Dexsuite (``dexsuite_env_cfg.TABLE_SPAWN_CFG``). +_DEXSUITE_TABLE_SPAWN_CFG = sim_utils.CuboidCfg( + size=(0.8, 1.5, 0.04), + rigid_props=sim_utils.RigidBodyPropertiesCfg(kinematic_enabled=True), + collision_props=sim_utils.CollisionPropertiesCfg(), + visible=False, +) + +# Single cuboid preset used with Newton (no multi-asset spawner); matches ``ObjectCfg.cube`` in Dexsuite. +_DEXSUITE_LIFT_CUBE_SPAWN_CFG = CuboidCfg( + size=(0.05, 0.1, 0.1), + physics_material=RigidBodyMaterialCfg(static_friction=0.5), + rigid_props=sim_utils.RigidBodyPropertiesCfg( + solver_position_iteration_count=16, + solver_velocity_iteration_count=0, + disable_gravity=False, + ), + collision_props=sim_utils.CollisionPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=0.2), +) + + +@register_asset +class DexsuiteManipTable(Object): + """Kinematic cuboid table matching Isaac Lab Dexsuite (asset name ``table`` for command visuals).""" + + name = "dexsuite_manip_table" + tags = ["background", "dexsuite"] + # ``LiftObjectTask.make_il_termination_cfg`` reads this before Dexsuite tasks replace terminations. + # Matches Dexsuite ``object_out_of_bound`` z lower bound (see ``dexsuite_env_cfg.TerminationsCfg``). + object_min_z: float = 0.0 + + def __init__( + self, + instance_name: str | None = None, + prim_path: str | None = None, + initial_pose: Pose | None = None, + ): + resolved_name = instance_name if instance_name is not None else "table" + resolved_prim = prim_path if prim_path is not None else "{ENV_REGEX_NS}/table" + pose = initial_pose or Pose( + position_xyz=(-0.55, 0.0, 0.235), + rotation_xyzw=(0.0, 0.0, 0.0, 1.0), + ) + # ``usd_path`` is required by ``Object`` for RIGID; unused when spawn is procedural (``object_type`` is fixed). + super().__init__( + name=resolved_name, + prim_path=resolved_prim, + object_type=ObjectType.RIGID, + usd_path="", + initial_pose=pose, + ) + self.disable_reset_pose() + + def _generate_rigid_cfg(self) -> RigidObjectCfg: + cfg = RigidObjectCfg( + prim_path=self.prim_path, + spawn=_DEXSUITE_TABLE_SPAWN_CFG, + **self.asset_cfg_addon, + ) + return self._add_initial_pose_to_cfg(cfg) + + +@register_asset +class DexsuiteLiftManipObject(Object): + """Lift-task manipuland: procedural cuboid matching Dexsuite ``ObjectCfg.cube`` (Newton-safe, single geometry).""" + + name = "dexsuite_lift_object" + tags = ["object", "dexsuite"] + + def __init__( + self, + instance_name: str | None = None, + prim_path: str | None = None, + initial_pose: Pose | None = None, + ): + resolved_name = instance_name if instance_name is not None else "object" + resolved_prim = prim_path if prim_path is not None else "{ENV_REGEX_NS}/Object" + pose = initial_pose or Pose( + position_xyz=(-0.55, 0.1, 0.35), + rotation_xyzw=(0.0, 0.0, 0.0, 1.0), + ) + super().__init__( + name=resolved_name, + prim_path=resolved_prim, + object_type=ObjectType.RIGID, + usd_path="", + initial_pose=pose, + ) + self.disable_reset_pose() + + def _generate_rigid_cfg(self) -> RigidObjectCfg: + cfg = RigidObjectCfg( + prim_path=self.prim_path, + spawn=_DEXSUITE_LIFT_CUBE_SPAWN_CFG, + **self.asset_cfg_addon, + ) + return self._add_initial_pose_to_cfg(cfg) diff --git a/isaaclab_arena/embodiments/__init__.py b/isaaclab_arena/embodiments/__init__.py index 5f3052fd2..d63721b26 100644 --- a/isaaclab_arena/embodiments/__init__.py +++ b/isaaclab_arena/embodiments/__init__.py @@ -9,3 +9,4 @@ from .g1.g1 import * from .galbot.galbot import * from .gr1t2.gr1t2 import * +from .kuka_allegro.kuka_allegro import * diff --git a/isaaclab_arena/embodiments/kuka_allegro/__init__.py b/isaaclab_arena/embodiments/kuka_allegro/__init__.py new file mode 100644 index 000000000..7465b02fe --- /dev/null +++ b/isaaclab_arena/embodiments/kuka_allegro/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from .kuka_allegro import * # noqa: F403 diff --git a/isaaclab_arena/embodiments/kuka_allegro/kuka_allegro.py b/isaaclab_arena/embodiments/kuka_allegro/kuka_allegro.py new file mode 100644 index 000000000..d39f361b4 --- /dev/null +++ b/isaaclab_arena/embodiments/kuka_allegro/kuka_allegro.py @@ -0,0 +1,229 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Kuka + Allegro embodiment for Dexsuite-style manipulation. + +Aligned with +:class:`isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_env_cfg.KukaAllegroMixinCfg`: +robot, fingertip contact sensors, relative joint actions, state or tiled-camera observations, +PhysX vs Newton event presets, and Dexsuite simulation rates. + +Scene geometry (object, table, ground, lights) is supplied by the Arena :class:`~isaaclab_arena.scene.scene.Scene`; +this embodiment only adds the robot and sensors (and optional cameras). +""" + +from __future__ import annotations + +from typing import Any, Literal + +from isaaclab.assets import ArticulationCfg +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.sensors import ContactSensorCfg, TiledCameraCfg +from isaaclab.utils import configclass +from isaaclab.utils.noise import UniformNoiseCfg as Unoise +from isaaclab_assets.robots import KUKA_ALLEGRO_CFG + +from isaaclab_tasks.manager_based.manipulation.dexsuite import dexsuite_env_cfg as dexsuite +from isaaclab_tasks.manager_based.manipulation.dexsuite import mdp as dexsuite_mdp +from isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro import ( + dexsuite_kuka_allegro_env_cfg as kuka_dexsuite_cfg, +) +from isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.camera_cfg import ( + BaseTiledCameraCfg, + WristTiledCameraCfg, +) + +from isaaclab_arena.assets.register import register_asset +from isaaclab_arena.embodiments.common.arm_mode import ArmMode +from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase +from isaaclab_arena.environments.isaaclab_arena_manager_based_env import IsaacLabArenaManagerBasedRLEnvCfg +from isaaclab_arena.utils.pose import Pose + +FINGERTIP_LIST = kuka_dexsuite_cfg.FINGERTIP_LIST + + +# --------------------------------------------------------------------------- +# Observation cfgs (Arena-local): Isaac Lab's ``StateObservationCfg`` / camera obs use ``super()`` in +# ``__post_init__``. Arena's :func:`~isaaclab_arena.utils.configclass.combine_configclass_instances` +# calls those ``__post_init__`` hooks with ``self`` equal to the *merged* config type, which is not a +# subclass of ``StateObservationCfg``, so ``super()`` raises. We duplicate upstream layout with explicit +# ``Parent.__post_init__(self)`` calls instead (same as ``camera_cfg.StateObservationCfg`` et al.). +# --------------------------------------------------------------------------- + + +@configclass +class ArenaDexsuiteKukaStateObservationCfg(dexsuite.ObservationsCfg): + """State observations matching ``camera_cfg.StateObservationCfg``; merge-safe ``__post_init__``.""" + + def __post_init__(self) -> None: + dexsuite.ObservationsCfg.__post_init__(self) + self.proprio.contact = ObsTerm( + func=dexsuite_mdp.fingers_contact_force_b, + params={"contact_sensor_names": [f"{link}_object_s" for link in FINGERTIP_LIST]}, + clip=(-20.0, 20.0), + ) + self.proprio.hand_tips_state_b.params["body_asset_cfg"].body_names = ["palm_link", ".*_tip"] + + +@configclass +class ArenaDexsuiteKukaSingleCameraObservationsCfg(ArenaDexsuiteKukaStateObservationCfg): + """Single-camera observations matching ``camera_cfg.SingleCameraObservationsCfg``.""" + + @configclass + class BaseImageObsCfg(ObsGroup): + object_observation_b = ObsTerm( + func=dexsuite_mdp.vision_camera, + noise=Unoise(n_min=-0.0, n_max=0.0), + clip=(-1.0, 1.0), + params={"sensor_cfg": SceneEntityCfg("base_camera")}, + ) + + base_image: BaseImageObsCfg = BaseImageObsCfg() + + def __post_init__(self) -> None: + ArenaDexsuiteKukaStateObservationCfg.__post_init__(self) + for group in self.__dataclass_fields__.values(): + obs_group = getattr(self, group.name) + obs_group.history_length = None + + +@configclass +class ArenaDexsuiteKukaDuoCameraObservationsCfg(ArenaDexsuiteKukaSingleCameraObservationsCfg): + """Duo-camera observations matching ``camera_cfg.DuoCameraObservationsCfg``.""" + + @configclass + class WristImageObsCfg(ObsGroup): + wrist_observation = ObsTerm( + func=dexsuite_mdp.vision_camera, + noise=Unoise(n_min=-0.0, n_max=0.0), + clip=(-1.0, 1.0), + params={"sensor_cfg": SceneEntityCfg("wrist_camera")}, + ) + + wrist_image: WristImageObsCfg = WristImageObsCfg() + + +@configclass +class DexsuiteKukaAllegroEmbodimentSceneCfg: + """Robot and fingertip contact sensors (Dexsuite naming). + + .. note:: + Do not set :attr:`replicate_physics` here. It belongs on :class:`~isaaclab.scene.InteractiveSceneCfg`; + merging both into one Arena scene class triggers a type clash across Isaac Lab versions. + :meth:`KukaAllegroDexsuiteEmbodiment.modify_env_cfg` sets ``env_cfg.scene.replicate_physics = True`` + to match Dexsuite. + """ + + robot: ArticulationCfg = KUKA_ALLEGRO_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + index_link_3_object_s: ContactSensorCfg = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link/index_link_3", + filter_prim_paths_expr=["{ENV_REGEX_NS}/Object"], + ) + middle_link_3_object_s: ContactSensorCfg = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link/middle_link_3", + filter_prim_paths_expr=["{ENV_REGEX_NS}/Object"], + ) + ring_link_3_object_s: ContactSensorCfg = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link/ring_link_3", + filter_prim_paths_expr=["{ENV_REGEX_NS}/Object"], + ) + thumb_link_3_object_s: ContactSensorCfg = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link/thumb_link_3", + filter_prim_paths_expr=["{ENV_REGEX_NS}/Object"], + ) + + +@configclass +class KukaAllegroDexsuiteCameraSceneCfg: + """Tiled base camera and optional wrist camera (Dexsuite layouts).""" + + base_camera: TiledCameraCfg = BaseTiledCameraCfg() + wrist_camera: TiledCameraCfg | None = None + + +@register_asset +class KukaAllegroDexsuiteEmbodiment(EmbodimentBase): + """Kuka Allegro for Dexsuite tasks: joint-space actions, contact-rich proprioception, optional RGB cameras.""" + + name = "kuka_allegro_dexsuite" + default_arm_mode = ArmMode.SINGLE_ARM + + def __init__( + self, + physics_preset: Literal["physx", "newton"] = "physx", + enable_cameras: bool = False, + duo_cameras: bool = False, + initial_pose: Pose | None = None, + concatenate_observation_terms: bool = True, + arm_mode: ArmMode | None = None, + ): + # Default ``True``: Dexsuite ``PolicyCfg`` / ``ProprioObsCfg`` use ``concatenate_terms=True`` in Isaac Lab; + # RSL-RL MLPs require flat 1D-per-env vectors. ``False`` yields invalid shapes (e.g. ``torch.Size([1])``). + super().__init__(enable_cameras, initial_pose, concatenate_observation_terms, arm_mode) + self.physics_preset = physics_preset + self.duo_cameras = duo_cameras + + self.scene_config = DexsuiteKukaAllegroEmbodimentSceneCfg() + self.action_config = kuka_dexsuite_cfg.KukaAllegroRelJointPosActionCfg() + self.observation_config = self._make_observation_cfg() + self._apply_concatenate_observation_terms(self.observation_config) + + # ``PresetCfg`` subclasses (e.g. ``KukaAllegroEventCfg``) store presets as *instance* fields, not + # class attributes — ``KukaAllegroEventCfg.newton`` raises ``AttributeError``. + _event_presets = kuka_dexsuite_cfg.KukaAllegroEventCfg() + if physics_preset == "newton": + self.event_config = getattr(_event_presets, "newton", None) + if self.event_config is None: + from isaaclab_tasks.manager_based.manipulation.dexsuite import dexsuite_env_cfg as _dexsuite + + self.event_config = _dexsuite.EventCfg() + else: + self.event_config = _event_presets.default + + self.reward_config = None + self.command_config = None + self.termination_cfg = None + self.curriculum_config = None + self.mimic_env = None + + if enable_cameras: + self.camera_config = KukaAllegroDexsuiteCameraSceneCfg() + if duo_cameras: + self.camera_config.wrist_camera = WristTiledCameraCfg() + else: + self.camera_config = None + + def _make_observation_cfg(self) -> Any: + if self.enable_cameras: + if self.duo_cameras: + return ArenaDexsuiteKukaDuoCameraObservationsCfg() + return ArenaDexsuiteKukaSingleCameraObservationsCfg() + return ArenaDexsuiteKukaStateObservationCfg() + + def _apply_concatenate_observation_terms(self, obs_cfg: Any) -> None: + for field_name in ("policy", "proprio", "perception", "base_image", "wrist_image"): + if hasattr(obs_cfg, field_name): + grp = getattr(obs_cfg, field_name) + if hasattr(grp, "concatenate_terms"): + grp.concatenate_terms = self.concatenate_observation_terms + + def modify_env_cfg(self, env_cfg: IsaacLabArenaManagerBasedRLEnvCfg) -> IsaacLabArenaManagerBasedRLEnvCfg: + physics_cfg = kuka_dexsuite_cfg.KukaAllegroPhysicsCfg() + env_cfg.sim.physics = getattr(physics_cfg, self.physics_preset, physics_cfg.default) + env_cfg.sim.dt = 1 / 120 + env_cfg.decimation = 2 + # Dexsuite uses replicated physics; Arena's builder defaults InteractiveSceneCfg to False. + if hasattr(env_cfg, "scene") and env_cfg.scene is not None: + env_cfg.scene.replicate_physics = True + return env_cfg + + def get_ee_frame_name(self, arm_mode: ArmMode) -> str: + return "palm_link" + + def get_command_body_name(self) -> str: + # Dexsuite object_pose command uses asset frame on the robot, not a named body. + return "" diff --git a/isaaclab_arena/evaluation/policy_runner.py b/isaaclab_arena/evaluation/policy_runner.py index 36176feb2..1e94556ea 100644 --- a/isaaclab_arena/evaluation/policy_runner.py +++ b/isaaclab_arena/evaluation/policy_runner.py @@ -123,8 +123,9 @@ def rollout_policy( else: - # Only compute metrics if env has a non-None metrics list (e.g. NoTask leaves metrics as None). - if hasattr(env.cfg, "metrics") and env.cfg.metrics is not None: + # Only compute metrics when at least one metric is registered. An empty list still configures a + # dataset path but policy_runner may never create the HDF5 (no recorders / no completed episodes). + if getattr(env.cfg, "metrics", None): # NOTE(xinjieyao, 2025-10-07): lazy import to prevent app stalling caused by omni.kit from isaaclab_arena.metrics.metrics import compute_metrics metrics = compute_metrics(env) diff --git a/isaaclab_arena/metrics/metrics.py b/isaaclab_arena/metrics/metrics.py index ad927a4c3..0719eddb7 100644 --- a/isaaclab_arena/metrics/metrics.py +++ b/isaaclab_arena/metrics/metrics.py @@ -48,6 +48,8 @@ def get_recorded_metric_data(dataset_path: pathlib.Path, recorder_term_name: str Returns: A list of recorded metric data for each simulated episode. """ + if not dataset_path.is_file(): + return [] recorded_metric_data_per_demo: list[np.ndarray] = [] with h5py.File(dataset_path, "r") as f: demos = f["data"] @@ -65,6 +67,8 @@ def get_num_episodes(dataset_path: pathlib.Path) -> int: Returns: The number of episodes in the dataset. """ + if not dataset_path.is_file(): + return 0 with h5py.File(dataset_path, "r") as f: return len(f["data"]) diff --git a/isaaclab_arena/tasks/dexsuite_kuka_allegro_lift_task.py b/isaaclab_arena/tasks/dexsuite_kuka_allegro_lift_task.py new file mode 100644 index 000000000..169ff6abe --- /dev/null +++ b/isaaclab_arena/tasks/dexsuite_kuka_allegro_lift_task.py @@ -0,0 +1,182 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Dexsuite Kuka Allegro **lift** MDP: commands, rewards, success signal, terminations, curriculum. + +Extends :class:`~isaaclab_arena.tasks.lift_object_task.LiftObjectTask` so the task holds the same +``lift_object`` / ``background_scene`` :class:`~isaaclab_arena.assets.asset.Asset` references as +other lift examples (viewer look-at, future IL/mimic hooks). The **MDP** still mirrors Isaac Lab +:class:`~isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_env_cfg.DexsuiteKukaAllegroLiftEnvCfg` +(Dexsuite commands/rewards/terminations/curriculum), not Arena's generic +:class:`~isaaclab_arena.tasks.lift_object_task.LiftObjectRewardCfg` stack. + +After construction, parent's IL-style :attr:`~isaaclab_arena.tasks.lift_object_task.LiftObjectTask.termination_cfg` +is replaced with Dexsuite terminations **plus** a ``success`` termination tied to +:class:`~isaaclab_tasks.manager_based.manipulation.dexsuite.mdp.rewards.success_reward` (``succeeded``), +so :class:`~isaaclab_arena.metrics.success_rate.SuccessRateMetric` works like other Arena lift tasks. +That success termination **ends the episode when success is first achieved** (vanilla Dexsuite Isaac +envs typically keep rolling until time-out). +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import torch +from isaaclab.envs import ManagerBasedRLEnv +from isaaclab.envs.common import ViewerCfg +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.dexsuite import dexsuite_env_cfg as dexsuite +from isaaclab_tasks.manager_based.manipulation.dexsuite import mdp as dexsuite_mdp +from isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_env_cfg import ( + FINGER_SENSORS, + THUMB_SENSOR, +) + +from isaaclab_arena.assets.asset import Asset +from isaaclab_arena.embodiments.common.arm_mode import ArmMode +from isaaclab_arena.environments.isaaclab_arena_manager_based_env import IsaacLabArenaManagerBasedRLEnvCfg +from isaaclab_arena.tasks.lift_object_task import LiftObjectTask + + +def success_reward_succeeded(env: ManagerBasedRLEnv) -> torch.Tensor: + """Per-env termination from Dexsuite ``success`` reward term's sticky ``succeeded`` flag. + + Stock Isaac Dexsuite lift does not use this as a termination. Arena adds it so + :class:`~isaaclab_arena.metrics.success_rate.SuccessRateMetric` can use the standard ``success`` + termination channel. With ``time_out=False``, the episode **resets when success is first achieved** + (vanilla Dexsuite may continue until time-out). + + Args: + env: Manager-based RL environment (reward manager must expose a ``success`` term). + + Returns: + Boolean tensor of shape ``(num_envs,)``. + """ + if "success" not in env.reward_manager.active_terms: + return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + func = env.reward_manager.get_term_cfg("success").func + if not hasattr(func, "succeeded"): + return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + return func.succeeded.clone() + + +@configclass +class ArenaDexsuiteKukaReorientRewardCfg(dexsuite.RewardsCfg): + """Same as ``KukaAllegroReorientRewardCfg`` but merge-safe: no ``super()`` in ``__post_init__``.""" + + good_finger_contact = RewTerm( + func=dexsuite_mdp.contacts, + weight=0.5, + params={"threshold": 0.1, "thumb_name": THUMB_SENSOR, "finger_names": FINGER_SENSORS}, + ) + + contact_count = RewTerm( + func=dexsuite_mdp.contact_count, + weight=1.0, + params={ + "threshold": 0.01, + "sensor_names": FINGER_SENSORS + [THUMB_SENSOR], + }, + ) + + def __post_init__(self) -> None: + dexsuite.RewardsCfg.__post_init__(self) + self.fingers_to_object.params["asset_cfg"] = SceneEntityCfg("robot", body_names=["palm_link", ".*_tip"]) + self.fingers_to_object.params["thumb_name"] = THUMB_SENSOR + self.fingers_to_object.params["finger_names"] = FINGER_SENSORS + self.position_tracking.params["thumb_name"] = THUMB_SENSOR + self.position_tracking.params["finger_names"] = FINGER_SENSORS + if self.orientation_tracking: + self.orientation_tracking.params["thumb_name"] = THUMB_SENSOR + self.orientation_tracking.params["finger_names"] = FINGER_SENSORS + self.success.params["thumb_name"] = THUMB_SENSOR + self.success.params["finger_names"] = FINGER_SENSORS + + +def _build_dexsuite_kuka_lift_cfgs() -> tuple[Any, Any, Any, Any]: + from isaaclab_tasks.manager_based.manipulation.dexsuite.adr_curriculum import CurriculumCfg + from isaaclab_tasks.manager_based.manipulation.dexsuite.dexsuite_env_cfg import CommandsCfg + + commands = CommandsCfg() + rewards = ArenaDexsuiteKukaReorientRewardCfg() + terminations = ArenaDexsuiteKukaLiftTerminationsCfg() + curriculum = CurriculumCfg() + + # ``DexsuiteLiftEnvCfg.__post_init__`` + rewards.orientation_tracking = None + commands.object_pose.position_only = True + rewards.success.params["rot_std"] = None + + return commands, rewards, terminations, curriculum + + +@configclass +class ArenaDexsuiteKukaLiftTerminationsCfg(dexsuite.TerminationsCfg): + """Dexsuite terminations + ``success`` for :class:`~isaaclab_arena.metrics.success_rate.SuccessRateMetric`.""" + + success = DoneTerm(func=success_reward_succeeded, time_out=False) + + +class DexsuiteKukaAllegroLiftTask(LiftObjectTask): + """Lift task matching Isaac Lab ``Isaac-Dexsuite-Kuka-Allegro-Lift-v0`` (state observations). + + Reuses :class:`LiftObjectTask` for ``lift_object`` / ``background_scene`` and the default + look-at-object viewer. MDP pieces come from Dexsuite (not :class:`LiftObjectTaskRL`). + """ + + def __init__(self, lift_object: Asset, background_scene: Asset) -> None: + # Goal fields from LiftObjectTask are unused for Dexsuite terminations but keep parent API consistent. + super().__init__( + lift_object=lift_object, + background_scene=background_scene, + episode_length_s=6.0, + goal_position_delta_xyz=(0.0, 0.0, 0.3), + goal_position_tolerance=0.05, + ) + self.task_description = "Dexsuite Kuka Allegro lift (Arena, Newton-ready scene)." + + commands, rewards, terminations, curriculum = _build_dexsuite_kuka_lift_cfgs() + self._commands_cfg = commands + self._rewards_cfg = rewards + self._terminations_cfg = terminations + self._curriculum_cfg = curriculum + # Replace parent's IL terminations with Dexsuite MDP terminations. + self.termination_cfg = self._terminations_cfg + + def get_commands_cfg(self) -> Any: + return self._commands_cfg + + def get_rewards_cfg(self) -> Any: + return self._rewards_cfg + + def get_curriculum_cfg(self) -> Any: + return self._curriculum_cfg + + def get_viewer_cfg(self) -> ViewerCfg: + # Reuse LiftObjectTask's look-at-object framing (same offset as generic lift examples). + from isaaclab_arena.utils.cameras import get_viewer_cfg_look_at_object + + return get_viewer_cfg_look_at_object( + lookat_object=self.lift_object, + offset=np.array([-1.5, -1.5, 1.5]), + ) + + def get_mimic_env_cfg(self, arm_mode: ArmMode) -> Any: + raise NotImplementedError("Dexsuite Kuka Allegro lift mimic is not configured in Arena yet.") + + def modify_env_cfg(self, env_cfg: IsaacLabArenaManagerBasedRLEnvCfg) -> IsaacLabArenaManagerBasedRLEnvCfg: + # ``DexsuiteReorientEnvCfg.__post_init__`` timing / horizon (sim.dt and physics come from embodiment). + env_cfg.decimation = 2 + env_cfg.commands.object_pose.resampling_time_range = (2.0, 3.0) + env_cfg.commands.object_pose.position_only = True + env_cfg.episode_length_s = 6.0 + env_cfg.is_finite_horizon = False + return env_cfg diff --git a/isaaclab_arena/tests/test_configclass.py b/isaaclab_arena/tests/test_configclass.py index a95dc2b23..f9a29e8bd 100644 --- a/isaaclab_arena/tests/test_configclass.py +++ b/isaaclab_arena/tests/test_configclass.py @@ -96,8 +96,29 @@ def __post_init__(self): assert CombinedCfg().c == 4 +def test_combine_configclass_instances_preserves_default_factory_nested_config(): + """Merged classes must not assign raw ``default_factory`` callables as field defaults (see ``get_field_info``).""" + from isaaclab.utils import configclass + + from isaaclab_arena.utils.configclass import combine_configclass_instances + + @configclass + class Inner: + x: int = 0 + + @configclass + class Outer: + inner: Inner = Inner() + + merged = combine_configclass_instances("MergedOuter", Outer()) + assert not callable(merged.inner) + assert type(merged.inner).__name__ == "Inner" + assert merged.inner.x == 0 + + if __name__ == "__main__": test_combine_configclasses_with_multiple_inheritance() test_combine_configclasses_with_inheritance() test_combine_configclasses_with_post_init() + test_combine_configclass_instances_preserves_default_factory_nested_config() diff --git a/isaaclab_arena/tests/test_dexsuite_kuka_lift_example.py b/isaaclab_arena/tests/test_dexsuite_kuka_lift_example.py new file mode 100644 index 000000000..cff24686e --- /dev/null +++ b/isaaclab_arena/tests/test_dexsuite_kuka_lift_example.py @@ -0,0 +1,53 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Dexsuite Kuka Allegro lift Arena example (no simulation).""" + +import pytest + +pytest.importorskip("isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_env_cfg") + + +def test_kuka_allegro_dexsuite_lift_example_in_cli_registry() -> None: + from isaaclab_arena_environments.cli import ExampleEnvironments + + assert "kuka_allegro_dexsuite_lift" in ExampleEnvironments + assert ExampleEnvironments["kuka_allegro_dexsuite_lift"].name == "kuka_allegro_dexsuite_lift" + + +def test_dexsuite_procedural_assets_registered() -> None: + from isaaclab_arena.assets.asset_registry import AssetRegistry + + reg = AssetRegistry() + assert reg.is_registered("dexsuite_manip_table") + assert reg.is_registered("dexsuite_lift_object") + + +def test_dexsuite_kuka_lift_task_matches_lift_mdp_flags() -> None: + from isaaclab_arena.assets.asset_registry import AssetRegistry + from isaaclab_arena.metrics.success_rate import SuccessRateMetric + from isaaclab_arena.tasks.dexsuite_kuka_allegro_lift_task import ( + ArenaDexsuiteKukaLiftTerminationsCfg, + DexsuiteKukaAllegroLiftTask, + ) + from isaaclab_arena.tasks.lift_object_task import LiftObjectTask + + reg = AssetRegistry() + lift = reg.get_asset_by_name("dexsuite_lift_object")() + table = reg.get_asset_by_name("dexsuite_manip_table")() + task = DexsuiteKukaAllegroLiftTask(lift_object=lift, background_scene=table) + assert isinstance(task, LiftObjectTask) + assert task.lift_object is lift + assert task.get_scene_cfg() is None + assert task._rewards_cfg.orientation_tracking is None + assert task._commands_cfg.object_pose.position_only is True + assert task._rewards_cfg.success.params.get("rot_std") is None + metrics = task.get_metrics() + assert len(metrics) == 1 + assert isinstance(metrics[0], SuccessRateMetric) + assert metrics[0].recorder_term_name == "success" + term = task._terminations_cfg # noqa: SLF001 + assert isinstance(term, ArenaDexsuiteKukaLiftTerminationsCfg) + assert hasattr(term, "success") diff --git a/isaaclab_arena/tests/test_kuka_allegro_embodiment.py b/isaaclab_arena/tests/test_kuka_allegro_embodiment.py new file mode 100644 index 000000000..2fdfd3fe5 --- /dev/null +++ b/isaaclab_arena/tests/test_kuka_allegro_embodiment.py @@ -0,0 +1,105 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for :class:`KukaAllegroDexsuiteEmbodiment` (no simulation).""" + +import pytest + +pytest.importorskip("isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.dexsuite_kuka_allegro_env_cfg") + +from isaaclab_arena.assets.asset_registry import AssetRegistry +from isaaclab_arena.embodiments.kuka_allegro.kuka_allegro import ( + DexsuiteKukaAllegroEmbodimentSceneCfg, + KukaAllegroDexsuiteCameraSceneCfg, + KukaAllegroDexsuiteEmbodiment, +) + + +def test_kuka_allegro_dexsuite_registered_in_asset_registry() -> None: + reg = AssetRegistry() + assert reg.is_registered("kuka_allegro_dexsuite") + cls = reg.get_asset_by_name("kuka_allegro_dexsuite") + assert cls is KukaAllegroDexsuiteEmbodiment + + +def test_scene_cfg_has_robot_and_fingertip_contact_sensors() -> None: + cfg = DexsuiteKukaAllegroEmbodimentSceneCfg() + assert cfg.robot is not None + for link in ("index_link_3", "middle_link_3", "ring_link_3", "thumb_link_3"): + attr = f"{link}_object_s" + assert hasattr(cfg, attr), f"missing sensor {attr}" + sensor = getattr(cfg, attr) + assert "{ENV_REGEX_NS}/Object" in sensor.filter_prim_paths_expr + + +def test_embodiment_default_action_and_observation() -> None: + emb = KukaAllegroDexsuiteEmbodiment() + assert emb.physics_preset == "physx" + assert emb.enable_cameras is False + assert emb.duo_cameras is False + assert emb.concatenate_observation_terms is True + assert emb.observation_config.policy.concatenate_terms is True + assert emb.action_config.action.scale == 0.1 + assert emb.action_config.action.joint_names == [".*"] + assert emb.action_config.action.asset_name == "robot" + # State observation extends dexsuite ObservationsCfg with fingertip contact term + assert hasattr(emb.observation_config, "proprio") + assert hasattr(emb.observation_config.proprio, "contact") + + +def test_embodiment_physx_vs_newton_events() -> None: + physx_emb = KukaAllegroDexsuiteEmbodiment(physics_preset="physx") + newton_emb = KukaAllegroDexsuiteEmbodiment(physics_preset="newton") + assert physx_emb.event_config is not newton_emb.event_config + + +def test_embodiment_enable_cameras_sets_camera_scene_and_observations() -> None: + single = KukaAllegroDexsuiteEmbodiment(enable_cameras=True, duo_cameras=False) + assert single.camera_config is not None + assert isinstance(single.camera_config, KukaAllegroDexsuiteCameraSceneCfg) + assert single.camera_config.base_camera is not None + assert single.camera_config.wrist_camera is None + assert hasattr(single.observation_config, "base_image") + + duo = KukaAllegroDexsuiteEmbodiment(enable_cameras=True, duo_cameras=True) + assert duo.camera_config.wrist_camera is not None + assert hasattr(duo.observation_config, "wrist_image") + + +def test_get_scene_cfg_includes_cameras_when_enabled() -> None: + emb = KukaAllegroDexsuiteEmbodiment(enable_cameras=True, duo_cameras=True) + scene_cfg = emb.get_scene_cfg() + assert hasattr(scene_cfg, "base_camera") + assert scene_cfg.base_camera is not None + assert hasattr(scene_cfg, "wrist_camera") + assert scene_cfg.wrist_camera is not None + + +def test_modify_env_cfg_sets_dexsuite_timestep_and_physics() -> None: + """Duck-typed env cfg: ``sim``, ``decimation``, and optional ``scene.replicate_physics``.""" + from isaaclab_physx.physics import PhysxCfg + + class _Sim: + def __init__(self) -> None: + self.dt = 0.01 + self.physics = None + + class _Scene: + def __init__(self) -> None: + self.replicate_physics = False + + class _EnvCfg: + def __init__(self) -> None: + self.sim = _Sim() + self.decimation = 4 + self.scene = _Scene() + + emb = KukaAllegroDexsuiteEmbodiment(physics_preset="physx") + cfg = _EnvCfg() + out = emb.modify_env_cfg(cfg) # type: ignore[arg-type] + assert out.decimation == 2 + assert out.sim.dt == pytest.approx(1 / 120) + assert isinstance(out.sim.physics, PhysxCfg) + assert out.scene.replicate_physics is True diff --git a/isaaclab_arena/utils/configclass.py b/isaaclab_arena/utils/configclass.py index 28c69835d..9748c1349 100644 --- a/isaaclab_arena/utils/configclass.py +++ b/isaaclab_arena/utils/configclass.py @@ -127,7 +127,10 @@ def get_field_info(field: dataclasses.Field) -> tuple[str, type, Any]: if field.default is not dataclasses.MISSING: field_info += (field.default,) elif field.default_factory is not dataclasses.MISSING: - field_info += (field.default_factory,) + # Isaac Lab ``@configclass`` turns mutable defaults into ``field(default_factory=...)``. + # Passing the raw callable to :func:`make_configclass` sets ``Class.field_name = `` in the + # generated class body, so merged instances get a function instead of a config instance (e.g. ``proprio``). + field_info += (dataclasses.field(default_factory=field.default_factory),) return field_info diff --git a/isaaclab_arena_environments/cli.py b/isaaclab_arena_environments/cli.py index 6df4797a3..22a5c06d2 100644 --- a/isaaclab_arena_environments/cli.py +++ b/isaaclab_arena_environments/cli.py @@ -22,6 +22,7 @@ ) from isaaclab_arena_environments.gr1_turn_stand_mixer_knob_environment import Gr1TurnStandMixerKnobEnvironment from isaaclab_arena_environments.kitchen_pick_and_place_environment import KitchenPickAndPlaceEnvironment +from isaaclab_arena_environments.kuka_allegro_dexsuite_lift_environment import KukaAllegroDexsuiteLiftEnvironment from isaaclab_arena_environments.lift_object_environment import LiftObjectEnvironment from isaaclab_arena_environments.press_button_environment import PressButtonEnvironment from isaaclab_arena_environments.tabletop_place_upright_environment import TableTopPlaceUprightEnvironment @@ -44,6 +45,7 @@ GalileoG1LocomanipPickAndPlaceEnvironment.name: GalileoG1LocomanipPickAndPlaceEnvironment, PressButtonEnvironment.name: PressButtonEnvironment, CubeGoalPoseEnvironment.name: CubeGoalPoseEnvironment, + KukaAllegroDexsuiteLiftEnvironment.name: KukaAllegroDexsuiteLiftEnvironment, LiftObjectEnvironment.name: LiftObjectEnvironment, TableTopPlaceUprightEnvironment.name: TableTopPlaceUprightEnvironment, Gr1TurnStandMixerKnobEnvironment.name: Gr1TurnStandMixerKnobEnvironment, diff --git a/isaaclab_arena_environments/kuka_allegro_dexsuite_lift_environment.py b/isaaclab_arena_environments/kuka_allegro_dexsuite_lift_environment.py new file mode 100644 index 000000000..d75376ca2 --- /dev/null +++ b/isaaclab_arena_environments/kuka_allegro_dexsuite_lift_environment.py @@ -0,0 +1,91 @@ +# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Arena example: Dexsuite Kuka Allegro **lift** with **Newton** physics. + +Matches the MDP of Isaac Lab ``Isaac-Dexsuite-Kuka-Allegro-Lift-v0`` (state observations, joint actions): +procedural lift cuboid + Dexsuite-style kinematic table, :class:`~isaaclab_arena.embodiments.kuka_allegro.kuka_allegro.KukaAllegroDexsuiteEmbodiment` with ``physics_preset="newton"``. + +**Play a checkpoint trained in Isaac Lab** (same PPO runner / obs groups):: + + ./isaaclab.sh -p isaaclab_arena/scripts/reinforcement_learning/play.py \\ + kuka_allegro_dexsuite_lift \\ + --num_envs 1 --env_spacing 3 \\ + --checkpoint /path/to/logs/rsl_rl/dexsuite_kuka_allegro//model_.pt + +Match Dexsuite training layout: use ``--env_spacing 3`` (Arena CLI defaults to a larger spacing). + +The task subclasses :class:`~isaaclab_arena.tasks.lift_object_task.LiftObjectTask` and uses the same +look-at-lift-object viewer helper as other Arena lift examples (Isaac Lab's stock task uses a fixed +viewer pose instead). + +Use ``--enable_cameras`` (and optionally ``--duo_cameras``) if the policy was trained with vision presets. + +**Note:** Checkpoints from the stock Isaac Lab task are usually trained with **PhysX**; this example uses **Newton**, +so replay quality may differ unless you train or fine-tune with Newton as well. +""" + +from __future__ import annotations + +import argparse + +from isaaclab_arena_environments.example_environment_base import ExampleEnvironmentBase + +# NOTE: Same pattern as other example envs — avoid heavy imports before AppLauncher. + + +class KukaAllegroDexsuiteLiftEnvironment(ExampleEnvironmentBase): + """Dexsuite Kuka Allegro lift task; Newton backend; RSL-RL config ``DexsuiteKukaAllegroPPORunnerCfg``.""" + + name: str = "kuka_allegro_dexsuite_lift" + + def get_env(self, args_cli: argparse.Namespace): + import isaaclab_tasks.manager_based.manipulation.dexsuite # noqa: F401 + + from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment + from isaaclab_arena.reinforcement_learning.frameworks import RLFramework + from isaaclab_arena.scene.scene import Scene + from isaaclab_arena.tasks.dexsuite_kuka_allegro_lift_task import DexsuiteKukaAllegroLiftTask + + dexsuite_table = self.asset_registry.get_asset_by_name("dexsuite_manip_table")() + manip_object = self.asset_registry.get_asset_by_name("dexsuite_lift_object")() + ground_plane = self.asset_registry.get_asset_by_name("ground_plane")() + light = self.asset_registry.get_asset_by_name("light")() + + enable_cameras = getattr(args_cli, "enable_cameras", False) + duo_cameras = getattr(args_cli, "duo_cameras", False) + + embodiment = self.asset_registry.get_asset_by_name("kuka_allegro_dexsuite")( + physics_preset="newton", + enable_cameras=enable_cameras, + duo_cameras=duo_cameras, + ) + + scene = Scene(assets=[dexsuite_table, manip_object, ground_plane, light]) + task = DexsuiteKukaAllegroLiftTask(lift_object=manip_object, background_scene=dexsuite_table) + + dexsuite_rl_cfg_entry = ( + "isaaclab_tasks.manager_based.manipulation.dexsuite.config.kuka_allegro.agents." + "rsl_rl_ppo_cfg:DexsuiteKukaAllegroPPORunnerCfg" + ) + + return IsaacLabArenaEnvironment( + name=self.name, + embodiment=embodiment, + scene=scene, + task=task, + teleop_device=None, + rl_framework=RLFramework.RSL_RL, + rl_policy_cfg=dexsuite_rl_cfg_entry, + ) + + @staticmethod + def add_cli_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--duo_cameras", + action="store_true", + default=False, + help="Use base+wrist cameras (Dexsuite duo layout). Match the checkpoint training preset.", + )