From edd51227e0ad539a706849d85d1a8ecf18e978d2 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Wed, 22 Jul 2026 13:05:41 -0700 Subject: [PATCH 01/15] test franka rope env --- isaac_autodata_examples/envs/__init__.py | 6 + .../envs/isaac_lab/__init__.py | 22 ++ .../envs/isaac_lab/franka_rope/__init__.py | 22 ++ .../isaac_lab/franka_rope/assets/Rope.usd | Bin 0 -> 154413 bytes .../franka_rope/franka_rope_env_cfg.py | 289 ++++++++++++++ .../isaac_lab/franka_rope/mdp/__init__.py | 22 ++ .../envs/isaac_lab/franka_rope/mdp/events.py | 361 ++++++++++++++++++ .../isaac_lab/franka_rope/mdp/observations.py | 96 +++++ .../isaac_lab/franka_rope/mdp/terminations.py | 81 ++++ 9 files changed, 899 insertions(+) create mode 100644 isaac_autodata_examples/envs/__init__.py create mode 100644 isaac_autodata_examples/envs/isaac_lab/__init__.py create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/assets/Rope.usd create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py diff --git a/isaac_autodata_examples/envs/__init__.py b/isaac_autodata_examples/envs/__init__.py new file mode 100644 index 0000000..4396f64 --- /dev/null +++ b/isaac_autodata_examples/envs/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Example simulation environments used by Isaac AutoData workflows.""" diff --git a/isaac_autodata_examples/envs/isaac_lab/__init__.py b/isaac_autodata_examples/envs/isaac_lab/__init__.py new file mode 100644 index 0000000..944ed7b --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Isaac Lab environment registrations supplied by Isaac AutoData examples.""" + + +def register_environments() -> list[str]: + """Register AutoData's example Isaac Lab environments. + + This callback is intended for Isaac Lab tools that expose ``--external_callback``. + The returned empty list indicates that the registration callback consumes no extra + command-line arguments. + + Returns: + An empty list of callback-specific command-line arguments. + """ + + from . import franka_rope # noqa: F401 + + return [] diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py new file mode 100644 index 0000000..a0b7c72 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Gym registration for the Franka rope manipulation environment.""" + +import gymnasium as gym + +FRANKA_ROPE_ENV_ID = "Isaac-Rope-Franka-IK-Rel-v0" + +if FRANKA_ROPE_ENV_ID not in gym.registry: + gym.register( + id=FRANKA_ROPE_ENV_ID, + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": ( + "isaac_autodata_examples.envs.isaac_lab.franka_rope.franka_rope_env_cfg:FrankaRopeEnvCfg" + ), + }, + disable_env_checker=True, + ) diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/assets/Rope.usd b/isaac_autodata_examples/envs/isaac_lab/franka_rope/assets/Rope.usd new file mode 100644 index 0000000000000000000000000000000000000000..34c17d24b3f4ac246ec28f5a772e24c02ff1f5b1 GIT binary patch literal 154413 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 None: + self.enable_corruption = False + self.concatenate_terms = False + + @configclass + class SubtaskCfg(ObsGroup): + """Signals that will be consumed by the later annotation workflow.""" + + 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 + + policy: PolicyCfg = PolicyCfg() + subtask_terms: SubtaskCfg = SubtaskCfg() + + +@configclass +class EventCfg: + """Scene, robot, and deformable reset events.""" + + reset_all = EventTerm(func=base_mdp.reset_scene_to_default, mode="reset") + + randomize_franka_joint_state = EventTerm( + func=base_mdp.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.*"]), + }, + ) + + 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"), + }, + ) + + +@configclass +class TerminationsCfg: + """Episode completion and failure conditions.""" + + time_out = DoneTerm(func=base_mdp.time_out, time_out=True) + object_dropping = DoneTerm( + func=mdp.rope_below_minimum, + params={"minimum_height": -0.05, "object_cfg": SceneEntityCfg("object")}, + ) + success = DoneTerm(func=mdp.rope_ends_close_tracked) + + +@configclass +class FrankaRopeEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for teleoperated Franka rope manipulation.""" + + scene: FrankaRopeSceneCfg = FrankaRopeSceneCfg(num_envs=1, env_spacing=2.5, replicate_physics=False) + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + events: EventCfg = EventCfg() + terminations: TerminationsCfg = TerminationsCfg() + + commands = None + rewards = None + curriculum = None + + teleop_devices: DevicesCfg = DevicesCfg( + devices={ + "keyboard": Se3KeyboardCfg(pos_sensitivity=0.05, rot_sensitivity=0.2), + "spacemouse": Se3SpaceMouseCfg(pos_sensitivity=0.2, rot_sensitivity=0.5), + } + ) + + def __post_init__(self) -> None: + """Configure simulation timing, rendering, and the default viewer.""" + + self.decimation = 5 + self.episode_length_s = 10.0 + self.seed = 7 + self.sim.dt = 0.01 + self.sim.render_interval = 2 + self.sim.render.antialiasing_mode = "DLSS" + self.num_rerenders_on_reset = 1 + self.viewer.eye = (1.0, 1.0, 0.5) + self.viewer.lookat = (0.0, 0.0, 0.0) diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py new file mode 100644 index 0000000..63c56b1 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Task-specific MDP terms for Franka rope manipulation.""" + +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/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py new file mode 100644 index 0000000..b469d9f --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py @@ -0,0 +1,361 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Reset events for Franka rope manipulation.""" + +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. +_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, +) + + +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/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py new file mode 100644 index 0000000..d52bb4f --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Observation terms for Franka rope manipulation.""" + +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/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py new file mode 100644 index 0000000..a573d65 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py @@ -0,0 +1,81 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Termination terms for Franka rope manipulation.""" + +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 From dd223342f42fae1e23d30f9eb3b0ae618f43b54d Mon Sep 17 00:00:00 2001 From: Peter Du Date: Thu, 23 Jul 2026 12:35:12 -0700 Subject: [PATCH 02/15] data gen test --- isaac_autodata_core/__init__.py | 2 + isaac_autodata_core/algorithms.py | 139 ++++++++++++++- isaac_autodata_core/data_generator.py | 34 +++- isaac_autodata_core/datagen_info.py | 6 + isaac_autodata_core/deformable_transforms.py | 168 ++++++++++++++++++ isaac_autodata_core/pool.py | 4 +- isaac_autodata_core/selection_strategy.py | 62 ++++++- .../embodiments/franka_rope_ik_rel.yaml | 21 +++ .../envs/isaac_lab/franka_rope/__init__.py | 2 +- .../isaac_lab/franka_rope/franka_rope_env.py | 36 ++++ .../franka_rope/franka_rope_env_cfg.py | 3 + .../tasks/franka_rope_softmimicgen.yaml | 60 +++++++ .../datastream/datastream.py | 21 +++ .../tasks/subtask_spec.py | 18 ++ isaac_autodata_tests/core/test_algorithms.py | 18 +- .../core/test_datagen_info.py | 11 ++ .../core/test_deformable_transforms.py | 60 +++++++ isaac_autodata_tests/core/test_pool.py | 43 +++++ .../core/test_selection_strategy.py | 22 +++ .../interfaces/datastream/test_datastream.py | 23 +++ isaac_autodata_tests/interfaces/mocks.py | 4 + .../interfaces/tasks/test_subtask_spec.py | 11 ++ .../interfaces/tasks/test_task_descriptor.py | 20 ++- scripts/annotate_demos.py | 4 + scripts/generate_dataset.py | 8 +- 25 files changed, 784 insertions(+), 16 deletions(-) create mode 100644 isaac_autodata_core/deformable_transforms.py create mode 100644 isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml create mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env.py create mode 100644 isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml create mode 100644 isaac_autodata_tests/core/test_deformable_transforms.py diff --git a/isaac_autodata_core/__init__.py b/isaac_autodata_core/__init__.py index cba0cad..ac8db73 100644 --- a/isaac_autodata_core/__init__.py +++ b/isaac_autodata_core/__init__.py @@ -18,6 +18,7 @@ GenerationAlgorithm, MimicGen, SkillGen, + SoftMimicGen, get_algorithm, iter_algorithms, ) @@ -35,6 +36,7 @@ "GenerationResult", "MimicGen", "MultiWaypoint", + "SoftMimicGen", "SkillGen", "Waypoint", "WaypointSequence", diff --git a/isaac_autodata_core/algorithms.py b/isaac_autodata_core/algorithms.py index 7df7da6..0e0b879 100644 --- a/isaac_autodata_core/algorithms.py +++ b/isaac_autodata_core/algorithms.py @@ -9,14 +9,15 @@ behavior diverges between Mimic, DexMimicGen, SkillGen, etc. Subclasses self-register at import time via :class:`_AlgorithmMeta`. -The single behavioral hook is :meth:`GenerationAlgorithm.plan_subtask_trajectory`, called by -``DataGenerator`` every time an EEF needs a new executable trajectory. The default implementation -generates a subtask trajectory and merges an interpolation segment (the Mimic / DexMimicGen path). -SkillGen overrides this to insert a motion-planned transit ahead of the merge. +The main behavioral hook is :meth:`GenerationAlgorithm.plan_subtask_trajectory`, called by +``DataGenerator`` every time an EEF needs a new executable trajectory. Object-state adaptation is +routed through :meth:`GenerationAlgorithm.transform_source_eef_poses`; rigid algorithms use the +default object-pose transform while SoftMimicGen overrides it with nodal TPS registration. """ from __future__ import annotations +import torch from collections.abc import Iterator from typing import TYPE_CHECKING, Any @@ -24,6 +25,7 @@ from isaac_autodata_core.data_generator import DataGenerator, _EEFGenerationState from isaac_autodata_core.waypoint import Waypoint from isaac_autodata_interfaces.datastream.datastream import Datastream + from isaac_autodata_interfaces.tasks.subtask_spec import Subtask REGISTERED_ALGORITHMS: dict[str, type[GenerationAlgorithm]] = {} @@ -73,6 +75,45 @@ def validate_setup(self, datastream: Datastream) -> None: or ``datastream.get_env()`` (escape hatch) to enforce algorithm-specific invariants. """ + def is_deformable_subtask(self, subtask: Subtask) -> bool: + """Return whether ``subtask`` uses deformable nodal state instead of a rigid pose.""" + + return False + + def transform_source_eef_poses( + self, + *, + data_generator: DataGenerator, + eef_name: str, + subtask_ind: int, + subtask_object_name: str | None, + subtask_object_pose: torch.Tensor | None, + src_subtask_object_pose: torch.Tensor | None, + subtask_object_nodal_positions: torch.Tensor | None, + src_subtask_object_nodal_positions: torch.Tensor | None, + src_eef_poses: torch.Tensor, + use_delta_transform: torch.Tensor | None, + coord_transform_scheme: Any, + runtime_subtask_constraints_dict: dict, + ) -> torch.Tensor: + """Adapt source EEF poses to the current subtask object state. + + The default implementation performs the existing rigid-object transform. Algorithms using + another object representation override this method. + """ + + return data_generator._apply_subtask_transform( + eef_name=eef_name, + subtask_ind=subtask_ind, + subtask_object_name=subtask_object_name, + subtask_object_pose=subtask_object_pose, + src_subtask_object_pose=src_subtask_object_pose, + src_eef_poses=src_eef_poses, + use_delta_transform=use_delta_transform, + coord_transform_scheme=coord_transform_scheme, + runtime_subtask_constraints_dict=runtime_subtask_constraints_dict, + ) + def plan_subtask_trajectory( self, *, @@ -167,6 +208,96 @@ class DexMimicGen(GenerationAlgorithm): supports_coordination = True +class SoftMimicGen(GenerationAlgorithm): + """MimicGen for rigid and deformable reference objects. + + Deformable subtasks select source segments using nodal state and warp their EEF trajectories + with a thin-plate-spline transform. Rigid subtasks retain the normal MimicGen transform. + """ + + name = "softmimicgen" + expected_eef_count = (1, 2) + requires_motion_planner = False + uses_subtask_start_signals = False + supports_coordination = False + + def is_deformable_subtask(self, subtask: Subtask) -> bool: + return bool(getattr(subtask.algo_params, "object_soft", False)) + + def validate_setup(self, datastream: Datastream) -> None: + live_nodal_positions = datastream.get_object_nodal_positions() + for eef_name in datastream.get_eef_names(): + for subtask_index, subtask in enumerate(datastream.get_subtasks(eef_name)): + if not self.is_deformable_subtask(subtask): + continue + assert ( + subtask.object_ref + ), f"SoftMimicGen deformable subtask {eef_name}[{subtask_index}] requires object_ref" + assert subtask.object_ref in live_nodal_positions, ( + f"Deformable object {subtask.object_ref!r} is not present in the live scene; " + f"available: {sorted(live_nodal_positions)}" + ) + live_node_count = live_nodal_positions[subtask.object_ref].shape[-2] + for demo_index, datagen_info in enumerate(datastream.source_pool.datagen_infos): + assert ( + datagen_info.object_nodal_positions is not None + ), f"Source demo {demo_index} lacks object_nodal_position annotations" + assert ( + subtask.object_ref in datagen_info.object_nodal_positions + ), f"Source demo {demo_index} lacks nodal positions for {subtask.object_ref!r}" + source_node_count = datagen_info.object_nodal_positions[subtask.object_ref].shape[-2] + assert source_node_count == live_node_count, ( + f"Source demo {demo_index} has {source_node_count} nodes for " + f"{subtask.object_ref!r}, live object has {live_node_count}" + ) + + def transform_source_eef_poses( + self, + *, + data_generator: DataGenerator, + eef_name: str, + subtask_ind: int, + subtask_object_name: str | None, + subtask_object_pose: torch.Tensor | None, + src_subtask_object_pose: torch.Tensor | None, + subtask_object_nodal_positions: torch.Tensor | None, + src_subtask_object_nodal_positions: torch.Tensor | None, + src_eef_poses: torch.Tensor, + use_delta_transform: torch.Tensor | None, + coord_transform_scheme: Any, + runtime_subtask_constraints_dict: dict, + ) -> torch.Tensor: + subtask = data_generator.datastream.get_subtask(eef_name, subtask_ind) + if not self.is_deformable_subtask(subtask): + return super().transform_source_eef_poses( + data_generator=data_generator, + eef_name=eef_name, + subtask_ind=subtask_ind, + subtask_object_name=subtask_object_name, + subtask_object_pose=subtask_object_pose, + src_subtask_object_pose=src_subtask_object_pose, + subtask_object_nodal_positions=subtask_object_nodal_positions, + src_subtask_object_nodal_positions=src_subtask_object_nodal_positions, + src_eef_poses=src_eef_poses, + use_delta_transform=use_delta_transform, + coord_transform_scheme=coord_transform_scheme, + runtime_subtask_constraints_dict=runtime_subtask_constraints_dict, + ) + + assert subtask_object_nodal_positions is not None, "current deformable nodal state is missing" + assert src_subtask_object_nodal_positions is not None, "source deformable nodal state is missing" + from isaac_autodata_core.deformable_transforms import transform_source_data_segment_using_nodal_registration + + return transform_source_data_segment_using_nodal_registration( + src_eef_poses=src_eef_poses, + src_obj_nodal_pos=src_subtask_object_nodal_positions, + tgt_obj_nodal_pos=subtask_object_nodal_positions, + use_rotation_transform=subtask.algo_params.use_rotation_transform, + bend_coef=subtask.algo_params.bend_coef, + rot_coef=subtask.algo_params.rot_coef, + ) + + class SkillGen(GenerationAlgorithm): """Single-arm SkillGen — motion-planned transit followed by skill replay per subtask. diff --git a/isaac_autodata_core/data_generator.py b/isaac_autodata_core/data_generator.py index 4af5983..2d8ec01 100644 --- a/isaac_autodata_core/data_generator.py +++ b/isaac_autodata_core/data_generator.py @@ -214,8 +214,10 @@ def select_source_demo( eef_name: str, eef_pose: torch.Tensor, object_pose: torch.Tensor | None, + object_nodal_positions: torch.Tensor | None, src_demo_current_subtask_boundaries: np.ndarray, subtask_object_name: str | None, + subtask_object_soft: bool, selection_strategy_name: str, selection_strategy_kwargs: dict | None = None, ) -> int: @@ -233,7 +235,12 @@ def select_source_demo( eef_pose=src_ep.eef_pose[eef_name][start_ind:end_ind], object_poses=( {subtask_object_name: src_ep.object_poses[subtask_object_name][start_ind:end_ind]} - if subtask_object_name is not None + if subtask_object_name is not None and not subtask_object_soft + else None + ), + object_nodal_positions=( + {subtask_object_name: src_ep.object_nodal_positions[subtask_object_name][start_ind:end_ind]} + if subtask_object_name is not None and subtask_object_soft else None ), subtask_term_signals=None, @@ -248,6 +255,7 @@ def select_source_demo( eef_pose=eef_pose, object_pose=object_pose, src_subtask_datagen_infos=src_subtask_datagen_infos, + object_nodal_positions=object_nodal_positions, **kwargs, ) @@ -270,9 +278,15 @@ def generate_eef_subtask_trajectory( # Subtask.object_ref is empty string when no object is involved; normalize to None so the # rest of the pipeline can keep using the upstream `is not None` convention. subtask_object_name = subtasks[subtask_ind].object_ref or None + subtask_object_soft = self.algorithm.is_deformable_subtask(subtasks[subtask_ind]) subtask_object_pose = ( self.datastream.get_object_poses(env_ids=[env_id])[subtask_object_name][0] - if subtask_object_name is not None + if subtask_object_name is not None and not subtask_object_soft + else None + ) + subtask_object_nodal_positions = ( + self.datastream.get_object_nodal_positions(env_ids=[env_id])[subtask_object_name][0] + if subtask_object_name is not None and subtask_object_soft else None ) @@ -296,8 +310,10 @@ def generate_eef_subtask_trajectory( eef_name=eef_name, eef_pose=self.datastream.get_robot_eef_pose(env_ids=[env_id], eef_name=eef_name)[0], object_pose=subtask_object_pose, + object_nodal_positions=subtask_object_nodal_positions, src_demo_current_subtask_boundaries=all_randomized_subtask_boundaries[eef_name][:, subtask_ind], subtask_object_name=subtask_object_name, + subtask_object_soft=subtask_object_soft, selection_strategy_name=subtasks[subtask_ind].selection_strategy, selection_strategy_kwargs=subtasks[subtask_ind].selection_strategy_kwargs, ) @@ -330,7 +346,14 @@ def generate_eef_subtask_trajectory( if channel_name == eef_name or channel_name not in eef_names } src_subtask_object_pose = ( - src_ep.object_poses[subtask_object_name][selected_boundary[0]] if subtask_object_name is not None else None + src_ep.object_poses[subtask_object_name][selected_boundary[0]] + if subtask_object_name is not None and not subtask_object_soft + else None + ) + src_subtask_object_nodal_positions = ( + src_ep.object_nodal_positions[subtask_object_name][selected_boundary[0]] + if subtask_object_name is not None and subtask_object_soft + else None ) if is_first_subtask or policy.transform_first_robot_pose: @@ -348,12 +371,15 @@ def generate_eef_subtask_trajectory( for channel_name, channel_tensor in src_subtask_passthrough_actions.items() } - transformed_eef_poses = self._apply_subtask_transform( + transformed_eef_poses = self.algorithm.transform_source_eef_poses( + data_generator=self, eef_name=eef_name, subtask_ind=subtask_ind, subtask_object_name=subtask_object_name, subtask_object_pose=subtask_object_pose, src_subtask_object_pose=src_subtask_object_pose, + subtask_object_nodal_positions=subtask_object_nodal_positions, + src_subtask_object_nodal_positions=src_subtask_object_nodal_positions, src_eef_poses=src_eef_poses, use_delta_transform=use_delta_transform, coord_transform_scheme=coord_transform_scheme, diff --git a/isaac_autodata_core/datagen_info.py b/isaac_autodata_core/datagen_info.py index c2939ea..0b796ce 100644 --- a/isaac_autodata_core/datagen_info.py +++ b/isaac_autodata_core/datagen_info.py @@ -24,6 +24,8 @@ class DatagenInfo: Attributes: eef_pose: ``{eef_name: [T, 4, 4]}`` recorded EEF poses [m, rad]. object_poses: ``{object_name: [T, 4, 4]}`` recorded object poses [m, rad]. + object_nodal_positions: ``{object_name: [T, N, 3]}`` recorded deformable-object + nodal positions [m]. subtask_term_signals: ``{subtask_name: [T]}`` binary completion flag per step. subtask_start_signals: ``{subtask_name: [T]}`` binary start flag per step; required by SkillGen. target_eef_pose: ``{eef_name: [T, 4, 4]}`` controller target poses [m, rad]. @@ -36,6 +38,7 @@ def __init__( self, eef_pose: dict[str, torch.Tensor] | None = None, object_poses: dict[str, torch.Tensor] | None = None, + object_nodal_positions: dict[str, torch.Tensor] | None = None, subtask_term_signals: dict[str, Any] | None = None, subtask_start_signals: dict[str, Any] | None = None, target_eef_pose: dict[str, torch.Tensor] | None = None, @@ -43,6 +46,7 @@ def __init__( ) -> None: self.eef_pose = eef_pose self.object_poses = dict(object_poses) if object_poses is not None else None + self.object_nodal_positions = dict(object_nodal_positions) if object_nodal_positions is not None else None self.subtask_term_signals = dict(subtask_term_signals) if subtask_term_signals is not None else None self.subtask_start_signals = dict(subtask_start_signals) if subtask_start_signals is not None else None self.target_eef_pose = target_eef_pose @@ -55,6 +59,8 @@ def to_dict(self) -> dict[str, Any]: out["eef_pose"] = self.eef_pose if self.object_poses is not None: out["object_poses"] = deepcopy(self.object_poses) + if self.object_nodal_positions is not None: + out["object_nodal_positions"] = deepcopy(self.object_nodal_positions) if self.subtask_start_signals is not None: out["subtask_start_signals"] = deepcopy(self.subtask_start_signals) if self.subtask_term_signals is not None: diff --git a/isaac_autodata_core/deformable_transforms.py b/isaac_autodata_core/deformable_transforms.py new file mode 100644 index 0000000..7b4c64e --- /dev/null +++ b/isaac_autodata_core/deformable_transforms.py @@ -0,0 +1,168 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Non-rigid transforms used by SoftMimicGen.""" + +from __future__ import annotations + +import numpy as np +import torch + +import isaaclab.utils.math as PoseUtils + + +def _load_tps_functions(): + """Load the pinned Rapprentice TPS implementation on first use.""" + + try: + from rapprentice.tps import tps_cost, tps_eval, tps_fit, tps_fit2, tps_grad + except ImportError as exc: + raise ImportError( + "SoftMimicGen requires the 'rapprentice' package. Rebuild the AutoData Docker image " + "after the SoftMimicGen dependency change." + ) from exc + return tps_cost, tps_eval, tps_fit, tps_fit2, tps_grad + + +def transform_source_data_segment_using_nodal_registration( + src_eef_poses: torch.Tensor, + src_obj_nodal_pos: torch.Tensor, + tgt_obj_nodal_pos: torch.Tensor, + *, + use_rotation_transform: bool = True, + bend_coef: float = 0.1, + rot_coef: float = 1e-3, +) -> torch.Tensor: + """Warp an EEF pose sequence using TPS registration between deformable-object nodes. + + Args: + src_eef_poses: Source EEF poses shaped ``(T, 4, 4)``. + src_obj_nodal_pos: Source object node positions shaped ``(N, 3)`` [m]. + tgt_obj_nodal_pos: Target object node positions shaped ``(N, 3)`` [m]. + use_rotation_transform: Transform EEF rotations using the local TPS Jacobian. + bend_coef: TPS bending regularization coefficient. + rot_coef: TPS affine-rotation regularization coefficient. + + Returns: + Warped EEF poses shaped ``(T, 4, 4)``. + """ + + assert src_eef_poses.ndim == 3 and src_eef_poses.shape[-2:] == ( + 4, + 4, + ), f"src_eef_poses must have shape (T, 4, 4), got {tuple(src_eef_poses.shape)}" + assert ( + src_obj_nodal_pos.ndim == 2 and src_obj_nodal_pos.shape[1] == 3 + ), f"source nodal positions must have shape (N, 3), got {tuple(src_obj_nodal_pos.shape)}" + assert ( + tgt_obj_nodal_pos.ndim == 2 and tgt_obj_nodal_pos.shape[1] == 3 + ), f"target nodal positions must have shape (N, 3), got {tuple(tgt_obj_nodal_pos.shape)}" + assert src_obj_nodal_pos.shape[0] == tgt_obj_nodal_pos.shape[0], ( + "corresponding-node TPS requires source and target objects to have the same node count: " + f"{src_obj_nodal_pos.shape[0]} != {tgt_obj_nodal_pos.shape[0]}" + ) + + _, tps_eval, _, tps_fit2, tps_grad = _load_tps_functions() + src_nodal_np = src_obj_nodal_pos.detach().cpu().numpy() + tgt_nodal_np = tgt_obj_nodal_pos.detach().cpu().numpy() + lin_ag, trans_g, w_ng = tps_fit2( + x_na=src_nodal_np, + y_ng=tgt_nodal_np, + bend_coef=bend_coef, + rot_coef=rot_coef, + ) + + src_eef_pos, src_eef_rot = PoseUtils.unmake_pose(src_eef_poses) + src_eef_pos_np = src_eef_pos.detach().cpu().numpy() + transformed_pos_np = tps_eval( + x_ma=src_eef_pos_np, + lin_ag=lin_ag, + trans_g=trans_g, + w_ng=w_ng, + x_na=src_nodal_np, + ) + + if use_rotation_transform: + jacobians = tps_grad( + x_ma=src_eef_pos_np, + lin_ag=lin_ag, + _trans_g=trans_g, + w_ng=w_ng, + x_na=src_nodal_np, + ) + src_eef_rot_np = src_eef_rot.detach().cpu().numpy() + transformed_rot_np = np.empty_like(src_eef_rot_np) + for pose_index, jacobian in enumerate(jacobians): + transformed_rotation = jacobian @ src_eef_rot_np[pose_index] + u_mat, _, vt_mat = np.linalg.svd(transformed_rotation) + transformed_rotation = u_mat @ vt_mat + if np.linalg.det(transformed_rotation) < 0: + vt_mat[-1, :] *= -1 + transformed_rotation = u_mat @ vt_mat + transformed_rot_np[pose_index] = transformed_rotation + else: + transformed_rot_np = src_eef_rot.detach().cpu().numpy() + + transformed_pos = torch.as_tensor( + transformed_pos_np, + device=src_eef_poses.device, + dtype=src_eef_poses.dtype, + ) + transformed_rot = torch.as_tensor( + transformed_rot_np, + device=src_eef_poses.device, + dtype=src_eef_poses.dtype, + ) + assert torch.isfinite(transformed_pos).all(), "TPS produced non-finite EEF positions" + assert torch.isfinite(transformed_rot).all(), "TPS produced non-finite EEF rotations" + return PoseUtils.make_pose(pos=transformed_pos, rot=transformed_rot) + + +def nodal_registration_cost( + src_obj_nodal_pos: torch.Tensor, + tgt_obj_nodal_pos: torch.Tensor, + *, + bend_coef: float = 0.1, + rot_reg: float = 1e-3, +) -> float: + """Return the TPS fitting cost between two corresponding deformable node sets. + + Args: + src_obj_nodal_pos: Source object node positions shaped ``(N, 3)`` [m]. + tgt_obj_nodal_pos: Target object node positions shaped ``(N, 3)`` [m]. + bend_coef: TPS bending regularization coefficient. + rot_reg: TPS affine-rotation regularization coefficient. + + Returns: + Scalar TPS residual-plus-bending cost. + """ + + assert src_obj_nodal_pos.shape == tgt_obj_nodal_pos.shape, ( + "registration-cost TPS requires matching nodal shapes: " + f"{tuple(src_obj_nodal_pos.shape)} != {tuple(tgt_obj_nodal_pos.shape)}" + ) + assert ( + src_obj_nodal_pos.ndim == 2 and src_obj_nodal_pos.shape[1] == 3 + ), f"nodal positions must have shape (N, 3), got {tuple(src_obj_nodal_pos.shape)}" + + tps_cost, _, tps_fit, _, _ = _load_tps_functions() + src_nodal_np = src_obj_nodal_pos.detach().cpu().numpy() + tgt_nodal_np = tgt_obj_nodal_pos.detach().cpu().numpy() + lin_ag, trans_g, w_ng = tps_fit( + src_nodal_np, + tgt_nodal_np, + bend_coef, + rot_reg, + ) + return float( + tps_cost( + lin_ag, + trans_g, + w_ng, + src_nodal_np, + tgt_nodal_np, + bend_coef, + ) + ) diff --git a/isaac_autodata_core/pool.py b/isaac_autodata_core/pool.py index 4958cec..e81906a 100644 --- a/isaac_autodata_core/pool.py +++ b/isaac_autodata_core/pool.py @@ -126,7 +126,8 @@ def _add_episode(self, episode: EpisodeData) -> None: raise ValueError("Episode lacks 'datagen_info' obs annotations") eef_pose = ep_grp["obs"]["datagen_info"]["eef_pose"] - object_poses_dict = ep_grp["obs"]["datagen_info"]["object_pose"] + object_poses_dict = ep_grp["obs"]["datagen_info"].get("object_pose") + object_nodal_positions_dict = ep_grp["obs"]["datagen_info"].get("object_nodal_position") target_eef_pose = ep_grp["obs"]["datagen_info"]["target_eef_pose"] subtask_term_signals_dict = ep_grp["obs"]["datagen_info"]["subtask_term_signals"] subtask_start_signals_dict = ep_grp["obs"]["datagen_info"].get("subtask_start_signals") @@ -136,6 +137,7 @@ def _add_episode(self, episode: EpisodeData) -> None: ep_datagen_info = DatagenInfo( eef_pose=eef_pose, object_poses=object_poses_dict, + object_nodal_positions=object_nodal_positions_dict, subtask_start_signals=subtask_start_signals_dict, subtask_term_signals=subtask_term_signals_dict, target_eef_pose=target_eef_pose, diff --git a/isaac_autodata_core/selection_strategy.py b/isaac_autodata_core/selection_strategy.py index 059993f..f5f3632 100644 --- a/isaac_autodata_core/selection_strategy.py +++ b/isaac_autodata_core/selection_strategy.py @@ -12,6 +12,7 @@ from __future__ import annotations import abc +import numpy as np import torch from typing import Any @@ -53,6 +54,7 @@ def select_source_demo( eef_pose: torch.Tensor, object_pose: torch.Tensor | None, src_subtask_datagen_infos: list, + object_nodal_positions: torch.Tensor | None = None, ) -> int: """Return the index of the source demo whose subtask segment best fits the current scene. @@ -60,6 +62,7 @@ def select_source_demo( eef_pose: Current 4x4 EEF pose [m, rad]. object_pose: Current 4x4 pose of the subtask's reference object [m, rad], or None. src_subtask_datagen_infos: Per-source-demo :class:`DatagenInfo` slices covering this subtask. + object_nodal_positions: Current deformable-object nodes shaped ``(N, 3)`` [m], or None. """ raise NotImplementedError @@ -69,7 +72,13 @@ class RandomStrategy(SelectionStrategy): NAME = "random" - def select_source_demo(self, eef_pose, object_pose, src_subtask_datagen_infos) -> int: + def select_source_demo( + self, + eef_pose, + object_pose, + src_subtask_datagen_infos, + object_nodal_positions=None, + ) -> int: n_src_demo = len(src_subtask_datagen_infos) return torch.randint(0, n_src_demo, (1,)).item() @@ -87,6 +96,7 @@ def select_source_demo( pos_weight: float = 1.0, rot_weight: float = 1.0, nn_k: int = 3, + object_nodal_positions: torch.Tensor | None = None, ) -> int: src_object_poses = [] for di in src_subtask_datagen_infos: @@ -128,6 +138,7 @@ def select_source_demo( pos_weight: float = 1.0, rot_weight: float = 1.0, nn_k: int = 3, + object_nodal_positions: torch.Tensor | None = None, ) -> int: src_eef_poses = [] src_object_poses = [] @@ -167,3 +178,52 @@ def select_source_demo( nn_k = min(nn_k, len(dists)) rand_k = torch.randint(0, nn_k, (1,)).item() return torch.argsort(dists)[:nn_k][rand_k] + + +class RegistrationCostStrategy(SelectionStrategy): + """Pick a source segment using deformable-object TPS registration cost.""" + + NAME = "registration_cost" + + def select_source_demo( + self, + eef_pose: torch.Tensor, + object_pose: torch.Tensor | None, + src_subtask_datagen_infos: list, + object_nodal_positions: torch.Tensor | None = None, + bend_coef: float = 0.1, + rot_reg: float = 1e-3, + nn_k: int = 3, + ) -> int: + """Choose uniformly among the ``nn_k`` source shapes with lowest TPS cost.""" + + from isaac_autodata_core.deformable_transforms import nodal_registration_cost + + assert object_nodal_positions is not None, "registration_cost requires current object nodal positions" + assert nn_k >= 1, f"nn_k must be at least 1, got {nn_k}" + + costs: list[float] = [] + for datagen_info in src_subtask_datagen_infos: + assert ( + datagen_info.object_nodal_positions is not None + ), "registration_cost requires source object nodal positions" + source_objects = list(datagen_info.object_nodal_positions.values()) + assert ( + len(source_objects) == 1 + ), f"registration_cost expects exactly one source object, got {len(source_objects)}" + source_nodal_positions = source_objects[0][0] + try: + cost = nodal_registration_cost( + source_nodal_positions, + object_nodal_positions, + bend_coef=bend_coef, + rot_reg=rot_reg, + ) + except (AssertionError, np.linalg.LinAlgError, ValueError): + cost = float("inf") + costs.append(cost) + + costs_tensor = torch.tensor(costs, dtype=torch.float32) + nn_k = min(nn_k, len(costs)) + rand_k = torch.randint(0, nn_k, (1,)).item() + return torch.argsort(costs_tensor)[:nn_k][rand_k] diff --git a/isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml b/isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml new file mode 100644 index 0000000..3c0dd13 --- /dev/null +++ b/isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml @@ -0,0 +1,21 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +type: delta_pose_ik_single_arm +name: franka_rope +description: Franka arm with parallel gripper and delta-pose IK control for the rope task. + +eef_name: robot0 + +pose_obs_keys: + pos: robot0_eef_pos + quat: robot0_eef_quat + +action_layout: + gripper_dim: 1 + clip_pose_action_to_unit: true + +# The environment observations and source annotations both use the inter-fingertip control frame. +eef_offset: [0.0, 0.0, 0.0] diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py index a0b7c72..20b9b66 100644 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py @@ -12,7 +12,7 @@ if FRANKA_ROPE_ENV_ID not in gym.registry: gym.register( id=FRANKA_ROPE_ENV_ID, - entry_point="isaaclab.envs:ManagerBasedRLEnv", + entry_point="isaac_autodata_examples.envs.isaac_lab.franka_rope.franka_rope_env:FrankaRopeEnv", kwargs={ "env_cfg_entry_point": ( "isaac_autodata_examples.envs.isaac_lab.franka_rope.franka_rope_env_cfg:FrankaRopeEnvCfg" diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env.py new file mode 100644 index 0000000..3d9c1c8 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Franka rope environment with deformable reset settling.""" + +from __future__ import annotations + +import torch +from collections.abc import Sequence + +from isaaclab.envs import ManagerBasedRLEnv + + +class FrankaRopeEnv(ManagerBasedRLEnv): + """Manager-based environment that settles the rope after each reset.""" + + def _reset_idx(self, env_ids: Sequence[int]) -> None: + super()._reset_idx(env_ids) + + settling_steps = self.cfg.settling_steps + if settling_steps <= 0: + return + + zero_actions = torch.zeros( + (self.num_envs, self.action_manager.total_action_dim), + device=self.device, + ) + for _ in range(settling_steps): + self.action_manager.process_action(zero_actions) + for _ in range(self.cfg.decimation): + self.action_manager.apply_action() + self.scene.write_data_to_sim() + self.sim.step(render=True) + self.scene.update(dt=self.physics_dt) diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py index da70b04..931356c 100644 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py @@ -258,6 +258,9 @@ class TerminationsCfg: class FrankaRopeEnvCfg(ManagerBasedRLEnvCfg): """Configuration for teleoperated Franka rope manipulation.""" + settling_steps: int = 10 + """Number of control iterations used to settle the deformable rope after reset.""" + scene: FrankaRopeSceneCfg = FrankaRopeSceneCfg(num_envs=1, env_spacing=2.5, replicate_physics=False) observations: ObservationsCfg = ObservationsCfg() actions: ActionsCfg = ActionsCfg() diff --git a/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml b/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml new file mode 100644 index 0000000..b119db8 --- /dev/null +++ b/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml @@ -0,0 +1,60 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# 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: true + source_dataset_path: null + generation_path: null + task_name: null + use_skillgen: false + use_navigation_controller: false + select_src_per_subtask: false + select_src_per_arm: false + transform_first_robot_pose: false + interpolate_from_last_target_pose: true + +subtasks: + robot0: + - 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/isaac_autodata_interfaces/datastream/datastream.py b/isaac_autodata_interfaces/datastream/datastream.py index 73dd395..4ed9e8f 100644 --- a/isaac_autodata_interfaces/datastream/datastream.py +++ b/isaac_autodata_interfaces/datastream/datastream.py @@ -257,6 +257,27 @@ def get_object_poses(self, env_ids: Sequence[int] | None = None) -> dict[str, to object_pose_matrix[obj_name] = pose_math.make_pose(pos_rel, pose_math.matrix_from_quat(quat)) return object_pose_matrix + def get_object_nodal_positions(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]: + """Get all deformable-object nodal positions in the env-relative frame. + + Args: + env_ids: Environments to read; ``None`` reads all. + + Returns: + Mapping from deformable-object name to a tensor shaped + ``(len(env_ids), num_nodes, 3)`` containing positions [m]. + """ + + index: slice | Sequence[int] = slice(None) if env_ids is None else env_ids + scene = self.env.scene + env_origins = scene.env_origins[index] + object_nodal_positions: dict[str, torch.Tensor] = {} + for obj_name, obj in scene.deformable_objects.items(): + nodal_pos_w = obj.data.nodal_pos_w + nodal_pos_w = nodal_pos_w.torch if hasattr(nodal_pos_w, "torch") else as_torch(nodal_pos_w) + object_nodal_positions[obj_name] = nodal_pos_w[index] - env_origins.unsqueeze(1) + return object_nodal_positions + def get_subtask_term_signals( self, env_ids: Sequence[int] | None = None, obs_group: str = "subtask_terms" ) -> dict[str, torch.Tensor]: diff --git a/isaac_autodata_interfaces/tasks/subtask_spec.py b/isaac_autodata_interfaces/tasks/subtask_spec.py index 63a85c3..63e35b1 100644 --- a/isaac_autodata_interfaces/tasks/subtask_spec.py +++ b/isaac_autodata_interfaces/tasks/subtask_spec.py @@ -46,6 +46,23 @@ class SkillGenSubtaskAlgoParams(SubtaskAlgoParams): subtask_start_offset_range: tuple[int, int] = (0, 0) +@dataclass +class SoftMimicGenSubtaskAlgoParams(SubtaskAlgoParams): + """SoftMimicGen-specific subtask parameters. + + Args: + object_soft: Whether the reference object is deformable. + use_rotation_transform: Whether to transform EEF rotations with the local TPS Jacobian. + bend_coef: TPS bending regularization coefficient. + rot_coef: TPS affine-rotation regularization coefficient. + """ + + object_soft: bool = False + use_rotation_transform: bool = True + bend_coef: float = 0.1 + rot_coef: float = 1e-3 + + @dataclass(kw_only=True) class Subtask: """Configuration object used to specify subtasks used in data generation. @@ -96,6 +113,7 @@ class Subtask: "mimicgen": MimicGenSubtaskAlgoParams, "dexmimicgen": DexMimicGenSubtaskAlgoParams, "skillgen": SkillGenSubtaskAlgoParams, + "softmimicgen": SoftMimicGenSubtaskAlgoParams, } """Maps the ``algo:`` discriminator in a YAML task config to the corresponding :class:`SubtaskAlgoParams` subclass. diff --git a/isaac_autodata_tests/core/test_algorithms.py b/isaac_autodata_tests/core/test_algorithms.py index 1154b34..dd56f2c 100644 --- a/isaac_autodata_tests/core/test_algorithms.py +++ b/isaac_autodata_tests/core/test_algorithms.py @@ -13,6 +13,7 @@ GenerationAlgorithm, MimicGen, SkillGen, + SoftMimicGen, get_algorithm, iter_algorithms, ) @@ -22,6 +23,7 @@ def test_registry_contents(): assert REGISTERED_ALGORITHMS == { "mimicgen": MimicGen, "dexmimicgen": DexMimicGen, + "softmimicgen": SoftMimicGen, "skillgen": SkillGen, } @@ -33,10 +35,13 @@ def test_base_class_not_registered(): def test_iter_algorithms(): - assert set(iter_algorithms()) == {MimicGen, DexMimicGen, SkillGen} + assert set(iter_algorithms()) == {MimicGen, DexMimicGen, SoftMimicGen, SkillGen} -@pytest.mark.parametrize("name, cls", [("mimicgen", MimicGen), ("dexmimicgen", DexMimicGen)]) +@pytest.mark.parametrize( + "name, cls", + [("mimicgen", MimicGen), ("dexmimicgen", DexMimicGen), ("softmimicgen", SoftMimicGen)], +) def test_get_algorithm_no_kwargs(name, cls): assert isinstance(get_algorithm(name), cls) @@ -63,6 +68,15 @@ def test_dexmimicgen_attributes(): assert algo.requires_motion_planner is False +def test_softmimicgen_attributes(): + algo = SoftMimicGen() + assert algo.name == "softmimicgen" + assert algo.expected_eef_count == (1, 2) + assert algo.requires_motion_planner is False + assert algo.uses_subtask_start_signals is False + assert algo.supports_coordination is False + + def test_skillgen_attributes(): algo = SkillGen(motion_planners={0: object()}) assert algo.name == "skillgen" diff --git a/isaac_autodata_tests/core/test_datagen_info.py b/isaac_autodata_tests/core/test_datagen_info.py index cead04f..50a2ad6 100644 --- a/isaac_autodata_tests/core/test_datagen_info.py +++ b/isaac_autodata_tests/core/test_datagen_info.py @@ -14,6 +14,7 @@ def test_defaults_all_none(): di = DatagenInfo() assert di.eef_pose is None assert di.object_poses is None + assert di.object_nodal_positions is None assert di.subtask_term_signals is None assert di.subtask_start_signals is None assert di.target_eef_pose is None @@ -35,6 +36,7 @@ def test_to_dict_key_names(): di = DatagenInfo( eef_pose={}, object_poses={}, + object_nodal_positions={}, subtask_term_signals={}, subtask_start_signals={}, target_eef_pose={}, @@ -43,6 +45,7 @@ def test_to_dict_key_names(): assert set(di.to_dict()) == { "eef_pose", "object_poses", + "object_nodal_positions", "subtask_term_signals", "subtask_start_signals", "target_eef_pose", @@ -74,3 +77,11 @@ def test_to_dict_deepcopies_object_poses(): out = di.to_dict() out["object_poses"]["cube"] += 1.0 # mutate the returned copy assert torch.count_nonzero(di.object_poses["cube"]) == 0 # original untouched + + +def test_to_dict_deepcopies_object_nodal_positions(): + nodes = {"rope": torch.zeros(2, 8, 3)} + di = DatagenInfo(object_nodal_positions=nodes) + out = di.to_dict() + out["object_nodal_positions"]["rope"] += 1.0 + assert torch.count_nonzero(di.object_nodal_positions["rope"]) == 0 diff --git a/isaac_autodata_tests/core/test_deformable_transforms.py b/isaac_autodata_tests/core/test_deformable_transforms.py new file mode 100644 index 0000000..dc2673f --- /dev/null +++ b/isaac_autodata_tests/core/test_deformable_transforms.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for SoftMimicGen deformable transforms.""" + +import torch + +import pytest + +from isaac_autodata_core.deformable_transforms import ( + nodal_registration_cost, + transform_source_data_segment_using_nodal_registration, +) + +_NODES = torch.tensor( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 0.0], + [1.0, 0.0, 1.0], + ], + dtype=torch.float64, +) + + +def _poses() -> torch.Tensor: + poses = torch.eye(4, dtype=torch.float64).repeat(2, 1, 1) + poses[0, :3, 3] = torch.tensor([0.25, 0.25, 0.25], dtype=torch.float64) + poses[1, :3, 3] = torch.tensor([0.75, 0.25, 0.25], dtype=torch.float64) + return poses + + +def test_nodal_tps_translation_transforms_positions_and_preserves_rotations(): + translation = torch.tensor([0.2, -0.1, 0.3], dtype=torch.float64) + source_poses = _poses() + transformed = transform_source_data_segment_using_nodal_registration( + source_poses, + _NODES, + _NODES + translation, + ) + assert torch.allclose(transformed[:, :3, 3], source_poses[:, :3, 3] + translation, atol=1e-6) + assert torch.allclose(transformed[:, :3, :3], source_poses[:, :3, :3], atol=1e-6) + assert torch.allclose(torch.linalg.det(transformed[:, :3, :3]), torch.ones(2, dtype=torch.float64)) + + +def test_nodal_registration_cost_is_near_zero_for_identical_nodes(): + assert nodal_registration_cost(_NODES, _NODES) == pytest.approx(0.0, abs=1e-8) + + +def test_nodal_tps_rejects_mismatched_node_counts_before_fitting(): + with pytest.raises(AssertionError, match="same node count"): + transform_source_data_segment_using_nodal_registration( + _poses(), + _NODES, + _NODES[:-1], + ) diff --git a/isaac_autodata_tests/core/test_pool.py b/isaac_autodata_tests/core/test_pool.py index d194e26..cece55c 100644 --- a/isaac_autodata_tests/core/test_pool.py +++ b/isaac_autodata_tests/core/test_pool.py @@ -88,6 +88,27 @@ def _skillgen_task() -> TaskDescriptor: }) +def _softmimicgen_task() -> TaskDescriptor: + return TaskDescriptor.from_dict({ + "name": "rope", + "algo": "softmimicgen", + "subtasks": { + "franka": [ + { + "object_ref": "rope", + "subtask_term_signal": "grasp", + "algo_params": {"object_soft": True}, + }, + { + "object_ref": "rope", + "subtask_term_signal": "", + "algo_params": {"object_soft": True}, + }, + ] + }, + }) + + def _step_signal(length: int, edge: int) -> torch.Tensor: """A per-step step-function: False before ``edge``, True from ``edge`` on.""" sig = torch.ones(length, dtype=torch.bool) @@ -159,6 +180,18 @@ def _skillgen_episode( ) +def _softmimicgen_episode(actions_len: int = 10, term_edge: int = 4) -> types.SimpleNamespace: + return _episode( + actions_len=actions_len, + datagen_info={ + "eef_pose": {"franka": torch.zeros(actions_len, 4, 4)}, + "object_nodal_position": {"rope": torch.zeros(actions_len, 12, 3)}, + "target_eef_pose": {"franka": torch.zeros(actions_len, 4, 4)}, + "subtask_term_signals": {"grasp": _step_signal(actions_len, term_edge)}, + }, + ) + + # --------------------------------------------------------------------------------------------------- # __init__: subtask signal-name / offset-range tables built from the task descriptor # --------------------------------------------------------------------------------------------------- @@ -194,6 +227,16 @@ def test_add_episode_populates_datagen_info_and_passthrough(): assert di.passthrough_action["franka"].shape == (8, 1) +def test_add_episode_accepts_deformable_state_without_rigid_object_pose(): + pool = _pool(_softmimicgen_task()) + pool._add_episode(_softmimicgen_episode(actions_len=8, term_edge=3)) + di = pool.datagen_infos[0] + assert di.object_poses is None + assert set(di.object_nodal_positions) == {"rope"} + assert di.object_nodal_positions["rope"].shape == (8, 12, 3) + assert pool.subtask_boundaries["franka"] == [[(0, 4), (4, 8)]] + + def test_add_episode_missing_datagen_info_error(): pool = _pool(_mimicgen_task()) bad = types.SimpleNamespace(data={"actions": torch.zeros(5, 7), "obs": {}}) diff --git a/isaac_autodata_tests/core/test_selection_strategy.py b/isaac_autodata_tests/core/test_selection_strategy.py index d79451d..c5d54c2 100644 --- a/isaac_autodata_tests/core/test_selection_strategy.py +++ b/isaac_autodata_tests/core/test_selection_strategy.py @@ -15,6 +15,7 @@ NearestNeighborObjectStrategy, NearestNeighborRobotDistanceStrategy, RandomStrategy, + RegistrationCostStrategy, make_selection_strategy, ) @@ -36,6 +37,7 @@ def test_registry_contents(): "random", "nearest_neighbor_object", "nearest_neighbor_robot_distance", + "registration_cost", } @@ -111,3 +113,23 @@ def test_nearest_neighbor_robot_distance_transforms_source_eef_into_current_obje ) assert int(index) == 0 + + +def test_registration_cost_picks_lowest_cost(monkeypatch): + import isaac_autodata_core.deformable_transforms as deformable_transforms + + monkeypatch.setattr( + deformable_transforms, + "nodal_registration_cost", + lambda source, target, **kwargs: float(torch.linalg.vector_norm(source - target)), + ) + current = torch.zeros(6, 3) + infos = [DatagenInfo(object_nodal_positions={"rope": torch.full((1, 6, 3), value)}) for value in (2.0, 0.0, 1.0)] + index = RegistrationCostStrategy().select_source_demo( + None, + None, + infos, + object_nodal_positions=current, + nn_k=1, + ) + assert int(index) == 1 diff --git a/isaac_autodata_tests/interfaces/datastream/test_datastream.py b/isaac_autodata_tests/interfaces/datastream/test_datastream.py index 414b888..443913d 100644 --- a/isaac_autodata_tests/interfaces/datastream/test_datastream.py +++ b/isaac_autodata_tests/interfaces/datastream/test_datastream.py @@ -58,9 +58,15 @@ def _env(env_origins: torch.Tensor | None = None) -> MockEnv: root_quat_w=torch.tensor(_IDENTITY_QUAT_XYZW), ) ) + rope = MockAsset( + MockArticulationData( + nodal_pos_w=torch.tensor([[[1.0, 1.0, 0.05], [1.2, 1.0, 0.05]]]), + ) + ) scene = MockScene( assets={"robot": robot}, rigid_objects={"cube_1": cube}, + deformable_objects={"rope": rope}, env_origins=torch.tensor([[0.0, 0.0, 0.0]]) if env_origins is None else env_origins, state=_SCENE_STATE_SENTINEL, ) @@ -227,6 +233,23 @@ def test_get_object_poses_subtracts_env_origin(): assert torch.allclose(pose[0, :3, 3], torch.tensor([0.0, 0.0, 0.05])) +def test_get_object_nodal_positions_is_env_relative(): + task = TaskDescriptor.from_dict(_task_dict()) + embodiment_adapter = _embodiment_adapter() + env = _env(env_origins=torch.tensor([[1.0, 1.0, 0.0]])) + pool = DataGenInfoPool( + task_descriptor=task, + embodiment_adapter=embodiment_adapter, + device=env.device, + uses_start_signals=False, + ) + datastream = Datastream(env=env, task_descriptor=task, embodiment_adapter=embodiment_adapter, source_pool=pool) + nodes = datastream.get_object_nodal_positions()["rope"] + assert nodes.shape == (1, 2, 3) + assert torch.allclose(nodes[0, 0], torch.tensor([0.0, 0.0, 0.05])) + assert torch.allclose(nodes[0, 1], torch.tensor([0.2, 0.0, 0.05])) + + def test_get_robot_root_pose(): datastream, _, _, _ = _datastream() pose = datastream.get_robot_root_pose(env_ids=[0]) diff --git a/isaac_autodata_tests/interfaces/mocks.py b/isaac_autodata_tests/interfaces/mocks.py index 344e578..2bef9e4 100644 --- a/isaac_autodata_tests/interfaces/mocks.py +++ b/isaac_autodata_tests/interfaces/mocks.py @@ -20,11 +20,13 @@ def __init__( joint_names: list[str] | None = None, root_pos_w: torch.Tensor | None = None, root_quat_w: torch.Tensor | None = None, + nodal_pos_w: torch.Tensor | None = None, ) -> None: self.joint_pos = joint_pos self.joint_names = joint_names self.root_pos_w = root_pos_w self.root_quat_w = root_quat_w + self.nodal_pos_w = nodal_pos_w class MockAsset: @@ -41,11 +43,13 @@ def __init__( self, assets: dict[str, MockAsset] | None = None, rigid_objects: dict[str, MockAsset] | None = None, + deformable_objects: dict[str, MockAsset] | None = None, env_origins: torch.Tensor | None = None, state: Any = None, ) -> None: self._assets = assets or {} self.rigid_objects = rigid_objects or {} + self.deformable_objects = deformable_objects or {} self.env_origins = env_origins self._state = state diff --git a/isaac_autodata_tests/interfaces/tasks/test_subtask_spec.py b/isaac_autodata_tests/interfaces/tasks/test_subtask_spec.py index abc349a..7b635ff 100644 --- a/isaac_autodata_tests/interfaces/tasks/test_subtask_spec.py +++ b/isaac_autodata_tests/interfaces/tasks/test_subtask_spec.py @@ -12,6 +12,7 @@ DexMimicGenSubtaskAlgoParams, MimicGenSubtaskAlgoParams, SkillGenSubtaskAlgoParams, + SoftMimicGenSubtaskAlgoParams, Subtask, SubtaskAlgoParams, ) @@ -83,6 +84,7 @@ def test_algo_params_registry_contents(): "mimicgen": MimicGenSubtaskAlgoParams, "dexmimicgen": DexMimicGenSubtaskAlgoParams, "skillgen": SkillGenSubtaskAlgoParams, + "softmimicgen": SoftMimicGenSubtaskAlgoParams, } for cls in ALGO_PARAMS_REGISTRY.values(): assert issubclass(cls, SubtaskAlgoParams) @@ -93,6 +95,15 @@ def test_skillgen_algo_params_default_and_override(): assert SkillGenSubtaskAlgoParams(subtask_start_offset_range=(1, 3)).subtask_start_offset_range == (1, 3) +def test_softmimicgen_algo_params_defaults_and_override(): + defaults = SoftMimicGenSubtaskAlgoParams() + assert defaults.object_soft is False + assert defaults.use_rotation_transform is True + assert defaults.bend_coef == 0.1 + assert defaults.rot_coef == 1e-3 + assert SoftMimicGenSubtaskAlgoParams(object_soft=True).object_soft is True + + @pytest.mark.parametrize("cls", [MimicGenSubtaskAlgoParams, DexMimicGenSubtaskAlgoParams]) def test_plain_algo_params_have_no_extra_fields(cls): # MimicGen / DexMimicGen carry no params beyond the shared ones on Subtask. diff --git a/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py b/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py index ca2d380..912994f 100644 --- a/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py +++ b/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py @@ -11,7 +11,11 @@ from isaac_autodata_interfaces.tasks.generation_policy_spec import GenerationPolicy from isaac_autodata_interfaces.tasks.subtask_constraint_spec import SubtaskConstraint, SubTaskConstraintType -from isaac_autodata_interfaces.tasks.subtask_spec import MimicGenSubtaskAlgoParams, SkillGenSubtaskAlgoParams +from isaac_autodata_interfaces.tasks.subtask_spec import ( + MimicGenSubtaskAlgoParams, + SkillGenSubtaskAlgoParams, + SoftMimicGenSubtaskAlgoParams, +) from isaac_autodata_interfaces.tasks.task_descriptor import TaskDescriptor from isaac_autodata_tests.utils.constants import TestPaths @@ -88,6 +92,20 @@ def test_from_dict_description_defaults_empty(): assert td.description == "" +def test_franka_rope_softmimicgen_yaml(): + path = os.path.join(TestPaths.tasks_dir, "franka_rope_softmimicgen.yaml") + task = TaskDescriptor.from_yaml(path) + assert task.name == "franka_rope" + assert task.get_eef_names() == ["robot0"] + assert len(task.get_subtasks("robot0")) == 2 + first = task.get_subtasks("robot0")[0] + assert first.object_ref == "object" + assert first.selection_strategy == "registration_cost" + assert first.subtask_term_offset_range == (10, 15) + assert isinstance(first.algo_params, SoftMimicGenSubtaskAlgoParams) + assert first.algo_params.object_soft is True + + # --------------------------------------------------------------------------------------------------- # per-EEF, subtask-ordered getters # --------------------------------------------------------------------------------------------------- diff --git a/scripts/annotate_demos.py b/scripts/annotate_demos.py index 41d8b43..a025f0c 100644 --- a/scripts/annotate_demos.py +++ b/scripts/annotate_demos.py @@ -105,6 +105,7 @@ from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler # noqa: E402 from isaac_autodata_core.pool import DataGenInfoPool # noqa: E402 +from isaac_autodata_examples.envs.isaac_lab import register_environments # noqa: E402 from isaac_autodata_interfaces.datastream import Datastream # noqa: E402 from isaac_autodata_interfaces.embodiments import embodiment_adapter_from_yaml # noqa: E402 from isaac_autodata_interfaces.env import get_env_name_from_dataset, setup_env_config, setup_output_paths # noqa: E402 @@ -117,6 +118,8 @@ _datastream: Datastream | None = None +register_environments() + def play_cb() -> None: global is_paused @@ -147,6 +150,7 @@ def record_pre_step(self): assert _datastream is not None, "Datastream must be initialized before recording." datagen_info = { "object_pose": _datastream.get_object_poses(), + "object_nodal_position": _datastream.get_object_nodal_positions(), "eef_pose": _datastream.embodiment_adapter.get_eef_poses(env_ids=None), "target_eef_pose": _datastream.action_to_target_eef_pose(self._env.action_manager.action), } diff --git a/scripts/generate_dataset.py b/scripts/generate_dataset.py index c60079b..3abeb9c 100755 --- a/scripts/generate_dataset.py +++ b/scripts/generate_dataset.py @@ -10,7 +10,7 @@ python scripts/generate_dataset.py \\ --env_name \\ - --alg {mimicgen|dexmimicgen|skillgen} \\ + --alg {mimicgen|dexmimicgen|skillgen|softmimicgen} \\ --task_descriptor \\ --embodiment \\ --env_profile \\ @@ -26,6 +26,7 @@ * ``skillgen`` — single-arm SkillGen. SkillGen depends on a motion-planner interface; until the planner code is ported into this repo, the CLI satisfies that interface with the upstream Arena ``CuroboPlanner``. +* ``softmimicgen`` — one- or two-arm MimicGen with deformable-object nodal registration. The CLI composes a :class:`Datastream` from the task descriptor YAML, the embodiment YAML, the live env, and the HDF5 source dataset, then hands it to :class:`DataGenerator`. @@ -39,7 +40,7 @@ # Hardcoded to keep argparse importable without pulling in the heavy core package. # Add new algorithms here when registering them in isaac_autodata_core.algorithms. -_ALG_CHOICES = ["mimicgen", "dexmimicgen", "skillgen"] +_ALG_CHOICES = ["mimicgen", "dexmimicgen", "skillgen", "softmimicgen"] parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument( @@ -121,6 +122,7 @@ from isaac_autodata_core import DataGenerator, get_algorithm # noqa: E402 from isaac_autodata_core.algorithms import REGISTERED_ALGORITHMS # noqa: E402 +from isaac_autodata_examples.envs.isaac_lab import register_environments # noqa: E402 from isaac_autodata_interfaces.datastream import Datastream # noqa: E402 from isaac_autodata_interfaces.embodiments import embodiment_adapter_from_yaml # noqa: E402 from isaac_autodata_interfaces.env import ( # noqa: E402 @@ -133,6 +135,8 @@ from isaac_autodata_interfaces.tasks.task_descriptor import TaskDescriptor # noqa: E402 from isaac_autodata_utils.generation_result import write_generation_result # noqa: E402 +register_environments() + async def run_data_generator( env: Any, From 4dcf01d28bcdaf11057002dee2fae5cffd001366 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Thu, 23 Jul 2026 14:40:57 -0700 Subject: [PATCH 03/15] update deps --- pyproject.toml | 1 + setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 92e70e6..64a5b72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ known_third_party = [ "isaaclab_mimic", "isaaclab_tasks", "isaaclab_arena", + "rapprentice", ] # Imports from this repository diff --git a/setup.py b/setup.py index 376c7d3..988be0e 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,7 @@ ISAAC_AUTO_DATA_VERSION_NUMBER = "0.1.0" RUNTIME_DEPS = [ + "rapprentice @ git+https://github.com/masoudmoghani/rapprentice.git@512894df66ec4d0e608e409f7dac4ef448578f78", "typing_extensions", "pyzmq", ] From 293ae4aa61a67959bb74efbd950ca8689947e762 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Fri, 24 Jul 2026 16:09:17 -0700 Subject: [PATCH 04/15] add frnaka rope arena env --- .../embodiments/franka_rope_arena_ik_rel.yaml | 20 ++ isaac_autodata_examples/envs/__init__.py | 18 ++ .../envs/isaac_lab/franka_rope/mdp/events.py | 304 ++---------------- .../envs/isaac_lab_arena/__init__.py | 10 + .../isaac_lab_arena/franka_rope/__init__.py | 54 ++++ .../franka_rope/assets/Rope.usd | Bin 0 -> 154413 bytes .../isaac_lab_arena/franka_rope/embodiment.py | 186 +++++++++++ .../franka_rope/environment.py | 109 +++++++ .../franka_rope/mdp/__init__.py | 23 ++ .../isaac_lab_arena/franka_rope/mdp/events.py | 142 ++++++++ .../franka_rope/mdp/observations.py | 96 ++++++ .../franka_rope/mdp/terminations.py | 81 +++++ .../isaac_lab_arena/franka_rope/rope_asset.py | 42 +++ .../envs/isaac_lab_arena/franka_rope/task.py | 158 +++++++++ .../interfaces/embodiments/test_factory.py | 2 + scripts/annotate_demos.py | 4 +- scripts/generate_dataset.py | 4 +- 17 files changed, 971 insertions(+), 282 deletions(-) create mode 100644 isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/__init__.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/assets/Rope.usd create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py diff --git a/isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml b/isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml new file mode 100644 index 0000000..2f63ec6 --- /dev/null +++ b/isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +type: delta_pose_ik_single_arm +name: franka_rope_arena +description: Arena-composed Franka arm with delta-pose IK control for the deformable rope task. + +eef_name: robot0 + +pose_obs_keys: + pos: eef_pos + quat: eef_quat + +action_layout: + gripper_dim: 1 + clip_pose_action_to_unit: true + +eef_offset: [0.0, 0.0, 0.0] diff --git a/isaac_autodata_examples/envs/__init__.py b/isaac_autodata_examples/envs/__init__.py index 4396f64..8f12207 100644 --- a/isaac_autodata_examples/envs/__init__.py +++ b/isaac_autodata_examples/envs/__init__.py @@ -4,3 +4,21 @@ # SPDX-License-Identifier: Apache-2.0 """Example simulation environments used by Isaac AutoData workflows.""" + + +def register_environments(enable_cameras: bool = True) -> list[str]: + """Register all AutoData-owned Isaac Lab and Isaac Lab Arena environments. + + Args: + enable_cameras: Whether Arena environments should include their camera sensors. + + Returns: + An empty list of callback-specific command-line arguments. + """ + + from .isaac_lab import register_environments as register_isaac_lab_environments + from .isaac_lab_arena import register_environments as register_isaac_lab_arena_environments + + register_isaac_lab_environments() + register_isaac_lab_arena_environments(enable_cameras=enable_cameras) + return [] diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py index b469d9f..56b0742 100644 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py +++ b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py @@ -19,286 +19,34 @@ # 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, + 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( diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py new file mode 100644 index 0000000..17476a1 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Isaac Lab Arena environments composed by Isaac AutoData.""" + +from .franka_rope import register_environments + +__all__ = ["register_environments"] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py new file mode 100644 index 0000000..23378e1 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Arena composition and Gym registration for Franka rope manipulation.""" + +from __future__ import annotations + +import gymnasium as gym + +from . import embodiment as _embodiment # noqa: F401 +from .environment import FRANKA_ROPE_ARENA_ENV_ID, FrankaRopeArenaEnvironment, FrankaRopeArenaEnvironmentCfg + + +def register_environments(enable_cameras: bool = True) -> list[str]: + """Compose and register the AutoData-owned Arena Franka rope environment. + + Args: + enable_cameras: Whether to include the fixed and wrist camera sensors. + + Returns: + An empty list of callback-specific command-line arguments. + """ + + if FRANKA_ROPE_ARENA_ENV_ID in gym.registry: + return [] + + from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder + from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg + + arena_env = FrankaRopeArenaEnvironment().build( + FrankaRopeArenaEnvironmentCfg(enable_cameras=enable_cameras), + ) + builder = ArenaEnvBuilder( + arena_env, + ArenaEnvBuilderCfg( + num_envs=1, + env_spacing=2.5, + seed=7, + solve_relations=False, + device="cuda:0", + ), + ) + builder.build_registered() + return [] + + +__all__ = [ + "FRANKA_ROPE_ARENA_ENV_ID", + "FrankaRopeArenaEnvironment", + "FrankaRopeArenaEnvironmentCfg", + "register_environments", +] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/assets/Rope.usd b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/assets/Rope.usd new file mode 100644 index 0000000000000000000000000000000000000000..34c17d24b3f4ac246ec28f5a772e24c02ff1f5b1 GIT binary patch literal 154413 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 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/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py new file mode 100644 index 0000000..bf362a4 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Typed Arena composition for the AutoData Franka rope environment.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from isaaclab.devices import DevicesCfg, Se3KeyboardCfg, Se3SpaceMouseCfg +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-specific simulation timing and legacy teleop devices.""" + + env_cfg.decimation = 5 + env_cfg.sim.dt = 0.01 + env_cfg.sim.render_interval = 2 + env_cfg.sim.render.antialiasing_mode = "DLSS" + 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 + + +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/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py new file mode 100644 index 0000000..daa77c3 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""MDP terms used by the Arena Franka rope task.""" + +from .events import reset_rope_end_tracking, reset_rope_nodal_state, settle_rope_after_reset +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", + "settle_rope_after_reset", +] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py new file mode 100644 index 0000000..57285d4 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py @@ -0,0 +1,142 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Reset and settling events for Arena Franka rope manipulation.""" + +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) + + +def settle_rope_after_reset( + env: ManagerBasedRLEnv, + env_ids: torch.Tensor, + settling_steps: int = 10, +) -> None: + """Advance zero-action control iterations after nodal-state reset. + + PhysX advances the full simulation when any subset of environments resets, so + zero actions are deliberately applied to all environments. + + Args: + env: Arena manager-based environment. + env_ids: Reset environment indices. Used to satisfy the reset-event interface. + settling_steps: Number of control iterations to advance. + """ + + del env_ids + if settling_steps <= 0: + return + + zero_actions = torch.zeros( + (env.num_envs, env.action_manager.total_action_dim), + device=env.device, + ) + for _ in range(settling_steps): + env.action_manager.process_action(zero_actions) + for _ in range(env.cfg.decimation): + env.action_manager.apply_action() + env.scene.write_data_to_sim() + env.sim.step(render=True) + env.scene.update(dt=env.physics_dt) diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py new file mode 100644 index 0000000..bba1fc3 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Observation terms for Arena Franka rope manipulation.""" + +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/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py new file mode 100644 index 0000000..e839459 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py @@ -0,0 +1,81 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Termination terms for Arena Franka rope manipulation.""" + +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/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py new file mode 100644 index 0000000..58cb2b8 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Arena asset wrapper for the AutoData deformable rope.""" + +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/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py new file mode 100644 index 0000000..f312871 --- /dev/null +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Arena task definition for grasping and folding a deformable rope.""" + +from __future__ import annotations + +import math +from typing import Never + +import isaaclab.envs.mdp as mdp_isaac_lab +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.tasks.task_base import TaskBase + +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, endpoint tracking, and post-reset settling.""" + + 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"), + }, + ) + + settle_rope_after_reset = EventTerm( + func=mdp.settle_rope_after_reset, + mode="reset", + params={"settling_steps": 10}, + ) + + +@configclass +class FrankaRopeTaskTerminationsCfg: + """Rope success, failure, and time-limit terms.""" + + time_out = DoneTerm(func=mdp_isaac_lab.time_out, time_out=True) + object_dropping = DoneTerm( + func=mdp.rope_below_minimum, + params={ + "minimum_height": -0.05, + "object_cfg": SceneEntityCfg("object"), + }, + ) + success = DoneTerm(func=mdp.rope_ends_close_tracked) + + +@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 = FrankaRopeTaskTerminationsCfg() + + 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) -> FrankaRopeTaskTerminationsCfg: + """Return task termination terms.""" + + return self.termination_cfg + + def get_events_cfg(self) -> FrankaRopeTaskEventsCfg: + """Return deformable reset and settling 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/isaac_autodata_tests/interfaces/embodiments/test_factory.py b/isaac_autodata_tests/interfaces/embodiments/test_factory.py index 0fec9b4..ea736e2 100644 --- a/isaac_autodata_tests/interfaces/embodiments/test_factory.py +++ b/isaac_autodata_tests/interfaces/embodiments/test_factory.py @@ -93,6 +93,8 @@ def test_from_yaml_round_trip(tmp_path): [ ("franka_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), ("franka_ik_rel_skillgen.yaml", DeltaPoseIKSingleArmAdapter), + ("franka_rope_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), + ("franka_rope_arena_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), ("g1_ik_abs.yaml", AbsolutePoseWholeBodyBimanualAdapter), ("gr1_ik_abs.yaml", AbsolutePoseWholeBodyBimanualAdapter), ], diff --git a/scripts/annotate_demos.py b/scripts/annotate_demos.py index a025f0c..c28c54e 100644 --- a/scripts/annotate_demos.py +++ b/scripts/annotate_demos.py @@ -105,7 +105,7 @@ from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler # noqa: E402 from isaac_autodata_core.pool import DataGenInfoPool # noqa: E402 -from isaac_autodata_examples.envs.isaac_lab import register_environments # noqa: E402 +from isaac_autodata_examples.envs import register_environments # noqa: E402 from isaac_autodata_interfaces.datastream import Datastream # noqa: E402 from isaac_autodata_interfaces.embodiments import embodiment_adapter_from_yaml # noqa: E402 from isaac_autodata_interfaces.env import get_env_name_from_dataset, setup_env_config, setup_output_paths # noqa: E402 @@ -118,7 +118,7 @@ _datastream: Datastream | None = None -register_environments() +register_environments(enable_cameras=args_cli.enable_cameras) def play_cb() -> None: diff --git a/scripts/generate_dataset.py b/scripts/generate_dataset.py index 3abeb9c..609dd24 100755 --- a/scripts/generate_dataset.py +++ b/scripts/generate_dataset.py @@ -122,7 +122,7 @@ from isaac_autodata_core import DataGenerator, get_algorithm # noqa: E402 from isaac_autodata_core.algorithms import REGISTERED_ALGORITHMS # noqa: E402 -from isaac_autodata_examples.envs.isaac_lab import register_environments # noqa: E402 +from isaac_autodata_examples.envs import register_environments # noqa: E402 from isaac_autodata_interfaces.datastream import Datastream # noqa: E402 from isaac_autodata_interfaces.embodiments import embodiment_adapter_from_yaml # noqa: E402 from isaac_autodata_interfaces.env import ( # noqa: E402 @@ -135,7 +135,7 @@ from isaac_autodata_interfaces.tasks.task_descriptor import TaskDescriptor # noqa: E402 from isaac_autodata_utils.generation_result import write_generation_result # noqa: E402 -register_environments() +register_environments(enable_cameras=args_cli.enable_cameras) async def run_data_generator( From e44be117de1a61bd064c1965a9ae2aec8fb1659b Mon Sep 17 00:00:00 2001 From: Peter Du Date: Mon, 3 Aug 2026 10:46:01 -0700 Subject: [PATCH 05/15] add native tps util --- isaac_autodata_core/deformable_transforms.py | 75 +- .../interfaces/env/test_franka_rope_arena.py | 53 ++ isaac_autodata_tests/scripts/__init__.py | 6 + .../scripts/test_convert_deformable_usd.py | 111 +++ .../utils/test_thin_plate_spline.py | 88 +++ isaac_autodata_utils/thin_plate_spline.py | 265 +++++++ pyproject.toml | 1 - scripts/convert_deformable_usd.py | 748 ++++++++++++++++++ setup.py | 1 - 9 files changed, 1301 insertions(+), 47 deletions(-) create mode 100644 isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py create mode 100644 isaac_autodata_tests/scripts/__init__.py create mode 100644 isaac_autodata_tests/scripts/test_convert_deformable_usd.py create mode 100644 isaac_autodata_tests/utils/test_thin_plate_spline.py create mode 100644 isaac_autodata_utils/thin_plate_spline.py create mode 100644 scripts/convert_deformable_usd.py diff --git a/isaac_autodata_core/deformable_transforms.py b/isaac_autodata_core/deformable_transforms.py index 7b4c64e..089dd93 100644 --- a/isaac_autodata_core/deformable_transforms.py +++ b/isaac_autodata_core/deformable_transforms.py @@ -12,18 +12,7 @@ import isaaclab.utils.math as PoseUtils - -def _load_tps_functions(): - """Load the pinned Rapprentice TPS implementation on first use.""" - - try: - from rapprentice.tps import tps_cost, tps_eval, tps_fit, tps_fit2, tps_grad - except ImportError as exc: - raise ImportError( - "SoftMimicGen requires the 'rapprentice' package. Rebuild the AutoData Docker image " - "after the SoftMimicGen dependency change." - ) from exc - return tps_cost, tps_eval, tps_fit, tps_fit2, tps_grad +from isaac_autodata_utils import thin_plate_spline as tps def transform_source_data_segment_using_nodal_registration( @@ -64,33 +53,32 @@ def transform_source_data_segment_using_nodal_registration( f"{src_obj_nodal_pos.shape[0]} != {tgt_obj_nodal_pos.shape[0]}" ) - _, tps_eval, _, tps_fit2, tps_grad = _load_tps_functions() src_nodal_np = src_obj_nodal_pos.detach().cpu().numpy() tgt_nodal_np = tgt_obj_nodal_pos.detach().cpu().numpy() - lin_ag, trans_g, w_ng = tps_fit2( - x_na=src_nodal_np, - y_ng=tgt_nodal_np, - bend_coef=bend_coef, - rot_coef=rot_coef, + linear, translation, weights = tps.fit_reduced( + source_points=src_nodal_np, + target_points=tgt_nodal_np, + bend_coefficient=bend_coef, + rotation_coefficient=rot_coef, ) src_eef_pos, src_eef_rot = PoseUtils.unmake_pose(src_eef_poses) src_eef_pos_np = src_eef_pos.detach().cpu().numpy() - transformed_pos_np = tps_eval( - x_ma=src_eef_pos_np, - lin_ag=lin_ag, - trans_g=trans_g, - w_ng=w_ng, - x_na=src_nodal_np, + transformed_pos_np = tps.evaluate( + query_points=src_eef_pos_np, + linear=linear, + translation=translation, + weights=weights, + source_points=src_nodal_np, ) if use_rotation_transform: - jacobians = tps_grad( - x_ma=src_eef_pos_np, - lin_ag=lin_ag, - _trans_g=trans_g, - w_ng=w_ng, - x_na=src_nodal_np, + jacobians = tps.gradient( + query_points=src_eef_pos_np, + linear=linear, + translation=translation, + weights=weights, + source_points=src_nodal_np, ) src_eef_rot_np = src_eef_rot.detach().cpu().numpy() transformed_rot_np = np.empty_like(src_eef_rot_np) @@ -147,22 +135,19 @@ def nodal_registration_cost( src_obj_nodal_pos.ndim == 2 and src_obj_nodal_pos.shape[1] == 3 ), f"nodal positions must have shape (N, 3), got {tuple(src_obj_nodal_pos.shape)}" - tps_cost, _, tps_fit, _, _ = _load_tps_functions() src_nodal_np = src_obj_nodal_pos.detach().cpu().numpy() tgt_nodal_np = tgt_obj_nodal_pos.detach().cpu().numpy() - lin_ag, trans_g, w_ng = tps_fit( - src_nodal_np, - tgt_nodal_np, - bend_coef, - rot_reg, + linear, translation, weights = tps.fit( + source_points=src_nodal_np, + target_points=tgt_nodal_np, + bend_coefficient=bend_coef, + rotation_coefficient=rot_reg, ) - return float( - tps_cost( - lin_ag, - trans_g, - w_ng, - src_nodal_np, - tgt_nodal_np, - bend_coef, - ) + return tps.cost( + linear=linear, + translation=translation, + weights=weights, + source_points=src_nodal_np, + target_points=tgt_nodal_np, + bend_coefficient=bend_coef, ) diff --git a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py new file mode 100644 index 0000000..26dd665 --- /dev/null +++ b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py @@ -0,0 +1,53 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Configuration tests for the AutoData-owned Arena Franka rope environment.""" + +from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope import mdp +from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope.embodiment import FrankaRopeIKEmbodiment +from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope.environment import ( + FRANKA_ROPE_ARENA_ENV_ID, + FrankaRopeArenaEnvironment, + FrankaRopeArenaEnvironmentCfg, +) +from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope.rope_asset import FrankaRopeAsset +from isaac_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_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 = "isaac_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() + assert task.get_observation_cfg().subtask_terms.grasp is not None + assert task.get_termination_cfg().success is not None + assert task.get_events_cfg().settle_rope_after_reset.params["settling_steps"] == 10 + + +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 diff --git a/isaac_autodata_tests/scripts/__init__.py b/isaac_autodata_tests/scripts/__init__.py new file mode 100644 index 0000000..7dd453f --- /dev/null +++ b/isaac_autodata_tests/scripts/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for repository command-line utilities.""" diff --git a/isaac_autodata_tests/scripts/test_convert_deformable_usd.py b/isaac_autodata_tests/scripts/test_convert_deformable_usd.py new file mode 100644 index 0000000..8109a18 --- /dev/null +++ b/isaac_autodata_tests/scripts/test_convert_deformable_usd.py @@ -0,0 +1,111 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Focused tests for deformable USD schema detection and tetrahedral topology conversion.""" + +from __future__ import annotations + +import pytest +from pxr import Gf, Sdf, Usd + +from scripts.convert_deformable_usd import ( + DeformableSchemaKind, + _load_usd_modules, + _remove_api_schema, + _surface_faces, + _tetrahedra_from_flat_indices, + inspect_stage, +) + +_load_usd_modules() + + +def test_detect_legacy_volume_schema() -> None: + """A mesh with legacy simulation arrays is classified as legacy volume.""" + + stage = Usd.Stage.CreateInMemory() + root = stage.DefinePrim("/World", "Xform") + stage.SetDefaultPrim(root) + mesh = stage.DefinePrim("/World/Object", "Mesh") + mesh.CreateAttribute("physxDeformable:simulationPoints", Sdf.ValueTypeNames.Point3fArray).Set( + [Gf.Vec3f(0.0), Gf.Vec3f(1.0, 0.0, 0.0), Gf.Vec3f(0.0, 1.0, 0.0), Gf.Vec3f(0.0, 0.0, 1.0)] + ) + mesh.CreateAttribute("physxDeformable:simulationIndices", Sdf.ValueTypeNames.IntArray).Set([0, 1, 2, 3]) + + inspection = inspect_stage(stage) + + assert inspection.kind is DeformableSchemaKind.LEGACY_VOLUME + assert inspection.default_prim_path == "/World" + assert inspection.legacy_source_prim_paths == ("/World/Object",) + + +def test_remove_unregistered_api_schema() -> None: + """Obsolete API tokens can be removed without requiring schema registration.""" + + stage = Usd.Stage.CreateInMemory() + prim = stage.DefinePrim("/World", "Xform") + prim.SetMetadata( + "apiSchemas", + Sdf.TokenListOp.CreateExplicit(["MaterialBindingAPI", "ObsoleteDeformableAPI"]), + ) + + _remove_api_schema(prim, "ObsoleteDeformableAPI") + + assert prim.GetMetadata("apiSchemas").GetAppliedItems() == ["MaterialBindingAPI"] + + +def test_group_and_validate_tetrahedra() -> None: + """Flat tetrahedral indices are grouped without changing connectivity.""" + + tetrahedra = _tetrahedra_from_flat_indices([0, 1, 2, 3, 1, 2, 3, 4], vertex_count=5, label="test") + + assert tetrahedra == [(0, 1, 2, 3), (1, 2, 3, 4)] + + +@pytest.mark.parametrize( + ("indices", "message"), + [ + ([0, 1, 2], "divisible by four"), + ([0, 1, 1, 3], "duplicate vertices"), + ([0, 1, 2, 4], "references vertex 4"), + ], +) +def test_reject_invalid_tetrahedra(indices: list[int], message: str) -> None: + """Malformed legacy topology fails with a targeted diagnostic.""" + + with pytest.raises(AssertionError, match=message): + _tetrahedra_from_flat_indices(indices, vertex_count=4, label="test") + + +def test_extract_oriented_surface_faces() -> None: + """A positive-volume tetrahedron produces four outward boundary triangles.""" + + points = [ + Gf.Vec3f(0.0, 0.0, 0.0), + Gf.Vec3f(1.0, 0.0, 0.0), + Gf.Vec3f(0.0, 1.0, 0.0), + Gf.Vec3f(0.0, 0.0, 1.0), + ] + + faces = _surface_faces([(0, 1, 2, 3)], points, "test") + + assert faces == [(0, 1, 3), (0, 2, 1), (0, 3, 2), (1, 2, 3)] + + +def test_remove_shared_tetrahedral_face() -> None: + """The shared face of two tetrahedra is excluded from the surface mesh.""" + + points = [ + Gf.Vec3f(0.0, 0.0, 0.0), + Gf.Vec3f(1.0, 0.0, 0.0), + Gf.Vec3f(0.0, 1.0, 0.0), + Gf.Vec3f(0.0, 0.0, 1.0), + Gf.Vec3f(0.0, 0.0, -1.0), + ] + + faces = _surface_faces([(0, 1, 2, 3), (0, 2, 1, 4)], points, "test") + + assert len(faces) == 6 + assert (0, 1, 2) not in {tuple(sorted(face)) for face in faces} diff --git a/isaac_autodata_tests/utils/test_thin_plate_spline.py b/isaac_autodata_tests/utils/test_thin_plate_spline.py new file mode 100644 index 0000000..626f65c --- /dev/null +++ b/isaac_autodata_tests/utils/test_thin_plate_spline.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for AutoData's three-dimensional TPS implementation.""" + +import numpy as np + +from isaac_autodata_utils import thin_plate_spline as tps + +_SOURCE_POINTS = np.array([ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 0.0], + [1.0, 0.0, 1.0], +]) +_TARGET_POINTS = _SOURCE_POINTS.copy() +_TARGET_POINTS[0] += [0.10, -0.05, 0.02] +_TARGET_POINTS[1] += [0.20, 0.10, -0.03] +_TARGET_POINTS[4] += [-0.10, 0.15, 0.05] +_QUERY_POINTS = np.array([ + [0.25, 0.25, 0.25], + [0.75, 0.25, 0.25], +]) + + +def test_reduced_fit_matches_previous_tps_reference_outputs(): + linear, translation, weights = tps.fit_reduced( + source_points=_SOURCE_POINTS, + target_points=_TARGET_POINTS, + bend_coefficient=0.1, + rotation_coefficient=1e-3, + ) + + transformed = tps.evaluate( + query_points=_QUERY_POINTS, + linear=linear, + translation=translation, + weights=weights, + source_points=_SOURCE_POINTS, + ) + expected_transformed = np.array([ + [0.303313771813, 0.251697797185, 0.258325640304], + [0.821598859240, 0.310808443454, 0.249183096591], + ]) + np.testing.assert_allclose(transformed, expected_transformed, rtol=1e-10, atol=1e-10) + + jacobians = tps.gradient( + query_points=_QUERY_POINTS, + linear=linear, + translation=translation, + weights=weights, + source_points=_SOURCE_POINTS, + ) + expected_jacobians = np.array([ + [ + [1.032732261752, -0.163893736190, -0.132353662016], + [0.116302039264, 1.049445488374, 0.002135377114], + [-0.016366130876, 0.012051710831, 0.996281673744], + ], + [ + [1.032732261752, -0.235557062625, -0.167147111810], + [0.116302039264, 1.050479586533, -0.052135339689], + [-0.016366130876, 0.047883374048, 1.013678398641], + ], + ]) + np.testing.assert_allclose(jacobians, expected_jacobians, rtol=1e-10, atol=1e-10) + + +def test_regularized_fit_cost_matches_previous_tps_reference_output(): + linear, translation, weights = tps.fit( + source_points=_SOURCE_POINTS, + target_points=_TARGET_POINTS, + bend_coefficient=0.1, + rotation_coefficient=1e-3, + ) + registration_cost = tps.cost( + linear=linear, + translation=translation, + weights=weights, + source_points=_SOURCE_POINTS, + target_points=_TARGET_POINTS, + bend_coefficient=0.1, + ) + np.testing.assert_allclose(registration_cost, 0.0073160059705457805, rtol=1e-10, atol=1e-10) diff --git a/isaac_autodata_utils/thin_plate_spline.py b/isaac_autodata_utils/thin_plate_spline.py new file mode 100644 index 0000000..a640e4a --- /dev/null +++ b/isaac_autodata_utils/thin_plate_spline.py @@ -0,0 +1,265 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Three-dimensional thin-plate-spline fitting and evaluation. + +This utility module contains the small numerical surface SoftMimicGen needs for deformable-object +registration. It intentionally avoids the unrelated robotics stack that accompanied the former +Rapprentice dependency. +""" + +from __future__ import annotations + +import numpy as np + +_POINT_DIMENSION = 3 + + +def _points(points: np.ndarray, name: str) -> np.ndarray: + """Return finite three-dimensional points as double-precision values.""" + + points = np.asarray(points, dtype=np.float64) + assert ( + points.ndim == 2 and points.shape[1] == _POINT_DIMENSION + ), f"{name} must have shape (N, {_POINT_DIMENSION}), got {points.shape}" + assert np.isfinite(points).all(), f"{name} contains non-finite values" + return points + + +def _corresponding_points(source_points: np.ndarray, target_points: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Validate and return corresponding source and target points.""" + + source_points = _points(source_points, "source_points") + target_points = _points(target_points, "target_points") + assert ( + source_points.shape == target_points.shape + ), f"source_points and target_points must have matching shapes, got {source_points.shape} and {target_points.shape}" + assert ( + source_points.shape[0] >= _POINT_DIMENSION + 1 + ), f"TPS requires at least {_POINT_DIMENSION + 1} corresponding points, got {source_points.shape[0]}" + return source_points, target_points + + +def _kernel_matrix(left_points: np.ndarray, right_points: np.ndarray) -> np.ndarray: + """Return the three-dimensional TPS kernel ``K(r) = -r``.""" + + differences = left_points[:, None, :] - right_points[None, :, :] + return -np.linalg.norm(differences, axis=-1) + + +def _transform_parameters( + linear: np.ndarray, + translation: np.ndarray, + weights: np.ndarray, + source_point_count: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Validate and return TPS transform parameters.""" + + linear = np.asarray(linear, dtype=np.float64) + translation = np.asarray(translation, dtype=np.float64) + weights = np.asarray(weights, dtype=np.float64) + assert linear.shape == ( + _POINT_DIMENSION, + _POINT_DIMENSION, + ), f"linear must have shape ({_POINT_DIMENSION}, {_POINT_DIMENSION}), got {linear.shape}" + assert translation.shape == ( + _POINT_DIMENSION, + ), f"translation must have shape ({_POINT_DIMENSION},), got {translation.shape}" + assert weights.shape == ( + source_point_count, + _POINT_DIMENSION, + ), f"weights must have shape ({source_point_count}, {_POINT_DIMENSION}), got {weights.shape}" + return linear, translation, weights + + +def fit( + source_points: np.ndarray, + target_points: np.ndarray, + bend_coefficient: float, + rotation_coefficient: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Fit a regularized TPS transform between corresponding points. + + Args: + source_points: Source control points shaped ``(N, 3)``. + target_points: Target control points shaped ``(N, 3)``. + bend_coefficient: TPS bending regularization coefficient. + rotation_coefficient: Affine-rotation regularization coefficient. + + Returns: + A ``(linear, translation, weights)`` tuple describing the fitted transform. + """ + + source_points, target_points = _corresponding_points(source_points, target_points) + point_count, dimension = source_points.shape + kernel = _kernel_matrix(source_points, source_points) + rotation_ratio = bend_coefficient / rotation_coefficient if rotation_coefficient > 0 else 0.0 + + system = np.zeros((point_count + dimension + 1, point_count + dimension + 1)) + system[:point_count, :point_count] = kernel + diagonal = np.arange(point_count) + system[diagonal, diagonal] += bend_coefficient + system[:point_count, point_count : point_count + dimension] = source_points + system[:point_count, point_count + dimension] = 1.0 + system[point_count : point_count + dimension, :point_count] = source_points.T + system[point_count + dimension, :point_count] = 1.0 + system[ + point_count : point_count + dimension, + point_count : point_count + dimension, + ] = rotation_ratio * np.eye(dimension) + + right_hand_side = np.empty((point_count + dimension + 1, dimension)) + right_hand_side[:point_count] = target_points + right_hand_side[point_count : point_count + dimension] = rotation_ratio * np.eye(dimension) + right_hand_side[point_count + dimension] = 0.0 + + solution = np.linalg.solve(system, right_hand_side) + weights = solution[:point_count] + linear = solution[point_count : point_count + dimension] + translation = solution[point_count + dimension] + return linear, translation, weights + + +def fit_reduced( + source_points: np.ndarray, + target_points: np.ndarray, + bend_coefficient: float, + rotation_coefficient: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Fit a TPS transform in the null space of its affine constraints. + + Args: + source_points: Source control points shaped ``(N, 3)``. + target_points: Target control points shaped ``(N, 3)``. + bend_coefficient: TPS bending regularization coefficient. + rotation_coefficient: Affine-rotation regularization coefficient. + + Returns: + A ``(linear, translation, weights)`` tuple describing the fitted transform. + """ + + source_points, target_points = _corresponding_points(source_points, target_points) + point_count, dimension = source_points.shape + affine_column_count = dimension + 1 + affine_basis = np.column_stack((source_points, np.ones(point_count))) + left_singular_vectors, _, _ = np.linalg.svd(affine_basis, full_matrices=True) + null_space = left_singular_vectors[:, affine_column_count:] + + kernel = _kernel_matrix(source_points, source_points) + design = np.column_stack((source_points, np.ones(point_count), kernel @ null_space)) + system = design.T @ design + system[affine_column_count:, affine_column_count:] += bend_coefficient * null_space.T @ kernel @ null_space + right_hand_side = design.T @ target_points + system[:dimension, :dimension] += rotation_coefficient * np.eye(dimension) + right_hand_side[:dimension, :dimension] += rotation_coefficient * np.eye(dimension) + + solution = np.linalg.solve(system, right_hand_side) + linear = solution[:dimension] + translation = solution[dimension] + weights = null_space @ solution[affine_column_count:] + return linear, translation, weights + + +def evaluate( + query_points: np.ndarray, + linear: np.ndarray, + translation: np.ndarray, + weights: np.ndarray, + source_points: np.ndarray, +) -> np.ndarray: + """Evaluate a fitted TPS transform at query points. + + Args: + query_points: Points to transform, shaped ``(M, 3)``. + linear: Affine linear component, shaped ``(3, 3)``. + translation: Affine translation component, shaped ``(3,)``. + weights: Non-rigid kernel weights, shaped ``(N, 3)``. + source_points: Source control points, shaped ``(N, 3)``. + + Returns: + Transformed points shaped ``(M, 3)``. + """ + + query_points = _points(query_points, "query_points") + source_points = _points(source_points, "source_points") + linear, translation, weights = _transform_parameters( + linear, + translation, + weights, + source_points.shape[0], + ) + kernel = _kernel_matrix(query_points, source_points) + return kernel @ weights + query_points @ linear + translation[None, :] + + +def gradient( + query_points: np.ndarray, + linear: np.ndarray, + translation: np.ndarray, + weights: np.ndarray, + source_points: np.ndarray, +) -> np.ndarray: + """Evaluate the local Jacobian of a fitted TPS transform. + + Args: + query_points: Points at which to evaluate the Jacobian, shaped ``(M, 3)``. + linear: Affine linear component, shaped ``(3, 3)``. + translation: Affine translation component, shaped ``(3,)``. + weights: Non-rigid kernel weights, shaped ``(N, 3)``. + source_points: Source control points, shaped ``(N, 3)``. + + Returns: + Transform Jacobians shaped ``(M, 3, 3)``. + """ + + query_points = _points(query_points, "query_points") + source_points = _points(source_points, "source_points") + linear, _, weights = _transform_parameters(linear, translation, weights, source_points.shape[0]) + differences = query_points[:, None, :] - source_points[None, :, :] + distances = np.linalg.norm(differences, axis=-1) + directions = np.divide( + differences, + distances[:, :, None], + out=np.zeros_like(differences), + where=distances[:, :, None] != 0.0, + ) + kernel_gradient = np.einsum("mna,ng->mga", directions, weights) + return linear.T[None, :, :] - kernel_gradient + + +def cost( + linear: np.ndarray, + translation: np.ndarray, + weights: np.ndarray, + source_points: np.ndarray, + target_points: np.ndarray, + bend_coefficient: float, +) -> float: + """Return the residual-plus-bending cost of a fitted TPS transform. + + Args: + linear: Affine linear component, shaped ``(3, 3)``. + translation: Affine translation component, shaped ``(3,)``. + weights: Non-rigid kernel weights, shaped ``(N, 3)``. + source_points: Source control points, shaped ``(N, 3)``. + target_points: Target control points, shaped ``(N, 3)``. + bend_coefficient: TPS bending regularization coefficient. + + Returns: + Scalar residual-plus-bending cost. + """ + + source_points, target_points = _corresponding_points(source_points, target_points) + linear, translation, weights = _transform_parameters( + linear, + translation, + weights, + source_points.shape[0], + ) + kernel = _kernel_matrix(source_points, source_points) + predicted_points = kernel @ weights + source_points @ linear + translation[None, :] + residual_cost = np.square(predicted_points - target_points).sum() + bending_cost = bend_coefficient * np.sum(weights * (kernel @ weights)) + return float(residual_cost + bending_cost) diff --git a/pyproject.toml b/pyproject.toml index 64a5b72..92e70e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ known_third_party = [ "isaaclab_mimic", "isaaclab_tasks", "isaaclab_arena", - "rapprentice", ] # Imports from this repository diff --git a/scripts/convert_deformable_usd.py b/scripts/convert_deformable_usd.py new file mode 100644 index 0000000..57328f4 --- /dev/null +++ b/scripts/convert_deformable_usd.py @@ -0,0 +1,748 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Convert legacy PhysX volumetric deformable USD assets to the current schema. + +The converter recognizes three asset families: + +* legacy volumetric deformables whose simulation and collision tetrahedra are + stored in ``physxDeformable:*`` attributes on a render mesh; +* current OmniPhysics volumetric deformables; and +* current OmniPhysics surface deformables. + +Only the legacy volumetric representation is rewritten. Current assets are +validated and, when an output path is provided, copied without modification. +Unknown representations fail instead of being converted heuristically. + +Usage:: + + python scripts/convert_deformable_usd.py \ + --input_usd ./Rope.usd \ + --output_usd ./Rope_sim6.usd \ + --density 1000 \ + --static_friction 0.25 \ + --poissons_ratio 0.45 +""" + +from __future__ import annotations + +import argparse +import dataclasses +import enum +import shutil +from collections import Counter +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pxr import Gf, Sdf, Usd, UsdGeom, UsdShade, Vt +else: + Gf = None + Sdf = None + Usd = None + UsdGeom = None + UsdShade = None + Vt = None + + +class DeformableSchemaKind(enum.StrEnum): + """Deformable schema families recognized by the converter.""" + + LEGACY_VOLUME = "legacy_volume" + CURRENT_VOLUME = "current_volume" + CURRENT_SURFACE = "current_surface" + UNSUPPORTED = "unsupported" + + +@dataclasses.dataclass(frozen=True) +class AssetInspection: + """Summary of the deformable representation authored in a USD stage.""" + + kind: DeformableSchemaKind + default_prim_path: str | None + body_prim_paths: tuple[str, ...] + simulation_prim_paths: tuple[str, ...] + legacy_source_prim_paths: tuple[str, ...] + + +@dataclasses.dataclass(frozen=True) +class MaterialOverrides: + """Optional values used when the legacy material does not author a property.""" + + density: float | None = None + static_friction: float | None = None + dynamic_friction: float | None = None + youngs_modulus: float | None = None + poissons_ratio: float | None = None + elasticity_damping: float | None = None + + +_LEGACY_SIMULATION_POINTS = "physxDeformable:simulationPoints" +_LEGACY_SIMULATION_REST_POINTS = "physxDeformable:simulationRestPoints" +_LEGACY_SIMULATION_INDICES = "physxDeformable:simulationIndices" +_LEGACY_COLLISION_POINTS = "physxDeformable:collisionPoints" +_LEGACY_COLLISION_REST_POINTS = "physxDeformable:collisionRestPoints" +_LEGACY_COLLISION_INDICES = "physxDeformable:collisionIndices" + +_CURRENT_BODY_SCHEMAS = { + "OmniPhysicsDeformableBodyAPI", + "PhysxBaseDeformableBodyAPI", +} + + +def _load_usd_modules() -> None: + """Load USD modules after Isaac Sim starts. + + Isaac Sim requires :mod:`pxr` to be imported after :class:`AppLauncher` + creates the application. Unit tests may call this helper directly because + they do not launch Kit. + """ + + global Gf, Sdf, Usd, UsdGeom, UsdShade, Vt + + from pxr import Gf as gf_module + from pxr import Sdf as sdf_module + from pxr import Usd as usd_module + from pxr import UsdGeom as usd_geom_module + from pxr import UsdShade as usd_shade_module + from pxr import Vt as vt_module + + Gf = gf_module + Sdf = sdf_module + Usd = usd_module + UsdGeom = usd_geom_module + UsdShade = usd_shade_module + Vt = vt_module + + +def inspect_stage(stage: Usd.Stage) -> AssetInspection: + """Inspect a USD stage and classify its deformable representation. + + Args: + stage: Open USD stage to inspect. + + Returns: + The detected schema family and relevant prim paths. + """ + + legacy_sources: list[str] = [] + volume_simulation_prims: list[str] = [] + surface_simulation_prims: list[str] = [] + body_prims: list[str] = [] + + for prim in stage.Traverse(): + path = str(prim.GetPath()) + schemas = set(prim.GetAppliedSchemas()) + + if prim.HasAttribute(_LEGACY_SIMULATION_POINTS) and prim.HasAttribute(_LEGACY_SIMULATION_INDICES): + legacy_sources.append(path) + if "OmniPhysicsVolumeDeformableSimAPI" in schemas: + volume_simulation_prims.append(path) + if "OmniPhysicsSurfaceDeformableSimAPI" in schemas: + surface_simulation_prims.append(path) + if _CURRENT_BODY_SCHEMAS.issubset(schemas): + body_prims.append(path) + + if legacy_sources: + kind = DeformableSchemaKind.LEGACY_VOLUME + simulation_prims: list[str] = [] + elif surface_simulation_prims and not volume_simulation_prims: + kind = DeformableSchemaKind.CURRENT_SURFACE + simulation_prims = surface_simulation_prims + elif volume_simulation_prims and not surface_simulation_prims: + kind = DeformableSchemaKind.CURRENT_VOLUME + simulation_prims = volume_simulation_prims + else: + kind = DeformableSchemaKind.UNSUPPORTED + simulation_prims = volume_simulation_prims + surface_simulation_prims + + default_prim = stage.GetDefaultPrim() + default_prim_path = str(default_prim.GetPath()) if default_prim else None + return AssetInspection( + kind=kind, + default_prim_path=default_prim_path, + body_prim_paths=tuple(body_prims), + simulation_prim_paths=tuple(simulation_prims), + legacy_source_prim_paths=tuple(legacy_sources), + ) + + +def _tetrahedra_from_flat_indices( + indices: list[int] | Vt.IntArray, + vertex_count: int, + label: str, +) -> list[tuple[int, int, int, int]]: + """Validate and group a flat tetrahedral index array.""" + + flat_indices = [int(index) for index in indices] + assert flat_indices, f"{label} indices are empty" + assert len(flat_indices) % 4 == 0, f"{label} index count must be divisible by four" + + tetrahedra: list[tuple[int, int, int, int]] = [] + for offset in range(0, len(flat_indices), 4): + tet = tuple(flat_indices[offset : offset + 4]) + assert len(set(tet)) == 4, f"{label} tetrahedron {offset // 4} contains duplicate vertices: {tet}" + assert min(tet) >= 0, f"{label} tetrahedron {offset // 4} contains a negative index: {tet}" + assert ( + max(tet) < vertex_count + ), f"{label} tetrahedron {offset // 4} references vertex {max(tet)}, but the mesh has {vertex_count} vertices" + tetrahedra.append(tet) + return tetrahedra + + +def _surface_faces( + tetrahedra: list[tuple[int, int, int, int]], + points: list[Gf.Vec3f] | Vt.Vec3fArray, + label: str, +) -> list[tuple[int, int, int]]: + """Extract consistently oriented boundary triangles from tetrahedra.""" + + face_counts: Counter[tuple[int, int, int]] = Counter() + oriented_faces: dict[tuple[int, int, int], tuple[int, int, int]] = {} + + for tet_index, (a, b, c, d) in enumerate(tetrahedra): + point_a = Gf.Vec3d(points[a]) + point_b = Gf.Vec3d(points[b]) + point_c = Gf.Vec3d(points[c]) + point_d = Gf.Vec3d(points[d]) + signed_volume_six = Gf.Dot(point_b - point_a, Gf.Cross(point_c - point_a, point_d - point_a)) + assert abs(signed_volume_six) > 1.0e-15, f"{label} tetrahedron {tet_index} is degenerate" + + faces = [ + (b, c, d), + (a, d, c), + (a, b, d), + (a, c, b), + ] + if signed_volume_six < 0.0: + faces = [(face[0], face[2], face[1]) for face in faces] + + for face in faces: + key = tuple(sorted(face)) + face_counts[key] += 1 + oriented_faces.setdefault(key, face) + + non_manifold_faces = [key for key, count in face_counts.items() if count > 2] + assert not non_manifold_faces, f"{label} mesh has non-manifold tetrahedral faces: {non_manifold_faces[:5]}" + boundary_faces = [oriented_faces[key] for key, count in face_counts.items() if count == 1] + return sorted(boundary_faces) + + +def _required_array(prim: Usd.Prim, name: str) -> object: + """Read a required authored array attribute.""" + + attribute = prim.GetAttribute(name) + assert attribute and attribute.HasAuthoredValueOpinion(), f"Missing required attribute {name} on {prim.GetPath()}" + value = attribute.Get() + assert value is not None and len(value) > 0, f"Attribute {name} on {prim.GetPath()} is empty" + return value + + +def _optional_array(prim: Usd.Prim, name: str, fallback: object) -> object: + """Read an optional array attribute or return a fallback value.""" + + attribute = prim.GetAttribute(name) + if attribute and attribute.HasAuthoredValueOpinion(): + value = attribute.Get() + if value is not None and len(value) > 0: + return value + return fallback + + +def _transform_points( + points: list[Gf.Vec3f] | Vt.Vec3fArray, + transform: Gf.Matrix4d, +) -> Vt.Vec3fArray: + """Transform points and return a float-precision USD array.""" + + return Vt.Vec3fArray([Gf.Vec3f(transform.Transform(Gf.Vec3d(point))) for point in points]) + + +def _apply_api(prim: Usd.Prim, schema_name: str, instance_name: str | None = None) -> None: + """Apply a codeless API schema and fail if it is unavailable.""" + + applied = prim.ApplyAPI(schema_name, instance_name) if instance_name else prim.ApplyAPI(schema_name) + assert applied, f"Could not apply {schema_name} to {prim.GetPath()}; launch the script through Isaac Sim 6" + + +def _remove_api_schema(prim: Usd.Prim, schema_name: str) -> None: + """Remove an authored API token, including schemas no longer registered.""" + + schemas = prim.GetMetadata("apiSchemas") + if schemas is None: + return + applied_schemas = [schema for schema in schemas.GetAppliedItems() if schema != schema_name] + prim.SetMetadata("apiSchemas", Sdf.TokenListOp.CreateExplicit(applied_schemas)) + + +def _author_pose(prim: Usd.Prim, points: Vt.Vec3fArray) -> None: + """Author the default deformable bind pose on a prim.""" + + _apply_api(prim, "OmniPhysicsDeformablePoseAPI", "default") + prim.CreateAttribute( + "deformablePose:default:omniphysics:points", + Sdf.ValueTypeNames.Point3fArray, + custom=False, + ).Set(points) + prim.CreateAttribute( + "deformablePose:default:omniphysics:purposes", + Sdf.ValueTypeNames.TokenArray, + custom=False, + ).Set(Vt.TokenArray(["bindPose"])) + + +def _author_tet_mesh( + stage: Usd.Stage, + path: Sdf.Path, + points: Vt.Vec3fArray, + tetrahedra: list[tuple[int, int, int, int]], + surface_faces: list[tuple[int, int, int]], +) -> Usd.Prim: + """Create an explicit tetrahedral mesh.""" + + assert not stage.GetPrimAtPath(path), f"Output prim already exists: {path}" + mesh = UsdGeom.TetMesh.Define(stage, path) + mesh.CreatePointsAttr(points) + mesh.CreateTetVertexIndicesAttr(Vt.Vec4iArray([Gf.Vec4i(*tet) for tet in tetrahedra])) + mesh.CreateSurfaceFaceVertexIndicesAttr(Vt.Vec3iArray([Gf.Vec3i(*face) for face in surface_faces])) + mesh.CreatePurposeAttr(UsdGeom.Tokens.guide) + return mesh.GetPrim() + + +def _legacy_scalar(prim: Usd.Prim, name: str) -> float | int | bool | None: + """Read a legacy scalar when it has an authored value.""" + + attribute = prim.GetAttribute(name) + if not attribute or not attribute.HasAuthoredValueOpinion(): + return None + return attribute.Get() + + +def _find_physics_material(stage: Usd.Stage, source_prim: Usd.Prim, body_prim: Usd.Prim) -> Usd.Prim: + """Find the legacy physics material or create one under the body root.""" + + binding = source_prim.GetRelationship("material:binding:physics") + if binding: + targets = binding.GetTargets() + if targets: + material_prim = stage.GetPrimAtPath(targets[0]) + if material_prim: + return material_prim + + for prim in stage.Traverse(): + if any(attribute.GetName().startswith("physxDeformableBodyMaterial:") for attribute in prim.GetAttributes()): + return prim + + material_path = body_prim.GetPath().AppendChild("PhysicsMaterial") + return UsdShade.Material.Define(stage, material_path).GetPrim() + + +def _author_material( + material_prim: Usd.Prim, + overrides: MaterialOverrides, +) -> list[str]: + """Migrate legacy material values and report properties left to schema defaults.""" + + _apply_api(material_prim, "OmniPhysicsDeformableMaterialAPI") + _apply_api(material_prim, "PhysxDeformableMaterialAPI") + + mappings: tuple[tuple[str, str, str, Sdf.ValueTypeName, float | None], ...] = ( + ( + "density", + "physxDeformableBodyMaterial:density", + "omniphysics:density", + Sdf.ValueTypeNames.Float, + overrides.density, + ), + ( + "static friction", + "physxDeformableBodyMaterial:staticFriction", + "omniphysics:staticFriction", + Sdf.ValueTypeNames.Float, + overrides.static_friction, + ), + ( + "dynamic friction", + "physxDeformableBodyMaterial:dynamicFriction", + "omniphysics:dynamicFriction", + Sdf.ValueTypeNames.Float, + overrides.dynamic_friction, + ), + ( + "Young's modulus", + "physxDeformableBodyMaterial:youngsModulus", + "omniphysics:youngsModulus", + Sdf.ValueTypeNames.Float, + overrides.youngs_modulus, + ), + ( + "Poisson's ratio", + "physxDeformableBodyMaterial:poissonsRatio", + "omniphysics:poissonsRatio", + Sdf.ValueTypeNames.Float, + overrides.poissons_ratio, + ), + ( + "elasticity damping", + "physxDeformableBodyMaterial:elasticityDamping", + "physxDeformableMaterial:elasticityDamping", + Sdf.ValueTypeNames.Float, + overrides.elasticity_damping, + ), + ) + + warnings: list[str] = [] + for label, legacy_name, current_name, value_type, override in mappings: + legacy_value = _legacy_scalar(material_prim, legacy_name) + value = override if override is not None else legacy_value + if value is None: + warnings.append(f"{label} is not authored; Isaac Sim 6 will use the schema default") + else: + material_prim.CreateAttribute(current_name, value_type, custom=False).Set(float(value)) + + for attribute in list(material_prim.GetAuthoredAttributes()): + if attribute.GetName().startswith("physxDeformableBodyMaterial:"): + material_prim.RemoveProperty(attribute.GetName()) + return warnings + + +def _copy_current_asset(input_path: Path, output_path: Path, overwrite: bool) -> None: + """Copy an already-current asset without rewriting its USD layer.""" + + if input_path.resolve() == output_path.resolve(): + assert overwrite, "Input and output paths are identical; pass --overwrite to validate in place" + return + assert overwrite or not output_path.exists(), f"Output file already exists: {output_path}" + output_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(input_path, output_path) + + +def _convert_legacy_volume( + input_path: Path, + output_path: Path, + inspection: AssetInspection, + source_prim_path: str | None, + body_prim_path: str | None, + overrides: MaterialOverrides, + overwrite: bool, +) -> list[str]: + """Convert one legacy volumetric deformable and export a flattened stage.""" + + assert overwrite or not output_path.exists(), f"Output file already exists: {output_path}" + assert ( + input_path.resolve() != output_path.resolve() or overwrite + ), "Input and output paths are identical; pass --overwrite to replace the source" + + source_stage = Usd.Stage.Open(str(input_path)) + assert source_stage, f"Could not open input USD: {input_path}" + flattened_layer = source_stage.Flatten() + stage = Usd.Stage.Open(flattened_layer) + assert stage, f"Could not create a writable stage for {input_path}" + + candidate_paths = inspection.legacy_source_prim_paths + if source_prim_path is None: + assert len(candidate_paths) == 1, ( + "Expected exactly one legacy deformable mesh, found " + f"{len(candidate_paths)}: {list(candidate_paths)}. Pass --source_prim." + ) + source_prim_path = candidate_paths[0] + assert ( + source_prim_path in candidate_paths + ), f"Source prim {source_prim_path} is not a detected legacy deformable mesh: {list(candidate_paths)}" + + source_prim = stage.GetPrimAtPath(source_prim_path) + assert source_prim and source_prim.IsA(UsdGeom.Mesh), f"Legacy source prim must be a Mesh: {source_prim_path}" + + if body_prim_path is None: + body_prim_path = str(source_prim.GetParent().GetPath()) + body_prim = stage.GetPrimAtPath(body_prim_path) + assert body_prim, f"Body prim does not exist: {body_prim_path}" + assert source_prim.GetPath().HasPrefix( + body_prim.GetPath() + ), f"Source prim {source_prim_path} must be under body prim {body_prim_path}" + + simulation_pose_points = _required_array(source_prim, _LEGACY_SIMULATION_POINTS) + simulation_rest_points = _optional_array( + source_prim, + _LEGACY_SIMULATION_REST_POINTS, + simulation_pose_points, + ) + collision_pose_points = _required_array(source_prim, _LEGACY_COLLISION_POINTS) + collision_rest_points = _optional_array( + source_prim, + _LEGACY_COLLISION_REST_POINTS, + collision_pose_points, + ) + + simulation_tetrahedra = _tetrahedra_from_flat_indices( + _required_array(source_prim, _LEGACY_SIMULATION_INDICES), + len(simulation_rest_points), + "simulation", + ) + collision_tetrahedra = _tetrahedra_from_flat_indices( + _required_array(source_prim, _LEGACY_COLLISION_INDICES), + len(collision_rest_points), + "collision", + ) + + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + source_to_world = xform_cache.GetLocalToWorldTransform(source_prim) + body_to_world = xform_cache.GetLocalToWorldTransform(body_prim) + source_to_body = source_to_world * body_to_world.GetInverse() + + simulation_pose_points = _transform_points(simulation_pose_points, source_to_body) + simulation_rest_points = _transform_points(simulation_rest_points, source_to_body) + collision_pose_points = _transform_points(collision_pose_points, source_to_body) + collision_rest_points = _transform_points(collision_rest_points, source_to_body) + + simulation_faces = _surface_faces(simulation_tetrahedra, simulation_rest_points, "simulation") + collision_faces = _surface_faces(collision_tetrahedra, collision_rest_points, "collision") + + simulation_prim = _author_tet_mesh( + stage, + body_prim.GetPath().AppendChild("simulation_mesh"), + simulation_rest_points, + simulation_tetrahedra, + simulation_faces, + ) + _apply_api(simulation_prim, "OmniPhysicsVolumeDeformableSimAPI") + _author_pose(simulation_prim, simulation_pose_points) + simulation_prim.CreateAttribute( + "omniphysics:restShapePoints", + Sdf.ValueTypeNames.Point3fArray, + custom=False, + ).Set(simulation_rest_points) + simulation_prim.CreateAttribute( + "omniphysics:restTetVtxIndices", + Sdf.ValueTypeNames.Int4Array, + custom=False, + ).Set(Vt.Vec4iArray([Gf.Vec4i(*tet) for tet in simulation_tetrahedra])) + + collision_prim = _author_tet_mesh( + stage, + body_prim.GetPath().AppendChild("collision_mesh"), + collision_rest_points, + collision_tetrahedra, + collision_faces, + ) + _apply_api(collision_prim, "PhysicsCollisionAPI") + _apply_api(collision_prim, "PhysxCollisionAPI") + _author_pose(collision_prim, collision_pose_points) + + contact_offset = _legacy_scalar(source_prim, "physxCollision:contactOffset") + rest_offset = _legacy_scalar(source_prim, "physxCollision:restOffset") + if contact_offset is not None: + collision_prim.CreateAttribute( + "physxCollision:contactOffset", + Sdf.ValueTypeNames.Float, + custom=False, + ).Set(float(contact_offset)) + if rest_offset is not None: + collision_prim.CreateAttribute( + "physxCollision:restOffset", + Sdf.ValueTypeNames.Float, + custom=False, + ).Set(float(rest_offset)) + + _apply_api(body_prim, "OmniPhysicsDeformableBodyAPI") + _apply_api(body_prim, "PhysxBaseDeformableBodyAPI") + UsdShade.MaterialBindingAPI.Apply(body_prim) + + solver_iterations = _legacy_scalar(source_prim, "physxDeformable:solverPositionIterationCount") + linear_damping = _legacy_scalar(source_prim, "physxDeformable:vertexVelocityDamping") + enable_ccd = _legacy_scalar(source_prim, "physxDeformable:enableCCD") + if solver_iterations is not None: + body_prim.CreateAttribute( + "physxDeformableBody:solverPositionIterationCount", + Sdf.ValueTypeNames.UInt, + custom=False, + ).Set(int(solver_iterations)) + if linear_damping is not None: + body_prim.CreateAttribute( + "physxDeformableBody:linearDamping", + Sdf.ValueTypeNames.Float, + custom=False, + ).Set(float(linear_damping)) + if enable_ccd is not None: + body_prim.CreateAttribute( + "physxDeformableBody:enableSpeculativeCCD", + Sdf.ValueTypeNames.Bool, + custom=False, + ).Set(bool(enable_ccd)) + + material_prim = _find_physics_material(stage, source_prim, body_prim) + warnings = _author_material(material_prim, overrides) + body_prim.CreateRelationship("material:binding:physics", custom=False).SetTargets([material_prim.GetPath()]) + + render_points = _required_array(source_prim, "points") + _author_pose(source_prim, Vt.Vec3fArray(render_points)) + source_prim.RemoveProperty("normals") + source_prim.RemoveProperty("material:binding:physics") + _remove_api_schema(source_prim, "PhysxDeformableBodyAPI") + _remove_api_schema(source_prim, "PhysxCollisionAPI") + _remove_api_schema(source_prim, "PhysicsCollisionAPI") + for attribute in list(source_prim.GetAuthoredAttributes()): + if attribute.GetName().startswith(("physxDeformable:", "physxCollision:")): + source_prim.RemoveProperty(attribute.GetName()) + + output_path.parent.mkdir(parents=True, exist_ok=True) + assert stage.GetRootLayer().Export(str(output_path)), f"Could not export converted USD: {output_path}" + return warnings + + +def _validate_current_asset(stage: Usd.Stage, inspection: AssetInspection) -> None: + """Check the minimum invariants expected by Isaac Lab's deformable view.""" + + assert inspection.body_prim_paths, "Current deformable asset has no recognized body prim" + assert inspection.simulation_prim_paths, "Current deformable asset has no recognized simulation mesh" + + for path in inspection.simulation_prim_paths: + prim = stage.GetPrimAtPath(path) + points = prim.GetAttribute("points").Get() + assert points is not None and len(points) > 0, f"Simulation mesh has no points: {path}" + pose_points = prim.GetAttribute("deformablePose:default:omniphysics:points").Get() + assert pose_points is not None and len(pose_points) == len( + points + ), f"Simulation pose count does not match point count on {path}" + + if inspection.kind is DeformableSchemaKind.CURRENT_VOLUME: + tetrahedra = prim.GetAttribute("tetVertexIndices").Get() + assert tetrahedra is not None and len(tetrahedra) > 0, f"Volume mesh has no tetrahedra: {path}" + elif inspection.kind is DeformableSchemaKind.CURRENT_SURFACE: + triangles = prim.GetAttribute("omniphysics:restTriVtxIndices").Get() + assert triangles is not None and len(triangles) > 0, f"Surface mesh has no triangles: {path}" + + +def _print_inspection(stage: Usd.Stage, inspection: AssetInspection) -> None: + """Print a concise human-readable asset report.""" + + print(f"Schema kind: {inspection.kind.value}") + print(f"Default prim: {inspection.default_prim_path or ''}") + print(f"Body prims: {list(inspection.body_prim_paths)}") + print(f"Simulation prims: {list(inspection.simulation_prim_paths)}") + print(f"Legacy source prims: {list(inspection.legacy_source_prim_paths)}") + + for path in inspection.simulation_prim_paths: + prim = stage.GetPrimAtPath(path) + points = prim.GetAttribute("points").Get() + if inspection.kind is DeformableSchemaKind.CURRENT_VOLUME: + elements = prim.GetAttribute("tetVertexIndices").Get() + element_label = "tetrahedra" + else: + elements = prim.GetAttribute("omniphysics:restTriVtxIndices").Get() + element_label = "triangles" + print(f" {path}: {len(points)} points, {len(elements)} {element_label}") + + +def _build_parser() -> argparse.ArgumentParser: + """Build the command-line parser.""" + + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--input_usd", type=Path, required=True, help="Input deformable USD file.") + parser.add_argument( + "--output_usd", + type=Path, + default=None, + help="Output USD file. Required when converting a legacy asset.", + ) + parser.add_argument( + "--source_prim", + type=str, + default=None, + help="Legacy render/deformable Mesh prim. Required only when multiple candidates are present.", + ) + parser.add_argument( + "--body_prim", + type=str, + default=None, + help="Body root prim. Defaults to the legacy source mesh's parent.", + ) + parser.add_argument("--overwrite", action="store_true", help="Allow replacing an existing output file.") + parser.add_argument( + "--validate_only", + action="store_true", + help="Inspect and validate the input without converting or copying it.", + ) + parser.add_argument("--density", type=float, default=None, help="Override deformable density [kg/m^3].") + parser.add_argument("--static_friction", type=float, default=None, help="Override static friction coefficient.") + parser.add_argument("--dynamic_friction", type=float, default=None, help="Override dynamic friction coefficient.") + parser.add_argument("--youngs_modulus", type=float, default=None, help="Override Young's modulus [Pa].") + parser.add_argument("--poissons_ratio", type=float, default=None, help="Override Poisson's ratio.") + parser.add_argument("--elasticity_damping", type=float, default=None, help="Override elasticity damping.") + return parser + + +def main() -> None: + """Run schema detection, conversion, and output validation.""" + + from isaaclab.app import AppLauncher + + parser = _build_parser() + AppLauncher.add_app_launcher_args(parser) + args = parser.parse_args() + + input_path = args.input_usd.expanduser().resolve() + assert input_path.is_file(), f"Input USD does not exist: {input_path}" + output_path = args.output_usd.expanduser().resolve() if args.output_usd is not None else None + + app_launcher = AppLauncher(args) + simulation_app = app_launcher.app + try: + _load_usd_modules() + stage = Usd.Stage.Open(str(input_path)) + assert stage, f"Could not open input USD: {input_path}" + inspection = inspect_stage(stage) + _print_inspection(stage, inspection) + + if inspection.kind is DeformableSchemaKind.UNSUPPORTED: + raise AssertionError("Unsupported or mixed deformable schema; no output was written") + + if args.validate_only: + if inspection.kind is not DeformableSchemaKind.LEGACY_VOLUME: + _validate_current_asset(stage, inspection) + print("Validation passed; no output was written.") + return + + if inspection.kind is DeformableSchemaKind.LEGACY_VOLUME: + assert output_path is not None, "--output_usd is required for a legacy asset conversion" + warnings = _convert_legacy_volume( + input_path=input_path, + output_path=output_path, + inspection=inspection, + source_prim_path=args.source_prim, + body_prim_path=args.body_prim, + overrides=MaterialOverrides( + density=args.density, + static_friction=args.static_friction, + dynamic_friction=args.dynamic_friction, + youngs_modulus=args.youngs_modulus, + poissons_ratio=args.poissons_ratio, + elasticity_damping=args.elasticity_damping, + ), + overwrite=args.overwrite, + ) + converted_stage = Usd.Stage.Open(str(output_path)) + assert converted_stage, f"Could not reopen converted USD: {output_path}" + converted_inspection = inspect_stage(converted_stage) + assert ( + converted_inspection.kind is DeformableSchemaKind.CURRENT_VOLUME + ), f"Converted output classified as {converted_inspection.kind.value}" + _validate_current_asset(converted_stage, converted_inspection) + print(f"Converted asset written to: {output_path}") + _print_inspection(converted_stage, converted_inspection) + for warning in warnings: + print(f"WARNING: {warning}") + else: + _validate_current_asset(stage, inspection) + print("Asset already uses a current Isaac Sim deformable schema; conversion is not required.") + if output_path is not None: + _copy_current_asset(input_path, output_path, args.overwrite) + print(f"Unmodified compatible asset copied to: {output_path}") + finally: + simulation_app.close() + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 988be0e..376c7d3 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,6 @@ ISAAC_AUTO_DATA_VERSION_NUMBER = "0.1.0" RUNTIME_DEPS = [ - "rapprentice @ git+https://github.com/masoudmoghani/rapprentice.git@512894df66ec4d0e608e409f7dac4ef448578f78", "typing_extensions", "pyzmq", ] From b5f0d680036b61c902eddb5275166132bde06a18 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Mon, 10 Aug 2026 11:43:05 -0700 Subject: [PATCH 06/15] fix scene state for nodal --- isaac_autodata_core/data_generator.py | 6 +- .../franka_rope/mdp/__init__.py | 3 +- .../isaac_lab_arena/franka_rope/mdp/events.py | 33 ----- .../envs/isaac_lab_arena/franka_rope/task.py | 10 +- .../tasks/franka_rope_softmimicgen.yaml | 1 + .../datastream/datastream.py | 3 +- isaac_autodata_interfaces/env/__init__.py | 2 + .../env/isaaclab_env_interface.py | 77 ++++++++-- isaac_autodata_interfaces/env/recorders.py | 47 +++++++ .../env/reset_request.py | 25 ++++ isaac_autodata_interfaces/env/scene_state.py | 42 ++++++ .../tasks/generation_policy_spec.py | 4 + .../interfaces/env/test_env_loop.py | 133 ++++++++++++++++++ .../interfaces/env/test_scene_state.py | 89 ++++++++++++ .../tasks/test_generation_policy_spec.py | 4 +- 15 files changed, 422 insertions(+), 57 deletions(-) create mode 100644 isaac_autodata_interfaces/env/recorders.py create mode 100644 isaac_autodata_interfaces/env/reset_request.py create mode 100644 isaac_autodata_interfaces/env/scene_state.py create mode 100644 isaac_autodata_tests/interfaces/env/test_env_loop.py create mode 100644 isaac_autodata_tests/interfaces/env/test_scene_state.py diff --git a/isaac_autodata_core/data_generator.py b/isaac_autodata_core/data_generator.py index 2d8ec01..084d1f3 100644 --- a/isaac_autodata_core/data_generator.py +++ b/isaac_autodata_core/data_generator.py @@ -30,6 +30,7 @@ transform_source_data_segment_using_object_pose, ) from isaac_autodata_core.waypoint import MultiWaypoint, Waypoint, WaypointSequence, WaypointTrajectory +from isaac_autodata_interfaces.env.reset_request import EnvResetRequest from isaac_autodata_interfaces.tasks.subtask_constraint_spec import ( SubTaskConstraintCoordinationScheme, SubTaskConstraintType, @@ -654,8 +655,9 @@ async def _reset_environment_for_generation( # Recorder + reset queue stay on env. The initial scene state # snapshot is read through the Datastream interface. self.datastream.get_env().recorder_manager.reset(env_ids=env_id_tensor) - await env_reset_queue.put(env_id) - await env_reset_queue.join() + completion = asyncio.get_running_loop().create_future() + await env_reset_queue.put(EnvResetRequest(env_id=env_id, completion=completion)) + await completion return env_id_tensor, self.datastream.get_scene_state(is_relative=True) def _build_runtime_subtask_constraints(self) -> dict: diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py index daa77c3..0301607 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py @@ -5,7 +5,7 @@ """MDP terms used by the Arena Franka rope task.""" -from .events import reset_rope_end_tracking, reset_rope_nodal_state, settle_rope_after_reset +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 @@ -19,5 +19,4 @@ "reset_rope_nodal_state", "rope_below_minimum", "rope_ends_close_tracked", - "settle_rope_after_reset", ] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py index 57285d4..24ef741 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py @@ -107,36 +107,3 @@ def reset_rope_nodal_state( 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) - - -def settle_rope_after_reset( - env: ManagerBasedRLEnv, - env_ids: torch.Tensor, - settling_steps: int = 10, -) -> None: - """Advance zero-action control iterations after nodal-state reset. - - PhysX advances the full simulation when any subset of environments resets, so - zero actions are deliberately applied to all environments. - - Args: - env: Arena manager-based environment. - env_ids: Reset environment indices. Used to satisfy the reset-event interface. - settling_steps: Number of control iterations to advance. - """ - - del env_ids - if settling_steps <= 0: - return - - zero_actions = torch.zeros( - (env.num_envs, env.action_manager.total_action_dim), - device=env.device, - ) - for _ in range(settling_steps): - env.action_manager.process_action(zero_actions) - for _ in range(env.cfg.decimation): - env.action_manager.apply_action() - env.scene.write_data_to_sim() - env.sim.step(render=True) - env.scene.update(dt=env.physics_dt) diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py index f312871..7866c8c 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py @@ -53,7 +53,7 @@ def __post_init__(self) -> None: @configclass class FrankaRopeTaskEventsCfg: - """Rope randomization, endpoint tracking, and post-reset settling.""" + """Rope randomization and endpoint tracking events.""" reset_rope_end_tracking = EventTerm( func=mdp.reset_rope_end_tracking, @@ -84,12 +84,6 @@ class FrankaRopeTaskEventsCfg: }, ) - settle_rope_after_reset = EventTerm( - func=mdp.settle_rope_after_reset, - mode="reset", - params={"settling_steps": 10}, - ) - @configclass class FrankaRopeTaskTerminationsCfg: @@ -133,7 +127,7 @@ def get_termination_cfg(self) -> FrankaRopeTaskTerminationsCfg: return self.termination_cfg def get_events_cfg(self) -> FrankaRopeTaskEventsCfg: - """Return deformable reset and settling events.""" + """Return deformable reset events.""" return self.events_cfg diff --git a/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml b/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml index b119db8..c8001e8 100644 --- a/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml +++ b/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml @@ -18,6 +18,7 @@ generation_policy: 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 diff --git a/isaac_autodata_interfaces/datastream/datastream.py b/isaac_autodata_interfaces/datastream/datastream.py index 4ed9e8f..a157ceb 100644 --- a/isaac_autodata_interfaces/datastream/datastream.py +++ b/isaac_autodata_interfaces/datastream/datastream.py @@ -14,6 +14,7 @@ from isaac_autodata_core.pool import DataGenInfoPool from isaac_autodata_interfaces.embodiments.embodiment_adapter import EmbodimentAdapter +from isaac_autodata_interfaces.env.scene_state import get_scene_state from isaac_autodata_interfaces.tasks.generation_policy_spec import GenerationPolicy from isaac_autodata_interfaces.tasks.subtask_constraint_spec import SubtaskConstraint from isaac_autodata_interfaces.tasks.subtask_spec import Subtask, SubtaskAlgoParams @@ -345,7 +346,7 @@ def get_scene_state(self, is_relative: bool = True) -> dict: hatch for callers that need the full dict (e.g. recorder ``initial_state``). """ - return self.env.scene.get_state(is_relative=is_relative) + return get_scene_state(self.env.scene, is_relative=is_relative) # ------------------------------------------------------------------ # Collision-world source diff --git a/isaac_autodata_interfaces/env/__init__.py b/isaac_autodata_interfaces/env/__init__.py index 542d7b0..055ddcc 100644 --- a/isaac_autodata_interfaces/env/__init__.py +++ b/isaac_autodata_interfaces/env/__init__.py @@ -13,9 +13,11 @@ setup_env_config, setup_output_paths, ) +from isaac_autodata_interfaces.env.reset_request import EnvResetRequest __all__ = [ "EnvironmentProfile", + "EnvResetRequest", "apply_env_profile", "get_env_name_from_dataset", "setup_output_paths", diff --git a/isaac_autodata_interfaces/env/isaaclab_env_interface.py b/isaac_autodata_interfaces/env/isaaclab_env_interface.py index f37e584..52843a6 100644 --- a/isaac_autodata_interfaces/env/isaaclab_env_interface.py +++ b/isaac_autodata_interfaces/env/isaaclab_env_interface.py @@ -24,7 +24,6 @@ import isaaclab.sim as sim_utils from isaaclab.assets import RigidObjectCfg -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 @@ -33,6 +32,8 @@ from isaaclab_tasks.utils.parse_cfg import parse_env_cfg from isaac_autodata_interfaces.env.env_profile import EnvironmentProfile, convert_event_params +from isaac_autodata_interfaces.env.recorders import make_action_state_recorder_manager_cfg +from isaac_autodata_interfaces.env.reset_request import EnvResetRequest from isaac_autodata_interfaces.tasks.generation_policy_spec import GenerationPolicy @@ -194,7 +195,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() else: env_cfg.recorders = recorder_cfg env_cfg.recorders.dataset_export_dir_path = output_dir @@ -230,7 +231,7 @@ def env_loop( Args: env: The environment to run the main step loop on. - env_reset_queue: The asyncio queue carrying per-env reset requests. + env_reset_queue: The asyncio queue carrying :class:`EnvResetRequest` instances. env_action_queue: The asyncio queue carrying ``(env_id, action)`` pairs to execute. asyncio_event_loop: The main asyncio event loop. generation_policy_params: The task descriptor's generation config (source of truth). @@ -243,13 +244,17 @@ def env_loop( """ num_trials = generation_policy_params.num_trials guarantee_success = generation_policy_params.guarantee_success + reset_settling_steps = generation_policy_params.reset_settling_steps + assert reset_settling_steps >= 0, "reset_settling_steps must be non-negative" env_id_tensor = torch.tensor([0], dtype=torch.int64, device=env.device) + settling_requests: dict[int, EnvResetRequest] = {} + settling_steps_remaining: dict[int, int] = {} prev_num_attempts = 0 # simulate environment -- run everything in inference mode with contextlib.suppress(KeyboardInterrupt) and torch.inference_mode(): while True: # check if any environment needs to be reset while waiting for actions - while env_action_queue.qsize() != env.num_envs: + while env_action_queue.qsize() + len(settling_requests) != env.num_envs: asyncio_event_loop.run_until_complete(asyncio.sleep(0)) if data_gen_tasks is not None and data_gen_tasks.done(): exc = data_gen_tasks.exception() @@ -257,25 +262,77 @@ def env_loop( raise exc return False while not env_reset_queue.empty(): - env_id_tensor[0] = env_reset_queue.get_nowait() + request = env_reset_queue.get_nowait() + assert isinstance( + request, EnvResetRequest + ), f"env_reset_queue entries must be EnvResetRequest instances, got {type(request).__name__}" + assert ( + 0 <= request.env_id < env.num_envs + ), f"Reset environment index {request.env_id} is outside [0, {env.num_envs})." + assert ( + request.env_id not in settling_requests + ), f"Environment {request.env_id} already has a reset settling request in progress." + env_id_tensor[0] = request.env_id env.reset(env_ids=env_id_tensor) - env_reset_queue.task_done() + + if reset_settling_steps > 0: + settling_requests[request.env_id] = request + settling_steps_remaining[request.env_id] = reset_settling_steps + else: + request.completion.set_result(None) + env_reset_queue.task_done() + + expected_action_count = env.num_envs - len(settling_requests) + assert env_action_queue.qsize() <= expected_action_count, ( + f"Received {env_action_queue.qsize()} queued actions for only {expected_action_count} " + "non-settling environments." + ) actions = torch.zeros(env.action_space.shape) - # batch-fetch all per-env actions in one gather instead of sequential blocking calls - get_tasks = [env_action_queue.get() for _ in range(env.num_envs)] - results = asyncio_event_loop.run_until_complete(asyncio.gather(*get_tasks)) + # Settling environments use the zero action. Batch-fetch one real action for every + # other environment so their generation trajectories continue without interruption. + action_count = env.num_envs - len(settling_requests) + get_tasks = [env_action_queue.get() for _ in range(action_count)] + results = asyncio_event_loop.run_until_complete(asyncio.gather(*get_tasks)) if get_tasks else [] + action_env_ids: set[int] = set() for env_id, action in results: + assert ( + env_id not in settling_requests + ), f"Environment {env_id} supplied an action while its reset was still settling." + assert env_id not in action_env_ids, f"Received multiple actions for environment {env_id}." + action_env_ids.add(env_id) actions[env_id] = action + expected_action_env_ids = set(range(env.num_envs)) - settling_requests.keys() + assert ( + action_env_ids == expected_action_env_ids + ), f"Expected actions for environments {sorted(expected_action_env_ids)}, got {sorted(action_env_ids)}." # perform action on environment env.step(actions) # mark done so the data generators can continue with the step results - for _ in range(env.num_envs): + for _ in range(action_count): env_action_queue.task_done() + settled_env_ids: list[int] = [] + for env_id in settling_steps_remaining: + settling_steps_remaining[env_id] -= 1 + if settling_steps_remaining[env_id] == 0: + settled_env_ids.append(env_id) + + if settled_env_ids: + settled_env_ids_tensor = torch.tensor(settled_env_ids, dtype=torch.int64, device=env.device) + # Discard temporary settle frames, then capture the settled state as the generated + # episode's initial state before releasing the corresponding generators. + env.recorder_manager.reset(env_ids=settled_env_ids_tensor) + env.recorder_manager.record_post_reset(env_ids=settled_env_ids_tensor) + for env_id in settled_env_ids: + request = settling_requests.pop(env_id) + settling_steps_remaining.pop(env_id) + request.completion.set_result(None) + env_reset_queue.task_done() + if prev_num_attempts != stats["num_attempts"]: prev_num_attempts = stats["num_attempts"] num_success = stats["num_success"] diff --git a/isaac_autodata_interfaces/env/recorders.py b/isaac_autodata_interfaces/env/recorders.py new file mode 100644 index 0000000..249845d --- /dev/null +++ b/isaac_autodata_interfaces/env/recorders.py @@ -0,0 +1,47 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""AutoData recorder terms for parallel scene-state capture.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from isaaclab.envs.mdp.recorders.recorders_cfg import ActionStateRecorderManagerCfg +from isaaclab.managers.recorder_manager import RecorderTerm + +from isaac_autodata_interfaces.env.scene_state import get_scene_state + + +class InitialStateRecorder(RecorderTerm): + """Record the correctly env-relative initial scene state after reset.""" + + def record_post_reset(self, env_ids: Sequence[int] | None): + """Return initial state for the reset environments.""" + + def select_envs(value): + if isinstance(value, dict): + return {key: select_envs(item) for key, item in value.items()} + return value if env_ids is None else value[env_ids] + + return "initial_state", select_envs(get_scene_state(self._env.scene, is_relative=True)) + + +class PostStepStatesRecorder(RecorderTerm): + """Record correctly env-relative scene state after each environment step.""" + + def record_post_step(self): + """Return state for every environment.""" + + return "states", get_scene_state(self._env.scene, is_relative=True) + + +def make_action_state_recorder_manager_cfg() -> ActionStateRecorderManagerCfg: + """Return Isaac Lab's action-state recorder with corrected state terms.""" + + cfg = ActionStateRecorderManagerCfg() + cfg.record_initial_state.class_type = InitialStateRecorder + cfg.record_post_step_states.class_type = PostStepStatesRecorder + return cfg diff --git a/isaac_autodata_interfaces/env/reset_request.py b/isaac_autodata_interfaces/env/reset_request.py new file mode 100644 index 0000000..7724fee --- /dev/null +++ b/isaac_autodata_interfaces/env/reset_request.py @@ -0,0 +1,25 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Reset coordination between asynchronous generators and the synchronous environment loop.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + + +@dataclass(frozen=True) +class EnvResetRequest: + """Request an environment reset and wait for data-generation preparation to finish. + + Args: + env_id: Environment index to reset. + completion: Future resolved by the environment loop after reset settling and recorder + initialization are complete. + """ + + env_id: int + completion: asyncio.Future[None] diff --git a/isaac_autodata_interfaces/env/scene_state.py b/isaac_autodata_interfaces/env/scene_state.py new file mode 100644 index 0000000..b75c6f4 --- /dev/null +++ b/isaac_autodata_interfaces/env/scene_state.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Scene-state reads with correct per-environment coordinate conversion.""" + +from __future__ import annotations + +from typing import Any + + +def get_scene_state(scene: Any, is_relative: bool = True) -> dict: + """Return scene state in world or per-environment coordinates. + + Isaac Lab's relative-state conversion indexes deformable nodal positions as if their second + dimension were XYZ. Read world state first and apply the environment origin along the actual + ``(environment, node, xyz)`` layout here. + + Args: + scene: Isaac Lab interactive scene. + is_relative: Whether positions are relative to each environment origin. + + Returns: + Nested scene-state mapping containing cloned tensors. + """ + + state = scene.get_state(is_relative=False) + if not is_relative: + return state + + env_origins = scene.env_origins + for asset_state in state.get("articulation", {}).values(): + if "root_pose" in asset_state: + asset_state["root_pose"][:, :3] -= env_origins + for asset_state in state.get("deformable_object", {}).values(): + if "nodal_position" in asset_state: + asset_state["nodal_position"] -= env_origins.unsqueeze(1) + for asset_state in state.get("rigid_object", {}).values(): + if "root_pose" in asset_state: + asset_state["root_pose"][:, :3] -= env_origins + return state diff --git a/isaac_autodata_interfaces/tasks/generation_policy_spec.py b/isaac_autodata_interfaces/tasks/generation_policy_spec.py index e8b9872..9f03ef3 100644 --- a/isaac_autodata_interfaces/tasks/generation_policy_spec.py +++ b/isaac_autodata_interfaces/tasks/generation_policy_spec.py @@ -25,6 +25,9 @@ class GenerationPolicy: use_skillgen: Whether SkillGen is used to generate motion trajectories. use_navigation_controller: Whether a navigation controller generates loco-manipulation trajectories. + reset_settling_steps: Number of ordinary batched environment steps to run after reset + before generation begins. During these steps, the resetting environment receives zero + actions while other environments continue executing their generated actions. Segment stitching parameters: select_src_per_subtask: If True, re-select a source demo for every subtask. If False, the @@ -49,6 +52,7 @@ class GenerationPolicy: task_name: str | None = None use_skillgen: bool = False use_navigation_controller: bool = False + reset_settling_steps: int = 0 # --- segment stitching --- select_src_per_subtask: bool = False diff --git a/isaac_autodata_tests/interfaces/env/test_env_loop.py b/isaac_autodata_tests/interfaces/env/test_env_loop.py new file mode 100644 index 0000000..9b50816 --- /dev/null +++ b/isaac_autodata_tests/interfaces/env/test_env_loop.py @@ -0,0 +1,133 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for reset settling in the synchronous data-generation environment loop.""" + +from __future__ import annotations + +import asyncio +import contextlib +import torch +from types import SimpleNamespace + +from isaac_autodata_interfaces.env.isaaclab_env_interface import env_loop +from isaac_autodata_interfaces.env.reset_request import EnvResetRequest +from isaac_autodata_interfaces.tasks.generation_policy_spec import GenerationPolicy + + +class _MockRecorderManager: + def __init__(self) -> None: + self.reset_env_ids: list[tuple[int, ...]] = [] + self.post_reset_env_ids: list[tuple[int, ...]] = [] + + def reset(self, env_ids: torch.Tensor) -> None: + self.reset_env_ids.append(tuple(env_ids.tolist())) + + def record_post_reset(self, env_ids: torch.Tensor) -> None: + self.post_reset_env_ids.append(tuple(env_ids.tolist())) + + +class _MockSimulation: + def is_stopped(self) -> bool: + return False + + +class _MockEnv: + def __init__(self, num_envs: int) -> None: + self.num_envs = num_envs + self.device = "cpu" + self.action_space = SimpleNamespace(shape=(num_envs, 1)) + self.recorder_manager = _MockRecorderManager() + self.sim = _MockSimulation() + self.reset_env_ids: list[tuple[int, ...]] = [] + self.actions: list[torch.Tensor] = [] + + def reset(self, env_ids: torch.Tensor) -> None: + self.reset_env_ids.append(tuple(env_ids.tolist())) + + def step(self, actions: torch.Tensor) -> None: + self.actions.append(actions.clone()) + + +def _close_event_loop(loop: asyncio.AbstractEventLoop, tasks: asyncio.Future) -> None: + if not tasks.done(): + tasks.cancel() + with contextlib.suppress(asyncio.CancelledError): + loop.run_until_complete(tasks) + loop.close() + asyncio.set_event_loop(None) + + +def test_reset_without_settling_completes_without_environment_steps(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + env = _MockEnv(num_envs=1) + reset_queue: asyncio.Queue = asyncio.Queue() + action_queue: asyncio.Queue = asyncio.Queue() + completion = loop.create_future() + reset_queue.put_nowait(EnvResetRequest(env_id=0, completion=completion)) + + async def wait_for_reset() -> None: + await completion + + tasks = asyncio.gather(wait_for_reset()) + try: + completed = env_loop( + env=env, + env_reset_queue=reset_queue, + env_action_queue=action_queue, + asyncio_event_loop=loop, + generation_policy_params=GenerationPolicy(reset_settling_steps=0), + stats={"num_success": 0, "num_failures": 0, "num_attempts": 0}, + data_gen_tasks=tasks, + ) + assert completed is False + assert env.reset_env_ids == [(0,)] + assert env.actions == [] + assert env.recorder_manager.reset_env_ids == [] + assert env.recorder_manager.post_reset_env_ids == [] + loop.run_until_complete(asyncio.wait_for(reset_queue.join(), timeout=0.1)) + finally: + _close_event_loop(loop, tasks) + + +def test_settling_env_uses_zero_actions_while_other_env_continues(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + env = _MockEnv(num_envs=2) + reset_queue: asyncio.Queue = asyncio.Queue() + action_queue: asyncio.Queue = asyncio.Queue() + completion = loop.create_future() + reset_queue.put_nowait(EnvResetRequest(env_id=0, completion=completion)) + + async def provide_running_env_actions() -> None: + for value in (3.0, 4.0): + await action_queue.put((1, torch.tensor([value]))) + await action_queue.join() + + async def wait_for_reset() -> None: + await completion + + tasks = asyncio.gather(provide_running_env_actions(), wait_for_reset()) + try: + completed = env_loop( + env=env, + env_reset_queue=reset_queue, + env_action_queue=action_queue, + asyncio_event_loop=loop, + generation_policy_params=GenerationPolicy(reset_settling_steps=2), + stats={"num_success": 0, "num_failures": 0, "num_attempts": 0}, + data_gen_tasks=tasks, + ) + assert completed is False + assert env.reset_env_ids == [(0,)] + assert len(env.actions) == 2 + assert env.actions[0][:, 0].tolist() == [0.0, 3.0] + assert env.actions[1][:, 0].tolist() == [0.0, 4.0] + assert env.recorder_manager.reset_env_ids == [(0,)] + assert env.recorder_manager.post_reset_env_ids == [(0,)] + loop.run_until_complete(asyncio.wait_for(reset_queue.join(), timeout=0.1)) + finally: + _close_event_loop(loop, tasks) diff --git a/isaac_autodata_tests/interfaces/env/test_scene_state.py b/isaac_autodata_tests/interfaces/env/test_scene_state.py new file mode 100644 index 0000000..9f8f6dd --- /dev/null +++ b/isaac_autodata_tests/interfaces/env/test_scene_state.py @@ -0,0 +1,89 @@ +# Copyright (c) 2026, The Isaac AutoData Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for parallel env-relative scene-state capture.""" + +from __future__ import annotations + +import copy +import torch + +from isaac_autodata_interfaces.env.recorders import ( + InitialStateRecorder, + PostStepStatesRecorder, + make_action_state_recorder_manager_cfg, +) +from isaac_autodata_interfaces.env.scene_state import get_scene_state + + +class _MockScene: + def __init__(self) -> None: + self.env_origins = torch.tensor([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]]) + self.state = { + "articulation": { + "robot": { + "root_pose": torch.tensor( + [[1.5, 2.5, 3.5, 0.0, 0.0, 0.0, 1.0], [10.5, 20.5, 30.5, 0.0, 0.0, 0.0, 1.0]] + ) + } + }, + "deformable_object": { + "rope": { + "nodal_position": torch.tensor([ + [[1.1, 2.2, 3.3], [1.4, 2.5, 3.6]], + [[10.1, 20.2, 30.3], [10.4, 20.5, 30.6]], + ]) + } + }, + "rigid_object": { + "cube": { + "root_pose": torch.tensor( + [[1.2, 2.3, 3.4, 0.0, 0.0, 0.0, 1.0], [10.2, 20.3, 30.4, 0.0, 0.0, 0.0, 1.0]] + ) + } + }, + } + + def get_state(self, is_relative: bool = False) -> dict: + assert is_relative is False + return copy.deepcopy(self.state) + + +def test_scene_state_subtracts_origin_from_every_deformable_node(): + state = get_scene_state(_MockScene(), is_relative=True) + assert torch.allclose( + state["deformable_object"]["rope"]["nodal_position"], + torch.tensor([ + [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], + [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], + ]), + ) + assert torch.allclose( + state["articulation"]["robot"]["root_pose"][:, :3], + torch.tensor([[0.5, 0.5, 0.5], [0.5, 0.5, 0.5]]), + ) + assert torch.allclose( + state["rigid_object"]["cube"]["root_pose"][:, :3], + torch.tensor([[0.2, 0.3, 0.4], [0.2, 0.3, 0.4]]), + ) + + +def test_scene_state_world_coordinates_are_unchanged(): + scene = _MockScene() + state = get_scene_state(scene, is_relative=False) + assert torch.equal( + state["articulation"]["robot"]["root_pose"], + scene.state["articulation"]["robot"]["root_pose"], + ) + assert torch.equal( + state["deformable_object"]["rope"]["nodal_position"], + scene.state["deformable_object"]["rope"]["nodal_position"], + ) + + +def test_action_state_recorder_uses_autodata_state_terms(): + cfg = make_action_state_recorder_manager_cfg() + assert cfg.record_initial_state.class_type is InitialStateRecorder + assert cfg.record_post_step_states.class_type is PostStepStatesRecorder diff --git a/isaac_autodata_tests/interfaces/tasks/test_generation_policy_spec.py b/isaac_autodata_tests/interfaces/tasks/test_generation_policy_spec.py index e35cb14..6a3afc5 100644 --- a/isaac_autodata_tests/interfaces/tasks/test_generation_policy_spec.py +++ b/isaac_autodata_tests/interfaces/tasks/test_generation_policy_spec.py @@ -20,6 +20,7 @@ def test_generation_policy_defaults(): assert p.task_name is None assert p.use_skillgen is False assert p.use_navigation_controller is False + assert p.reset_settling_steps == 0 assert p.select_src_per_subtask is False assert p.select_src_per_arm is False assert p.transform_first_robot_pose is False @@ -27,11 +28,12 @@ def test_generation_policy_defaults(): def test_generation_policy_from_kwargs(): - p = GenerationPolicy(name="run", seed=42, num_trials=100, use_skillgen=True) + p = GenerationPolicy(name="run", seed=42, num_trials=100, use_skillgen=True, reset_settling_steps=10) assert p.name == "run" assert p.seed == 42 assert p.num_trials == 100 assert p.use_skillgen is True + assert p.reset_settling_steps == 10 # Unspecified fields keep their defaults. assert p.guarantee_success is True assert p.interpolate_from_last_target_pose is True From 8875aa101302a5731359f8f9479f0d29f3127f50 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Mon, 10 Aug 2026 14:41:09 -0700 Subject: [PATCH 07/15] test --- isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py index 26dd665..f333aef 100644 --- a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py +++ b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py @@ -40,7 +40,7 @@ def test_rope_task_exposes_annotation_and_success_terms(): task = FrankaRopeTask() assert task.get_observation_cfg().subtask_terms.grasp is not None assert task.get_termination_cfg().success is not None - assert task.get_events_cfg().settle_rope_after_reset.params["settling_steps"] == 10 + assert task.get_events_cfg().reset_object_position is not None def test_rope_embodiment_preserves_arena_observation_names(): From af3ef6d3cae567b01a99235f9dc5f04b0d5a7d09 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Wed, 12 Aug 2026 08:53:34 -0700 Subject: [PATCH 08/15] clean up env registration --- isaac_autodata_examples/envs/__init__.py | 62 ++++++-- .../envs/isaac_lab_arena/__init__.py | 8 +- .../isaac_lab_arena/franka_rope/__init__.py | 43 +----- .../franka_rope/environment.py | 6 +- .../envs/isaac_lab_arena/registration.py | 131 +++++++++++++++++ .../interfaces/env/test_franka_rope_arena.py | 139 +++++++++++++++++- scripts/annotate_demos.py | 20 ++- scripts/generate_dataset.py | 22 ++- 8 files changed, 358 insertions(+), 73 deletions(-) create mode 100644 isaac_autodata_examples/envs/isaac_lab_arena/registration.py diff --git a/isaac_autodata_examples/envs/__init__.py b/isaac_autodata_examples/envs/__init__.py index 8f12207..0574bc6 100644 --- a/isaac_autodata_examples/envs/__init__.py +++ b/isaac_autodata_examples/envs/__init__.py @@ -1,24 +1,68 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Example simulation environments used by Isaac AutoData workflows.""" +import argparse +from typing import Any -def register_environments(enable_cameras: bool = True) -> list[str]: - """Register all AutoData-owned Isaac Lab and Isaac Lab Arena environments. + +def register_environment_for_run( + *, + env_name: str, + enable_cameras: bool, + num_envs: int, + device: str, + seed: int, +) -> 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. + + Returns: + Gym constructor kwargs keyed by registered environment ID. + """ + + from .isaac_lab import register_environments as register_isaac_lab_environments + from .isaac_lab_arena import is_arena_environment + from .isaac_lab_arena import register_environment_for_run as register_arena_environment + + register_isaac_lab_environments() + if not is_arena_environment(env_name): + return {} + return register_arena_environment( + enable_cameras=enable_cameras, + num_envs=num_envs, + device=device, + seed=seed, + ) + + +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: - An empty list of callback-specific command-line arguments. + Command-line arguments not consumed by Arena environment registration. """ from .isaac_lab import register_environments as register_isaac_lab_environments - from .isaac_lab_arena import register_environments as register_isaac_lab_arena_environments + from .isaac_lab_arena import is_arena_environment, register_environment_from_cli register_isaac_lab_environments() - register_isaac_lab_arena_environments(enable_cameras=enable_cameras) - return [] + + 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/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py index 17476a1..76a218b 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py @@ -1,10 +1,8 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Isaac Lab Arena environments composed by Isaac AutoData.""" -from .franka_rope import register_environments +from .registration import is_arena_environment, register_environment_for_run, register_environment_from_cli -__all__ = ["register_environments"] +__all__ = ["is_arena_environment", "register_environment_for_run", "register_environment_from_cli"] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py index 23378e1..f9beb20 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py @@ -1,54 +1,15 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Arena composition and Gym registration for Franka rope manipulation.""" +"""Arena components for Franka rope manipulation.""" from __future__ import annotations -import gymnasium as gym - from . import embodiment as _embodiment # noqa: F401 from .environment import FRANKA_ROPE_ARENA_ENV_ID, FrankaRopeArenaEnvironment, FrankaRopeArenaEnvironmentCfg - -def register_environments(enable_cameras: bool = True) -> list[str]: - """Compose and register the AutoData-owned Arena Franka rope environment. - - Args: - enable_cameras: Whether to include the fixed and wrist camera sensors. - - Returns: - An empty list of callback-specific command-line arguments. - """ - - if FRANKA_ROPE_ARENA_ENV_ID in gym.registry: - return [] - - from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder - from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg - - arena_env = FrankaRopeArenaEnvironment().build( - FrankaRopeArenaEnvironmentCfg(enable_cameras=enable_cameras), - ) - builder = ArenaEnvBuilder( - arena_env, - ArenaEnvBuilderCfg( - num_envs=1, - env_spacing=2.5, - seed=7, - solve_relations=False, - device="cuda:0", - ), - ) - builder.build_registered() - return [] - - __all__ = [ "FRANKA_ROPE_ARENA_ENV_ID", "FrankaRopeArenaEnvironment", "FrankaRopeArenaEnvironmentCfg", - "register_environments", ] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py index bf362a4..62a30f1 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Typed Arena composition for the AutoData Franka rope environment.""" @@ -11,6 +9,7 @@ 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 @@ -56,6 +55,7 @@ def _configure_franka_rope_env(env_cfg: Any) -> Any: return env_cfg +@register_environment class FrankaRopeArenaEnvironment(ArenaEnvironmentFactory[FrankaRopeArenaEnvironmentCfg]): """Compose the rope scene, Franka embodiment, and rope task through Arena.""" diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/registration.py b/isaac_autodata_examples/envs/isaac_lab_arena/registration.py new file mode 100644 index 0000000..d670b0d --- /dev/null +++ b/isaac_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 + +"""Register AutoData-owned Arena environments for an AutoData run.""" + +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. Keeping them out of ``EnvSpec.kwargs`` is + intentional: Gym deep-copies that dictionary, but Arena's variation recorder must + retain object identity with the variations that notify it. + """ + + 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 register_environment_for_run( + *, + enable_cameras: bool, + num_envs: int, + device: str, + seed: int, +) -> dict[str, dict[str, Any]]: + """Build and register AutoData's typed Arena environments 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. + + 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, + ), + ) + 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/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py index f333aef..332ed85 100644 --- a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py +++ b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py @@ -1,10 +1,19 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# 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 + +from isaaclab_arena.assets.registries import EnvironmentRegistry + +import isaac_autodata_examples.envs as env_registration +import isaac_autodata_examples.envs.isaac_lab as isaac_lab_registration +import isaac_autodata_examples.envs.isaac_lab_arena as arena_registration +from isaac_autodata_examples.envs.isaac_lab_arena import registration from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope import mdp from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope.embodiment import FrankaRopeIKEmbodiment from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope.environment import ( @@ -21,6 +30,130 @@ def test_arena_environment_factory_has_typed_config(): 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 + + +def test_arena_registration_uses_run_configuration_and_preserves_make_kwargs(monkeypatch): + from isaaclab_arena.environments import arena_env_builder + + 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.setattr(arena_env_builder, "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 = registration.register_environment_for_run( + enable_cameras=True, + num_envs=8, + device="cuda:1", + seed=23, + ) + + 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 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(isaac_lab_registration, "register_environments", lambda: []) + monkeypatch.setattr(arena_registration, "register_environment_for_run", 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(isaac_lab_registration, "register_environments", lambda: []) + 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(isaac_lab_registration, "register_environments", lambda: []) + 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() diff --git a/scripts/annotate_demos.py b/scripts/annotate_demos.py index b113d82..884de0c 100644 --- a/scripts/annotate_demos.py +++ b/scripts/annotate_demos.py @@ -91,7 +91,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 torch # noqa: E402 from collections.abc import Callable # noqa: E402 @@ -103,7 +105,7 @@ from isaaclab.utils.datasets import EpisodeData, HDF5DatasetFileHandler # noqa: E402 from isaac_autodata_core.pool import DataGenInfoPool # noqa: E402 -from isaac_autodata_examples.envs import register_environments # noqa: E402 +from isaac_autodata_examples.envs import register_environment_for_run # noqa: E402 from isaac_autodata_interfaces.datastream import Datastream # noqa: E402 from isaac_autodata_interfaces.embodiments import embodiment_adapter_from_yaml # noqa: E402 from isaac_autodata_interfaces.env import get_env_name_from_dataset, setup_env_config, setup_output_paths # noqa: E402 @@ -116,8 +118,6 @@ _datastream: Datastream | None = None -register_environments(enable_cameras=args_cli.enable_cameras) - def play_cb() -> None: global is_paused @@ -224,6 +224,18 @@ 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.enable_cameras, + num_envs=1, + device=args_cli.device, + seed=generation_policy.seed, + ) + # Start signals are required only by SkillGen. annotate_start_signals = generation_policy.use_skillgen @@ -258,7 +270,7 @@ def main() -> int: # 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/generate_dataset.py b/scripts/generate_dataset.py index 17b9c0a..a6732b8 100755 --- a/scripts/generate_dataset.py +++ b/scripts/generate_dataset.py @@ -120,7 +120,7 @@ from isaac_autodata_core import DataGenerator, get_algorithm # noqa: E402 from isaac_autodata_core.algorithms import REGISTERED_ALGORITHMS # noqa: E402 -from isaac_autodata_examples.envs import register_environments # noqa: E402 +from isaac_autodata_examples.envs import register_environment_for_run # noqa: E402 from isaac_autodata_interfaces.datastream import Datastream # noqa: E402 from isaac_autodata_interfaces.embodiments import embodiment_adapter_from_yaml # noqa: E402 from isaac_autodata_interfaces.env import ( # noqa: E402 @@ -133,8 +133,6 @@ from isaac_autodata_interfaces.tasks.task_descriptor import TaskDescriptor # noqa: E402 from isaac_autodata_utils.generation_result import write_generation_result # noqa: E402 -register_environments(enable_cameras=args_cli.enable_cameras) - async def run_data_generator( env: Any, @@ -290,6 +288,18 @@ 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.enable_cameras, + num_envs=args_cli.num_envs, + device=args_cli.device, + seed=generation_policy_params.seed, + ) + # 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). @@ -310,11 +320,7 @@ def main() -> None: env_profile=env_profile, ) - 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() From 5be858915408fbe97f6ac715260586c01960592a Mon Sep 17 00:00:00 2001 From: Peter Du Date: Wed, 12 Aug 2026 11:32:35 -0700 Subject: [PATCH 09/15] remove isaaclab franke rope rnv --- .../embodiments/franka_rope_ik_rel.yaml | 21 -- isaac_autodata_examples/envs/__init__.py | 5 - .../envs/isaac_lab/__init__.py | 22 -- .../envs/isaac_lab/franka_rope/__init__.py | 22 -- .../isaac_lab/franka_rope/assets/Rope.usd | Bin 154413 -> 0 bytes .../isaac_lab/franka_rope/franka_rope_env.py | 36 --- .../franka_rope/franka_rope_env_cfg.py | 292 ------------------ .../isaac_lab/franka_rope/mdp/__init__.py | 22 -- .../envs/isaac_lab/franka_rope/mdp/events.py | 109 ------- .../isaac_lab/franka_rope/mdp/observations.py | 96 ------ .../isaac_lab/franka_rope/mdp/terminations.py | 81 ----- .../interfaces/embodiments/test_factory.py | 1 - .../interfaces/env/test_franka_rope_arena.py | 4 - 13 files changed, 711 deletions(-) delete mode 100644 isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml delete mode 100644 isaac_autodata_examples/envs/isaac_lab/__init__.py delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/assets/Rope.usd delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env.py delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py delete mode 100644 isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py diff --git a/isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml b/isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml deleted file mode 100644 index 3c0dd13..0000000 --- a/isaac_autodata_examples/embodiments/franka_rope_ik_rel.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -type: delta_pose_ik_single_arm -name: franka_rope -description: Franka arm with parallel gripper and delta-pose IK control for the rope task. - -eef_name: robot0 - -pose_obs_keys: - pos: robot0_eef_pos - quat: robot0_eef_quat - -action_layout: - gripper_dim: 1 - clip_pose_action_to_unit: true - -# The environment observations and source annotations both use the inter-fingertip control frame. -eef_offset: [0.0, 0.0, 0.0] diff --git a/isaac_autodata_examples/envs/__init__.py b/isaac_autodata_examples/envs/__init__.py index 0574bc6..2b5d41b 100644 --- a/isaac_autodata_examples/envs/__init__.py +++ b/isaac_autodata_examples/envs/__init__.py @@ -28,11 +28,9 @@ def register_environment_for_run( Gym constructor kwargs keyed by registered environment ID. """ - from .isaac_lab import register_environments as register_isaac_lab_environments from .isaac_lab_arena import is_arena_environment from .isaac_lab_arena import register_environment_for_run as register_arena_environment - register_isaac_lab_environments() if not is_arena_environment(env_name): return {} return register_arena_environment( @@ -54,11 +52,8 @@ def register_environments() -> list[str]: Command-line arguments not consumed by Arena environment registration. """ - from .isaac_lab import register_environments as register_isaac_lab_environments from .isaac_lab_arena import is_arena_environment, register_environment_from_cli - register_isaac_lab_environments() - parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--task", type=str) args, _ = parser.parse_known_args() diff --git a/isaac_autodata_examples/envs/isaac_lab/__init__.py b/isaac_autodata_examples/envs/isaac_lab/__init__.py deleted file mode 100644 index 944ed7b..0000000 --- a/isaac_autodata_examples/envs/isaac_lab/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Isaac Lab environment registrations supplied by Isaac AutoData examples.""" - - -def register_environments() -> list[str]: - """Register AutoData's example Isaac Lab environments. - - This callback is intended for Isaac Lab tools that expose ``--external_callback``. - The returned empty list indicates that the registration callback consumes no extra - command-line arguments. - - Returns: - An empty list of callback-specific command-line arguments. - """ - - from . import franka_rope # noqa: F401 - - return [] diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py deleted file mode 100644 index 20b9b66..0000000 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Gym registration for the Franka rope manipulation environment.""" - -import gymnasium as gym - -FRANKA_ROPE_ENV_ID = "Isaac-Rope-Franka-IK-Rel-v0" - -if FRANKA_ROPE_ENV_ID not in gym.registry: - gym.register( - id=FRANKA_ROPE_ENV_ID, - entry_point="isaac_autodata_examples.envs.isaac_lab.franka_rope.franka_rope_env:FrankaRopeEnv", - kwargs={ - "env_cfg_entry_point": ( - "isaac_autodata_examples.envs.isaac_lab.franka_rope.franka_rope_env_cfg:FrankaRopeEnvCfg" - ), - }, - disable_env_checker=True, - ) diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/assets/Rope.usd b/isaac_autodata_examples/envs/isaac_lab/franka_rope/assets/Rope.usd deleted file mode 100644 index 34c17d24b3f4ac246ec28f5a772e24c02ff1f5b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154413 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 None: - super()._reset_idx(env_ids) - - settling_steps = self.cfg.settling_steps - if settling_steps <= 0: - return - - zero_actions = torch.zeros( - (self.num_envs, self.action_manager.total_action_dim), - device=self.device, - ) - for _ in range(settling_steps): - self.action_manager.process_action(zero_actions) - for _ in range(self.cfg.decimation): - self.action_manager.apply_action() - self.scene.write_data_to_sim() - self.sim.step(render=True) - self.scene.update(dt=self.physics_dt) diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py deleted file mode 100644 index 931356c..0000000 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/franka_rope_env_cfg.py +++ /dev/null @@ -1,292 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Franka rope manipulation environment for Isaac Lab 3.""" - -from __future__ import annotations - -import math -from pathlib import Path - -import isaaclab.sim as sim_utils -from isaaclab.assets import ArticulationCfg, AssetBaseCfg, DeformableObjectCfg -from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg -from isaaclab.devices import DevicesCfg, Se3KeyboardCfg, Se3SpaceMouseCfg -from isaaclab.envs import ManagerBasedRLEnvCfg -from isaaclab.envs import mdp as base_mdp -from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg -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.scene import InteractiveSceneCfg -from isaaclab.sensors import CameraCfg, FrameTransformerCfg -from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg -from isaaclab.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg -from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR -from isaaclab.utils.configclass import configclass -from isaaclab_assets.robots.franka import FRANKA_PANDA_HIGH_PD_CFG - -from . import mdp - -_ROPE_USD_PATH = Path(__file__).resolve().parent / "assets" / "Rope.usd" - -_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, -} - - -@configclass -class FrankaRopeSceneCfg(InteractiveSceneCfg): - """Scene containing a Franka robot, deformable rope, table, cameras, and lighting.""" - - robot: ArticulationCfg = FRANKA_PANDA_HIGH_PD_CFG.replace( - prim_path="{ENV_REGEX_NS}/Robot", - init_state=ArticulationCfg.InitialStateCfg(joint_pos=_FRANKA_ROPE_INIT_JOINT_POS), - ) - - ee_frame = FrameTransformerCfg( - prim_path="{ENV_REGEX_NS}/Robot/panda_link0", - debug_vis=False, - target_frames=[ - FrameTransformerCfg.FrameCfg( - prim_path="{ENV_REGEX_NS}/Robot/panda_hand", - name="end_effector", - offset=OffsetCfg(pos=(0.0, 0.0, 0.1034)), - ), - ], - ) - - object = 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, - ) - - table = AssetBaseCfg( - prim_path="{ENV_REGEX_NS}/Table", - init_state=AssetBaseCfg.InitialStateCfg(pos=(0.5, 0.0, 0.0), rot=(0.0, 0.0, 0.707, 0.707)), - spawn=UsdFileCfg(usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/SeattleLabTable/table_instanceable.usd"), - ) - - plane = AssetBaseCfg( - prim_path="/World/GroundPlane", - init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -1.05)), - spawn=GroundPlaneCfg(), - ) - - light = AssetBaseCfg( - prim_path="/World/light", - spawn=sim_utils.DomeLightCfg(color=(0.75, 0.75, 0.75), intensity=3000.0), - ) - - 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"], - 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"], - 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 ActionsCfg: - """Relative end-effector pose and binary gripper actions.""" - - arm_action = DifferentialInverseKinematicsActionCfg( - asset_name="robot", - joint_names=["panda_joint.*"], - body_name="panda_hand", - controller=DifferentialIKControllerCfg(command_type="pose", use_relative_mode=True, ik_method="dls"), - scale=0.5, - body_offset=DifferentialInverseKinematicsActionCfg.OffsetCfg(pos=(0.0, 0.0, 0.107)), - ) - gripper_action = base_mdp.BinaryJointPositionActionCfg( - asset_name="robot", - joint_names=["panda_finger.*"], - open_command_expr={"panda_finger_.*": 0.04}, - close_command_expr={"panda_finger_.*": 0.0}, - ) - - -@configclass -class ObservationsCfg: - """Observation groups used for recording and later subtask annotation.""" - - @configclass - class PolicyCfg(ObsGroup): - """Robot, rope, action, and image observations.""" - - actions = ObsTerm(func=base_mdp.last_action) - robot0_joint_pos_rel = ObsTerm(func=base_mdp.joint_pos_rel) - robot0_joint_vel_rel = ObsTerm(func=base_mdp.joint_vel_rel) - robot0_eef_pos = ObsTerm(func=mdp.ee_frame_pos) - robot0_eef_quat = ObsTerm(func=mdp.ee_frame_quat) - robot0_gripper_qpos = ObsTerm(func=mdp.gripper_pos) - object_nodal_pos = ObsTerm(func=mdp.object_nodal_pos) - agentview_image = ObsTerm( - func=base_mdp.image, - params={"sensor_cfg": SceneEntityCfg("agentview_image"), "data_type": "rgb", "normalize": False}, - ) - robot0_eye_in_hand_image = ObsTerm( - func=base_mdp.image, - params={"sensor_cfg": SceneEntityCfg("robot0_eye_in_hand_image"), "data_type": "rgb", "normalize": False}, - ) - - def __post_init__(self) -> None: - self.enable_corruption = False - self.concatenate_terms = False - - @configclass - class SubtaskCfg(ObsGroup): - """Signals that will be consumed by the later annotation workflow.""" - - 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 - - policy: PolicyCfg = PolicyCfg() - subtask_terms: SubtaskCfg = SubtaskCfg() - - -@configclass -class EventCfg: - """Scene, robot, and deformable reset events.""" - - reset_all = EventTerm(func=base_mdp.reset_scene_to_default, mode="reset") - - randomize_franka_joint_state = EventTerm( - func=base_mdp.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.*"]), - }, - ) - - 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"), - }, - ) - - -@configclass -class TerminationsCfg: - """Episode completion and failure conditions.""" - - time_out = DoneTerm(func=base_mdp.time_out, time_out=True) - object_dropping = DoneTerm( - func=mdp.rope_below_minimum, - params={"minimum_height": -0.05, "object_cfg": SceneEntityCfg("object")}, - ) - success = DoneTerm(func=mdp.rope_ends_close_tracked) - - -@configclass -class FrankaRopeEnvCfg(ManagerBasedRLEnvCfg): - """Configuration for teleoperated Franka rope manipulation.""" - - settling_steps: int = 10 - """Number of control iterations used to settle the deformable rope after reset.""" - - scene: FrankaRopeSceneCfg = FrankaRopeSceneCfg(num_envs=1, env_spacing=2.5, replicate_physics=False) - observations: ObservationsCfg = ObservationsCfg() - actions: ActionsCfg = ActionsCfg() - events: EventCfg = EventCfg() - terminations: TerminationsCfg = TerminationsCfg() - - commands = None - rewards = None - curriculum = None - - teleop_devices: DevicesCfg = DevicesCfg( - devices={ - "keyboard": Se3KeyboardCfg(pos_sensitivity=0.05, rot_sensitivity=0.2), - "spacemouse": Se3SpaceMouseCfg(pos_sensitivity=0.2, rot_sensitivity=0.5), - } - ) - - def __post_init__(self) -> None: - """Configure simulation timing, rendering, and the default viewer.""" - - self.decimation = 5 - self.episode_length_s = 10.0 - self.seed = 7 - self.sim.dt = 0.01 - self.sim.render_interval = 2 - self.sim.render.antialiasing_mode = "DLSS" - self.num_rerenders_on_reset = 1 - self.viewer.eye = (1.0, 1.0, 0.5) - self.viewer.lookat = (0.0, 0.0, 0.0) diff --git a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py deleted file mode 100644 index 63c56b1..0000000 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Task-specific MDP terms for Franka rope manipulation.""" - -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/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py deleted file mode 100644 index 56b0742..0000000 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/events.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Reset events for Franka rope manipulation.""" - -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/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py deleted file mode 100644 index d52bb4f..0000000 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/observations.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Observation terms for Franka rope manipulation.""" - -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/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py b/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py deleted file mode 100644 index a573d65..0000000 --- a/isaac_autodata_examples/envs/isaac_lab/franka_rope/mdp/terminations.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Termination terms for Franka rope manipulation.""" - -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/isaac_autodata_tests/interfaces/embodiments/test_factory.py b/isaac_autodata_tests/interfaces/embodiments/test_factory.py index 6652890..e3de839 100644 --- a/isaac_autodata_tests/interfaces/embodiments/test_factory.py +++ b/isaac_autodata_tests/interfaces/embodiments/test_factory.py @@ -91,7 +91,6 @@ def test_from_yaml_round_trip(tmp_path): [ ("franka_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), ("franka_ik_rel_skillgen.yaml", DeltaPoseIKSingleArmAdapter), - ("franka_rope_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), ("franka_rope_arena_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), ("g1_ik_abs.yaml", AbsolutePoseWholeBodyBimanualAdapter), ("gr1_ik_abs.yaml", AbsolutePoseWholeBodyBimanualAdapter), diff --git a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py index 332ed85..861d00a 100644 --- a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py +++ b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py @@ -11,7 +11,6 @@ from isaaclab_arena.assets.registries import EnvironmentRegistry import isaac_autodata_examples.envs as env_registration -import isaac_autodata_examples.envs.isaac_lab as isaac_lab_registration import isaac_autodata_examples.envs.isaac_lab_arena as arena_registration from isaac_autodata_examples.envs.isaac_lab_arena import registration from isaac_autodata_examples.envs.isaac_lab_arena.franka_rope import mdp @@ -87,7 +86,6 @@ def register_arena_environment(**_kwargs): arena_registration_called = True return {} - monkeypatch.setattr(isaac_lab_registration, "register_environments", lambda: []) monkeypatch.setattr(arena_registration, "register_environment_for_run", register_arena_environment) make_kwargs = env_registration.register_environment_for_run( @@ -110,7 +108,6 @@ def register_arena_environment_from_cli(): callback_called = True return ["hydra.option=value"] - monkeypatch.setattr(isaac_lab_registration, "register_environments", lambda: []) 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]) @@ -128,7 +125,6 @@ def register_arena_environment_from_cli(): callback_called = True return [] - monkeypatch.setattr(isaac_lab_registration, "register_environments", lambda: []) 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"]) From 700e985111879dc50ac885603ec6efcc164b0a31 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Wed, 12 Aug 2026 13:35:30 -0700 Subject: [PATCH 10/15] cleanup and headers --- isaac_autodata_core/deformable_transforms.py | 4 +--- .../embodiments/franka_rope_arena_ik_rel.yaml | 20 ------------------- .../envs/isaac_lab_arena/__init__.py | 1 - .../isaac_lab_arena/franka_rope/__init__.py | 1 - .../isaac_lab_arena/franka_rope/embodiment.py | 5 +---- .../franka_rope/environment.py | 1 - .../franka_rope/mdp/__init__.py | 5 +---- .../isaac_lab_arena/franka_rope/mdp/events.py | 5 +---- .../franka_rope/mdp/observations.py | 5 +---- .../franka_rope/mdp/terminations.py | 5 +---- .../isaac_lab_arena/franka_rope/rope_asset.py | 5 +---- .../envs/isaac_lab_arena/franka_rope/task.py | 5 +---- .../envs/isaac_lab_arena/registration.py | 5 +---- .../tasks/franka_rope_softmimicgen.yaml | 6 ++---- isaac_autodata_interfaces/env/recorders.py | 4 +--- .../env/reset_request.py | 4 +--- isaac_autodata_interfaces/env/scene_state.py | 4 +--- .../core/test_deformable_transforms.py | 4 +--- .../interfaces/env/test_env_loop.py | 4 +--- .../interfaces/env/test_scene_state.py | 4 +--- isaac_autodata_tests/scripts/__init__.py | 4 +--- .../scripts/test_convert_deformable_usd.py | 4 +--- .../utils/test_thin_plate_spline.py | 4 +--- isaac_autodata_utils/thin_plate_spline.py | 4 +--- scripts/convert_deformable_usd.py | 4 +--- 25 files changed, 22 insertions(+), 95 deletions(-) delete mode 100644 isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml diff --git a/isaac_autodata_core/deformable_transforms.py b/isaac_autodata_core/deformable_transforms.py index 089dd93..4c82473 100644 --- a/isaac_autodata_core/deformable_transforms.py +++ b/isaac_autodata_core/deformable_transforms.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Non-rigid transforms used by SoftMimicGen.""" diff --git a/isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml b/isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml deleted file mode 100644 index 2f63ec6..0000000 --- a/isaac_autodata_examples/embodiments/franka_rope_arena_ik_rel.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -type: delta_pose_ik_single_arm -name: franka_rope_arena -description: Arena-composed Franka arm with delta-pose IK control for the deformable rope task. - -eef_name: robot0 - -pose_obs_keys: - pos: eef_pos - quat: eef_quat - -action_layout: - gripper_dim: 1 - clip_pose_action_to_unit: true - -eef_offset: [0.0, 0.0, 0.0] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py index 76a218b..b7c390c 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py @@ -1,7 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Isaac Lab Arena environments composed by Isaac AutoData.""" from .registration import is_arena_environment, register_environment_for_run, register_environment_from_cli diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py index f9beb20..2574837 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/__init__.py @@ -1,7 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Arena components for Franka rope manipulation.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py index b3721d2..70b1364 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/embodiment.py @@ -1,9 +1,6 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Arena Franka embodiment configured for deformable rope manipulation.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py index 62a30f1..55b5358 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/environment.py @@ -1,7 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Typed Arena composition for the AutoData Franka rope environment.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py index 0301607..5185858 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/__init__.py @@ -1,9 +1,6 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""MDP terms used by the Arena Franka rope task.""" 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 diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py index 24ef741..2297c31 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/events.py @@ -1,9 +1,6 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Reset and settling events for Arena Franka rope manipulation.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py index bba1fc3..98900ca 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/observations.py @@ -1,9 +1,6 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Observation terms for Arena Franka rope manipulation.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py index e839459..ac42349 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/mdp/terminations.py @@ -1,9 +1,6 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Termination terms for Arena Franka rope manipulation.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py index 58cb2b8..1eb21cd 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/rope_asset.py @@ -1,9 +1,6 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Arena asset wrapper for the AutoData deformable rope.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py index 7866c8c..634f71c 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/franka_rope/task.py @@ -1,9 +1,6 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Arena task definition for grasping and folding a deformable rope.""" from __future__ import annotations diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/registration.py b/isaac_autodata_examples/envs/isaac_lab_arena/registration.py index d670b0d..a0ec47a 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/registration.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/registration.py @@ -1,7 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Register AutoData-owned Arena environments for an AutoData run.""" from __future__ import annotations @@ -26,9 +25,7 @@ def _bind_gym_make_kwargs(env_name: str, env_kwargs: dict[str, Any]) -> None: 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. Keeping them out of ``EnvSpec.kwargs`` is - intentional: Gym deep-copies that dictionary, but Arena's variation recorder must - retain object identity with the variations that notify it. + registered entry point instead. """ env_spec = gym.spec(env_name) diff --git a/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml b/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml index c8001e8..682618b 100644 --- a/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml +++ b/isaac_autodata_examples/tasks/franka_rope_softmimicgen.yaml @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 name: franka_rope @@ -25,7 +23,7 @@ generation_policy: interpolate_from_last_target_pose: true subtasks: - robot0: + franka: - object_ref: object description: Approach and grasp the deformable rope. subtask_term_signal: grasp diff --git a/isaac_autodata_interfaces/env/recorders.py b/isaac_autodata_interfaces/env/recorders.py index 249845d..ab64e65 100644 --- a/isaac_autodata_interfaces/env/recorders.py +++ b/isaac_autodata_interfaces/env/recorders.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """AutoData recorder terms for parallel scene-state capture.""" diff --git a/isaac_autodata_interfaces/env/reset_request.py b/isaac_autodata_interfaces/env/reset_request.py index 7724fee..f6a7e51 100644 --- a/isaac_autodata_interfaces/env/reset_request.py +++ b/isaac_autodata_interfaces/env/reset_request.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Reset coordination between asynchronous generators and the synchronous environment loop.""" diff --git a/isaac_autodata_interfaces/env/scene_state.py b/isaac_autodata_interfaces/env/scene_state.py index b75c6f4..71acb0c 100644 --- a/isaac_autodata_interfaces/env/scene_state.py +++ b/isaac_autodata_interfaces/env/scene_state.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Scene-state reads with correct per-environment coordinate conversion.""" diff --git a/isaac_autodata_tests/core/test_deformable_transforms.py b/isaac_autodata_tests/core/test_deformable_transforms.py index dc2673f..f34bc8d 100644 --- a/isaac_autodata_tests/core/test_deformable_transforms.py +++ b/isaac_autodata_tests/core/test_deformable_transforms.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Unit tests for SoftMimicGen deformable transforms.""" diff --git a/isaac_autodata_tests/interfaces/env/test_env_loop.py b/isaac_autodata_tests/interfaces/env/test_env_loop.py index 9b50816..cc4bd09 100644 --- a/isaac_autodata_tests/interfaces/env/test_env_loop.py +++ b/isaac_autodata_tests/interfaces/env/test_env_loop.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Unit tests for reset settling in the synchronous data-generation environment loop.""" diff --git a/isaac_autodata_tests/interfaces/env/test_scene_state.py b/isaac_autodata_tests/interfaces/env/test_scene_state.py index 9f8f6dd..cf04087 100644 --- a/isaac_autodata_tests/interfaces/env/test_scene_state.py +++ b/isaac_autodata_tests/interfaces/env/test_scene_state.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Tests for parallel env-relative scene-state capture.""" diff --git a/isaac_autodata_tests/scripts/__init__.py b/isaac_autodata_tests/scripts/__init__.py index 7dd453f..4d6b70f 100644 --- a/isaac_autodata_tests/scripts/__init__.py +++ b/isaac_autodata_tests/scripts/__init__.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Tests for repository command-line utilities.""" diff --git a/isaac_autodata_tests/scripts/test_convert_deformable_usd.py b/isaac_autodata_tests/scripts/test_convert_deformable_usd.py index 8109a18..1dc0eb1 100644 --- a/isaac_autodata_tests/scripts/test_convert_deformable_usd.py +++ b/isaac_autodata_tests/scripts/test_convert_deformable_usd.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Focused tests for deformable USD schema detection and tetrahedral topology conversion.""" diff --git a/isaac_autodata_tests/utils/test_thin_plate_spline.py b/isaac_autodata_tests/utils/test_thin_plate_spline.py index 626f65c..9a98749 100644 --- a/isaac_autodata_tests/utils/test_thin_plate_spline.py +++ b/isaac_autodata_tests/utils/test_thin_plate_spline.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Regression tests for AutoData's three-dimensional TPS implementation.""" diff --git a/isaac_autodata_utils/thin_plate_spline.py b/isaac_autodata_utils/thin_plate_spline.py index a640e4a..01235a6 100644 --- a/isaac_autodata_utils/thin_plate_spline.py +++ b/isaac_autodata_utils/thin_plate_spline.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Three-dimensional thin-plate-spline fitting and evaluation. diff --git a/scripts/convert_deformable_usd.py b/scripts/convert_deformable_usd.py index 57328f4..496d6b4 100644 --- a/scripts/convert_deformable_usd.py +++ b/scripts/convert_deformable_usd.py @@ -1,6 +1,4 @@ -# Copyright (c) 2026, The Isaac AutoData Project Developers. -# All rights reserved. -# +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Convert legacy PhysX volumetric deformable USD assets to the current schema. From 9257252c68f66679e1e7723a86e5b3ee0a58cd21 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Wed, 12 Aug 2026 13:45:32 -0700 Subject: [PATCH 11/15] clean up registration --- isaac_autodata_examples/envs/__init__.py | 5 ++--- isaac_autodata_examples/envs/isaac_lab_arena/__init__.py | 4 ++-- .../envs/isaac_lab_arena/registration.py | 4 ++-- .../interfaces/env/test_franka_rope_arena.py | 6 ++++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/isaac_autodata_examples/envs/__init__.py b/isaac_autodata_examples/envs/__init__.py index 2b5d41b..98887c8 100644 --- a/isaac_autodata_examples/envs/__init__.py +++ b/isaac_autodata_examples/envs/__init__.py @@ -28,12 +28,11 @@ def register_environment_for_run( Gym constructor kwargs keyed by registered environment ID. """ - from .isaac_lab_arena import is_arena_environment - from .isaac_lab_arena import register_environment_for_run as register_arena_environment + from .isaac_lab_arena import build_and_register_arena_environment, is_arena_environment if not is_arena_environment(env_name): return {} - return register_arena_environment( + return build_and_register_arena_environment( enable_cameras=enable_cameras, num_envs=num_envs, device=device, diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py index b7c390c..d33c1b6 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/__init__.py @@ -2,6 +2,6 @@ # SPDX-License-Identifier: Apache-2.0 -from .registration import is_arena_environment, register_environment_for_run, register_environment_from_cli +from .registration import build_and_register_arena_environment, is_arena_environment, register_environment_from_cli -__all__ = ["is_arena_environment", "register_environment_for_run", "register_environment_from_cli"] +__all__ = ["build_and_register_arena_environment", "is_arena_environment", "register_environment_from_cli"] diff --git a/isaac_autodata_examples/envs/isaac_lab_arena/registration.py b/isaac_autodata_examples/envs/isaac_lab_arena/registration.py index a0ec47a..e66aeb7 100644 --- a/isaac_autodata_examples/envs/isaac_lab_arena/registration.py +++ b/isaac_autodata_examples/envs/isaac_lab_arena/registration.py @@ -38,14 +38,14 @@ def _bind_gym_make_kwargs(env_name: str, env_kwargs: dict[str, Any]) -> None: env_spec.entry_point = partial(entry_point, **env_kwargs) -def register_environment_for_run( +def build_and_register_arena_environment( *, enable_cameras: bool, num_envs: int, device: str, seed: int, ) -> dict[str, dict[str, Any]]: - """Build and register AutoData's typed Arena environments for the current run. + """Build and register AutoData's typed Arena environment for the current run. Args: enable_cameras: Whether to include environment camera sensors. diff --git a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py index 861d00a..90e7000 100644 --- a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py +++ b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py @@ -60,7 +60,7 @@ def build_registered(self): ), ) - make_kwargs = registration.register_environment_for_run( + make_kwargs = registration.build_and_register_arena_environment( enable_cameras=True, num_envs=8, device="cuda:1", @@ -86,7 +86,9 @@ def register_arena_environment(**_kwargs): arena_registration_called = True return {} - monkeypatch.setattr(arena_registration, "register_environment_for_run", register_arena_environment) + 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", From e322aeec773cbb01c136e6141f7bebd9e808c3a9 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Wed, 12 Aug 2026 14:17:01 -0700 Subject: [PATCH 12/15] cleanup --- isaac_autodata_core/algorithms.py | 4 +- .../datastream/datastream.py | 1 + isaac_autodata_interfaces/env/recorders.py | 3 +- isaac_autodata_interfaces/env/scene_state.py | 1 + .../interfaces/embodiments/test_factory.py | 1 - .../interfaces/env/test_franka_rope_arena.py | 4 +- .../interfaces/env/test_scene_state.py | 87 -------------- .../interfaces/tasks/test_task_descriptor.py | 20 +--- isaac_autodata_tests/scripts/__init__.py | 4 - .../scripts/test_convert_deformable_usd.py | 109 ------------------ ...sd.py => convert_deformable_usd (Copy).py} | 0 11 files changed, 8 insertions(+), 226 deletions(-) delete mode 100644 isaac_autodata_tests/interfaces/env/test_scene_state.py delete mode 100644 isaac_autodata_tests/scripts/__init__.py delete mode 100644 isaac_autodata_tests/scripts/test_convert_deformable_usd.py rename scripts/{convert_deformable_usd.py => convert_deformable_usd (Copy).py} (100%) diff --git a/isaac_autodata_core/algorithms.py b/isaac_autodata_core/algorithms.py index 9bf41c4..33db715 100644 --- a/isaac_autodata_core/algorithms.py +++ b/isaac_autodata_core/algorithms.py @@ -9,7 +9,7 @@ The main behavioral hook is :meth:`GenerationAlgorithm.plan_subtask_trajectory`, called by ``DataGenerator`` every time an EEF needs a new executable trajectory. Object-state adaptation is -routed through :meth:`GenerationAlgorithm.transform_source_eef_poses`; rigid algorithms use the +routed through :meth:`GenerationAlgorithm.transform_source_eef_poses. Rigid algorithms use the default object-pose transform while SoftMimicGen overrides it with nodal TPS registration. """ @@ -207,7 +207,7 @@ class DexMimicGen(GenerationAlgorithm): class SoftMimicGen(GenerationAlgorithm): - """MimicGen for rigid and deformable reference objects. + """MimicGen for deformable reference objects. Deformable subtasks select source segments using nodal state and warp their EEF trajectories with a thin-plate-spline transform. Rigid subtasks retain the normal MimicGen transform. diff --git a/isaac_autodata_interfaces/datastream/datastream.py b/isaac_autodata_interfaces/datastream/datastream.py index 467ae30..de80fcc 100644 --- a/isaac_autodata_interfaces/datastream/datastream.py +++ b/isaac_autodata_interfaces/datastream/datastream.py @@ -344,6 +344,7 @@ def get_scene_state(self, is_relative: bool = True) -> dict: hatch for callers that need the full dict (e.g. recorder ``initial_state``). """ + # TODO: Remove this custom method once upstream Lab fixes the nodal position relative scene state bug. return get_scene_state(self.env.scene, is_relative=is_relative) # ------------------------------------------------------------------ diff --git a/isaac_autodata_interfaces/env/recorders.py b/isaac_autodata_interfaces/env/recorders.py index ab64e65..f8a0b28 100644 --- a/isaac_autodata_interfaces/env/recorders.py +++ b/isaac_autodata_interfaces/env/recorders.py @@ -1,7 +1,6 @@ # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""AutoData recorder terms for parallel scene-state capture.""" from __future__ import annotations @@ -12,6 +11,8 @@ from isaac_autodata_interfaces.env.scene_state import get_scene_state +# TODO: Remove this custom recorder once upstream Lab fixes the nodal position relative scene state bug. + class InitialStateRecorder(RecorderTerm): """Record the correctly env-relative initial scene state after reset.""" diff --git a/isaac_autodata_interfaces/env/scene_state.py b/isaac_autodata_interfaces/env/scene_state.py index 71acb0c..e012c1d 100644 --- a/isaac_autodata_interfaces/env/scene_state.py +++ b/isaac_autodata_interfaces/env/scene_state.py @@ -8,6 +8,7 @@ from typing import Any +# TODO: Remove this custom method once upstream Lab fixes the nodal position relative scene state bug. def get_scene_state(scene: Any, is_relative: bool = True) -> dict: """Return scene state in world or per-environment coordinates. diff --git a/isaac_autodata_tests/interfaces/embodiments/test_factory.py b/isaac_autodata_tests/interfaces/embodiments/test_factory.py index e3de839..2026703 100644 --- a/isaac_autodata_tests/interfaces/embodiments/test_factory.py +++ b/isaac_autodata_tests/interfaces/embodiments/test_factory.py @@ -91,7 +91,6 @@ def test_from_yaml_round_trip(tmp_path): [ ("franka_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), ("franka_ik_rel_skillgen.yaml", DeltaPoseIKSingleArmAdapter), - ("franka_rope_arena_ik_rel.yaml", DeltaPoseIKSingleArmAdapter), ("g1_ik_abs.yaml", AbsolutePoseWholeBodyBimanualAdapter), ("gr1_ik_abs.yaml", AbsolutePoseWholeBodyBimanualAdapter), ], diff --git a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py index 90e7000..a5b9546 100644 --- a/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py +++ b/isaac_autodata_tests/interfaces/env/test_franka_rope_arena.py @@ -86,9 +86,7 @@ def register_arena_environment(**_kwargs): arena_registration_called = True return {} - monkeypatch.setattr( - arena_registration, "build_and_register_arena_environment", register_arena_environment - ) + 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", diff --git a/isaac_autodata_tests/interfaces/env/test_scene_state.py b/isaac_autodata_tests/interfaces/env/test_scene_state.py deleted file mode 100644 index cf04087..0000000 --- a/isaac_autodata_tests/interfaces/env/test_scene_state.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for parallel env-relative scene-state capture.""" - -from __future__ import annotations - -import copy -import torch - -from isaac_autodata_interfaces.env.recorders import ( - InitialStateRecorder, - PostStepStatesRecorder, - make_action_state_recorder_manager_cfg, -) -from isaac_autodata_interfaces.env.scene_state import get_scene_state - - -class _MockScene: - def __init__(self) -> None: - self.env_origins = torch.tensor([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]]) - self.state = { - "articulation": { - "robot": { - "root_pose": torch.tensor( - [[1.5, 2.5, 3.5, 0.0, 0.0, 0.0, 1.0], [10.5, 20.5, 30.5, 0.0, 0.0, 0.0, 1.0]] - ) - } - }, - "deformable_object": { - "rope": { - "nodal_position": torch.tensor([ - [[1.1, 2.2, 3.3], [1.4, 2.5, 3.6]], - [[10.1, 20.2, 30.3], [10.4, 20.5, 30.6]], - ]) - } - }, - "rigid_object": { - "cube": { - "root_pose": torch.tensor( - [[1.2, 2.3, 3.4, 0.0, 0.0, 0.0, 1.0], [10.2, 20.3, 30.4, 0.0, 0.0, 0.0, 1.0]] - ) - } - }, - } - - def get_state(self, is_relative: bool = False) -> dict: - assert is_relative is False - return copy.deepcopy(self.state) - - -def test_scene_state_subtracts_origin_from_every_deformable_node(): - state = get_scene_state(_MockScene(), is_relative=True) - assert torch.allclose( - state["deformable_object"]["rope"]["nodal_position"], - torch.tensor([ - [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], - [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], - ]), - ) - assert torch.allclose( - state["articulation"]["robot"]["root_pose"][:, :3], - torch.tensor([[0.5, 0.5, 0.5], [0.5, 0.5, 0.5]]), - ) - assert torch.allclose( - state["rigid_object"]["cube"]["root_pose"][:, :3], - torch.tensor([[0.2, 0.3, 0.4], [0.2, 0.3, 0.4]]), - ) - - -def test_scene_state_world_coordinates_are_unchanged(): - scene = _MockScene() - state = get_scene_state(scene, is_relative=False) - assert torch.equal( - state["articulation"]["robot"]["root_pose"], - scene.state["articulation"]["robot"]["root_pose"], - ) - assert torch.equal( - state["deformable_object"]["rope"]["nodal_position"], - scene.state["deformable_object"]["rope"]["nodal_position"], - ) - - -def test_action_state_recorder_uses_autodata_state_terms(): - cfg = make_action_state_recorder_manager_cfg() - assert cfg.record_initial_state.class_type is InitialStateRecorder - assert cfg.record_post_step_states.class_type is PostStepStatesRecorder diff --git a/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py b/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py index c3d2360..e9b3331 100644 --- a/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py +++ b/isaac_autodata_tests/interfaces/tasks/test_task_descriptor.py @@ -9,11 +9,7 @@ from isaac_autodata_interfaces.tasks.generation_policy_spec import GenerationPolicy from isaac_autodata_interfaces.tasks.subtask_constraint_spec import SubtaskConstraint, SubTaskConstraintType -from isaac_autodata_interfaces.tasks.subtask_spec import ( - MimicGenSubtaskAlgoParams, - SkillGenSubtaskAlgoParams, - SoftMimicGenSubtaskAlgoParams, -) +from isaac_autodata_interfaces.tasks.subtask_spec import MimicGenSubtaskAlgoParams, SkillGenSubtaskAlgoParams from isaac_autodata_interfaces.tasks.task_descriptor import TaskDescriptor from isaac_autodata_tests.utils.constants import TestPaths @@ -90,20 +86,6 @@ def test_from_dict_description_defaults_empty(): assert td.description == "" -def test_franka_rope_softmimicgen_yaml(): - path = os.path.join(TestPaths.tasks_dir, "franka_rope_softmimicgen.yaml") - task = TaskDescriptor.from_yaml(path) - assert task.name == "franka_rope" - assert task.get_eef_names() == ["robot0"] - assert len(task.get_subtasks("robot0")) == 2 - first = task.get_subtasks("robot0")[0] - assert first.object_ref == "object" - assert first.selection_strategy == "registration_cost" - assert first.subtask_term_offset_range == (10, 15) - assert isinstance(first.algo_params, SoftMimicGenSubtaskAlgoParams) - assert first.algo_params.object_soft is True - - # --------------------------------------------------------------------------------------------------- # per-EEF, subtask-ordered getters # --------------------------------------------------------------------------------------------------- diff --git a/isaac_autodata_tests/scripts/__init__.py b/isaac_autodata_tests/scripts/__init__.py deleted file mode 100644 index 4d6b70f..0000000 --- a/isaac_autodata_tests/scripts/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for repository command-line utilities.""" diff --git a/isaac_autodata_tests/scripts/test_convert_deformable_usd.py b/isaac_autodata_tests/scripts/test_convert_deformable_usd.py deleted file mode 100644 index 1dc0eb1..0000000 --- a/isaac_autodata_tests/scripts/test_convert_deformable_usd.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Focused tests for deformable USD schema detection and tetrahedral topology conversion.""" - -from __future__ import annotations - -import pytest -from pxr import Gf, Sdf, Usd - -from scripts.convert_deformable_usd import ( - DeformableSchemaKind, - _load_usd_modules, - _remove_api_schema, - _surface_faces, - _tetrahedra_from_flat_indices, - inspect_stage, -) - -_load_usd_modules() - - -def test_detect_legacy_volume_schema() -> None: - """A mesh with legacy simulation arrays is classified as legacy volume.""" - - stage = Usd.Stage.CreateInMemory() - root = stage.DefinePrim("/World", "Xform") - stage.SetDefaultPrim(root) - mesh = stage.DefinePrim("/World/Object", "Mesh") - mesh.CreateAttribute("physxDeformable:simulationPoints", Sdf.ValueTypeNames.Point3fArray).Set( - [Gf.Vec3f(0.0), Gf.Vec3f(1.0, 0.0, 0.0), Gf.Vec3f(0.0, 1.0, 0.0), Gf.Vec3f(0.0, 0.0, 1.0)] - ) - mesh.CreateAttribute("physxDeformable:simulationIndices", Sdf.ValueTypeNames.IntArray).Set([0, 1, 2, 3]) - - inspection = inspect_stage(stage) - - assert inspection.kind is DeformableSchemaKind.LEGACY_VOLUME - assert inspection.default_prim_path == "/World" - assert inspection.legacy_source_prim_paths == ("/World/Object",) - - -def test_remove_unregistered_api_schema() -> None: - """Obsolete API tokens can be removed without requiring schema registration.""" - - stage = Usd.Stage.CreateInMemory() - prim = stage.DefinePrim("/World", "Xform") - prim.SetMetadata( - "apiSchemas", - Sdf.TokenListOp.CreateExplicit(["MaterialBindingAPI", "ObsoleteDeformableAPI"]), - ) - - _remove_api_schema(prim, "ObsoleteDeformableAPI") - - assert prim.GetMetadata("apiSchemas").GetAppliedItems() == ["MaterialBindingAPI"] - - -def test_group_and_validate_tetrahedra() -> None: - """Flat tetrahedral indices are grouped without changing connectivity.""" - - tetrahedra = _tetrahedra_from_flat_indices([0, 1, 2, 3, 1, 2, 3, 4], vertex_count=5, label="test") - - assert tetrahedra == [(0, 1, 2, 3), (1, 2, 3, 4)] - - -@pytest.mark.parametrize( - ("indices", "message"), - [ - ([0, 1, 2], "divisible by four"), - ([0, 1, 1, 3], "duplicate vertices"), - ([0, 1, 2, 4], "references vertex 4"), - ], -) -def test_reject_invalid_tetrahedra(indices: list[int], message: str) -> None: - """Malformed legacy topology fails with a targeted diagnostic.""" - - with pytest.raises(AssertionError, match=message): - _tetrahedra_from_flat_indices(indices, vertex_count=4, label="test") - - -def test_extract_oriented_surface_faces() -> None: - """A positive-volume tetrahedron produces four outward boundary triangles.""" - - points = [ - Gf.Vec3f(0.0, 0.0, 0.0), - Gf.Vec3f(1.0, 0.0, 0.0), - Gf.Vec3f(0.0, 1.0, 0.0), - Gf.Vec3f(0.0, 0.0, 1.0), - ] - - faces = _surface_faces([(0, 1, 2, 3)], points, "test") - - assert faces == [(0, 1, 3), (0, 2, 1), (0, 3, 2), (1, 2, 3)] - - -def test_remove_shared_tetrahedral_face() -> None: - """The shared face of two tetrahedra is excluded from the surface mesh.""" - - points = [ - Gf.Vec3f(0.0, 0.0, 0.0), - Gf.Vec3f(1.0, 0.0, 0.0), - Gf.Vec3f(0.0, 1.0, 0.0), - Gf.Vec3f(0.0, 0.0, 1.0), - Gf.Vec3f(0.0, 0.0, -1.0), - ] - - faces = _surface_faces([(0, 1, 2, 3), (0, 2, 1, 4)], points, "test") - - assert len(faces) == 6 - assert (0, 1, 2) not in {tuple(sorted(face)) for face in faces} diff --git a/scripts/convert_deformable_usd.py b/scripts/convert_deformable_usd (Copy).py similarity index 100% rename from scripts/convert_deformable_usd.py rename to scripts/convert_deformable_usd (Copy).py From 8c5afdc4c8e9f9a3571578c28f8e4c028b6756fc Mon Sep 17 00:00:00 2001 From: Peter Du Date: Wed, 12 Aug 2026 14:22:59 -0700 Subject: [PATCH 13/15] remove script --- scripts/convert_deformable_usd (Copy).py | 746 ----------------------- 1 file changed, 746 deletions(-) delete mode 100644 scripts/convert_deformable_usd (Copy).py diff --git a/scripts/convert_deformable_usd (Copy).py b/scripts/convert_deformable_usd (Copy).py deleted file mode 100644 index 496d6b4..0000000 --- a/scripts/convert_deformable_usd (Copy).py +++ /dev/null @@ -1,746 +0,0 @@ -# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Convert legacy PhysX volumetric deformable USD assets to the current schema. - -The converter recognizes three asset families: - -* legacy volumetric deformables whose simulation and collision tetrahedra are - stored in ``physxDeformable:*`` attributes on a render mesh; -* current OmniPhysics volumetric deformables; and -* current OmniPhysics surface deformables. - -Only the legacy volumetric representation is rewritten. Current assets are -validated and, when an output path is provided, copied without modification. -Unknown representations fail instead of being converted heuristically. - -Usage:: - - python scripts/convert_deformable_usd.py \ - --input_usd ./Rope.usd \ - --output_usd ./Rope_sim6.usd \ - --density 1000 \ - --static_friction 0.25 \ - --poissons_ratio 0.45 -""" - -from __future__ import annotations - -import argparse -import dataclasses -import enum -import shutil -from collections import Counter -from pathlib import Path -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pxr import Gf, Sdf, Usd, UsdGeom, UsdShade, Vt -else: - Gf = None - Sdf = None - Usd = None - UsdGeom = None - UsdShade = None - Vt = None - - -class DeformableSchemaKind(enum.StrEnum): - """Deformable schema families recognized by the converter.""" - - LEGACY_VOLUME = "legacy_volume" - CURRENT_VOLUME = "current_volume" - CURRENT_SURFACE = "current_surface" - UNSUPPORTED = "unsupported" - - -@dataclasses.dataclass(frozen=True) -class AssetInspection: - """Summary of the deformable representation authored in a USD stage.""" - - kind: DeformableSchemaKind - default_prim_path: str | None - body_prim_paths: tuple[str, ...] - simulation_prim_paths: tuple[str, ...] - legacy_source_prim_paths: tuple[str, ...] - - -@dataclasses.dataclass(frozen=True) -class MaterialOverrides: - """Optional values used when the legacy material does not author a property.""" - - density: float | None = None - static_friction: float | None = None - dynamic_friction: float | None = None - youngs_modulus: float | None = None - poissons_ratio: float | None = None - elasticity_damping: float | None = None - - -_LEGACY_SIMULATION_POINTS = "physxDeformable:simulationPoints" -_LEGACY_SIMULATION_REST_POINTS = "physxDeformable:simulationRestPoints" -_LEGACY_SIMULATION_INDICES = "physxDeformable:simulationIndices" -_LEGACY_COLLISION_POINTS = "physxDeformable:collisionPoints" -_LEGACY_COLLISION_REST_POINTS = "physxDeformable:collisionRestPoints" -_LEGACY_COLLISION_INDICES = "physxDeformable:collisionIndices" - -_CURRENT_BODY_SCHEMAS = { - "OmniPhysicsDeformableBodyAPI", - "PhysxBaseDeformableBodyAPI", -} - - -def _load_usd_modules() -> None: - """Load USD modules after Isaac Sim starts. - - Isaac Sim requires :mod:`pxr` to be imported after :class:`AppLauncher` - creates the application. Unit tests may call this helper directly because - they do not launch Kit. - """ - - global Gf, Sdf, Usd, UsdGeom, UsdShade, Vt - - from pxr import Gf as gf_module - from pxr import Sdf as sdf_module - from pxr import Usd as usd_module - from pxr import UsdGeom as usd_geom_module - from pxr import UsdShade as usd_shade_module - from pxr import Vt as vt_module - - Gf = gf_module - Sdf = sdf_module - Usd = usd_module - UsdGeom = usd_geom_module - UsdShade = usd_shade_module - Vt = vt_module - - -def inspect_stage(stage: Usd.Stage) -> AssetInspection: - """Inspect a USD stage and classify its deformable representation. - - Args: - stage: Open USD stage to inspect. - - Returns: - The detected schema family and relevant prim paths. - """ - - legacy_sources: list[str] = [] - volume_simulation_prims: list[str] = [] - surface_simulation_prims: list[str] = [] - body_prims: list[str] = [] - - for prim in stage.Traverse(): - path = str(prim.GetPath()) - schemas = set(prim.GetAppliedSchemas()) - - if prim.HasAttribute(_LEGACY_SIMULATION_POINTS) and prim.HasAttribute(_LEGACY_SIMULATION_INDICES): - legacy_sources.append(path) - if "OmniPhysicsVolumeDeformableSimAPI" in schemas: - volume_simulation_prims.append(path) - if "OmniPhysicsSurfaceDeformableSimAPI" in schemas: - surface_simulation_prims.append(path) - if _CURRENT_BODY_SCHEMAS.issubset(schemas): - body_prims.append(path) - - if legacy_sources: - kind = DeformableSchemaKind.LEGACY_VOLUME - simulation_prims: list[str] = [] - elif surface_simulation_prims and not volume_simulation_prims: - kind = DeformableSchemaKind.CURRENT_SURFACE - simulation_prims = surface_simulation_prims - elif volume_simulation_prims and not surface_simulation_prims: - kind = DeformableSchemaKind.CURRENT_VOLUME - simulation_prims = volume_simulation_prims - else: - kind = DeformableSchemaKind.UNSUPPORTED - simulation_prims = volume_simulation_prims + surface_simulation_prims - - default_prim = stage.GetDefaultPrim() - default_prim_path = str(default_prim.GetPath()) if default_prim else None - return AssetInspection( - kind=kind, - default_prim_path=default_prim_path, - body_prim_paths=tuple(body_prims), - simulation_prim_paths=tuple(simulation_prims), - legacy_source_prim_paths=tuple(legacy_sources), - ) - - -def _tetrahedra_from_flat_indices( - indices: list[int] | Vt.IntArray, - vertex_count: int, - label: str, -) -> list[tuple[int, int, int, int]]: - """Validate and group a flat tetrahedral index array.""" - - flat_indices = [int(index) for index in indices] - assert flat_indices, f"{label} indices are empty" - assert len(flat_indices) % 4 == 0, f"{label} index count must be divisible by four" - - tetrahedra: list[tuple[int, int, int, int]] = [] - for offset in range(0, len(flat_indices), 4): - tet = tuple(flat_indices[offset : offset + 4]) - assert len(set(tet)) == 4, f"{label} tetrahedron {offset // 4} contains duplicate vertices: {tet}" - assert min(tet) >= 0, f"{label} tetrahedron {offset // 4} contains a negative index: {tet}" - assert ( - max(tet) < vertex_count - ), f"{label} tetrahedron {offset // 4} references vertex {max(tet)}, but the mesh has {vertex_count} vertices" - tetrahedra.append(tet) - return tetrahedra - - -def _surface_faces( - tetrahedra: list[tuple[int, int, int, int]], - points: list[Gf.Vec3f] | Vt.Vec3fArray, - label: str, -) -> list[tuple[int, int, int]]: - """Extract consistently oriented boundary triangles from tetrahedra.""" - - face_counts: Counter[tuple[int, int, int]] = Counter() - oriented_faces: dict[tuple[int, int, int], tuple[int, int, int]] = {} - - for tet_index, (a, b, c, d) in enumerate(tetrahedra): - point_a = Gf.Vec3d(points[a]) - point_b = Gf.Vec3d(points[b]) - point_c = Gf.Vec3d(points[c]) - point_d = Gf.Vec3d(points[d]) - signed_volume_six = Gf.Dot(point_b - point_a, Gf.Cross(point_c - point_a, point_d - point_a)) - assert abs(signed_volume_six) > 1.0e-15, f"{label} tetrahedron {tet_index} is degenerate" - - faces = [ - (b, c, d), - (a, d, c), - (a, b, d), - (a, c, b), - ] - if signed_volume_six < 0.0: - faces = [(face[0], face[2], face[1]) for face in faces] - - for face in faces: - key = tuple(sorted(face)) - face_counts[key] += 1 - oriented_faces.setdefault(key, face) - - non_manifold_faces = [key for key, count in face_counts.items() if count > 2] - assert not non_manifold_faces, f"{label} mesh has non-manifold tetrahedral faces: {non_manifold_faces[:5]}" - boundary_faces = [oriented_faces[key] for key, count in face_counts.items() if count == 1] - return sorted(boundary_faces) - - -def _required_array(prim: Usd.Prim, name: str) -> object: - """Read a required authored array attribute.""" - - attribute = prim.GetAttribute(name) - assert attribute and attribute.HasAuthoredValueOpinion(), f"Missing required attribute {name} on {prim.GetPath()}" - value = attribute.Get() - assert value is not None and len(value) > 0, f"Attribute {name} on {prim.GetPath()} is empty" - return value - - -def _optional_array(prim: Usd.Prim, name: str, fallback: object) -> object: - """Read an optional array attribute or return a fallback value.""" - - attribute = prim.GetAttribute(name) - if attribute and attribute.HasAuthoredValueOpinion(): - value = attribute.Get() - if value is not None and len(value) > 0: - return value - return fallback - - -def _transform_points( - points: list[Gf.Vec3f] | Vt.Vec3fArray, - transform: Gf.Matrix4d, -) -> Vt.Vec3fArray: - """Transform points and return a float-precision USD array.""" - - return Vt.Vec3fArray([Gf.Vec3f(transform.Transform(Gf.Vec3d(point))) for point in points]) - - -def _apply_api(prim: Usd.Prim, schema_name: str, instance_name: str | None = None) -> None: - """Apply a codeless API schema and fail if it is unavailable.""" - - applied = prim.ApplyAPI(schema_name, instance_name) if instance_name else prim.ApplyAPI(schema_name) - assert applied, f"Could not apply {schema_name} to {prim.GetPath()}; launch the script through Isaac Sim 6" - - -def _remove_api_schema(prim: Usd.Prim, schema_name: str) -> None: - """Remove an authored API token, including schemas no longer registered.""" - - schemas = prim.GetMetadata("apiSchemas") - if schemas is None: - return - applied_schemas = [schema for schema in schemas.GetAppliedItems() if schema != schema_name] - prim.SetMetadata("apiSchemas", Sdf.TokenListOp.CreateExplicit(applied_schemas)) - - -def _author_pose(prim: Usd.Prim, points: Vt.Vec3fArray) -> None: - """Author the default deformable bind pose on a prim.""" - - _apply_api(prim, "OmniPhysicsDeformablePoseAPI", "default") - prim.CreateAttribute( - "deformablePose:default:omniphysics:points", - Sdf.ValueTypeNames.Point3fArray, - custom=False, - ).Set(points) - prim.CreateAttribute( - "deformablePose:default:omniphysics:purposes", - Sdf.ValueTypeNames.TokenArray, - custom=False, - ).Set(Vt.TokenArray(["bindPose"])) - - -def _author_tet_mesh( - stage: Usd.Stage, - path: Sdf.Path, - points: Vt.Vec3fArray, - tetrahedra: list[tuple[int, int, int, int]], - surface_faces: list[tuple[int, int, int]], -) -> Usd.Prim: - """Create an explicit tetrahedral mesh.""" - - assert not stage.GetPrimAtPath(path), f"Output prim already exists: {path}" - mesh = UsdGeom.TetMesh.Define(stage, path) - mesh.CreatePointsAttr(points) - mesh.CreateTetVertexIndicesAttr(Vt.Vec4iArray([Gf.Vec4i(*tet) for tet in tetrahedra])) - mesh.CreateSurfaceFaceVertexIndicesAttr(Vt.Vec3iArray([Gf.Vec3i(*face) for face in surface_faces])) - mesh.CreatePurposeAttr(UsdGeom.Tokens.guide) - return mesh.GetPrim() - - -def _legacy_scalar(prim: Usd.Prim, name: str) -> float | int | bool | None: - """Read a legacy scalar when it has an authored value.""" - - attribute = prim.GetAttribute(name) - if not attribute or not attribute.HasAuthoredValueOpinion(): - return None - return attribute.Get() - - -def _find_physics_material(stage: Usd.Stage, source_prim: Usd.Prim, body_prim: Usd.Prim) -> Usd.Prim: - """Find the legacy physics material or create one under the body root.""" - - binding = source_prim.GetRelationship("material:binding:physics") - if binding: - targets = binding.GetTargets() - if targets: - material_prim = stage.GetPrimAtPath(targets[0]) - if material_prim: - return material_prim - - for prim in stage.Traverse(): - if any(attribute.GetName().startswith("physxDeformableBodyMaterial:") for attribute in prim.GetAttributes()): - return prim - - material_path = body_prim.GetPath().AppendChild("PhysicsMaterial") - return UsdShade.Material.Define(stage, material_path).GetPrim() - - -def _author_material( - material_prim: Usd.Prim, - overrides: MaterialOverrides, -) -> list[str]: - """Migrate legacy material values and report properties left to schema defaults.""" - - _apply_api(material_prim, "OmniPhysicsDeformableMaterialAPI") - _apply_api(material_prim, "PhysxDeformableMaterialAPI") - - mappings: tuple[tuple[str, str, str, Sdf.ValueTypeName, float | None], ...] = ( - ( - "density", - "physxDeformableBodyMaterial:density", - "omniphysics:density", - Sdf.ValueTypeNames.Float, - overrides.density, - ), - ( - "static friction", - "physxDeformableBodyMaterial:staticFriction", - "omniphysics:staticFriction", - Sdf.ValueTypeNames.Float, - overrides.static_friction, - ), - ( - "dynamic friction", - "physxDeformableBodyMaterial:dynamicFriction", - "omniphysics:dynamicFriction", - Sdf.ValueTypeNames.Float, - overrides.dynamic_friction, - ), - ( - "Young's modulus", - "physxDeformableBodyMaterial:youngsModulus", - "omniphysics:youngsModulus", - Sdf.ValueTypeNames.Float, - overrides.youngs_modulus, - ), - ( - "Poisson's ratio", - "physxDeformableBodyMaterial:poissonsRatio", - "omniphysics:poissonsRatio", - Sdf.ValueTypeNames.Float, - overrides.poissons_ratio, - ), - ( - "elasticity damping", - "physxDeformableBodyMaterial:elasticityDamping", - "physxDeformableMaterial:elasticityDamping", - Sdf.ValueTypeNames.Float, - overrides.elasticity_damping, - ), - ) - - warnings: list[str] = [] - for label, legacy_name, current_name, value_type, override in mappings: - legacy_value = _legacy_scalar(material_prim, legacy_name) - value = override if override is not None else legacy_value - if value is None: - warnings.append(f"{label} is not authored; Isaac Sim 6 will use the schema default") - else: - material_prim.CreateAttribute(current_name, value_type, custom=False).Set(float(value)) - - for attribute in list(material_prim.GetAuthoredAttributes()): - if attribute.GetName().startswith("physxDeformableBodyMaterial:"): - material_prim.RemoveProperty(attribute.GetName()) - return warnings - - -def _copy_current_asset(input_path: Path, output_path: Path, overwrite: bool) -> None: - """Copy an already-current asset without rewriting its USD layer.""" - - if input_path.resolve() == output_path.resolve(): - assert overwrite, "Input and output paths are identical; pass --overwrite to validate in place" - return - assert overwrite or not output_path.exists(), f"Output file already exists: {output_path}" - output_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(input_path, output_path) - - -def _convert_legacy_volume( - input_path: Path, - output_path: Path, - inspection: AssetInspection, - source_prim_path: str | None, - body_prim_path: str | None, - overrides: MaterialOverrides, - overwrite: bool, -) -> list[str]: - """Convert one legacy volumetric deformable and export a flattened stage.""" - - assert overwrite or not output_path.exists(), f"Output file already exists: {output_path}" - assert ( - input_path.resolve() != output_path.resolve() or overwrite - ), "Input and output paths are identical; pass --overwrite to replace the source" - - source_stage = Usd.Stage.Open(str(input_path)) - assert source_stage, f"Could not open input USD: {input_path}" - flattened_layer = source_stage.Flatten() - stage = Usd.Stage.Open(flattened_layer) - assert stage, f"Could not create a writable stage for {input_path}" - - candidate_paths = inspection.legacy_source_prim_paths - if source_prim_path is None: - assert len(candidate_paths) == 1, ( - "Expected exactly one legacy deformable mesh, found " - f"{len(candidate_paths)}: {list(candidate_paths)}. Pass --source_prim." - ) - source_prim_path = candidate_paths[0] - assert ( - source_prim_path in candidate_paths - ), f"Source prim {source_prim_path} is not a detected legacy deformable mesh: {list(candidate_paths)}" - - source_prim = stage.GetPrimAtPath(source_prim_path) - assert source_prim and source_prim.IsA(UsdGeom.Mesh), f"Legacy source prim must be a Mesh: {source_prim_path}" - - if body_prim_path is None: - body_prim_path = str(source_prim.GetParent().GetPath()) - body_prim = stage.GetPrimAtPath(body_prim_path) - assert body_prim, f"Body prim does not exist: {body_prim_path}" - assert source_prim.GetPath().HasPrefix( - body_prim.GetPath() - ), f"Source prim {source_prim_path} must be under body prim {body_prim_path}" - - simulation_pose_points = _required_array(source_prim, _LEGACY_SIMULATION_POINTS) - simulation_rest_points = _optional_array( - source_prim, - _LEGACY_SIMULATION_REST_POINTS, - simulation_pose_points, - ) - collision_pose_points = _required_array(source_prim, _LEGACY_COLLISION_POINTS) - collision_rest_points = _optional_array( - source_prim, - _LEGACY_COLLISION_REST_POINTS, - collision_pose_points, - ) - - simulation_tetrahedra = _tetrahedra_from_flat_indices( - _required_array(source_prim, _LEGACY_SIMULATION_INDICES), - len(simulation_rest_points), - "simulation", - ) - collision_tetrahedra = _tetrahedra_from_flat_indices( - _required_array(source_prim, _LEGACY_COLLISION_INDICES), - len(collision_rest_points), - "collision", - ) - - xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - source_to_world = xform_cache.GetLocalToWorldTransform(source_prim) - body_to_world = xform_cache.GetLocalToWorldTransform(body_prim) - source_to_body = source_to_world * body_to_world.GetInverse() - - simulation_pose_points = _transform_points(simulation_pose_points, source_to_body) - simulation_rest_points = _transform_points(simulation_rest_points, source_to_body) - collision_pose_points = _transform_points(collision_pose_points, source_to_body) - collision_rest_points = _transform_points(collision_rest_points, source_to_body) - - simulation_faces = _surface_faces(simulation_tetrahedra, simulation_rest_points, "simulation") - collision_faces = _surface_faces(collision_tetrahedra, collision_rest_points, "collision") - - simulation_prim = _author_tet_mesh( - stage, - body_prim.GetPath().AppendChild("simulation_mesh"), - simulation_rest_points, - simulation_tetrahedra, - simulation_faces, - ) - _apply_api(simulation_prim, "OmniPhysicsVolumeDeformableSimAPI") - _author_pose(simulation_prim, simulation_pose_points) - simulation_prim.CreateAttribute( - "omniphysics:restShapePoints", - Sdf.ValueTypeNames.Point3fArray, - custom=False, - ).Set(simulation_rest_points) - simulation_prim.CreateAttribute( - "omniphysics:restTetVtxIndices", - Sdf.ValueTypeNames.Int4Array, - custom=False, - ).Set(Vt.Vec4iArray([Gf.Vec4i(*tet) for tet in simulation_tetrahedra])) - - collision_prim = _author_tet_mesh( - stage, - body_prim.GetPath().AppendChild("collision_mesh"), - collision_rest_points, - collision_tetrahedra, - collision_faces, - ) - _apply_api(collision_prim, "PhysicsCollisionAPI") - _apply_api(collision_prim, "PhysxCollisionAPI") - _author_pose(collision_prim, collision_pose_points) - - contact_offset = _legacy_scalar(source_prim, "physxCollision:contactOffset") - rest_offset = _legacy_scalar(source_prim, "physxCollision:restOffset") - if contact_offset is not None: - collision_prim.CreateAttribute( - "physxCollision:contactOffset", - Sdf.ValueTypeNames.Float, - custom=False, - ).Set(float(contact_offset)) - if rest_offset is not None: - collision_prim.CreateAttribute( - "physxCollision:restOffset", - Sdf.ValueTypeNames.Float, - custom=False, - ).Set(float(rest_offset)) - - _apply_api(body_prim, "OmniPhysicsDeformableBodyAPI") - _apply_api(body_prim, "PhysxBaseDeformableBodyAPI") - UsdShade.MaterialBindingAPI.Apply(body_prim) - - solver_iterations = _legacy_scalar(source_prim, "physxDeformable:solverPositionIterationCount") - linear_damping = _legacy_scalar(source_prim, "physxDeformable:vertexVelocityDamping") - enable_ccd = _legacy_scalar(source_prim, "physxDeformable:enableCCD") - if solver_iterations is not None: - body_prim.CreateAttribute( - "physxDeformableBody:solverPositionIterationCount", - Sdf.ValueTypeNames.UInt, - custom=False, - ).Set(int(solver_iterations)) - if linear_damping is not None: - body_prim.CreateAttribute( - "physxDeformableBody:linearDamping", - Sdf.ValueTypeNames.Float, - custom=False, - ).Set(float(linear_damping)) - if enable_ccd is not None: - body_prim.CreateAttribute( - "physxDeformableBody:enableSpeculativeCCD", - Sdf.ValueTypeNames.Bool, - custom=False, - ).Set(bool(enable_ccd)) - - material_prim = _find_physics_material(stage, source_prim, body_prim) - warnings = _author_material(material_prim, overrides) - body_prim.CreateRelationship("material:binding:physics", custom=False).SetTargets([material_prim.GetPath()]) - - render_points = _required_array(source_prim, "points") - _author_pose(source_prim, Vt.Vec3fArray(render_points)) - source_prim.RemoveProperty("normals") - source_prim.RemoveProperty("material:binding:physics") - _remove_api_schema(source_prim, "PhysxDeformableBodyAPI") - _remove_api_schema(source_prim, "PhysxCollisionAPI") - _remove_api_schema(source_prim, "PhysicsCollisionAPI") - for attribute in list(source_prim.GetAuthoredAttributes()): - if attribute.GetName().startswith(("physxDeformable:", "physxCollision:")): - source_prim.RemoveProperty(attribute.GetName()) - - output_path.parent.mkdir(parents=True, exist_ok=True) - assert stage.GetRootLayer().Export(str(output_path)), f"Could not export converted USD: {output_path}" - return warnings - - -def _validate_current_asset(stage: Usd.Stage, inspection: AssetInspection) -> None: - """Check the minimum invariants expected by Isaac Lab's deformable view.""" - - assert inspection.body_prim_paths, "Current deformable asset has no recognized body prim" - assert inspection.simulation_prim_paths, "Current deformable asset has no recognized simulation mesh" - - for path in inspection.simulation_prim_paths: - prim = stage.GetPrimAtPath(path) - points = prim.GetAttribute("points").Get() - assert points is not None and len(points) > 0, f"Simulation mesh has no points: {path}" - pose_points = prim.GetAttribute("deformablePose:default:omniphysics:points").Get() - assert pose_points is not None and len(pose_points) == len( - points - ), f"Simulation pose count does not match point count on {path}" - - if inspection.kind is DeformableSchemaKind.CURRENT_VOLUME: - tetrahedra = prim.GetAttribute("tetVertexIndices").Get() - assert tetrahedra is not None and len(tetrahedra) > 0, f"Volume mesh has no tetrahedra: {path}" - elif inspection.kind is DeformableSchemaKind.CURRENT_SURFACE: - triangles = prim.GetAttribute("omniphysics:restTriVtxIndices").Get() - assert triangles is not None and len(triangles) > 0, f"Surface mesh has no triangles: {path}" - - -def _print_inspection(stage: Usd.Stage, inspection: AssetInspection) -> None: - """Print a concise human-readable asset report.""" - - print(f"Schema kind: {inspection.kind.value}") - print(f"Default prim: {inspection.default_prim_path or ''}") - print(f"Body prims: {list(inspection.body_prim_paths)}") - print(f"Simulation prims: {list(inspection.simulation_prim_paths)}") - print(f"Legacy source prims: {list(inspection.legacy_source_prim_paths)}") - - for path in inspection.simulation_prim_paths: - prim = stage.GetPrimAtPath(path) - points = prim.GetAttribute("points").Get() - if inspection.kind is DeformableSchemaKind.CURRENT_VOLUME: - elements = prim.GetAttribute("tetVertexIndices").Get() - element_label = "tetrahedra" - else: - elements = prim.GetAttribute("omniphysics:restTriVtxIndices").Get() - element_label = "triangles" - print(f" {path}: {len(points)} points, {len(elements)} {element_label}") - - -def _build_parser() -> argparse.ArgumentParser: - """Build the command-line parser.""" - - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--input_usd", type=Path, required=True, help="Input deformable USD file.") - parser.add_argument( - "--output_usd", - type=Path, - default=None, - help="Output USD file. Required when converting a legacy asset.", - ) - parser.add_argument( - "--source_prim", - type=str, - default=None, - help="Legacy render/deformable Mesh prim. Required only when multiple candidates are present.", - ) - parser.add_argument( - "--body_prim", - type=str, - default=None, - help="Body root prim. Defaults to the legacy source mesh's parent.", - ) - parser.add_argument("--overwrite", action="store_true", help="Allow replacing an existing output file.") - parser.add_argument( - "--validate_only", - action="store_true", - help="Inspect and validate the input without converting or copying it.", - ) - parser.add_argument("--density", type=float, default=None, help="Override deformable density [kg/m^3].") - parser.add_argument("--static_friction", type=float, default=None, help="Override static friction coefficient.") - parser.add_argument("--dynamic_friction", type=float, default=None, help="Override dynamic friction coefficient.") - parser.add_argument("--youngs_modulus", type=float, default=None, help="Override Young's modulus [Pa].") - parser.add_argument("--poissons_ratio", type=float, default=None, help="Override Poisson's ratio.") - parser.add_argument("--elasticity_damping", type=float, default=None, help="Override elasticity damping.") - return parser - - -def main() -> None: - """Run schema detection, conversion, and output validation.""" - - from isaaclab.app import AppLauncher - - parser = _build_parser() - AppLauncher.add_app_launcher_args(parser) - args = parser.parse_args() - - input_path = args.input_usd.expanduser().resolve() - assert input_path.is_file(), f"Input USD does not exist: {input_path}" - output_path = args.output_usd.expanduser().resolve() if args.output_usd is not None else None - - app_launcher = AppLauncher(args) - simulation_app = app_launcher.app - try: - _load_usd_modules() - stage = Usd.Stage.Open(str(input_path)) - assert stage, f"Could not open input USD: {input_path}" - inspection = inspect_stage(stage) - _print_inspection(stage, inspection) - - if inspection.kind is DeformableSchemaKind.UNSUPPORTED: - raise AssertionError("Unsupported or mixed deformable schema; no output was written") - - if args.validate_only: - if inspection.kind is not DeformableSchemaKind.LEGACY_VOLUME: - _validate_current_asset(stage, inspection) - print("Validation passed; no output was written.") - return - - if inspection.kind is DeformableSchemaKind.LEGACY_VOLUME: - assert output_path is not None, "--output_usd is required for a legacy asset conversion" - warnings = _convert_legacy_volume( - input_path=input_path, - output_path=output_path, - inspection=inspection, - source_prim_path=args.source_prim, - body_prim_path=args.body_prim, - overrides=MaterialOverrides( - density=args.density, - static_friction=args.static_friction, - dynamic_friction=args.dynamic_friction, - youngs_modulus=args.youngs_modulus, - poissons_ratio=args.poissons_ratio, - elasticity_damping=args.elasticity_damping, - ), - overwrite=args.overwrite, - ) - converted_stage = Usd.Stage.Open(str(output_path)) - assert converted_stage, f"Could not reopen converted USD: {output_path}" - converted_inspection = inspect_stage(converted_stage) - assert ( - converted_inspection.kind is DeformableSchemaKind.CURRENT_VOLUME - ), f"Converted output classified as {converted_inspection.kind.value}" - _validate_current_asset(converted_stage, converted_inspection) - print(f"Converted asset written to: {output_path}") - _print_inspection(converted_stage, converted_inspection) - for warning in warnings: - print(f"WARNING: {warning}") - else: - _validate_current_asset(stage, inspection) - print("Asset already uses a current Isaac Sim deformable schema; conversion is not required.") - if output_path is not None: - _copy_current_asset(input_path, output_path, args.overwrite) - print(f"Unmodified compatible asset copied to: {output_path}") - finally: - simulation_app.close() - - -if __name__ == "__main__": - main() From 54bd3abb5864615210bc60a17b266e25d2e7a621 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Fri, 14 Aug 2026 10:26:30 -0700 Subject: [PATCH 14/15] add e2e test --- .../e2e/test_softmimicgen_data_generation.py | 66 +++++++++++++++++++ ...ated_dataset_franka_rope_softmimicgen.hdf5 | 3 + 2 files changed, 69 insertions(+) create mode 100644 isaac_autodata_tests/e2e/test_softmimicgen_data_generation.py create mode 100644 isaac_autodata_tests/test_data/annotated_dataset_franka_rope_softmimicgen.hdf5 diff --git a/isaac_autodata_tests/e2e/test_softmimicgen_data_generation.py b/isaac_autodata_tests/e2e/test_softmimicgen_data_generation.py new file mode 100644 index 0000000..ec11c70 --- /dev/null +++ b/isaac_autodata_tests/e2e/test_softmimicgen_data_generation.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import tempfile + +import pytest + +from isaac_autodata_tests.utils.constants import TestPaths +from isaac_autodata_tests.utils.subprocess import run_subprocess +from isaac_autodata_tests.utils.utils import assert_valid_dataset + +HEADLESS = True +GENERATION_NUM_TRIALS = 1 + + +def _run_franka_rope_softmimicgen(num_envs: int, device: str) -> 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", + "--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) + + +@pytest.mark.with_subprocess +def test_franka_rope_softmimicgen_data_generation_single_env_cuda(): + """SoftMimicGen generation for the Franka rope task on a single env on GPU.""" + _run_franka_rope_softmimicgen(num_envs=1, device="cuda") + + +@pytest.mark.with_subprocess +def test_franka_rope_softmimicgen_data_generation_multi_env_cuda(): + """SoftMimicGen generation for the Franka rope task on multiple parallel envs on GPU.""" + _run_franka_rope_softmimicgen(num_envs=10, device="cuda") + + +if __name__ == "__main__": + test_franka_rope_softmimicgen_data_generation_single_env_cuda() + test_franka_rope_softmimicgen_data_generation_multi_env_cuda() diff --git a/isaac_autodata_tests/test_data/annotated_dataset_franka_rope_softmimicgen.hdf5 b/isaac_autodata_tests/test_data/annotated_dataset_franka_rope_softmimicgen.hdf5 new file mode 100644 index 0000000..2c25c2e --- /dev/null +++ b/isaac_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 From 4330ca67d4dccd4e94d0a6692b63887bb20a5427 Mon Sep 17 00:00:00 2001 From: Peter Du Date: Fri, 14 Aug 2026 10:40:17 -0700 Subject: [PATCH 15/15] exclude inf cost from tps option --- isaac_autodata_core/selection_strategy.py | 17 ++++--- .../core/test_selection_strategy.py | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/isaac_autodata_core/selection_strategy.py b/isaac_autodata_core/selection_strategy.py index 92462ca..65cb402 100644 --- a/isaac_autodata_core/selection_strategy.py +++ b/isaac_autodata_core/selection_strategy.py @@ -200,8 +200,8 @@ def select_source_demo( assert object_nodal_positions is not None, "registration_cost requires current object nodal positions" assert nn_k >= 1, f"nn_k must be at least 1, got {nn_k}" - costs: list[float] = [] - for datagen_info in src_subtask_datagen_infos: + valid_candidates: list[tuple[int, float]] = [] + for demo_index, datagen_info in enumerate(src_subtask_datagen_infos): assert ( datagen_info.object_nodal_positions is not None ), "registration_cost requires source object nodal positions" @@ -218,10 +218,13 @@ def select_source_demo( rot_reg=rot_reg, ) except (AssertionError, np.linalg.LinAlgError, ValueError): - cost = float("inf") - costs.append(cost) + continue + if np.isfinite(cost): + valid_candidates.append((demo_index, cost)) - costs_tensor = torch.tensor(costs, dtype=torch.float32) - nn_k = min(nn_k, len(costs)) + assert valid_candidates, "registration_cost could not compute a finite TPS cost for any source demo" + costs_tensor = torch.tensor([cost for _, cost in valid_candidates], dtype=torch.float32) + nn_k = min(nn_k, len(valid_candidates)) rand_k = torch.randint(0, nn_k, (1,)).item() - return torch.argsort(costs_tensor)[:nn_k][rand_k] + selected_candidate = torch.argsort(costs_tensor)[:nn_k][rand_k].item() + return valid_candidates[selected_candidate][0] diff --git a/isaac_autodata_tests/core/test_selection_strategy.py b/isaac_autodata_tests/core/test_selection_strategy.py index 0a43e41..a9b9dc8 100644 --- a/isaac_autodata_tests/core/test_selection_strategy.py +++ b/isaac_autodata_tests/core/test_selection_strategy.py @@ -131,3 +131,47 @@ def test_registration_cost_picks_lowest_cost(monkeypatch): nn_k=1, ) assert int(index) == 1 + + +def test_registration_cost_skips_failed_and_nonfinite_candidates(monkeypatch): + import isaac_autodata_core.deformable_transforms as deformable_transforms + + def registration_cost(source, target, **kwargs): + del target, kwargs + source_value = source[0, 0].item() + if source_value == 0.0: + raise ValueError("invalid TPS input") + if source_value == 1.0: + return float("nan") + return 1.0 + + monkeypatch.setattr(deformable_transforms, "nodal_registration_cost", registration_cost) + current = torch.zeros(6, 3) + infos = [DatagenInfo(object_nodal_positions={"rope": torch.full((1, 6, 3), value)}) for value in (0.0, 1.0, 2.0)] + + index = RegistrationCostStrategy().select_source_demo( + None, + None, + infos, + object_nodal_positions=current, + nn_k=3, + ) + + assert index == 2 + + +def test_registration_cost_rejects_all_invalid_candidates(monkeypatch): + import isaac_autodata_core.deformable_transforms as deformable_transforms + + monkeypatch.setattr(deformable_transforms, "nodal_registration_cost", lambda source, target, **kwargs: float("inf")) + current = torch.zeros(6, 3) + infos = [DatagenInfo(object_nodal_positions={"rope": torch.zeros(1, 6, 3)})] + + with pytest.raises(AssertionError, match="could not compute a finite TPS cost"): + RegistrationCostStrategy().select_source_demo( + None, + None, + infos, + object_nodal_positions=current, + nn_k=1, + )