From b62f2b6a34dd6741021b6bb9ac1ca1950ef604fa Mon Sep 17 00:00:00 2001 From: Peter Du Date: Mon, 19 Oct 2026 17:55:49 +0000 Subject: [PATCH] Add the PhysX Franka rope SoftMimicGen workflow --- autodata_examples/envs/__init__.py | 65 ++++++ .../envs/isaac_lab_arena/__init__.py | 7 + .../isaac_lab_arena/franka_rope/__init__.py | 14 ++ .../franka_rope/assets/Rope.usd | Bin 0 -> 154412 bytes .../isaac_lab_arena/franka_rope/embodiment.py | 186 +++++++++++++++ .../franka_rope/environment.py | 107 +++++++++ .../franka_rope/mdp/__init__.py | 19 ++ .../isaac_lab_arena/franka_rope/mdp/events.py | 106 +++++++++ .../franka_rope/mdp/observations.py | 93 ++++++++ .../franka_rope/mdp/terminations.py | 78 +++++++ .../isaac_lab_arena/franka_rope/rope_asset.py | 39 ++++ .../envs/isaac_lab_arena/franka_rope/task.py | 152 +++++++++++++ .../envs/isaac_lab_arena/registration.py | 131 +++++++++++ .../tasks/franka_rope_softmimicgen.yaml | 59 +++++ .../env/isaaclab_env_interface.py | 4 +- autodata_interfaces/env/recorders.py | 29 +++ autodata_tests/e2e/test_annotation.py | 49 +++- autodata_tests/e2e/test_recording.py | 33 ++- autodata_tests/e2e/test_rope_environment.py | 181 +++++++++++++++ .../e2e/test_softmimicgen_data_generation.py | 85 +++++++ .../interfaces/env/test_franka_rope_arena.py | 213 ++++++++++++++++++ ...ated_dataset_franka_rope_softmimicgen.hdf5 | 3 + autodata_tests/utils/test_ci_cache.py | 97 ++++++++ docker/run_docker.sh | 9 +- scripts/annotate_demos.py | 26 ++- scripts/ci/run_tests.sh | 17 +- scripts/generate_dataset.py | 23 +- 27 files changed, 1795 insertions(+), 30 deletions(-) create mode 100644 autodata_examples/envs/__init__.py create mode 100644 autodata_examples/envs/isaac_lab_arena/__init__.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/assets/Rope.usd create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py create mode 100644 autodata_examples/envs/isaac_lab_arena/franka_rope/task.py create mode 100644 autodata_examples/envs/isaac_lab_arena/registration.py create mode 100644 autodata_examples/tasks/franka_rope_softmimicgen.yaml create mode 100644 autodata_interfaces/env/recorders.py create mode 100644 autodata_tests/e2e/test_rope_environment.py create mode 100644 autodata_tests/e2e/test_softmimicgen_data_generation.py create mode 100644 autodata_tests/interfaces/env/test_franka_rope_arena.py create mode 100644 autodata_tests/test_data/annotated_dataset_franka_rope_softmimicgen.hdf5 create mode 100644 autodata_tests/utils/test_ci_cache.py diff --git a/autodata_examples/envs/__init__.py b/autodata_examples/envs/__init__.py new file mode 100644 index 0000000..d829881 --- /dev/null +++ b/autodata_examples/envs/__init__.py @@ -0,0 +1,65 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Example simulation environments used by AutoData workflows.""" + +import argparse +from typing import Any + + +def register_environment_for_run( + *, + env_name: str, + enable_cameras: bool, + num_envs: int, + device: str, + seed: int, + presets: str | None = None, +) -> dict[str, dict[str, Any]]: + """Register the AutoData-owned environment selected for the current run. + + Args: + env_name: Gym ID of the environment selected for this run. + enable_cameras: Whether Arena environments should include their camera sensors. + num_envs: Number of parallel environments in this run. + device: Simulation device for this run. + seed: Environment seed for this run. + presets: Optional Arena physics backend preset; None preserves the environment default. + + Returns: + Gym constructor kwargs keyed by registered environment ID. + """ + + from .isaac_lab_arena import build_and_register_arena_environment, is_arena_environment + + if not is_arena_environment(env_name): + return {} + return build_and_register_arena_environment( + enable_cameras=enable_cameras, + num_envs=num_envs, + device=device, + seed=seed, + presets=presets, + ) + + +def register_environments() -> list[str]: + """External callback used by unmodified Isaac Lab scripts. + + Isaac Lab invokes this function without arguments after starting Isaac Sim. The + selected ``--task`` determines whether the callback needs to compile an AutoData + Arena environment or only import AutoData's regular Isaac Lab registrations. + + Returns: + Command-line arguments not consumed by Arena environment registration. + """ + + from .isaac_lab_arena import is_arena_environment, register_environment_from_cli + + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--task", type=str) + args, _ = parser.parse_known_args() + env_name = args.task.split(":")[-1] if args.task is not None else None + if env_name is not None and not is_arena_environment(env_name): + return [] + return register_environment_from_cli() diff --git a/autodata_examples/envs/isaac_lab_arena/__init__.py b/autodata_examples/envs/isaac_lab_arena/__init__.py new file mode 100644 index 0000000..d33c1b6 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from .registration import build_and_register_arena_environment, is_arena_environment, register_environment_from_cli + +__all__ = ["build_and_register_arena_environment", "is_arena_environment", "register_environment_from_cli"] diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py new file mode 100644 index 0000000..2574837 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +from . import embodiment as _embodiment # noqa: F401 +from .environment import FRANKA_ROPE_ARENA_ENV_ID, FrankaRopeArenaEnvironment, FrankaRopeArenaEnvironmentCfg + +__all__ = [ + "FRANKA_ROPE_ARENA_ENV_ID", + "FrankaRopeArenaEnvironment", + "FrankaRopeArenaEnvironmentCfg", +] diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/assets/Rope.usd b/autodata_examples/envs/isaac_lab_arena/franka_rope/assets/Rope.usd new file mode 100644 index 0000000000000000000000000000000000000000..fac1654f92ce3118d7ce000cdbc3bb5ebf38675e GIT binary patch literal 154412 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 literal 0 HcmV?d00001 diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py new file mode 100644 index 0000000..e75c49a --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py @@ -0,0 +1,186 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +from collections.abc import Callable + +import isaaclab.envs.mdp as mdp_isaac_lab +import isaaclab.sim as sim_utils +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.sensors import CameraCfg +from isaaclab.utils.configclass import configclass +from isaaclab_arena.assets.register import register_asset, register_retargeter +from isaaclab_arena.assets.retargeter_library import RetargetterBase +from isaaclab_arena.embodiments.common.arm_mode import ArmMode +from isaaclab_arena.embodiments.franka.franka import FrankaIKEmbodiment +from isaaclab_arena.utils.cameras import ArenaCameraCfg +from isaaclab_arena.utils.pose import Pose +from isaaclab_physx.renderers import IsaacRtxRendererCfg, IsaacRtxRendererGlobalSettingsCfg + +from . import mdp + +_FRANKA_ROPE_INIT_JOINT_POS: dict[str, float] = { + "panda_joint1": 0.0444, + "panda_joint2": -0.1894, + "panda_joint3": -0.1107, + "panda_joint4": -2.5148, + "panda_joint5": 0.0044, + "panda_joint6": 2.3775, + "panda_joint7": 2.43, + "panda_finger_joint.*": 0.04, +} + +_FRANKA_ROPE_INIT_JOINT_POSE = [ + 0.0444, + -0.1894, + -0.1107, + -2.5148, + 0.0044, + 2.3775, + 2.43, + 0.04, + 0.04, +] + + +@configclass +class FrankaRopeCameraCfg(ArenaCameraCfg): + """Wrist and fixed-view cameras matching the Isaac Lab rope environment.""" + + robot0_eye_in_hand_image = CameraCfg( + prim_path="{ENV_REGEX_NS}/Robot/panda_hand/robot0_eye_in_hand_image", + update_period=0.0, + height=128, + width=128, + data_types=["rgb"], + renderer_cfg=IsaacRtxRendererCfg(global_settings=IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS")), + spawn=sim_utils.PinholeCameraCfg( + focal_length=24.0, + focus_distance=400.0, + horizontal_aperture=20.955, + clipping_range=(0.1, 2.0), + ), + offset=CameraCfg.OffsetCfg( + pos=(0.13, 0.0, -0.15), + rot=(0.03701, 0.03701, -0.70614, -0.70614), + convention="ros", + ), + ) + + agentview_image = CameraCfg( + prim_path="{ENV_REGEX_NS}/agentview_image", + update_period=0.0, + height=128, + width=128, + data_types=["rgb"], + renderer_cfg=IsaacRtxRendererCfg(global_settings=IsaacRtxRendererGlobalSettingsCfg(antialiasing_mode="DLSS")), + spawn=sim_utils.PinholeCameraCfg( + focal_length=14.0, + focus_distance=400.0, + horizontal_aperture=20.955, + clipping_range=(0.1, 2.0), + ), + offset=CameraCfg.OffsetCfg( + pos=(0.5, 0.5, 0.4), + rot=(0.0, 0.38268, 0.92388, 0.0), + convention="opengl", + ), + ) + + +@configclass +class FrankaRopeObservationsCfg: + """Arena-standard Franka observations plus deformable rope nodal state.""" + + @configclass + class PolicyCfg(ObsGroup): + """Policy observations recorded into demonstration datasets.""" + + actions = ObsTerm(func=mdp_isaac_lab.last_action) + joint_pos = ObsTerm(func=mdp_isaac_lab.joint_pos_rel) + joint_vel = ObsTerm(func=mdp_isaac_lab.joint_vel_rel) + eef_pos = ObsTerm(func=mdp.ee_frame_pos) + eef_quat = ObsTerm(func=mdp.ee_frame_quat) + gripper_pos = ObsTerm(func=mdp.gripper_pos) + object_nodal_pos = ObsTerm(func=mdp.object_nodal_pos) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = False + + policy: PolicyCfg = PolicyCfg() + + +@configclass +class FrankaRopeEventsCfg: + """Reset the Franka exactly as the equivalent Isaac Lab rope environment.""" + + reset_all = EventTerm(func=mdp_isaac_lab.reset_scene_to_default, mode="reset") + randomize_franka_joint_state = EventTerm( + func=mdp_isaac_lab.reset_joints_by_offset, + mode="reset", + params={ + "position_range": (-0.02, 0.02), + "velocity_range": (0.0, 0.0), + "asset_cfg": SceneEntityCfg("robot", joint_names=["panda_joint.*"]), + }, + ) + + +@register_asset +class FrankaRopeIKEmbodiment(FrankaIKEmbodiment): + """Arena Franka IK embodiment with rope-specific pose, observations, and cameras.""" + + name = "franka_rope_ik" + + def __init__( + self, + enable_cameras: bool = False, + initial_pose: Pose | None = None, + concatenate_observation_terms: bool = False, + arm_mode: ArmMode | None = None, + ) -> None: + super().__init__( + enable_cameras=enable_cameras, + initial_pose=initial_pose, + initial_joint_pose=list(_FRANKA_ROPE_INIT_JOINT_POSE), + concatenate_observation_terms=concatenate_observation_terms, + arm_mode=arm_mode, + ) + self.scene_config.robot.init_state.joint_pos = dict(_FRANKA_ROPE_INIT_JOINT_POS) + self.observation_config = FrankaRopeObservationsCfg() + self.observation_config.policy.concatenate_terms = concatenate_observation_terms + self.camera_config = FrankaRopeCameraCfg() + self.event_config = FrankaRopeEventsCfg() + self.reward_config = None + + +@register_retargeter +class FrankaRopeKeyboardRetargeter(RetargetterBase): + """Use the standard relative-pose keyboard pipeline for the rope embodiment.""" + + device = "keyboard" + embodiment = FrankaRopeIKEmbodiment.name + + def get_pipeline_builder(self, embodiment: object) -> Callable | None: + """Return no custom pipeline; Isaac Lab handles relative-pose keyboard input.""" + + del embodiment + + +@register_retargeter +class FrankaRopeSpaceMouseRetargeter(RetargetterBase): + """Use the standard relative-pose SpaceMouse pipeline for the rope embodiment.""" + + device = "spacemouse" + embodiment = FrankaRopeIKEmbodiment.name + + def get_pipeline_builder(self, embodiment: object) -> Callable | None: + """Return no custom pipeline; Isaac Lab handles relative-pose SpaceMouse input.""" + + del embodiment diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py new file mode 100644 index 0000000..1781e57 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py @@ -0,0 +1,107 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from isaaclab.devices import DevicesCfg, Se3KeyboardCfg, Se3SpaceMouseCfg +from isaaclab_arena.assets.register import register_environment +from isaaclab_arena.environments.arena_environment_factory import ArenaEnvironmentCfg, ArenaEnvironmentFactory +from isaaclab_arena.utils.pose import Pose + +from .rope_asset import FrankaRopeAsset +from .task import FrankaRopeTask + +if TYPE_CHECKING: + from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment + +FRANKA_ROPE_ARENA_ENV_ID = "Isaac-Rope-Franka-Arena-IK-Rel-v0" + + +@dataclass +class FrankaRopeArenaEnvironmentCfg(ArenaEnvironmentCfg): + """Configure the AutoData-owned Arena Franka rope environment.""" + + enable_cameras: bool = False + embodiment: str = "franka_rope_ik" + teleop_device: str | None = None + + +def _configure_franka_rope_env(env_cfg: Any) -> Any: + """Apply rope timing and asset settings for PhysX.""" + + env_cfg.decimation = 5 + env_cfg.sim.dt = 0.01 + env_cfg.sim.render_interval = 2 + env_cfg.num_rerenders_on_reset = 1 + env_cfg.scene.replicate_physics = False + env_cfg.teleop_devices = DevicesCfg( + devices={ + "keyboard": Se3KeyboardCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.2, + ), + "spacemouse": Se3SpaceMouseCfg( + pos_sensitivity=0.2, + rot_sensitivity=0.5, + ), + }, + ) + return env_cfg + + +@register_environment +class FrankaRopeArenaEnvironment(ArenaEnvironmentFactory[FrankaRopeArenaEnvironmentCfg]): + """Compose the rope scene, Franka embodiment, and rope task through Arena.""" + + name = FRANKA_ROPE_ARENA_ENV_ID + _legacy_argparse_cfg_type = FrankaRopeArenaEnvironmentCfg + + def build(self, cfg: FrankaRopeArenaEnvironmentCfg) -> IsaacLabArenaEnvironment: + """Build an Arena environment description from ``cfg``.""" + + from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment + from isaaclab_arena.scene.scene import Scene + + table = self.asset_registry.get_asset_by_name("table")() + table.set_initial_pose( + Pose( + position_xyz=(0.5, 0.0, 0.0), + rotation_xyzw=(0.0, 0.0, 0.707, 0.707), + ), + ) + rope = FrankaRopeAsset() + ground_plane = self.asset_registry.get_asset_by_name("ground_plane")() + ground_plane.set_initial_pose(Pose(position_xyz=(0.0, 0.0, -1.05))) + light = self.asset_registry.get_asset_by_name("light")() + light.set_intensity(3000.0) + light.set_color((0.75, 0.75, 0.75)) + + embodiment = self.asset_registry.get_asset_by_name(cfg.embodiment)( + enable_cameras=cfg.enable_cameras, + ) + teleop_device = ( + self.device_registry.get_device_by_name(cfg.teleop_device)() if cfg.teleop_device is not None else None + ) + + scene = Scene( + assets=[ + table, + rope, + ground_plane, + light, + ], + ) + task = FrankaRopeTask() + + return IsaacLabArenaEnvironment( + name=self.name, + embodiment=embodiment, + scene=scene, + task=task, + teleop_device=teleop_device, + env_cfg_callback=_configure_franka_rope_env, + ) diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py new file mode 100644 index 0000000..5185858 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from .events import reset_rope_end_tracking, reset_rope_nodal_state +from .observations import ee_frame_pos, ee_frame_quat, gripper_pos, object_grasped, object_nodal_pos +from .terminations import rope_below_minimum, rope_ends_close_tracked + +__all__ = [ + "ee_frame_pos", + "ee_frame_quat", + "gripper_pos", + "object_grasped", + "object_nodal_pos", + "reset_rope_end_tracking", + "reset_rope_nodal_state", + "rope_below_minimum", + "rope_ends_close_tracked", +] diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py new file mode 100644 index 0000000..2297c31 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py @@ -0,0 +1,106 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +import torch +from typing import TYPE_CHECKING + +import isaaclab.utils.math as math_utils +from isaaclab.assets import DeformableObject +from isaaclab.managers import SceneEntityCfg + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv, ManagerBasedRLEnv + + +# Mesh-resolution-specific nodes covering one half of the 549-node rope simulation mesh. +# fmt: off +_ROPE_PARTIAL_NODE_IDS = ( + 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, + 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, + 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 296, 297, + 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, + 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, + 322, 323, 324, 325, 326, 327, 328, 329, 331, 332, 333, 334, + 335, 336, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, + 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, + 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, + 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, + 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, + 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, + 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, + 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, + 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, + 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, + 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, + 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, + 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, + 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, + 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, + 516, 517, 518, 519, 520, 528, 529, 530, 531, 532, 533, 534, + 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, + 547, 548, +) +# fmt: on + + +def _sample_pose_delta( + pose_range: dict[str, tuple[float, float]], + count: int, + device: str, +) -> tuple[torch.Tensor, torch.Tensor]: + """Sample translation and rotation deltas from named pose ranges.""" + + range_list = [pose_range.get(key, (0.0, 0.0)) for key in ("x", "y", "z", "roll", "pitch", "yaw")] + ranges = torch.tensor(range_list, device=device) + samples = math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], (count, 6), device=device) + quaternion = math_utils.quat_from_euler_xyz(samples[:, 3], samples[:, 4], samples[:, 5]) + return samples[:, :3], quaternion + + +def reset_rope_end_tracking(env: ManagerBasedRLEnv, env_ids: torch.Tensor | None = None) -> None: + """Discard cached rope endpoint indices for reset environments.""" + + if not hasattr(env, "_rope_end_indices"): + return + if env_ids is None: + env._rope_end_indices.clear() + return + for env_id in env_ids.tolist(): + env._rope_end_indices.pop(int(env_id), None) + + +def reset_rope_nodal_state( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + pose_range: dict[str, tuple[float, float]], + partial_pose_range: dict[str, tuple[float, float]], + asset_cfg: SceneEntityCfg = SceneEntityCfg("object"), +) -> None: + """Reset and randomize the rope, including a transform of one rope half. + + Args: + env: Environment containing the deformable rope. + env_ids: Environments to reset. + pose_range: Translation [m] and rotation [rad] ranges applied to all nodes. + partial_pose_range: Additional translation [m] and rotation [rad] ranges applied to one rope half. + asset_cfg: Deformable rope entity. + """ + + rope: DeformableObject = env.scene[asset_cfg.name] + nodal_state = rope.data.default_nodal_state_w.torch[env_ids].clone() + + position, quaternion = _sample_pose_delta(pose_range, len(env_ids), rope.device) + nodal_state[..., :3] = rope.transform_nodal_pos(nodal_state[..., :3], position, quaternion) + + assert max(_ROPE_PARTIAL_NODE_IDS) < nodal_state.shape[1], ( + f"Rope mesh has {nodal_state.shape[1]} simulation nodes, but the reset mapping expects at least " + f"{max(_ROPE_PARTIAL_NODE_IDS) + 1}." + ) + node_ids = torch.tensor(_ROPE_PARTIAL_NODE_IDS, device=rope.device) + position, quaternion = _sample_pose_delta(partial_pose_range, len(env_ids), rope.device) + nodal_state[..., node_ids, :3] = rope.transform_nodal_pos(nodal_state[..., node_ids, :3], position, quaternion) + + rope.write_nodal_state_to_sim_index(nodal_state, env_ids=env_ids) diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py new file mode 100644 index 0000000..98900ca --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py @@ -0,0 +1,93 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +import torch +from typing import TYPE_CHECKING + +from isaaclab.assets import Articulation, DeformableObject +from isaaclab.managers import SceneEntityCfg +from isaaclab.sensors import FrameTransformer + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + +def ee_frame_pos( + env: ManagerBasedRLEnv, + ee_frame_cfg: SceneEntityCfg = SceneEntityCfg("ee_frame"), +) -> torch.Tensor: + """Return the end-effector position in the environment frame [m].""" + + ee_frame: FrameTransformer = env.scene[ee_frame_cfg.name] + return ee_frame.data.target_pos_w.torch[:, 0, :] - env.scene.env_origins + + +def ee_frame_quat( + env: ManagerBasedRLEnv, + ee_frame_cfg: SceneEntityCfg = SceneEntityCfg("ee_frame"), +) -> torch.Tensor: + """Return the end-effector orientation quaternion in the world frame.""" + + ee_frame: FrameTransformer = env.scene[ee_frame_cfg.name] + return ee_frame.data.target_quat_w.torch[:, 0, :] + + +def gripper_pos( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=["panda_finger_joint.*"]), +) -> torch.Tensor: + """Return the two Franka finger positions using the SoftMimicGen sign convention [m].""" + + robot: Articulation = env.scene[robot_cfg.name] + joint_pos = robot.data.joint_pos.torch[:, robot_cfg.joint_ids] + return torch.cat((joint_pos[:, 0:1], -joint_pos[:, 1:2]), dim=1) + + +def object_grasped( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg, + ee_frame_cfg: SceneEntityCfg, + object_cfg: SceneEntityCfg, + distance_threshold: float = 0.015, + gripper_open_value: float = 0.04, + gripper_threshold: float = 0.005, +) -> torch.Tensor: + """Return whether the gripper is closed around a nearby rope node. + + Args: + env: Environment containing the robot and rope. + robot_cfg: Franka articulation and finger-joint selection. + ee_frame_cfg: End-effector frame sensor. + object_cfg: Deformable rope entity. + distance_threshold: Maximum end-effector-to-node distance [m]. + gripper_open_value: Finger position when fully open [m]. + gripper_threshold: Required deviation from the open position [m]. + + Returns: + Boolean tensor with one value per environment. + """ + + robot: Articulation = env.scene[robot_cfg.name] + ee_frame: FrameTransformer = env.scene[ee_frame_cfg.name] + rope: DeformableObject = env.scene[object_cfg.name] + + nodal_pos_w = rope.data.nodal_pos_w.torch + end_effector_pos_w = ee_frame.data.target_pos_w.torch[:, 0, :] + minimum_distance = torch.linalg.vector_norm(nodal_pos_w - end_effector_pos_w.unsqueeze(1), dim=-1).amin(dim=1) + + finger_pos = robot.data.joint_pos.torch[:, robot_cfg.joint_ids] + gripper_closed = (torch.abs(finger_pos - gripper_open_value) > gripper_threshold).all(dim=1) + return (minimum_distance < distance_threshold) & gripper_closed + + +def object_nodal_pos( + env: ManagerBasedRLEnv, + object_cfg: SceneEntityCfg = SceneEntityCfg("object"), +) -> torch.Tensor: + """Return rope simulation-node positions in the environment frame [m].""" + + rope: DeformableObject = env.scene[object_cfg.name] + return rope.data.nodal_pos_w.torch - env.scene.env_origins.unsqueeze(1) diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py new file mode 100644 index 0000000..ac42349 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +import torch +from typing import TYPE_CHECKING + +from isaaclab.assets import DeformableObject +from isaaclab.managers import SceneEntityCfg + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + +def rope_below_minimum( + env: ManagerBasedRLEnv, + minimum_height: float, + object_cfg: SceneEntityCfg = SceneEntityCfg("object"), +) -> torch.Tensor: + """Return whether the rope center is below a minimum world height [m].""" + + rope: DeformableObject = env.scene[object_cfg.name] + return rope.data.root_pos_w.torch[:, 2] < minimum_height + + +def rope_ends_close_tracked( + env: ManagerBasedRLEnv, + object_cfg: SceneEntityCfg = SceneEntityCfg("object"), + distance_threshold: float = 0.08, + distance_threshold_min: float = 0.02, + num_end_nodes: int = 3, +) -> torch.Tensor: + """Return whether the two tracked rope ends are close enough for success. + + Endpoint nodes are discovered from the farthest node pair at the start of each + episode and then tracked by simulation-node index. + + Args: + env: Environment containing the rope. + object_cfg: Deformable rope entity. + distance_threshold: Maximum distance between endpoint neighborhoods [m]. + distance_threshold_min: Minimum separation used to reject collapsed false positives [m]. + num_end_nodes: Number of nearby nodes used for each endpoint neighborhood. + + Returns: + Boolean tensor with one value per environment. + """ + + rope: DeformableObject = env.scene[object_cfg.name] + nodal_positions = rope.data.nodal_pos_w.torch + num_envs, num_nodes, _ = nodal_positions.shape + assert num_end_nodes <= num_nodes, f"Requested {num_end_nodes} endpoint nodes from a {num_nodes}-node rope." + + if not hasattr(env, "_rope_end_indices"): + env._rope_end_indices = {} + + result = torch.zeros(num_envs, dtype=torch.bool, device=env.device) + for env_id in range(num_envs): + env_nodal_pos = nodal_positions[env_id] + if env_id not in env._rope_end_indices: + farthest_flat_index = torch.cdist(env_nodal_pos, env_nodal_pos).argmax() + env._rope_end_indices[env_id] = ( + int((farthest_flat_index // num_nodes).item()), + int((farthest_flat_index % num_nodes).item()), + ) + + end_1_id, end_2_id = env._rope_end_indices[env_id] + end_1_distances = torch.linalg.vector_norm(env_nodal_pos - env_nodal_pos[end_1_id], dim=1) + end_2_distances = torch.linalg.vector_norm(env_nodal_pos - env_nodal_pos[end_2_id], dim=1) + end_1_node_ids = torch.topk(end_1_distances, num_end_nodes, largest=False).indices + end_2_node_ids = torch.topk(end_2_distances, num_end_nodes, largest=False).indices + + minimum_end_distance = torch.cdist(env_nodal_pos[end_1_node_ids], env_nodal_pos[end_2_node_ids]).amin() + result[env_id] = distance_threshold_min < minimum_end_distance < distance_threshold + + return result diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py new file mode 100644 index 0000000..1eb21cd --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py @@ -0,0 +1,39 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +from pathlib import Path + +from isaaclab.assets import DeformableObjectCfg +from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg +from isaaclab_arena.assets.asset import Asset + +_ROPE_USD_PATH = Path(__file__).resolve().parent / "assets" / "Rope.usd" + + +class FrankaRopeAsset(Asset): + """Deformable rope asset that can participate in an Arena :class:`Scene`.""" + + def __init__(self, name: str = "object") -> None: + super().__init__(name=name, tags=["deformable"]) + self.object_cfg = DeformableObjectCfg( + prim_path="{ENV_REGEX_NS}/Object", + init_state=DeformableObjectCfg.InitialStateCfg( + pos=(0.5, 0.0, 0.02), + rot=(0.0, 0.0, 0.707, 0.707), + ), + spawn=UsdFileCfg(usd_path=str(_ROPE_USD_PATH)), + debug_vis=False, + ) + + def get_object_cfg(self) -> tuple[str, DeformableObjectCfg]: + """Return the scene key and Isaac Lab deformable-object configuration.""" + + return self.name, self.object_cfg + + def get_event_cfg(self) -> tuple[str, None]: + """Return no asset-level event; the Arena task owns nodal reset semantics.""" + + return self.name, None diff --git a/autodata_examples/envs/isaac_lab_arena/franka_rope/task.py b/autodata_examples/envs/isaac_lab_arena/franka_rope/task.py new file mode 100644 index 0000000..c7127f9 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/franka_rope/task.py @@ -0,0 +1,152 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +import math +from typing import Never + +from isaaclab.envs.common import ViewerCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.utils.configclass import configclass +from isaaclab_arena.assets.register import register_task +from isaaclab_arena.embodiments.common.arm_mode import ArmMode +from isaaclab_arena.metrics.metric_base import MetricBase +from isaaclab_arena.metrics.success_rate import SuccessRateMetric +from isaaclab_arena.progress_tracking.completion_criteria import CompletionCriteria +from isaaclab_arena.tasks.task_base import TaskBase +from isaaclab_arena.tasks.task_termination_cfg import TaskTerminationCfg + +from . import mdp + + +@configclass +class FrankaRopeTaskObservationsCfg: + """Automatic SoftMimicGen subtask signals.""" + + @configclass + class SubtaskCfg(ObsGroup): + """Signals consumed by AutoData annotation.""" + + grasp = ObsTerm( + func=mdp.object_grasped, + params={ + "robot_cfg": SceneEntityCfg("robot", joint_names=["panda_finger_joint.*"]), + "ee_frame_cfg": SceneEntityCfg("ee_frame"), + "object_cfg": SceneEntityCfg("object"), + }, + ) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = False + + subtask_terms: SubtaskCfg = SubtaskCfg() + + +@configclass +class FrankaRopeTaskEventsCfg: + """Rope randomization and endpoint tracking events.""" + + reset_rope_end_tracking = EventTerm( + func=mdp.reset_rope_end_tracking, + mode="reset", + ) + + reset_object_position = EventTerm( + func=mdp.reset_rope_nodal_state, + mode="reset", + params={ + "pose_range": { + "x": (0.0, 0.1), + "y": (0.0, 0.0), + "z": (0.0, 0.0), + "roll": (0.0, 0.0), + "pitch": (0.0, 0.0), + "yaw": (0.0, 0.0), + }, + "partial_pose_range": { + "x": (0.0, 0.1), + "y": (0.0, 0.1), + "z": (0.0, 0.0), + "roll": (0.0, 0.0), + "pitch": (0.0, 0.0), + "yaw": (-math.pi / 6.0, math.pi / 6.0), + }, + "asset_cfg": SceneEntityCfg("object"), + }, + ) + + +@register_task +class FrankaRopeTask(TaskBase): + """Bring the two ends of a deformable rope together with a Franka arm.""" + + def __init__(self, episode_length_s: float = 10.0) -> None: + super().__init__( + episode_length_s=episode_length_s, + task_description="Grasp and manipulate the deformable rope until its ends meet.", + ) + self.observation_cfg = FrankaRopeTaskObservationsCfg() + self.events_cfg = FrankaRopeTaskEventsCfg() + self.termination_cfg = TaskTerminationCfg( + timeout_s=self.episode_length_s, + success=[ + CompletionCriteria( + name="rope_ends_close", + predicate_sequence=[mdp.rope_ends_close_tracked], + ), + ], + failures={ + "object_dropping": DoneTerm( + func=mdp.rope_below_minimum, + params={ + "minimum_height": -0.05, + "object_cfg": SceneEntityCfg("object"), + }, + ), + }, + ) + + def get_scene_cfg(self) -> None: + """Return no task scene additions; the environment owns all assets.""" + + def get_observation_cfg(self) -> FrankaRopeTaskObservationsCfg: + """Return automatic subtask-signal observations.""" + + return self.observation_cfg + + def get_termination_cfg(self) -> TaskTerminationCfg: + """Return task termination terms.""" + + return self.termination_cfg + + def get_events_cfg(self) -> FrankaRopeTaskEventsCfg: + """Return deformable reset events.""" + + return self.events_cfg + + def get_mimic_env_cfg(self, arm_mode: ArmMode) -> Never: + """Reject Arena Mimic mode; SoftMimicGen is provided by AutoData.""" + + del arm_mode + raise NotImplementedError("FrankaRopeTask uses AutoData SoftMimicGen, not Arena Mimic mode.") + + def get_metrics(self) -> list[MetricBase]: + """Return success-rate evaluation.""" + + return [SuccessRateMetric()] + + def get_viewer_cfg(self) -> ViewerCfg: + """Return the rope workspace camera framing.""" + + return ViewerCfg( + eye=(1.0, 1.0, 0.5), + lookat=(0.0, 0.0, 0.0), + origin_type="env", + ) diff --git a/autodata_examples/envs/isaac_lab_arena/registration.py b/autodata_examples/envs/isaac_lab_arena/registration.py new file mode 100644 index 0000000..4c4a666 --- /dev/null +++ b/autodata_examples/envs/isaac_lab_arena/registration.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +import argparse +import gymnasium as gym +from functools import partial +from typing import Any + +from isaaclab_arena.assets.registries import EnvironmentRegistry + +from .franka_rope import FRANKA_ROPE_ARENA_ENV_ID + + +def is_arena_environment(env_name: str) -> bool: + """Return whether ``env_name`` is owned by AutoData's Arena package.""" + + return env_name == FRANKA_ROPE_ARENA_ENV_ID + + +def _bind_gym_make_kwargs(env_name: str, env_kwargs: dict[str, Any]) -> None: + """Bind Arena-only constructor arguments for callers that use plain ``gym.make``. + + Isaac Lab's external-callback API can register an environment but cannot return + keyword arguments to the later ``gym.make`` call. Bind those arguments into the + registered entry point instead. + """ + + env_spec = gym.spec(env_name) + entry_point = env_spec.entry_point + assert entry_point is not None, f"Environment {env_name!r} has no Gym entry point." + if isinstance(entry_point, str): + from gymnasium.envs.registration import load_env_creator + + entry_point = load_env_creator(entry_point) + env_spec.entry_point = partial(entry_point, **env_kwargs) + + +def build_and_register_arena_environment( + *, + enable_cameras: bool, + num_envs: int, + device: str, + seed: int, + presets: str | None = None, +) -> dict[str, dict[str, Any]]: + """Build and register AutoData's typed Arena environment for the current run. + + Args: + enable_cameras: Whether to include environment camera sensors. + num_envs: Number of parallel environments in this run. + device: Simulation device for this run. + seed: Environment seed for this run. + presets: Optional physics backend preset; None preserves the environment default. + + Returns: + Gym constructor kwargs keyed by registered environment ID. + """ + + assert ( + FRANKA_ROPE_ARENA_ENV_ID not in gym.registry + ), f"Environment {FRANKA_ROPE_ARENA_ENV_ID!r} is already registered in this process." + + # ArenaEnvBuilder imports simulator modules and must be loaded only after AppLauncher starts Isaac Sim. + from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder + from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg + + environment_registry = EnvironmentRegistry() + environment_factory_type = environment_registry.get_component_by_name(FRANKA_ROPE_ARENA_ENV_ID) + environment_cfg_type = environment_registry.get_environment_cfg_type(environment_factory_type) + arena_environment = environment_factory_type().build(environment_cfg_type(enable_cameras=enable_cameras)) + + builder = ArenaEnvBuilder( + arena_environment, + ArenaEnvBuilderCfg( + num_envs=num_envs, + env_spacing=2.5, + seed=seed, + solve_relations=False, + device=device, + presets=presets, + ), + ) + env_name, _, env_kwargs = builder.build_registered() + return {env_name: env_kwargs} + + +def register_environment_from_cli() -> list[str]: + """Register the Arena rope environment through Isaac Lab's callback API. + + Isaac Lab invokes external registration callbacks without arguments. This adapter + reads the standard Lab and Arena options already present in ``sys.argv``, builds the + typed AutoData environment, and binds Arena's additional constructor arguments for + the script's later plain ``gym.make`` call. + + Returns: + Command-line arguments not consumed by the environment registration parser. + """ + + from isaaclab.app import AppLauncher + from isaaclab_arena.cli.isaaclab_arena_cli import ( + add_isaac_lab_cli_args, + add_isaaclab_arena_cli_args, + arena_env_builder_cfg_from_argparse, + ) + from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder + from isaaclab_arena_environments.cli import add_environment_cli_args, build_environment_from_cli + + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--task", type=str, default=FRANKA_ROPE_ARENA_ENV_ID) + AppLauncher.add_app_launcher_args(parser) + add_isaac_lab_cli_args(parser) + add_isaaclab_arena_cli_args(parser) + + environment_factory_type = EnvironmentRegistry().get_component_by_name(FRANKA_ROPE_ARENA_ENV_ID) + add_environment_cli_args(parser, environment_factory_type) + parser.set_defaults(env_spacing=2.5, solve_relations=False) + args, remaining_args = parser.parse_known_args() + + requested_env_name = args.task.split(":")[-1] + assert ( + requested_env_name == FRANKA_ROPE_ARENA_ENV_ID + ), f"Arena callback expected {FRANKA_ROPE_ARENA_ENV_ID!r}, got {requested_env_name!r}." + + arena_environment = build_environment_from_cli(environment_factory_type, args) + builder = ArenaEnvBuilder(arena_environment, arena_env_builder_cfg_from_argparse(args)) + env_name, _, env_kwargs = builder.build_registered() + _bind_gym_make_kwargs(env_name, env_kwargs) + return remaining_args diff --git a/autodata_examples/tasks/franka_rope_softmimicgen.yaml b/autodata_examples/tasks/franka_rope_softmimicgen.yaml new file mode 100644 index 0000000..3794901 --- /dev/null +++ b/autodata_examples/tasks/franka_rope_softmimicgen.yaml @@ -0,0 +1,59 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: franka_rope +description: Grasp and manipulate a deformable rope until its ends meet. +algo: softmimicgen + +generation_policy: + name: franka_rope + seed: 1 + num_trials: 10 + guarantee_success: true + keep_failed: false + source_dataset_path: null + generation_path: null + task_name: null + use_skillgen: false + use_navigation_controller: false + reset_settling_steps: 10 + select_src_per_subtask: false + select_src_per_arm: false + transform_first_robot_pose: false + interpolate_from_last_target_pose: true + +subtasks: + franka: + - object_ref: object + description: Approach and grasp the deformable rope. + subtask_term_signal: grasp + selection_strategy: registration_cost + selection_strategy_kwargs: + nn_k: 3 + subtask_term_offset_range: [10, 15] + action_noise: 0.0 + num_interpolation_steps: 10 + num_fixed_steps: 0 + apply_noise_during_interpolation: false + algo_params: + object_soft: true + use_rotation_transform: true + bend_coef: 0.1 + rot_coef: 0.001 + + # Final subtask: no term signal (end-of-trajectory) and no term offset. + - object_ref: object + description: Manipulate the rope until its ends meet. + selection_strategy: registration_cost + selection_strategy_kwargs: + nn_k: 3 + subtask_term_offset_range: [0, 0] + action_noise: 0.0 + num_interpolation_steps: 5 + num_fixed_steps: 0 + apply_noise_during_interpolation: false + algo_params: + object_soft: true + use_rotation_transform: true + bend_coef: 0.1 + rot_coef: 0.001 diff --git a/autodata_interfaces/env/isaaclab_env_interface.py b/autodata_interfaces/env/isaaclab_env_interface.py index 435cc8d..73f8de2 100644 --- a/autodata_interfaces/env/isaaclab_env_interface.py +++ b/autodata_interfaces/env/isaaclab_env_interface.py @@ -23,7 +23,6 @@ import isaaclab.sim as sim_utils from isaaclab.assets import RigidObjectCfg from isaaclab.envs.mdp.actions.pink_task_space_actions import PinkInverseKinematicsAction -from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg from isaaclab.managers import DatasetExportMode, EventTermCfg, SceneEntityCfg from isaaclab.managers.recorder_manager import RecorderManagerBaseCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR @@ -34,6 +33,7 @@ from autodata_interfaces.env.env_profile import EnvironmentProfile, convert_event_params from autodata_interfaces.env.pink_ik_action import PinkInverseKinematicsActionSharedUrdf +from autodata_interfaces.env.recorders import make_action_state_recorder_manager_cfg from autodata_interfaces.env.reset_request import EnvResetRequest from autodata_interfaces.env.scene_state import get_scene_state from autodata_interfaces.tasks.generation_policy_spec import GenerationPolicy @@ -230,7 +230,7 @@ def setup_env_config( # Setup recorders if recorder_cfg is None: - env_cfg.recorders = ActionStateRecorderManagerCfg() + env_cfg.recorders = make_action_state_recorder_manager_cfg(record_images=enable_cameras) else: env_cfg.recorders = recorder_cfg env_cfg.recorders.dataset_export_dir_path = output_dir diff --git a/autodata_interfaces/env/recorders.py b/autodata_interfaces/env/recorders.py new file mode 100644 index 0000000..a12bc5c --- /dev/null +++ b/autodata_interfaces/env/recorders.py @@ -0,0 +1,29 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from __future__ import annotations + +from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg +from isaaclab.managers.recorder_manager import RecorderTerm, RecorderTermCfg + + +class PreStepCameraObservationsRecorder(RecorderTerm): + """Record image observations exposed in a separate camera group.""" + + def record_pre_step(self): + """Return camera observations alongside the recorded policy observations.""" + return "obs", self._env.obs_buf.get("camera_obs", {}) + + +def make_action_state_recorder_manager_cfg(record_images: bool = False) -> ActionStateRecorderManagerCfg: + """Return Isaac Lab's action-state recorder with optional camera observations. + + Args: + record_images: Include the separate camera observation group when present. + """ + + cfg = ActionStateRecorderManagerCfg() + if record_images: + cfg.record_pre_step_camera_observations = RecorderTermCfg(class_type=PreStepCameraObservationsRecorder) + return cfg diff --git a/autodata_tests/e2e/test_annotation.py b/autodata_tests/e2e/test_annotation.py index f5383d3..e6ff381 100644 --- a/autodata_tests/e2e/test_annotation.py +++ b/autodata_tests/e2e/test_annotation.py @@ -16,9 +16,33 @@ @pytest.mark.with_subprocess -def test_automatic_annotation_from_dataset(tmp_path): +@pytest.mark.parametrize( + ("input_dataset", "task_descriptor", "env_name", "signal_names", "device", "require_all_demos"), + [ + ( + "annotated_dataset_franka_stack_mimicgen.hdf5", + "franka_cube_stack.yaml", + "IsaacContrib-Stack-Cube-Franka-IK-Rel", + {"grasp_1", "stack_1", "grasp_2"}, + "cpu", + True, + ), + ( + "annotated_dataset_franka_rope_softmimicgen.hdf5", + "franka_rope_softmimicgen.yaml", + "Isaac-Rope-Franka-Arena-IK-Rel-v0", + {"grasp"}, + "cuda", + False, + ), + ], + ids=["franka_cube_stack", "franka_rope"], +) +def test_automatic_annotation_from_dataset( + tmp_path, input_dataset, task_descriptor, env_name, signal_names, device, require_all_demos +): """Use the stored task name and export usable termination signals during replay.""" - input_file = Path(TestPaths.test_data_dir) / "annotated_dataset_franka_stack_mimicgen.hdf5" + input_file = Path(TestPaths.test_data_dir) / input_dataset output_file = tmp_path / "annotated.hdf5" run_subprocess([ TestPaths.python_path, @@ -27,9 +51,9 @@ def test_automatic_annotation_from_dataset(tmp_path): "--viz", "none", "--device", - "cpu", + device, "--task_descriptor", - str(Path(TestPaths.tasks_dir) / "franka_cube_stack.yaml"), + str(Path(TestPaths.tasks_dir) / task_descriptor), "--embodiment", str(Path(TestPaths.embodiments_dir) / "franka_ik_rel.yaml"), "--input_file", @@ -39,14 +63,25 @@ def test_automatic_annotation_from_dataset(tmp_path): ]) assert_valid_dataset(str(output_file), min_num_demos=1) with h5py.File(input_file, "r") as source, h5py.File(output_file, "r") as annotated: - assert len(annotated["data"]) == len(source["data"]) - assert json.loads(annotated["data"].attrs["env_args"])["env_name"] == "IsaacContrib-Stack-Cube-Franka-IK-Rel" + if require_all_demos: + assert len(annotated["data"]) == len(source["data"]) + else: + # Deformable replay can diverge across simulator versions. Annotation exports only successful replays. + assert len(annotated["data"]) <= len(source["data"]) + assert json.loads(annotated["data"].attrs["env_args"])["env_name"] == env_name + source_info = next(iter(source["data"].values()))["obs/datagen_info"] for episode in annotated["data"].values(): signals = episode["obs/datagen_info/subtask_term_signals"] - assert set(signals) == {"grasp_1", "stack_1", "grasp_2"} + assert set(signals) == signal_names for signal in signals.values(): assert len(signal) == len(episode["actions"]) assert signal[:].any() + if "object_nodal_position" in source_info: + nodes = episode["obs/datagen_info/object_nodal_position/object"] + assert nodes.shape == ( + len(episode["actions"]), + *source_info["object_nodal_position/object"].shape[1:], + ) @pytest.mark.with_subprocess diff --git a/autodata_tests/e2e/test_recording.py b/autodata_tests/e2e/test_recording.py index 3de6757..b995fd5 100644 --- a/autodata_tests/e2e/test_recording.py +++ b/autodata_tests/e2e/test_recording.py @@ -14,7 +14,7 @@ from autodata_tests.utils.subprocess import run_subprocess -def configure_recording_smoke() -> None: +def configure_recording_smoke() -> list[str]: """Replace physical HID I/O and export three real simulation steps for the test.""" import torch @@ -22,9 +22,15 @@ def configure_recording_smoke() -> None: from isaaclab.devices import Se3SpaceMouse from isaaclab.envs import ManagerBasedRLEnv + from autodata_examples.envs import register_environments + # This assertion observes the actual script's parser after Python startup, # before its task lookup. No task or environment resolution is mocked. - assert recorder.args_cli.task == "IsaacContrib-Stack-Cube-Franka-IK-Rel" + assert recorder.args_cli.task in { + "IsaacContrib-Stack-Cube-Franka-IK-Rel", + "Isaac-Rope-Franka-Arena-IK-Rel-v0", + } + remaining_args = register_environments() Se3SpaceMouse._find_device = lambda self: None Se3SpaceMouse._run_device = lambda self: None original_step = ManagerBasedRLEnv.step @@ -35,6 +41,11 @@ def step_and_finish(env, actions): result = original_step(env, actions) steps += 1 if steps == 3: + if recorder.args_cli.task == "Isaac-Rope-Franka-Arena-IK-Rel-v0": + camera_obs = env.obs_buf["camera_obs"] + assert len(camera_obs) == 2 + for image in camera_obs.values(): + assert image.shape[:3] == (1, 128, 128) # Synthetic input does not solve the task. Explicitly export this # short test episode to verify recording and metadata, then stop. env.recorder_manager.record_pre_reset([0], force_export_or_skip=False) @@ -46,10 +57,19 @@ def step_and_finish(env, actions): return result ManagerBasedRLEnv.step = step_and_finish + return remaining_args @pytest.mark.with_subprocess -def test_lab_record_demos_task_startup(tmp_path): +@pytest.mark.parametrize( + ("task_name", "camera_args"), + [ + ("IsaacContrib-Stack-Cube-Franka-IK-Rel", []), + ("Isaac-Rope-Franka-Arena-IK-Rel-v0", ["--enable_cameras"]), + ], + ids=["franka_cube_stack", "franka_rope"], +) +def test_lab_record_demos_task_startup(tmp_path, task_name, camera_args): """The direct recorder uses the registered task and records real actions and states.""" output_file = tmp_path / "recording.hdf5" script = Path(TestPaths.repo_root) / "submodules/IsaacLab-Arena/submodules/IsaacLab/scripts/tools/record_demos.py" @@ -60,7 +80,7 @@ def test_lab_record_demos_task_startup(tmp_path): "--viz", "kit", "--task", - "IsaacContrib-Stack-Cube-Franka-IK-Rel", + task_name, "--teleop_device", "spacemouse", "--dataset_file", @@ -69,12 +89,15 @@ def test_lab_record_demos_task_startup(tmp_path): "1", "--external_callback", "autodata_tests.e2e.test_recording.configure_recording_smoke", + *camera_args, ], env={**os.environ, "HEADLESS": "1"}, ) with h5py.File(output_file, "r") as dataset: - assert json.loads(dataset["data"].attrs["env_args"])["env_name"] == "IsaacContrib-Stack-Cube-Franka-IK-Rel" + assert json.loads(dataset["data"].attrs["env_args"])["env_name"] == task_name assert len(dataset["data"]) == 1 episode = next(iter(dataset["data"].values())) assert episode["actions"].shape == (3, 7) assert "states" in episode + if task_name == "Isaac-Rope-Franka-Arena-IK-Rel-v0": + assert episode["states/deformable_object/object/nodal_position"].shape == (3, 549, 3) diff --git a/autodata_tests/e2e/test_rope_environment.py b/autodata_tests/e2e/test_rope_environment.py new file mode 100644 index 0000000..63abc7b --- /dev/null +++ b/autodata_tests/e2e/test_rope_environment.py @@ -0,0 +1,181 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Simulate the rope-folding task with the PhysX physics backend.""" + +import os + +import pytest +import warp as wp + +from autodata_tests.utils.constants import TestPaths +from autodata_tests.utils.subprocess import run_subprocess + + +@pytest.mark.with_subprocess +@pytest.mark.parametrize("presets", ["physx"]) +@pytest.mark.parametrize("num_envs", [1, 3]) +def test_rope_environment(presets, num_envs, tmp_path): + result_file = tmp_path / "passed" + run_subprocess( + [ + TestPaths.python_path, + "-m", + __name__, + "--presets", + presets, + "--num_envs", + str(num_envs), + "--viz", + "kit", + "--result_file", + str(result_file), + "--enable_cameras", + ], + env={**os.environ, "HEADLESS": "1"}, + ) + assert result_file.exists(), "Simulation exited before completing the checks" + + +def main() -> None: + """Check spawning, stepping, contacts, observations and indexed resets.""" + import argparse + import tempfile + + wp.config.enable_backward = False + from isaaclab.app import AppLauncher + + parser = argparse.ArgumentParser() + parser.add_argument("--presets", choices=("physx",), required=True) + parser.add_argument("--num_envs", type=int, default=1) + parser.add_argument("--snapshot", type=str, default=None) + parser.add_argument("--result_file", type=str, default=None) + AppLauncher.add_app_launcher_args(parser) + parser.add_argument("--enable_cameras", action="store_true") + args = parser.parse_args() + enable_cameras = args.enable_cameras + app = AppLauncher(args).app + + import gymnasium as gym + import torch + + from autodata_examples.envs.isaac_lab_arena.franka_rope.environment import FRANKA_ROPE_ARENA_ENV_ID + from autodata_examples.envs.isaac_lab_arena.registration import register_environment_from_cli + + # Use the same backend-selection callback as the public Lab teleop/replay scripts. + register_environment_from_cli() + cfg = gym.spec(FRANKA_ROPE_ARENA_ENV_ID).kwargs["env_cfg_entry_point"] + cfg.seed = 42 + cfg.recorders.dataset_export_dir_path = tempfile.mkdtemp(prefix="rope_env_check_") + env = gym.make(FRANKA_ROPE_ARENA_ENV_ID, cfg=cfg).unwrapped + exit_code = 0 + try: + + def camera_observations(): + # Headless Kit renders on demand in the pinned Lab version. Request + # an actual frame before reading the RTX camera annotators. + if args.presets == "physx": + for visualizer in env.sim.visualizers: + if visualizer.pumps_app_update(): + visualizer.render_rgb_array() + for sensor in env.scene.sensors.values(): + if hasattr(sensor.cfg, "renderer_cfg"): + sensor.update(0.0, force_recompute=True) + return env.observation_manager.compute()["camera_obs"] + + env.reset() + rope = env.scene["object"] + robot = env.scene["robot"] + assert rope.data.nodal_pos_w.torch.shape == (args.num_envs, 549, 3) + expected_module = "isaaclab_physx" + assert type(rope).__module__.startswith(expected_module) + print(f"ROPE backend={args.presets} implementation={type(rope)}", flush=True) + initial_nodes = rope.data.nodal_pos_w.torch.clone() + actions = torch.zeros((args.num_envs, env.action_manager.total_action_dim), device=env.device) + actions[:, -1] = 1.0 + with torch.inference_mode(): + for step in range(100): + obs, _, terminated, _, _ = env.step(actions) + nodes = rope.data.nodal_pos_w.torch + assert torch.isfinite(nodes).all(), f"Non-finite rope nodes at step {step}" + assert not terminated.any(), ( + f"Unexpected task termination at step {step}: " + f"{env.termination_manager.get_active_iterable_terms(int(terminated.nonzero()[0]))}" + ) + assert torch.isfinite(obs["policy"]["object_nodal_pos"]).all() + if step % 20 == 0: + local = nodes - env.scene.env_origins[:, None, :] + print(f"ROPE step={step} min={local.amin((0, 1))} max={local.amax((0, 1))}", flush=True) + assert (rope.data.nodal_pos_w.torch - initial_nodes).abs().max() > 1.0e-4 + + # Gravity must settle the rope on the tabletop, without penetrating or exploding. + local = rope.data.nodal_pos_w.torch - env.scene.env_origins[:, None, :] + assert local[..., 2].min() > -0.01 + assert local[..., 2].max() < 0.15 + assert (local.amax(1) - local.amin(1)).amax() < 1.5 + + if enable_cameras: + images = camera_observations() + assert len(images) == 2 + for image in images.values(): + assert image.shape[:3] == (args.num_envs, 128, 128) + assert image.float().std() > 1.0 + if args.snapshot: + from PIL import Image + + snapshot = next(value for name, value in images.items() if "agentview" in name) + Image.fromarray(snapshot[0, ..., :3].cpu().numpy()).save(args.snapshot) + + # Relative IK and gripper actions must drive the PhysX articulation. + before_joints = robot.data.joint_pos.torch.clone() + before_images = {name: value.clone() for name, value in images.items()} if enable_cameras else {} + actions[:, 2] = 0.005 + actions[:, -1] = -1.0 + for _ in range(12): + obs, *_ = env.step(actions) + after_joints = robot.data.joint_pos.torch + assert torch.isfinite(after_joints).all() + assert (after_joints[:, :7] - before_joints[:, :7]).abs().max() > 0.005 + assert after_joints[:, -2:].mean() < before_joints[:, -2:].mean() - 0.005 + if enable_cameras: + images = camera_observations() + assert any( + (images[name].float() - before.float()).abs().mean() > 0.01 for name, before in before_images.items() + ), "Camera images did not change when the robot moved" + + # Reset one environment without changing the other environments' nodes. + before_reset = rope.data.nodal_pos_w.torch.clone() + env.reset(env_ids=torch.tensor([0], device=env.device)) + after_reset = rope.data.nodal_pos_w.torch + assert (after_reset[0] - before_reset[0]).abs().max() > 0.001 + torch.testing.assert_close(after_reset[1:], before_reset[1:], atol=1.0e-5, rtol=0) + + # Restoring a saved state must preserve nodal coordinates, including env offsets. + state = env.scene.get_state(is_relative=True) + saved_nodes = rope.data.nodal_pos_w.torch.clone() + env.reset() + env.reset_to(state, env_ids=None, is_relative=True) + torch.testing.assert_close(rope.data.nodal_pos_w.torch, saved_nodes, atol=1.0e-5, rtol=0) + actions.zero_() + actions[:, -1] = 1.0 + for _ in range(5): + env.step(actions) + assert torch.isfinite(rope.data.nodal_pos_w.torch).all() + print("ROPE smoke passed", flush=True) + if args.result_file: + from pathlib import Path + + Path(args.result_file).touch() + except BaseException: + import traceback + + traceback.print_exc() + exit_code = 1 + finally: + env.close() + # Kit terminates the process directly; preserve failures across shutdown. + app.close(exit_code=exit_code) + + +if __name__ == "__main__": + main() diff --git a/autodata_tests/e2e/test_softmimicgen_data_generation.py b/autodata_tests/e2e/test_softmimicgen_data_generation.py new file mode 100644 index 0000000..f3b06f8 --- /dev/null +++ b/autodata_tests/e2e/test_softmimicgen_data_generation.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import h5py +import numpy as np +import os +import tempfile + +import pytest + +from autodata_tests.utils.constants import TestPaths +from autodata_tests.utils.subprocess import run_subprocess +from autodata_tests.utils.utils import assert_valid_dataset + +HEADLESS = True +GENERATION_NUM_TRIALS = 1 + + +def _run_franka_rope_softmimicgen(num_envs: int, device: str, presets: str = "physx") -> None: + """Run SoftMimicGen data generation for the Franka rope task.""" + + with tempfile.TemporaryDirectory() as temp_dir: + output_file = os.path.join(temp_dir, "generated.hdf5") + + args = [ + TestPaths.python_path, + TestPaths.generate_dataset_script, + "--env_name", + "Isaac-Rope-Franka-Arena-IK-Rel-v0", + "--presets", + presets, + "--alg", + "softmimicgen", + "--task_descriptor", + os.path.join(TestPaths.tasks_dir, "franka_rope_softmimicgen.yaml"), + "--embodiment", + os.path.join(TestPaths.embodiments_dir, "franka_ik_rel.yaml"), + "--input_file", + os.path.join(TestPaths.test_data_dir, "annotated_dataset_franka_rope_softmimicgen.hdf5"), + "--output_file", + output_file, + "--generation_num_trials", + str(GENERATION_NUM_TRIALS), + "--num_envs", + str(num_envs), + "--device", + device, + "--viz", + "none" if HEADLESS else "kit", + ] + run_subprocess(args) + + assert_valid_dataset(output_file, min_num_demos=GENERATION_NUM_TRIALS) + with h5py.File(output_file, "r") as dataset: + for episode in dataset["data"].values(): + assert episode.attrs["success"] + initial_nodes = episode["initial_state/deformable_object/object/nodal_position"][:] + assert initial_nodes.shape == (1, 549, 3) + assert np.isfinite(initial_nodes).all() + assert np.abs(initial_nodes).max() < 2.0 + nodes = episode["states/deformable_object/object/nodal_position"][:] + assert nodes.shape == (len(episode["actions"]), 549, 3) + assert np.isfinite(nodes).all() + # Parallel environment origins must not leak into recorded positions. + assert np.abs(nodes).max() < 2.0 + + +@pytest.mark.with_subprocess +@pytest.mark.parametrize("presets", ["physx"]) +def test_franka_rope_softmimicgen_data_generation_single_env_cuda(presets): + """SoftMimicGen generation for the Franka rope task on a single env on GPU.""" + _run_franka_rope_softmimicgen(num_envs=1, device="cuda", presets=presets) + + +@pytest.mark.with_subprocess +@pytest.mark.parametrize("presets", ["physx"]) +def test_franka_rope_softmimicgen_data_generation_multi_env_cuda(presets): + """SoftMimicGen generation for the Franka rope task on multiple parallel envs on GPU.""" + _run_franka_rope_softmimicgen(num_envs=10, device="cuda", presets=presets) + + +if __name__ == "__main__": + for presets in ("physx",): + test_franka_rope_softmimicgen_data_generation_single_env_cuda(presets) + test_franka_rope_softmimicgen_data_generation_multi_env_cuda(presets) diff --git a/autodata_tests/interfaces/env/test_franka_rope_arena.py b/autodata_tests/interfaces/env/test_franka_rope_arena.py new file mode 100644 index 0000000..86696e9 --- /dev/null +++ b/autodata_tests/interfaces/env/test_franka_rope_arena.py @@ -0,0 +1,213 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Configuration tests for the AutoData-owned Arena Franka rope environment.""" + +import gymnasium as gym +import sys +from functools import partial +from types import SimpleNamespace + +import pytest +from isaaclab.sim import SimulationCfg +from isaaclab_arena.assets.registries import EnvironmentRegistry +from isaaclab_arena.tasks.task_termination_cfg import TaskTerminationCfg +from isaaclab_arena.utils.physics_backend import PhysicsBackend + +import autodata_examples.envs as env_registration +import autodata_examples.envs.isaac_lab_arena as arena_registration +from autodata_examples.envs.isaac_lab_arena import registration +from autodata_examples.envs.isaac_lab_arena.franka_rope import mdp +from autodata_examples.envs.isaac_lab_arena.franka_rope.embodiment import FrankaRopeIKEmbodiment +from autodata_examples.envs.isaac_lab_arena.franka_rope.environment import ( + FRANKA_ROPE_ARENA_ENV_ID, + FrankaRopeArenaEnvironment, + FrankaRopeArenaEnvironmentCfg, + _configure_franka_rope_env, +) +from autodata_examples.envs.isaac_lab_arena.franka_rope.rope_asset import FrankaRopeAsset +from autodata_examples.envs.isaac_lab_arena.franka_rope.task import FrankaRopeTask + + +def test_arena_environment_factory_has_typed_config(): + assert FrankaRopeArenaEnvironment.name == FRANKA_ROPE_ARENA_ENV_ID + assert FrankaRopeArenaEnvironment._legacy_argparse_cfg_type is FrankaRopeArenaEnvironmentCfg + + +def test_arena_environment_factory_is_registered(): + registry = EnvironmentRegistry() + assert registry.get_component_by_name(FRANKA_ROPE_ARENA_ENV_ID) is FrankaRopeArenaEnvironment + assert registry.get_environment_cfg_type(FrankaRopeArenaEnvironment) is FrankaRopeArenaEnvironmentCfg + + +@pytest.mark.parametrize("presets", [None, "physx"]) +def test_arena_registration_uses_run_configuration_and_preserves_make_kwargs(monkeypatch, presets): + captured = {} + variation_recorder = object() + + class FakeArenaEnvBuilder: + def __init__(self, arena_environment, cfg): + captured["arena_environment"] = arena_environment + captured["cfg"] = cfg + + def build_registered(self): + return FRANKA_ROPE_ARENA_ENV_ID, object(), {"variation_recorder": variation_recorder} + + monkeypatch.delitem(gym.registry, FRANKA_ROPE_ARENA_ENV_ID, raising=False) + monkeypatch.setitem( + sys.modules, + "isaaclab_arena.environments.arena_env_builder", + SimpleNamespace(ArenaEnvBuilder=FakeArenaEnvBuilder), + ) + monkeypatch.setattr( + FrankaRopeArenaEnvironment, + "build", + lambda _self, cfg: SimpleNamespace( + name=FRANKA_ROPE_ARENA_ENV_ID, + embodiment=SimpleNamespace(enable_cameras=cfg.enable_cameras), + ), + ) + + make_kwargs = env_registration.register_environment_for_run( + env_name=FRANKA_ROPE_ARENA_ENV_ID, + enable_cameras=True, + num_envs=8, + device="cuda:1", + seed=23, + presets=presets, + ) + + builder_cfg = captured["cfg"] + assert captured["arena_environment"].name == FRANKA_ROPE_ARENA_ENV_ID + assert captured["arena_environment"].embodiment.enable_cameras is True + assert builder_cfg.num_envs == 8 + assert builder_cfg.env_spacing == 2.5 + assert builder_cfg.seed == 23 + assert builder_cfg.solve_relations is False + assert builder_cfg.device == "cuda:1" + assert builder_cfg.presets == (None if presets is None else PhysicsBackend(presets)) + assert make_kwargs == {FRANKA_ROPE_ARENA_ENV_ID: {"variation_recorder": variation_recorder}} + + +def test_regular_isaac_lab_run_does_not_build_arena(monkeypatch): + arena_registration_called = False + + def register_arena_environment(**_kwargs): + nonlocal arena_registration_called + arena_registration_called = True + return {} + + monkeypatch.setattr(arena_registration, "build_and_register_arena_environment", register_arena_environment) + + make_kwargs = env_registration.register_environment_for_run( + env_name="Isaac-Regular-Lab-Env-v0", + enable_cameras=False, + num_envs=4, + device="cuda:0", + seed=17, + ) + + assert make_kwargs == {} + assert arena_registration_called is False + + +def test_external_callback_routes_arena_task(monkeypatch): + callback_called = False + + def register_arena_environment_from_cli(): + nonlocal callback_called + callback_called = True + return ["hydra.option=value"] + + monkeypatch.setattr(arena_registration, "register_environment_from_cli", register_arena_environment_from_cli) + monkeypatch.setattr(sys, "argv", ["replay_demos.py", "--task", FRANKA_ROPE_ARENA_ENV_ID]) + + remaining_args = env_registration.register_environments() + + assert callback_called is True + assert remaining_args == ["hydra.option=value"] + + +def test_external_callback_skips_arena_for_regular_lab_task(monkeypatch): + callback_called = False + + def register_arena_environment_from_cli(): + nonlocal callback_called + callback_called = True + return [] + + monkeypatch.setattr(arena_registration, "register_environment_from_cli", register_arena_environment_from_cli) + monkeypatch.setattr(sys, "argv", ["replay_demos.py", "--task", "Isaac-Regular-Lab-Env-v0"]) + + assert env_registration.register_environments() == [] + assert callback_called is False + + +def test_external_callback_binds_make_kwargs_without_gym_deepcopy(): + test_env_name = "AutoData-Test-Arena-Callback-v0" + variation_recorder = object() + gym.register(id=test_env_name, entry_point=lambda **_kwargs: None) + try: + registration._bind_gym_make_kwargs( # noqa: SLF001 + test_env_name, + {"variation_recorder": variation_recorder}, + ) + + env_spec = gym.spec(test_env_name) + assert isinstance(env_spec.entry_point, partial) + assert env_spec.entry_point.keywords["variation_recorder"] is variation_recorder + assert "variation_recorder" not in env_spec.kwargs + finally: + del gym.registry[test_env_name] + + +def test_rope_asset_uses_deformable_scene_key(): + rope = FrankaRopeAsset() + name, object_cfg = rope.get_object_cfg() + assert name == "object" + assert object_cfg.prim_path == "{ENV_REGEX_NS}/Object" + assert object_cfg.spawn.usd_path.endswith("/isaac_lab_arena/franka_rope/assets/Rope.usd") + + +def test_rope_mdp_terms_are_owned_by_arena_package(): + arena_module_prefix = "autodata_examples.envs.isaac_lab_arena.franka_rope.mdp" + assert mdp.object_nodal_pos.__module__.startswith(arena_module_prefix) + assert mdp.reset_rope_nodal_state.__module__.startswith(arena_module_prefix) + assert mdp.rope_ends_close_tracked.__module__.startswith(arena_module_prefix) + + +def test_rope_task_exposes_annotation_and_success_terms(): + task = FrankaRopeTask(episode_length_s=15.0) + assert task.get_observation_cfg().subtask_terms.grasp is not None + terminations = task.get_termination_cfg() + assert isinstance(terminations, TaskTerminationCfg) + assert terminations.timeout_s == 15.0 + assert len(terminations.success) == 1 + assert terminations.success[0].predicate_sequence == [mdp.rope_ends_close_tracked] + assert terminations.failures["object_dropping"].func is mdp.rope_below_minimum + assert task.get_events_cfg().reset_object_position is not None + + +def test_rope_environment_configures_lab_timing(): + env_cfg = SimpleNamespace(sim=SimulationCfg(), scene=SimpleNamespace(replicate_physics=True)) + + assert _configure_franka_rope_env(env_cfg) is env_cfg + assert env_cfg.decimation * env_cfg.sim.dt == 0.05 + assert env_cfg.sim.render_interval == 2 + assert env_cfg.num_rerenders_on_reset == 1 + assert env_cfg.scene.replicate_physics is False + assert set(env_cfg.teleop_devices.devices) == {"keyboard", "spacemouse"} + + +def test_rope_embodiment_preserves_arena_observation_names(): + embodiment = FrankaRopeIKEmbodiment(enable_cameras=False) + assert embodiment.name == "franka_rope_ik" + assert embodiment.observation_config.policy.eef_pos is not None + assert embodiment.observation_config.policy.eef_quat is not None + assert embodiment.observation_config.policy.object_nodal_pos is not None + assert embodiment.event_config.randomize_franka_joint_state.params["position_range"] == (-0.02, 0.02) + assert embodiment.reward_config is None + for camera_name in embodiment.camera_config.camera_names(): + camera = getattr(embodiment.camera_config, camera_name) + assert camera.renderer_cfg.renderer_type == "isaac_rtx" + assert camera.renderer_cfg.global_settings.antialiasing_mode == "DLSS" diff --git a/autodata_tests/test_data/annotated_dataset_franka_rope_softmimicgen.hdf5 b/autodata_tests/test_data/annotated_dataset_franka_rope_softmimicgen.hdf5 new file mode 100644 index 0000000..2c25c2e --- /dev/null +++ b/autodata_tests/test_data/annotated_dataset_franka_rope_softmimicgen.hdf5 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fa2b5db4752242eb91cec522053ad1a040741764dcb71ebab6f3abe80e4992c +size 30514205 diff --git a/autodata_tests/utils/test_ci_cache.py b/autodata_tests/utils/test_ci_cache.py new file mode 100644 index 0000000..fe3a21f --- /dev/null +++ b/autodata_tests/utils/test_ci_cache.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""CI must mount a writable home cache for Arena's composed robot assets.""" + +import json +import os +import pwd +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from autodata_tests.utils.constants import TestPaths + + +@pytest.fixture +def ci_launcher(tmp_path): + """Exercise the real shell entry points while replacing only Docker and X11.""" + root = tmp_path / "checkout with spaces" + for relative in ("scripts/ci/run_tests.sh", "docker/run_docker.sh"): + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(Path(TestPaths.repo_root) / relative, target) + target.chmod(0o755) + + commands = tmp_path / "bin" + commands.mkdir() + docker = commands / "docker" + docker.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "args = sys.argv[1:]\n" + "if args[:2] == ['images', '-q']:\n" + " print('test-image')\n" + "elif args[:2] == ['container', 'inspect']:\n" + " print('false')\n" + "elif args[:1] == ['run']:\n" + " with open(os.environ['TEST_DOCKER_LOG'], 'w') as output:\n" + " json.dump(args, output)\n" + "elif args[:2] != ['ps', '-a']:\n" + " raise SystemExit(f'Unexpected Docker command: {args}')\n" + ) + docker.chmod(0o755) + xhost = commands / "xhost" + xhost.write_text("#!/bin/sh\nexit 0\n") + xhost.chmod(0o755) + runner_temp = tmp_path / "runner temp" + runner_temp.mkdir() + log = tmp_path / "docker.json" + environment = os.environ.copy() + environment.pop("AUTODATA_DOCKER_CACHE_DIR", None) + environment.update( + PATH=f"{commands}:{environment['PATH']}", + RUNNER_TEMP=str(runner_temp), + TEST_DOCKER_LOG=str(log), + FORCE_REBUILD="false", + AUTODATA_RESULTS_DIR="", + ) + + def run(**variables): + result = subprocess.run( + ["bash", str(root / "scripts/ci/run_tests.sh")], + env=environment | variables, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + args = json.loads(log.read_text()) + target = f"/home/{pwd.getpwuid(os.getuid()).pw_name}/.cache" + sources = [args[i + 1].split(":")[0] for i, arg in enumerate(args[:-1]) if arg == "-v"] + mounts = [source for source in sources if f"{source}:{target}" in args] + assert len(mounts) == 1 + source = Path(mounts[0]) + assert source.is_dir() + assert source.stat().st_uid == os.getuid() + (source / "arena-cache-write-check").write_text("writable") + assert "/isaac-sim/python.sh" in args and "pytest" in args + return source + + return run, runner_temp + + +def test_ci_cache_is_fresh_and_owned_by_runner(ci_launcher): + run, runner_temp = ci_launcher + first, second = run(), run() + assert first.parent == second.parent == runner_temp + assert first != second + + +def test_ci_cache_respects_explicit_mount(ci_launcher, tmp_path): + run, _ = ci_launcher + selected = tmp_path / "selected cache" + assert run(AUTODATA_DOCKER_CACHE_DIR=str(selected)) == selected diff --git a/docker/run_docker.sh b/docker/run_docker.sh index f623012..3946bf4 100755 --- a/docker/run_docker.sh +++ b/docker/run_docker.sh @@ -43,6 +43,8 @@ ISAACLAB_PATH="${WORKDIR}/submodules/IsaacLab-Arena/submodules/IsaacLab" # Optional host datasets directory mounted at /datasets (in addition to the repo's own datasets/). DATASETS_HOST_MOUNT_DIRECTORY="$HOME/datasets" +# CI can provide a writable cache without changing the shared host cache's ownership. +CACHE_HOST_MOUNT_DIRECTORY="${AUTODATA_DOCKER_CACHE_DIR:-$HOME/.cache}" FORCE_REBUILD=false NO_CACHE="" @@ -133,6 +135,9 @@ add_volume_if_it_exists() { [ -d "$1" ] && echo "-v $1:$2" } +# Create it as the host user before Docker can create a root-owned bind source. +mkdir -p "${CACHE_HOST_MOUNT_DIRECTORY}" + # Forward the host SSH agent if available (so git over SSH works in-container). SSH_DOCKER_ARGS=() if [ -n "${SSH_AUTH_SOCK:-}" ] && [ -S "$SSH_AUTH_SOCK" ]; then @@ -151,8 +156,8 @@ DOCKER_RUN_ARGS=( # Live-mount the repo: host edits are reflected in the container's editable installs. "-v" "${REPO_ROOT}:${WORKDIR}" $(add_volume_if_it_exists "$DATASETS_HOST_MOUNT_DIRECTORY" /datasets) - # Share the host Kit/pip cache to speed up shader warmup and reinstalls across runs. - "-v" "$HOME/.cache:/home/$(id -un)/.cache" + # Share the selected cache, including Arena's hard-coded ~/.cache location. + "-v" "${CACHE_HOST_MOUNT_DIRECTORY}:/home/$(id -un)/.cache" # X11 passthrough so "--viz kit" can open a window. "-v" "/tmp/.X11-unix:/tmp/.X11-unix:rw" "${SSH_DOCKER_ARGS[@]}" diff --git a/scripts/annotate_demos.py b/scripts/annotate_demos.py index ea522d6..83930ec 100644 --- a/scripts/annotate_demos.py +++ b/scripts/annotate_demos.py @@ -45,6 +45,9 @@ default=None, help="Environment name. Overrides the env name recorded in the source dataset.", ) +parser.add_argument( + "--presets", choices=("physx",), default=None, help="Physics backend preset for Arena environments." +) parser.add_argument( "--task_descriptor", type=str, @@ -102,7 +105,9 @@ import contextlib # noqa: E402 import gymnasium as gym # noqa: E402 import math # noqa: E402 +import numpy as np # noqa: E402 import os # noqa: E402 +import random # noqa: E402 import sys # noqa: E402 import torch # noqa: E402 import traceback # noqa: E402 @@ -116,9 +121,11 @@ from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler # noqa: E402 from autodata_core.pool import DataGenInfoPool # noqa: E402 +from autodata_examples.envs import register_environment_for_run # noqa: E402 from autodata_interfaces.datastream import Datastream # noqa: E402 from autodata_interfaces.embodiments import embodiment_adapter_from_yaml # noqa: E402 from autodata_interfaces.env import get_env_name_from_dataset, setup_env_config, setup_output_paths # noqa: E402 +from autodata_interfaces.env.recorders import PreStepCameraObservationsRecorder # noqa: E402 from autodata_interfaces.env.registration import register_external_environment # noqa: E402 from autodata_interfaces.tasks.task_descriptor import TaskDescriptor # noqa: E402 @@ -236,6 +243,19 @@ def main() -> int: task_descriptor = TaskDescriptor.from_yaml(args_cli.task_descriptor) generation_policy = task_descriptor.get_generation_policy() + random.seed(generation_policy.seed) + np.random.seed(generation_policy.seed) + torch.manual_seed(generation_policy.seed) + + env_make_kwargs = register_environment_for_run( + env_name=env_name, + enable_cameras=args_cli.record_images, + num_envs=1, + device=args_cli.device, + seed=generation_policy.seed, + presets=args_cli.presets, + ) + # Start signals are required only by SkillGen. annotate_start_signals = generation_policy.use_skillgen @@ -268,10 +288,14 @@ def main() -> int: recorder_cfg=AnnotationRecorderManagerCfg(), enable_cameras=args_cli.record_images, ) + if args_cli.record_images: + env_cfg.recorders.record_pre_step_camera_observations = RecorderTermCfg( + class_type=PreStepCameraObservationsRecorder + ) # Only export episodes we explicitly mark successful (i.e. fully annotated). env_cfg.recorders.dataset_export_mode = DatasetExportMode.EXPORT_SUCCEEDED_ONLY - env = gym.make(env_name, cfg=env_cfg).unwrapped + env = gym.make(env_name, cfg=env_cfg, **env_make_kwargs.get(env_name, {})).unwrapped try: # Create the Datastream embodiment_adapter = embodiment_adapter_from_yaml(args_cli.embodiment) diff --git a/scripts/ci/run_tests.sh b/scripts/ci/run_tests.sh index f9701ae..2b91fdc 100755 --- a/scripts/ci/run_tests.sh +++ b/scripts/ci/run_tests.sh @@ -30,13 +30,14 @@ FORCE_REBUILD="${FORCE_REBUILD:-false}" # run_docker.sh gives the container a fresh environment, so this is forwarded # explicitly on the in-container command line rather than exported on the host. SUBPROCESS_TIMEOUT="${AUTODATA_SUBPROCESS_TIMEOUT:-1200}" -# Container-local cache directory. run_docker.sh bind-mounts the host's -# $HOME/.cache into the container; on the CI runner that path is root-owned (or -# auto-created as root), so the recreated non-root container user cannot write -# it -- warp fails to create ~/.cache/warp with a PermissionError. Pointing the -# cache env vars at a writable, container-local /tmp path sidesteps the mounted -# host cache entirely. The tools create these dirs themselves (makedirs). +# Writable container-local cache for Warp and tools that honor XDG_CACHE_HOME. +# Arena's hard-coded home cache is handled separately below. CONTAINER_CACHE_DIR="${CONTAINER_CACHE_DIR:-/tmp/autodata_ci_cache}" +# Arena's composed USD cache ignores XDG_CACHE_HOME and writes under ~/.cache. +# Mount a fresh directory owned by the runner instead of its shared host cache. +# RUNNER_TEMP is cleaned by Actions; local invocations fall back to TMPDIR or /tmp. +AUTODATA_DOCKER_CACHE_DIR="${AUTODATA_DOCKER_CACHE_DIR:-$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/autodata-ci-cache.XXXXXX")}" +export AUTODATA_DOCKER_CACHE_DIR # Optional JUnit report dir (repo-relative). The repo is bind-mounted, so a report # written here lands on the host for CI to collect. RESULTS_DIR="${AUTODATA_RESULTS_DIR-}" @@ -56,8 +57,8 @@ fi # container (run_docker.sh passes trailing args through verbatim, and the values # are inherited by the data-generation subprocess pytest spawns). Call # /isaac-sim/python.sh -m pytest explicitly so it does not depend on the -# in-container `pytest` alias expanding. XDG_CACHE_HOME/WARP_CACHE_PATH steer all -# caches away from the mounted host $HOME/.cache (see CONTAINER_CACHE_DIR above). +# in-container `pytest` alias expanding. XDG_CACHE_HOME/WARP_CACHE_PATH keep those +# caches container-local; Arena uses the writable bind mount configured above. PYTEST_ARGS=( env "AUTODATA_SUBPROCESS_TIMEOUT=${SUBPROCESS_TIMEOUT}" diff --git a/scripts/generate_dataset.py b/scripts/generate_dataset.py index 2cbbbd2..812b418 100755 --- a/scripts/generate_dataset.py +++ b/scripts/generate_dataset.py @@ -87,6 +87,9 @@ ) parser.add_argument("--generation_num_trials", type=int, default=None, help="Number of demos to generate.") parser.add_argument("--num_envs", type=int, default=1, help="Number of parallel environments.") +parser.add_argument( + "--presets", choices=("physx",), default=None, help="Physics backend preset for Arena environments." +) parser.add_argument("--input_file", type=str, required=True, help="Source dataset HDF5 file.") parser.add_argument( "--output_file", @@ -146,6 +149,7 @@ from autodata_core import DataGenerator, get_algorithm # noqa: E402 from autodata_core.algorithms import REGISTERED_ALGORITHMS # noqa: E402 +from autodata_examples.envs import register_environment_for_run # noqa: E402 from autodata_interfaces.datastream import Datastream # noqa: E402 from autodata_interfaces.env import ( # noqa: E402 EnvironmentProfile, @@ -311,6 +315,19 @@ def main() -> None: if args_cli.generation_num_trials is not None: generation_policy_params.num_trials = args_cli.generation_num_trials + random.seed(generation_policy_params.seed) + np.random.seed(generation_policy_params.seed) + torch.manual_seed(generation_policy_params.seed) + + env_make_kwargs = register_environment_for_run( + env_name=env_name, + enable_cameras=args_cli.record_images, + num_envs=args_cli.num_envs, + device=args_cli.device, + seed=generation_policy_params.seed, + presets=args_cli.presets, + ) + # The algorithm's start-signal expectation is a class attribute, so we resolve it before # instantiating (the Datastream needs it, and SkillGen can only be instantiated once the # planners exist, which in turn need the Datastream). @@ -333,11 +350,7 @@ def main() -> None: enable_cameras=args_cli.record_images, ) - env = gym.make(env_name, cfg=env_cfg).unwrapped - - random.seed(generation_policy_params.seed) - np.random.seed(generation_policy_params.seed) - torch.manual_seed(generation_policy_params.seed) + env = gym.make(env_name, cfg=env_cfg, **env_make_kwargs.get(env_name, {})).unwrapped env.reset()