From 3ca87e980cc31f8d61a2bf2ee5efc4bae89de4ee Mon Sep 17 00:00:00 2001 From: ooctipus Date: Wed, 27 Aug 2025 03:04:57 +0000 Subject: [PATCH 01/47] Disables generate internal template when detecting isaaclab install via pip (#3225) # Description If Isaac Lab is installed through pip, tasks generated with the Internal template cannot be placed inside the site-packages directory of that installation. Since this setup would never work correctly, it makes no sense to offer the Internal template as an option. This PR therefore disables that option entirely whenever Isaac Lab is detected as running from a pip-installed path. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../overview/developer-guide/template.rst | 6 ++-- tools/template/cli.py | 32 ++++++++++++------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/source/overview/developer-guide/template.rst b/docs/source/overview/developer-guide/template.rst index 7c75d27179e2..f9d954acdf4f 100644 --- a/docs/source/overview/developer-guide/template.rst +++ b/docs/source/overview/developer-guide/template.rst @@ -24,9 +24,9 @@ The template generator enables you to create an: .. warning:: - If you installed Isaac Lab via pip, any task generated by template outside of the pip-installed environment may not - be discovered properly. We are working on better support, but please prefer external projects when using - isaac lab pip installation. + Pip installations of Isaac Lab do not support *Internal* templates. + If ``isaaclab`` is loaded from ``site-packages`` or ``dist-packages``, the *Internal* option is disabled + and the *External* template will be used instead. Running the template generator ------------------------------ diff --git a/tools/template/cli.py b/tools/template/cli.py index f9480b461d84..013519f2a89e 100644 --- a/tools/template/cli.py +++ b/tools/template/cli.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause import enum +import importlib import os from collections.abc import Callable @@ -147,18 +148,25 @@ def main() -> None: """Main function to run template generation from CLI.""" cli_handler = CLIHandler() - # project type - is_external_project = ( - cli_handler.input_select( - "Task type:", - choices=["External", "Internal"], - long_instruction=( - "External (recommended): task/project is in its own folder/repo outside the Isaac Lab project.\n" - "Internal: the task is implemented within the Isaac Lab project (in source/isaaclab_tasks)." - ), - ).lower() - == "external" - ) + lab_module = importlib.import_module("isaaclab") + lab_path = os.path.realpath(getattr(lab_module, "__file__", "") or (getattr(lab_module, "__path__", [""])[0])) + is_lab_pip_installed = ("site-packages" in lab_path) or ("dist-packages" in lab_path) + + if not is_lab_pip_installed: + # project type + is_external_project = ( + cli_handler.input_select( + "Task type:", + choices=["External", "Internal"], + long_instruction=( + "External (recommended): task/project is in its own folder/repo outside the Isaac Lab project.\n" + "Internal: the task is implemented within the Isaac Lab project (in source/isaaclab_tasks)." + ), + ).lower() + == "external" + ) + else: + is_external_project = True # project path (if 'external') project_path = None From 3d3af0b6f603bcd5274b720eb9a8e5d7b4267ea4 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Wed, 27 Aug 2025 03:05:08 +0000 Subject: [PATCH 02/47] Fixes typo in isaaclab.bat (#3272) This PR fixes typo in isaaclab.bat Should be `!allArgs!` instead of `!allArgs1` ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- isaaclab.bat | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/isaaclab.bat b/isaaclab.bat index d4862217f347..6923c9ee9174 100644 --- a/isaaclab.bat +++ b/isaaclab.bat @@ -547,7 +547,7 @@ if "%arg%"=="-i" ( set "skip=1" ) ) - !isaacsim_exe! --ext-folder %ISAACLAB_PATH%\source !allArgs1 + !isaacsim_exe! --ext-folder %ISAACLAB_PATH%\source !allArgs! goto :end ) else if "%arg%"=="--sim" ( rem run the simulator exe provided by Isaac Sim @@ -562,7 +562,7 @@ if "%arg%"=="-i" ( set "skip=1" ) ) - !isaacsim_exe! --ext-folder %ISAACLAB_PATH%\source !allArgs1 + !isaacsim_exe! --ext-folder %ISAACLAB_PATH%\source !allArgs! goto :end ) else if "%arg%"=="-n" ( rem run the template generator script From b3f6b31674b1e3d7958d9c1161773cc863c88dce Mon Sep 17 00:00:00 2001 From: michaellin6 Date: Tue, 26 Aug 2025 20:09:35 -0700 Subject: [PATCH 03/47] =?UTF-8?q?Enhances=20Pink=20IK=20controller=20with?= =?UTF-8?q?=20null-space=20posture=20control=20and=20improv=E2=80=A6=20(#3?= =?UTF-8?q?149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Enhance Pink IK Controller with Null Space Posture Control This PR improves the Pink IK controller integration for better humanoid robot control and more natural postures. **Note**: Original this PR was staged in the internal repo (#547). It has been moved here due to new Github workflow. ## Key Changes ### New Null Space Posture Task - Added NullSpacePostureTask to enforce postural constraints on shoulder/waist joints while prioritizing end-effector tasks - Maintains natural robot poses during manipulation ### Controller Improvements - Tuned low level PD controller gains - Support mixed task types (FrameTask + NullSpacePostureTask) ### Testing & Environment Updates - Redesigned pink controller test script to use JSON-based configurations to program test motions. - Updated all environments (PickPlace, NutPour, ExhaustPipe) with null space control, damping tasks, and improved tracking - Added `Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0` env that is identical to `Isaac-PickPlace-GR1T2-Abs-v0` but enables the Waist DOFs. - Added target_eef_link_names mapping for clearer link specification Fixes # (issue) These changes help fix the following problems from [VDR feedback](https://docs.google.com/document/d/1saB1QA5r_WlD1l17q7C04WWNltnBW-K0ydI2UB8jxAs/edit?tab=t.0) - [Enable Waist DOF](https://nvbugspro.nvidia.com/bug/5235527) - Discourage elbow flare - Make controller low-latency and low-jerk. **We improved the unit test for the pink controller and reduced our position and rotation accuracy tolerance from 30 mm, 10 degrees to 1 mm, 1 degree.** - Develop metric for controller performance - Added a flag to disable failure due to joint limits. Previously, any commanded pose that ended in joint limit violation would result in no solution and the controlled robot freezing in place. This change gets the solver to still provide a solution and instead issue a warning for joint limit violations. ## Screenshots These controller changes have been tested through the Mimic pipeline (teleop_se3_agent.py, record/replay_demos.py). Here are videos showing teleoperation of all three environments working. ### PickPlace-GR1T2-Abs ![IK Improvements - Pick Place Wheel](https://github.com/user-attachments/assets/98bd5a70-e5fc-4b5b-954a-848c8dbe85d4) ### NutPour-GR1T2 ![IK Improvements - NutPour](https://github.com/user-attachments/assets/b3603dd4-73cb-4ee7-9963-c68a32dffc60) ### ExhaustPipe-GR1T2 ![IK Improvements - ExhaustPipe](https://github.com/user-attachments/assets/28cd1a4b-29cc-402c-9ec4-7082b2c64d98) ### Successfully Trained Robomimic Model Rollout on PickPlace task For the two robomimic tasks: `Isaac-PickPlace-GR1T2-Abs-v0` and `Isaac-NutPour-GR1T2-Pink-IK-Abs-v0`, if we collect a new dataset, we achieve a success rate of 96 and 92% respectively. ![IK Improvements - GR1T2 Waist Enabled Model Rollout Trimmed](https://github.com/user-attachments/assets/d270e8a8-ed72-41f3-84ac-bdc2c02d190d) ![IK Improvements - GR1T2 Nut Pour Model Rollout Trimmed](https://github.com/user-attachments/assets/1434721a-5dce-4b76-845a-6ac1379982f5) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- CONTRIBUTORS.md | 3 + .../manipulation/gr-1_pick_place_waist.jpg | Bin 0 -> 61739 bytes docs/source/api/lab/isaaclab.controllers.rst | 13 + docs/source/overview/environments.rst | 5 + docs/source/overview/teleop_imitation.rst | 5 +- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 22 + .../isaaclab/isaaclab/controllers/__init__.py | 1 + .../isaaclab/isaaclab/controllers/pink_ik.py | 133 ----- .../isaaclab/controllers/pink_ik/__init__.py | 13 + .../pink_ik/null_space_posture_task.py | 242 ++++++++++ .../isaaclab/controllers/pink_ik/pink_ik.py | 193 ++++++++ .../controllers/{ => pink_ik}/pink_ik_cfg.py | 5 + .../envs/mdp/actions/pink_actions_cfg.py | 9 +- .../mdp/actions/pink_task_space_actions.py | 23 +- source/isaaclab/isaaclab/utils/string.py | 10 +- .../controllers/simplified_test_robot.urdf | 191 ++++++++ .../pink_ik_gr1_test_configs.json | 86 ++++ .../test_null_space_posture_task.py | 339 +++++++++++++ .../isaaclab/test/controllers/test_pink_ik.py | 456 ++++++++++++------ source/isaaclab/test/utils/test_string.py | 21 + .../isaaclab_assets/robots/fourier.py | 38 ++ source/isaaclab_mimic/config/extension.toml | 2 +- source/isaaclab_mimic/docs/CHANGELOG.rst | 8 + .../envs/pinocchio_envs/__init__.py | 10 + .../pickplace_gr1t2_mimic_env.py | 4 +- .../pickplace_gr1t2_mimic_env_cfg.py | 2 +- ...place_gr1t2_waist_enabled_mimic_env_cfg.py | 111 +++++ source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 19 + .../manipulation/pick_place/__init__.py | 18 +- .../exhaustpipe_gr1t2_base_env_cfg.py | 3 + .../exhaustpipe_gr1t2_pink_ik_env_cfg.py | 45 +- .../pick_place/nutpour_gr1t2_base_env_cfg.py | 3 + .../nutpour_gr1t2_pink_ik_env_cfg.py | 45 +- .../pick_place/pickplace_gr1t2_env_cfg.py | 63 ++- .../pickplace_gr1t2_waist_enabled_env_cfg.py | 91 ++++ 37 files changed, 1897 insertions(+), 339 deletions(-) create mode 100644 docs/source/_static/tasks/manipulation/gr-1_pick_place_waist.jpg delete mode 100644 source/isaaclab/isaaclab/controllers/pink_ik.py create mode 100644 source/isaaclab/isaaclab/controllers/pink_ik/__init__.py create mode 100644 source/isaaclab/isaaclab/controllers/pink_ik/null_space_posture_task.py create mode 100644 source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py rename source/isaaclab/isaaclab/controllers/{ => pink_ik}/pink_ik_cfg.py (87%) create mode 100644 source/isaaclab/test/controllers/simplified_test_robot.urdf create mode 100644 source/isaaclab/test/controllers/test_configs/pink_ik_gr1_test_configs.json create mode 100644 source/isaaclab/test/controllers/test_null_space_posture_task.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 17cd64b4db39..bde83712b642 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -70,6 +70,7 @@ Guidelines for modifications: * HoJin Jeon * Hongwei Xiong * Hongyu Li +* Huihua Zhao * Iretiayo Akinola * Jack Zeng * Jan Kerner @@ -94,6 +95,7 @@ Guidelines for modifications: * Maurice Rahme * Michael Gussert * Michael Noseworthy +* Michael Lin * Miguel Alonso Jr * Mingyu Lee * Muhong Guo @@ -147,4 +149,5 @@ Guidelines for modifications: * Gavriel State * Hammad Mazhar * Marco Hutter +* Yan Chang * Yashraj Narang diff --git a/docs/source/_static/tasks/manipulation/gr-1_pick_place_waist.jpg b/docs/source/_static/tasks/manipulation/gr-1_pick_place_waist.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1f99cb72741922f75be1236462193841eb6b7f31 GIT binary patch literal 61739 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 o&%XliAB!{v3>(P>bTqka+Oq#E{vb(s2SBR`__ .. |reach-ur10-link| replace:: `Isaac-Reach-UR10-v0 `__ @@ -154,6 +158,7 @@ for the lift-cube environment: .. |stack-cube-link| replace:: `Isaac-Stack-Cube-Franka-v0 `__ .. |stack-cube-bp-link| replace:: `Isaac-Stack-Cube-Franka-IK-Rel-Blueprint-v0 `__ .. |gr1_pick_place-link| replace:: `Isaac-PickPlace-GR1T2-Abs-v0 `__ +.. |gr1_pp_waist-link| replace:: `Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0 `__ .. |cube-shadow-link| replace:: `Isaac-Repose-Cube-Shadow-Direct-v0 `__ .. |cube-shadow-ff-link| replace:: `Isaac-Repose-Cube-Shadow-OpenAI-FF-Direct-v0 `__ diff --git a/docs/source/overview/teleop_imitation.rst b/docs/source/overview/teleop_imitation.rst index a8c3c347f9b9..67cddf03ec18 100644 --- a/docs/source/overview/teleop_imitation.rst +++ b/docs/source/overview/teleop_imitation.rst @@ -333,7 +333,7 @@ Collect human demonstrations Data collection for the GR-1 humanoid robot environment requires use of an Apple Vision Pro headset. If you do not have access to an Apple Vision Pro, you may skip this step and continue on to the next step: `Generate the dataset`_. - A pre-recorded annotated dataset is provided in the next step . + A pre-recorded annotated dataset is provided in the next step. .. tip:: The GR1 scene utilizes the wrist poses from the Apple Vision Pro (AVP) as setpoints for a differential IK controller (Pink-IK). @@ -380,6 +380,9 @@ Collect five demonstrations by running the following command: --dataset_file ./datasets/dataset_gr1.hdf5 \ --num_demos 5 --enable_pinocchio +.. note:: + We also provide a GR-1 pick and place task with waist degrees-of-freedom enabled ``Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0`` (see :ref:`environments` for details on the available environments, including the GR1 Waist Enabled variant). The same command above applies but with the task name changed to ``Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0``. + .. tip:: If a demo fails during data collection, the environment can be reset using the teleoperation controls panel in the XR teleop client on the Apple Vision Pro or via voice control by saying "reset". See :ref:`teleoperate-apple-vision-pro` for more details. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 64f008c0aba1..d51360662a22 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.7" +version = "0.45.8" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 6bb60f6b2753..10ed470e018f 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,28 @@ Changelog --------- +0.45.8 (2025-07-25) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Created :attr:`~isaaclab.controllers.pink_ik.PinkIKControllerCfg.target_eef_link_names` to :class:`~isaaclab.controllers.pink_ik.PinkIKControllerCfg` + to specify the target end-effector link names for the pink inverse kinematics controller. + +Changed +^^^^^^^ + +* Updated pink inverse kinematics controller configuration for the following tasks (Isaac-PickPlace-GR1T2, Isaac-NutPour-GR1T2, Isaac-ExhaustPipe-GR1T2) + to increase end-effector tracking accuracy and speed. Also added a null-space regularizer that enables turning on of waist degrees-of-freedom. +* Improved the test_pink_ik script to more comprehensive test on controller accuracy. Also, migrated to use pytest. With the current IK controller + improvements, our unit tests pass position and orientation accuracy test within **(1 mm, 1 degree)**. Previously, the position accuracy tolerances + were set to **(30 mm, 10 degrees)**. +* Included a new config parameter :attr:`fail_on_ik_error` to :class:`~isaaclab.controllers.pink_ik.PinkIKControllerCfg` + to control whether the IK controller raise an exception if robot joint limits are exceeded. In the case of an exception, the controller will hold the + last joint position. This adds to stability of the controller and avoids operator experiencing what is perceived as sudden large delays in robot control. + + 0.45.7 (2025-08-21) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/controllers/__init__.py b/source/isaaclab/isaaclab/controllers/__init__.py index ffc5a5fb9a77..6a5b884b78ac 100644 --- a/source/isaaclab/isaaclab/controllers/__init__.py +++ b/source/isaaclab/isaaclab/controllers/__init__.py @@ -15,3 +15,4 @@ from .differential_ik_cfg import DifferentialIKControllerCfg from .operational_space import OperationalSpaceController from .operational_space_cfg import OperationalSpaceControllerCfg +from .pink_ik import NullSpacePostureTask, PinkIKController, PinkIKControllerCfg diff --git a/source/isaaclab/isaaclab/controllers/pink_ik.py b/source/isaaclab/isaaclab/controllers/pink_ik.py deleted file mode 100644 index 8fff42247223..000000000000 --- a/source/isaaclab/isaaclab/controllers/pink_ik.py +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Pink IK controller implementation for IsaacLab. - -This module provides integration between Pink inverse kinematics solver and IsaacLab. -Pink is a differentiable inverse kinematics solver framework that provides task-space control capabilities. -""" - -import numpy as np -import torch - -from pink import solve_ik -from pink.configuration import Configuration -from pinocchio.robot_wrapper import RobotWrapper - -from .pink_ik_cfg import PinkIKControllerCfg - - -class PinkIKController: - """Integration of Pink IK controller with Isaac Lab. - - The Pink IK controller is available at: https://github.com/stephane-caron/pink - """ - - def __init__(self, cfg: PinkIKControllerCfg, device: str): - """Initialize the Pink IK Controller. - - Args: - cfg: The configuration for the controller. - device: The device to use for computations (e.g., 'cuda:0'). - """ - # Initialize the robot model from URDF and mesh files - self.robot_wrapper = RobotWrapper.BuildFromURDF(cfg.urdf_path, cfg.mesh_path, root_joint=None) - self.pink_configuration = Configuration( - self.robot_wrapper.model, self.robot_wrapper.data, self.robot_wrapper.q0 - ) - - # Set the default targets for each task from the configuration - for task in cfg.variable_input_tasks: - task.set_target_from_configuration(self.pink_configuration) - for task in cfg.fixed_input_tasks: - task.set_target_from_configuration(self.pink_configuration) - - # Map joint names from Isaac Lab to Pink's joint conventions - pink_joint_names = self.robot_wrapper.model.names.tolist()[1:] # Skip the root and universal joints - isaac_lab_joint_names = cfg.joint_names - - # Create reordering arrays for joint indices - self.isaac_lab_to_pink_ordering = [isaac_lab_joint_names.index(pink_joint) for pink_joint in pink_joint_names] - self.pink_to_isaac_lab_ordering = [ - pink_joint_names.index(isaac_lab_joint) for isaac_lab_joint in isaac_lab_joint_names - ] - - self.cfg = cfg - self.device = device - - """ - Operations. - """ - - def reorder_array(self, input_array: list[float], reordering_array: list[int]) -> list[float]: - """Reorder the input array based on the provided ordering. - - Args: - input_array: The array to reorder. - reordering_array: The indices to use for reordering. - - Returns: - Reordered array. - """ - return [input_array[i] for i in reordering_array] - - def initialize(self): - """Initialize the internals of the controller. - - This method is called during setup but before the first compute call. - """ - pass - - def compute( - self, - curr_joint_pos: np.ndarray, - dt: float, - ) -> torch.Tensor: - """Compute the target joint positions based on current state and tasks. - - Args: - curr_joint_pos: The current joint positions. - dt: The time step for computing joint position changes. - - Returns: - The target joint positions as a tensor. - """ - # Initialize joint positions for Pink, including the root and universal joints - joint_positions_pink = np.array(self.reorder_array(curr_joint_pos, self.isaac_lab_to_pink_ordering)) - - # Update Pink's robot configuration with the current joint positions - self.pink_configuration.update(joint_positions_pink) - - # pink.solve_ik can raise an exception if the solver fails - try: - velocity = solve_ik( - self.pink_configuration, self.cfg.variable_input_tasks + self.cfg.fixed_input_tasks, dt, solver="osqp" - ) - Delta_q = velocity * dt - except (AssertionError, Exception): - # Print warning and return the current joint positions as the target - # Not using omni.log since its not available in CI during docs build - if self.cfg.show_ik_warnings: - print( - "Warning: IK quadratic solver could not find a solution! Did not update the target joint positions." - ) - return torch.tensor(curr_joint_pos, device=self.device, dtype=torch.float32) - - # Discard the first 6 values (for root and universal joints) - pink_joint_angle_changes = Delta_q - - # Reorder the joint angle changes back to Isaac Lab conventions - joint_vel_isaac_lab = torch.tensor( - self.reorder_array(pink_joint_angle_changes, self.pink_to_isaac_lab_ordering), - device=self.device, - dtype=torch.float, - ) - - # Add the velocity changes to the current joint positions to get the target joint positions - target_joint_pos = torch.add( - joint_vel_isaac_lab, torch.tensor(curr_joint_pos, device=self.device, dtype=torch.float32) - ) - - return target_joint_pos diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/__init__.py b/source/isaaclab/isaaclab/controllers/pink_ik/__init__.py new file mode 100644 index 000000000000..005645f97985 --- /dev/null +++ b/source/isaaclab/isaaclab/controllers/pink_ik/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Pink IK controller package for IsaacLab. + +This package provides integration between Pink inverse kinematics solver and IsaacLab. +""" + +from .null_space_posture_task import NullSpacePostureTask +from .pink_ik import PinkIKController +from .pink_ik_cfg import PinkIKControllerCfg diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/null_space_posture_task.py b/source/isaaclab/isaaclab/controllers/pink_ik/null_space_posture_task.py new file mode 100644 index 000000000000..212071c904e8 --- /dev/null +++ b/source/isaaclab/isaaclab/controllers/pink_ik/null_space_posture_task.py @@ -0,0 +1,242 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np + +import pinocchio as pin +from pink.configuration import Configuration +from pink.tasks import Task + + +class NullSpacePostureTask(Task): + r"""Pink-based task that adds a posture objective that is in the null space projection of other tasks. + + This task implements posture control in the null space of higher priority tasks + (typically end-effector pose tasks) within the Pink inverse kinematics framework. + + **Mathematical Formulation:** + + For details on Pink Inverse Kinematics optimization formulation visit: https://github.com/stephane-caron/pink + + **Null Space Posture Task Implementation:** + + This task consists of two components: + + 1. **Error Function**: The posture error is computed as: + + .. math:: + + \mathbf{e}(\mathbf{q}) = \mathbf{M} \cdot (\mathbf{q}^* - \mathbf{q}) + + where: + - :math:`\mathbf{q}^*` is the target joint configuration + - :math:`\mathbf{q}` is the current joint configuration + - :math:`\mathbf{M}` is a joint selection mask matrix + + 2. **Jacobian Matrix**: The task Jacobian is the null space projector: + + .. math:: + + \mathbf{J}_{\text{posture}}(\mathbf{q}) = \mathbf{N}(\mathbf{q}) = \mathbf{I} - \mathbf{J}_{\text{primary}}^+ \mathbf{J}_{\text{primary}} + + where: + - :math:`\mathbf{J}_{\text{primary}}` is the combined Jacobian of all higher priority tasks + - :math:`\mathbf{J}_{\text{primary}}^+` is the pseudoinverse of the primary task Jacobian + - :math:`\mathbf{N}(\mathbf{q})` is the null space projector matrix + + For example, if there are two frame tasks (e.g., controlling the pose of two end-effectors), the combined Jacobian + :math:`\mathbf{J}_{\text{primary}}` is constructed by stacking the individual Jacobians for each frame vertically: + + .. math:: + + \mathbf{J}_{\text{primary}} = + \begin{bmatrix} + \mathbf{J}_1(\mathbf{q}) \\ + \mathbf{J}_2(\mathbf{q}) + \end{bmatrix} + + where :math:`\mathbf{J}_1(\mathbf{q})` and :math:`\mathbf{J}_2(\mathbf{q})` are the Jacobians for the first and second frame tasks, respectively. + + The null space projector ensures that joint velocities in the null space produce zero velocity + for the primary tasks: :math:`\mathbf{J}_{\text{primary}} \cdot \dot{\mathbf{q}}_{\text{null}} = \mathbf{0}`. + + **Task Integration:** + + When integrated into the Pink framework, this task contributes to the optimization as: + + .. math:: + + \left\| \mathbf{N}(\mathbf{q}) \mathbf{v} + \mathbf{M} \cdot (\mathbf{q}^* - \mathbf{q}) \right\|_{W_{\text{posture}}}^2 + + This formulation allows the robot to maintain a desired posture while respecting the constraints + imposed by higher priority tasks (e.g., end-effector positioning). + + """ + + def __init__( + self, + cost: float, + lm_damping: float = 0.0, + gain: float = 1.0, + controlled_frames: list[str] | None = None, + controlled_joints: list[str] | None = None, + ) -> None: + r"""Initialize the null space posture task. + + This task maintains a desired joint posture in the null space of higher-priority + frame tasks. Joint selection allows excluding specific joints (e.g., wrist joints + in humanoid manipulation) to prevent large rotational ranges from overwhelming + errors in critical joints like shoulders and waist. + + Args: + cost: Task weighting factor in the optimization objective. + Units: :math:`[\text{cost}] / [\text{rad}]`. + lm_damping: Levenberg-Marquardt regularization scale (unitless). Defaults to 0.0. + gain: Task gain :math:`\alpha \in [0, 1]` for low-pass filtering. + Defaults to 1.0 (no filtering). + controlled_frames: Frame names whose Jacobians define the primary tasks for + null space projection. If None or empty, no projection is applied. + controlled_joints: Joint names to control in the posture task. If None or + empty, all actuated joints are controlled. + """ + super().__init__(cost=cost, gain=gain, lm_damping=lm_damping) + self.target_q: np.ndarray | None = None + self.controlled_frames: list[str] = controlled_frames or [] + self.controlled_joints: list[str] = controlled_joints or [] + self._joint_mask: np.ndarray | None = None + self._frame_names: list[str] | None = None + + def __repr__(self) -> str: + """Human-readable representation of the task.""" + return ( + f"NullSpacePostureTask(cost={self.cost}, gain={self.gain}, lm_damping={self.lm_damping}," + f" controlled_frames={self.controlled_frames}, controlled_joints={self.controlled_joints})" + ) + + def _build_joint_mapping(self, configuration: Configuration) -> None: + """Build joint mask and cache frequently used values. + + Creates a binary mask that selects which joints should be controlled + in the posture task. + + Args: + configuration: Robot configuration containing the model and joint information. + """ + # Create joint mask for full configuration size + self._joint_mask = np.zeros(configuration.model.nq) + + # Create dictionary for joint names to indices (exclude root joint) + joint_names = configuration.model.names.tolist()[1:] + + # Build joint mask efficiently + for i, joint_name in enumerate(joint_names): + if joint_name in self.controlled_joints: + self._joint_mask[i] = 1.0 + + # Cache frame names for performance + self._frame_names = list(self.controlled_frames) + + def set_target(self, target_q: np.ndarray) -> None: + """Set target posture configuration. + + Args: + target_q: Target vector in the configuration space. If the model + has a floating base, then this vector should include + floating-base coordinates (although they have no effect on the + posture task since only actuated joints are controlled). + """ + self.target_q = target_q.copy() + + def set_target_from_configuration(self, configuration: Configuration) -> None: + """Set target posture from a robot configuration. + + Args: + configuration: Robot configuration whose joint angles will be used + as the target posture. + """ + self.set_target(configuration.q) + + def compute_error(self, configuration: Configuration) -> np.ndarray: + r"""Compute posture task error. + + The error computation follows: + + .. math:: + + \mathbf{e}(\mathbf{q}) = \mathbf{M} \cdot (\mathbf{q}^* - \mathbf{q}) + + where :math:`\mathbf{M}` is the joint selection mask and :math:`\mathbf{q}^* - \mathbf{q}` + is computed using Pinocchio's difference function to handle joint angle wrapping. + + Args: + configuration: Robot configuration :math:`\mathbf{q}`. + + Returns: + Posture task error :math:`\mathbf{e}(\mathbf{q})` with the same dimension + as the configuration vector, but with zeros for non-controlled joints. + + Raises: + ValueError: If no posture target has been set. + """ + if self.target_q is None: + raise ValueError("No posture target has been set. Call set_target() first.") + + # Initialize joint mapping if needed + if self._joint_mask is None: + self._build_joint_mapping(configuration) + + # Compute configuration difference using Pinocchio's difference function + # This handles joint angle wrapping correctly + err = pin.difference( + configuration.model, + self.target_q, + configuration.q, + ) + + # Apply pre-computed joint mask to select only controlled joints + return self._joint_mask * err + + def compute_jacobian(self, configuration: Configuration) -> np.ndarray: + r"""Compute the null space projector Jacobian. + + The null space projector is defined as: + + .. math:: + + \mathbf{N}(\mathbf{q}) = \mathbf{I} - \mathbf{J}_{\text{primary}}^+ \mathbf{J}_{\text{primary}} + + where: + - :math:`\mathbf{J}_{\text{primary}}` is the combined Jacobian of all controlled frames + - :math:`\mathbf{J}_{\text{primary}}^+` is the pseudoinverse of the primary task Jacobian + - :math:`\mathbf{I}` is the identity matrix + + The null space projector ensures that joint velocities in the null space produce + zero velocity for the primary tasks: :math:`\mathbf{J}_{\text{primary}} \cdot \dot{\mathbf{q}}_{\text{null}} = \mathbf{0}`. + + If no controlled frames are specified, returns the identity matrix. + + Args: + configuration: Robot configuration :math:`\mathbf{q}`. + + Returns: + Null space projector matrix :math:`\mathbf{N}(\mathbf{q})` with dimensions + :math:`n_q \times n_q` where :math:`n_q` is the number of configuration variables. + """ + # Initialize joint mapping if needed + if self._frame_names is None: + self._build_joint_mapping(configuration) + + # If no frame tasks are defined, return identity matrix (no null space projection) + if not self._frame_names: + return np.eye(configuration.model.nq) + + # Get Jacobians for all frame tasks and combine them + J_frame_tasks = [configuration.get_frame_jacobian(frame_name) for frame_name in self._frame_names] + J_combined = np.concatenate(J_frame_tasks, axis=0) + + # Compute null space projector: N = I - J^+ * J + N_combined = np.eye(J_combined.shape[1]) - np.linalg.pinv(J_combined) @ J_combined + + return N_combined diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py new file mode 100644 index 000000000000..f37ebe163e19 --- /dev/null +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py @@ -0,0 +1,193 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Pink IK controller implementation for IsaacLab. + +This module provides integration between Pink inverse kinematics solver and IsaacLab. +Pink is a differentiable inverse kinematics solver framework that provides task-space control capabilities. + +Reference: + Pink IK Solver: https://github.com/stephane-caron/pink +""" + +from __future__ import annotations + +import numpy as np +import torch +from typing import TYPE_CHECKING + +from pink import solve_ik +from pink.configuration import Configuration +from pink.tasks import FrameTask +from pinocchio.robot_wrapper import RobotWrapper + +from isaaclab.assets import ArticulationCfg +from isaaclab.utils.string import resolve_matching_names_values + +from .null_space_posture_task import NullSpacePostureTask + +if TYPE_CHECKING: + from .pink_ik_cfg import PinkIKControllerCfg + + +class PinkIKController: + """Integration of Pink IK controller with Isaac Lab. + + The Pink IK controller solves differential inverse kinematics through weighted tasks. Each task is defined + by a residual function e(q) that is driven to zero (e.g., e(q) = p_target - p_ee(q) for end-effector positioning). + The controller computes joint velocities v satisfying J_e(q)v = -αe(q), where J_e(q) is the task Jacobian. + Multiple tasks are resolved through weighted optimization, formulating a quadratic program that minimizes + weighted task errors while respecting joint velocity limits. + + It supports user defined tasks, and we have provided a NullSpacePostureTask for maintaining desired joint configurations. + + Reference: + Pink IK Solver: https://github.com/stephane-caron/pink + """ + + def __init__(self, cfg: PinkIKControllerCfg, robot_cfg: ArticulationCfg, device: str): + """Initialize the Pink IK Controller. + + Args: + cfg: The configuration for the Pink IK controller containing task definitions, solver parameters, + and joint configurations. + robot_cfg: The robot articulation configuration containing initial joint positions and robot + specifications. + device: The device to use for computations (e.g., 'cuda:0', 'cpu'). + + Raises: + KeyError: When Pink joint names cannot be matched to robot configuration joint positions. + """ + # Initialize the robot model from URDF and mesh files + self.robot_wrapper = RobotWrapper.BuildFromURDF(cfg.urdf_path, cfg.mesh_path, root_joint=None) + self.pink_configuration = Configuration( + self.robot_wrapper.model, self.robot_wrapper.data, self.robot_wrapper.q0 + ) + + # Find the initial joint positions by matching Pink's joint names to robot_cfg.init_state.joint_pos, + # where the joint_pos keys may be regex patterns and the values are the initial positions. + # We want to assign to each Pink joint name the value from the first matching regex key in joint_pos. + pink_joint_names = self.pink_configuration.model.names.tolist()[1:] + joint_pos_dict = robot_cfg.init_state.joint_pos + + # Use resolve_matching_names_values to match Pink joint names to joint_pos values + indices, names, values = resolve_matching_names_values( + joint_pos_dict, pink_joint_names, preserve_order=False, strict=False + ) + if len(indices) != len(pink_joint_names): + unmatched = [name for name in pink_joint_names if name not in names] + raise KeyError( + "Could not find a match for all Pink joint names in robot_cfg.init_state.joint_pos. " + f"Unmatched: {unmatched}, Expected: {pink_joint_names}" + ) + self.init_joint_positions = np.array(values) + + # Set the default targets for each task from the configuration + for task in cfg.variable_input_tasks: + # If task is a NullSpacePostureTask, set the target to the initial joint positions + if isinstance(task, NullSpacePostureTask): + task.set_target(self.init_joint_positions) + continue + task.set_target_from_configuration(self.pink_configuration) + for task in cfg.fixed_input_tasks: + task.set_target_from_configuration(self.pink_configuration) + + # Map joint names from Isaac Lab to Pink's joint conventions + self.pink_joint_names = self.robot_wrapper.model.names.tolist()[1:] # Skip the root and universal joints + self.isaac_lab_joint_names = cfg.joint_names + assert cfg.joint_names is not None, "cfg.joint_names cannot be None" + + # Frame task link names + self.frame_task_link_names = [] + for task in cfg.variable_input_tasks: + if isinstance(task, FrameTask): + self.frame_task_link_names.append(task.frame) + + # Create reordering arrays for joint indices + self.isaac_lab_to_pink_ordering = np.array( + [self.isaac_lab_joint_names.index(pink_joint) for pink_joint in self.pink_joint_names] + ) + self.pink_to_isaac_lab_ordering = np.array( + [self.pink_joint_names.index(isaac_lab_joint) for isaac_lab_joint in self.isaac_lab_joint_names] + ) + + self.cfg = cfg + self.device = device + + def update_null_space_joint_targets(self, curr_joint_pos: np.ndarray): + """Update the null space joint targets. + + This method updates the target joint positions for null space posture tasks based on the current + joint configuration. This is useful for maintaining desired joint configurations when the primary + task allows redundancy. + + Args: + curr_joint_pos: The current joint positions of shape (num_joints,). + """ + for task in self.cfg.variable_input_tasks: + if isinstance(task, NullSpacePostureTask): + task.set_target(curr_joint_pos) + + def compute( + self, + curr_joint_pos: np.ndarray, + dt: float, + ) -> torch.Tensor: + """Compute the target joint positions based on current state and tasks. + + Performs inverse kinematics using the Pink solver to compute target joint positions that satisfy + the defined tasks. The solver uses quadratic programming to find optimal joint velocities that + minimize task errors while respecting constraints. + + Args: + curr_joint_pos: The current joint positions of shape (num_joints,). + dt: The time step for computing joint position changes in seconds. + + Returns: + The target joint positions as a tensor of shape (num_joints,) on the specified device. + If the IK solver fails, returns the current joint positions unchanged to maintain stability. + """ + # Initialize joint positions for Pink, change from isaac_lab to pink/pinocchio joint ordering. + joint_positions_pink = curr_joint_pos[self.isaac_lab_to_pink_ordering] + + # Update Pink's robot configuration with the current joint positions + self.pink_configuration.update(joint_positions_pink) + + # pink.solve_ik can raise an exception if the solver fails + try: + velocity = solve_ik( + self.pink_configuration, + self.cfg.variable_input_tasks + self.cfg.fixed_input_tasks, + dt, + solver="osqp", + safety_break=self.cfg.fail_on_joint_limit_violation, + ) + Delta_q = velocity * dt + except (AssertionError, Exception) as e: + # Print warning and return the current joint positions as the target + # Not using omni.log since its not available in CI during docs build + if self.cfg.show_ik_warnings: + print( + "Warning: IK quadratic solver could not find a solution! Did not update the target joint" + f" positions.\nError: {e}" + ) + return torch.tensor(curr_joint_pos, device=self.device, dtype=torch.float32) + + # Discard the first 6 values (for root and universal joints) + pink_joint_angle_changes = Delta_q + + # Reorder the joint angle changes back to Isaac Lab conventions + joint_vel_isaac_lab = torch.tensor( + pink_joint_angle_changes[self.pink_to_isaac_lab_ordering], + device=self.device, + dtype=torch.float, + ) + + # Add the velocity changes to the current joint positions to get the target joint positions + target_joint_pos = torch.add( + joint_vel_isaac_lab, torch.tensor(curr_joint_pos, device=self.device, dtype=torch.float32) + ) + + return target_joint_pos diff --git a/source/isaaclab/isaaclab/controllers/pink_ik_cfg.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py similarity index 87% rename from source/isaaclab/isaaclab/controllers/pink_ik_cfg.py rename to source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py index 52bea14f6ccb..5add83a59168 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik_cfg.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py @@ -57,3 +57,8 @@ class PinkIKControllerCfg: show_ik_warnings: bool = True """Show warning if IK solver fails to find a solution.""" + + fail_on_joint_limit_violation: bool = True + """If True, the Pink IK solver will fail and raise an error if any joint limit is violated during optimization. PinkIKController + will handle the error by setting the last joint positions. If False, the solver will ignore joint limit violations and return the + closest solution found.""" diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py index 6b7c412de7dc..834d23d955a0 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py @@ -5,7 +5,7 @@ from dataclasses import MISSING -from isaaclab.controllers.pink_ik_cfg import PinkIKControllerCfg +from isaaclab.controllers.pink_ik import PinkIKControllerCfg from isaaclab.managers.action_manager import ActionTerm, ActionTermCfg from isaaclab.utils import configclass @@ -34,3 +34,10 @@ class PinkInverseKinematicsActionCfg(ActionTermCfg): controller: PinkIKControllerCfg = MISSING """Configuration for the Pink IK controller that will be used to solve the inverse kinematics.""" + + target_eef_link_names: dict[str, str] = MISSING + """Dictionary mapping task names to controlled link names for the Pink IK controller. + + This dictionary should map the task names (e.g., 'left_wrist', 'right_wrist') to the + corresponding link names in the URDF that will be controlled by the IK solver. + """ diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py index e0d008834a52..f1e9fd7a819c 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py @@ -10,6 +10,8 @@ from collections.abc import Sequence from typing import TYPE_CHECKING +from pink.tasks import FrameTask + import isaaclab.utils.math as math_utils from isaaclab.assets.articulation import Articulation from isaaclab.controllers.pink_ik import PinkIKController @@ -57,7 +59,9 @@ def __init__(self, cfg: pink_actions_cfg.PinkInverseKinematicsActionCfg, env: Ma assert env.num_envs > 0, "Number of environments specified are less than 1." self._ik_controllers = [] for _ in range(env.num_envs): - self._ik_controllers.append(PinkIKController(cfg=copy.deepcopy(self.cfg.controller), device=self.device)) + self._ik_controllers.append( + PinkIKController(cfg=self.cfg.controller.copy(), robot_cfg=env.scene.cfg.robot, device=self.device) + ) # Create tensors to store raw and processed actions self._raw_actions = torch.zeros(self.num_envs, self.action_dim, device=self.device) @@ -113,8 +117,11 @@ def pose_dim(self) -> int: @property def action_dim(self) -> int: """Dimension of the action space (based on number of tasks and pose dimension).""" - # The tasks for all the controllers are the same, hence just using the first one to calculate the action_dim - return len(self._ik_controllers[0].cfg.variable_input_tasks) * self.pose_dim + self.hand_joint_dim + # Count only FrameTask instances in variable_input_tasks + frame_tasks_count = sum( + 1 for task in self._ik_controllers[0].cfg.variable_input_tasks if isinstance(task, FrameTask) + ) + return frame_tasks_count * self.pose_dim + self.hand_joint_dim @property def raw_actions(self) -> torch.Tensor: @@ -163,7 +170,6 @@ def process_actions(self, actions: torch.Tensor): """ # Store the raw actions self._raw_actions[:] = actions - self._processed_actions[:] = self.raw_actions # Make a copy of actions before modifying so that raw actions are not modified actions_clone = actions.clone() @@ -204,10 +210,11 @@ def process_actions(self, actions: torch.Tensor): # Loop through each task and set the target for env_index, ik_controller in enumerate(self._ik_controllers): for task_index, task in enumerate(ik_controller.cfg.variable_input_tasks): - target = task.transform_target_to_world - target.translation = controlled_frame_in_base_link_frame_pos[task_index, env_index, :].cpu().numpy() - target.rotation = controlled_frame_in_base_link_frame_mat[task_index, env_index, :].cpu().numpy() - task.set_target(target) + if isinstance(task, FrameTask): + target = task.transform_target_to_world + target.translation = controlled_frame_in_base_link_frame_pos[task_index, env_index, :].cpu().numpy() + target.rotation = controlled_frame_in_base_link_frame_mat[task_index, env_index, :].cpu().numpy() + task.set_target(target) def apply_actions(self): # start_time = time.time() # Capture the time before the step diff --git a/source/isaaclab/isaaclab/utils/string.py b/source/isaaclab/isaaclab/utils/string.py index e0bf5dc45780..43a2fa0b3106 100644 --- a/source/isaaclab/isaaclab/utils/string.py +++ b/source/isaaclab/isaaclab/utils/string.py @@ -272,7 +272,10 @@ def resolve_matching_names( def resolve_matching_names_values( - data: dict[str, Any], list_of_strings: Sequence[str], preserve_order: bool = False + data: dict[str, Any], + list_of_strings: Sequence[str], + preserve_order: bool = False, + strict: bool = True, ) -> tuple[list[int], list[str], list[Any]]: """Match a list of regular expressions in a dictionary against a list of strings and return the matched indices, names, and values. @@ -293,6 +296,7 @@ def resolve_matching_names_values( data: A dictionary of regular expressions and values to match the strings in the list. list_of_strings: A list of strings to match. preserve_order: Whether to preserve the order of the query keys in the returned values. Defaults to False. + strict: Whether to require that all keys in the dictionary get matched. Defaults to True. Returns: A tuple of lists containing the matched indices, names, and values. @@ -300,7 +304,7 @@ def resolve_matching_names_values( Raises: TypeError: When the input argument :attr:`data` is not a dictionary. ValueError: When multiple matches are found for a string in the dictionary. - ValueError: When not all regular expressions in the data keys are matched. + ValueError: When not all regular expressions in the data keys are matched (if strict is True). """ # check valid input if not isinstance(data, dict): @@ -354,7 +358,7 @@ def resolve_matching_names_values( names_list = names_list_reorder values_list = values_list_reorder # check that all regular expressions are matched - if not all(keys_match_found): + if strict and not all(keys_match_found): # make this print nicely aligned for debugging msg = "\n" for key, value in zip(data.keys(), keys_match_found): diff --git a/source/isaaclab/test/controllers/simplified_test_robot.urdf b/source/isaaclab/test/controllers/simplified_test_robot.urdf new file mode 100644 index 000000000000..b66ce68324bb --- /dev/null +++ b/source/isaaclab/test/controllers/simplified_test_robot.urdf @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/isaaclab/test/controllers/test_configs/pink_ik_gr1_test_configs.json b/source/isaaclab/test/controllers/test_configs/pink_ik_gr1_test_configs.json new file mode 100644 index 000000000000..b033b95b81f6 --- /dev/null +++ b/source/isaaclab/test/controllers/test_configs/pink_ik_gr1_test_configs.json @@ -0,0 +1,86 @@ +{ + "tolerances": { + "position": 0.001, + "pd_position": 0.001, + "rotation": 0.02, + "check_errors": true + }, + "tests": { + "stay_still": { + "left_hand_pose": [ + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5] + ], + "right_hand_pose": [ + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5] + ], + "allowed_steps_per_motion": 10, + "repeat": 2 + }, + "vertical_movement": { + "left_hand_pose": [ + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [-0.23, 0.32, 1.2, 0.5, 0.5, -0.5, 0.5] + ], + "right_hand_pose": [ + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [0.23, 0.32, 1.2, 0.5, 0.5, -0.5, 0.5] + ], + "allowed_steps_per_motion": 15, + "repeat": 2 + }, + "horizontal_movement": { + "left_hand_pose": [ + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [-0.13, 0.32, 1.1, 0.5, 0.5, -0.5, 0.5] + ], + "right_hand_pose": [ + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [0.13, 0.32, 1.1, 0.5, 0.5, -0.5, 0.5] + ], + "allowed_steps_per_motion": 15, + "repeat": 2 + }, + "horizontal_small_movement": { + "left_hand_pose": [ + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [-0.22, 0.32, 1.1, 0.5, 0.5, -0.5, 0.5] + ], + "right_hand_pose": [ + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [0.22, 0.32, 1.1, 0.5, 0.5, -0.5, 0.5] + ], + "allowed_steps_per_motion": 15, + "repeat": 2 + }, + "forward_waist_bending_movement": { + "left_hand_pose": [ + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [-0.23, 0.5, 1.05, 0.5, 0.5, -0.5, 0.5] + ], + "right_hand_pose": [ + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [0.23, 0.5, 1.05, 0.5, 0.5, -0.5, 0.5] + ], + "allowed_steps_per_motion": 30, + "repeat": 3 + }, + "rotation_movements": { + "left_hand_pose": [ + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [-0.23, 0.32, 1.1, 0.7071, 0.7071, 0.0, 0.0], + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [-0.23, 0.32, 1.1, 0.0000, 0.0000, -0.7071, 0.7071] + ], + "right_hand_pose": [ + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [0.23, 0.32, 1.1, 0.0000, 0.0000, -0.7071, 0.7071], + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], + [0.23, 0.32, 1.1, 0.7071, 0.7071, 0.0, 0.0] + ], + "allowed_steps_per_motion": 20, + "repeat": 2 + } + } +} diff --git a/source/isaaclab/test/controllers/test_null_space_posture_task.py b/source/isaaclab/test/controllers/test_null_space_posture_task.py new file mode 100644 index 000000000000..97fc7221748a --- /dev/null +++ b/source/isaaclab/test/controllers/test_null_space_posture_task.py @@ -0,0 +1,339 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +"""Launch Isaac Sim Simulator first.""" +# Import pinocchio in the main script to force the use of the dependencies installed by IsaacLab and not the one installed by Isaac Sim +# pinocchio is required by the Pink IK controller +import sys + +if sys.platform != "win32": + import pinocchio # noqa: F401 + import pinocchio as pin # noqa: F401 +else: + import pinocchio # noqa: F401 + import pinocchio as pin # noqa: F401 + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Unit tests for NullSpacePostureTask with simplified robot configuration using Pink library directly.""" + +import numpy as np + +import pytest +from pink.configuration import Configuration +from pink.tasks import FrameTask +from pinocchio.robot_wrapper import RobotWrapper + +from isaaclab.controllers.pink_ik.null_space_posture_task import NullSpacePostureTask + + +class TestNullSpacePostureTaskSimplifiedRobot: + """Test cases for NullSpacePostureTask with simplified robot configuration.""" + + @pytest.fixture + def num_joints(self): + """Number of joints in the simplified robot.""" + return 20 + + @pytest.fixture + def joint_configurations(self): + """Pre-generated joint configurations for testing.""" + # Set random seed for reproducible tests + np.random.seed(42) + + return { + "random": np.random.uniform(-0.5, 0.5, 20), + "controlled_only": np.array([0.5] * 5 + [0.0] * 15), # Non-zero for controlled joints only + "sequential": np.linspace(0.1, 2.0, 20), + } + + @pytest.fixture + def robot_urdf(self): + """Load the simplified test robot URDF file.""" + import os + + current_dir = os.path.dirname(os.path.abspath(__file__)) + urdf_path = os.path.join(current_dir, "simplified_test_robot.urdf") + return urdf_path + + @pytest.fixture + def robot_configuration(self, robot_urdf): + """Simplified robot wrapper.""" + wrapper = RobotWrapper.BuildFromURDF(robot_urdf, None, root_joint=None) + return Configuration(wrapper.model, wrapper.data, wrapper.q0) + + @pytest.fixture + def tasks(self): + """pink tasks.""" + return [ + FrameTask("left_hand_pitch_link", position_cost=1.0, orientation_cost=1.0), + NullSpacePostureTask( + cost=1.0, + controlled_frames=["left_hand_pitch_link"], + controlled_joints=[ + "waist_yaw_joint", + "waist_pitch_joint", + "waist_roll_joint", + "left_shoulder_pitch_joint", + "left_shoulder_roll_joint", + ], + ), + ] + + def test_null_space_jacobian_zero_end_effector_velocity( + self, robot_configuration, tasks, joint_configurations, num_joints + ): + """Test that velocities projected through null space Jacobian result in zero end-effector velocity.""" + # Set specific joint configuration + robot_configuration.q = joint_configurations["random"] + + # Set frame task target to a specific position in workspace + frame_task = tasks[0] + # Create pin.SE3 from position and quaternion + position = np.array([0.5, 0.3, 0.8]) # x, y, z + quaternion = pin.Quaternion(1.0, 0.0, 0.0, 0.0) # w, x, y, z (identity quaternion) + target_pose = pin.SE3(quaternion, position) + frame_task.set_target(target_pose) + + # Set null space posture task target + null_space_task = tasks[1] + target_posture = np.zeros(num_joints) + null_space_task.set_target(target_posture) + + # Get the null space Jacobian + null_space_jacobian = null_space_task.compute_jacobian(robot_configuration) + + # Get the end-effector Jacobian + frame_task_jacobian = frame_task.compute_jacobian(robot_configuration) + + # Test multiple random velocities in null space + for _ in range(10): + # Generate random joint velocity + random_velocity = np.random.randn(num_joints) * 0.1 + + # Project through null space Jacobian + null_space_velocity = null_space_jacobian @ random_velocity + + # Compute resulting end-effector velocity + ee_velocity = frame_task_jacobian @ null_space_velocity + + # The end-effector velocity should be approximately zero + assert np.allclose(ee_velocity, np.zeros(6), atol=1e-7), f"End-effector velocity not zero: {ee_velocity}" + + def test_null_space_jacobian_properties(self, robot_configuration, tasks, joint_configurations, num_joints): + """Test mathematical properties of the null space Jacobian.""" + # Set specific joint configuration + robot_configuration.q = joint_configurations["random"] + + # Set frame task target + frame_task = tasks[0] + # Create pin.SE3 from position and quaternion + position = np.array([0.3, 0.4, 0.6]) + quaternion = pin.Quaternion(0.707, 0.0, 0.0, 0.707) # w, x, y, z (90-degree rotation around X) + target_pose = pin.SE3(quaternion, position) + frame_task.set_target(target_pose) + + # Set null space posture task target + null_space_task = tasks[1] + target_posture = np.zeros(num_joints) + target_posture[0:5] = [0.1, -0.1, 0.2, -0.2, 0.0] # Set first 5 joints (controlled joints) + null_space_task.set_target(target_posture) + + # Get Jacobians + null_space_jacobian = null_space_task.compute_jacobian(robot_configuration) + ee_jacobian = robot_configuration.get_frame_jacobian("left_hand_pitch_link") + + # Test: N * J^T should be approximately zero (null space property) + # where N is the null space projector and J is the end-effector Jacobian + null_space_projection = null_space_jacobian @ ee_jacobian.T + assert np.allclose( + null_space_projection, np.zeros_like(null_space_projection), atol=1e-7 + ), f"Null space projection of end-effector Jacobian not zero: {null_space_projection}" + + def test_null_space_jacobian_identity_when_no_frame_tasks( + self, robot_configuration, joint_configurations, num_joints + ): + """Test that null space Jacobian is identity when no frame tasks are defined.""" + # Create null space task without frame task controlled joints + null_space_task = NullSpacePostureTask(cost=1.0, controlled_frames=[], controlled_joints=[]) + + # Set specific joint configuration + robot_configuration.q = joint_configurations["sequential"] + + # Set target + target_posture = np.zeros(num_joints) + null_space_task.set_target(target_posture) + + # Get the null space Jacobian + null_space_jacobian = null_space_task.compute_jacobian(robot_configuration) + + # Should be identity matrix + expected_identity = np.eye(num_joints) + assert np.allclose( + null_space_jacobian, expected_identity + ), f"Null space Jacobian should be identity when no frame tasks defined: {null_space_jacobian}" + + def test_null_space_jacobian_consistency_across_configurations( + self, robot_configuration, tasks, joint_configurations, num_joints + ): + """Test that null space Jacobian is consistent across different joint configurations.""" + # Test multiple joint configurations + test_configs = [ + np.zeros(num_joints), # Zero configuration + joint_configurations["controlled_only"], # Non-zero for controlled joints + joint_configurations["random"], # Random configuration + ] + + # Set frame task target + frame_task = tasks[0] + # Create pin.SE3 from position and quaternion + position = np.array([0.3, 0.3, 0.5]) + quaternion = pin.Quaternion(1.0, 0.0, 0.0, 0.0) # w, x, y, z (identity quaternion) + target_pose = pin.SE3(quaternion, position) + frame_task.set_target(target_pose) + + # Set null space posture task target + null_space_task = tasks[1] + target_posture = np.zeros(num_joints) + null_space_task.set_target(target_posture) + + jacobians = [] + for config in test_configs: + robot_configuration.q = config + jacobian = null_space_task.compute_jacobian(robot_configuration) + jacobians.append(jacobian) + + # Verify that velocities through this Jacobian result in zero end-effector velocity + ee_jacobian = robot_configuration.get_frame_jacobian("left_hand_pitch_link") + + # Test with random velocity + random_velocity = np.random.randn(num_joints) * 0.1 + null_space_velocity = jacobian @ random_velocity + ee_velocity = ee_jacobian @ null_space_velocity + + assert np.allclose( + ee_velocity, np.zeros(6), atol=1e-7 + ), f"End-effector velocity not zero for configuration {config}: {ee_velocity}" + + def test_compute_error_without_target(self, robot_configuration, joint_configurations): + """Test that compute_error raises ValueError when no target is set.""" + null_space_task = NullSpacePostureTask( + cost=1.0, + controlled_frames=["left_hand_pitch_link"], + controlled_joints=["waist_yaw_joint", "waist_pitch_joint"], + ) + + robot_configuration.q = joint_configurations["sequential"] + + # Should raise ValueError when no target is set + with pytest.raises(ValueError, match="No posture target has been set"): + null_space_task.compute_error(robot_configuration) + + def test_joint_masking(self, robot_configuration, joint_configurations, num_joints): + """Test that joint mask correctly filters only controlled joints.""" + + controlled_joint_names = ["waist_pitch_joint", "left_shoulder_pitch_joint", "left_elbow_pitch_joint"] + + # Create task with specific controlled joints + null_space_task = NullSpacePostureTask( + cost=1.0, controlled_frames=["left_hand_pitch_link"], controlled_joints=controlled_joint_names + ) + + # Find the joint indexes in robot_configuration.model.names.tolist()[1:] + joint_names = robot_configuration.model.names.tolist()[1:] + joint_indexes = [joint_names.index(jn) for jn in controlled_joint_names] + + # Set configurations + current_config = joint_configurations["sequential"] + target_config = np.zeros(num_joints) + + robot_configuration.q = current_config + null_space_task.set_target(target_config) + + # Compute error + error = null_space_task.compute_error(robot_configuration) + + # Only controlled joints should have non-zero error + # Joint indices: waist_yaw_joint=0, waist_pitch_joint=1, waist_roll_joint=2, left_shoulder_pitch_joint=3, left_shoulder_roll_joint=4, etc. + expected_error = np.zeros(num_joints) + for i in joint_indexes: + expected_error[i] = current_config[i] + + assert np.allclose( + error, expected_error, atol=1e-7 + ), f"Joint mask not working correctly: expected {expected_error}, got {error}" + + def test_empty_controlled_joints(self, robot_configuration, joint_configurations, num_joints): + """Test behavior when controlled_joints is empty.""" + null_space_task = NullSpacePostureTask( + cost=1.0, controlled_frames=["left_hand_pitch_link"], controlled_joints=[] + ) + + current_config = joint_configurations["sequential"] + target_config = np.zeros(num_joints) + + robot_configuration.q = current_config + null_space_task.set_target(target_config) + + # Error should be all zeros + error = null_space_task.compute_error(robot_configuration) + expected_error = np.zeros(num_joints) + assert np.allclose(error, expected_error), f"Error should be zero when no joints controlled: {error}" + + def test_set_target_from_configuration(self, robot_configuration, joint_configurations): + """Test set_target_from_configuration method.""" + null_space_task = NullSpacePostureTask( + cost=1.0, + controlled_frames=["left_hand_pitch_link"], + controlled_joints=["waist_yaw_joint", "waist_pitch_joint"], + ) + + # Set a specific configuration + test_config = joint_configurations["sequential"] + robot_configuration.q = test_config + + # Set target from configuration + null_space_task.set_target_from_configuration(robot_configuration) + + # Verify target was set correctly + assert null_space_task.target_q is not None + assert np.allclose(null_space_task.target_q, test_config) + + def test_multiple_frame_tasks(self, robot_configuration, joint_configurations, num_joints): + """Test null space projection with multiple frame tasks.""" + # Create task with multiple controlled frames + null_space_task = NullSpacePostureTask( + cost=1.0, + controlled_frames=["left_hand_pitch_link", "right_hand_pitch_link"], + controlled_joints=["waist_yaw_joint", "waist_pitch_joint", "waist_roll_joint"], + ) + + current_config = joint_configurations["sequential"] + robot_configuration.q = current_config + + # Get null space Jacobian + null_space_jacobian = null_space_task.compute_jacobian(robot_configuration) + + # Get Jacobians for both frames + jacobian_left_hand = robot_configuration.get_frame_jacobian("left_hand_pitch_link") + jacobian_right_hand = robot_configuration.get_frame_jacobian("right_hand_pitch_link") + + # Test that null space velocities result in zero velocity for both frames + for _ in range(5): + random_velocity = np.random.randn(num_joints) * 0.1 + null_space_velocity = null_space_jacobian @ random_velocity + + # Check both frames + ee_velocity_left = jacobian_left_hand @ null_space_velocity + ee_velocity_right = jacobian_right_hand @ null_space_velocity + + assert np.allclose( + ee_velocity_left, np.zeros(6), atol=1e-7 + ), f"Left hand velocity not zero: {ee_velocity_left}" + assert np.allclose( + ee_velocity_right, np.zeros(6), atol=1e-7 + ), f"Right hand velocity not zero: {ee_velocity_right}" diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index a1b7993b9bde..3485f367e373 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -4,10 +4,10 @@ # SPDX-License-Identifier: BSD-3-Clause """Launch Isaac Sim Simulator first.""" -import sys - # Import pinocchio in the main script to force the use of the dependencies installed by IsaacLab and not the one installed by Isaac Sim # pinocchio is required by the Pink IK controller +import sys + if sys.platform != "win32": import pinocchio # noqa: F401 @@ -20,9 +20,13 @@ import contextlib import gymnasium as gym +import json +import numpy as np +import os import torch import pytest +from pink.configuration import Configuration from isaaclab.utils.math import axis_angle_from_quat, matrix_from_quat, quat_from_matrix, quat_inv @@ -31,179 +35,329 @@ from isaaclab_tasks.utils.parse_cfg import parse_env_cfg +@pytest.fixture(scope="module") +def test_cfg(): + """Load test configuration.""" + config_path = os.path.join(os.path.dirname(__file__), "test_configs", "pink_ik_gr1_test_configs.json") + with open(config_path) as f: + return json.load(f) + + +@pytest.fixture(scope="module") +def test_params(test_cfg): + """Set up test parameters.""" + return { + "position_tolerance": test_cfg["tolerances"]["position"], + "rotation_tolerance": test_cfg["tolerances"]["rotation"], + "pd_position_tolerance": test_cfg["tolerances"]["pd_position"], + "check_errors": test_cfg["tolerances"]["check_errors"], + } + + +def create_test_env(num_envs): + """Create a test environment with the Pink IK controller.""" + env_name = "Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0" + device = "cuda:0" + + try: + env_cfg = parse_env_cfg(env_name, device=device, num_envs=num_envs) + # Modify scene config to not spawn the packing table to avoid collision with the robot + del env_cfg.scene.packing_table + del env_cfg.terminations.object_dropping + del env_cfg.terminations.time_out + return gym.make(env_name, cfg=env_cfg).unwrapped, env_cfg + except Exception as e: + print(f"Failed to create environment: {str(e)}") + raise + + +@pytest.fixture(scope="module") +def env_and_cfg(): + """Create environment and configuration for tests.""" + env, env_cfg = create_test_env(num_envs=1) + + # Set up camera view + env.sim.set_camera_view(eye=[2.5, 2.5, 2.5], target=[0.0, 0.0, 1.0]) + + return env, env_cfg + + @pytest.fixture -def pink_ik_test_config(): - """Test configuration for Pink IK controller tests.""" - # End effector position mean square error tolerance in meters - pos_tolerance = 0.03 # 3 cm - # End effector orientation mean square error tolerance in radians - rot_tolerance = 0.17 # 10 degrees - - # Number of environments - num_envs = 1 - - # Number of joints in the 2 robot hands - num_joints_in_robot_hands = 22 - - # Number of steps to wait for controller convergence - num_steps_controller_convergence = 25 - - num_times_to_move_hands_up = 3 - num_times_to_move_hands_down = 3 - - # Create starting setpoints with respect to the env origin frame - # These are the setpoints for the forward kinematics result of the - # InitialStateCfg specified in `PickPlaceGR1T2EnvCfg` - y_axis_z_axis_90_rot_quaternion = [0.5, 0.5, -0.5, 0.5] - left_hand_roll_link_pos = [-0.23, 0.28, 1.1] - left_hand_roll_link_pose = left_hand_roll_link_pos + y_axis_z_axis_90_rot_quaternion - right_hand_roll_link_pos = [0.23, 0.28, 1.1] - right_hand_roll_link_pose = right_hand_roll_link_pos + y_axis_z_axis_90_rot_quaternion +def test_setup(env_and_cfg): + """Set up test case - runs before each test.""" + env, env_cfg = env_and_cfg + + num_joints_in_robot_hands = env_cfg.actions.pink_ik_cfg.controller.num_hand_joints + + # Get Action Term and IK controller + action_term = env.action_manager.get_term(name="pink_ik_cfg") + pink_controllers = action_term._ik_controllers + articulation = action_term._asset + + # Initialize Pink Configuration for forward kinematics + kinematics_model = Configuration( + pink_controllers[0].robot_wrapper.model, + pink_controllers[0].robot_wrapper.data, + pink_controllers[0].robot_wrapper.q0, + ) + left_target_link_name = env_cfg.actions.pink_ik_cfg.target_eef_link_names["left_wrist"] + right_target_link_name = env_cfg.actions.pink_ik_cfg.target_eef_link_names["right_wrist"] return { - "pos_tolerance": pos_tolerance, - "rot_tolerance": rot_tolerance, - "num_envs": num_envs, + "env": env, + "env_cfg": env_cfg, "num_joints_in_robot_hands": num_joints_in_robot_hands, - "num_steps_controller_convergence": num_steps_controller_convergence, - "num_times_to_move_hands_up": num_times_to_move_hands_up, - "num_times_to_move_hands_down": num_times_to_move_hands_down, - "left_hand_roll_link_pose": left_hand_roll_link_pose, - "right_hand_roll_link_pose": right_hand_roll_link_pose, + "action_term": action_term, + "pink_controllers": pink_controllers, + "articulation": articulation, + "kinematics_model": kinematics_model, + "left_target_link_name": left_target_link_name, + "right_target_link_name": right_target_link_name, } -def test_gr1t2_ik_pose_abs(pink_ik_test_config): - """Test IK controller for GR1T2 humanoid. +def test_stay_still(test_setup, test_cfg): + """Test staying still.""" + print("Running stay still test...") + run_movement_test(test_setup, test_cfg["tests"]["stay_still"], test_cfg) - This test validates that the Pink IK controller can accurately track commanded - end-effector poses for a humanoid robot. It specifically: - 1. Creates a GR1T2 humanoid robot with the Pink IK controller - 2. Sends target pose commands to the left and right hand roll links - 3. Checks that the observed poses of the links match the target poses within tolerance - 4. Tests adaptability by moving the hands up and down multiple times +def test_vertical_movement(test_setup, test_cfg): + """Test vertical movement of robot hands.""" + print("Running vertical movement test...") + run_movement_test(test_setup, test_cfg["tests"]["vertical_movement"], test_cfg) - The test succeeds when the controller can accurately converge to each new target - position, demonstrating both accuracy and adaptability to changing targets. - """ - env_name = "Isaac-PickPlace-GR1T2-Abs-v0" - device = "cuda:0" - env_cfg = parse_env_cfg(env_name, device=device, num_envs=pink_ik_test_config["num_envs"]) +def test_horizontal_movement(test_setup, test_cfg): + """Test horizontal movement of robot hands.""" + print("Running horizontal movement test...") + run_movement_test(test_setup, test_cfg["tests"]["horizontal_movement"], test_cfg) - # create environment from loaded config - env = gym.make(env_name, cfg=env_cfg).unwrapped - # reset before starting - obs, _ = env.reset() +def test_horizontal_small_movement(test_setup, test_cfg): + """Test small horizontal movement of robot hands.""" + print("Running horizontal small movement test...") + run_movement_test(test_setup, test_cfg["tests"]["horizontal_small_movement"], test_cfg) - num_runs = 0 # Counter for the number of runs - move_hands_up = True - test_counter = 0 +def test_forward_waist_bending_movement(test_setup, test_cfg): + """Test forward waist bending movement of robot hands.""" + print("Running forward waist bending movement test...") + run_movement_test(test_setup, test_cfg["tests"]["forward_waist_bending_movement"], test_cfg) + + +def test_rotation_movements(test_setup, test_cfg): + """Test rotation movements of robot hands.""" + print("Running rotation movements test...") + run_movement_test(test_setup, test_cfg["tests"]["rotation_movements"], test_cfg) + + +def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): + """Run a movement test with the given configuration.""" + env = test_setup["env"] + num_joints_in_robot_hands = test_setup["num_joints_in_robot_hands"] - # Get poses from config - left_hand_roll_link_pose = pink_ik_test_config["left_hand_roll_link_pose"].copy() - right_hand_roll_link_pose = pink_ik_test_config["right_hand_roll_link_pose"].copy() + left_hand_poses = np.array(test_config["left_hand_pose"], dtype=np.float32) + right_hand_poses = np.array(test_config["right_hand_pose"], dtype=np.float32) + + curr_pose_idx = 0 + test_counter = 0 + num_runs = 0 - # simulate environment -- run everything in inference mode with contextlib.suppress(KeyboardInterrupt) and torch.inference_mode(): - while simulation_app.is_running() and not simulation_app.is_exiting(): + obs, _ = env.reset() + while simulation_app.is_running() and not simulation_app.is_exiting(): num_runs += 1 - setpoint_poses = left_hand_roll_link_pose + right_hand_roll_link_pose - actions = setpoint_poses + [0.0] * pink_ik_test_config["num_joints_in_robot_hands"] - actions = torch.tensor(actions, device=device) - actions = torch.stack([actions for _ in range(env.num_envs)]) - obs, _, _, _, _ = env.step(actions) + # Call auxiliary function if provided + if aux_function is not None: + aux_function(num_runs) - left_hand_roll_link_pose_obs = obs["policy"]["robot_links_state"][ - :, env.scene["robot"].data.body_names.index("left_hand_roll_link"), :7 - ] - right_hand_roll_link_pose_obs = obs["policy"]["robot_links_state"][ - :, env.scene["robot"].data.body_names.index("right_hand_roll_link"), :7 - ] - - # The setpoints are wrt the env origin frame - # The observations are also wrt the env origin frame - left_hand_roll_link_feedback = left_hand_roll_link_pose_obs - left_hand_roll_link_setpoint = ( - torch.tensor(left_hand_roll_link_pose, device=device).unsqueeze(0).repeat(env.num_envs, 1) - ) - left_hand_roll_link_pos_error = left_hand_roll_link_setpoint[:, :3] - left_hand_roll_link_feedback[:, :3] - left_hand_roll_link_rot_error = axis_angle_from_quat( - quat_from_matrix( - matrix_from_quat(left_hand_roll_link_setpoint[:, 3:]) - * matrix_from_quat(quat_inv(left_hand_roll_link_feedback[:, 3:])) - ) - ) - - right_hand_roll_link_feedback = right_hand_roll_link_pose_obs - right_hand_roll_link_setpoint = ( - torch.tensor(right_hand_roll_link_pose, device=device).unsqueeze(0).repeat(env.num_envs, 1) - ) - right_hand_roll_link_pos_error = right_hand_roll_link_setpoint[:, :3] - right_hand_roll_link_feedback[:, :3] - right_hand_roll_link_rot_error = axis_angle_from_quat( - quat_from_matrix( - matrix_from_quat(right_hand_roll_link_setpoint[:, 3:]) - * matrix_from_quat(quat_inv(right_hand_roll_link_feedback[:, 3:])) - ) - ) - - if num_runs % pink_ik_test_config["num_steps_controller_convergence"] == 0: - # Check if the left hand roll link is at the target position - torch.testing.assert_close( - torch.mean(torch.abs(left_hand_roll_link_pos_error), dim=1), - torch.zeros(env.num_envs, device="cuda:0"), - rtol=0.0, - atol=pink_ik_test_config["pos_tolerance"], - ) + # Create actions from hand poses and joint positions + setpoint_poses = np.concatenate([left_hand_poses[curr_pose_idx], right_hand_poses[curr_pose_idx]]) + actions = np.concatenate([setpoint_poses, np.zeros(num_joints_in_robot_hands)]) + actions = torch.tensor(actions, device=env.device, dtype=torch.float32) + actions = actions.repeat(env.num_envs, 1) - # Check if the right hand roll link is at the target position - torch.testing.assert_close( - torch.mean(torch.abs(right_hand_roll_link_pos_error), dim=1), - torch.zeros(env.num_envs, device="cuda:0"), - rtol=0.0, - atol=pink_ik_test_config["pos_tolerance"], - ) + # Step environment + obs, _, _, _, _ = env.step(actions) - # Check if the left hand roll link is at the target orientation - torch.testing.assert_close( - torch.mean(torch.abs(left_hand_roll_link_rot_error), dim=1), - torch.zeros(env.num_envs, device="cuda:0"), - rtol=0.0, - atol=pink_ik_test_config["rot_tolerance"], + # Check convergence and verify errors + if num_runs % test_config["allowed_steps_per_motion"] == 0: + print("Computing errors...") + errors = compute_errors( + test_setup, env, left_hand_poses[curr_pose_idx], right_hand_poses[curr_pose_idx] ) + print_debug_info(errors, test_counter) + if test_cfg["tolerances"]["check_errors"]: + verify_errors(errors, test_setup, test_cfg["tolerances"]) + + curr_pose_idx = (curr_pose_idx + 1) % len(left_hand_poses) + if curr_pose_idx == 0: + test_counter += 1 + if test_counter > test_config["repeat"]: + print("Test completed successfully") + break + + +def get_link_pose(env, link_name): + """Get the position and orientation of a link.""" + link_index = env.scene["robot"].data.body_names.index(link_name) + link_states = env.scene._articulations["robot"]._data.body_link_state_w + link_pose = link_states[:, link_index, :7] + return link_pose[:, :3], link_pose[:, 3:7] + + +def calculate_rotation_error(current_rot, target_rot): + """Calculate the rotation error between current and target orientations in axis-angle format.""" + if isinstance(target_rot, torch.Tensor): + target_rot_tensor = ( + target_rot.unsqueeze(0).expand(current_rot.shape[0], -1) if target_rot.dim() == 1 else target_rot + ) + else: + target_rot_tensor = torch.tensor(target_rot, device=current_rot.device) + if target_rot_tensor.dim() == 1: + target_rot_tensor = target_rot_tensor.unsqueeze(0).expand(current_rot.shape[0], -1) + + return axis_angle_from_quat( + quat_from_matrix(matrix_from_quat(target_rot_tensor) * matrix_from_quat(quat_inv(current_rot))) + ) + + +def compute_errors(test_setup, env, left_target_pose, right_target_pose): + """Compute all error metrics for the current state.""" + action_term = test_setup["action_term"] + pink_controllers = test_setup["pink_controllers"] + articulation = test_setup["articulation"] + kinematics_model = test_setup["kinematics_model"] + left_target_link_name = test_setup["left_target_link_name"] + right_target_link_name = test_setup["right_target_link_name"] + num_joints_in_robot_hands = test_setup["num_joints_in_robot_hands"] + + # Get current hand positions and orientations + left_hand_pos, left_hand_rot = get_link_pose(env, left_target_link_name) + right_hand_pos, right_hand_rot = get_link_pose(env, right_target_link_name) + + # Create setpoint tensors + device = env.device + num_envs = env.num_envs + left_hand_pose_setpoint = torch.tensor(left_target_pose, device=device).unsqueeze(0).repeat(num_envs, 1) + right_hand_pose_setpoint = torch.tensor(right_target_pose, device=device).unsqueeze(0).repeat(num_envs, 1) + # compensate for the hand rotational offset + # nominal_hand_pose_rotmat = matrix_from_quat(torch.tensor(env_cfg.actions.pink_ik_cfg.controller.hand_rotational_offset, device=env.device)) + left_hand_pose_setpoint[:, 3:7] = quat_from_matrix(matrix_from_quat(left_hand_pose_setpoint[:, 3:7])) + right_hand_pose_setpoint[:, 3:7] = quat_from_matrix(matrix_from_quat(right_hand_pose_setpoint[:, 3:7])) + + # Calculate position and rotation errors + left_pos_error = left_hand_pose_setpoint[:, :3] - left_hand_pos + right_pos_error = right_hand_pose_setpoint[:, :3] - right_hand_pos + left_rot_error = calculate_rotation_error(left_hand_rot, left_hand_pose_setpoint[:, 3:]) + right_rot_error = calculate_rotation_error(right_hand_rot, right_hand_pose_setpoint[:, 3:]) + + # Calculate PD controller errors + ik_controller = pink_controllers[0] + pink_controlled_joint_ids = action_term._pink_controlled_joint_ids + + # Get current and target positions + curr_joints = articulation.data.joint_pos[:, pink_controlled_joint_ids].cpu().numpy()[0] + target_joints = action_term.processed_actions[:, :num_joints_in_robot_hands].cpu().numpy()[0] + + # Reorder joints for Pink IK + curr_joints = np.array(curr_joints)[ik_controller.isaac_lab_to_pink_ordering] + target_joints = np.array(target_joints)[ik_controller.isaac_lab_to_pink_ordering] + + # Run forward kinematics + kinematics_model.update(curr_joints) + left_curr_pos = kinematics_model.get_transform_frame_to_world( + frame="GR1T2_fourier_hand_6dof_left_hand_pitch_link" + ).translation + right_curr_pos = kinematics_model.get_transform_frame_to_world( + frame="GR1T2_fourier_hand_6dof_right_hand_pitch_link" + ).translation + + kinematics_model.update(target_joints) + left_target_pos = kinematics_model.get_transform_frame_to_world( + frame="GR1T2_fourier_hand_6dof_left_hand_pitch_link" + ).translation + right_target_pos = kinematics_model.get_transform_frame_to_world( + frame="GR1T2_fourier_hand_6dof_right_hand_pitch_link" + ).translation + + # Calculate PD errors + left_pd_error = ( + torch.tensor(left_target_pos - left_curr_pos, device=device, dtype=torch.float32) + .unsqueeze(0) + .repeat(num_envs, 1) + ) + right_pd_error = ( + torch.tensor(right_target_pos - right_curr_pos, device=device, dtype=torch.float32) + .unsqueeze(0) + .repeat(num_envs, 1) + ) + + return { + "left_pos_error": left_pos_error, + "right_pos_error": right_pos_error, + "left_rot_error": left_rot_error, + "right_rot_error": right_rot_error, + "left_pd_error": left_pd_error, + "right_pd_error": right_pd_error, + } - # Check if the right hand roll link is at the target orientation - torch.testing.assert_close( - torch.mean(torch.abs(right_hand_roll_link_rot_error), dim=1), - torch.zeros(env.num_envs, device="cuda:0"), - rtol=0.0, - atol=pink_ik_test_config["rot_tolerance"], - ) - # Change the setpoints to move the hands up and down as per the counter - test_counter += 1 - if move_hands_up and test_counter > pink_ik_test_config["num_times_to_move_hands_up"]: - move_hands_up = False - elif not move_hands_up and test_counter > ( - pink_ik_test_config["num_times_to_move_hands_down"] - + pink_ik_test_config["num_times_to_move_hands_up"] - ): - # Test is done after moving the hands up and down - break - if move_hands_up: - left_hand_roll_link_pose[1] += 0.05 - left_hand_roll_link_pose[2] += 0.05 - right_hand_roll_link_pose[1] += 0.05 - right_hand_roll_link_pose[2] += 0.05 - else: - left_hand_roll_link_pose[1] -= 0.05 - left_hand_roll_link_pose[2] -= 0.05 - right_hand_roll_link_pose[1] -= 0.05 - right_hand_roll_link_pose[2] -= 0.05 - - env.close() +def verify_errors(errors, test_setup, tolerances): + """Verify that all error metrics are within tolerance.""" + env = test_setup["env"] + device = env.device + num_envs = env.num_envs + zero_tensor = torch.zeros(num_envs, device=device) + + for hand in ["left", "right"]: + # Check PD controller errors + pd_error_norm = torch.norm(errors[f"{hand}_pd_error"], dim=1) + torch.testing.assert_close( + pd_error_norm, + zero_tensor, + rtol=0.0, + atol=tolerances["pd_position"], + msg=( + f"{hand.capitalize()} hand PD controller error ({pd_error_norm.item():.6f}) exceeds tolerance" + f" ({tolerances['pd_position']:.6f})" + ), + ) + + # Check IK position errors + pos_error_norm = torch.norm(errors[f"{hand}_pos_error"], dim=1) + torch.testing.assert_close( + pos_error_norm, + zero_tensor, + rtol=0.0, + atol=tolerances["position"], + msg=( + f"{hand.capitalize()} hand IK position error ({pos_error_norm.item():.6f}) exceeds tolerance" + f" ({tolerances['position']:.6f})" + ), + ) + + # Check rotation errors + rot_error_max = torch.max(errors[f"{hand}_rot_error"]) + torch.testing.assert_close( + rot_error_max, + torch.zeros_like(rot_error_max), + rtol=0.0, + atol=tolerances["rotation"], + msg=( + f"{hand.capitalize()} hand IK rotation error ({rot_error_max.item():.6f}) exceeds tolerance" + f" ({tolerances['rotation']:.6f})" + ), + ) + + +def print_debug_info(errors, test_counter): + """Print debug information about the current state.""" + print(f"\nTest iteration {test_counter + 1}:") + for hand in ["left", "right"]: + print(f"Measured {hand} hand position error:", errors[f"{hand}_pos_error"]) + print(f"Measured {hand} hand rotation error:", errors[f"{hand}_rot_error"]) + print(f"Measured {hand} hand PD error:", errors[f"{hand}_pd_error"]) diff --git a/source/isaaclab/test/utils/test_string.py b/source/isaaclab/test/utils/test_string.py index 501f73805810..f697509586b2 100644 --- a/source/isaaclab/test/utils/test_string.py +++ b/source/isaaclab/test/utils/test_string.py @@ -164,6 +164,27 @@ def test_resolve_matching_names_values_with_basic_strings(): _ = string_utils.resolve_matching_names_values(query_names, target_names) +def test_resolve_matching_names_values_with_strict_false(): + """Test resolving matching names with strict=False parameter.""" + # list of strings + target_names = ["a", "b", "c", "d", "e"] + # test strict=False + data = {"a|c": 1, "b": 2, "f": 3} + index_list, names_list, values_list = string_utils.resolve_matching_names_values(data, target_names, strict=False) + assert index_list == [0, 1, 2] + assert names_list == ["a", "b", "c"] + assert values_list == [1, 2, 1] + + # test failure case: multiple matches for a string (should still raise ValueError even with strict=False) + data = {"a|c": 1, "a": 2, "b": 3} + with pytest.raises(ValueError, match="Multiple matches for 'a':"): + _ = string_utils.resolve_matching_names_values(data, target_names, strict=False) + + # test failure case: invalid input type (should still raise TypeError even with strict=False) + with pytest.raises(TypeError, match="Input argument `data` should be a dictionary"): + _ = string_utils.resolve_matching_names_values("not_a_dict", target_names, strict=False) + + def test_resolve_matching_names_values_with_basic_strings_and_preserved_order(): """Test resolving matching names with a basic expression.""" # list of strings diff --git a/source/isaaclab_assets/isaaclab_assets/robots/fourier.py b/source/isaaclab_assets/isaaclab_assets/robots/fourier.py index 42a2aa63885d..b2d87b1ee8f3 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/fourier.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/fourier.py @@ -8,6 +8,7 @@ The following configuration parameters are available: * :obj:`GR1T2_CFG`: The GR1T2 humanoid. +* :obj:`GR1T2_HIGH_PD_CFG`: The GR1T2 humanoid configured with high PD gains on upper body joints for pick-place manipulation tasks. Reference: https://www.fftai.com/products-gr1 """ @@ -123,3 +124,40 @@ }, ) """Configuration for the GR1T2 Humanoid robot.""" + + +GR1T2_HIGH_PD_CFG = GR1T2_CFG.replace( + actuators={ + "trunk": ImplicitActuatorCfg( + joint_names_expr=["waist_.*"], + effort_limit=None, + velocity_limit=None, + stiffness=4400, + damping=40.0, + armature=0.01, + ), + "right-arm": ImplicitActuatorCfg( + joint_names_expr=["right_shoulder_.*", "right_elbow_.*", "right_wrist_.*"], + stiffness=4400.0, + damping=40.0, + armature=0.01, + ), + "left-arm": ImplicitActuatorCfg( + joint_names_expr=["left_shoulder_.*", "left_elbow_.*", "left_wrist_.*"], + stiffness=4400.0, + damping=40.0, + armature=0.01, + ), + "right-hand": ImplicitActuatorCfg( + joint_names_expr=["R_.*"], + stiffness=None, + damping=None, + ), + "left-hand": ImplicitActuatorCfg( + joint_names_expr=["L_.*"], + stiffness=None, + damping=None, + ), + }, +) +"""Configuration for the GR1T2 Humanoid robot configured for with high PD gains for pick-place manipulation tasks.""" diff --git a/source/isaaclab_mimic/config/extension.toml b/source/isaaclab_mimic/config/extension.toml index 88cffbe962d2..5fa8eb214513 100644 --- a/source/isaaclab_mimic/config/extension.toml +++ b/source/isaaclab_mimic/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.0.12" +version = "1.0.13" # Description category = "isaaclab" diff --git a/source/isaaclab_mimic/docs/CHANGELOG.rst b/source/isaaclab_mimic/docs/CHANGELOG.rst index c4260d355d12..a234c5cd3ab8 100644 --- a/source/isaaclab_mimic/docs/CHANGELOG.rst +++ b/source/isaaclab_mimic/docs/CHANGELOG.rst @@ -1,6 +1,14 @@ Changelog --------- +1.0.13 (2025-08-14) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`PickPlaceGR1T2WaistEnabledEnvCfg` and :class:`PickPlaceGR1T2WaistEnabledMimicEnvCfg` for GR1T2 robot manipulation tasks with waist joint control enabled. + 1.0.12 (2025-07-31) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py index 89dc84590215..c782576c3630 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py @@ -11,6 +11,7 @@ from .nutpour_gr1t2_mimic_env_cfg import NutPourGR1T2MimicEnvCfg from .pickplace_gr1t2_mimic_env import PickPlaceGR1T2MimicEnv from .pickplace_gr1t2_mimic_env_cfg import PickPlaceGR1T2MimicEnvCfg +from .pickplace_gr1t2_waist_enabled_mimic_env_cfg import PickPlaceGR1T2WaistEnabledMimicEnvCfg gym.register( id="Isaac-PickPlace-GR1T2-Abs-Mimic-v0", @@ -21,6 +22,15 @@ disable_env_checker=True, ) +gym.register( + id="Isaac-PickPlace-GR1T2-WaistEnabled-Abs-Mimic-v0", + entry_point="isaaclab_mimic.envs.pinocchio_envs:PickPlaceGR1T2MimicEnv", + kwargs={ + "env_cfg_entry_point": pickplace_gr1t2_waist_enabled_mimic_env_cfg.PickPlaceGR1T2WaistEnabledMimicEnvCfg, + }, + disable_env_checker=True, +) + gym.register( id="Isaac-NutPour-GR1T2-Pink-IK-Abs-Mimic-v0", entry_point="isaaclab_mimic.envs.pinocchio_envs:PickPlaceGR1T2MimicEnv", diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env.py index 5a70a3746191..6bede8c0897c 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env.py @@ -39,7 +39,7 @@ def target_eef_pose_to_action( target_eef_pose_dict: dict, gripper_action_dict: dict, action_noise_dict: dict | None = None, - env_id: int = 0, + env_id: int = 0, # Unused, but required to conform to interface ) -> torch.Tensor: """ Takes a target pose and gripper action for the end effector controller and returns an action @@ -49,7 +49,7 @@ def target_eef_pose_to_action( Args: target_eef_pose_dict: Dictionary of 4x4 target eef pose for each end-effector. gripper_action_dict: Dictionary of gripper actions for each end-effector. - noise: Noise to add to the action. If None, no noise is added. + action_noise_dict: Noise to add to the action. If None, no noise is added. env_id: Environment index to get the action for. Returns: diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env_cfg.py index 2e53e20c89d2..14c0ebd2a9d9 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_mimic_env_cfg.py @@ -17,7 +17,7 @@ def __post_init__(self): super().__post_init__() # Override the existing values - self.datagen_config.name = "demo_src_gr1t2_demo_task_D0" + self.datagen_config.name = "gr1t2_pick_place_D0" self.datagen_config.generation_guarantee = True self.datagen_config.generation_keep_failed = False self.datagen_config.generation_num_trials = 1000 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py new file mode 100644 index 000000000000..d5b033bf7371 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py @@ -0,0 +1,111 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.pick_place.pickplace_gr1t2_waist_enabled_env_cfg import ( + PickPlaceGR1T2WaistEnabledEnvCfg, +) + + +@configclass +class PickPlaceGR1T2WaistEnabledMimicEnvCfg(PickPlaceGR1T2WaistEnabledEnvCfg, MimicEnvCfg): + + def __post_init__(self): + # Calling post init of parents + super().__post_init__() + + # Override the existing values + self.datagen_config.name = "gr1t2_pick_place_waist_enabled_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = False + self.datagen_config.generation_num_trials = 1000 + self.datagen_config.generation_select_src_per_subtask = False + self.datagen_config.generation_select_src_per_arm = False + self.datagen_config.generation_relative = False + self.datagen_config.generation_joint_pos = False + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.num_demo_to_render = 10 + self.datagen_config.num_fail_demo_to_render = 25 + self.datagen_config.seed = 1 + + # The following are the subtask configurations for the stack task. + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="object", + # This key corresponds to the binary indicator in "datagen_info" that signals + # when this subtask is finished (e.g., on a 0 to 1 edge). + subtask_term_signal="idle_right", + first_subtask_start_offset_range=(0, 0), + # Randomization range for starting index of the first subtask + subtask_term_offset_range=(0, 0), + # Selection strategy for the source subtask segment during data generation + # selection_strategy="nearest_neighbor_object", + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.003, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="object", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal=None, + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.003, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=3, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["right"] = subtask_configs + + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="object", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal=None, + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.003, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["left"] = subtask_configs diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 289d07ed5b79..0299870aca2e 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.10.46" +version = "0.10.47" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 98e23db6c816..c33f645b33d2 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,25 @@ Changelog --------- +0.10.47 (2025-07-25) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* New ``Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0`` environment that enables the waist degrees-of-freedom for the GR1T2 robot. + + +Changed +^^^^^^^ + +* Updated pink inverse kinematics controller configuration for the following tasks (``Isaac-PickPlace-GR1T2``, ``Isaac-NutPour-GR1T2``, ``Isaac-ExhaustPipe-GR1T2``) + to increase end-effector tracking accuracy and speed. Also added a null-space regularizer that enables turning on of waist degrees-of-freedom without + the robot control drifting to a bending posture. +* Tuned the pink inverse kinematics controller and joint PD controllers for the following tasks (``Isaac-PickPlace-GR1T2``, ``Isaac-NutPour-GR1T2``, ``Isaac-ExhaustPipe-GR1T2``) + to improve the end-effector tracking accuracy and speed. Achieving position and orientation accuracy test within **(2 mm, 1 degree)**. + + 0.10.46 (2025-08-16) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/__init__.py index db926c6a162c..ff72798e0f4c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/__init__.py @@ -6,7 +6,13 @@ import gymnasium as gym import os -from . import agents, exhaustpipe_gr1t2_pink_ik_env_cfg, nutpour_gr1t2_pink_ik_env_cfg, pickplace_gr1t2_env_cfg +from . import ( + agents, + exhaustpipe_gr1t2_pink_ik_env_cfg, + nutpour_gr1t2_pink_ik_env_cfg, + pickplace_gr1t2_env_cfg, + pickplace_gr1t2_waist_enabled_env_cfg, +) gym.register( id="Isaac-PickPlace-GR1T2-Abs-v0", @@ -37,3 +43,13 @@ }, disable_env_checker=True, ) + +gym.register( + id="Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": pickplace_gr1t2_waist_enabled_env_cfg.PickPlaceGR1T2WaistEnabledEnvCfg, + "robomimic_bc_cfg_entry_point": os.path.join(agents.__path__[0], "robomimic/bc_rnn_low_dim.json"), + }, + disable_env_checker=True, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py index 554203a8b7c2..ed1f0f06130c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py @@ -263,6 +263,9 @@ class ExhaustPipeGR1T2BaseEnvCfg(ManagerBasedRLEnvCfg): anchor_rot=(1.0, 0.0, 0.0, 0.0), ) + # OpenXR hand tracking has 26 joints per hand + NUM_OPENXR_HAND_JOINTS = 26 + # Temporary directory for URDF files temp_urdf_dir = tempfile.gettempdir() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py index c430a194483d..8b35bf2c3cb9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py @@ -3,10 +3,10 @@ # # SPDX-License-Identifier: BSD-3-Clause -from pink.tasks import FrameTask +from pink.tasks import DampingTask, FrameTask import isaaclab.controllers.utils as ControllerUtils -from isaaclab.controllers.pink_ik_cfg import PinkIKControllerCfg +from isaaclab.controllers.pink_ik import NullSpacePostureTask, PinkIKControllerCfg from isaaclab.devices import DevicesCfg from isaaclab.devices.openxr import OpenXRDeviceCfg from isaaclab.devices.openxr.retargeters import GR1T2RetargeterCfg @@ -108,6 +108,10 @@ def __post_init__(self): "L_thumb_distal_joint", "R_thumb_distal_joint", ], + target_eef_link_names={ + "left_wrist": "left_hand_pitch_link", + "right_wrist": "right_hand_pitch_link", + }, # the robot in the sim scene we are controlling asset_name="robot", # Configuration for the IK controller @@ -118,20 +122,45 @@ def __post_init__(self): base_link_name="base_link", num_hand_joints=22, show_ik_warnings=False, + fail_on_joint_limit_violation=False, # Determines whether to pink solver will fail due to a joint limit violation variable_input_tasks=[ FrameTask( "GR1T2_fourier_hand_6dof_left_hand_pitch_link", - position_cost=1.0, # [cost] / [m] + position_cost=8.0, # [cost] / [m] orientation_cost=1.0, # [cost] / [rad] lm_damping=10, # dampening for solver for step jumps - gain=0.1, + gain=0.5, ), FrameTask( "GR1T2_fourier_hand_6dof_right_hand_pitch_link", - position_cost=1.0, # [cost] / [m] + position_cost=8.0, # [cost] / [m] orientation_cost=1.0, # [cost] / [rad] lm_damping=10, # dampening for solver for step jumps - gain=0.1, + gain=0.5, + ), + DampingTask( + cost=0.5, # [cost] * [s] / [rad] + ), + NullSpacePostureTask( + cost=0.2, + lm_damping=1, + controlled_frames=[ + "GR1T2_fourier_hand_6dof_left_hand_pitch_link", + "GR1T2_fourier_hand_6dof_right_hand_pitch_link", + ], + controlled_joints=[ + "left_shoulder_pitch_joint", + "left_shoulder_roll_joint", + "left_shoulder_yaw_joint", + "left_elbow_pitch_joint", + "right_shoulder_pitch_joint", + "right_shoulder_roll_joint", + "right_shoulder_yaw_joint", + "right_elbow_pitch_joint", + "waist_yaw_joint", + "waist_pitch_joint", + "waist_roll_joint", + ], ), ], fixed_input_tasks=[ @@ -162,8 +191,8 @@ def __post_init__(self): retargeters=[ GR1T2RetargeterCfg( enable_visualization=True, - # OpenXR hand tracking has 26 joints per hand - num_open_xr_hand_joints=2 * 26, + # number of joints in both hands + num_open_xr_hand_joints=2 * self.NUM_OPENXR_HAND_JOINTS, sim_device=self.sim.device, hand_joint_names=self.actions.gr1_action.hand_joint_names, ), diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py index a59bd6dfab3e..ffa7929c9539 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py @@ -298,6 +298,9 @@ class NutPourGR1T2BaseEnvCfg(ManagerBasedRLEnvCfg): anchor_rot=(1.0, 0.0, 0.0, 0.0), ) + # OpenXR hand tracking has 26 joints per hand + NUM_OPENXR_HAND_JOINTS = 26 + # Temporary directory for URDF files temp_urdf_dir = tempfile.gettempdir() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py index fd39e47df7ae..d18b4866d155 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py @@ -3,10 +3,10 @@ # # SPDX-License-Identifier: BSD-3-Clause -from pink.tasks import FrameTask +from pink.tasks import DampingTask, FrameTask import isaaclab.controllers.utils as ControllerUtils -from isaaclab.controllers.pink_ik_cfg import PinkIKControllerCfg +from isaaclab.controllers.pink_ik import NullSpacePostureTask, PinkIKControllerCfg from isaaclab.devices import DevicesCfg from isaaclab.devices.openxr import OpenXRDeviceCfg from isaaclab.devices.openxr.retargeters import GR1T2RetargeterCfg @@ -106,6 +106,10 @@ def __post_init__(self): "L_thumb_distal_joint", "R_thumb_distal_joint", ], + target_eef_link_names={ + "left_wrist": "left_hand_pitch_link", + "right_wrist": "right_hand_pitch_link", + }, # the robot in the sim scene we are controlling asset_name="robot", # Configuration for the IK controller @@ -116,20 +120,45 @@ def __post_init__(self): base_link_name="base_link", num_hand_joints=22, show_ik_warnings=False, + fail_on_joint_limit_violation=False, # Determines whether to pink solver will fail due to a joint limit violation variable_input_tasks=[ FrameTask( "GR1T2_fourier_hand_6dof_left_hand_pitch_link", - position_cost=1.0, # [cost] / [m] + position_cost=8.0, # [cost] / [m] orientation_cost=1.0, # [cost] / [rad] lm_damping=10, # dampening for solver for step jumps - gain=0.1, + gain=0.5, ), FrameTask( "GR1T2_fourier_hand_6dof_right_hand_pitch_link", - position_cost=1.0, # [cost] / [m] + position_cost=8.0, # [cost] / [m] orientation_cost=1.0, # [cost] / [rad] lm_damping=10, # dampening for solver for step jumps - gain=0.1, + gain=0.5, + ), + DampingTask( + cost=0.5, # [cost] * [s] / [rad] + ), + NullSpacePostureTask( + cost=0.2, + lm_damping=1, + controlled_frames=[ + "GR1T2_fourier_hand_6dof_left_hand_pitch_link", + "GR1T2_fourier_hand_6dof_right_hand_pitch_link", + ], + controlled_joints=[ + "left_shoulder_pitch_joint", + "left_shoulder_roll_joint", + "left_shoulder_yaw_joint", + "left_elbow_pitch_joint", + "right_shoulder_pitch_joint", + "right_shoulder_roll_joint", + "right_shoulder_yaw_joint", + "right_elbow_pitch_joint", + "waist_yaw_joint", + "waist_pitch_joint", + "waist_roll_joint", + ], ), ], fixed_input_tasks=[ @@ -160,8 +189,8 @@ def __post_init__(self): retargeters=[ GR1T2RetargeterCfg( enable_visualization=True, - # OpenXR hand tracking has 26 joints per hand - num_open_xr_hand_joints=2 * 26, + # number of joints in both hands + num_open_xr_hand_joints=2 * self.NUM_OPENXR_HAND_JOINTS, sim_device=self.sim.device, hand_joint_names=self.actions.gr1_action.hand_joint_names, ), diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index f19bc3629f60..4d0871fcb8a0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -6,13 +6,13 @@ import tempfile import torch -from pink.tasks import FrameTask +from pink.tasks import DampingTask, FrameTask import isaaclab.controllers.utils as ControllerUtils import isaaclab.envs.mdp as base_mdp import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg -from isaaclab.controllers.pink_ik_cfg import PinkIKControllerCfg +from isaaclab.controllers.pink_ik import NullSpacePostureTask, PinkIKControllerCfg from isaaclab.devices.device_base import DevicesCfg from isaaclab.devices.openxr import OpenXRDeviceCfg, XrCfg from isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1t2_retargeter import GR1T2RetargeterCfg @@ -30,7 +30,7 @@ from . import mdp -from isaaclab_assets.robots.fourier import GR1T2_CFG # isort: skip +from isaaclab_assets.robots.fourier import GR1T2_HIGH_PD_CFG # isort: skip ## @@ -59,8 +59,8 @@ class ObjectTableSceneCfg(InteractiveSceneCfg): ), ) - # Humanoid robot w/ arms higher - robot: ArticulationCfg = GR1T2_CFG.replace( + # Humanoid robot configured for pick-place manipulation tasks + robot: ArticulationCfg = GR1T2_HIGH_PD_CFG.replace( prim_path="/World/envs/env_.*/Robot", init_state=ArticulationCfg.InitialStateCfg( pos=(0, 0, 0.93), @@ -199,6 +199,10 @@ class ActionsCfg: "L_thumb_distal_joint", "R_thumb_distal_joint", ], + target_eef_link_names={ + "left_wrist": "left_hand_pitch_link", + "right_wrist": "right_hand_pitch_link", + }, # the robot in the sim scene we are controlling asset_name="robot", # Configuration for the IK controller @@ -209,30 +213,48 @@ class ActionsCfg: base_link_name="base_link", num_hand_joints=22, show_ik_warnings=False, + fail_on_joint_limit_violation=False, # Determines whether to pink solver will fail due to a joint limit violation variable_input_tasks=[ FrameTask( "GR1T2_fourier_hand_6dof_left_hand_pitch_link", - position_cost=1.0, # [cost] / [m] + position_cost=8.0, # [cost] / [m] orientation_cost=1.0, # [cost] / [rad] lm_damping=10, # dampening for solver for step jumps - gain=0.1, + gain=0.5, ), FrameTask( "GR1T2_fourier_hand_6dof_right_hand_pitch_link", - position_cost=1.0, # [cost] / [m] + position_cost=8.0, # [cost] / [m] orientation_cost=1.0, # [cost] / [rad] lm_damping=10, # dampening for solver for step jumps - gain=0.1, + gain=0.5, + ), + DampingTask( + cost=0.5, # [cost] * [s] / [rad] + ), + NullSpacePostureTask( + cost=0.5, + lm_damping=1, + controlled_frames=[ + "GR1T2_fourier_hand_6dof_left_hand_pitch_link", + "GR1T2_fourier_hand_6dof_right_hand_pitch_link", + ], + controlled_joints=[ + "left_shoulder_pitch_joint", + "left_shoulder_roll_joint", + "left_shoulder_yaw_joint", + "left_elbow_pitch_joint", + "right_shoulder_pitch_joint", + "right_shoulder_roll_joint", + "right_shoulder_yaw_joint", + "right_elbow_pitch_joint", + "waist_yaw_joint", + "waist_pitch_joint", + "waist_roll_joint", + ], ), ], - fixed_input_tasks=[ - # COMMENT OUT IF LOCKING WAIST/HEAD - # FrameTask( - # "GR1T2_fourier_hand_6dof_head_yaw_link", - # position_cost=1.0, # [cost] / [m] - # orientation_cost=0.05, # [cost] / [rad] - # ), - ], + fixed_input_tasks=[], ), ) @@ -331,6 +353,9 @@ class PickPlaceGR1T2EnvCfg(ManagerBasedRLEnvCfg): anchor_rot=(1.0, 0.0, 0.0, 0.0), ) + # OpenXR hand tracking has 26 joints per hand + NUM_OPENXR_HAND_JOINTS = 26 + # Temporary directory for URDF files temp_urdf_dir = tempfile.gettempdir() @@ -403,8 +428,8 @@ def __post_init__(self): retargeters=[ GR1T2RetargeterCfg( enable_visualization=True, - # OpenXR hand tracking has 26 joints per hand - num_open_xr_hand_joints=2 * 26, + # number of joints in both hands + num_open_xr_hand_joints=2 * self.NUM_OPENXR_HAND_JOINTS, sim_device=self.sim.device, hand_joint_names=self.actions.pink_ik_cfg.hand_joint_names, ), diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py new file mode 100644 index 000000000000..636f347109f4 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py @@ -0,0 +1,91 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import tempfile + +import isaaclab.controllers.utils as ControllerUtils +from isaaclab.devices.device_base import DevicesCfg +from isaaclab.devices.openxr import OpenXRDeviceCfg, XrCfg +from isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1t2_retargeter import GR1T2RetargeterCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.utils import configclass + +from .pickplace_gr1t2_env_cfg import ActionsCfg, EventCfg, ObjectTableSceneCfg, ObservationsCfg, TerminationsCfg + + +@configclass +class PickPlaceGR1T2WaistEnabledEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the GR1T2 environment.""" + + # Scene settings + scene: ObjectTableSceneCfg = ObjectTableSceneCfg(num_envs=1, env_spacing=2.5, replicate_physics=True) + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + # MDP settings + terminations: TerminationsCfg = TerminationsCfg() + events = EventCfg() + + # Unused managers + commands = None + rewards = None + curriculum = None + + # Position of the XR anchor in the world frame + xr: XrCfg = XrCfg( + anchor_pos=(0.0, 0.0, 0.0), + anchor_rot=(1.0, 0.0, 0.0, 0.0), + ) + + # OpenXR hand tracking has 26 joints per hand + NUM_OPENXR_HAND_JOINTS = 26 + + # Temporary directory for URDF files + temp_urdf_dir = tempfile.gettempdir() + + def __post_init__(self): + """Post initialization.""" + # general settings + self.decimation = 6 + self.episode_length_s = 20.0 + # simulation settings + self.sim.dt = 1 / 120 # 120Hz + self.sim.render_interval = 2 + + # Add waist joint to pink_ik_cfg + waist_joint_names = ["waist_yaw_joint", "waist_pitch_joint", "waist_roll_joint"] + for joint_name in waist_joint_names: + self.actions.pink_ik_cfg.pink_controlled_joint_names.append(joint_name) + self.actions.pink_ik_cfg.ik_urdf_fixed_joint_names.remove(joint_name) + + # Convert USD to URDF and change revolute joints to fixed + temp_urdf_output_path, temp_urdf_meshes_output_path = ControllerUtils.convert_usd_to_urdf( + self.scene.robot.spawn.usd_path, self.temp_urdf_dir, force_conversion=True + ) + ControllerUtils.change_revolute_to_fixed( + temp_urdf_output_path, self.actions.pink_ik_cfg.ik_urdf_fixed_joint_names + ) + + # Set the URDF and mesh paths for the IK controller + self.actions.pink_ik_cfg.controller.urdf_path = temp_urdf_output_path + self.actions.pink_ik_cfg.controller.mesh_path = temp_urdf_meshes_output_path + + self.teleop_devices = DevicesCfg( + devices={ + "handtracking": OpenXRDeviceCfg( + retargeters=[ + GR1T2RetargeterCfg( + enable_visualization=True, + # number of joints in both hands + num_open_xr_hand_joints=2 * self.NUM_OPENXR_HAND_JOINTS, + sim_device=self.sim.device, + hand_joint_names=self.actions.pink_ik_cfg.hand_joint_names, + ), + ], + sim_device=self.sim.device, + xr_cfg=self.xr, + ), + } + ) From 66f48774282a8526b5ca5d3d14cede74e3e33296 Mon Sep 17 00:00:00 2001 From: Francisco Beltrao Date: Wed, 27 Aug 2025 18:59:25 +0200 Subject: [PATCH 04/47] Fix typo in list_envs.py script path (#3282) # Description Corrected the file path for the list_envs.py script in the documentation. Fixes # (issue) ## Type of change - Fix a typo ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there Signed-off-by: Francisco Beltrao --- docs/source/setup/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/setup/quickstart.rst b/docs/source/setup/quickstart.rst index 2bd314101e0c..a42bc665570a 100644 --- a/docs/source/setup/quickstart.rst +++ b/docs/source/setup/quickstart.rst @@ -125,7 +125,7 @@ List Available Environments Above, ``Isaac-Ant-v0`` is the task name and ``skrl`` is the RL framework being used. The ``Isaac-Ant-v0`` environment has been registered with the `Gymnasium API `_, and you can see how the entry point is defined -by calling the ``list_envs.py`` script, which can be found in ``isaaclab/scripts/environments/lsit_envs.py``. You should see entries like the following +by calling the ``list_envs.py`` script, which can be found in ``isaaclab/scripts/environments/list_envs.py``. You should see entries like the following .. code-block:: bash From 3edc06c0ccd10dc055f558585c55e1ff817709bd Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Wed, 27 Aug 2025 09:59:51 -0700 Subject: [PATCH 05/47] Fixes distributed training hanging issue (#3273) # Description We have been hunting down a strange issue in distributed training setups with rendering enabled, where often the process would hang midway through training and causes NCCL timeouts. A workaround was discovered to set `app.execution.debug.forceSerial = true`, which forces serialized scheduling of omni graph within the same thread. This appears to have resolved the hanging issue and did not cause performance regressions. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- apps/isaaclab.python.headless.rendering.kit | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/isaaclab.python.headless.rendering.kit b/apps/isaaclab.python.headless.rendering.kit index 09fc3ba98efe..ed20ad42c321 100644 --- a/apps/isaaclab.python.headless.rendering.kit +++ b/apps/isaaclab.python.headless.rendering.kit @@ -83,6 +83,9 @@ app.updateOrder.checkForHydraRenderComplete = 1000 app.renderer.waitIdle=true app.hydraEngine.waitIdle=true +# Forces serial processing for omni graph to avoid NCCL timeout hangs in distributed training +app.execution.debug.forceSerial = true + app.audio.enabled = false # Enable Vulkan - avoids torch+cu12 error on windows From d2bdbe3296324daa3970429897b43d12414da55e Mon Sep 17 00:00:00 2001 From: ooctipus Date: Wed, 27 Aug 2025 23:54:13 +0000 Subject: [PATCH 06/47] Disables verbose printing in conftest.py (#3291) # Description The test priniting, sometimes gets super verbose and non-readable when isaacsim or kit are in bad state. since we only care about failure test cases and those printing, we can disable --verbose flag ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (fix or feature that would cause existing functionality to not work as expected) - This change requires a documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- .github/workflows/license-exceptions.json | 5 +++++ tools/conftest.py | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/license-exceptions.json b/.github/workflows/license-exceptions.json index c4d0fbc8233a..6beb8dab54b2 100644 --- a/.github/workflows/license-exceptions.json +++ b/.github/workflows/license-exceptions.json @@ -385,5 +385,10 @@ "package": "typing-inspection", "license" : "UNKNOWN", "comment": "MIT" + }, + { + "package": "ml_dtypes", + "license" : "UNKNOWN", + "comment": "Apache 2.0" } ] diff --git a/tools/conftest.py b/tools/conftest.py index 9c9b2cd6d019..ed5db4cb69f4 100644 --- a/tools/conftest.py +++ b/tools/conftest.py @@ -159,7 +159,6 @@ def run_individual_tests(test_files, workspace_root, isaacsim_ci): "-c", f"{workspace_root}/pytest.ini", f"--junitxml=tests/test-reports-{str(file_name)}.xml", - "--verbose", "--tb=short", ] From f1ca08780c5b8e290db9b3c2b827b170833cc248 Mon Sep 17 00:00:00 2001 From: michaellin6 Date: Wed, 27 Aug 2025 17:20:06 -0700 Subject: [PATCH 07/47] Removes isaaclab.controller import of Pink IK to prevent pinocchio import error (#3292) # Description Feature introduced in #3149 imports pink_ik for isaaclab.controller module. This is causing an error IsaacLab wide due to pinocchio import. This PR removes the import. Fixes # (issue) Fixed pinocchio import error due to isaaclab.controller module importing pink_ik by default. ## Type of change - Breaking change (fix or feature that would cause existing functionality to not work as expected) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 9 +++++++++ source/isaaclab/isaaclab/controllers/__init__.py | 1 - 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index d51360662a22..ae3aaf088ff1 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.8" +version = "0.45.9" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 10ed470e018f..6f8a8bad38de 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.45.9 (2025-08-27) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed removing import of pink_ik controller from isaaclab.controllers which is causing pinocchio import error. + + 0.45.8 (2025-07-25) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/controllers/__init__.py b/source/isaaclab/isaaclab/controllers/__init__.py index 6a5b884b78ac..ffc5a5fb9a77 100644 --- a/source/isaaclab/isaaclab/controllers/__init__.py +++ b/source/isaaclab/isaaclab/controllers/__init__.py @@ -15,4 +15,3 @@ from .differential_ik_cfg import DifferentialIKControllerCfg from .operational_space import OperationalSpaceController from .operational_space_cfg import OperationalSpaceControllerCfg -from .pink_ik import NullSpacePostureTask, PinkIKController, PinkIKControllerCfg From 1b2d6eaf0c887380387a4bd3fe154e737aacadcc Mon Sep 17 00:00:00 2001 From: Ziqi Fan Date: Thu, 28 Aug 2025 14:51:35 +0800 Subject: [PATCH 08/47] Fixes `terrain_out_of_bounds` to return tensor instead of bool (#3180) # Description Fix terrain_out_of_bounds to return tensor instead of bool Ensure the function always returns a PyTorch tensor (shape [num_envs]) rather than a Python boolean when terrain_type is "plane", preventing AttributeError in termination_manager. Before: ```bash Error executing job with overrides: [] Traceback (most recent call last): File "/home/ubuntu/workspaces/IsaacLab/source/isaaclab_tasks/isaaclab_tasks/utils/hydra.py", line 101, in hydra_main func(env_cfg, agent_cfg, *args, **kwargs) File "/home/ubuntu/workspaces/robot_lab/scripts/reinforcement_learning/rsl_rl/train.py", line 165, in main runner.learn(num_learning_iterations=agent_cfg.max_iterations, init_at_random_ep_len=True) File "/home/ubuntu/miniconda3/envs/lab/lib/python3.11/site-packages/rsl_rl/runners/on_policy_runner.py", line 206, in learn obs, rewards, dones, infos = self.env.step(actions.to(self.env.device)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/ubuntu/workspaces/IsaacLab/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py", line 176, in step obs_dict, rew, terminated, truncated, extras = self.env.step(actions) ^^^^^^^^^^^^^^^^^^^^^^ File "/home/ubuntu/miniconda3/envs/lab/lib/python3.11/site-packages/gymnasium/wrappers/common.py", line 393, in step return super().step(action) ^^^^^^^^^^^^^^^^^^^^ File "/home/ubuntu/miniconda3/envs/lab/lib/python3.11/site-packages/gymnasium/core.py", line 327, in step return self.env.step(action) ^^^^^^^^^^^^^^^^^^^^^ File "/home/ubuntu/workspaces/IsaacLab/source/isaaclab/isaaclab/envs/manager_based_rl_env.py", line 204, in step self.reset_buf = self.termination_manager.compute() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/ubuntu/workspaces/IsaacLab/source/isaaclab/isaaclab/managers/termination_manager.py", line 172, in compute rows = value.nonzero(as_tuple=True)[0] # indexing is cheaper than boolean advance indexing ^^^^^^^^^^^^^ AttributeError: 'bool' object has no attribute 'nonzero' ``` ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Mayank Mittal <12863862+Mayankm96@users.noreply.github.com> --- .../manager_based/locomotion/velocity/mdp/terminations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/terminations.py index 2bebfb20e777..833663df1637 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/terminations.py @@ -30,7 +30,8 @@ def terrain_out_of_bounds( to the edge of the terrain is calculated based on the size of the terrain and the distance buffer. """ if env.scene.cfg.terrain.terrain_type == "plane": - return False # we have infinite terrain because it is a plane + # we have infinite terrain because it is a plane + return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) elif env.scene.cfg.terrain.terrain_type == "generator": # obtain the size of the sub-terrains terrain_gen_cfg = env.scene.terrain.cfg.terrain_generator From a80fa53bb7ad014981c1e7f8d84100afde52649a Mon Sep 17 00:00:00 2001 From: Alexander Poddubny <143108850+nv-apoddubny@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:01:21 -0700 Subject: [PATCH 09/47] Disables CI reporting for PRs created from the forks (#3209) # Description Temporary disabled test reporting for PRs created from the forks. --- .github/workflows/build.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90a232a7ee38..89c6501a2ee8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,6 +94,7 @@ jobs: isaacsim-version: ${{ env.ISAACSIM_BASE_VERSION }} - name: Run General Tests + id: run-general-tests uses: ./.github/actions/run-tests with: test-path: "tools" @@ -120,6 +121,12 @@ jobs: retention-days: 1 compression-level: 9 + - name: Fail on Test Failure for Fork PRs + if: github.event.pull_request.head.repo.full_name != github.repository && steps.run-general-tests.outcome == 'failure' + run: | + echo "Tests failed for PR from fork. The test report is in the logs. Failing the job." + exit 1 + combine-results: needs: [test-isaaclab-tasks, test-general] runs-on: [self-hosted, gpu] @@ -166,6 +173,7 @@ jobs: - name: Comment on Test Results id: test-reporter + if: github.event.pull_request.head.repo.full_name == github.repository uses: EnricoMi/publish-unit-test-result-action@v2 with: files: "reports/combined-results.xml" @@ -179,6 +187,7 @@ jobs: action_fail_on_inconclusive: true - name: Report Test Results + if: github.event.pull_request.head.repo.full_name == github.repository uses: dorny/test-reporter@v1 with: name: IsaacLab Build and Test Results From 8608fbce14db1aa93ce093fa1f5c68f746a12891 Mon Sep 17 00:00:00 2001 From: Pascal Roth <57946385+pascal-roth@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:54:23 +0200 Subject: [PATCH 10/47] Fixes template docs and restructure imitation learning docs (#3283) # Description There have been two places where the template documentation has been placed (under Developers Guide and Workthrough), this PR unifies them into a new structure (see image below). Furthermore, the imitation learning examples were missing a grouping, this PR introduces a structure similar to the section about reinforcement learning Also some general docs fixes are included. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots image ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/index.rst | 4 +- docs/source/how-to/write_articulation_cfg.rst | 2 +- .../source/overview/developer-guide/index.rst | 1 - .../augmented_imitation.rst | 4 +- .../overview/imitation-learning/index.rst | 11 ++ .../teleop_imitation.rst | 2 +- docs/source/overview/own-project/index.rst | 14 +++ .../own-project/project_structure.rst | 44 +++++++ .../template.rst | 5 +- .../setup/walkthrough/concepts_env_design.rst | 2 +- docs/source/setup/walkthrough/index.rst | 1 - .../setup/walkthrough/project_setup.rst | 111 ------------------ source/isaaclab/docs/CHANGELOG.rst | 6 +- .../assets/rigid_object/rigid_object.py | 1 + .../templates/external/.vscode/tasks.json | 2 +- .../external/.vscode/tools/setup_vscode.py | 10 +- 16 files changed, 89 insertions(+), 131 deletions(-) rename docs/source/overview/{ => imitation-learning}/augmented_imitation.rst (94%) create mode 100644 docs/source/overview/imitation-learning/index.rst rename docs/source/overview/{ => imitation-learning}/teleop_imitation.rst (99%) create mode 100644 docs/source/overview/own-project/index.rst create mode 100644 docs/source/overview/own-project/project_structure.rst rename docs/source/overview/{developer-guide => own-project}/template.rst (99%) delete mode 100644 docs/source/setup/walkthrough/project_setup.rst diff --git a/docs/index.rst b/docs/index.rst index c623639334d7..baeeffdd35f0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -89,6 +89,7 @@ Table of Contents :titlesonly: source/setup/quickstart + source/overview/own-project/index source/setup/walkthrough/index source/tutorials/index source/how-to/index @@ -104,8 +105,7 @@ Table of Contents source/overview/core-concepts/index source/overview/environments source/overview/reinforcement-learning/index - source/overview/teleop_imitation - source/overview/augmented_imitation + source/overview/imitation-learning/index source/overview/showroom source/overview/simple_agents diff --git a/docs/source/how-to/write_articulation_cfg.rst b/docs/source/how-to/write_articulation_cfg.rst index 910faf7e7bf0..d681f281473b 100644 --- a/docs/source/how-to/write_articulation_cfg.rst +++ b/docs/source/how-to/write_articulation_cfg.rst @@ -18,7 +18,7 @@ properties of an :class:`~assets.Articulation` in Isaac Lab. We will use the Cartpole example to demonstrate how to create an :class:`~assets.ArticulationCfg`. The Cartpole is a simple robot that consists of a cart with a pole attached to it. The cart is free to move along a rail, and the pole is free to rotate about the cart. The file for this configuration example is - ``source/isaaclab_assets/isaaclab_assets/robots/cartpole.py``. +``source/isaaclab_assets/isaaclab_assets/robots/cartpole.py``. .. dropdown:: Code for Cartpole configuration :icon: code diff --git a/docs/source/overview/developer-guide/index.rst b/docs/source/overview/developer-guide/index.rst index e77ebc6cc464..59f603fbfad2 100644 --- a/docs/source/overview/developer-guide/index.rst +++ b/docs/source/overview/developer-guide/index.rst @@ -13,4 +13,3 @@ using VSCode. VS Code repo_structure development - template diff --git a/docs/source/overview/augmented_imitation.rst b/docs/source/overview/imitation-learning/augmented_imitation.rst similarity index 94% rename from docs/source/overview/augmented_imitation.rst rename to docs/source/overview/imitation-learning/augmented_imitation.rst index 530bb9e4583b..38059879c71b 100644 --- a/docs/source/overview/augmented_imitation.rst +++ b/docs/source/overview/imitation-learning/augmented_imitation.rst @@ -83,7 +83,7 @@ Example usage for the cube stacking task: Running Cosmos for Visual Augmentation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -After converting the demonstrations to MP4 format, you can use a `Cosmos `_ model to visually augment the videos. Follow the Cosmos documentation for details on the augmentation process. Visual augmentation can include changes to lighting, textures, backgrounds, and other visual elements while preserving the essential task-relevant features. +After converting the demonstrations to MP4 format, you can use a `Cosmos`_ model to visually augment the videos. Follow the Cosmos documentation for details on the augmentation process. Visual augmentation can include changes to lighting, textures, backgrounds, and other visual elements while preserving the essential task-relevant features. We use the RGB, depth and shaded segmentation videos from the previous step as input to the Cosmos model as seen below: @@ -99,7 +99,7 @@ We provide an example augmentation output from `Cosmos Transfer1 `_ model for visual augmentation as we found it to produce the best results in the form of a highly diverse dataset with a wide range of visual variations. You can refer to the installation instructions `here `_, the checkpoint download instructions `here `_ and `this example `_ for reference on how to use Transfer1 for this usecase. We further recommend the following settings to be used with the Transfer1 model for this task: +We recommend using the `Cosmos Transfer1 `_ model for visual augmentation as we found it to produce the best results in the form of a highly diverse dataset with a wide range of visual variations. You can refer to the `installation instructions `_, the `checkpoint download instructions `_ and `this example `_ for reference on how to use Transfer1 for this usecase. We further recommend the following settings to be used with the Transfer1 model for this task: .. rubric:: Hyperparameters diff --git a/docs/source/overview/imitation-learning/index.rst b/docs/source/overview/imitation-learning/index.rst new file mode 100644 index 000000000000..5c21b1f34066 --- /dev/null +++ b/docs/source/overview/imitation-learning/index.rst @@ -0,0 +1,11 @@ +Imitation Learning +================== + +In this section, we show existing scripts for running imitation learning +with Isaac Lab. + +.. toctree:: + :maxdepth: 1 + + augmented_imitation + teleop_imitation diff --git a/docs/source/overview/teleop_imitation.rst b/docs/source/overview/imitation-learning/teleop_imitation.rst similarity index 99% rename from docs/source/overview/teleop_imitation.rst rename to docs/source/overview/imitation-learning/teleop_imitation.rst index 67cddf03ec18..ac9ff229a865 100644 --- a/docs/source/overview/teleop_imitation.rst +++ b/docs/source/overview/imitation-learning/teleop_imitation.rst @@ -413,7 +413,7 @@ Annotations denote the end of a subtask. For the pick and place task, this means Each demo requires a single annotation between the first and second subtask of the right arm. This annotation ("S" button press) should be done when the right robot arm finishes the "idle" subtask and begins to move towards the target object. An example of a correct annotation is shown below: -.. figure:: ../_static/tasks/manipulation/gr-1_pick_place_annotation.jpg +.. figure:: ../../_static/tasks/manipulation/gr-1_pick_place_annotation.jpg :width: 100% :align: center diff --git a/docs/source/overview/own-project/index.rst b/docs/source/overview/own-project/index.rst new file mode 100644 index 000000000000..36ef4443f5b1 --- /dev/null +++ b/docs/source/overview/own-project/index.rst @@ -0,0 +1,14 @@ +.. _own-project: + +Build your Own Project or Task +============================== + +To get started, first create a new project or task with the template generator :ref:`template-generator`. +For more detailed information on how your project is structured, see :ref:`project-structure`. + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + template + project_structure diff --git a/docs/source/overview/own-project/project_structure.rst b/docs/source/overview/own-project/project_structure.rst new file mode 100644 index 000000000000..a0e17f0344d4 --- /dev/null +++ b/docs/source/overview/own-project/project_structure.rst @@ -0,0 +1,44 @@ +.. _project-structure: + + +Project Structure +================= + +There are four nested structures you need to be aware of when working in the direct workflow with an Isaac Lab template +project: the **Project**, the **Extension**, the **Modules**, and the **Task**. + +.. figure:: ../../_static/setup/walkthrough_project_setup.svg + :align: center + :figwidth: 100% + :alt: The structure of the isaac lab template project. + +The **Project** is the root directory of the generated template. It contains the source and scripts directories, as well as +a ``README.md`` file. When we created the template, we named the project *IsaacLabTutorial* and this defined the root directory +of a git repository. If you examine the project root with hidden files visible you will see a number of files defining +the behavior of the project with respect to git. The ``scripts`` directory contains the ``train.py`` and ``play.py`` scripts for the +various RL libraries you chose when generating the template, while the source directory contains the python packages for the project. + +The **Extension** is the name of the python package we installed via pip. By default, the template generates a project +with a single extension of the same name. A project can have multiple extensions, and so they are kept in a common ``source`` +directory. Traditional python packages are defined by the presence of a ``pyproject.toml`` file that describes the package +metadata, but packages using Isaac Lab must also be Isaac Sim extensions and so require a ``config`` directory and an accompanying +``extension.toml`` file that describes the metadata needed by the Isaac Sim extension manager. Finally, because the template +is intended to be installed via pip, it needs a ``setup.py`` file to complete the setup procedure using the ``extension.toml`` +config. A project can have multiple extensions, as evidenced by the Isaac Lab repository itself! + +The **Modules** are what actually gets loaded by Isaac Lab to run training (the meat of the code). By default, the template +generates an extension with a single module that is named the same as the project. The structure of the various sub-modules +in the extension is what determines the ``entry_point`` for an environment in Isaac Lab. This is why our template project needed +to be installed before we could call ``train.py``: the path to the necessary components to run the task needed to be exposed +to python for Isaac Lab to find them. + +Finally, the **Task** is the heart of the direct workflow. By default, the template generates a single task with the same name +as the project. The environment and configuration files are stored here, as well as placeholder, RL library dependent ``agents``. +Critically, note the contents of the ``__init__.py``! Specifically, the ``gym.register`` function needs to be called at least once +before an environment and task can be used with the Isaac Lab ``train.py`` and ``play.py`` scripts. +This function should be included in one of the module ``__init__.py`` files so it is called at installation. The path to +this init file is what defines the entry point for the task! + +For the template, ``gym.register`` is called within ``isaac_lab_tutorial/source/isaac_lab_tutorial/isaac_lab_tutorial/tasks/direct/isaac_lab_tutorial/__init__.py``. +The repeated name is a consequence of needing default names for the template, but now we can see the structure of the project. +**Project**/source/**Extension**/**Module**/tasks/direct/**Task**/__init__.py diff --git a/docs/source/overview/developer-guide/template.rst b/docs/source/overview/own-project/template.rst similarity index 99% rename from docs/source/overview/developer-guide/template.rst rename to docs/source/overview/own-project/template.rst index f9d954acdf4f..521b959a7482 100644 --- a/docs/source/overview/developer-guide/template.rst +++ b/docs/source/overview/own-project/template.rst @@ -1,7 +1,8 @@ .. _template-generator: -Build your Own Project or Task -============================== + +Create new project or task +========================== Traditionally, building new projects that utilize Isaac Lab's features required creating your own extensions within the Isaac Lab repository. However, this approach can obscure project visibility and diff --git a/docs/source/setup/walkthrough/concepts_env_design.rst b/docs/source/setup/walkthrough/concepts_env_design.rst index 892172f5298a..d446820a1472 100644 --- a/docs/source/setup/walkthrough/concepts_env_design.rst +++ b/docs/source/setup/walkthrough/concepts_env_design.rst @@ -24,7 +24,7 @@ reference frame is what defines the world. "Above" the world in structure is the **Sim**\ ulation and the **App**\ lication. The **Application** is "the thing responsible for everything else": It governs all resource management as well as launching and destroying the simulation when we are done with it. -When we :ref:`launched training with the template`, the window that appears with the viewport of cartpoles +When we :ref:`launched training with the template`, the window that appears with the viewport of cartpoles training is the Application window. The application is not defined by the GUI however, and even when running in headless mode all simulations have an application that governs them. diff --git a/docs/source/setup/walkthrough/index.rst b/docs/source/setup/walkthrough/index.rst index 3dc885788cf4..2ba226625583 100644 --- a/docs/source/setup/walkthrough/index.rst +++ b/docs/source/setup/walkthrough/index.rst @@ -17,7 +17,6 @@ represents a different stage of modifying the default template project to achiev :maxdepth: 1 :titlesonly: - project_setup concepts_env_design api_env_design technical_env_design diff --git a/docs/source/setup/walkthrough/project_setup.rst b/docs/source/setup/walkthrough/project_setup.rst deleted file mode 100644 index f8cf950b150f..000000000000 --- a/docs/source/setup/walkthrough/project_setup.rst +++ /dev/null @@ -1,111 +0,0 @@ -.. _walkthrough_project_setup: - - -Isaac Lab Project Setup -======================== - -The best way to create a new project is to use the :ref:`Template Generator`. Generating the template -for this tutorial series is done by calling the ``isaaclab`` script from the root directory of the repository - -.. code-block:: bash - - ./isaaclab.sh --new - -Be sure to select ``External`` and ``Direct | single agent``. For the frameworks, select ``skrl`` and both ``PPO`` and ``AMP`` on the following menu. You can -select other frameworks if you like, but this tutorial will detail ``skrl`` specifically. The configuration process for other frameworks is similar. You -can get a copy of this code directly by checking out the `initial branch of the tutorial repository `_! - - -This will create an extension project with the specified name at the chosen path. For this tutorial, we chose the name ``isaac_lab_tutorial``. - -.. note:: - - The template generator expects the project name to respect "snake_case": all lowercase with underscores separating words. However, we have renamed the - sample project to "IsaacLabTutorial" to more closely match the naming convention GitHub and our other projects. If you are following along with the example - repository, note this minor difference as some superficial path names may change. If you are following along by building the project yourself, then you can ignore this note. - -Next, we must install the project as a python module. Navigate to the directory that was just created -(it will contain the ``source`` and ``scripts`` directories for the project) and then run the following to install the module. - -.. code-block:: bash - - python -m pip install -e source/isaac_lab_tutorial - -To verify that things have been setup properly, run - -.. code-block:: bash - - python scripts/list_envs.py - -from the root directory of your new project. This should generate a table that looks something like the following - -.. code-block:: bash - - +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ - | Available Environments in Isaac Lab | - +--------+---------------------------------------+-----------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ - | S. No. | Task Name | Entry Point | Config | - +--------+---------------------------------------+-----------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ - | 1 | Template-Isaac-Lab-Tutorial-Direct-v0 | isaac_lab_tutorial.tasks.direct.isaac_lab_tutorial.isaac_lab_tutorial_env:IsaacLabTutorialEnv | isaac_lab_tutorial.tasks.direct.isaac_lab_tutorial.isaac_lab_tutorial_env_cfg:IsaacLabTutorialEnvCfg | - +--------+---------------------------------------+-----------------------------------------------------------------------------------------------+------------------------------------------------------------------------------------------------------+ - -We can now use the task name to run the environment. - -.. code-block:: bash - - python scripts/skrl/train.py --task=Template-Isaac-Lab-Tutorial-Direct-v0 - -and by default, this should start a cartpole training environment. - -Let the training finish and then run the following command to see the trained policy in action! - -.. code-block:: bash - - python scripts/skrl/play.py --task=Template-Isaac-Lab-Tutorial-Direct-v0 - -Notice that you did not need to specify the path for the checkpoint file! This is because Isaac Lab handles much of the minute details -like checkpoint saving, loading, and logging. In this case, the ``train.py`` script will create two directories: **logs** and **output**, which are -used as the default output directories for tasks run by this project. - - -Project Structure ------------------ - -There are four nested structures you need to be aware of when working in the direct workflow with an Isaac Lab template -project: the **Project**, the **Extension**, the **Modules**, and the **Task**. - -.. figure:: ../../_static/setup/walkthrough_project_setup.svg - :align: center - :figwidth: 100% - :alt: The structure of the isaac lab template project. - -The **Project** is the root directory of the generated template. It contains the source and scripts directories, as well as -a ``README.md`` file. When we created the template, we named the project *IsaacLabTutorial* and this defined the root directory -of a git repository. If you examine the project root with hidden files visible you will see a number of files defining -the behavior of the project with respect to git. The ``scripts`` directory contains the ``train.py`` and ``play.py`` scripts for the -various RL libraries you chose when generating the template, while the source directory contains the python packages for the project. - -The **Extension** is the name of the python package we installed via pip. By default, the template generates a project -with a single extension of the same name. A project can have multiple extensions, and so they are kept in a common ``source`` -directory. Traditional python packages are defined by the presence of a ``pyproject.toml`` file that describes the package -metadata, but packages using Isaac Lab must also be Isaac Sim extensions and so require a ``config`` directory and an accompanying -``extension.toml`` file that describes the metadata needed by the Isaac Sim extension manager. Finally, because the template -is intended to be installed via pip, it needs a ``setup.py`` file to complete the setup procedure using the ``extension.toml`` -config. A project can have multiple extensions, as evidenced by the Isaac Lab repository itself! - -The **Modules** are what actually gets loaded by Isaac Lab to run training (the meat of the code). By default, the template -generates an extension with a single module that is named the same as the project. The structure of the various sub-modules -in the extension is what determines the ``entry_point`` for an environment in Isaac Lab. This is why our template project needed -to be installed before we could call ``train.py``: the path to the necessary components to run the task needed to be exposed -to python for Isaac Lab to find them. - -Finally, the **Task** is the heart of the direct workflow. By default, the template generates a single task with the same name -as the project. The environment and configuration files are stored here, as well as placeholder, RL library dependent ``agents``. -Critically, note the contents of the ``__init__.py``! Specifically, the ``gym.register`` function needs to be called at least once -before an environment and task can be used with the Isaac Lab ``train.py`` and ``play.py`` scripts. -This function should be included in one of the module ``__init__.py`` files so it is called at installation. The path to -this init file is what defines the entry point for the task! - -For the template, ``gym.register`` is called within ``isaac_lab_tutorial/source/isaac_lab_tutorial/isaac_lab_tutorial/tasks/direct/isaac_lab_tutorial/__init__.py``. -The repeated name is a consequence of needing default names for the template, but now we can see the structure of the project. -**Project**/source/**Extension**/**Module**/tasks/direct/**Task**/__init__.py diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 6f8a8bad38de..2b6345aca4a6 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -125,7 +125,7 @@ Added 0.44.12 (2025-08-12) -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ @@ -135,7 +135,7 @@ Fixed 0.44.11 (2025-08-11) -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ @@ -144,7 +144,7 @@ Fixed 0.44.10 (2025-08-06) -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ diff --git a/source/isaaclab/isaaclab/assets/rigid_object/rigid_object.py b/source/isaaclab/isaaclab/assets/rigid_object/rigid_object.py index 5daef0f2fe90..ac76326116ed 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object/rigid_object.py +++ b/source/isaaclab/isaaclab/assets/rigid_object/rigid_object.py @@ -393,6 +393,7 @@ def set_external_force_and_torque( all the external wrenches will be applied in the frame specified by the last call. .. code-block:: python + # example of setting external wrench in the global frame asset.set_external_force_and_torque(forces=torch.ones(1, 1, 3), env_ids=[0], is_global=True) # example of setting external wrench in the link frame diff --git a/tools/template/templates/external/.vscode/tasks.json b/tools/template/templates/external/.vscode/tasks.json index cc67d77812d6..0ebe2101cff6 100644 --- a/tools/template/templates/external/.vscode/tasks.json +++ b/tools/template/templates/external/.vscode/tasks.json @@ -15,7 +15,7 @@ "inputs": [ { "id": "isaac_path", - "description": "Absolute path to the current Isaac Sim installation. Can be skipped if Isaac Sim installed from pip.", + "description": "Absolute path to the current Isaac Sim installation. If you installed IsaacSim from pip, the import of it failed. Please make sure you run the task with the correct python environment. As fallback, you can directly execute the python script by running: ``python.sh /.vscode/tools/setup_vscode.py``", {% if platform == "win32" %} "default": "C:/isaacsim", {% else %} diff --git a/tools/template/templates/external/.vscode/tools/setup_vscode.py b/tools/template/templates/external/.vscode/tools/setup_vscode.py index a1578f68165d..3a96361dffda 100644 --- a/tools/template/templates/external/.vscode/tools/setup_vscode.py +++ b/tools/template/templates/external/.vscode/tools/setup_vscode.py @@ -49,11 +49,11 @@ # check if the isaac-sim directory exists if not os.path.exists(isaacsim_dir): raise FileNotFoundError( - f"Could not find the isaac-sim directory: {isaacsim_dir}. There are two possible reasons for this:" - "\n\t1. The Isaac Sim directory does not exist as provided CLI path." - "\n\t2. The script could import the 'isaacsim' package. This could be due to the 'isaacsim' package not being " - "installed in the Python environment.\n" - "\nPlease make sure that the Isaac Sim directory exists or that the 'isaacsim' package is installed." + f"Could not find the isaac-sim directory: {isaacsim_dir}. There are two possible reasons for this:\n\t1. The" + " Isaac Sim directory does not exist as provided CLI path.\n\t2. The script couldn't import the 'isaacsim'" + " package. This could be due to the 'isaacsim' package not being installed in the Python" + " environment.\n\nPlease make sure that the Isaac Sim directory exists or that the 'isaacsim' package is" + " installed." ) ISAACSIM_DIR = isaacsim_dir From 0f00ca2b4b2d54d5f90006a92abb1b00a72b2f20 Mon Sep 17 00:00:00 2001 From: Mayank Mittal <12863862+Mayankm96@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:57:15 +0200 Subject: [PATCH 11/47] Updates version and release notes for v2.2.1 (#3296) # Description Updates version of the framework for 2.2.1 patch release. ## Type of change - This change requires a documentation update ## Checklist - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- CITATION.cff | 2 +- README.md | 49 +++++--- VERSION | 2 +- apps/isaaclab.python.headless.kit | 2 +- apps/isaaclab.python.headless.rendering.kit | 2 +- apps/isaaclab.python.kit | 2 +- apps/isaaclab.python.rendering.kit | 2 +- apps/isaaclab.python.xr.openxr.headless.kit | 2 +- apps/isaaclab.python.xr.openxr.kit | 2 +- .../isaacsim_4_5/isaaclab.python.headless.kit | 2 +- .../isaaclab.python.headless.rendering.kit | 2 +- apps/isaacsim_4_5/isaaclab.python.kit | 2 +- .../isaaclab.python.rendering.kit | 2 +- .../isaaclab.python.xr.openxr.headless.kit | 2 +- .../isaaclab.python.xr.openxr.kit | 2 +- docs/source/refs/release_notes.rst | 109 ++++++++++++++++-- 16 files changed, 145 insertions(+), 41 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 81a4caa3607c..ce2eaabc5054 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,7 +1,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite both the Isaac Lab repository and the Orbit paper." title: Isaac Lab -version: 2.2.0 +version: 2.2.1 repository-code: https://github.com/NVIDIA-Omniverse/IsaacLab type: software authors: diff --git a/README.md b/README.md index 1a430cae71e5..d031c7bfe2fe 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,21 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-yellow.svg)](https://opensource.org/license/apache-2-0) -**Isaac Lab** is a GPU-accelerated, open-source framework designed to unify and simplify robotics research workflows, such as reinforcement learning, imitation learning, and motion planning. Built on [NVIDIA Isaac Sim](https://docs.isaacsim.omniverse.nvidia.com/latest/index.html), it combines fast and accurate physics and sensor simulation, making it an ideal choice for sim-to-real transfer in robotics. +**Isaac Lab** is a GPU-accelerated, open-source framework designed to unify and simplify robotics research workflows, +such as reinforcement learning, imitation learning, and motion planning. Built on [NVIDIA Isaac Sim](https://docs.isaacsim.omniverse.nvidia.com/latest/index.html), +it combines fast and accurate physics and sensor simulation, making it an ideal choice for sim-to-real +transfer in robotics. -Isaac Lab provides developers with a range of essential features for accurate sensor simulation, such as RTX-based cameras, LIDAR, or contact sensors. The framework's GPU acceleration enables users to run complex simulations and computations faster, which is key for iterative processes like reinforcement learning and data-intensive tasks. Moreover, Isaac Lab can run locally or be distributed across the cloud, offering flexibility for large-scale deployments. +Isaac Lab provides developers with a range of essential features for accurate sensor simulation, such as RTX-based +cameras, LIDAR, or contact sensors. The framework's GPU acceleration enables users to run complex simulations and +computations faster, which is key for iterative processes like reinforcement learning and data-intensive tasks. +Moreover, Isaac Lab can run locally or be distributed across the cloud, offering flexibility for large-scale deployments. ## Key Features Isaac Lab offers a comprehensive set of tools and environments designed to facilitate robot learning: + - **Robots**: A diverse collection of robots, from manipulators, quadrupeds, to humanoids, with 16 commonly available models. - **Environments**: Ready-to-train implementations of more than 30 environments, which can be trained with popular reinforcement learning frameworks such as RSL RL, SKRL, RL Games, or Stable Baselines. We also support multi-agent reinforcement learning. - **Physics**: Rigid bodies, articulated systems, deformable objects @@ -118,7 +125,8 @@ For detailed Isaac Sim installation instructions, please refer to ### Documentation -Our [documentation page](https://isaac-sim.github.io/IsaacLab) provides everything you need to get started, including detailed tutorials and step-by-step guides. Follow these links to learn more about: +Our [documentation page](https://isaac-sim.github.io/IsaacLab) provides everything you need to get started, including +detailed tutorials and step-by-step guides. Follow these links to learn more about: - [Installation steps](https://isaac-sim.github.io/IsaacLab/main/source/setup/installation/index.html#local-installation) - [Reinforcement learning](https://isaac-sim.github.io/IsaacLab/main/source/overview/reinforcement-learning/rl_existing_scripts.html) @@ -128,18 +136,16 @@ Our [documentation page](https://isaac-sim.github.io/IsaacLab) provides everythi ## Isaac Sim Version Dependency -Isaac Lab is built on top of Isaac Sim and requires specific versions of Isaac Sim that are compatible with each release of Isaac Lab. -Below, we outline the recent Isaac Lab releases and GitHub branches and their corresponding dependency versions for Isaac Sim. +Isaac Lab is built on top of Isaac Sim and requires specific versions of Isaac Sim that are compatible with each +release of Isaac Lab. Below, we outline the recent Isaac Lab releases and GitHub branches and their corresponding +dependency versions for Isaac Sim. | Isaac Lab Version | Isaac Sim Version | | ----------------------------- | ------------------- | | `main` branch | Isaac Sim 4.5 / 5.0 | -| `v2.2.0` | Isaac Sim 4.5 / 5.0 | -| `v2.1.1` | Isaac Sim 4.5 | -| `v2.1.0` | Isaac Sim 4.5 | -| `v2.0.2` | Isaac Sim 4.5 | -| `v2.0.1` | Isaac Sim 4.5 | -| `v2.0.0` | Isaac Sim 4.5 | +| `v2.2.X` | Isaac Sim 4.5 / 5.0 | +| `v2.1.X` | Isaac Sim 4.5 | +| `v2.0.X` | Isaac Sim 4.5 | ## Contributing to Isaac Lab @@ -150,8 +156,8 @@ These may happen as bug reports, feature requests, or code contributions. For de ## Show & Tell: Share Your Inspiration -We encourage you to utilize our [Show & Tell](https://github.com/isaac-sim/IsaacLab/discussions/categories/show-and-tell) area in the -`Discussions` section of this repository. This space is designed for you to: +We encourage you to utilize our [Show & Tell](https://github.com/isaac-sim/IsaacLab/discussions/categories/show-and-tell) +area in the `Discussions` section of this repository. This space is designed for you to: * Share the tutorials you've created * Showcase your learning content @@ -171,8 +177,11 @@ or opening a question on its [forums](https://forums.developer.nvidia.com/c/agx- ## Support -* Please use GitHub [Discussions](https://github.com/isaac-sim/IsaacLab/discussions) for discussing ideas, asking questions, and requests for new features. -* Github [Issues](https://github.com/isaac-sim/IsaacLab/issues) should only be used to track executable pieces of work with a definite scope and a clear deliverable. These can be fixing bugs, documentation issues, new features, or general updates. +* Please use GitHub [Discussions](https://github.com/isaac-sim/IsaacLab/discussions) for discussing ideas, + asking questions, and requests for new features. +* Github [Issues](https://github.com/isaac-sim/IsaacLab/issues) should only be used to track executable pieces of + work with a definite scope and a clear deliverable. These can be fixing bugs, documentation issues, new features, + or general updates. ## Connect with the NVIDIA Omniverse Community @@ -182,15 +191,19 @@ to spotlight your work. You can also join the conversation on the [Omniverse Discord](https://discord.com/invite/nvidiaomniverse) to connect with other developers, share your projects, and help grow a vibrant, collaborative ecosystem -where creativity and technology intersect. Your contributions can make a meaningful impact on the Isaac Lab community and beyond! +where creativity and technology intersect. Your contributions can make a meaningful impact on the Isaac Lab +community and beyond! ## License -The Isaac Lab framework is released under [BSD-3 License](LICENSE). The `isaaclab_mimic` extension and its corresponding standalone scripts are released under [Apache 2.0](LICENSE-mimic). The license files of its dependencies and assets are present in the [`docs/licenses`](docs/licenses) directory. +The Isaac Lab framework is released under [BSD-3 License](LICENSE). The `isaaclab_mimic` extension and its +corresponding standalone scripts are released under [Apache 2.0](LICENSE-mimic). The license files of its +dependencies and assets are present in the [`docs/licenses`](docs/licenses) directory. ## Acknowledgement -Isaac Lab development initiated from the [Orbit](https://isaac-orbit.github.io/) framework. We would appreciate if you would cite it in academic publications as well: +Isaac Lab development initiated from the [Orbit](https://isaac-orbit.github.io/) framework. We would appreciate if +you would cite it in academic publications as well: ``` @article{mittal2023orbit, diff --git a/VERSION b/VERSION index ccbccc3dc626..c043eea7767e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.0 +2.2.1 diff --git a/apps/isaaclab.python.headless.kit b/apps/isaaclab.python.headless.kit index 55b345d3f264..9d3bd66f722c 100644 --- a/apps/isaaclab.python.headless.kit +++ b/apps/isaaclab.python.headless.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python Headless" description = "An app for running Isaac Lab headlessly" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "isaaclab", "python", "headless"] diff --git a/apps/isaaclab.python.headless.rendering.kit b/apps/isaaclab.python.headless.rendering.kit index ed20ad42c321..dad5e35b40ee 100644 --- a/apps/isaaclab.python.headless.rendering.kit +++ b/apps/isaaclab.python.headless.rendering.kit @@ -9,7 +9,7 @@ [package] title = "Isaac Lab Python Headless Camera" description = "An app for running Isaac Lab headlessly with rendering enabled" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "isaaclab", "python", "camera", "minimal"] diff --git a/apps/isaaclab.python.kit b/apps/isaaclab.python.kit index 004d3ffd1ac4..9d1687204a34 100644 --- a/apps/isaaclab.python.kit +++ b/apps/isaaclab.python.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python" description = "An app for running Isaac Lab" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "usd"] diff --git a/apps/isaaclab.python.rendering.kit b/apps/isaaclab.python.rendering.kit index 087887c454ed..ab88e1cf9059 100644 --- a/apps/isaaclab.python.rendering.kit +++ b/apps/isaaclab.python.rendering.kit @@ -9,7 +9,7 @@ [package] title = "Isaac Lab Python Camera" description = "An app for running Isaac Lab with rendering enabled" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "isaaclab", "python", "camera", "minimal"] diff --git a/apps/isaaclab.python.xr.openxr.headless.kit b/apps/isaaclab.python.xr.openxr.headless.kit index 7322f9e48d98..f9b89dc1b299 100644 --- a/apps/isaaclab.python.xr.openxr.headless.kit +++ b/apps/isaaclab.python.xr.openxr.headless.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python OpenXR Headless" description = "An app for running Isaac Lab with OpenXR in headless mode" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "usd", "headless"] diff --git a/apps/isaaclab.python.xr.openxr.kit b/apps/isaaclab.python.xr.openxr.kit index cb62421ad7c3..c88fbe8ddc82 100644 --- a/apps/isaaclab.python.xr.openxr.kit +++ b/apps/isaaclab.python.xr.openxr.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python OpenXR" description = "An app for running Isaac Lab with OpenXR" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "usd"] diff --git a/apps/isaacsim_4_5/isaaclab.python.headless.kit b/apps/isaacsim_4_5/isaaclab.python.headless.kit index c8e2bca05de4..13327588e0da 100644 --- a/apps/isaacsim_4_5/isaaclab.python.headless.kit +++ b/apps/isaacsim_4_5/isaaclab.python.headless.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python Headless" description = "An app for running Isaac Lab headlessly" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "isaaclab", "python", "headless"] diff --git a/apps/isaacsim_4_5/isaaclab.python.headless.rendering.kit b/apps/isaacsim_4_5/isaaclab.python.headless.rendering.kit index 7bcc3d03f117..df06ee11a0b6 100644 --- a/apps/isaacsim_4_5/isaaclab.python.headless.rendering.kit +++ b/apps/isaacsim_4_5/isaaclab.python.headless.rendering.kit @@ -9,7 +9,7 @@ [package] title = "Isaac Lab Python Headless Camera" description = "An app for running Isaac Lab headlessly with rendering enabled" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "isaaclab", "python", "camera", "minimal"] diff --git a/apps/isaacsim_4_5/isaaclab.python.kit b/apps/isaacsim_4_5/isaaclab.python.kit index 7c87df7c2f9f..4b7f4086b660 100644 --- a/apps/isaacsim_4_5/isaaclab.python.kit +++ b/apps/isaacsim_4_5/isaaclab.python.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python" description = "An app for running Isaac Lab" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "usd"] diff --git a/apps/isaacsim_4_5/isaaclab.python.rendering.kit b/apps/isaacsim_4_5/isaaclab.python.rendering.kit index c8ab4dd09902..8c319a040cd9 100644 --- a/apps/isaacsim_4_5/isaaclab.python.rendering.kit +++ b/apps/isaacsim_4_5/isaaclab.python.rendering.kit @@ -9,7 +9,7 @@ [package] title = "Isaac Lab Python Camera" description = "An app for running Isaac Lab with rendering enabled" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "isaaclab", "python", "camera", "minimal"] diff --git a/apps/isaacsim_4_5/isaaclab.python.xr.openxr.headless.kit b/apps/isaacsim_4_5/isaaclab.python.xr.openxr.headless.kit index 7a6e92f7ea16..f8b07af33833 100644 --- a/apps/isaacsim_4_5/isaaclab.python.xr.openxr.headless.kit +++ b/apps/isaacsim_4_5/isaaclab.python.xr.openxr.headless.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python OpenXR Headless" description = "An app for running Isaac Lab with OpenXR in headless mode" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "usd", "headless"] diff --git a/apps/isaacsim_4_5/isaaclab.python.xr.openxr.kit b/apps/isaacsim_4_5/isaaclab.python.xr.openxr.kit index e852ae47430d..663b7dfb4f32 100644 --- a/apps/isaacsim_4_5/isaaclab.python.xr.openxr.kit +++ b/apps/isaacsim_4_5/isaaclab.python.xr.openxr.kit @@ -5,7 +5,7 @@ [package] title = "Isaac Lab Python OpenXR" description = "An app for running Isaac Lab with OpenXR" -version = "2.2.0" +version = "2.2.1" # That makes it browsable in UI with "experience" filter keywords = ["experience", "app", "usd"] diff --git a/docs/source/refs/release_notes.rst b/docs/source/refs/release_notes.rst index 59396ce33ef7..be9dc4d8ec18 100644 --- a/docs/source/refs/release_notes.rst +++ b/docs/source/refs/release_notes.rst @@ -4,6 +4,97 @@ Release Notes The release notes are now available in the `Isaac Lab GitHub repository `_. We summarize the release notes here for convenience. +v2.2.1 +====== + +Overview +-------- + +This is a minor patch release with some improvements and bug fixes. + +Full Changelog: https://github.com/isaac-sim/IsaacLab/compare/v2.2.0...v2.2.1 + +New Features +------------ + +- Adds contact point location reporting to ContactSensor by @jtigue-bdai +- Adds environments actions/observations descriptors for export by @AntoineRichard +- Adds RSL-RL symmetry example for cartpole and ANYmal locomotion by @Mayankm96 + +Improvements +------------ + +Core API +~~~~~~~~ + +- Enhances Pink IK controller with null-space posture control and improvements by @michaellin6 +- Adds periodic logging when checking USD path on Nucleus server by @matthewtrepte +- Disallows string value written in sb3_ppo_cfg.yaml from being evaluated in process_sb3_cfg by @ooctipus + +Infrastructure +~~~~~~~~~~~~~~ + +* **Application Settings** + - Disables rate limit for headless and headless rendering app by @matthewtrepte, @kellyguo11 + - Disables ``rtx.indirrectDiffuse.enabled`` in render preset balanced and performance modes by @matthewtrepte + - Sets profiler backend to NVTX by default by @soowanpNV, @rwiltz +* **Dependencies** + - Adds hf-xet license by @hhansen-bdai + - Fixes new typing-inspection dependency license by @kellyguo11 +* **Testing & Benchmarking** + - Adds basic validation tests for scale-based randomization ranges by @louislelay + - Adds ``SensorBase`` tests by @jtigue-bdai +* **Repository Utilities** + - Adds improved readout from install_deps.py by @hhansen-bdai + - Fixes isaaclab.sh to detect isaacsim_version accurately 4.5 or >= 5.0 by @ooctipus + - Disables verbose printing in conftest.py by @ooctipus + - Updates pytest flags for isaacsim integration testing by @ben-johnston-nv + - Updates CodeOwners to be more fine-grained by @pascal-roth + - Fixes minor issues in CI by @nv-apoddubny + +Bug Fixes +--------- + +Core API +~~~~~~~~ + +* **Asset Interfaces** + - Fixes setting friction coefficients into PhysX in the articulation classes by @ossamaAhmed + - Sets joint_friction_coeff only for selected physx_env_ids by @ashwinvkNV +* **Manager Interfaces** + - Fixes observation space Dict for non-concatenated groups only keeping the last term by @CSCSX +* **MDP Terms** + - Fixes termination term effort limit check logic by @moribots + - Broadcasts environment ids inside ``mdp.randomize_rigid_body_com`` by @Foruck + - Fixes IndexError in reset_joints_by_scale and reset_joints_by_offset by @Creampelt + - Fixes ``terrain_out_of_bounds`` to return tensor instead of bool by @fan-ziqi + +Infrastructure +~~~~~~~~~~~~~~ + +- Fixes distributed training hanging issue by @kellyguo11 +- Disables generation of internal template when detecting isaaclab install via pip by @ooctipus +- Fixes typo in isaaclab.bat by @ooctipus +- Updates app pathing for user-provided rendering preset mode by @matthewtrepte + +Documentation +------------- + +- Adds documentation for Newton integration by @mpgussert +- Adapts FAQ section in docs with Isaac Sim open-sourcing by @Mayankm96 +- Changes checkpoint path in rsl-rl to an absolute path in documentation by @fan-ziqi +- Fixes MuJoCo link in docs by @fan-ziqi +- Adds client version direction to XR document by @lotusl-code +- Fixes broken link in doc by @kellyguo11 +- Fixes typo in list_envs.py script path by @fbeltrao +- Fixes Franka blueprint env ID in docs by @louislelay + +Breaking Changes +---------------- + +- Improves termination manager logging to report aggregated percentage of environments done due to each term by @ooctipus + + v2.2.0 ====== @@ -54,10 +145,10 @@ New Features * Adds FORGE tasks for contact-rich manipulation with force sensing to IsaacLab by @noseworm in #2968 * Adds two new GR1 environments for IsaacLab Mimic by @peterd-NV * Adds stack environment, scripts for Cosmos, and visual robustness evaluation by @shauryadNv -* Updates Joint Friction Parameters to Isaac Sim 5.0 PhysX APIs by @ossamaAhmed in 87130f23a11b84851133685b234dfa4e0991cfcd -* Adds support for spatial tendons by @ossamaAhmed in 7a176fa984dfac022d7f99544037565e78354067 -* Adds support and example for SurfaceGrippers by @AntoineRichard in 14a3a7afc835754da7a275209a95ea21b40c0d7a -* Adds support for stage in memory by @matthewtrepte in 33bcf6605bcd908c10dfb485a4432fa1110d2e73 +* Updates Joint Friction Parameters to Isaac Sim 5.0 PhysX APIs by @ossamaAhmed +* Adds support for spatial tendons by @ossamaAhmed +* Adds support and example for SurfaceGrippers by @AntoineRichard +* Adds support for stage in memory by @matthewtrepte * Adds OVD animation recording feature by @matthewtrepte Improvements @@ -71,7 +162,7 @@ Improvements * Updates Mimic test cases to pytest format by @peterd-NV * Updates cosmos test files to use pytest by @shauryadNv * Updates onnx and protobuf version due to vulnerabilities by @kellyguo11 -* Updates minimum skrl version to 1.4.3 by @Toni-SM in https://github.com/isaac-sim/IsaacLab/pull/3053 +* Updates minimum skrl version to 1.4.3 by @Toni-SM * Updates to Isaac Sim 5.0 by @kellyguo11 * Updates docker CloudXR runtime version by @lotusl-code * Removes xr rendering mode by @rwiltz @@ -84,16 +175,16 @@ Bug Fixes --------- * Fixes operational space unit test to avoid pi rotation error by @ooctipus -* Fixes GLIBC errors with importing torch before AppLauncher by @kellyguo11 in c80e2afb596372923dbab1090d4d0707423882f0 +* Fixes GLIBC errors with importing torch before AppLauncher by @kellyguo11 * Fixes rendering preset by @matthewtrepte in cc0dab6cd50778507efc3c9c2d74a28919ab2092 -* Fixes callbacks with stage in memory and organize environment tests by @matthewtrepte in 4dd6a1e804395561965ed242b3d3d80b8a8f72b9 -* Fixes XR and external camera bug with async rendering by @rwiltz in c80e2afb596372923dbab1090d4d0707423882f0 +* Fixes callbacks with stage in memory and organize environment tests by @matthewtrepte +* Fixes XR and external camera bug with async rendering by @rwiltz * Disables selection for rl_games when marl is selected for template generator by @ooctipus * Adds check for .gitignore when generating template by @kellyguo11 * Fixes camera obs errors in stack instance randomize envs by @peterd-NV * Fixes parsing for play envs by @matthewtrepte * Fixes issues with consecutive python exe calls in isaaclab.bat by @kellyguo11 -* Fixes spacemouse add callback function by @peterd-NV in 72f05a29ad12d02ec9585dad0fbb2299d70a929c +* Fixes spacemouse add callback function by @peterd-NV * Fixes humanoid training with new velocity_limit_sim by @AntoineRichard Documentation From c91a125c73c8b574878419a9583afc0b63b99f0a Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Mon, 1 Sep 2025 05:46:25 -0700 Subject: [PATCH 12/47] Fixes missing visible attribute in spawn_ground_plane (#3304) # Description `GroundPlaneCfg` allows for specifying `visible` parameter, but this would not being parsed in `spawn_ground_plane`, resulting in the parameter being a no-op when specified. This change adds a fix to parse the `visible` parameter from the cfg and sets the visibility attribute for the ground plane cfg appropriately. Fixes #3263 ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../isaaclab/isaaclab/sim/spawners/from_files/from_files.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index c47b029b29a8..639fada48b88 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -217,6 +217,10 @@ def spawn_ground_plane( # create semantic type and data attributes sem.CreateSemanticTypeAttr().Set(semantic_type) sem.CreateSemanticDataAttr().Set(semantic_value) + + # Apply visibility + prim_utils.set_prim_visibility(prim, cfg.visible) + # return the prim return prim From 82b24dd42a87242db7fc0afd64fccc243ce59461 Mon Sep 17 00:00:00 2001 From: Clemens Schwarke <96480707+ClemensSchwarke@users.noreply.github.com> Date: Tue, 2 Sep 2025 23:28:30 +0200 Subject: [PATCH 13/47] Adds changes for rsl_rl 3.0.1 (#2962) # Description This PR adds the necessary changes to work with the new version of RSL RL. ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Clemens Schwarke <96480707+ClemensSchwarke@users.noreply.github.com> Signed-off-by: Kelly Guo Co-authored-by: Pascal Roth <57946385+pascal-roth@users.noreply.github.com> Co-authored-by: Kelly Guo Co-authored-by: Octi Zhang --- .github/workflows/license-exceptions.json | 5 ++ isaaclab.bat | 1 - .../reinforcement_learning/rsl_rl/cli_args.py | 8 +- scripts/reinforcement_learning/rsl_rl/play.py | 39 +++++---- .../reinforcement_learning/rsl_rl/train.py | 20 +++-- .../isaaclab_rl/rsl_rl/distillation_cfg.py | 12 +++ .../isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py | 84 +++++++++++++++---- .../isaaclab_rl/rsl_rl/symmetry_cfg.py | 6 +- .../isaaclab_rl/rsl_rl/vecenv_wrapper.py | 60 +++++-------- source/isaaclab_rl/setup.py | 3 +- .../isaaclab_rl/test/test_rsl_rl_wrapper.py | 3 + .../allegro_hand/agents/rsl_rl_ppo_cfg.py | 3 +- .../direct/ant/agents/rsl_rl_ppo_cfg.py | 3 +- .../direct/anymal_c/agents/rsl_rl_ppo_cfg.py | 6 +- .../direct/cartpole/agents/rsl_rl_ppo_cfg.py | 3 +- .../franka_cabinet/agents/rsl_rl_ppo_cfg.py | 3 +- .../direct/humanoid/agents/rsl_rl_ppo_cfg.py | 3 +- .../quadcopter/agents/rsl_rl_ppo_cfg.py | 3 +- .../shadow_hand/agents/rsl_rl_ppo_cfg.py | 9 +- .../classic/ant/agents/rsl_rl_ppo_cfg.py | 3 +- .../classic/cartpole/agents/rsl_rl_ppo_cfg.py | 3 +- .../classic/cartpole/mdp/symmetry.py | 23 +++-- .../classic/humanoid/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/digit/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/a1/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/anymal_b/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/anymal_c/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/anymal_d/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/cassie/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/digit/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/g1/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/go1/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/go2/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/h1/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/spot/agents/rsl_rl_ppo_cfg.py | 3 +- .../velocity/mdp/symmetry/anymal.py | 61 ++++++-------- .../config/franka/agents/rsl_rl_ppo_cfg.py | 3 +- .../allegro_hand/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/franka/agents/rsl_rl_ppo_cfg.py | 3 +- .../config/franka/agents/rsl_rl_ppo_cfg.py | 4 +- .../config/ur_10/agents/rsl_rl_ppo_cfg.py | 4 +- .../config/anymal_c/agents/rsl_rl_ppo_cfg.py | 3 +- .../template/templates/agents/rsl_rl_ppo_cfg | 3 +- 43 files changed, 255 insertions(+), 171 deletions(-) diff --git a/.github/workflows/license-exceptions.json b/.github/workflows/license-exceptions.json index 6beb8dab54b2..231e5e470e8f 100644 --- a/.github/workflows/license-exceptions.json +++ b/.github/workflows/license-exceptions.json @@ -390,5 +390,10 @@ "package": "ml_dtypes", "license" : "UNKNOWN", "comment": "Apache 2.0" + }, + { + "package": "zipp", + "license" : "UNKNOWN", + "comment": "MIT" } ] diff --git a/isaaclab.bat b/isaaclab.bat index 6923c9ee9174..5780f5d83064 100644 --- a/isaaclab.bat +++ b/isaaclab.bat @@ -377,7 +377,6 @@ if "%arg%"=="-i" ( rem install the python packages in source directory echo [INFO] Installing extensions inside the Isaac Lab repository... call :extract_python_exe - rem check if pytorch is installed and its version rem install pytorch with cuda 12.8 for blackwell support call !python_exe! -m pip list | findstr /C:"torch" >nul diff --git a/scripts/reinforcement_learning/rsl_rl/cli_args.py b/scripts/reinforcement_learning/rsl_rl/cli_args.py index df7e5f0ff8b2..c176f774515c 100644 --- a/scripts/reinforcement_learning/rsl_rl/cli_args.py +++ b/scripts/reinforcement_learning/rsl_rl/cli_args.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg + from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg def add_rsl_rl_args(parser: argparse.ArgumentParser): @@ -39,7 +39,7 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser): ) -def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlOnPolicyRunnerCfg: +def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlBaseRunnerCfg: """Parse configuration for RSL-RL agent based on inputs. Args: @@ -52,12 +52,12 @@ def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlOnPol from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry # load the default configuration - rslrl_cfg: RslRlOnPolicyRunnerCfg = load_cfg_from_registry(task_name, "rsl_rl_cfg_entry_point") + rslrl_cfg: RslRlBaseRunnerCfg = load_cfg_from_registry(task_name, "rsl_rl_cfg_entry_point") rslrl_cfg = update_rsl_rl_cfg(rslrl_cfg, args_cli) return rslrl_cfg -def update_rsl_rl_cfg(agent_cfg: RslRlOnPolicyRunnerCfg, args_cli: argparse.Namespace): +def update_rsl_rl_cfg(agent_cfg: RslRlBaseRunnerCfg, args_cli: argparse.Namespace): """Update configuration for RSL-RL agent based on inputs. Args: diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index 150bbd034927..9e89c6ff318f 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -58,7 +58,7 @@ import time import torch -from rsl_rl.runners import OnPolicyRunner +from rsl_rl.runners import DistillationRunner, OnPolicyRunner from isaaclab.envs import ( DirectMARLEnv, @@ -71,7 +71,7 @@ from isaaclab.utils.dict import print_dict from isaaclab.utils.pretrained_checkpoint import get_published_pretrained_checkpoint -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlVecEnvWrapper, export_policy_as_jit, export_policy_as_onnx +from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper, export_policy_as_jit, export_policy_as_onnx import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import get_checkpoint_path @@ -81,14 +81,14 @@ @hydra_task_config(args_cli.task, args_cli.agent) -def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlOnPolicyRunnerCfg): +def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Play with RSL-RL agent.""" # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") # override configurations with non-hydra CLI arguments - agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) + agent_cfg: RslRlBaseRunnerCfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs # set the environment seed @@ -136,32 +136,43 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen print(f"[INFO]: Loading model checkpoint from: {resume_path}") # load previously trained model - ppo_runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) - ppo_runner.load(resume_path) + if agent_cfg.class_name == "OnPolicyRunner": + runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + elif agent_cfg.class_name == "DistillationRunner": + runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=None, device=agent_cfg.device) + else: + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") + runner.load(resume_path) # obtain the trained policy for inference - policy = ppo_runner.get_inference_policy(device=env.unwrapped.device) + policy = runner.get_inference_policy(device=env.unwrapped.device) # extract the neural network module # we do this in a try-except to maintain backwards compatibility. try: # version 2.3 onwards - policy_nn = ppo_runner.alg.policy + policy_nn = runner.alg.policy except AttributeError: # version 2.2 and below - policy_nn = ppo_runner.alg.actor_critic + policy_nn = runner.alg.actor_critic + + # extract the normalizer + if hasattr(policy_nn, "actor_obs_normalizer"): + normalizer = policy_nn.actor_obs_normalizer + elif hasattr(policy_nn, "student_obs_normalizer"): + normalizer = policy_nn.student_obs_normalizer + else: + normalizer = None # export policy to onnx/jit export_model_dir = os.path.join(os.path.dirname(resume_path), "exported") - export_policy_as_jit(policy_nn, ppo_runner.obs_normalizer, path=export_model_dir, filename="policy.pt") - export_policy_as_onnx( - policy_nn, normalizer=ppo_runner.obs_normalizer, path=export_model_dir, filename="policy.onnx" - ) + export_policy_as_jit(policy_nn, normalizer=normalizer, path=export_model_dir, filename="policy.pt") + export_policy_as_onnx(policy_nn, normalizer=normalizer, path=export_model_dir, filename="policy.onnx") dt = env.unwrapped.step_dt # reset environment - obs, _ = env.get_observations() + obs = env.get_observations() timestep = 0 # simulate environment while simulation_app.is_running(): diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index ff6ed50c333f..33bfc9f63d4a 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -15,7 +15,6 @@ # local imports import cli_args # isort: skip - # add argparse arguments parser = argparse.ArgumentParser(description="Train an RL agent with RSL-RL.") parser.add_argument("--video", action="store_true", default=False, help="Record videos during training.") @@ -56,10 +55,10 @@ from packaging import version -# for distributed training, check minimum supported rsl-rl version -RSL_RL_VERSION = "2.3.1" +# check minimum supported rsl-rl version +RSL_RL_VERSION = "3.0.1" installed_version = metadata.version("rsl-rl-lib") -if args_cli.distributed and version.parse(installed_version) < version.parse(RSL_RL_VERSION): +if version.parse(installed_version) < version.parse(RSL_RL_VERSION): if platform.system() == "Windows": cmd = [r".\isaaclab.bat", "-p", "-m", "pip", "install", f"rsl-rl-lib=={RSL_RL_VERSION}"] else: @@ -79,7 +78,7 @@ from datetime import datetime import omni -from rsl_rl.runners import OnPolicyRunner +from rsl_rl.runners import DistillationRunner, OnPolicyRunner from isaaclab.envs import ( DirectMARLEnv, @@ -91,7 +90,7 @@ from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_pickle, dump_yaml -from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlVecEnvWrapper +from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils import get_checkpoint_path @@ -106,7 +105,7 @@ @hydra_task_config(args_cli.task, args_cli.agent) -def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlOnPolicyRunnerCfg): +def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Train with RSL-RL agent.""" # override configurations with non-hydra CLI arguments agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) @@ -178,7 +177,12 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) # create runner from rsl-rl - runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) + if agent_cfg.class_name == "OnPolicyRunner": + runner = OnPolicyRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) + elif agent_cfg.class_name == "DistillationRunner": + runner = DistillationRunner(env, agent_cfg.to_dict(), log_dir=log_dir, device=agent_cfg.device) + else: + raise ValueError(f"Unsupported runner class: {agent_cfg.class_name}") # write git state to logs runner.add_git_repo_to_log(__file__) # load the checkpoint diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py index 3571511c3661..d4153d5cf2b0 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py @@ -28,6 +28,12 @@ class RslRlDistillationStudentTeacherCfg: noise_std_type: Literal["scalar", "log"] = "scalar" """The type of noise standard deviation for the policy. Default is scalar.""" + student_obs_normalization: bool = MISSING + """Whether to normalize the observation for the student network.""" + + teacher_obs_normalization: bool = MISSING + """Whether to normalize the observation for the teacher network.""" + student_hidden_dims: list[int] = MISSING """The hidden dimensions of the student network.""" @@ -81,3 +87,9 @@ class RslRlDistillationAlgorithmCfg: max_grad_norm: None | float = None """The maximum norm the gradient is clipped to.""" + + optimizer: Literal["adam", "adamw", "sgd", "rmsprop"] = "adam" + """The optimizer to use for the student policy.""" + + loss_type: Literal["mse", "huber"] = "mse" + """The loss type to use for the student policy.""" diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py index 81a00b1e7a6b..90ef6c026652 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py @@ -32,6 +32,12 @@ class RslRlPpoActorCriticCfg: noise_std_type: Literal["scalar", "log"] = "scalar" """The type of noise standard deviation for the policy. Default is scalar.""" + actor_obs_normalization: bool = MISSING + """Whether to normalize the observation for the actor network.""" + + critic_obs_normalization: bool = MISSING + """Whether to normalize the observation for the critic network.""" + actor_hidden_dims: list[int] = MISSING """The hidden dimensions of the actor network.""" @@ -114,14 +120,12 @@ class RslRlPpoAlgorithmCfg: Otherwise, the advantage is normalized over the entire collected trajectories. """ + rnd_cfg: RslRlRndCfg | None = None + """The RND configuration. Default is None, in which case RND is not used.""" + symmetry_cfg: RslRlSymmetryCfg | None = None """The symmetry configuration. Default is None, in which case symmetry is not used.""" - rnd_cfg: RslRlRndCfg | None = None - """The configuration for the Random Network Distillation (RND) module. Default is None, - in which case RND is not used. - """ - ######################### # Runner configurations # @@ -129,8 +133,8 @@ class RslRlPpoAlgorithmCfg: @configclass -class RslRlOnPolicyRunnerCfg: - """Configuration of the runner for on-policy algorithms.""" +class RslRlBaseRunnerCfg: + """Base configuration of the runner.""" seed: int = 42 """The seed for the experiment. Default is 42.""" @@ -144,17 +148,36 @@ class RslRlOnPolicyRunnerCfg: max_iterations: int = MISSING """The maximum number of iterations.""" - empirical_normalization: bool = MISSING - """Whether to use empirical normalization.""" + empirical_normalization: bool | None = None + """This parameter is deprecated and will be removed in the future. - policy: RslRlPpoActorCriticCfg | RslRlDistillationStudentTeacherCfg = MISSING - """The policy configuration.""" + Use `actor_obs_normalization` and `critic_obs_normalization` instead. + """ - algorithm: RslRlPpoAlgorithmCfg | RslRlDistillationAlgorithmCfg = MISSING - """The algorithm configuration.""" + obs_groups: dict[str, list[str]] = MISSING + """A mapping from observation groups to observation sets. + + The keys of the dictionary are predefined observation sets used by the underlying algorithm + and values are lists of observation groups provided by the environment. + + For instance, if the environment provides a dictionary of observations with groups "policy", "images", + and "privileged", these can be mapped to algorithmic observation sets as follows: + + .. code-block:: python + + obs_groups = { + "policy": ["policy", "images"], + "critic": ["policy", "privileged"], + } + + This way, the policy will receive the "policy" and "images" observations, and the critic will + receive the "policy" and "privileged" observations. + + For more details, please check ``vec_env.py`` in the rsl_rl library. + """ clip_actions: float | None = None - """The clipping value for actions. If ``None``, then no clipping is done. + """The clipping value for actions. If None, then no clipping is done. Defaults to None. .. note:: This clipping is performed inside the :class:`RslRlVecEnvWrapper` wrapper. @@ -184,7 +207,10 @@ class RslRlOnPolicyRunnerCfg: """The wandb project name. Default is "isaaclab".""" resume: bool = False - """Whether to resume. Default is False.""" + """Whether to resume a previous training. Default is False. + + This flag will be ignored for distillation. + """ load_run: str = ".*" """The run directory to load. Default is ".*" (all). @@ -197,3 +223,31 @@ class RslRlOnPolicyRunnerCfg: If regex expression, the latest (alphabetical order) matching file will be loaded. """ + + +@configclass +class RslRlOnPolicyRunnerCfg(RslRlBaseRunnerCfg): + """Configuration of the runner for on-policy algorithms.""" + + class_name: str = "OnPolicyRunner" + """The runner class name. Default is OnPolicyRunner.""" + + policy: RslRlPpoActorCriticCfg = MISSING + """The policy configuration.""" + + algorithm: RslRlPpoAlgorithmCfg = MISSING + """The algorithm configuration.""" + + +@configclass +class RslRlDistillationRunnerCfg(RslRlBaseRunnerCfg): + """Configuration of the runner for distillation algorithms.""" + + class_name: str = "DistillationRunner" + """The runner class name. Default is DistillationRunner.""" + + policy: RslRlDistillationStudentTeacherCfg = MISSING + """The policy configuration.""" + + algorithm: RslRlDistillationAlgorithmCfg = MISSING + """The algorithm configuration.""" diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py index bf0ecc9a829c..0cd476e848db 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/symmetry_cfg.py @@ -39,13 +39,11 @@ class RslRlSymmetryCfg: Args: env (VecEnv): The environment object. This is used to access the environment's properties. - obs (torch.Tensor | None): The observation tensor. If None, the observation is not used. + obs (tensordict.TensorDict | None): The observation tensor dictionary. If None, the observation is not used. action (torch.Tensor | None): The action tensor. If None, the action is not used. - obs_type (str): The name of the observation type. Defaults to "policy". - This is useful when handling augmentation for different observation groups. Returns: - A tuple containing the augmented observation and action tensors. The tensors can be None, + A tuple containing the augmented observation dictionary and action tensors. The tensors can be None, if their respective inputs are None. """ diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py index d909bf2d9128..73ceae04693b 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py @@ -5,6 +5,7 @@ import gymnasium as gym import torch +from tensordict import TensorDict from rsl_rl.env import VecEnv @@ -12,16 +13,9 @@ class RslRlVecEnvWrapper(VecEnv): - """Wraps around Isaac Lab environment for RSL-RL library - - To use asymmetric actor-critic, the environment instance must have the attributes :attr:`num_privileged_obs` (int). - This is used by the learning agent to allocate buffers in the trajectory memory. Additionally, the returned - observations should have the key "critic" which corresponds to the privileged observations. Since this is - optional for some environments, the wrapper checks if these attributes exist. If they don't then the wrapper - defaults to zero as number of privileged observations. + """Wraps around Isaac Lab environment for the RSL-RL library .. caution:: - This class must be the last wrapper in the wrapper chain. This is because the wrapper does not follow the :class:`gym.Wrapper` interface. Any subsequent wrappers will need to be modified to work with this wrapper. @@ -43,12 +37,14 @@ def __init__(self, env: ManagerBasedRLEnv | DirectRLEnv, clip_actions: float | N Raises: ValueError: When the environment is not an instance of :class:`ManagerBasedRLEnv` or :class:`DirectRLEnv`. """ + # check that input is valid if not isinstance(env.unwrapped, ManagerBasedRLEnv) and not isinstance(env.unwrapped, DirectRLEnv): raise ValueError( "The environment must be inherited from ManagerBasedRLEnv or DirectRLEnv. Environment type:" f" {type(env)}" ) + # initialize the wrapper self.env = env self.clip_actions = clip_actions @@ -63,20 +59,6 @@ def __init__(self, env: ManagerBasedRLEnv | DirectRLEnv, clip_actions: float | N self.num_actions = self.unwrapped.action_manager.total_action_dim else: self.num_actions = gym.spaces.flatdim(self.unwrapped.single_action_space) - if hasattr(self.unwrapped, "observation_manager"): - self.num_obs = self.unwrapped.observation_manager.group_obs_dim["policy"][0] - else: - self.num_obs = gym.spaces.flatdim(self.unwrapped.single_observation_space["policy"]) - # -- privileged observations - if ( - hasattr(self.unwrapped, "observation_manager") - and "critic" in self.unwrapped.observation_manager.group_obs_dim - ): - self.num_privileged_obs = self.unwrapped.observation_manager.group_obs_dim["critic"][0] - elif hasattr(self.unwrapped, "num_states") and "critic" in self.unwrapped.single_observation_space: - self.num_privileged_obs = gym.spaces.flatdim(self.unwrapped.single_observation_space["critic"]) - else: - self.num_privileged_obs = 0 # modify the action space to the clip range self._modify_action_space() @@ -133,14 +115,6 @@ def unwrapped(self) -> ManagerBasedRLEnv | DirectRLEnv: Properties """ - def get_observations(self) -> tuple[torch.Tensor, dict]: - """Returns the current observations of the environment.""" - if hasattr(self.unwrapped, "observation_manager"): - obs_dict = self.unwrapped.observation_manager.compute() - else: - obs_dict = self.unwrapped._get_observations() - return obs_dict["policy"], {"observations": obs_dict} - @property def episode_length_buf(self) -> torch.Tensor: """The episode length buffer.""" @@ -162,13 +136,20 @@ def episode_length_buf(self, value: torch.Tensor): def seed(self, seed: int = -1) -> int: # noqa: D102 return self.unwrapped.seed(seed) - def reset(self) -> tuple[torch.Tensor, dict]: # noqa: D102 + def reset(self) -> tuple[TensorDict, dict]: # noqa: D102 # reset the environment - obs_dict, _ = self.env.reset() - # return observations - return obs_dict["policy"], {"observations": obs_dict} + obs_dict, extras = self.env.reset() + return TensorDict(obs_dict, batch_size=[self.num_envs]), extras + + def get_observations(self) -> TensorDict: + """Returns the current observations of the environment.""" + if hasattr(self.unwrapped, "observation_manager"): + obs_dict = self.unwrapped.observation_manager.compute() + else: + obs_dict = self.unwrapped._get_observations() + return TensorDict(obs_dict, batch_size=[self.num_envs]) - def step(self, actions: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, dict]: + def step(self, actions: torch.Tensor) -> tuple[TensorDict, torch.Tensor, torch.Tensor, dict]: # clip actions if self.clip_actions is not None: actions = torch.clamp(actions, -self.clip_actions, self.clip_actions) @@ -176,16 +157,12 @@ def step(self, actions: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch obs_dict, rew, terminated, truncated, extras = self.env.step(actions) # compute dones for compatibility with RSL-RL dones = (terminated | truncated).to(dtype=torch.long) - # move extra observations to the extras dict - obs = obs_dict["policy"] - extras["observations"] = obs_dict # move time out information to the extras dict # this is only needed for infinite horizon tasks if not self.unwrapped.cfg.is_finite_horizon: extras["time_outs"] = truncated - # return the step information - return obs, rew, dones, extras + return TensorDict(obs_dict, batch_size=[self.num_envs]), rew, dones, extras def close(self): # noqa: D102 return self.env.close() @@ -200,7 +177,8 @@ def _modify_action_space(self): return # modify the action space to the clip range - # note: this is only possible for the box action space. we need to change it in the future for other action spaces. + # note: this is only possible for the box action space. we need to change it in the future for other + # action spaces. self.env.unwrapped.single_action_space = gym.spaces.Box( low=-self.clip_actions, high=self.clip_actions, shape=(self.num_actions,) ) diff --git a/source/isaaclab_rl/setup.py b/source/isaaclab_rl/setup.py index 706f2b529cd2..f9ddcdb0fa50 100644 --- a/source/isaaclab_rl/setup.py +++ b/source/isaaclab_rl/setup.py @@ -33,6 +33,7 @@ "moviepy", # make sure this is consistent with isaac sim version "pillow==11.2.1", + "packaging<24", ] PYTORCH_INDEX_URL = ["https://download.pytorch.org/whl/cu128"] @@ -45,7 +46,7 @@ "rl-games @ git+https://github.com/isaac-sim/rl_games.git@python3.11", "gym", ], # rl-games still needs gym :( - "rsl-rl": ["rsl-rl-lib==2.3.3"], + "rsl-rl": ["rsl-rl-lib==3.0.1"], } # Add the names with hyphens as aliases for convenience EXTRAS_REQUIRE["rl_games"] = EXTRAS_REQUIRE["rl-games"] diff --git a/source/isaaclab_rl/test/test_rsl_rl_wrapper.py b/source/isaaclab_rl/test/test_rsl_rl_wrapper.py index a88d4864fb20..4eaf921be85c 100644 --- a/source/isaaclab_rl/test/test_rsl_rl_wrapper.py +++ b/source/isaaclab_rl/test/test_rsl_rl_wrapper.py @@ -16,6 +16,7 @@ import gymnasium as gym import torch +from tensordict import TensorDict import carb import omni.usd @@ -161,6 +162,8 @@ def _check_valid_tensor(data: torch.Tensor | dict) -> bool: """ if isinstance(data, torch.Tensor): return not torch.any(torch.isnan(data)) + elif isinstance(data, TensorDict): + return not data.isnan().any() elif isinstance(data, dict): valid_tensor = True for value in data.values(): diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py index 6dd5f3c99f2d..8da27d1a7e00 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class AllegroHandPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 10000 save_interval = 250 experiment_name = "allegro_hand" - empirical_normalization = True policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, actor_hidden_dims=[1024, 512, 256, 128], critic_hidden_dims=[1024, 512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py index 38b42ea08cbd..5ea9520fec2c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/ant/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class AntPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 50 experiment_name = "ant_direct" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[400, 200, 100], critic_hidden_dims=[400, 200, 100], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py index 5c11cde53d2e..efdf7d4f991a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class AnymalCFlatPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 500 save_interval = 50 experiment_name = "anymal_c_flat_direct" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[128, 128, 128], critic_hidden_dims=[128, 128, 128], activation="elu", @@ -43,9 +44,10 @@ class AnymalCRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_c_rough_direct" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py index 81f77fcbd7ac..1cadf22d48c0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class CartpolePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 150 save_interval = 50 experiment_name = "cartpole_direct" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[32, 32], critic_hidden_dims=[32, 32], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py index 797777f90056..74788e7b220c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class FrankaCabinetPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "franka_cabinet_direct" - empirical_normalization = True policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, actor_hidden_dims=[256, 128, 64], critic_hidden_dims=[256, 128, 64], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py index ebbbdb6990cb..029629225092 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class HumanoidPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 50 experiment_name = "humanoid_direct" - empirical_normalization = True policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, actor_hidden_dims=[400, 200, 100], critic_hidden_dims=[400, 200, 100], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py index dae0dee0bf5e..86b2c5508382 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class QuadcopterPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 200 save_interval = 50 experiment_name = "quadcopter_direct" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[64, 64], critic_hidden_dims=[64, 64], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py index 524a799bae37..665c997e635d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class ShadowHandPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 10000 save_interval = 250 experiment_name = "shadow_hand" - empirical_normalization = True policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, actor_hidden_dims=[512, 512, 256, 128], critic_hidden_dims=[512, 512, 256, 128], activation="elu", @@ -43,9 +44,10 @@ class ShadowHandAsymFFPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 10000 save_interval = 250 experiment_name = "shadow_hand_openai_ff" - empirical_normalization = True policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, actor_hidden_dims=[400, 400, 200, 100], critic_hidden_dims=[512, 512, 256, 128], activation="elu", @@ -72,9 +74,10 @@ class ShadowHandVisionFFPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 50000 save_interval = 250 experiment_name = "shadow_hand_vision" - empirical_normalization = True policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, actor_hidden_dims=[1024, 512, 512, 256, 128], critic_hidden_dims=[1024, 512, 512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py index 7d729795f163..5257b0508681 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class AntPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 50 experiment_name = "ant" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[400, 200, 100], critic_hidden_dims=[400, 200, 100], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py index f80815b97e38..86ab5309c362 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/agents/rsl_rl_ppo_cfg.py @@ -16,9 +16,10 @@ class CartpolePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 150 save_interval = 50 experiment_name = "cartpole" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[32, 32], critic_hidden_dims=[32, 32], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/mdp/symmetry.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/mdp/symmetry.py index 8b13bf7c017f..5bf81c900578 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/mdp/symmetry.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/mdp/symmetry.py @@ -8,6 +8,7 @@ from __future__ import annotations import torch +from tensordict import TensorDict from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -20,9 +21,8 @@ @torch.no_grad() def compute_symmetric_states( env: ManagerBasedRLEnv, - obs: torch.Tensor | None = None, + obs: TensorDict | None = None, actions: torch.Tensor | None = None, - obs_type: str = "policy", ): """Augments the given observations and actions by applying symmetry transformations. @@ -33,9 +33,8 @@ def compute_symmetric_states( Args: env: The environment instance. - obs: The original observation tensor. Defaults to None. + obs: The original observation tensor dictionary. Defaults to None. actions: The original actions tensor. Defaults to None. - obs_type: The type of observation to augment. Defaults to "policy". Returns: Augmented observations and actions tensors, or None if the respective input was None. @@ -43,25 +42,25 @@ def compute_symmetric_states( # observations if obs is not None: - num_envs = obs.shape[0] + batch_size = obs.batch_size[0] # since we have 2 different symmetries, we need to augment the batch size by 2 - obs_aug = torch.zeros(num_envs * 2, obs.shape[1], device=obs.device) + obs_aug = obs.repeat(2) # -- original - obs_aug[:num_envs] = obs[:] + obs_aug["policy"][:batch_size] = obs["policy"][:] # -- left-right - obs_aug[num_envs : 2 * num_envs] = -obs + obs_aug["policy"][batch_size : 2 * batch_size] = -obs["policy"] else: obs_aug = None # actions if actions is not None: - num_envs = actions.shape[0] + batch_size = actions.shape[0] # since we have 4 different symmetries, we need to augment the batch size by 4 - actions_aug = torch.zeros(num_envs * 2, actions.shape[1], device=actions.device) + actions_aug = torch.zeros(batch_size * 2, actions.shape[1], device=actions.device) # -- original - actions_aug[:num_envs] = actions[:] + actions_aug[:batch_size] = actions[:] # -- left-right - actions_aug[num_envs : 2 * num_envs] = -actions + actions_aug[batch_size : 2 * batch_size] = -actions else: actions_aug = None diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py index ae44b8085a1d..663012f94f03 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class HumanoidPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1000 save_interval = 50 experiment_name = "humanoid" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[400, 200, 100], critic_hidden_dims=[400, 200, 100], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py index cb898b1e89c6..942a5230f1d7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class DigitLocoManipPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 2000 save_interval = 50 experiment_name = "digit_loco_manip" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[256, 128, 128], critic_hidden_dims=[256, 128, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py index 99c53ce9d7a7..db162f1228fc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class UnitreeA1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "unitree_a1_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py index 7e89bf7acd4e..b92ccac2e794 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/agents/rsl_rl_ppo_cfg.py @@ -16,9 +16,10 @@ class AnymalBRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_b_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py index aa620d940309..507f602c3c57 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -16,9 +16,10 @@ class AnymalCRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_c_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py index b1db4f60f8a4..c5b2c1c1848d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_ppo_cfg.py @@ -16,9 +16,10 @@ class AnymalDRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_d_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py index 9c57f001af14..719f8a241051 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/cassie/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class CassieRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "cassie_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py index ab23e2c7b71c..00be11a490f7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class DigitRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 3000 save_interval = 50 experiment_name = "digit_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py index 39e93c7dd9eb..946490165380 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class G1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 3000 save_interval = 50 experiment_name = "g1_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py index 47301907c398..5be515ccc0d6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class UnitreeGo1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "unitree_go1_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py index caeafe6bc4a8..e0c6afab9ea6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class UnitreeGo2RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "unitree_go2_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py index 39d80f892f25..1163ac744c46 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class H1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 3000 save_interval = 50 experiment_name = "h1_rough" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py index 155864175c25..951fb421cfce 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/agents/rsl_rl_ppo_cfg.py @@ -14,10 +14,11 @@ class SpotFlatPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 20000 save_interval = 50 experiment_name = "spot_flat" - empirical_normalization = False store_code_state = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/symmetry/anymal.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/symmetry/anymal.py index 2a3f4564fb87..7d2db8fa7fff 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/symmetry/anymal.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/mdp/symmetry/anymal.py @@ -9,6 +9,7 @@ from __future__ import annotations import torch +from tensordict import TensorDict from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -21,9 +22,8 @@ @torch.no_grad() def compute_symmetric_states( env: ManagerBasedRLEnv, - obs: torch.Tensor | None = None, + obs: TensorDict | None = None, actions: torch.Tensor | None = None, - obs_type: str = "policy", ): """Augments the given observations and actions by applying symmetry transformations. @@ -34,9 +34,8 @@ def compute_symmetric_states( Args: env: The environment instance. - obs: The original observation tensor. Defaults to None. + obs: The original observation tensor dictionary. Defaults to None. actions: The original actions tensor. Defaults to None. - obs_type: The type of observation to augment. Defaults to "policy". Returns: Augmented observations and actions tensors, or None if the respective input was None. @@ -44,33 +43,39 @@ def compute_symmetric_states( # observations if obs is not None: - num_envs = obs.shape[0] + batch_size = obs.batch_size[0] # since we have 4 different symmetries, we need to augment the batch size by 4 - obs_aug = torch.zeros(num_envs * 4, obs.shape[1], device=obs.device) + obs_aug = obs.repeat(4) + + # policy observation group # -- original - obs_aug[:num_envs] = obs[:] + obs_aug["policy"][:batch_size] = obs["policy"][:] # -- left-right - obs_aug[num_envs : 2 * num_envs] = _transform_obs_left_right(env.unwrapped, obs, obs_type) + obs_aug["policy"][batch_size : 2 * batch_size] = _transform_policy_obs_left_right(env.unwrapped, obs["policy"]) # -- front-back - obs_aug[2 * num_envs : 3 * num_envs] = _transform_obs_front_back(env.unwrapped, obs, obs_type) + obs_aug["policy"][2 * batch_size : 3 * batch_size] = _transform_policy_obs_front_back( + env.unwrapped, obs["policy"] + ) # -- diagonal - obs_aug[3 * num_envs :] = _transform_obs_front_back(env.unwrapped, obs_aug[num_envs : 2 * num_envs]) + obs_aug["policy"][3 * batch_size :] = _transform_policy_obs_front_back( + env.unwrapped, obs_aug["policy"][batch_size : 2 * batch_size] + ) else: obs_aug = None # actions if actions is not None: - num_envs = actions.shape[0] + batch_size = actions.shape[0] # since we have 4 different symmetries, we need to augment the batch size by 4 - actions_aug = torch.zeros(num_envs * 4, actions.shape[1], device=actions.device) + actions_aug = torch.zeros(batch_size * 4, actions.shape[1], device=actions.device) # -- original - actions_aug[:num_envs] = actions[:] + actions_aug[:batch_size] = actions[:] # -- left-right - actions_aug[num_envs : 2 * num_envs] = _transform_actions_left_right(actions) + actions_aug[batch_size : 2 * batch_size] = _transform_actions_left_right(actions) # -- front-back - actions_aug[2 * num_envs : 3 * num_envs] = _transform_actions_front_back(actions) + actions_aug[2 * batch_size : 3 * batch_size] = _transform_actions_front_back(actions) # -- diagonal - actions_aug[3 * num_envs :] = _transform_actions_front_back(actions_aug[num_envs : 2 * num_envs]) + actions_aug[3 * batch_size :] = _transform_actions_front_back(actions_aug[batch_size : 2 * batch_size]) else: actions_aug = None @@ -82,7 +87,7 @@ def compute_symmetric_states( """ -def _transform_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor, obs_type: str = "policy") -> torch.Tensor: +def _transform_policy_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor) -> torch.Tensor: """Apply a left-right symmetry transformation to the observation tensor. This function modifies the given observation tensor by applying transformations @@ -95,7 +100,6 @@ def _transform_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor, obs_typ Args: env: The environment instance from which the observation is obtained. obs: The observation tensor to be transformed. - obs_type: The type of observation to augment. Defaults to "policy". Returns: The transformed observation tensor with left-right symmetry applied. @@ -118,21 +122,14 @@ def _transform_obs_left_right(env: ManagerBasedRLEnv, obs: torch.Tensor, obs_typ # last actions obs[:, 36:48] = _switch_anymal_joints_left_right(obs[:, 36:48]) - # height-scan - if obs_type == "critic": - # handle asymmetric actor-critic formulation - group_name = "critic" if "critic" in env.observation_manager.active_terms else "policy" - else: - group_name = "policy" - # note: this is hard-coded for grid-pattern of ordering "xy" and size (1.6, 1.0) - if "height_scan" in env.observation_manager.active_terms[group_name]: + if "height_scan" in env.observation_manager.active_terms["policy"]: obs[:, 48:235] = obs[:, 48:235].view(-1, 11, 17).flip(dims=[1]).view(-1, 11 * 17) return obs -def _transform_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor, obs_type: str = "policy") -> torch.Tensor: +def _transform_policy_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor) -> torch.Tensor: """Applies a front-back symmetry transformation to the observation tensor. This function modifies the given observation tensor by applying transformations @@ -144,7 +141,6 @@ def _transform_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor, obs_typ Args: env: The environment instance from which the observation is obtained. obs: The observation tensor to be transformed. - obs_type: The type of observation to augment. Defaults to "policy". Returns: The transformed observation tensor with front-back symmetry applied. @@ -167,15 +163,8 @@ def _transform_obs_front_back(env: ManagerBasedRLEnv, obs: torch.Tensor, obs_typ # last actions obs[:, 36:48] = _switch_anymal_joints_front_back(obs[:, 36:48]) - # height-scan - if obs_type == "critic": - # handle asymmetric actor-critic formulation - group_name = "critic" if "critic" in env.observation_manager.active_terms else "policy" - else: - group_name = "policy" - # note: this is hard-coded for grid-pattern of ordering "xy" and size (1.6, 1.0) - if "height_scan" in env.observation_manager.active_terms[group_name]: + if "height_scan" in env.observation_manager.active_terms["policy"]: obs[:, 48:235] = obs[:, 48:235].view(-1, 11, 17).flip(dims=[2]).view(-1, 11 * 17) return obs diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py index 99a4730f8357..ee642fb07aa8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class CabinetPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 400 save_interval = 50 experiment_name = "franka_open_drawer" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[256, 128, 64], critic_hidden_dims=[256, 128, 64], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py index c3471f192036..4cbe6266f240 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class AllegroCubePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 5000 save_interval = 50 experiment_name = "allegro_cube" - empirical_normalization = True policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=True, + critic_obs_normalization=True, actor_hidden_dims=[512, 256, 128], critic_hidden_dims=[512, 256, 128], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py index 3d519e926b4b..067425a74d48 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class LiftCubePPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "franka_lift" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[256, 128, 64], critic_hidden_dims=[256, 128, 64], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py index 1b51d812d96c..24bea7c5ac14 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/agents/rsl_rl_ppo_cfg.py @@ -15,10 +15,10 @@ class FrankaReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): save_interval = 50 experiment_name = "franka_reach" run_name = "" - resume = False - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[64, 64], critic_hidden_dims=[64, 64], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py index 287b4ec95f81..1b55830a64ea 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/agents/rsl_rl_ppo_cfg.py @@ -15,10 +15,10 @@ class UR10ReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): save_interval = 50 experiment_name = "reach_ur10" run_name = "" - resume = False - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[64, 64], critic_hidden_dims=[64, 64], activation="elu", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py index 1ea1a61dba05..4b23def89b2f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/agents/rsl_rl_ppo_cfg.py @@ -14,9 +14,10 @@ class NavigationEnvPPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 1500 save_interval = 50 experiment_name = "anymal_c_navigation" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=0.5, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[128, 128], critic_hidden_dims=[128, 128], activation="elu", diff --git a/tools/template/templates/agents/rsl_rl_ppo_cfg b/tools/template/templates/agents/rsl_rl_ppo_cfg index eaeaf78bfc04..85970dfc2ce4 100644 --- a/tools/template/templates/agents/rsl_rl_ppo_cfg +++ b/tools/template/templates/agents/rsl_rl_ppo_cfg @@ -14,9 +14,10 @@ class PPORunnerCfg(RslRlOnPolicyRunnerCfg): max_iterations = 150 save_interval = 50 experiment_name = "cartpole_direct" - empirical_normalization = False policy = RslRlPpoActorCriticCfg( init_noise_std=1.0, + actor_obs_normalization=False, + critic_obs_normalization=False, actor_hidden_dims=[32, 32], critic_hidden_dims=[32, 32], activation="elu", From dddd51dbaad98f6a99cd4cd0b63ee9ad31b55959 Mon Sep 17 00:00:00 2001 From: Willbon <60505354+binw666@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:56:26 +0800 Subject: [PATCH 14/47] Adds YAML Resource Specification To Ray Integration (#2847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR: - Add task_runner.py to support specifying resources, py_modules, and pip. Fixes [# (issue)](https://github.com/isaac-sim/IsaacLab/issues/2632) ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: garylvov <67614381+garylvov@users.noreply.github.com> Co-authored-by: 松翊 Co-authored-by: garylvov <67614381+garylvov@users.noreply.github.com> --- CONTRIBUTORS.md | 1 + docs/source/features/ray.rst | 41 ++- .../reinforcement_learning/ray/submit_job.py | 25 +- .../reinforcement_learning/ray/task_runner.py | 230 +++++++++++++++++ scripts/reinforcement_learning/ray/util.py | 241 +++++++++++++++++- .../ray/wrap_resources.py | 58 +++-- 6 files changed, 554 insertions(+), 42 deletions(-) create mode 100644 scripts/reinforcement_learning/ray/task_runner.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index bde83712b642..b652b4b54bb7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -140,6 +140,7 @@ Guidelines for modifications: * Ziqi Fan * Zoe McCarthy * David Leon +* Song Yi ## Acknowledgements diff --git a/docs/source/features/ray.rst b/docs/source/features/ray.rst index a804bf483d67..98367fac1746 100644 --- a/docs/source/features/ray.rst +++ b/docs/source/features/ray.rst @@ -44,22 +44,28 @@ specifying the ``--num_workers`` argument for resource-wrapped jobs, or ``--num_ for tuning jobs, which is especially critical for parallel aggregate job processing on local/virtual multi-GPU machines. Tuning jobs assume homogeneous node resource composition for nodes with GPUs. -The two following files contain the core functionality of the Ray integration. +The three following files contain the core functionality of the Ray integration. .. dropdown:: scripts/reinforcement_learning/ray/wrap_resources.py :icon: code .. literalinclude:: ../../../scripts/reinforcement_learning/ray/wrap_resources.py :language: python - :emphasize-lines: 14-66 + :emphasize-lines: 10-63 .. dropdown:: scripts/reinforcement_learning/ray/tuner.py :icon: code .. literalinclude:: ../../../scripts/reinforcement_learning/ray/tuner.py :language: python - :emphasize-lines: 18-53 + :emphasize-lines: 18-54 +.. dropdown:: scripts/reinforcement_learning/ray/task_runner.py + :icon: code + + .. literalinclude:: ../../../scripts/reinforcement_learning/ray/task_runner.py + :language: python + :emphasize-lines: 13-105 The following script can be used to submit aggregate jobs to one or more Ray cluster(s), which can be used for @@ -71,7 +77,7 @@ resource requirements. .. literalinclude:: ../../../scripts/reinforcement_learning/ray/submit_job.py :language: python - :emphasize-lines: 12-53 + :emphasize-lines: 13-61 The following script can be used to extract KubeRay cluster information for aggregate job submission. @@ -89,7 +95,7 @@ The following script can be used to easily create clusters on Google GKE. .. literalinclude:: ../../../scripts/reinforcement_learning/ray/launch.py :language: python - :emphasize-lines: 16-37 + :emphasize-lines: 15-36 Docker-based Local Quickstart ----------------------------- @@ -147,7 +153,26 @@ Submitting resource-wrapped individual jobs instead of automatic tuning runs is .. literalinclude:: ../../../scripts/reinforcement_learning/ray/wrap_resources.py :language: python - :emphasize-lines: 14-66 + :emphasize-lines: 10-63 + +The ``task_runner.py`` dispatches Python tasks to a Ray cluster via a single declarative YAML file. This approach allows users to specify additional pip packages and Python modules for each run. Fine-grained resource allocation is supported, with explicit control over the number of CPUs, GPUs, and memory assigned to each task. The runner also offers advanced scheduling capabilities: tasks can be restricted to specific nodes by hostname or node ID, and supports two launch modes: tasks can be executed independently as resources become available, or grouped into a simultaneous batch—ideal for multi-node training jobs—which ensures that all tasks launch together only when sufficient resources are available across the cluster. + +.. dropdown:: scripts/reinforcement_learning/ray/task_runner.py + :icon: code + + .. literalinclude:: ../../../scripts/reinforcement_learning/ray/task_runner.py + :language: python + :emphasize-lines: 13-105 + +To use this script, run a command similar to the following (replace ``tasks.yaml`` with your actual configuration file): + +.. code-block:: bash + + python3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs task_runner.py --task_cfg tasks.yaml + +For detailed instructions on how to write your ``tasks.yaml`` file, please refer to the comments in ``task_runner.py``. + +**Tip:** Place the ``tasks.yaml`` file in the ``scripts/reinforcement_learning/ray`` directory so that it is included when the ``working_dir`` is uploaded. You can then reference it using a relative path in the command. Transferring files from the running container can be done as follows. @@ -288,7 +313,7 @@ where instructions are included in the following creation file. .. literalinclude:: ../../../scripts/reinforcement_learning/ray/launch.py :language: python - :emphasize-lines: 15-37 + :emphasize-lines: 15-36 For other cloud services, the ``kuberay.yaml.ninja`` will be similar to that of Google's. @@ -345,7 +370,7 @@ Dispatching Steps Shared Between KubeRay and Pure Ray Part II .. literalinclude:: ../../../scripts/reinforcement_learning/ray/submit_job.py :language: python - :emphasize-lines: 12-53 + :emphasize-lines: 13-61 3.) For tuning jobs, specify the tuning job / hyperparameter sweep as a :class:`JobCfg` . The included :class:`JobCfg` only supports the ``rl_games`` workflow due to differences in diff --git a/scripts/reinforcement_learning/ray/submit_job.py b/scripts/reinforcement_learning/ray/submit_job.py index 27c00eda71fb..b02d92537e93 100644 --- a/scripts/reinforcement_learning/ray/submit_job.py +++ b/scripts/reinforcement_learning/ray/submit_job.py @@ -3,13 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -import argparse -import os -import time -from concurrent.futures import ThreadPoolExecutor - -from ray import job_submission - """ This script submits aggregate job(s) to cluster(s) described in a config file containing ``name: address: http://:`` on @@ -26,7 +19,11 @@ creates several individual jobs when started on a cluster. Alternatively, an aggregate job could be a :file:'../wrap_resources.py` resource-wrapped job, which may contain several individual sub-jobs separated by -the + delimiter. +the + delimiter. An aggregate job could also be a :file:`../task_runner.py` multi-task submission job, +where each sub-job and its resource requirements are defined in a YAML configuration file. +In this mode, :file:`../task_runner.py` will read the YAML file (via --task_cfg), and +submit all defined sub-tasks to the Ray cluster, supporting per-job resource specification and +real-time streaming of sub-job outputs. If there are more aggregate jobs than cluster(s), aggregate jobs will be submitted as clusters become available via the defined relation above. If there are less aggregate job(s) @@ -48,9 +45,21 @@ # Example: Submitting resource wrapped job python3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs wrap_resources.py --test + # Example: submitting tasks with specific resources, and supporting pip packages and py_modules + # You may use relative paths for task_cfg and py_modules, placing them in the scripts/reinforcement_learning/ray directory, which will be uploaded to the cluster. + python3 scripts/reinforcement_learning/ray/submit_job.py --aggregate_jobs task_runner.py --task_cfg tasks.yaml + # For all command line arguments python3 scripts/reinforcement_learning/ray/submit_job.py -h """ + +import argparse +import os +import time +from concurrent.futures import ThreadPoolExecutor + +from ray import job_submission + script_directory = os.path.dirname(os.path.abspath(__file__)) CONFIG = {"working_dir": script_directory, "executable": "/workspace/isaaclab/isaaclab.sh -p"} diff --git a/scripts/reinforcement_learning/ray/task_runner.py b/scripts/reinforcement_learning/ray/task_runner.py new file mode 100644 index 000000000000..43e369ff218e --- /dev/null +++ b/scripts/reinforcement_learning/ray/task_runner.py @@ -0,0 +1,230 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +""" +This script dispatches one or more user-defined Python tasks to workers in a Ray cluster. +Each task, along with its resource requirements and execution parameters, is specified in a YAML configuration file. +Users may define the number of CPUs, GPUs, and the amount of memory to allocate per task via the config file. + +Key features: +------------- +- Fine-grained, per-task resource management via config fields (`num_gpus`, `num_cpus`, `memory`). +- Parallel execution of multiple tasks using available resources across the Ray cluster. +- Option to specify node affinity for tasks, e.g., by hostname, node ID, or any node. +- Optional batch (simultaneous) or independent scheduling of tasks. + +Task scheduling and distribution are handled via Ray’s built-in resource manager. + +YAML configuration fields: +-------------------------- +- `pip`: List of extra pip packages to install before running any tasks. +- `py_modules`: List of additional Python module paths (directories or files) to include in the runtime environment. +- `concurrent`: (bool) It determines task dispatch semantics: + - If `concurrent: true`, **all tasks are scheduled as a batch**. The script waits until sufficient resources are available for every task in the batch, then launches all tasks together. If resources are insufficient, all tasks remain blocked until the cluster can support the full batch. + - If `concurrent: false`, tasks are launched as soon as resources are available for each individual task, and Ray independently schedules them. This may result in non-simultaneous task start times. +- `tasks`: List of task specifications, each with: + - `name`: String identifier for the task. + - `py_args`: Arguments to the Python interpreter (e.g., script/module, flags, user arguments). + - `num_gpus`: Number of GPUs to allocate (float or string arithmetic, e.g., "2*2"). + - `num_cpus`: Number of CPUs to allocate (float or string). + - `memory`: Amount of RAM in bytes (int or string). + - `node` (optional): Node placement constraints. + - `specific` (str): Type of node placement, support `hostname`, `node_id`, or `any`. + - `any`: Place the task on any available node. + - `hostname`: Place the task on a specific hostname. `hostname` must be specified in the node field. + - `node_id`: Place the task on a specific node ID. `node_id` must be specified in the node field. + - `hostname` (str): Specific hostname to place the task on. + - `node_id` (str): Specific node ID to place the task on. + + +Typical usage: +--------------- + +.. code-block:: bash + + # Print help and argument details: + python task_runner.py -h + + # Submit tasks defined in a YAML file to the Ray cluster (auto-detects Ray head address): + python task_runner.py --task_cfg /path/to/tasks.yaml + +YAML configuration example-1: +--------------------------- +.. code-block:: yaml + + pip: ["xxx"] + py_modules: ["my_package/my_package"] + concurrent: false + tasks: + - name: "Isaac-Cartpole-v0" + py_args: "-m torch.distributed.run --nnodes=1 --nproc_per_node=2 --rdzv_endpoint=localhost:29501 /workspace/isaaclab/scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Cartpole-v0 --max_iterations 200 --headless --distributed" + num_gpus: 2 + num_cpus: 10 + memory: 10737418240 + - name: "script need some dependencies" + py_args: "script.py --option arg" + num_gpus: 0 + num_cpus: 1 + memory: 10*1024*1024*1024 + +YAML configuration example-2: +--------------------------- +.. code-block:: yaml + + pip: ["xxx"] + py_modules: ["my_package/my_package"] + concurrent: true + tasks: + - name: "Isaac-Cartpole-v0-multi-node-train-1" + py_args: "-m torch.distributed.run --nproc_per_node=1 --nnodes=2 --node_rank=0 --rdzv_id=123 --rdzv_backend=c10d --rdzv_endpoint=localhost:5555 /workspace/isaaclab/scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Cartpole-v0 --headless --distributed --max_iterations 1000" + num_gpus: 1 + num_cpus: 10 + memory: 10*1024*1024*1024 + node: + specific: "hostname" + hostname: "xxx" + - name: "Isaac-Cartpole-v0-multi-node-train-2" + py_args: "-m torch.distributed.run --nproc_per_node=1 --nnodes=2 --node_rank=1 --rdzv_id=123 --rdzv_backend=c10d --rdzv_endpoint=x.x.x.x:5555 /workspace/isaaclab/scripts/reinforcement_learning/rsl_rl/train.py --task=Isaac-Cartpole-v0 --headless --distributed --max_iterations 1000" + num_gpus: 1 + num_cpus: 10 + memory: 10*1024*1024*1024 + node: + specific: "hostname" + hostname: "xxx" + +To stop all tasks early, press Ctrl+C; the script will cancel all running Ray tasks. +""" + +import argparse +import yaml +from datetime import datetime + +import util + + +def parse_args() -> argparse.Namespace: + """ + Parse command-line arguments for the Ray task runner. + + Returns: + argparse.Namespace: The namespace containing parsed CLI arguments: + - task_cfg (str): Path to the YAML task file. + - ray_address (str): Ray cluster address. + - test (bool): Whether to run a GPU resource isolation sanity check. + """ + parser = argparse.ArgumentParser(description="Run tasks from a YAML config file.") + parser.add_argument("--task_cfg", type=str, required=True, help="Path to the YAML task file.") + parser.add_argument("--ray_address", type=str, default="auto", help="the Ray address.") + parser.add_argument( + "--test", + action="store_true", + help=( + "Run nvidia-smi test instead of the arbitrary job," + "can use as a sanity check prior to any jobs to check " + "that GPU resources are correctly isolated." + ), + ) + return parser.parse_args() + + +def parse_task_resource(task: dict) -> util.JobResource: + """ + Parse task resource requirements from the YAML configuration. + + Args: + task (dict): Dictionary representing a single task's configuration. + Keys may include `num_gpus`, `num_cpus`, and `memory`, each either + as a number or evaluatable string expression. + + Returns: + util.JobResource: Resource object with the parsed values. + """ + resource = util.JobResource() + if "num_gpus" in task: + resource.num_gpus = eval(task["num_gpus"]) if isinstance(task["num_gpus"], str) else task["num_gpus"] + if "num_cpus" in task: + resource.num_cpus = eval(task["num_cpus"]) if isinstance(task["num_cpus"], str) else task["num_cpus"] + if "memory" in task: + resource.memory = eval(task["memory"]) if isinstance(task["memory"], str) else task["memory"] + return resource + + +def run_tasks( + tasks: list[dict], args: argparse.Namespace, runtime_env: dict | None = None, concurrent: bool = False +) -> None: + """ + Submit tasks to the Ray cluster for execution. + + Args: + tasks (list[dict]): A list of task configuration dictionaries. + args (argparse.Namespace): Parsed command-line arguments. + runtime_env (dict | None): Ray runtime environment configuration containing: + - pip (list[str] | None): Additional pip packages to install. + - py_modules (list[str] | None): Python modules to include in the environment. + concurrent (bool): Whether to launch tasks simultaneously as a batch, + or independently as resources become available. + + Returns: + None + """ + job_objs = [] + util.ray_init(ray_address=args.ray_address, runtime_env=runtime_env, log_to_driver=False) + for task in tasks: + resource = parse_task_resource(task) + print(f"[INFO] Creating job {task['name']} with resource={resource}") + job = util.Job( + name=task["name"], + py_args=task["py_args"], + resources=resource, + node=util.JobNode( + specific=task.get("node", {}).get("specific"), + hostname=task.get("node", {}).get("hostname"), + node_id=task.get("node", {}).get("node_id"), + ), + ) + job_objs.append(job) + start = datetime.now() + print(f"[INFO] Creating {len(job_objs)} jobs at {start.strftime('%H:%M:%S.%f')} with runtime env={runtime_env}") + # submit jobs + util.submit_wrapped_jobs( + jobs=job_objs, + test_mode=args.test, + concurrent=concurrent, + ) + end = datetime.now() + print( + f"[INFO] All jobs completed at {end.strftime('%H:%M:%S.%f')}, took {(end - start).total_seconds():.2f} seconds." + ) + + +def main() -> None: + """ + Main entry point for the Ray task runner script. + + Reads the YAML task configuration file, parses CLI arguments, + and dispatches tasks to the Ray cluster. + + Returns: + None + """ + args = parse_args() + with open(args.task_cfg) as f: + config = yaml.safe_load(f) + tasks = config["tasks"] + runtime_env = { + "pip": None if not config.get("pip") else config["pip"], + "py_modules": None if not config.get("py_modules") else config["py_modules"], + } + concurrent = config.get("concurrent", False) + run_tasks( + tasks=tasks, + args=args, + runtime_env=runtime_env, + concurrent=concurrent, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/reinforcement_learning/ray/util.py b/scripts/reinforcement_learning/ray/util.py index 3501e44b130d..ebc67dc568ed 100644 --- a/scripts/reinforcement_learning/ray/util.py +++ b/scripts/reinforcement_learning/ray/util.py @@ -7,12 +7,17 @@ import re import select import subprocess +import sys import threading +from collections.abc import Sequence +from dataclasses import dataclass from datetime import datetime from math import isclose from time import time +from typing import Any import ray +from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy from tensorboard.backend.event_processing.directory_watcher import DirectoryDeletedError from tensorboard.backend.event_processing.event_accumulator import EventAccumulator @@ -307,12 +312,21 @@ def stream_reader(stream, identifier_string, result_details): return " ".join(result_details) +def ray_init(ray_address: str = "auto", runtime_env: dict[str, Any] | None = None, log_to_driver: bool = False): + """Initialize Ray with the given address and runtime environment.""" + if not ray.is_initialized(): + print( + f"[INFO] Initializing Ray with address {ray_address}, log_to_driver={log_to_driver}," + f" runtime_env={runtime_env}" + ) + ray.init(address=ray_address, runtime_env=runtime_env, log_to_driver=log_to_driver) + + def get_gpu_node_resources( total_resources: bool = False, one_node_only: bool = False, include_gb_ram: bool = False, include_id: bool = False, - ray_address: str = "auto", ) -> list[dict] | dict: """Get information about available GPU node resources. @@ -329,8 +343,7 @@ def get_gpu_node_resources( or simply the resource for a single node if requested. """ if not ray.is_initialized(): - ray.init(address=ray_address) - + raise Exception("Ray is not initialized. Please initialize Ray before getting node resources.") nodes = ray.nodes() node_resources = [] total_cpus = 0 @@ -481,3 +494,225 @@ def _dicts_equal(d1: dict, d2: dict, tol=1e-9) -> bool: elif d1[key] != d2[key]: return False return True + + +@dataclass +class JobResource: + """A dataclass to represent a resource request for a job.""" + + num_gpus: float | None = None + num_cpus: float | None = None + memory: int | None = None # in bytes + + def to_opt(self) -> dict[str, Any]: + """Convert the resource request to a dictionary.""" + opt = {} + if self.num_gpus is not None: + opt["num_gpus"] = self.num_gpus + if self.num_cpus is not None: + opt["num_cpus"] = self.num_cpus + if self.memory is not None: + opt["memory"] = self.memory + return opt + + def to_pg_resources(self) -> dict[str, Any]: + """Convert the resource request to a dictionary suitable for placement groups.""" + res = {} + if self.num_gpus is not None: + res["GPU"] = self.num_gpus + if self.num_cpus is not None: + res["CPU"] = self.num_cpus + if self.memory is not None: + res["memory"] = self.memory + return res + + +@dataclass +class JobNode: + """A dataclass to represent a node for job affinity.""" + + specific: str | None = None + hostname: str | None = None + node_id: str | None = None + + def to_opt(self, nodes: list[dict[str, Any]]) -> dict[str, Any]: + """ + Convert node affinity settings into a dictionary of Ray actor scheduling options. + + Args: + nodes (list[dict[str, Any]]): List of node metadata from `ray.nodes()` which looks like this: + [{ + 'NodeID': 'xxx', + 'Alive': True, + 'NodeManagerAddress': 'x.x.x.x', + 'NodeManagerHostname': 'ray-head-mjzzf', + 'NodeManagerPort': 44039, + 'ObjectManagerPort': 35689, + 'ObjectStoreSocketName': '/tmp/ray/session_xxx/sockets/plasma_store', + 'RayletSocketName': '/tmp/ray/session_xxx/sockets/raylet', + 'MetricsExportPort': 8080, + 'NodeName': 'x.x.x.x', + 'RuntimeEnvAgentPort': 63725, + 'DeathReason': 0, + 'DeathReasonMessage': '', + 'alive': True, + 'Resources': { + 'node:__internal_head__': 1.0, + 'object_store_memory': 422449279795.0, + 'memory': 1099511627776.0, + 'GPU': 8.0, + 'node:x.x.x.x': 1.0, + 'CPU': 192.0, + 'accelerator_type:H20': 1.0 + }, + 'Labels': { + 'ray.io/node_id': 'xxx' + } + },...] + + Returns: + dict[str, Any]: A dictionary with possible scheduling options: + - Empty if no specific placement requirement. + - "scheduling_strategy" key set to `NodeAffinitySchedulingStrategy` + if hostname or node_id placement is specified. + + Raises: + ValueError: If hostname/node_id is specified but not found in the cluster + or the node is not alive. + """ + opt = {} + if self.specific is None or self.specific == "any": + return opt + elif self.specific == "hostname": + if self.hostname is None: + raise ValueError("Hostname must be specified when specific is 'hostname'") + for node in nodes: + if node["NodeManagerHostname"] == self.hostname: + if node["alive"] is False: + raise ValueError(f"Node {node['NodeID']} is not alive") + opt["scheduling_strategy"] = NodeAffinitySchedulingStrategy(node_id=node["NodeID"], soft=False) + return opt + raise ValueError(f"Hostname {self.hostname} not found in nodes: {nodes}") + elif self.specific == "node_id": + if self.node_id is None: + raise ValueError("Node ID must be specified when specific is 'node_id'") + for node in nodes: + if node["NodeID"] == self.node_id: + if node["alive"] is False: + raise ValueError(f"Node {node['NodeID']} is not alive") + opt["scheduling_strategy"] = NodeAffinitySchedulingStrategy(node_id=node["NodeID"], soft=False) + return opt + raise ValueError(f"Node ID {self.node_id} not found in nodes: {nodes}") + else: + raise ValueError(f"Invalid specific value: {self.specific}. Must be 'any', 'hostname', or 'node_id'.") + + +@dataclass +class Job: + """A dataclass to represent a job to be submitted to Ray.""" + + # job command + cmd: str | None = None + py_args: str | None = None + # identifier string for the job, e.g., "job 0" + name: str = "" + # job resources, e.g., {"CPU": 4, "GPU": 1} + resources: JobResource | None = None + # specify the node to run the job on, if needed to run on a specific node + node: JobNode | None = None + + def to_opt(self, nodes: list[dict[str, Any]]) -> dict[str, Any]: + """ + Convert the job definition into a dictionary of Ray scheduling options. + + Args: + nodes (list[dict[str, Any]]): Node information from `ray.nodes()`. + + Returns: + dict[str, Any]: Combined scheduling options from: + - `JobResource.to_opt()` for resource requirements + - `JobNode.to_opt()` for node placement constraints + """ + opt = {} + if self.resources is not None: + opt.update(self.resources.to_opt()) + if self.node is not None: + opt.update(self.node.to_opt(nodes)) + return opt + + +@ray.remote +class JobActor: + """Actor to run job in Ray cluster.""" + + def __init__(self, job: Job, test_mode: bool, log_all_output: bool, extract_experiment: bool = False): + self.job = job + self.test_mode = test_mode + self.log_all_output = log_all_output + self.extract_experiment = extract_experiment + self.done = True + + def ready(self) -> bool: + """Check if the job is ready to run.""" + return self.done + + def run(self): + """Run the job.""" + cmd = self.job.cmd if self.job.cmd else " ".join([sys.executable, *self.job.py_args.split()]) + return execute_job( + job_cmd=cmd, + identifier_string=self.job.name, + test_mode=self.test_mode, + extract_experiment=self.extract_experiment, + log_all_output=self.log_all_output, + ) + + +def submit_wrapped_jobs( + jobs: Sequence[Job], + log_realtime: bool = True, + test_mode: bool = False, + concurrent: bool = False, +) -> None: + """ + Submit a list of jobs to the Ray cluster and manage their execution. + + Args: + jobs (Sequence[Job]): A sequence of Job objects to execute on Ray. + log_realtime (bool): Whether to log stdout/stderr in real-time. Defaults to True. + test_mode (bool): If True, run in GPU sanity-check mode instead of actual jobs. Defaults to False. + concurrent (bool): Whether to launch tasks simultaneously as a batch, + or independently as resources become available. Defaults to False. + + Returns: + None + """ + if jobs is None or len(jobs) == 0: + print("[WARNING]: No jobs to submit") + return + if not ray.is_initialized(): + raise Exception("Ray is not initialized. Please initialize Ray before submitting jobs.") + nodes = ray.nodes() + actors = [] + for i, job in enumerate(jobs): + opts = job.to_opt(nodes) + name = job.name or f"job_{i + 1}" + print(f"[INFO] Create {name} with opts={opts}") + job_actor = JobActor.options(**opts).remote(job, test_mode, log_realtime) + actors.append(job_actor) + try: + if concurrent: + ray.get([actor.ready.remote() for actor in actors]) + print("[INFO] All actors are ready to run.") + future = [actor.run.remote() for actor in actors] + while future: + ready, not_ready = ray.wait(future, timeout=5) + for result in ray.get(ready): + print(f"\n{result}\n") + future = not_ready + print("[INFO] all jobs completed.") + except KeyboardInterrupt: + print("[INFO] KeyboardInterrupt received, cancelling …") + for actor in actors: + ray.cancel(actor, force=True) + sys.exit(0) diff --git a/scripts/reinforcement_learning/ray/wrap_resources.py b/scripts/reinforcement_learning/ray/wrap_resources.py index b2f1049e1e9b..75333ddcde07 100644 --- a/scripts/reinforcement_learning/ray/wrap_resources.py +++ b/scripts/reinforcement_learning/ray/wrap_resources.py @@ -3,12 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -import argparse - -import ray -import util -from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy - """ This script dispatches sub-job(s) (individual jobs, use :file:`tuner.py` for tuning jobs) to worker(s) on GPU-enabled node(s) of a specific cluster as part of an resource-wrapped aggregate @@ -64,6 +58,10 @@ ./isaaclab.sh -p scripts/reinforcement_learning/ray/wrap_resources.py -h """ +import argparse + +import util + def wrap_resources_to_jobs(jobs: list[str], args: argparse.Namespace) -> None: """ @@ -75,9 +73,14 @@ def wrap_resources_to_jobs(jobs: list[str], args: argparse.Namespace) -> None: args: The arguments for resource allocation """ - if not ray.is_initialized(): - ray.init(address=args.ray_address, log_to_driver=True) - job_results = [] + job_objs = [] + util.ray_init( + ray_address=args.ray_address, + runtime_env={ + "py_modules": None if not args.py_modules else args.py_modules, + }, + log_to_driver=False, + ) gpu_node_resources = util.get_gpu_node_resources(include_id=True, include_gb_ram=True) if any([args.gpu_per_worker, args.cpu_per_worker, args.ram_gb_per_worker]) and args.num_workers: @@ -97,7 +100,7 @@ def wrap_resources_to_jobs(jobs: list[str], args: argparse.Namespace) -> None: jobs = ["nvidia-smi"] * num_nodes for i, job in enumerate(jobs): gpu_node = gpu_node_resources[i % num_nodes] - print(f"[INFO]: Submitting job {i + 1} of {len(jobs)} with job '{job}' to node {gpu_node}") + print(f"[INFO]: Creating job {i + 1} of {len(jobs)} with job '{job}' to node {gpu_node}") print( f"[INFO]: Resource parameters: GPU: {args.gpu_per_worker[i]}" f" CPU: {args.cpu_per_worker[i]} RAM {args.ram_gb_per_worker[i]}" @@ -106,19 +109,19 @@ def wrap_resources_to_jobs(jobs: list[str], args: argparse.Namespace) -> None: num_gpus = args.gpu_per_worker[i] / args.num_workers[i] num_cpus = args.cpu_per_worker[i] / args.num_workers[i] memory = (args.ram_gb_per_worker[i] * 1024**3) / args.num_workers[i] - print(f"[INFO]: Requesting {num_gpus=} {num_cpus=} {memory=} id={gpu_node['id']}") - job = util.remote_execute_job.options( - num_gpus=num_gpus, - num_cpus=num_cpus, - memory=memory, - scheduling_strategy=NodeAffinitySchedulingStrategy(gpu_node["id"], soft=False), - ).remote(job, f"Job {i}", args.test) - job_results.append(job) - - results = ray.get(job_results) - for i, result in enumerate(results): - print(f"[INFO]: Job {i} result: {result}") - print("[INFO]: All jobs completed.") + job_objs.append( + util.Job( + cmd=job, + name=f"Job-{i + 1}", + resources=util.JobResource(num_gpus=num_gpus, num_cpus=num_cpus, memory=memory), + node=util.JobNode( + specific="node_id", + node_id=gpu_node["id"], + ), + ) + ) + # submit jobs + util.submit_wrapped_jobs(jobs=job_objs, test_mode=args.test, concurrent=False) if __name__ == "__main__": @@ -134,6 +137,15 @@ def wrap_resources_to_jobs(jobs: list[str], args: argparse.Namespace) -> None: "that GPU resources are correctly isolated." ), ) + parser.add_argument( + "--py_modules", + type=str, + nargs="*", + default=[], + help=( + "List of python modules or paths to add before running the job. Example: --py_modules my_package/my_package" + ), + ) parser.add_argument( "--sub_jobs", type=str, From 2f9298d78c11cf5803bb909cd4420e64a4e50aec Mon Sep 17 00:00:00 2001 From: ooctipus Date: Tue, 2 Sep 2025 19:34:22 -0700 Subject: [PATCH 15/47] Simplifies cross platform installation setup.py (#3294) # Description This PR 1. makes sure(skip if already satisfy, else install) the right torch is installed before and after pip installing isaaclab packages as sometime(rare case) due to flaky setup.py and unknown library dependencies changes pytorch version gets overriden. 2. only install pink and retargeters in linux x86 or amd64 machines ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Mayank Mittal <12863862+Mayankm96@users.noreply.github.com> --- isaaclab.bat | 73 +++++++++++++++++++++------------------- isaaclab.sh | 43 ++++++++++++++--------- source/isaaclab/setup.py | 15 +++++---- 3 files changed, 75 insertions(+), 56 deletions(-) diff --git a/isaaclab.bat b/isaaclab.bat index 5780f5d83064..0a6cd9f73617 100644 --- a/isaaclab.bat +++ b/isaaclab.bat @@ -38,6 +38,33 @@ if not exist "%isaac_path%" ( ) goto :eof +rem --- Ensure CUDA PyTorch helper ------------------------------------------ +:ensure_cuda_torch +rem expects: !python_exe! set by :extract_python_exe +setlocal EnableExtensions EnableDelayedExpansion +set "TORCH_VER=2.7.0" +set "TV_VER=0.22.0" +set "CUDA_TAG=cu128" +set "PYTORCH_INDEX=https://download.pytorch.org/whl/%CUDA_TAG%" + +rem Do we already have torch? +call "!python_exe!" -m pip show torch >nul 2>&1 +if errorlevel 1 ( + echo [INFO] Installing PyTorch !TORCH_VER! with CUDA !CUDA_TAG!... + call "!python_exe!" -m pip install "torch==!TORCH_VER!" "torchvision==!TV_VER!" --index-url "!PYTORCH_INDEX!" +) else ( + for /f "tokens=2" %%V in ('"!python_exe!" -m pip show torch ^| findstr /B /C:"Version:"') do set "TORCH_CUR=%%V" + echo [INFO] Found PyTorch version !TORCH_CUR!. + if /I not "!TORCH_CUR!"=="!TORCH_VER!+!CUDA_TAG!" ( + echo [INFO] Replacing PyTorch !TORCH_CUR! -> !TORCH_VER!+!CUDA_TAG!... + call "!python_exe!" -m pip uninstall -y torch torchvision torchaudio >nul 2>&1 + call "!python_exe!" -m pip install "torch==!TORCH_VER!" "torchvision==!TV_VER!" --index-url "!PYTORCH_INDEX!" + ) else ( + echo [INFO] PyTorch !TORCH_VER!+!CUDA_TAG! already installed. + ) +) +endlocal & exit /b 0 + rem ----------------------------------------------------------------------- rem Returns success (exit code 0) if Isaac Sim's version starts with "4.5" rem ----------------------------------------------------------------------- @@ -334,23 +361,7 @@ if "%arg%"=="-i" ( call :extract_python_exe rem check if pytorch is installed and its version rem install pytorch with cuda 12.8 for blackwell support - call !python_exe! -m pip list | findstr /C:"torch" >nul - if %errorlevel% equ 0 ( - for /f "tokens=2" %%i in ('!python_exe! -m pip show torch ^| findstr /C:"Version:"') do ( - set torch_version=%%i - ) - if not "!torch_version!"=="2.7.0+cu128" ( - echo [INFO] Uninstalling PyTorch version !torch_version!... - call !python_exe! -m pip uninstall -y torch torchvision torchaudio - echo [INFO] Installing PyTorch 2.7.0 with CUDA 12.8 support... - call !python_exe! -m pip install torch==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu128 - ) else ( - echo [INFO] PyTorch 2.7.0 is already installed. - ) - ) else ( - echo [INFO] Installing PyTorch 2.7.0 with CUDA 12.8 support... - call !python_exe! -m pip install torch==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu128 - ) + call :ensure_cuda_torch for /d %%d in ("%ISAACLAB_PATH%\source\*") do ( set ext_folder="%%d" @@ -372,6 +383,13 @@ if "%arg%"=="-i" ( ) rem install the rl-frameworks specified call !python_exe! -m pip install -e %ISAACLAB_PATH%\source\isaaclab_rl[!framework_name!] + rem in rare case if some packages or flaky setup override default torch installation, ensure right torch is + rem installed again + call :ensure_cuda_torch + rem update the vscode settings + rem once we have a docker container, we need to disable vscode settings + call :update_vscode_settings + shift shift ) else if "%arg%"=="--install" ( rem install the python packages in source directory @@ -379,23 +397,7 @@ if "%arg%"=="-i" ( call :extract_python_exe rem check if pytorch is installed and its version rem install pytorch with cuda 12.8 for blackwell support - call !python_exe! -m pip list | findstr /C:"torch" >nul - if %errorlevel% equ 0 ( - for /f "tokens=2" %%i in ('!python_exe! -m pip show torch ^| findstr /C:"Version:"') do ( - set torch_version=%%i - ) - if not "!torch_version!"=="2.7.0+cu128" ( - echo [INFO] Uninstalling PyTorch version !torch_version!... - call !python_exe! -m pip uninstall -y torch torchvision torchaudio - echo [INFO] Installing PyTorch 2.7.0 with CUDA 12.8 support... - call !python_exe! -m pip install torch==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu128 - ) else ( - echo [INFO] PyTorch 2.7.0 is already installed. - ) - ) else ( - echo [INFO] Installing PyTorch 2.7.0 with CUDA 12.8 support... - call !python_exe! -m pip install torch==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu128 - ) + call :ensure_cuda_torch for /d %%d in ("%ISAACLAB_PATH%\source\*") do ( set ext_folder="%%d" @@ -417,6 +419,9 @@ if "%arg%"=="-i" ( ) rem install the rl-frameworks specified call !python_exe! -m pip install -e %ISAACLAB_PATH%\source\isaaclab_rl[!framework_name!] + rem in rare case if some packages or flaky setup override default torch installation, ensure right torch is + rem installed again + call :ensure_cuda_torch rem update the vscode settings rem once we have a docker container, we need to disable vscode settings call :update_vscode_settings diff --git a/isaaclab.sh b/isaaclab.sh index fed536e680a4..3c8cf33a05d0 100755 --- a/isaaclab.sh +++ b/isaaclab.sh @@ -96,6 +96,30 @@ is_docker() { [[ "$(hostname)" == *"."* ]] } +ensure_cuda_torch() { + local py="$1" + local -r TORCH_VER="2.7.0" + local -r TV_VER="0.22.0" + local -r CUDA_TAG="cu128" + local -r PYTORCH_INDEX="https://download.pytorch.org/whl/${CUDA_TAG}" + local torch_ver + + if "$py" -m pip show torch >/dev/null 2>&1; then + torch_ver="$("$py" -m pip show torch 2>/dev/null | awk -F': ' '/^Version/{print $2}')" + echo "[INFO] Found PyTorch version ${torch_ver}." + if [[ "$torch_ver" != "${TORCH_VER}+${CUDA_TAG}" ]]; then + echo "[INFO] Replacing PyTorch ${torch_ver} → ${TORCH_VER}+${CUDA_TAG}..." + "$py" -m pip uninstall -y torch torchvision torchaudio >/dev/null 2>&1 || true + "$py" -m pip install "torch==${TORCH_VER}" "torchvision==${TV_VER}" --index-url "${PYTORCH_INDEX}" + else + echo "[INFO] PyTorch ${TORCH_VER}+${CUDA_TAG} already installed." + fi + else + echo "[INFO] Installing PyTorch ${TORCH_VER}+${CUDA_TAG}..." + "$py" -m pip install "torch==${TORCH_VER}" "torchvision==${TV_VER}" --index-url "${PYTORCH_INDEX}" + fi +} + # extract isaac sim path extract_isaacsim_path() { # Use the sym-link path to Isaac Sim directory @@ -364,21 +388,7 @@ while [[ $# -gt 0 ]]; do python_exe=$(extract_python_exe) # check if pytorch is installed and its version # install pytorch with cuda 12.8 for blackwell support - if ${python_exe} -m pip list 2>/dev/null | grep -q "torch"; then - torch_version=$(${python_exe} -m pip show torch 2>/dev/null | grep "Version:" | awk '{print $2}') - echo "[INFO] Found PyTorch version ${torch_version} installed." - if [[ "${torch_version}" != "2.7.0+cu128" ]]; then - echo "[INFO] Uninstalling PyTorch version ${torch_version}..." - ${python_exe} -m pip uninstall -y torch torchvision torchaudio - echo "[INFO] Installing PyTorch 2.7.0 with CUDA 12.8 support..." - ${python_exe} -m pip install torch==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu128 - else - echo "[INFO] PyTorch 2.7.0 is already installed." - fi - else - echo "[INFO] Installing PyTorch 2.7.0 with CUDA 12.8 support..." - ${python_exe} -m pip install torch==2.7.0 torchvision==0.22.0 --index-url https://download.pytorch.org/whl/cu128 - fi + ensure_cuda_torch ${python_exe} # recursively look into directories and install them # this does not check dependencies between extensions export -f extract_python_exe @@ -404,6 +414,9 @@ while [[ $# -gt 0 ]]; do ${python_exe} -m pip install -e ${ISAACLAB_PATH}/source/isaaclab_rl["${framework_name}"] ${python_exe} -m pip install -e ${ISAACLAB_PATH}/source/isaaclab_mimic["${framework_name}"] + # in some rare cases, torch might not be installed properly by setup.py, add one more check here + # can prevent that from happening + ensure_cuda_torch ${python_exe} # check if we are inside a docker container or are building a docker image # in that case don't setup VSCode since it asks for EULA agreement which triggers user interaction if is_docker; then diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index 5231c1bdaff6..c78f98172455 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -6,7 +6,6 @@ """Installation script for the 'isaaclab' python package.""" import os -import platform import toml from setuptools import setup @@ -47,12 +46,14 @@ "flaky", ] -# Additional dependencies that are only available on Linux platforms -if platform.system() == "Linux": - INSTALL_REQUIRES += [ - "pin-pink==3.1.0", # required by isaaclab.isaaclab.controllers.pink_ik - "dex-retargeting==0.4.6", # required by isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1_t2_dex_retargeting_utils - ] +# Append Linux x86_64–only deps via PEP 508 markers +X64 = "platform_machine in 'x86_64,AMD64'" +INSTALL_REQUIRES += [ + # required by isaaclab.isaaclab.controllers.pink_ik + f"pin-pink==3.1.0 ; platform_system == 'Linux' and ({X64})", + # required by isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1_t2_dex_retargeting_utils + f"dex-retargeting==0.4.6 ; platform_system == 'Linux' and ({X64})", +] PYTORCH_INDEX_URL = ["https://download.pytorch.org/whl/cu128"] From 5dae83eb921dad478d87f21d4db154d8e0cd68fe Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:58:28 -0400 Subject: [PATCH 16/47] Fixes the reach task regression with teleop devices returning the gripper (#3327) Fixes the reach task regression with teleop devices returning the gripper term # Description Fixes the reach task regression with teleop devices returning the gripper. The reach task expects just the se3 term and not the gripper term. We add a configuration parameter to the teleop devices which do not use retargeters to conditional return the gripper term, and update the reach env cfg to properly configure the teleop devices. Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes #3264 ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (fix or feature that would cause existing functionality to not work as expected) - This change requires a documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: rwiltz <165190220+rwiltz@users.noreply.github.com> Co-authored-by: Kelly Guo --- .github/workflows/license-exceptions.json | 5 +++++ source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 15 +++++++++++++ .../isaaclab/devices/gamepad/se3_gamepad.py | 10 ++++++--- .../isaaclab/devices/keyboard/se3_keyboard.py | 10 ++++++--- .../devices/spacemouse/se3_spacemouse.py | 10 ++++++--- .../manipulation/reach/reach_env_cfg.py | 21 +++++++++++++++++++ 7 files changed, 63 insertions(+), 10 deletions(-) diff --git a/.github/workflows/license-exceptions.json b/.github/workflows/license-exceptions.json index 231e5e470e8f..66530033efae 100644 --- a/.github/workflows/license-exceptions.json +++ b/.github/workflows/license-exceptions.json @@ -395,5 +395,10 @@ "package": "zipp", "license" : "UNKNOWN", "comment": "MIT" + }, + { + "package": "fsspec", + "license" : "UNKNOWN", + "comment": "BSD" } ] diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index ae3aaf088ff1..e0066c7685de 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.9" +version = "0.45.10" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 2b6345aca4a6..591805d37e1f 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,21 @@ Changelog --------- +0.45.10 (2025-09-02) +~~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed regression in reach task configuration where the gripper command was being returned. +* Added :attr:`~isaaclab.devices.Se3GamepadCfg.gripper_term` to :class:`~isaaclab.devices.Se3GamepadCfg` + to control whether the gamepad device should return a gripper command. +* Added :attr:`~isaaclab.devices.Se3SpaceMouseCfg.gripper_term` to :class:`~isaaclab.devices.Se3SpaceMouseCfg` + to control whether the spacemouse device should return a gripper command. +* Added :attr:`~isaaclab.devices.Se3KeyboardCfg.gripper_term` to :class:`~isaaclab.devices.Se3KeyboardCfg` + to control whether the keyboard device should return a gripper command. + + 0.45.9 (2025-08-27) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py b/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py index 24f3b0ef3874..f30dc8357e09 100644 --- a/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py +++ b/source/isaaclab/isaaclab/devices/gamepad/se3_gamepad.py @@ -22,6 +22,7 @@ class Se3GamepadCfg(DeviceCfg): """Configuration for SE3 gamepad devices.""" + gripper_term: bool = True dead_zone: float = 0.01 # For gamepad devices pos_sensitivity: float = 1.0 rot_sensitivity: float = 1.6 @@ -75,6 +76,7 @@ def __init__( self.pos_sensitivity = cfg.pos_sensitivity self.rot_sensitivity = cfg.rot_sensitivity self.dead_zone = cfg.dead_zone + self.gripper_term = cfg.gripper_term self._sim_device = cfg.sim_device # acquire omniverse interfaces self._appwindow = omni.appwindow.get_default_app_window() @@ -155,9 +157,11 @@ def advance(self) -> torch.Tensor: # -- convert to rotation vector rot_vec = Rotation.from_euler("XYZ", delta_rot).as_rotvec() # return the command and gripper state - gripper_value = -1.0 if self._close_gripper else 1.0 - delta_pose = np.concatenate([delta_pos, rot_vec]) - command = np.append(delta_pose, gripper_value) + command = np.concatenate([delta_pos, rot_vec]) + if self.gripper_term: + gripper_value = -1.0 if self._close_gripper else 1.0 + command = np.append(command, gripper_value) + return torch.tensor(command, dtype=torch.float32, device=self._sim_device) """ diff --git a/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard.py b/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard.py index 49dd02db3005..64e398ad14e9 100644 --- a/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard.py +++ b/source/isaaclab/isaaclab/devices/keyboard/se3_keyboard.py @@ -22,6 +22,7 @@ class Se3KeyboardCfg(DeviceCfg): """Configuration for SE3 keyboard devices.""" + gripper_term: bool = True pos_sensitivity: float = 0.4 rot_sensitivity: float = 0.8 retargeters: None = None @@ -67,6 +68,7 @@ def __init__(self, cfg: Se3KeyboardCfg): # store inputs self.pos_sensitivity = cfg.pos_sensitivity self.rot_sensitivity = cfg.rot_sensitivity + self.gripper_term = cfg.gripper_term self._sim_device = cfg.sim_device # acquire omniverse interfaces self._appwindow = omni.appwindow.get_default_app_window() @@ -139,9 +141,11 @@ def advance(self) -> torch.Tensor: # convert to rotation vector rot_vec = Rotation.from_euler("XYZ", self._delta_rot).as_rotvec() # return the command and gripper state - gripper_value = -1.0 if self._close_gripper else 1.0 - delta_pose = np.concatenate([self._delta_pos, rot_vec]) - command = np.append(delta_pose, gripper_value) + command = np.concatenate([self._delta_pos, rot_vec]) + if self.gripper_term: + gripper_value = -1.0 if self._close_gripper else 1.0 + command = np.append(command, gripper_value) + return torch.tensor(command, dtype=torch.float32, device=self._sim_device) """ diff --git a/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py b/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py index 54a1aebcea2b..092844ef114c 100644 --- a/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py +++ b/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py @@ -22,6 +22,7 @@ class Se3SpaceMouseCfg(DeviceCfg): """Configuration for SE3 space mouse devices.""" + gripper_term: bool = True pos_sensitivity: float = 0.4 rot_sensitivity: float = 0.8 retargeters: None = None @@ -58,6 +59,7 @@ def __init__(self, cfg: Se3SpaceMouseCfg): # store inputs self.pos_sensitivity = cfg.pos_sensitivity self.rot_sensitivity = cfg.rot_sensitivity + self.gripper_term = cfg.gripper_term self._sim_device = cfg.sim_device # acquire device interface self._device = hid.device() @@ -122,9 +124,11 @@ def advance(self) -> torch.Tensor: - gripper command: Last element as a binary value (+1.0 for open, -1.0 for close). """ rot_vec = Rotation.from_euler("XYZ", self._delta_rot).as_rotvec() - delta_pose = np.concatenate([self._delta_pos, rot_vec]) - gripper_value = -1.0 if self._close_gripper else 1.0 - command = np.append(delta_pose, gripper_value) + command = np.concatenate([self._delta_pos, rot_vec]) + if self.gripper_term: + gripper_value = -1.0 if self._close_gripper else 1.0 + command = np.append(command, gripper_value) + return torch.tensor(command, dtype=torch.float32, device=self._sim_device) """ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py index 6cee38aa2a6b..8890010a71be 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/reach_env_cfg.py @@ -7,6 +7,10 @@ import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.devices import DevicesCfg +from isaaclab.devices.gamepad import Se3GamepadCfg +from isaaclab.devices.keyboard import Se3KeyboardCfg +from isaaclab.devices.spacemouse import Se3SpaceMouseCfg from isaaclab.envs import ManagerBasedRLEnvCfg from isaaclab.managers import ActionTermCfg as ActionTerm from isaaclab.managers import CurriculumTermCfg as CurrTerm @@ -206,3 +210,20 @@ def __post_init__(self): self.viewer.eye = (3.5, 3.5, 3.5) # simulation settings self.sim.dt = 1.0 / 60.0 + + self.teleop_devices = DevicesCfg( + devices={ + "keyboard": Se3KeyboardCfg( + gripper_term=False, + sim_device=self.sim.device, + ), + "gamepad": Se3GamepadCfg( + gripper_term=False, + sim_device=self.sim.device, + ), + "spacemouse": Se3SpaceMouseCfg( + gripper_term=False, + sim_device=self.sim.device, + ), + }, + ) From e57da49397fbcde5ecee501479f048957a598c22 Mon Sep 17 00:00:00 2001 From: Mayank Mittal <12863862+Mayankm96@users.noreply.github.com> Date: Thu, 4 Sep 2025 17:15:03 +0200 Subject: [PATCH 17/47] Moves parameter `platform_height` to the correct mesh terrain configuration (#3316) # Description This PR fixes a bug where the platform_height parameter was incorrectly placed in the MeshPyramidStairsTerrainCfg class instead of the appropriate base configuration class for mesh terrain objects. - Removes the misplaced `platform_height` parameter from `MeshPyramidStairsTerrainCfg` - Adds the `platform_height` parameter to the correct location in the `MeshRepeatedObjectsTerrainCfg` class - Includes various formatting improvements with additional blank lines for consistency Fixes #3162 Regression was introduced in MR https://github.com/isaac-sim/IsaacLab/pull/2695 ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../terrains/height_field/hf_terrains_cfg.py | 23 +++++++++++-- .../terrains/trimesh/mesh_terrains_cfg.py | 32 +++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py b/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py index 731b878dadba..8fd49077aa26 100644 --- a/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py +++ b/source/isaaclab/isaaclab/terrains/height_field/hf_terrains_cfg.py @@ -21,10 +21,13 @@ class HfTerrainBaseCfg(SubTerrainBaseCfg): The border width is subtracted from the :obj:`size` of the terrain. If non-zero, it must be greater than or equal to the :obj:`horizontal scale`. """ + horizontal_scale: float = 0.1 """The discretization of the terrain along the x and y axes (in m). Defaults to 0.1.""" + vertical_scale: float = 0.005 """The discretization of the terrain along the z axis (in m). Defaults to 0.005.""" + slope_threshold: float | None = None """The slope threshold above which surfaces are made vertical. Defaults to None, in which case no correction is applied.""" @@ -43,8 +46,10 @@ class HfRandomUniformTerrainCfg(HfTerrainBaseCfg): noise_range: tuple[float, float] = MISSING """The minimum and maximum height noise (i.e. along z) of the terrain (in m).""" + noise_step: float = MISSING """The minimum height (in m) change between two points.""" + downsampled_scale: float | None = None """The distance between two randomly sampled points on the terrain. Defaults to None, in which case the :obj:`horizontal scale` is used. @@ -62,8 +67,10 @@ class HfPyramidSlopedTerrainCfg(HfTerrainBaseCfg): slope_range: tuple[float, float] = MISSING """The slope of the terrain (in radians).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" + inverted: bool = False """Whether the pyramid is inverted. Defaults to False. @@ -92,10 +99,13 @@ class HfPyramidStairsTerrainCfg(HfTerrainBaseCfg): step_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the steps (in m).""" + step_width: float = MISSING """The width of the steps (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" + inverted: bool = False """Whether the pyramid stairs is inverted. Defaults to False. @@ -127,12 +137,16 @@ class HfDiscreteObstaclesTerrainCfg(HfTerrainBaseCfg): The following modes are supported: "choice", "fixed". """ + obstacle_width_range: tuple[float, float] = MISSING """The minimum and maximum width of the obstacles (in m).""" + obstacle_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the obstacles (in m).""" + num_obstacles: int = MISSING """The number of obstacles to generate.""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" @@ -145,8 +159,9 @@ class HfWaveTerrainCfg(HfTerrainBaseCfg): amplitude_range: tuple[float, float] = MISSING """The minimum and maximum amplitude of the wave (in m).""" - num_waves: int = 1.0 - """The number of waves to generate. Defaults to 1.0.""" + + num_waves: int = 1 + """The number of waves to generate. Defaults to 1.""" @configclass @@ -157,11 +172,15 @@ class HfSteppingStonesTerrainCfg(HfTerrainBaseCfg): stone_height_max: float = MISSING """The maximum height of the stones (in m).""" + stone_width_range: tuple[float, float] = MISSING """The minimum and maximum width of the stones (in m).""" + stone_distance_range: tuple[float, float] = MISSING """The minimum and maximum distance between stones (in m).""" + holes_depth: float = -10.0 """The depth of the holes (negative obstacles). Defaults to -10.0.""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" diff --git a/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py b/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py index 3c4ca81d52f2..103aa18424dc 100644 --- a/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py +++ b/source/isaaclab/isaaclab/terrains/trimesh/mesh_terrains_cfg.py @@ -36,16 +36,16 @@ class MeshPyramidStairsTerrainCfg(SubTerrainBaseCfg): The border is a flat terrain with the same height as the terrain. """ + step_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the steps (in m).""" + step_width: float = MISSING """The width of the steps (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" - platform_height: float = -1.0 - """The height of the platform. Defaults to -1.0. - If the value is negative, the height is the same as the object height.""" holes: bool = False """If True, the terrain will have holes in the steps. Defaults to False. @@ -74,10 +74,13 @@ class MeshRandomGridTerrainCfg(SubTerrainBaseCfg): grid_width: float = MISSING """The width of the grid cells (in m).""" + grid_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the grid cells (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" + holes: bool = False """If True, the terrain will have holes in the steps. Defaults to False. @@ -94,8 +97,10 @@ class MeshRailsTerrainCfg(SubTerrainBaseCfg): rail_thickness_range: tuple[float, float] = MISSING """The thickness of the inner and outer rails (in m).""" + rail_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the rails (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" @@ -108,8 +113,10 @@ class MeshPitTerrainCfg(SubTerrainBaseCfg): pit_depth_range: tuple[float, float] = MISSING """The minimum and maximum height of the pit (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" + double_pit: bool = False """If True, the pit contains two levels of stairs. Defaults to False.""" @@ -122,8 +129,10 @@ class MeshBoxTerrainCfg(SubTerrainBaseCfg): box_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the box (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" + double_box: bool = False """If True, the pit contains two levels of stairs/boxes. Defaults to False.""" @@ -136,6 +145,7 @@ class MeshGapTerrainCfg(SubTerrainBaseCfg): gap_width_range: tuple[float, float] = MISSING """The minimum and maximum width of the gap (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" @@ -148,10 +158,13 @@ class MeshFloatingRingTerrainCfg(SubTerrainBaseCfg): ring_width_range: tuple[float, float] = MISSING """The minimum and maximum width of the ring (in m).""" + ring_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the ring (in m).""" + ring_thickness: float = MISSING """The thickness (along z) of the ring (in m).""" + platform_width: float = 1.0 """The width of the square platform at the center of the terrain. Defaults to 1.0.""" @@ -164,10 +177,13 @@ class MeshStarTerrainCfg(SubTerrainBaseCfg): num_bars: int = MISSING """The number of bars per-side the star. Must be greater than 2.""" + bar_width_range: tuple[float, float] = MISSING """The minimum and maximum width of the bars in the star (in m).""" + bar_height_range: tuple[float, float] = MISSING """The minimum and maximum height of the bars in the star (in m).""" + platform_width: float = 1.0 """The width of the cylindrical platform at the center of the terrain. Defaults to 1.0.""" @@ -194,6 +210,7 @@ class ObjectCfg: ``make_{object_type}`` in the current module scope. If it is a callable, the function will use the callable to generate the object. """ + object_params_start: ObjectCfg = MISSING """The object curriculum parameters at the start of the curriculum.""" @@ -212,6 +229,12 @@ class ObjectCfg: platform_width: float = 1.0 """The width of the cylindrical platform at the center of the terrain. Defaults to 1.0.""" + platform_height: float = -1.0 + """The height of the platform. Defaults to -1.0. + + If the value is negative, the height is the same as the object height. + """ + def __post_init__(self): if self.max_height_noise is not None: warnings.warn( @@ -240,6 +263,7 @@ class ObjectCfg(MeshRepeatedObjectsTerrainCfg.ObjectCfg): object_params_start: ObjectCfg = MISSING """The object curriculum parameters at the start of the curriculum.""" + object_params_end: ObjectCfg = MISSING """The object curriculum parameters at the end of the curriculum.""" @@ -263,6 +287,7 @@ class ObjectCfg(MeshRepeatedObjectsTerrainCfg.ObjectCfg): object_params_start: ObjectCfg = MISSING """The box curriculum parameters at the start of the curriculum.""" + object_params_end: ObjectCfg = MISSING """The box curriculum parameters at the end of the curriculum.""" @@ -286,5 +311,6 @@ class ObjectCfg(MeshRepeatedObjectsTerrainCfg.ObjectCfg): object_params_start: ObjectCfg = MISSING """The box curriculum parameters at the start of the curriculum.""" + object_params_end: ObjectCfg = MISSING """The box curriculum parameters at the end of the curriculum.""" From 4eae06fce979408a4396882f9e9b364dd0f28221 Mon Sep 17 00:00:00 2001 From: Ziqi Fan Date: Fri, 5 Sep 2025 02:41:42 +0800 Subject: [PATCH 18/47] Updates locomanip task name and link in docs (#3342) # Description Update locomanip task name and link in docs ## Type of change - This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/overview/environments.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 7e3e2668e7b6..9317e9d805f3 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -375,7 +375,7 @@ Environments based on legged locomotion tasks. .. |velocity-flat-digit-link| replace:: `Isaac-Velocity-Flat-Digit-v0 `__ .. |velocity-rough-digit-link| replace:: `Isaac-Velocity-Rough-Digit-v0 `__ -.. |tracking-loco-manip-digit-link| replace:: `Isaac-Tracking-Flat-Digit-v0 `__ +.. |tracking-loco-manip-digit-link| replace:: `Isaac-Tracking-LocoManip-Digit-v0 `__ .. |velocity-flat-anymal-b| image:: ../_static/tasks/locomotion/anymal_b_flat.jpg .. |velocity-rough-anymal-b| image:: ../_static/tasks/locomotion/anymal_b_rough.jpg From fb270ab56370774fa170385e93bdaa5ebf589335 Mon Sep 17 00:00:00 2001 From: peterd-NV Date: Thu, 4 Sep 2025 17:23:23 -0700 Subject: [PATCH 19/47] Improves recorder performance and add additional recording capability (#3302) # Description This PR adds fixes from LightWheel Labs and additional functionality to the IsaacLab recorder. Fixes # (issue) - Fixes performance issue when recording long episode data by replacing the use of torch.cat at every timestep with list append. - Fixes configclass validation when key is not a string Adds Functionality - Adds optional episode meta data to HDF5 recorder - Adds option to record data pre-physics step - Adds joint target data to episode data. Joint target data can be optionally recorded by users and replayed to bypass action term controllers and improve replay determinism. ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- .../isaaclab_mimic/annotate_demos.py | 1 + source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 22 +++++++- .../isaaclab/envs/manager_based_rl_env.py | 1 + .../isaaclab/managers/recorder_manager.py | 46 ++++++++++++++++ source/isaaclab/isaaclab/utils/configclass.py | 6 ++- .../isaaclab/utils/datasets/episode_data.py | 54 +++++++++++++++++-- .../test/managers/test_recorder_manager.py | 32 +++++++++-- .../isaaclab/test/utils/test_episode_data.py | 22 ++++---- .../utils/test_hdf5_dataset_file_handler.py | 1 + 10 files changed, 164 insertions(+), 23 deletions(-) diff --git a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py index 69f975ba858f..29a0f94885b6 100644 --- a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py +++ b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py @@ -358,6 +358,7 @@ def annotate_episode_in_auto_mode( annotated_episode = env.recorder_manager.get_episode(0) subtask_term_signal_dict = annotated_episode.data["obs"]["datagen_info"]["subtask_term_signals"] for signal_name, signal_flags in subtask_term_signal_dict.items(): + signal_flags = torch.tensor(signal_flags, device=env.device) if not torch.any(signal_flags): is_episode_annotated_successfully = False print(f'\tDid not detect completion for the subtask "{signal_name}".') diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index e0066c7685de..08c7cf2f1589 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.10" +version = "0.45.11" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 591805d37e1f..341cc1840729 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,12 +1,32 @@ Changelog --------- -0.45.10 (2025-09-02) +0.45.11 (2025-09-04) ~~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ +* Fixes a high memory usage and perf slowdown issue in episode data by removing the use of torch.cat when appending to the episode data + at each timestep. The use of torch.cat was causing the episode data to be copied at each timestep, which causes high memory usage and + significant performance slowdown when recording longer episode data. +* Patches the configclass to allow validate dict with key is not a string. + +Added +^^^^^ + +* Added optional episode metadata (ep_meta) to be stored in the HDF5 data attributes. +* Added option to record data pre-physics step. +* Added joint_target data to episode data. Joint target data can be optionally recorded by the user and replayed to improve + determinism of replay. + + +0.45.10 (2025-09-02) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + * Fixed regression in reach task configuration where the gripper command was being returned. * Added :attr:`~isaaclab.devices.Se3GamepadCfg.gripper_term` to :class:`~isaaclab.devices.Se3GamepadCfg` to control whether the gamepad device should return a gripper command. diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index afa40e2acb2c..118f588c100f 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -188,6 +188,7 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: self.scene.write_data_to_sim() # simulate self.sim.step(render=False) + self.recorder_manager.record_post_physics_decimation_step() # render between steps only if the GUI or an RTX sensor needs it # note: we assume the render interval to be the shortest accepted rendering interval. # If a camera needs rendering at a faster frequency, this will lead to unexpected behavior. diff --git a/source/isaaclab/isaaclab/managers/recorder_manager.py b/source/isaaclab/isaaclab/managers/recorder_manager.py index 4b6ba98f1e16..855c975f2a91 100644 --- a/source/isaaclab/isaaclab/managers/recorder_manager.py +++ b/source/isaaclab/isaaclab/managers/recorder_manager.py @@ -123,6 +123,15 @@ def record_post_step(self) -> tuple[str | None, torch.Tensor | dict | None]: """ return None, None + def record_post_physics_decimation_step(self) -> tuple[str | None, torch.Tensor | dict | None]: + """Record data after the physics step is executed in the decimation loop. + + Returns: + A tuple of key and value to be recorded. + Please refer to the `record_pre_reset` function for more details. + """ + return None, None + class RecorderManager(ManagerBase): """Manager for recording data from recorder terms.""" @@ -362,6 +371,16 @@ def record_post_step(self) -> None: key, value = term.record_post_step() self.add_to_episodes(key, value) + def record_post_physics_decimation_step(self) -> None: + """Trigger recorder terms for post-physics step functions in the decimation loop.""" + # Do nothing if no active recorder terms are provided + if len(self.active_terms) == 0: + return + + for term in self._terms.values(): + key, value = term.record_post_physics_decimation_step() + self.add_to_episodes(key, value) + def record_pre_reset(self, env_ids: Sequence[int] | None, force_export_or_skip=None) -> None: """Trigger recorder terms for pre-reset functions. @@ -406,6 +425,23 @@ def record_post_reset(self, env_ids: Sequence[int] | None) -> None: key, value = term.record_post_reset(env_ids) self.add_to_episodes(key, value, env_ids) + def get_ep_meta(self) -> dict: + """Get the episode metadata.""" + if not hasattr(self._env.cfg, "get_ep_meta"): + # Add basic episode metadata + ep_meta = dict() + ep_meta["sim_args"] = { + "dt": self._env.cfg.sim.dt, + "decimation": self._env.cfg.decimation, + "render_interval": self._env.cfg.sim.render_interval, + "num_envs": self._env.cfg.scene.num_envs, + } + return ep_meta + + # Add custom episode metadata if available + ep_meta = self._env.cfg.get_ep_meta() + return ep_meta + def export_episodes(self, env_ids: Sequence[int] | None = None) -> None: """Concludes and exports the episodes for the given environment ids. @@ -424,8 +460,18 @@ def export_episodes(self, env_ids: Sequence[int] | None = None) -> None: # Export episode data through dataset exporter need_to_flush = False + + if any(env_id in self._episodes and not self._episodes[env_id].is_empty() for env_id in env_ids): + ep_meta = self.get_ep_meta() + if self._dataset_file_handler is not None: + self._dataset_file_handler.add_env_args(ep_meta) + if self._failed_episode_dataset_file_handler is not None: + self._failed_episode_dataset_file_handler.add_env_args(ep_meta) + for env_id in env_ids: if env_id in self._episodes and not self._episodes[env_id].is_empty(): + self._episodes[env_id].pre_export() + episode_succeeded = self._episodes[env_id].success target_dataset_file_handler = None if (self.cfg.dataset_export_mode == DatasetExportMode.EXPORT_ALL) or ( diff --git a/source/isaaclab/isaaclab/utils/configclass.py b/source/isaaclab/isaaclab/utils/configclass.py index 83fcfddaf169..091b98624740 100644 --- a/source/isaaclab/isaaclab/utils/configclass.py +++ b/source/isaaclab/isaaclab/utils/configclass.py @@ -268,7 +268,11 @@ def _validate(obj: object, prefix: str = "") -> list[str]: missing_fields.extend(_validate(item, prefix=current_path)) return missing_fields elif isinstance(obj, dict): - obj_dict = obj + # Convert any non-string keys to strings to allow validation of dict with non-string keys + if any(not isinstance(key, str) for key in obj.keys()): + obj_dict = {str(key): value for key, value in obj.items()} + else: + obj_dict = obj elif hasattr(obj, "__dict__"): obj_dict = obj.__dict__ else: diff --git a/source/isaaclab/isaaclab/utils/datasets/episode_data.py b/source/isaaclab/isaaclab/utils/datasets/episode_data.py index 44f796269e14..31971b6181c6 100644 --- a/source/isaaclab/isaaclab/utils/datasets/episode_data.py +++ b/source/isaaclab/isaaclab/utils/datasets/episode_data.py @@ -21,6 +21,7 @@ def __init__(self) -> None: self._data = dict() self._next_action_index = 0 self._next_state_index = 0 + self._next_joint_target_index = 0 self._seed = None self._env_id = None self._success = None @@ -110,12 +111,11 @@ def add(self, key: str, value: torch.Tensor | dict): for sub_key_index in range(len(sub_keys)): if sub_key_index == len(sub_keys) - 1: # Add value to the final dict layer + # Use lists to prevent slow tensor copy during concatenation if sub_keys[sub_key_index] not in current_dataset_pointer: - current_dataset_pointer[sub_keys[sub_key_index]] = value.unsqueeze(0).clone() + current_dataset_pointer[sub_keys[sub_key_index]] = [value.clone()] else: - current_dataset_pointer[sub_keys[sub_key_index]] = torch.cat( - (current_dataset_pointer[sub_keys[sub_key_index]], value.unsqueeze(0)) - ) + current_dataset_pointer[sub_keys[sub_key_index]].append(value.clone()) break # key index if sub_keys[sub_key_index] not in current_dataset_pointer: @@ -160,7 +160,7 @@ def get_state_helper(states, state_index) -> dict | torch.Tensor | None: elif isinstance(states, torch.Tensor): if state_index >= len(states): return None - output_state = states[state_index] + output_state = states[state_index, None] else: raise ValueError(f"Invalid state type: {type(states)}") return output_state @@ -174,3 +174,47 @@ def get_next_state(self) -> dict | None: if state is not None: self._next_state_index += 1 return state + + def get_joint_target(self, joint_target_index) -> dict | torch.Tensor | None: + """Get the joint target of the specified index from the dataset.""" + if "joint_targets" not in self._data: + return None + + joint_targets = self._data["joint_targets"] + + def get_joint_target_helper(joint_targets, joint_target_index) -> dict | torch.Tensor | None: + if isinstance(joint_targets, dict): + output_joint_targets = dict() + for key, value in joint_targets.items(): + output_joint_targets[key] = get_joint_target_helper(value, joint_target_index) + if output_joint_targets[key] is None: + return None + elif isinstance(joint_targets, torch.Tensor): + if joint_target_index >= len(joint_targets): + return None + output_joint_targets = joint_targets[joint_target_index] + else: + raise ValueError(f"Invalid joint target type: {type(joint_targets)}") + return output_joint_targets + + output_joint_targets = get_joint_target_helper(joint_targets, joint_target_index) + return output_joint_targets + + def get_next_joint_target(self) -> dict | torch.Tensor | None: + """Get the next joint target from the dataset.""" + joint_target = self.get_joint_target(self._next_joint_target_index) + if joint_target is not None: + self._next_joint_target_index += 1 + return joint_target + + def pre_export(self): + """Prepare data for export by converting lists to tensors.""" + + def pre_export_helper(data): + for key, value in data.items(): + if isinstance(value, list): + data[key] = torch.stack(value) + elif isinstance(value, dict): + pre_export_helper(value) + + pre_export_helper(self._data) diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index 42c1b47e1a19..e36e33122f01 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -78,6 +78,28 @@ class DummyStepRecorderTermCfg(RecorderTermCfg): dataset_export_mode = DatasetExportMode.EXPORT_ALL +@configclass +class DummyEnvCfg: + """Dummy environment configuration.""" + + @configclass + class DummySimCfg: + """Configuration for the dummy sim.""" + + dt = 0.01 + render_interval = 1 + + @configclass + class DummySceneCfg: + """Configuration for the dummy scene.""" + + num_envs = 1 + + decimation = 1 + sim = DummySimCfg() + scene = DummySceneCfg() + + def create_dummy_env(device: str = "cpu") -> ManagerBasedEnv: """Create a dummy environment.""" @@ -86,8 +108,10 @@ class DummyTerminationManager: dummy_termination_manager = DummyTerminationManager() sim = SimulationContext() + dummy_cfg = DummyEnvCfg() + return namedtuple("ManagerBasedEnv", ["num_envs", "device", "sim", "cfg", "termination_manager"])( - 20, device, sim, dict(), dummy_termination_manager + 20, device, sim, dummy_cfg, dummy_termination_manager ) @@ -142,8 +166,8 @@ def test_record(dataset_dir): # check the recorded data for env_id in range(env.num_envs): episode = recorder_manager.get_episode(env_id) - assert episode.data["record_pre_step"].shape == (2, 4) - assert episode.data["record_post_step"].shape == (2, 5) + assert torch.stack(episode.data["record_pre_step"]).shape == (2, 4) + assert torch.stack(episode.data["record_post_step"]).shape == (2, 5) # Trigger pre-reset callbacks which then export and clean the episode data recorder_manager.record_pre_reset(env_ids=None) @@ -154,4 +178,4 @@ def test_record(dataset_dir): recorder_manager.record_post_reset(env_ids=None) for env_id in range(env.num_envs): episode = recorder_manager.get_episode(env_id) - assert episode.data["record_post_reset"].shape == (1, 3) + assert torch.stack(episode.data["record_post_reset"]).shape == (1, 3) diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py index dd332947ef69..27f5db7bed30 100644 --- a/source/isaaclab/test/utils/test_episode_data.py +++ b/source/isaaclab/test/utils/test_episode_data.py @@ -38,13 +38,13 @@ def test_add_tensors(device): # test adding data to a key that does not exist episode.add("key", dummy_data_0) - key_data = episode.data.get("key") + key_data = torch.stack(episode.data.get("key")) assert key_data is not None assert torch.equal(key_data, dummy_data_0.unsqueeze(0)) # test adding data to a key that exists episode.add("key", dummy_data_1) - key_data = episode.data.get("key") + key_data = torch.stack(episode.data.get("key")) assert key_data is not None assert torch.equal(key_data, expected_added_data) @@ -52,7 +52,7 @@ def test_add_tensors(device): episode.add("first/second", dummy_data_0) first_data = episode.data.get("first") assert first_data is not None - second_data = first_data.get("second") + second_data = torch.stack(first_data.get("second")) assert second_data is not None assert torch.equal(second_data, dummy_data_0.unsqueeze(0)) @@ -60,7 +60,7 @@ def test_add_tensors(device): episode.add("first/second", dummy_data_1) first_data = episode.data.get("first") assert first_data is not None - second_data = first_data.get("second") + second_data = torch.stack(first_data.get("second")) assert second_data is not None assert torch.equal(second_data, expected_added_data) @@ -83,15 +83,15 @@ def test_add_dict_tensors(device): episode.add("key", dummy_dict_data_0) key_data = episode.data.get("key") assert key_data is not None - key_0_data = key_data.get("key_0") + key_0_data = torch.stack(key_data.get("key_0")) assert key_0_data is not None assert torch.equal(key_0_data, torch.tensor([[0]], device=device)) key_1_data = key_data.get("key_1") assert key_1_data is not None - key_1_0_data = key_1_data.get("key_1_0") + key_1_0_data = torch.stack(key_1_data.get("key_1_0")) assert key_1_0_data is not None assert torch.equal(key_1_0_data, torch.tensor([[1]], device=device)) - key_1_1_data = key_1_data.get("key_1_1") + key_1_1_data = torch.stack(key_1_data.get("key_1_1")) assert key_1_1_data is not None assert torch.equal(key_1_1_data, torch.tensor([[2]], device=device)) @@ -99,15 +99,15 @@ def test_add_dict_tensors(device): episode.add("key", dummy_dict_data_1) key_data = episode.data.get("key") assert key_data is not None - key_0_data = key_data.get("key_0") + key_0_data = torch.stack(key_data.get("key_0")) assert key_0_data is not None assert torch.equal(key_0_data, torch.tensor([[0], [3]], device=device)) key_1_data = key_data.get("key_1") assert key_1_data is not None - key_1_0_data = key_1_data.get("key_1_0") + key_1_0_data = torch.stack(key_1_data.get("key_1_0")) assert key_1_0_data is not None assert torch.equal(key_1_0_data, torch.tensor([[1], [4]], device=device)) - key_1_1_data = key_1_data.get("key_1_1") + key_1_1_data = torch.stack(key_1_data.get("key_1_1")) assert key_1_1_data is not None assert torch.equal(key_1_1_data, torch.tensor([[2], [5]], device=device)) @@ -119,7 +119,7 @@ def test_get_initial_state(device): episode = EpisodeData() episode.add("initial_state", dummy_initial_state) - initial_state = episode.get_initial_state() + initial_state = torch.stack(episode.get_initial_state()) assert initial_state is not None assert torch.equal(initial_state, dummy_initial_state.unsqueeze(0)) diff --git a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py index a809df807180..362958ae9b58 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -82,6 +82,7 @@ def test_write_and_load_episode(temp_dir, device): test_episode = create_test_episode(device) # write the episode to the dataset + test_episode.pre_export() dataset_file_handler.write_episode(test_episode) dataset_file_handler.flush() From 90e5f31a0860cce0ccca416ecc04ad3b96e085d8 Mon Sep 17 00:00:00 2001 From: Ashwin Varghese Kuruttukulam <123109010+ashwinvkNV@users.noreply.github.com> Date: Thu, 4 Sep 2025 17:25:24 -0700 Subject: [PATCH 20/47] Adds task Reach-UR10e, an end-effector tracking environment (#3147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Initial Implementation of UR10e Reach Environment for IsaacLab This PR introduces a UR10e robot reach environment for IsaacLab, enabling reinforcement learning-based end-effector pose control using keypoint-based rewards and domain randomization. ## Summary Adds a new UR10e reach environment that trains RL agents to control the robot's end-effector to reach target poses. Uses manager-based RL framework with 6D keypoint alignment rewards. ### Key Features: - **UR10e Robot Configuration**: Asset definition for UE10e - **Keypoint-based Rewards**: 6D pose alignment using multiple keypoints for precise control - **Domain Randomization**: Joint position, stiffness, damping, and friction randomization ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] This change requires a documentation update ## Implementation Details ### Environment Configuration: - **Observations**: Joint positions, velocities, target pose commands (19-dim) - **Actions**: Relative joint position control with 0.0625 scale factor (6-dim) - **Rewards**: Keypoint tracking with exponential reward functions - **Domain Randomization**: Joint offsets (±0.125 rad), stiffness (0.9-1.1x), damping (0.75-1.5x), friction (0.0-0.1 N⋅m) ### Target Workspace: - **Position**: Center (0.8875, -0.225, 0.2) ± (0.25, 0.125, 0.1) meters - **Orientation**: (π, 0, -π/2) ± (π/6, π/6, 2π/3) radians ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there ## Usage Example ```python ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Reach-UR10e-v0 --num_envs 1024 --headless ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Reach-UR10e-v0 --num_envs 1 --checkpoint ``` Co-authored-by: Kelly Guo --- .../tasks/manipulation/ur10e_reach.jpg | Bin 0 -> 134849 bytes docs/source/overview/environments.rst | 81 +++--- .../robots/universal_robots.py | 53 +++- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 9 + .../manipulation/deploy/__init__.py | 17 ++ .../manipulation/deploy/mdp/__init__.py | 10 + .../manipulation/deploy/mdp/rewards.py | 231 ++++++++++++++++++ .../manipulation/deploy/reach/__init__.py | 6 + .../deploy/reach/config/__init__.py | 6 + .../deploy/reach/config/ur_10e/__init__.py | 42 ++++ .../reach/config/ur_10e/agents/__init__.py | 4 + .../config/ur_10e/agents/rsl_rl_ppo_cfg.py | 37 +++ .../reach/config/ur_10e/joint_pos_env_cfg.py | 112 +++++++++ .../config/ur_10e/ros_inference_env_cfg.py | 46 ++++ .../deploy/reach/reach_env_cfg.py | 215 ++++++++++++++++ 16 files changed, 833 insertions(+), 38 deletions(-) create mode 100644 docs/source/_static/tasks/manipulation/ur10e_reach.jpg create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py diff --git a/docs/source/_static/tasks/manipulation/ur10e_reach.jpg b/docs/source/_static/tasks/manipulation/ur10e_reach.jpg new file mode 100644 index 0000000000000000000000000000000000000000..740b33fde668606150744a7669107248958a32ab GIT binary patch literal 134849 zcmbTe1yoyG7Y3MwK#{Zs3KZAi?nR3ew73>`FJ81b6b-@MDems2Kyi2NEAGV$g?7UG z?ai85Gi&A~EBEYkzWaT9?{iObb8}DrEdKchz?PShlK}vM000pA0Q~t4ppbNOc;{l_ z>S|$5Y3fC3`_96O)y2Z>-;IA(03iVIe+wEKDjFL46J$Y$U_2Jc3&=AF^7#S>5BoVb z9uE0SeEgT>)MO-N)J(LrOgvmXQc`b$|J@(_|Bv8LF8~J}#S85z3XmEA!U3Y-0RQv> z$N&Hk2nF~U%)bi|1V90!qCG*!__GW^|5wM%oCcc)Y^N5Ci&lGBB@=1JQHK5_Vk(|C zfXatb1J2`O9t8mrL{TALP$J|2T!b})xjYCI>R>1|mkt*pAr8z1evcTyhK5Ljp+kBp zUI5)GqsTB$RTO>BS?)$v$2yI?Efw-1O^7`wR$VDJfhJ#?3TSpb0_xd}1SU9)=P&(|8P})3d z7TgyEvZ%SLGy#fgm#Dr+wwi1Gnsr?U@8O;Kh)>i*k>zZOgEu@qbi25vY}74!l5#C2 z)RM$92r()Ip?Q!TWsn>=AVLunO2$Hp~=gLI@L6mSUhW!lW7#d8uW(dzvgrXhpf#Erls98ISoLT1{c+C{7?Z{{ew z$mLd_MxL*n5s*na2m)q|AfiOR2BCoAxHTZk5J_khGL)VM6=R*rL33G4Rmv(X8dP9` z-UXyfSwsQr$oYpxR2?0!RFjsBDB*L9D-}?e=_ccIGjLVdm{&AI+Yb$~Tk0~P9VI-C zSR9HgVGb9gf)CbVpi=_|#B$}RzXmEuVyM(C_E1t1SS!kbInev1ntF(Ak|JE02LPk1 z72pbjA$yCm;d-t!j;*H&oWcEL%@mk~w{($39!D(YsJXSt)Uv}LB~#6aut2%U7$m_U zTq#|2Hsq)Rcu-YjECU*JG{nipLaPwkZ@G9v#15LdnCCvSu7AawbSA33b2Vs=3&cP$ zb86rcz=06cj~LSNWNSgJ;+w4+QTE!rU!4^-YEK&b3G<4g2O&C&LVlK9jPwG|svk)N z%1a|hXR77c{IZtsb@DsftqTcPh+-nDzOZ5LRmM6TS70|*6b4(=ZRGjfjl9Xe{|g+1 zt0D)0rq3Vxp)~SM|#aE0PV=grl+&472ov%oTM|b#+fU@#f#A8Z?oA>|R_b z-)Rss`~ZurgF#iGbYhUU-XsmG_-#9uP*lgWC4xqrs)lIqxu};73fkoID7tWYO&!`C zfin4WO$!pqiV_~HkPjudZrWQJ1GAF|usV zE8h_6sxBmwU#s|j$&=;F*Vew+ci$E*Y_@&OeMl>qN&$$%@h|$EkCWr}FSE zt7VRrnW5q+)v9zEp3a{esxf1oFt491?b-Aa`5;|-t(-vLcr?sLCn9qRuQwi}zH!j?cy;eB6IK|Nv~k*mvDrscz;cOawS!sVj>l00%Ofbgs; zsbI3s(j1oqx4FT9wNk!9H&Sn)`@{J664a_yyV@;IeTUEhj2O#_mqswpx`$!g5-H5v zc{6Hp>(S;H5Z)$TM8jlKy>8-ycX-rsjQ}fu#)ZgBxu&D8jpmxc%nA4{T@?lu0=-Ew zB^N`lZ9X*ZB&fo;)FO?an3k{_J{50(+E_sS!m{^J`=t>jR8(HN&|J(;!P&HT{)$*p z3KxI$Eq-*L{l%ruo|#cXT2v%~D#<%uYchKQg^#h*E0v2P#CMaSgCo9rLws)8`_uOm z7gf)dmVS`N76|Qimv_9hITHPdc;{Q*-x^Ryz=p5u(5g;c)X+KHFpE#Zbzl2Y&vZm$ zAo?)LQdrS@WFvcz!?w)GECaog$ra}yF3Y{V3?DHfNT%E%(SN~pg^wOKnsv=r3d$wY zC3J(Eb3QWPg`n63k(S~=E*YhYhbv4Y-9ciTei zD>ePs0(P^VnkVjV5|hoc;UyIqwCxqdDulu87KM4yoY#z%np71dtD0rSnyN$sNv47% zv|eei2)CLfSNwRsd&{<*Wh?X_In-$?i`GZ&xZe0UPAvLmRJ6H@^6#YG&oh-ZrpO-@ z!??0~;|*y;B2^j}w!ojz2Z`@TlZvA$Z)s!jw+3xx5-VO>SvBgsGR9>Y9iE|Ns^s_F zr=g)t+pp3wRN$YrDVNaFZ+7r5n+<2SlBvSgIb#0W7e>OiqHkEqAk}%2c+A!Ei(673 zzBr~NTul?&xF`5p>6%?(G0{oj2dhkeSzjEKU_DT;ytH2j^;WCsRau3l_gX@nMgh^{ zVng1L&w8P}jrBx)y=_emv}qy6K(}pl$d06~}3Dy(LjySH{zO$81H5K*}b7(tgCn`b(*Xf(!9OGVlAJOa`A- zmiV9KCoeYTCt?rfbnoorn6|7Qo)t?LRA$(^*-(G=OZLc)OeEkx+QH<}%-vq8lyLB2 z9Va9V`=Yj%Y5$eiY);=O!PK<)Ip^3)&6;~x$|d>Cx6VI+LO0r0a`xx=rYLSrtEL}e zLtTn`B+N`AI_K#jh@;V_>KDYN#Mg|5jt1`E_@ogPFq$KlSB)A^Ij=vd62mH;-i&PG zTXkj#ADoG4CD4B;8>pf#bqGHAvZaTjn-N|v?ZNKv-nLu{t{of{kB?F_k~i?66G&cG zmOhRN>$njp9$xdKAG+_pq%SxY`Q#;hv-9A$d`CW_D#cBvWPh7syJ1j?fueVEQkGb> zRBZOwmy{aqz5!pi7~0m9b&hw3@#~-EY&v@8p#d3Ocb#;#bgvk>uJx=9puu*Iv30b! zd`*7dnYPjImV6ZL2@yr=X$j#UJ>&-U7A%)nX@{$dNgxMTjeTaC?N~Nx=@S&gM;+{* zM+VwO4BE%3|`2yG!xQeFVfc~=lD6E8^K7T-7fvS_6&nMmuEa{e_uw$w#0zezHqL9kuw4aet?SdTH&U)u zkX^J7dqyV$P4G9ArP%FwQtR2dF#8nnb5{yl&-Uw_Va|+r`c>Cx^eEF|p2W5#XHSu9 z6FwcDmqaPp@~Fbkqb0<`KIr^@yMHc2f|AbZ>Q(4x>L`Aqjy3Vnw3j2z#H51pL?P{; zdgcO{OShJmiW8WAsmvr~mG)}1R3;l`x80R!M>d^2BOR~G3w=9M*YOHVZ!E>5{CUOB z@2_huQ@(-z{hT}G(4f(4;ucr@va&NrR%(#TO>%Lwz7B-8m3Mi(ag%x@`B<5FvHk6A z|3EMav-ASVQ(^km+BZmzr=xOW>%OVNT)op4P z%sAq763mo49dK7y4i-h5^))cZ?H6~9zgNoFZj85OcX!@B5%72Vrj4l^uSLQlQ_pQY zx33CdNilyp=-$6Te8)m z`G}$n9U6_L-JAE=Uz~TjR-Jk~@-fgE*v;8#8Zy}A__b9a&lAS_>OvI-hY+8WvXXcg zd6Q~c>lX4}I$6(Zd)SJyyADVIcm%>*}mBhcWt}=J>Tfx*L{N<6e5PJ)bOQnf<>Fa)tMg21Qtz?CZX5D2G ze~1KG+4x`q`G^t?d41zJ@<5tM%fa7&qj9HW;?*4CJhxD@WN!R@CKjQNJF^8z0xQQeBiekJPm{jfrMWiSVdjh@A!gndIs7>+ zO9$5)+_HCe(N1@s3+kF%0evx4JNW_1f#Yg ze7p9TCM!`Pwcq7Dvt7qO1+)Yt+1{e%UQ}25w4E$ z(xPK!kqBQAr9(Mpebu%_B*2ZEiYuyYsHLg1+?HP%!sph^8&-`=6LrBNcyHlzt^R`G zP{~b7GjD}{acQSK$~zaWy*BF_COk#xO18Ai&e*h_m|p! z&De}KjRJe)f|J0JbuZ6H#|wUlYIUiE?9!HIN!3dGMwN?m$H<`X_j~V9r?)RHPCSA$ z=Vp5ty;98khuX}{peYVy?!4gyCoZ;gQOtdiio&KZ%zU<2jYFnFtJh|0OYiU_Hxs#z zKS@qN3ox(?nr8C9AEfA;7P)mza7%nd*XMFQTH8Kbz8kJA>$7*cY8GS@<(BcA*_)Qj zNU^U*5w&Bmoo%q1SX(OYxp|W}P(IjUmTI5*F_3Mdo;(`WAl4zhLT@OXhV!DPw6_v@ z-e!=Kkl@E-Tl)>M8uZh;cdWqFWTl4ND0)CfE7yrX6ULa3cunl=xa!JVn2hmEd8TZ$ zmSLb)ql3@!^5>G5dGRw20Wz&>1qB)Hp~m6`ES;nXJIlCfd$%GE4#G4CqNVgz(}f-4 zgF?rOJF6MS9bu~>x1bS^cGs$z@BJQC%#K{Pa~p1b{gp`<)zw5y`Xu}o7KAt2;n5KW zI=Tr7E#=zbYCNHJ+>v(4ju)*2{+lmg&qP^4fTfix$5M-V>_p8EHiO2jB|XS*zA=@0 zWZuK0)JrWZ7ZNMw1H~;CzbaUrC93O&o={jB#*rq4E{-)#pkTNkD8{t^qAB?DO*nj% zgzD^vja|JZ87lRVhr`lg^*rYHde&gkt#&?Ro4+`Y>Svx95F6Dr#W*a~^h&i7OIjWl zDI2Lb>XjY;e5)ZgDeST8)GAFl%Mzdbdm(d9p^1Q@uLt=haY~&z`sos%{n~weGy*aRHsSRpaVAcRJ)SE5wf?#pP9U?Rr8@7=I}KD~DOOK;UbIyf zb?T(CQ6&Q#N2^K03m$Tzt-Fp@5tv$DCf~w`H?IR4X?WOLGxXdWPR>(gC74Am_N!qu zS+N#`OA|4b(s4AE6Df_0#;%OeKpLE`^>bh>-zzLqhk7>#q6-(uK(BJrt=_jtar=tw z^eRu>04;1Yce$~u1?XI808aI)W+jXOvWlp8kIGXeKtFQfzy3a(qjD!r`XSDH!3*V<6@@06Gcvz!czoDzr!B~N-d8};S@GBk^ zBf$>su)%jRGVde&^x_JkUEN<1mh9vy?7+w}+D0eJyla2Et+zQuLT}Tw*Xrm9FQj5~ z;rBkm;;ML+TG80`Mp9Np9q`pA9qpO?QP<$|U$;*2Ue}E7{&BC>WzaZuZPENn`WE6d zTDwWaB@Nn}QMBTT`UHMGtwQTIj7RxG*5pNl_1Y?CObb0_he%rB)g?H^TNW4y5+XgS zHMRV{SB4Gh;aT+%HZ3#J7_KBf3D>RyUevArb$B46;Hpu|v`@j$ZqvBK%LvtxBwY`G zBA1fo;GL<3b97}?aytEUBgr{rd#moyqxEh~G7Vq1aA;#&wPI$0fX6kVF;#Dx>-vRm ztns1&ZzCL5Hx_%yZ&bDL9fzr=ub!D(4vOXRCik|^VwY3NpPJD>vJ+o9a{7L7?;#v3 zf;vFT%kk7IpunISXl|A>=h_xT!3?b;FqyXiwPRdEPI`aFM5NDxk67J;`^O)f=|Qi1|@T9$7xxd z97&R}@A);+qrY>kzS`Ct=#Xog`{x*xr15i>gTVrRrz90pv<}_Y$S*tsnbsq?STM%| zvx$PkvqVr&Y@k~shr_(e!ogv^L-*VsGjOf0tM;vU<@2Vn>Ex$lkjPq+2NQs zUeC{zNhdQ~xv6IkOxV6l);i0N&uShXowF`qovX+baIh|nO=J8_R2Uwx%bwxD?3BKw4xMV z*VmnUbi1nPK-`C&%sM$&kfc~K)%NR2S>zL{(K|MZ^xG%d#;GJuwWIagg#-F{u!`Cn zfkc~YZ6z(PTp!e5;-O)2RO5S&Gu8{9>1~1%dDixb^#srIZJVSNH3GD5rrB!Df;~Y| zb8PAw)1_ac-C47?HozowreCoW6V9w7qguV68Df8R{C12`RCqQcvQu8rznz$bCeEtg zCOgMz|4ZAm;KHOZ%N6^k<=-(t)P zrYV}%Rd0Hn;NS>2e!T95x1lQee&$$4k)^P}yjFhNpkI;Q?&fitQ8eYgxwvjaqVv&lStp1&B#hYy5}==J!8| zOXEAzg)L&k+4yS)QNs^56wp2w#F$}0!Z#F2^h(oytaSGZ2$Zq>ofmb`(k&3I4Nle7^YOL z%+_v|>i(XqIaJ@d?(5|Axn#nO`9bT!#f7%+Tdh#S%BZ@7y2i{ZP74B5mR7GRw;ovx zl=9KvHx9kVb__6-KC2XuOiM1(`xHe|ajFjz)8|*>1j60!Nep$rO;ahuaHy6%-tLGh zdEfJyYbML=y_C#3@OB8>i0qE})?`5Lk9PVx{j;VS-b?q1{Ttr}1{`y05ht#j)4X2C zaJx6`A3^o{jrC8SLgDo%^}KDz$;LBgB_^n)<}pf81lG|-dA&j9rP33&c-T{P_j>v% z0T$irFIs8@jQ-bEOwHjAbtrNHDBJGB*GAi5F=_GT`>jT93x^&8 zuhw!j^L}I-j(-wLSP+0~*DjB;>RBEiOy`#(#J0#ciw3^C+2ECi42&%*8E{!RJxjnb z9gVqaxub;>uyUQd9ddapC#Y8ISp2Tuxmzv_HK`Z8Nhtj8oXij;L)om~u+^mOE|?S` z6E>bfk)uhc+r%0iRGGHodWWs4Cx`FQt{`w~w__q$eJ$mAdVOZILu$a{Je- zni!P&tQ%HrQQ7n7>w)v`zvE8kHeuZ)XW~ko*}KvelfAU>xS{RNjs=7RYp1r`S5+0Q zWUL*(F_Y+c-gb8BZIWr?2U7(ETl$Y(d0hSU^?04TFW{^jF-aJIbZ|rNlax-^jmE;o zb?hBWoKMP{F?y-&gWuG7nq@+z`98j==F;GlaoqMZ0uATe;E9{>=M=YAn+apOrp$HY zOOn$DZ$5$4BW8SF72AxX9mf^xfm*Ivwam>srovMA($OW$cnkD_1AK4|=rn z9spoJu%0?}T)J&}jk=2l_G|{g<0)Q~}+FOr#x7Iexs{A?11?f{Gn>qjB9hRuw4-EFlN_-4q z<6YyDq(c}wPdk+d#tc(5qHBZ|#OZBX$}^P4XGp}=V2VWgvOT?8a0O2h$NJdfhpU3* zefw=9>SP5z&;A9IZSRHDgL%}*b3&6tZ;_|Oog6yF#LJ{P@>{L_&-uJQ%47q^TG!6s z9}JuJvMjps#e`MEn`^ILs~x+o{sH8kzqRy~9yx9HLbLs?zwP!jhB{T7zLGRXiGZki zUzW2p!73-_6J{U2@c^Gufx*!EL6BIVzzd7y^u&a`gT}XNFuA7i%MT)7wK7Occz=o& zupLg{$C0BC8rFOK0YEqTtSD4)Ol#pdRt2_Rotf#QP#hheG(V4UmAV&vD_=kF#4q^F zdB?Rvrgr?QwM0dKq0zIK)ie2yZT&o^g5!{yUte?>Ks#PCz+VWAL_eCXJ5zzPk6-m-T!KoKcld@;4@Ok1JeOb)ux2H>a~W19 z6Ad=?eCVovDOoHr1>};O@>tI}(h;TVvv1PgyL@;)EoA@fdC2Z71H*J6IjqM1Zsnbe zvlnp)GgymiJ7ae;HjZB56K`U&oO>tjod^tNE~JkhL0_M zvu?)XUH%aR4yoFh_MOlLzgy}QNxfgkY&baHg2RbnfytvM2jic70z*<47d32e4csIt zibuAspXBWwY`a$kN(qV9KS?Sjq~RV|-~E13;FI*JN6L~Kgu(6HRnIJxursJrg<0F} zkPubE)8iphoi#hc^1Nl4RS+T-v+8g^4X=b?_66s(UOw}jI5WYIoeofs5`LlpR88tKE!IjtCR3bM5`A=&%hA;1O14?5K zR=-k)F@uQ52BhB$Hl2E{5$XbAiX727Y73^DcCE|3!B9&Y=IZIaX>K~L+aC?a-f@{X zdIoD9ju%21O5qe(P~X@bcTMl7xG$(bLNLEN&m4FqE?#O}ESgGlaUWg3U*87QvN#`&^s0fQS$ z(S%%md*N=!6;!RJg_)RNHSH^F`^w&wI>B?*Z$4G8tvW7ZH(n!wHNla!mK%%(VQ!X%<^E^phEL?}5~)8<*#ve3ZI)AuU`i~%%*pJ@}O`7;Sb zt5MCtoMNM`$ss$ETd^4kmx$tJ~`P*1RX3To@iw_xE-9@t(s>YGB6}QMYSZ5!< zKY-*rI&F${&YPC|?2aDz0NQv`Yvechz~bpl)@RN7{Jq=X9!&s3m`@|=8C;Dh@L)KXyZ(i!)GQJ}nYw~mRW+xhgf*27naU%b~FNaN8l7G4U znZZjjf+y^KCBeK~1oLrE)%sAG9b zu2_{~G=XiuanyK!tR`)|#x6N#J@Sp zK&ZcT_ht#t#`zENAgAs&UJk3RyC;FK?XBtUxSg)YNgZe0sBXH%! zdd4o04yEQufc0G@ec6w-84Xw&jdhe*b=kT#*_mjTlQX z>koiE7n{JV_2k`q17GcoE_{(_F1yDx(EY!#H^-;4V#G-|Km= zE!%YNq$(^c_i4uu_5KflPy$RjU}4s9^|qRNCd65TV+fLbr9ZANpLf%_{^Uszf-7~U zGL|5@@hQ~Q$YSo`)@|L1X}fl>2JpG^a{LL}T6jS*ha3i z?{b66v?N9Ds%`7%dF@ecPy{sE*vm<~W4r3u-S-)TDhBpI#?u|XKmct9du?@09)`f0 z%$ppb@$=^ndtKVWpQNyesL&+Yu6_$eMJgMq*7WbXO`-5)D4&kks>nnts`H7U&+m7# zbqnOpqMNf)zsewAi2r;b^jhHLy?;0NNGI1jgE;O{#M=78xO1c=iusktMVkehhf2Zm&2PySGnqJ*dl>vb}QAd3g#PZ4#-c-`48>ut1TE0SyAlfgq<2 zXKTuy>IuQ4#;6<>9Ank4yYB;YpVE24QuQ#Wb@xb&I1AAW2Vro|4evEkla(JLyQoW6 zUT;d?h@QUB0wWy!UZ!rLKXINman%tUf|A5WWzA8B;L6dLAGMy4SB=;W9KZviCWIe} zPY}mwyMDv_cJbz1!uot9vR~%DCobJlq|jRLaKTJdNpIF`cb15t9Z9Isf+BomUkq%5SV{zBkSeO1&%EA|Sh|vh}S7s`L=U-WbGH z&mr%+*-JMx&6T3$2x&t8Dq5{MwX!`6r;Im{B+&6@`!ufhaBcWfMAJeJm%}k?MmR?U zFKFPk+^m_4w_8lzWffM-H=hyV=2_XSJNj%}psDzp4_d)k140aWLr>_~gOYEjJWF%k zL94Q0M6J%qFZ*$N0{|LspHhxR;Md|CtK7LkU-YwPd z%!%Bz;b43UWPxcP`8^MvwRR@JLl6?>Wqi=ScnZ{+aab}Mb14Q>dw0KBe@C|I)m`{T z`0@kqJPytAov@=((Lu+dP7Mnnn2NL5%AjQT92$)x$_wwS4!URuMAcY2k?z z4iOqFu2y&p=LTCt>3~-5QD0f8Nvkp(q9$x`@qe2$<3a11r}C7MIA~V zb4ywo&#E83mAYAuH|m3S^E|ltSB~r*7r(E)x{IF+U*@nbaMl;BS>Mhk&`dY_iZFo@ zFf&P%%k_j087|hv=%!z2(1uSczH_L_G*_;ir8lTU*&Eir?P(g*LPL1KirpIL$V=4* zjvg;L%bD;ZrrpW&zQ3xggW-x~tYr~DeJ_&y;fV@<0yx&33#&6Mox_U^CjixqlDx6c zv&(Z&VJACVcys2y0L5%hcifhm5vMKoaNKRK-vsiMoV4r4y}!zdDg*@pga=I(TY0@w z)Y@yy*3N>}F-sC%H2=KA#Qsc!0S#k!${2R%`^#l{3O!IqhRKhgd)O9`ntXD7m^;V8 z(QNSn^CNyARU!3I?Xidc*c*g49(G;`n)k%ZwI3p@7fuSIb_f1`O`no`!x%Vohb}`e zqFwQNn~1X39pAHAwkFJtD<^RjB3^|C+IL+DR{0?0aB|VO#VOeR1y-G*j2Hn~H@fTZ zNC_a!#7qbXlg0X6@0oSQFTd%1cLpfv^K(z57m5fmoitB_>BIM9;+Nz(gSF;rHIf)t z$_mZjeCl`BlY|#$TMR9eBg~nxK%LL?&PaPBC>>IAkKR9X2c>b}9UJk%<)9pOQO~}# zDy~_*h%KeW9^fFNpwIXBL6?ppUb~TKbF?ei>sZdEaWrK?RA>?^TX)Yq z)6HNj>=MHll{nHiK(qFJ0Y62MBwh!<$89EG$B|E_Aq6o57_O^GTeR}~?MpjvgPz^i zttihjuWNV^X3r!JDdF19r`xb#u}CV|J~Vz-cdyM~0%YlA(=>CY<7}GxM*5)ZO&&nP zT=n4olhy9;FyY;)xb!aZns8m~2koYT9k)3`Vzxhk#tTgG5Sj@2Ue`O)tTz@;3A02} z2xdtd>XA+w@nJ0Q>~e~TS#1at6nxwnKISE@4pv()9rjrF+AH1ZN)cX}`thC=n=*=q z03nAaTkY8qm$au*ib2T=h1`m~+dRa^5@c&n**=U^jNuoVFNiFaqD*>kwtGjOV^Y1% zYmS50J2TsI;?ar$&ZB3~8(O&ddHx6B`0b|~2Mj$LKmQBc)l;?w_N1+|QA#4@9fcW3 zncCQJiEz8URV%yC-XO2;q6z-f`-P6(+qT>&8XkDB=kKVv!RyR!eB`Z?IJs2mFKQ3n zR#*MHZOXrwNgFG2yj=Ha^Jhxc)Ztd}u2%j2QxTOKH2`7iB=Tg~c@eixu_}T4dBy-X z%gV2ukJh`zgjQo z-J3K0i~%2l82`?yIX9U*ZXI{2*4`G16S(ws61|LUQEnc^aujkT(WqmLwcq!9qQ0Bn zk03>!RL$N#08qJjqRn6~J|84^@5?c@!N5XsC$?1vaHf1Tz(zXQ`3MRj6M`zL~Agkh&_C8-dP4v1q_i6IlY;6 zUcFk2ba~%{ih#*k*KWFX#&HgBduLA!x+{PNKbvq7VaD$zyROK$uGzALFk<%(bBWAM zybqNL?0>LQ4gY*F&lqNP=b3pBR1rp?Go|PsUbHYFb~XmXzpEBnde`~8JM1!3nWXsQ zBe1z*`G;GS_sLEA#gVrZyjL-;D=TVeagql?c#(G`aH9N?ZlreRLK7W|okkRbc&&d( z5=Z~d`Liw%&Vjs@*kRox`_s48ObtLkLMEU;!SlqBs<3n{qicTsb!r(5s1;LxnZrL< zNDb2^R2(n2%H1i;dR}$=Qm|lF;CFk>N#lLohmT)Rd@o^J4(;PYu4^v$Xv6?x6B;?1 z%dVK+%rX0#Fd@f7?S3;5f;Xe8Abnbk(u9hOK+VJOQ|;FETTgDCfX_g6ERbhLecrSu z$#a1!DF`%jnuy@VlDB8b_ucFEV|uJ00{p;2cuaM%S8Po{1%&U|J8$q%1&1$tY$wwY z`D0R5chrfqm+0a1gf(ZqCED_YJ<)=ZpIuN1Faj-vP_cRXyyFX@gsV_gi;``s6g6*_ z%uK=p1Fj5*texP7+Be_p454S`@NG3lG!_PAk+%P=T^`Wla zvleAvX^=P+o`7~SdH>~YKalVA&d-Q8HWvmQ-Rm&B)K9HYw^ zE=UO}=*yQ}eYaguLn^96)*ocoJ>Gr_aa~L1=U?3uWH0&c{^RLMd&j}6YzlI|!}jlP z;=mwSpKyG#Lg!1*q06`7EO2xbNkB5bU!29K?MZBt_PWbc;cuKvh4Gcgf^VoJFRZ2v zN+u_3#3Ag59Zz0SqUK?XZE`p4xxW>ycRlGMDv+ke4|)ORk`pxC7%5r3+=b7SGE;H^ zjo7cN3;lilE)EP0&e=`Ehkku%Grj%P;jgyo_oDLq0bHhglH=7fR2_m4W`n=n6M30; zu^}w&9$7d4G*e@k<| z7lR5O9`(#fzh&t;eIC9dHVOpa)KC5LnBcp={=?I~KLDJE&ca*m%j=2J=OvvA^qcIW zdx~5#tPPjsLYcZ4*zp*{r!GI01P7MyvV$j>K`a3;FyI|n@vA%TN?Ups954tS3Lo(m z7d{6DRUDH;3j?2=me*__WIAu+Cf0r{I~(vjBr3uYE<*Q)WJKoi!3Ili!m^_I z7!-}iz9SN#-r77k4|JrxT4VRUKV2>I+|h&!R5%EggmMX}{LU)+UP$tLmpyLF8o>ww zaydRM^j^vr6G3BG4g|@xsXkJV?>g-y_Jbre>Ky#nevX_xn>5?>_kaIHb?kSV@99a` zs`1aw&Gn4@js&V`J$EvrY~zpU$(Ii)jDy;gVJO^wMkr%v@lYEGe88sZs`;E%`Lt}u z>kRKR)Pjyf9gK@~L#&GLcjcDWb;cM4P8CzW9Ay|c#`+z5ut`v{Bnf}j;sxn3rw&7V z_uz$h?+}$m!coj0fO8Ar{__u)%goa$qudzq*=vRSp?Wh{nJ~gFvg&*n(lzs}U9aN5 zY7^tBC^4*z_OcyDm?B^thy{<|Sv#xGgs|GiswpZ|q3}}L!+7(MULcnkxw#M9Mbfw1 zEjdh>BziGV{6qBn2V7j`!|Lj&xqzAo#pcQI9F<79FgB|Vp@;nY)0N{t0N&PR{yRwA z?D%)$E|-hx3cOXk6UK zo;_3Ka*<#fF^wzojL*(<{d9`4hcmttfkg`Fat zEalFVi7^Ij2{1Uq+_d7;hm&=j{P831)@+vuN|hgy0J;sO*&ikOu!I-!gAasv+tYtdziAU92M8m=P=^d%uH_^h zf|DH{yuHw}D4~Or1nXCsO&{?U!2_5%^tTqJ%0~IGsFUT;7z_?;Mu%3v@ zz4e@3JZJCzcque-43JFn-KW=I7~+9h&nO#RpKn{4Xn`qI%yHclIZ|$@nug>KtSy2Wls#edI;dKt378d+~A0;YAqRKm&_qwI@Biod|QqG|jn?SL?C4{InA{ z5zQ$c$jdw2oA+oG7CVHa1Jp;IR+r~F#J6vHJlCpxJ;HP$R%!kdUBiquUyAGs zIbcXcB{a5H=Ii|XwNDO%w_T`sQ*ps1J^2lo9}Oh=ooD<|t1j?Z%!n-Ccy|0HFdIaK zQQtm(u__J)MD+5|J=yR#O@5lctOa z;i69J<$SK}(4vKyk8a;}A9c=E@26jXrsV57 z?V0|J1i1(cf<;8koiy+q5sFf{PR05-r_co!jhB zwcFy-3WNs&pf4bzmBrsC=XJWJk(X153xj|(*nCD}UY>wlNhq2OuIPtCw^fQKq5Psn z+slIGRb@8$(Y8!d@u%(7_ZiiLm+Y!%BBG}qUoRf)sU@kx2#FPhVw$r}(4m0^+jkTN zUREd)$<$=wW$a0R1yY7!n{XCJ&);Qt&;53v?#-7&hl>r!K{Mj?&mL@o=_}Uuy@WZz zD#oV6+T}N|QFaqy;uchSi{74+yB&N?L;Tr!+j>mpWk??)%PPe^yQzPj4z{hguf7`g z{RJz=fCLOG0#nKU0Q`EbRU#y_gBRc3hsy^MAh?I09`1dxR zZKwWX+%k~!m&j>)Vh1bHe- z*!L!Oc(kS6W~4PWO0Yzg7S{ZgCUQ>wtj37nnQI{=h$X3O(=ET=2P>37-)6>Fi^$*VKDupj+*`tX8{CT|LOu7vmh&& z0sjU*RtRjx2$1H#iO`mm9<`8R167b);NIFqYQW!B0H{X;)C17ix8mvn05l#uRj>dx zm;oq-Jpj}IP!DQ=4r%}k5`ubp{_rOvuPf z06H32fPADhC{G2I5;ZbV1q286!gWxw0W7FuB}jH8x&|l(G%i7=7&8f|f=>Xz2lX%n zU{YcM@R2(*A$gEt7ZM;{XF;Y?Lj&o9js`##po5PYfER#;8Bl{61;7)-qy!a-F@f?U zkP3M)CO|$2UIQorlxRGir_mp0(n0Q_ff*_-rUAIzeAwB6#GZ>_C&^}k*qi@GSgv)umxI5KY30srmC5JOKq?|eM&|5YLZ z*k4B`+86(Af^)&S0Mg&QH$DOXZb5G3DTH#S7Xe7;|6M?jlj>jP-;Mu@>OTnN_Fq-p zfJYQq6C#y)f+0&oLp z0XSkv-;jOJpG>6xN(WGh;fCblB_jEdJBW*K-Fn{{)&OvW7{DshAXP?&M>u-!;{&9E z8;}UD3qa2W;(-`|m|$Hoq$bjNq*tLJH5DM{V@)2Mi(!EbBoxG?0%T+W=i&y)0~tUn zv>32lX+Tg&E&yExHy^nHGhnDnsL%rO!1<~S$hH7l^inbEfJk{-3^6J(dFlYL3J?!B zNJ9l1sVtoc#00700VD#XRNse>4h6jWk3V(7I#-)~k>1csgH*uz4ARKzPNb|njyN*Mp#T7NKM;=!mfMG3g_H; z1sAH&KH=kqW$}P8feh3Uc?{U4Vn`8{)bXZ1-Wnu8Lj^}pfHkCIzl6%C(;Lqgp|%{dUtBPMD3gqGJ#N`*kf zB`Cf&@85sY0E7TsZ6e{?|VAAj?6!Hg9M9LO-#O^WuDEG)E_sI2o8tKvUBT!+j!N|g= zE$ZJ~2NX4(OHv=EI~V?T3rYCs*2$yh>}w?9BTmgP^kn%>!y}c-7wh}K43G1EVXZa1 zw(zNYRC9ePW6Oqo;JwjBG{thGk z{W~y*{|n^5N&YXS>mMv`|6>U~Y5Vs${4IpcpyAEfATTA|8h5_{qMZ|KMOrV{tuq6JD%<6Yim_0MeV9pHQS&yQ@eIiR8|-+Rxw_c`Y|&$<3N-+!L+ zZ=mLso%;d*eG1vs|MxEc8K~@kXY?Nf{NF(TalZc>=zpK`&(h?l{NK9#*FVWi_x}U= zk=Nz_&G`Q_a#10F<-aqcBTw4)zh8O!zcc#(fyf@Gwkpmylo!d}58KME-=zjL4K$2; zOik_9E--*23G6z&aC<^G{1jQDW+7p%N8FO`1i9_ei6677aY>-+j^@lr4dEc4jYcJS z!A6sr*(tJC4rT_9#~oP!!UGl|5UX0#si||w)YM;$nb|SW>?g#gGc4=)kC~a7MXl-I zHY9SJtn5V-iGMvBq`G|}-6KOtxPSQ0)Zg6gF)IKPS$HrtBPhrt<;wjD7rO$1K&aW= zwiPYD%LAG&C|(mD`rfkKYd&0UxLg~qe}spjRtV8qV=JCN0`(`N@`eG(bGB)_M%8HZ zpQeKlD0gbG*G^6{VX94}ELx+fjYI1)YW$5?npFoJTW=89mh z^b1uJ8ehK$!e__ibiFYr*?hqTgl`$HVY@FV%<4jjXIr(3S%4#a$%F?M*F5iaWX2o6 zb&N-it~ocFf%6;K!n#Mp`<=yk^|gGrFh@3DM))9E_ttD)OgWq)-Q}M6X8Tz&Ur_Xo z*yrGQ3AS!*UYdR+i*0_%QQNUO>$~#5vNQ=>!|qgcF)#P%giiG8Z?Q)B+Y;Y;WH^eQ zzOsuzn6!J}a;;IXKd@OUd_3ADtl&Q<6ZGmqyhtO{&kln;e7X}# z%)Xr5M+?gX7UVZ7#X%1=DF;f#hsAG*E>a2|8N5j?5Vl)bp9VtM9SJqcphzoUb+eP( zgd%`gic(TvA)`#hy*{V_&g)i4p1*UxyNp({*LWSV3B-Rn%(Q>^l%UBQZ1EkZlyzSyLcPi27;aCDo^#B2;~06yQ17cB978sj9%ruS_|umu=HN@K zt9Al877~0iFbv|o9>K5n-1bXfreg6%zd$Iwd0hYpwI#51(th8!nAhs9c`C)IEYa^z zCzeF=279a~!7nIiw~N3koC}13NoYJhZ`1IG(WwhSUrhAVBk#6(cYGEw)z< ztN=z@f#LxhH8ri|UAm1%*YATwXMF+Erv0gAD(#osNO;G}>N7ZY_+{j(vp-eVcb75P zyd&#~Gj>;aH#)c1q=C}0w+U?8Evf`lKuRv8O&DaE0c@TRu0r$YG@p?Ip4o{JL0l5- z5j`UZJwHD!4#<)Q^qN{r9if?nbn>p!k1i-G`6bhk?km0QcA&18m%R6^s0$N>PIp@Y zF%g<3Lhx?WF9F?E&5DVO3{>onV{;Nrgci@1r>U4BgKztg(0ufO9FyN=hTFd2+~62E zYSq|w-y)`(4pKA<+ma%0aT8cw!|^V}YNsAwF?7!E(X8liA;Hsz{3Cu0!sq=$nf^s6 zN}!b4X7)ZreQ@oLdC_=)_s>45nU}%C9W1D_#Re9(n{iL=J9@Co?~xkE`06WB-sq;& zMtP573vsp!3NFh0iGTvl;cUk&9bFduu`3!);m*kS3`?_O(=%m`1PDJS(D7&$XOvIh zPZ5#rX$WU`%r6$0ZIE1kZ5OCww^Y3hFKN)TNBd=j{e^t5cVwIvfhelp8E1P-0_$Ow zpJIrXUtj^%V}%ytmbmxrzZx>tIuEZ|%LLvyVz{7)lv%H;eBgcR-qyzac*)T6W60}f zPc6s5Y!Ldx0f-VuZTVD><- zjc#iJ?+jj3BhsWuR!i_h)rtK$$Ac&E;T5GP^slX>;G?!qCLYa%q36=cW)O&m30+sA z_NU8i^BWE1eR;B2GSq|{++lR~BYT+o(W;kZIl>5wWsA#oBasxm1e6Ux)F5L1I-^j| zr3h2I(0I?^m2sMaKEK6QIVq3zGrKOUuF7!t;5^kJR>%ATt;{HfXtAz0jmJo^uO=l< zcGpXURTww<(d=`eh@&+byr7tHuBlyU>kj!V1ERAgR4>P5kl2lhjr$fM zVk0Dg{N9vRh`6$8_q{&Hd&}@a=qX?a-JL!&=o116FL&;K_Kh21zj}H22QGr_a&lnH zDG2l*%;GDn(b%7wH^bOE_1cxD=2xeR##q8#=Os)JEr_@?J>p;4*>THJQ1)CdC zQd{p|&P$2--+1T+Mf}Lm+X;gJ6GCK=(pXSHirs?j+?TBqsRGlUcF+sdvgY8Llm7nF zXP^lz>sc4RRQ`(@3_Y-xbqMwtF0RaFPMPq}1;W@Rq!=DqN_U!$Ruj6T`{m%d!$<8_N0ea@2wLQRHOyiTl%SaA$Y zqq?D^a>h$(V|o)_k4GIk-M|P}cPO9}PlLFrj}`t%)HR>|MgEa}#O8%iEd3 z$~|OIgzXxN7_=mH$3$pl*YrcD0tBa#kr7|14bx__)w-``=#sHc%%6w*z8|AYJv*3&PVxSod4E8AdqTJe@kd3n|qKK)icL@(XHvK<(O-DCi5e zB%-FsQHtu=djkP~7xa$&em2B&UL;0nVwS)w40j`e0O{vX10bH5wDzjpwX-V%1+K|n zGSb21U27l(Oz$un+rhqksKWhe@N-~kq#1cCM_T=ZP9e;yI4eG(^wTsKUj1Vboz`D| z{=F$k<*(X}m)Tl$$a_B=TezSyk|jNx!wyk^Ihv}&K!(D#`qcpb?KM+TztmIh8`^QF zib&*Xl8T|JW67BHP*6hM!Z~}FUjLNMl(ni>PZLQ8m~DZq=?h=6IM;GGt%*92xU{O* z{SLg;&^-k`pxpsP*B}9%Y@KEo6oXb`-(aqtGbA$f;33yXR&TiMeNd|p?zG?&H@%>E zOp{d`I$gb$J9f2pjHJ~}KwTaQMcAQG9NRUf@%8%#IT?}MXGXSf@m6d*XyTTsoe3nI zqf%2FGjV_IdY9`qImA)!Km=N^(3Y$qe7uhstbXTji|>PWy-$i&5NWMZxidc6Z}07z z!M^XSq4w2MR9w&<*Bih)3!g-y&#b>r^p0~dW`MlqWxejNHWZqf`5zYpYMtm}n^HEv zw%r9!PQlFqP2R<}?`fV7x+#os#9L=M_^!a)_SgaZ`(VbB#dDB;n10BRIkAqhm?lZa zV;YuFKy2W9&K)|l9X%dt=X6>ulJ12-rT#e#F(2OE{3(PPe0 z@ct4v>HAK%lNHRAf?^<0Ku}^~Zk_Ypo7kZqv0AST`#lr~EjARMQW-LIjQ1yhB6?N; z;>T?5fhE|VT3*jkH(;Yj;4D%T2;Uog1^AwE#?jS&gg6#PL*UMR8+ zB@7gmLtZps$&xO1OMcJHD;sPPUY)BUh;!|Y0cq`;T}^;ETTctPqoy?HSKBrqm`dkV zFdeAv{;lQ};4b*-rRz1owo$tNn}$$`g~Y;nB-BT1_rNBvX2^1*v|xJZ!N}6^plsyk z?=`uASe%tgJUQ;oPJW#3ukLa+I$eb|{~rt~Av8A7e6pD~wcw~c_$53lhd!$sB~5PfDxalS933*k210U>p&=9H;(}a*8{WJVPtQDjjSrF6ynyig z&DlzJr&_>ydF0x{%4ERJUQ#bMWTtCv-)AyXOdIR264W&4ZRzQ{xP`1`Lv+snIWU8* zD9wC60PS0lW3_*X9a76)7=NCi*!^sYPqqhSyt) zr20(U3ekm>?#Pc$*?Y9S3BDP+9qn`8UeY5k%W?!B9J3*zP^LW-Qx4=GubtZs-R53m z`_Ai7tYVEQHNHzw#}R9$G66jA{cTAwN1XtJSw%8Hyyeo`@R&x9sBOPy+Sk6lpQ!yQ;i=xs?Fq~U-rX70;z zT|k-HnADmW2M}eB6(CZo{*?o~HTP(beod7{e{m>%dSQmt4aZUBuFGtg?UVN8tqvXj zIqEyJ?sO4S9igGcZ$rz_*5${rqO>Dt)uP{qgJ-fwYSNMefYG#v9eN`6c|dsb@sf_g zmLPFO!oD{AMP!4GR&Tf85=%f=e$Pboz;ZVDR_Hdy>5t8qAtbVH#Y(~r(D8cUfdJ1ZdywR}mSdwmYDW}B*O6r{E#%s$x3Wpxd4 zLGf&0Wwix<=o)uHf!@&|Ae#r zTFlkSzIh%`++coJFvi{7E>tu1sxfJX>AreOmfBn&QBz`_sQW1ictzR zwqNsOzjlRWIYa5~u4NoF^l+G7+FDvo*nyDhu9cKhQoj%OBV#k4%%Bu6GiFRxN5{@C zP|iG|z4cTqW-j6Npokl+Yt%V8*9aM~KAwF*^y*o2uo@eDqe~9&#ea}*oNIWP+7LynP4?85U|V!@ImwCAlR~xeEdkMpH+^jkzgDT_lbxPww2kF_q#~oe>>Id1 z4ePiSTm*hTH*I}kn3YPuF!gDMm%uicV-4Wj<1 zV9d|vzUm5;8jD?q+ixz;KnvUD#jt-RbaI#E$>9{Z66a|hOjkQ}L1BQNN+-n! zUrQPQwII{e_iIe&8ab>{GNO6B`ce{P^2I4P)+sXWvm%d9C+8+s(o!7+N_OOqgmfjS zrqU_KJ9@vL)X-v=||zHSAi~QHjkhor5B~wf+Q-#U--h5 zu%hnt!6#205+@a~+kpuVE_k}Z_}eE4^Aq00F{M-Ins#BI zTx#l4uGnODc~wWUE1UM0j6{$^j2}so#KRX_8!^6Kol{d5Qm`;l{V=&&A$1AJdrL3Q?7Lx2M4Zu^ zwm)rAtbAZ&GwpLj^ZSRb6GnoMyz4pGmPS?%vhFC)Z*^wXmA9{O5!hR!o4!RgpIuO_ zU0xwHgjd8MSPsAnQVRZNnDlNKk&J_G$HiPJ>5GboAz-l<>fZGFf{Xa{nip@#D+A| zHX~9>9(-Qr6e0YIJ+QD+{DzQ|igRc6r{!k@KNlSWHvs`FIB=mO^@Cu3={TWo-`1F* z(_Qq3{Z7xKwQBy)*?4=LD+?!}Ri<`%%U8Yg!Jd#8_`Law!+WLVIN+q-2P=e~(pUI$ z+X$mvUEBs*b|F7TsqpPs>;bdsoKjlqvDnC&z9jhdFk62etncc%3Adg1OP<>AwVqL8 zqb|0$n;^_}3!X1LCvV}ulmeD1b4nY*x8z&BsRBa8&Lh2OM}9uX^M1}j)w5_Be|{>} zWf6R59qEp)L4m{cJ{(wGlKoV%^EEzJH5T;Bx*zklQd;+PyN&MItH4bI`#q=&HabmJ zFVAZHi(2UYCCYX_-7Ytn-BQb@o7m3-aPpU|rWqG??;RnJ>2Ip$5dZd|v0;7(&4@wg z8ww=wfMtJFKpqol-9^ziX+i2p%14*0bZna+hPNl9cnCQ|p>g1|SpFj8D}DV}dzs_? zCE(DLU2;M=FDn{xc6)Ddu2v~SC&&1us;+5N<1}|l&7YqQstf^M8P+`2`VUku*c zYp7EG^UP`kd5ahf>7dd#5&Yr9VIb+w^k%NKaLJ`f6|VN|xFR_xw+;Tfkmdz#Mfry} zyZ-i(&hwVhUuP)0UO{UQ)j^Q`X9PCZCe}K!!aEbBT@)yg5j)CB=$*|zj}y?nziFB0 zFYlP>3j~*v?3Va6t$&(!z=oGKJyvT1ev=u6Jq@EOubolT?l%_{Rn|biu7O_i)(4pd zCt*$j@4iRTbx)f?yNw4hOeu#@aH-RYu6AIU*d+$Q!B@sg`l#;vAdaid4Qq z^0EAKLGfNj#A{UQ&23^MzHmsjWGSTSdY7{_P**+I(t6Ip&4>w{yJVoP@%PSpcnKz% z*G*pOqZM^Fm^uTox%BwU@6}i6>n~d$Apd>d#X?h8a5-+;x(^)@Ilmt^t^6mqb=}?v z=aKXGUzp?rGarJ9aLb^`jdbn~gQ?0x3nZJP7j{MGUVQD?_2yyRq(4Ph_N(=EmxjnI z%Pp6{R-qZU4q^VO9!k0LpHk9=zXUazC58bWp7DD@Rx!y+3#L;O)*B9c!7S-z5RJd(HBb2`x@I;{n9%0ZE*Nf49#f4>qYJ0 z0g1uIn^?o&Iq%C)u6w4-mvY!s&UXgZrunEoQ*iH2KV=={9npD$%cxeA82K*If0<5d z4@67+d&xXZddt-=GSbdm>-l2tLy<=Dawo^_DKTbgo_>FEw*SqiXnZ(qvp_c`G4Y#X9rJD0hIK`Re+iv9DJLHWD zQ~Z5Rhf;9Qd{>3feD-!AJ!9hm6CM>{tF^-;bLgIowTGwE)qhONm{5DRkkLC6|I@*x zqP>c*-QSbZ=FxZISBO8`XtvavZg@ZncBzrP1B>__@Q$R^+MSa*+75|TebCObF4c{d zVGjy8oL;58pv!J!kZ%Ls-jyXGTj$LM6Mb^7x#)aBaUe0k_?Obmp}FFbF%xD35Tc_` zq3r^xutSBCxr5=KrbUwgnLRLL8kK`e!rgrU{~qWCVU_2`RIPf2+E@1^{lzJ}gkq&n zbOHTAuxQ&L5=dakacV+i^s%Uh2+&-t@kiAOq0&pADc9y5MMB}v8|kg?=O8y2AoQm) zD4@{&(U;Hx%X~7~8Q>B(QC`ZUW3|nljVarZF$=5$5A1<|u5w%=fx~FUmG(6i$9w?t z3%Pn8G93%w`hx6<*_{Cc^J7n@=B>kPkbZ0F3j={*8qG4D}_&L8uSGY_A^O+UD!*?i=KZ{nJaxdni^p=6G+Z+#A^hJr%|YLbv6V z(RYJf!S+Ic+CmO5PLH-4l#1?$fl$P1@W3UvIUMiy_er#$mHdyu5kWFgT;k|Bdwjk@ z6_%L$xQ)lbDc*)%{3U+`o8sqBNjqaFVzqqF+W*Ct*(YLsR>_6{#f}3FK&vwu%@O8T zMU2!$S$Z$KC&FJpUJPipOTqSmdwIgN0=U!^At5jLVpcwU{eYnN@Q1UXv(9=b>sZ~n zpg5Q6f2^Xi(`aIBJDFzk=yw+9VadZPJ{~~L;gt1iUfaENr3rww*oW=#io)qEz5A~G z>9{-%DtDFy^rNSW+|wP7zG`geBen;xQgQQJXE~_S_EN6dMV2{t3pH$Q^S?@4gsFE; z3JlxV9W5k~hr)bNtQPuZ6uWAl>9$r^1lyA5M(j<=IIYrdrx!g+QndX3xVw@&5)i6X zdWmzH*kF@$ZBB*v0~c4D1Nwpfe&P^6&!ZT}b{`U0)#Nqqrc#EJn3A|* z)ytDtRtzWcl2PN|FrMUmfXruCZs-27jLz&gl8DT*e1Ac)KnWs7^RXgc`;l7I#(YC4SQ~01JgFaGjYSf zdLH>d9v{e;An&%=aZ=)bkfwq zV&_S8A**CO$vvGk8D!LL4}3c1c4<&xj=(&Vz!TsI8zO=V-&+uWWM69<8A=DmU0(c# zV?i+%YXUqt_QAYA1-}?D4fn5s-=#*FyjUM=1S%OVNo{kNT7F*riWv_nS?5#EvYHS= zs4DgR!c}UNRl;-E+F=cmspK6V!S(^nA(7ZsUP0`O5$-+PGy@SGq1z2le5 z>NBZ^v?_giHWpweCOXDt#m`@idVWymIg=j(JT{&@k(Vor#`ZJH>c$Tb-CP4O9@cI* zI87PV{9-H@5kIO|7%6jxRQ z1k%o=@VA0Jyru8Z5NLK6KNBtc`2ZDtMH~FLKn&Hf(S(nm#;x16A)Gg8M*jHvXRY}@ z>g+(AgU-w>58F`>>I}my66M=Mn%c;%3x# z_FTWI1c1Zn$K2u&IFM8AU``7;c*kfGdj z_Kw|#8x1Mr#o?O^M^3Q-uRH$zv~6wO0fn_=r$CI7$vAiW#>3J@rDO52J$?u0lb)l9 z^o9>G*HLn0oI(O9jsk^8#M7;X2kB#-kMb}_pthl75bt}(tKa{4SivZ;2&l;;3+c~J zU-S{}SC>~FC~-BfFea4Y3`@m18(uHe2M^TUSiJ`B&j1~tTL=h@=8(&30`RfC4~T+O zsiSSIWBE(%05F+%Cf8g}K*j|1ZwKI6a(U%~;*If?-Y00=K-b-r-W7>TUs;#oDbx_k zS*Gw&F-rbN};wD?SZ|8B^JU5KCc(7Z!*VZ=NaJ!~PXBUxaesNxJ~;o-oY`@LM;aXzxp+veY_{dR!(t*p|TYwEjfGo zTzh{b$yDuR7x=rq_wCh3yKmhKD8ICN(qOWDj|}XONoE+!9%hp><3+DKCt^%e0^|bR z6zhz9TOV!UKv0%(bp7IeZ6MXq&?p8P z=OVuaG9U)T$z(?N(t5xs`*CbZ_F~0f*?Dr|865W9b5Rkg!6UOH5&b()W;V&|$YQ5!)+tnY zTU74WNKu?vW`m~tq4=jgwAc&6udW+=0BP#4n6AW2UN%Hj#54Ew#$T=0oNX<*fv?1LV2h&_>gNf$yvr*N}-T=j|rV7p2;Q)47jUHpy!a$iYaRh8M0MV5hvxW}Zp$E1AC! z+pzd*UU@aW(6B;tyEQb8w$J?CcqroM)JK5M2`akQN#D#ZV)NSgI^jDy+_*ge;?{6v z;5$G5*Z`q1`EdRkVCYt|eoLwd0LU2L8S@F|7wCUL+s-J5ccd;K#08z` zF%9e@nA@fME$8|W1s$uym@!Pr?E+K&O?l9XQOnvK-v%x28Zo(7N@V|WlF~aH@xGMo zUGwEf(MD(FTVR1z__V4@%;5%>QDZy7LFqt~Y3)O%9CZs!R9|Gu4pXnFBP*MxgwN`3 zhpzWfzKgxjwASC)yU0mlh=eZ`WHLjlfcU!VVblI>ayZAYEGU-D-7{uxqd9B61Dkbt z+xhHrU{0v)T*aB1*CpbMF3@@7b&u2cuI4zphfvO-AC$ts2U`0_Jq{rew49DH_|m(- zr0UxTf*gFM78HwU{*kFGP9!sx>Oc7Rw;N~U(6;Fwd}2m#W@8*~{nWe@d;;1!Py%SG zl;<+-r(|O(_ndfHiM+YM^^{KdlK$O2<8Be)0+T)rcVDQoNEh55nrZR@>gL;M(y_M!$p@mIc2{~y=`Cr>YhuomE_qppm+7iFa{xhY zy@otx!&EnK{SLCG_2pgUW^b1zk!#YJa1NeR$8a3uf)eD=34S_uJVc%rS9ovBBzQ!LyFJB}${1wFTyQx_{X&}AiG*O@`% z*`8I|Z*1sJ$ix;s$EIMD(byv9m+lsS@A0`fy?M31U>g+WR=RY#o)+=U*!211Eg+t@ zb~_vXJh{>O*Zclzh=KYK9v)dC*$0<}t>Ogqu<4xm&-p(c9V1w{Z&v@(n|ok&;=ZG> zFJd$DS01o)ZaQ#^OnDF`y)o$mb#BkIg%^5h1Sq}L);MubOjdC#D*Pl<`&HS?BJ^>j zTI1*{smG;)Ft1$3R7`{96P$$xieg#up)M9vXI#NhpB=E$!;cWO{bYmo4#Ypz*Ouzx z!(fk9RBf2)!jk4|JVAk-pjO}B3)Q?`LC$NgjUo2?y{&&AX=V&>cMZ+su5q*f?y?O9 z2IDe?A2tc*huU4gE_MWOUU~ zm^dU>?sx!*oAN#pn1T|nv&Tff}q!gE%W##)+i^>|{9QTp}3O?8goS+OMU zHM(!X3kcKt(tty#d(@L(uPhuRs)vmV7XubfM9UB&(8N@WSvQ%89)tz>wp^N{Gr^3p zWORe+CBm0j_rW#qsfAQ`Qab`G-Lq99=5C76Y0uER@i@472sSn&YBPZu&U8rFO!7Lr z$pUZ{__c~&o|xRc|2edAT>JjyR9I2-*NF{u#Oc#apzc6gg#uXHnXjrNgdOZ8l0K|= zHgyQG*vwE-;aZ3=5>DpSElYJBk$aOb-&j+W2`hDkHDp?cJ$>0Rz0dnD>gIV?LKiTm zpRBccbxK}`(_+U0>LzyRX(>*Lz6&NB9q(%h5R zo$wmT-f!!5CmB1B;ut)co?K8Qbt5w9%X*5(j;wcMZ&sDA4(6Khz^DtT050EM1QI(j z^$Lr2;iF?SaVnB+xz)GgIhT!|F8V$S$yMLp39o<5o!{S}^B^flmt@&Mcw|Fg`mEtZ zM*PQV{L;_~c9hH57L#f03wxG;ns7%7H3yzvbNDC+35pJ*JJ ziR74FlG6cZ#IrG+yz*W$=|@0hFept-j~Z*m)cCJD#G+>~luI{~sU zd`CUA{jN5tl#`B7LKgP{F-m5<0ai#|8aBep!f9V`kE13rsP4_f5Bpm^II(s#9euw3e{+VCPB z&@_o@FbHP@I}bis(mny{E$#^H(OGIV7|D8R)Ytl~j>jm){ld^YJ+Ia9 z{!ES?ObmAfW0aJ9)Tdix1B(>ztn+#*%HT^{%v|HaCBGKStB6Snfl|5}#vCRRixWCP zq&8#qeP;zpPSs0R1-^g`oO8Dw=BJk#3rs_zUJ;N#^We9v@7E0Erzn`No(OY9U)Ry4&nR({Qv6M}fA$i~b~eMBLA$>A z=Po3ba~^I+9mHT9Gsm|ItKE;QS{oST%Xg+36)C0bXwp8nb)3DX<$YXnEhR2T?r!Vk zsyh|@&vM$=uPkN0ZTYRYzsoa6kDlo_KG-;V-6VgqFxBF-;cJCfE_ecflmHbobbqP= z9xoaog)99G*8+Bzf~i^p3to%8AO_h`gupwTGMI9fA7Aov*aJu^EksU#d+%xwFS*sJ zr6v6?5Xms)-jI?$05U##y`pyf_%=UiFjhDFQnoJBBm_ea@V_1-k)+qy83R@*)*h2y(2N4fM{2I%j{T}_y8(P9@4Z84y@E<{g(>`B zL3npj6QI2;n-jHZ*`uJoB>zbZY{H&3yu9gpL9st1B=hy4!2of(Lx0MD1-0qQnu6iV z?Zr)sPlpos@2}3wkNP=1?!$~L9KzZ=aEJfIOSuLUP;YjHlo$~S<`?3muTAD;*-V61 zeoeo<0pjHW6+C&>{fQT^9u_-vci$j#9XsUr5wrzZ|`5 zCVIl`C&-s!Y@`VSb6a)mA7n6nXCIa2}c^vzHzxMibM`@c5q|#tnWTovBtAqckFeUF&Du<$`$NE z53aZpc`8EREq>Vz7PTC*w>~5Zc`bW9@@9-&G-j_I;;Vr*-4yaN;Si?*fx}qr(w6lW zv_Az4fmfU~I9!W1(+5V@d6kX*oHo!{8nS|VK_yeo20gv?sOH9)oKCXg6KR8<+ru$J z5BVma2#C}_2))!H>{?eD?GRY3E^;D1ikxF|7s%Uf(3F>fcT#3tj%`r=l$E)I;NWVH z&MRrc`;R(bIldQ?0Q52LV>6&h_^wG-)B@%Qom3JOQ*wumd~=gSqXdp}gcM;vU&zPQ zt)i6*Za$4QlB@kl8Lu6aehCFL4z+pW7OEpGOQyFHla&UIK?-uK5!_J3U3_P0KzVst z+-2HUGaaZ7pP+zBvsV55+dsyWG>y#xgvugw3j0;Rq_H0Cu1eU@JJU1vLf3n(D`Tj3 zOK!FT0bjtzJ%Qhxw)j_**eTb5Geg+2`bf5Mb?W|_>(W3-IjnULFtWm9BJ@(`)+^hH zQ|_a}#o#DEv0DGs6Y+8Emov8q+=yQlrUra>VxBN>%4o&xuUCu2HO)EiAn1<4p0)Kg zA_MR7q9+E?K74E|RbwU?P`85A2UzUD$lkhx)ejtYgW-px_^OG63z4Qig`H6SK`;1V zD0A|g>{Fi}-#9r7*JkV?dySY^&}*iR&dh>(`}=PRgOx?TQ+7JaVU}^?k-i`ak`Qh1 zn@s?^zJxUVHAm(~kmIiP9(Tc$$v@4d6=h!v45ST6PFIq3w0yQc&=tSjaa&lf^70JI zrFG*JPHM6KiI@>;g>Xyya~14IXxef*sz#3DU|>Xy6j)$081k;YHVM|LxDT}TS8VM) zR8o+)oE&F=LpeZKG4W*A7&XM=UsznsdDlFgTWf79gXbpK&1JoiRUgaIkD@n6)93#+!qUZ?B%gbqSKGT84t=`6Y)W>xOLvYJa(IXRAj`@Vh+J;*Y02#X^E=Zak>L|Yd{Kr4ri%Mg;4;_N+kY6PFl1Ry_HoM} zSv+eZygeuLYnzB8zas$9$!;?MHdiSRU^LAShZk_P!2R_>8-`{+S|*RB4&yyV4=p`) z3e{*FMan&?X8`I$6emNOrjor|^~hkJ$44G+f@ym)sk_W%JM|(<9ep<<$pzKCTi-ti zFw==7m(dV&`joHK`}jh)rm$leQp2kdrqtsr4k5U3XZP{VrYqOci8~MEv8Q(o=AtA$hRe!+`2A6)g z%H12X(HeD3rMlX3%wNz^-~ePCw`ShBCF^waelr3+^0vFkuR?kFcKx(+6>7Zth8Z;8 zW~ZXnkEIbm#yS^lG#;a3-D=JxlSo)sIz(8meB^LJ%$W;Fj{@0XPvz8|{8}!H(;a(f zeHM^y>i>nRL-#p=Fu9`+bFbNtQG{yIyKY=AH)%`WdC(jJt_`j*}_PQ1HqpoScID_25;O7ZJeO`>&R~+LnQvi2{mW z(@ys?|6#hc`i}|6c0P?r!z3KZjLvZDE+M>LcCdc*=FwgwrEzRzACoWUw^D?j#b*IG zk3b)ar9di+`G}a8wI&YUdzKR(=CqCXIa*zBa4=lvZWmHpXQG2m2c=VWyzRU`oH)*_ zaZkdBu;%Yp(WUM8Sbaw-%-t=`U-2Z{9CJM|M$syf8JI__lHWWJVfD-Cwrak5>tP4D zK@oG+`xA|8n8QzE`VU1$6DFoYLv5g?R$cZ_^O|yp3yM@L${aP^zOkuP&>!YTN>Kz5 z(aI50xHe6eaQpwIGBQ?TXxEyTrFQmmE~okA!TW6jarJ`Y1HACkJ6k}62iHxyi2K## z7yBNME9v~Y?(LqVD0JjcYtF-T;MmEr8*@rlEhPvgmjSd@%Kzc*=uz#;fiXy*A*bQN zXL?KWf9DiSvCoDLh(-8E7!YkX_>7tGvKcR$)jcMpi;3GVEm6xo7O z@~Dp*T9DfezDAU(@D%o(s;?|HMhsNV>lH)plO$Jn7S#>))a2G%Q|%Sb-!ogu8J~u% zCqSAC4XLmQsXM&skY4CKQYvjVeR6-^W9qADg^D(rbWT;(y#sk$$C-26Ff#1`!Xe`q z(-BxA2oJ-km7mDYjhe>WPBD!1fOU50#X4ju1ri&Zj1kSC)-K$4@pAHocGHTlMReSx z?enK)Itib7zbL-^!*ry0G(SNr3kxPw#N<8DNoM(S;%XBFY`*SqEG*fE&j;74liKKg z&(rf*@Tkx?&t=;lYa+wc&!lcH991-ZPpe~K4l|=pC$|?By4i?liRux~EXeY4cd$U9 z#*AZLwyo$>oLk46FT3#)tpx(@CYEJSWkjBj?0QWk;N~T=|9n`igVdfDeq1|~DKPN8 zRxW7GX|cai=4C=-m4z5CBVB7G@fhSLxcndn*mn=DJdnMG&7$M<1im|Ha!gt%aHL#h4Y{o1vhq3};*Hz3tE6`xeVe3N@0#gEdE2C2xLQtJmoWm$c1$5rjqz2bzpFi8KDqwOW%p&6X&X99v8bkldUK zn%{-ZV28JhQUY8(O|9?xJMf7@aB_`_*)_b@<7b00vYbM}0>E+AKJ7NFJM|gHl;jL{bqu5J-?9ba6 zPk^7&Qu4-sawYcPORw&%^HuRJLL^7u#63W&nN55GeE0Ljp0TBJU475^DDMjDf&vVI zFF#LmUNiyb{~^;{``UU5Ou3Bowb)HgtL5mk$#E(31+eTVG?mp!p!l)XYxcc+cZCx- zeCr!)s;3#_1T*WR=iJEIBtdWFYsls0_6&C>-vV5prKSYvjX2{^9{04wI_I>4!ote< zT&tAyYqtJnwsZ!0m_h_|j9|Ahy=8U4!Ki;>Qm3T+P?O?DQj&Hs1h*x9L1C;O=r=Gt z(8rzI>uyEnXPR6F-4>ls#RpY%)3OiTbDmbm;$3eFUcRR*Tb@3Wsn6Mv2j>XMeAx?; z>Fku?_lg_`T_)Ez7%X$HZ3+c|!;2kToe66(>Ue+@?YiDW(hv)C7ihb+H&|Bt`M4#vCKZ@B z^fsro;z8b<=Q8Gs(UUs&Eol_rFRu(p*rk&hSa0LAL!K-xxgSsSOcX;PIg&~5f4k6` zwexqu6Q9Z5vN@M7EC5TgC)Fk?=-SNii!y2rvN-URtUQ`0--BOt;My?JT@_Q-Gl3MO zVpr~%YXp0(nz*d0{^q=kpAm*Rx%tK4U_zMSh|8HX5B*x2i;GAm2h zcS1s?bw;E(gd8rf*2Hv7+(}zs`J(5@Rmxd?kL%_RtB>>SaQPIz(Rs1*!GKria02&| zht3~7F5>RHle!-4B;LX4F71vXhE*&Cr}z84pXRe=^9r}GWmSJ2J-?;&ZYl_!X z!x1_m{8!>OAqAhScIX5tt4#}YI>m$niYBAzeW=x2jAbtKh;2WXqdkRzf)Fbr&4OHr}{{ zga#OB*>+Y2ki?LPk28GbG({WH1}A0cM#|w$fXsx#o&11~Ha+GKIOWp1XLnCMC{g^kEsNxp!P4S=>6}iu)yO`ROTZ!W#c$Rhl z%IYz&IhzB%-02Qko7b0g(_+UqmDN6t+`(64Vhdl7m1^h8t0C3D>Sjzp1ViRiPHf#+ z2svjkcwM!JW(~XB?v-DIKhzT+sD8|cOz!nh;$-*nVRF78Q9_%;GsS_5-lJ39C>`e# zXmVT-1Yomrqj#f%C?Yi(>>A*F5I)nx$j*)CAd?Qor zH|r3n|1@5hF+~(oA-8!wKpRy?N~29Us$l?E8OgO)cMF3iCwirBy)_a6b13Zv|Lgah z$`UGJH}Frk9geWVQTO>VH?H}L1xoDQ7E8nAO*_Zk#uO&fbZ$v^KWhbP58QM8@^}en zhIFlh7Rl76uJ`sJU7xKpS!K0w{;P&@pRQ2;Z$ZyN0G%FM;WpU<(a(4dAcc#tSLhyP zz2y2|8=nGl-?*wCs61ZtlJqvjv3Jggv-;|D#FAKCZ|B8UQ2NFw|BzMuJm<{Am-wp> z5v$@zVtGFH?c;Q*>C*_j!*?ssAO0^I%^tloLudZ2wpw*V2bzoN>1W-`@&RAv)4Zbs z9cUKb^B+sA-E>_Z@FNZX^5j-?^xI)iAHO~+z1MNyU$E4C$iXdFI~oil&B{?WefU>T zEUB#WvW&9O<{f2Q8lCIK-6DzlX`{mheZ{Kg+}aB4Bvr4(v77? zfZ+NYtuX_)&C=UWgBe)y*6hFIP!CEgS&HHU>H^<LH zY%SXSO8SeTVZCXTb}w1iqqR`+vvKrlM@{BUj317e3Gb|QdSa$sbwQzF!Bl8JqG=WJ zti^2h1|d&i)>nIrs}CgRK3Hb|BLHk!y|;>i@jj8ep)fZqK2Hs-d%b)Ibd-#^;+Qo& zOMO8xfPC+UPFmCmOdHbV1x|X7^vKTS5SYx?O4b`M`?-1M8OWc|vVQ6k8@`rq@(F4E zNBDn8`U<}$+xKl1#UPbVfzc&38kEjaqhTP;h#?IUDoP4SjTj|0V8Dox8=%tNqe1CL z8bRUv+w;Evz_t(E_jMiTQRmde@NH7>lq~wCoJ6~_bl&QJl>tZSfDOy9{@-%^v5sZ0 z;fY0Y-iSC)T`G}zb;>~Q`3hCpoR4d;4^4WxiO2o z{U{opy&HdidOAA>>4Qk72fE`3bWa{o$MRE}=$YsV>yjrE2_w$93FjoF+|nEM^|4h8 zGXHOIT~_%8{H6d!RRb`u?5Bq6?bOZ$Pgtb3~y5S>|H4L*AgCG;UV=pd7z}J(n{ZI+q&MD z51*)XUh+Yha@q?P%IVX&=t2(_RIG6dr%E!GO+jHg!IY ziNqXygw(6-?Q+P%SBZ)9K@cYaN&=S_$|7CEv`6U)>YwHbNznPbEtjWrSz;O@TW&(0 z)*(GmcH#^9f8(#%sYbcsb0sf7@fERhsVFPN=2z|2t3SMd2Vf!vj}Yr4XjVGbe?Yn2 zENX;_%C!U^^=rWkF*)h_4MB)0I*hV?(mSm&(9+#JME&$Oyf1RBDvk*&B_Vi9Wu7Hm z{dBr;Z`|c`jMW=%7he19ppw*KjbfDh9Q+kY}byZhS* zpG5&4aI%r!NsX5@oHP0fo@an?;I189$RVraaUG-Y$>HQU-T+8FMiOHUDA$8om zI+XcA*R-a@Yj>)WRg`l6kA%J=VA6StyW4__ilnCEGD&d_V6#IR6lf4H#Mc7cVyC70N1&7ra%?;fuVdcWA=t3Q$Jr?>O^ zEVkS+%n>OYymW5v{4WI-Q2X0%#rtuCm#ik^u^V6TX6#5&SUXcTzraz@ z^H0+Ac3DhBIQXiuG*5~9NiShg*xWUDJh zWW)=qtN04PpU7A7U;S#hjR!hr8m7hHE%J2@9a{rupk`Pm;i#VY#fZq%p8fp?TAKV; zZ4au#S99|?o2+)#{kp6}ELfh3mgB{rU+^gOX2(gInYE8Q>6-|NqdSYIj!k>#`TUZ$ zJ=AjiExeqt!RxeJAWag^|EPOq8L_?a@b^!1M^4QwX~_Ut0n$WbVxG9!>gvCnk}0Yz1|T?owJ@CZ^65^2cKZYQ_1tc&b5$h zJ*hhT{xitT)uA;2!Whu%9Pw&>vjr zG^#sYk1K=^^o9%IF@n*qPBjx$R}Q+jGY*l20(N}FRsVN~R*ve|+lhYAj=J)IJpLlj#Y#qOB%j%fy!2vY}D#4W~Vb>|~47hVl zknGr>&jtG|xbJ(w-swEq@6BGGu9)Zv&XBhE#b|blVbOe}Jr7FXFu!2xOm?3ucmOfq zZ#kRu0{UsY=Pai>&SMkx?*7u!V6RWk%;DuqwG4cGr<2=t*kH3~VqcM5X;OM-@U~G> zfY%NwVDNAchp!2Rj zXV{%ShzZ|#x-0j;Nv;qIqCN;p|3dT&UGY1S?f&BS+P>aWHGpiU$?rw%O(S%xF|lfD zBKk%;QjU4t7kH z{f@eZl^HxNc)z6%Z*doM1qvzhpEdCFw;%nLuKAOs@+h%Xb=H~i7o|=KgSne-X613I zP2VQuQqlpe|Gp7TP%m^jO)B)-G{N_SLe|)agC8_VoF_b;Y7*GxEh$~yjgQ>&EcPJv zmc92$?Aiim4h$`DDRt4&Nva5SkzkXE*9l)*GB*Otp{2YSMs)zCDO)xB*oV#~66-RE zlS!7>>En2{HuoA|-en`4IEYpy9jZcW`RJ+f{5_eWV^WVVx-?9X3ZUAJTIJBPH6b6z1+CIhhj z4SE2$KZ#xzT~3K?k^Xmbr;zhWC^5pEM&U?HT;LJC(k}hzT$+iWoU`XT0lW zS=qAiq}u=P^p?7cflR|IB(hIAv&oO<0?CwW;~Jf}S6NBQT(*L2b{RYo1P89UQmto9 zDSX+~D#l`Upy^1wGirNEdjD>wDB6zSmLVTf zOuywOjaj^VA{$(?Z6{E3cwS!SPJA2n{0S9%0K384N3RbWo`X*l&IKbTI+HF8{&(k| zbBiLw>R!y(gfafVo^=*+^BxY%dvv++46w3 z>yU<=m%=~wT156!ow12~6ToiAsQR+5Z-yJ$(Tb~j-G4OEmYa=9$BYwx;DLttuOMIQ zBS~kkl{u$E%onyMvHXrD;zXYJiNC_s27{^8lhNZm@Cd&zfl>m=o0%satdlNc=H9%Q zK1as-`Vtg^Ndf{Mgs!NN7juss1G%%~EdC8zN>Lx|mFZR#*2JmP#pXCLl&`@K&Z%DR zvAYGQVlHhX9Rogdl<#qUROw|Z-`V|<5cLi>jO|n0lV$MKXL%UG!f5I~+}dbi^*Z73KXm4=u|vj^#~pMoWVK6w zmY6$1WpiBY;b2f%M!c&0s5;R*rBS|rO6hxhx)OkSMq)##PEJq~0GQj8v`xx7H@^6 z&@_K4@zk-h4}=WED`DtLc>U?8>)ac@TqdhdC#K;xoaEqgU((KW)jaSp}KDKFADjMBih>v!}vN%`xLZ}lz$sb z%6loGPLtDbWW42vDsC6k=4Go{csjhzsAce-_DEbvk@^4b6ePIOfWtb(-)fpXGqp4@J*KU9m-qAj z&2;JOx`~~{+DR2MU6c6z^S82v$!DXObC~UbY%sDHfHl{q_6<+M&lPxmHW>R>;4*PV z=Rwp_>HtRVvWzHWTDD?Rs!?NQZqF$;&{48qKI?g`J|a*}FR>>J8}&c}%2&=)*{0Q; zo8kAU>2?t5sixHz14y$oyO-*U#u2vkw~zlhfJB#SDxQKUmyd}!Q9 zceCyKT8bHJtQynh;dMue;{gzv9Sqg-<#}t~DW{)?IJ1(cANY-_dU3&!_@pMMojbxq ziFkYeajn8)roqSaR^U_JWti`%t9z*QD*~H4JSd*#Z-fCczeMH_23&S>hMTecBhJYp zZ8Ve;V3gD~<>FyUXBWx@OC>jjQso1#*SVbL{Qbia*Ix-kS8T(xdW&8WmUOQhn7>>v zD2Msc$*bp_1HsSF2nJq_uFMCm$S;M08+fA=PXq}X2EyarAiAK&YnG)OooZyHZ6uPZ9D_Y-gqkfcQ7FBq{*;8sL`OKg zus15iFY-e|$ntIJvzW6(Yjo&XGak)rtaW~8CCCQgHKw{Jbv}{+86-5RP#^|#Rj1Xry%kF-2oR77bIEge@@Z0w$+};osfz_Z8JxtG# zdYV4{pDy>b!UkLHO#h?B&{XL9DXkTf;Q6}EDjLc?wUo9a2|wL(%IeLYq9OkUVNX_ThoQCY`_e-->Hecj5+`G}94L*#UretqxS9r$=| z!NB=WDg}+MZd`i};))h$vFC~_bIf!~cMI(DIEt~*+VGn#d8xceyvO-fU{Wee*ymZR3WfS$V)YS>@jOi1RJ|Ga&1^=c`HOi7$3vxR_QfUWBdzWOU2sHi*@$$_KQ zrwsr)ef?5CnshO{aMLDd8O0~MlD35Y{4P*M*l}}RlB3rNe_`v4GvDg+RX-v1Pc|ln z*X;fiJ!II)ZM7=}RI9iJ_(OW@KddN^ltmct-7CB81&Yu`9K0g@r77r7PAKucch5Y z9M0>Yv?I`c+OX#`_ph$QdO}&j~_;$e`!-=5-&4Ie8W1msfE4u?EJRz=LQJ#ezgEjKWHO%i ziGe!+4^H~_2H6Gl*{Zjz{Ai+4GfscgfO^QZ=cUhzhTTxkM=>_RvqF;^#uY&66Je_( zSpZ2GpuK`)_0X~6eFJ0RYQtuPlrdf}b~?PW97(jMQt@=25i&v0qn!iCaBQrCLv9?A zIm?%5JV_|OLB5rNA32%21CSvSbpT=|6jskiO7w_OC=k?j#$Ek{CRl{C4KadXR4 z?XK%jFfRBh$YH6!L^9>;4eh;j+Mos@i<&=J`_SSUO^G;ebrwUp>k@%{puc|>a9SqQ zLk-Nu;AWl=LIx!|`wG2(>8emJHJ)@$)({w<`IG4$Dub3Kc=KR38I zRwK>aXL))0xZ7UL@`qXfQ2ElEjgs^@*{qeL^~rY{Mndu-rP$buR=sBu_4RWd3*qyKPTfFCpvbzAu;ZVQAEPAFAyh5xra;*C z+Fn{q0Kz%QxNip3|Dj5c@T)ZgAop4d-T_0{Ot^e^ATV#dp;$lw0;Ce1%!m%TL$Ky% z>z$rebu!_3Nq};ppOedpI%5kMV_Al`bLO-6;|h*gAHHelMg0in5lnQ?4rq4YD4R8t z2+80}*1nDDSB?YEz$0>x4P|diMV{HgGep&yfV$^tmv-^$%vZM4mo%>>!*7;|N0(H$ zy1bYwF$CDBE#;%7`{$cb9cABJb2BIT^$^Rt3GwWlGdIK%2jN%enVkjhZyLbjoSsAu!|8l4o>isqy4Xfqd8V@d8F7}cufv3jnSg&k z#M!4gm*#5e&QkPXe~Pw~Dk%or4iBn@H7)otZ|+Xo&sW4_YUE+BQ#(=yG`YTH`0$RfH0pP-KzdKxkd(t`u=hj1k)8o5V<0Ot zl1EET4%irw{Ex|FJ)-WP;J37gDNYh~KF$&JEBMphA!}7J$t<+d$Uthvkn1F?2PU~$ z_OrkXq5Oe{e`B-EpObweW?owxsLE}j{k+QnewPovaZVO+2%DI0mkz}>&mV6^%M7eJKQ zYbVMAqJ4p#kOn#uPD$;b-$!C#Al+zq#EX!Y)*`2OLLVU3UlBTb&e^p3EINU{jiZQ% z9sV0l7a(NrjVLuQGces*6t>N=2R~t*xgSmj0Pmq}Z-)v-3EpGvbquB7IM|+94$<=) zkWDeNbVGHS+-#K>3GqF&W{ZsTc&0VcD?AKqibA=?ld z1zNlZUx1;}n54Fx&vHXuZacOh^bi#(9b*7_m zmG^k1yAncYQGQua-TBSH{dl3TsH2}Pp;P{p$Fxg0!Af*B29)z3pkr7GW=}$l-pbfr z>R7EBH}<&k+t7kDu-d!jGI9W&9Q~)9m%;lrx_Rc;Hp(=$nqv4@Gs467e=ZpFL6OmD zaVM{ie-D_S^-px*B)tDjyvG?og_24Mm*sB7UJ$yF_ZFo;4U{t#C%FIEuk-Ov=y`kx z=kW_hLFLg>P&*M`5X((PuCy&@7>J?eDK+^zcW9`7Six+GARdBaH5- z-c*jb47dzZ&bny-0JCTDsXQB6pEz@Sr}~<=!X@)QQUU`GU%mjZ&L zVGcQB_0o}Lw;TmoQHb16C511(Nu*Mp4w`I0{Rs`%PrCSrRU)De7q;xcZS*KI)1VVf$j1@ObmR@q3> znRYEK>{qyhV$Qc}Dk?7TU05>Jv&EWJW3gK3Ga@9*;!2Vftxl)6|qS^b&&)p5^X zwVL4b3u~IAF+TE>Q!-Qom#Bs`wSTxV{_RmE6ar{eE zg;seTZpp(N$FD@uuT|>8*^aXC8-DF|?!gQnw@y~F0Cu-=e&UDR_C$Z#DOuNfj7)M{ zkZB1s6V~oeZrL2N^M*11wJA7=MTRP-Y8HtrdMG132@j;A6XZb%w~4XFBs-u1JXoTp z2%alU{a*G!G2Qig)&w5$PuS`uZ2RWoo!raEH3NrH)V#>HC1(_!hWg+3VoU53wvnm6 z&p#>$iw%I~=l*(Ie0RG?b~&pk{{b-lP*22*IU;`@W1;43Qxk9|IRc}Ex`RtneoQfj z17eF^3HWB}WTQQyo%$|g>=(S_mh`E&z@?=J)?tZ(!mc*a)tk2qJclu!=m&9YF;*rn zyxp(!&=1sgM2?MUP?AE@v+{%t2owdDZ;3qsAXLTd5if=t9o)Kw?qp> z#=e?^65imy-?CBGYC}v}3$1awKulAZI!ulm41JzP*kZ~sm4Tj23*|*jU2VPt54Y?s zwU1eMW)(*aAT`BIfLdCed2}w&=6SIB<#x}rqD~Y!`tC=JQTLk3J)9G>EO=s5%$~*w zn$I?*5014Ixc9XpFLPOHr@f%+n5iZGErUHnc2DQe4^b9H@q)y6shID~p42{;;ICc8 z--_kQD7A3bhh|sYL)bD5GxZ5okBH$rI9XC2NA5UJTL#-51K5G+|6u$Vin4GtOda7y z@1IvBLvyTAA|zm0QjrI1fq16dxI+lV71uUq&?p;*)mnll<$je<2wep>D-6Y3nai@*QKV=N_058^%}J6a6|QQkY+gU7s8J|% z&QJSWpz4HW5VPf{!$8vbXWVwU<4h?(91WMqFlY88HxEEv+f`M_cRhIMGK1B1XVmMU zX3olizw~pVGS1NlYN$KWL_P5b$e^1B##m=X*&5b$i}Aet`Yc-`!(yF`>Ds*6AzJJf z>=1HIZ=J1X;4e5JCkQ&`XQbSJdfW22$|01|`TjLF`ZT3~GMu8L`>B6$o8UpPQ!g1eC$t7Ti6B3&hEg)59gy7>nEEC?eD zvJc82u`OS5ai*)QX+xOMot;PXHi_(|mVg_I1p1y7AG^{Qu z^(^Q6;nx5Mr#Pmn_g(BV8=%@#x|oTe>pR|1iHVP;l!}oS7A%zisuc)D{|}faHC6_c zX$m@pbjjX&QqwSY12Wqd*%=lCQI;Lj9Pz53a}I1y3?OLkAmb{%s;1BFmv`5F#*u~M5B#bGQC|y@u&b70 zyDE2y(h>*oo?AYi-}iG1y#F2lU*Oz3PRR#kRz(b446EsIG}A6P0PaNaynIVFb8dK-c2}Wu>p_1eo&`1Fi*WlC<=UAp7T!+mPtR0xY`KDEo--p-%J6)li9uO z5OhkZb~C}x$`IuSRZZ|^`7ft`IOns`*bPkl?{G_G%&c7FelRy+ErZoGDs#|Q?RIjy_>EJhLXmc?maqJBKOGHrA_80z;dLA{92 zwCJVcG2hCIMaXe<94i6hm2&9gXxWMi%Hm~-veEMs-WOwyuI<>p z{hYTXSAVKV*Kjay+%=@0W30BCo;~}CgV54dpnVH_lf@1g^-TBN*D&M@^G3kcI7*&D5`(A-Mk3j`xfsz~d5{ZNG>|P>&u2gp( zci9rqEq$2o?_KzyRJpNvZ1jpwH;vOB-w+V{FvCq_INk5X@!kn%>&rE*eR?)=bGxC*7}co_%YRk;C_Rn}h;JdIK*d3#Sr1{< zx7y}eDN7vjC#eczWo1w^KDDDteKR)f=HmV|mEvO|K%lq_#Vl4g-Z0`jytr!Gb|EvK z-9YH|XIMxzTY^lQtTF=-tXdLTo+Yj+`@O z4f#oI)_I>XaV8Uyv*m@j4R8xMF*_3&G$wR~)OHEuv$cxIEncUVTPxvg%ST|wi1%4DlZ(l}a z-JjTDR;>;kk{vW}qE7yU*;GI#zI>7`8V)e*AASBORTu^Z9>ke0^`vC2iw1Q-;S)H9fEf4%V4{u zxoUuwnd~Psxc|za{rzZIJXN`en0n+MC$`|W!>Kkp?MwVoC_1;o=pQ8`j$Ll7f-}TK zxz{c06`@JilL1Im4S=e@uF(XOOXS?#VBW%~zGA{~j~=-A^9g8~o3oTT_Wk-g6AE2$ zh_;i+^aDA1Jh)jjMZ{TJIHuFO{6I)qdL&Oju&06$F1_*=3Y)F{g0B_Xh2ma;olo09 z<~VoWotKTD+Is0jI|Zu-SjevrqjY!neaUhJ!$BAK3aZPZ^%|W!Q7y}lIXgF9M@D0Y zqAqGdMeuoCWMdH_vU>=ex1{eRS`S6EbyXx<3DtOD9TQ=d-Xsm8Yl6oQg}RJ_o|W6D zD>hm=U7T1?8R+oUdvK9Ty`V9!iVwg(vAxIqJL73tcKMa%8VU+Xu8V6dRCxSMq&*@W zNpJ1Sdhj|ZN#RTUM#v*cQw^7W!XWG3q@2weza4Dwa4KkQoA;>E%}u&A-SlX(hFCts zU%D#5XKSftHo$lN(=)kLgAzdbFTu3^|9oqa?aXF+sQjHfJephn^83S1k<&Hx6T{;% z6S4@EYoIL_?^fl6;(ZGrs>I1`KbYimbKHqXj%yBjxj2AB9;m1qy_DNT$htVjB{!us z5)5!Q2G(m8x&H4=!b+j%W@d9bWc&YwVtd;oI)VXydO{%{2CT95C*M-(oCXO4 zHUa)z=izK@pKv$G2Iu41=fX0q8^NYn;fL|#gSvA}ZAdBdhY-m*E6=3++jtflohwxW z4p4C8x5T$~+U+=$@KB}C**S2qppjQiBdz9X{$!Lhb-SfxzD(@|ld%9zb$|QpW(rkM zt`pO*s+Hsxu5b#|5y1>7GB!`JQQ6JBl~+7tT5rX!gCusBe{Z(OxvK;YysZer=eAdb z___ml@9n_v`B*(BH=%#$7SjvBD-e}4CAT&_DZhAN-0x%JjrGN}?%y3*7cT1DEuP^v z;N4-rT`k;dnw;Fx(Z(x;8Q&OX3sihvCDa~fnQIQp5&ekZ^Cwh)D@2XJmT3O|qUslm zGfS{aQ^OVpI8Ws)pCgmzEkpRq^Ix(kyh>7}>+-lr@y*#?8DSM43R+iH382F z?Y-Fz(~x30t(6C`Uj$OE`$&6ON0_{Ab7CO){oM%3^C4O{=M$*+f$4;_<&PpNWL9;vJUO!bkv zw5X(|1`soPU!uBS;ITplcXHQsslLEe}6Q{Lznj=DPX$B++G+)7E7#F!$FQ&3$4 z9G>QPIyg=hM;{EsX!mf4Y-4k-^jibp8cnKqbD0<~bB+M=^9I8MAV$-p+Iu~R&*tTI zV$;Vx=Tb+P7PUBVZDSG>(3oq zN;y+HIn_tpo=DfItPj}(yed0wHH<9 z1x?XmUH%wCfrg_3n%>E(Cf*5G-&=b?;qVcmJuu_@q=?&y!aMSS$KV2o37sRMO`@9?VjTFs?b&GQuv%ZvCF(t{21L+Gcjbj0QiAgH@tG5H`6WbEi&# z2mDBGdx=%#i>H^29*$yz1PhhjKHNUS`bn=EW(Pd}=T@nrLnK_t^xOlcA7q6C&gBkgr+N_=K^b86iKQ`^-6K z?!Al4*XW+I7Ygm^&kuGUY*bfdgr_?+Va{_ZP^3bWgD=LvEYUfunK%QiMcWlp-I7{Kaz+x z@5avEaQ;i&Ihh!yY`1FcIx!rd(&|S9ybWp%J_K2dLhs)@F&=&azBna_8AERu`} zQ$HAu_s4j1W_lFh`l<`dr<4iFJn@ z>!8HGUGC2%GfeZ)R?QRXi41p=&d$sWCTU9l1>_(Ndf{s|DhgFd^n=|Hwx`isGGG;L zL=>RQ>c(9b{h{WtGiU3W^{U>8&L6XXLNrk$ItDFBkYLygfqF50Mrw{I{U@Fv!L@B{ zL+cFc+uQm5Cxb#)WDX_efx2pT9_fS^oKYRva7KM6AUlP{1Vr~y^GP_D=>gHlyD1ur zZR>gG)7jc_W~xV$a2N2sMZKi!O;!Y+)OF7X(S@$rCF*)Mn@z; zH3^no7q~j~QR<>pGHy7Y5;2-46R5eIWbTZU3xpA#*7D@GYl&xB(Nq5!poIj(2?ppQ zis&E6WjcGfhPtIFEkInqJ0M_Chq^ON8ha zdRbffMK9T8!@vsNjnM_-IqjpoFEsm5y#2s$>mAs&xkna*8taI zAr;EFaK`yJh6Qnkh?LhGoJPt%ZJ#`dL~9tkqoxMYx>fss{CWSJ{AT@U#@4Sx;_ytL zvpAzQ1zk_r1=Z7>V0obC5Hl!F)ME(^m0I`RK{_`!|D*A2G<#LOK}^TW0_J5sg;g@bXMzB$R@ ztRhD?%GL|_y1znKuc#({iVDiZadi)0JIK350pBgypN5t$lQ}0D_29X}jB_^GoxKCl zTz3AIZzNLlnAH=e>YhvPj5gE!&97^j^9)b{tOfbfy$%3S!Yc=^TuFt6@?PO@n8Wpg z53A}jR?Ui4x)uR`pkQ={c}!5dhaHzm{DZcrh*Gtl9kp0Bb=qt);oeJ&a@-CU9E z0Ah_QH62FGO3hjAL~2~$GChZ*y=D349-Od`Xqy)qDm=_k+oll2(9E5erwM_tuwKOM z*Acibt^UX-l9-;{DvKSC9cakco<-u_fQM=(s{C2)h1t?VzT{?&U6)=zv2iY{uo(;E z0q)mtX#IRP3{2b@wW;D&=7}Z#*n7;QqBqBZ<+V!zJ{z+ibKxyHyS7fYz+CY6@qHMX z*JlzNR9M81^@o^Hp4Dar7zJBmz_Q54N*F;lA0=BduF+vADye|I)w<(1bux()j0dy^ zsNJvg6M@enmRxdxUgfWk>_>J4b~z^{O*2@M^#!?)+q`ymC33TvcL5$pt(p^$#&&8B zZvN(^$KB$HqYfk`;55DxYJzeXA*B?YGLx`j_21-l$P#k!4BpsQKGJHmr8>|Abwjv+ zIF6Q^jt1Ad9pPA8oYSpSgExPVYGHd2)_Ui7XJZ8k|*6&f>4F&lgAtlyx^Nmuc_ja<=xg z{nOtLFDdJ5(vupj@xL)9u&sl|=aEV=ika)Far~_^7^wz>-RW`T*14J%QZe;C`Kov; zvS^fscGx-rARrx#8}MNN0ngE_S?!w(5;81X=)YfRkAy{&&y9t#;|c37^6wgd{&eB& zk@*)n>FT_BL@#BWoF6OcG+*%L`+FYa(HBxlFZTR11qYsFL6;CxlvXAhG4XIV4WP;W zv#3Luv#rxDSczX9*UtD#YD~0ad!+o(cAe+8c!)Xbx&J{m4X%asrwg;52k>gBY z+fU@<+=dF&E?BISS`A3QWyl>xXLWqvc+hpqOReu;O|a#932~rhg4`VFB(i$kT%w~T z>XTL+U>B$vzS@kOA~5SyQHBx@ZVG=xg7Z9dw)Hmy$300|v*x7{&L6g53LZ#XVMImG z7oW?OiH*^_&uSvbVrs9f8-1i0xlLLnC+-4Ncz5N+9NzqJXC^`{)c*i!dy1bbYHuwF z3X!_!ZLtEv5B_GH&-OdTEX!zew57xor13q>IOwMJO1Iu$l%yrY^P9rpTV5zKXI_$U zRVe9-PY+Pl2Ce);mbyCD$TsHI0i$2y2#pZ#i02mEMSdx<6F^QS{XvI&j09d}au#nN3)oRUgNk6m2N zid<6jKu}l`0c4NL=lVQrc(Qjj2a@}mq)?(y!oT)YWok2hJHSoS`hZDT(n&Y+_jqzj zcGOg^4HG@3LHC+*2ExPsiOnOn6%_pZ$cuNqHdQfD(*37eHF`-t`3B!Q103}nz&0D> zzkK|(+s|iT4!cO(-IQq}r{$LocHD_6P-Uu|Xeh%*2`BAPcqNgjPS+@EaY}{65|(6o z+*}yhr+k+JhiW<%lvdawJ}l}d9OqO+AL9J|7`L*4#brkWrSaVb3DmQptO|NQ-cN$+ zOOUQ6CNIh`IdMUvIFSvKNJUP<4n5L2eIS_`sZg-bO!Hrk{4GDHq_kEeJONUBUl) zu2=6ZGLiQo(;q(US|xEWubAhkx?5+mg89)iB7M%>Gp)fHHDt#x&*~$tWLW3|ql@L-qnqY9G_@%RY(PDoCT@G*aEKlc?YS)<*()Kf6=lb2#r0gc1)oRg zqW6I^buqDm9f6*sX5tPIR78e$P*Y-A*Y4I>KfI*t;B;Q@eL8^v<*k)LET-NSxE~Z| z7R?@cI%fP`+-c~CLk(f!YT{!jc~XH|4LTa#uK)Fso&*Rjq;kHr&F1)aP+Ymw zy*y`mkbOv$7Mr3Bh$n7`=7;nVR`K!w4KC{U#Y23p8zDBgn(qBZQ;H80g51ijrTG}P{aje6Mj`JeM>>+x^y=o0~qO!Qw^AM)?+?m_bFuu;M6LB1^Z zb5L*BOP{$08siD0)?T)`>X3_9yuZ@*GW#Jhn4aWHvw|#@qJ%A z@ANM~xh1tP<7W*Mz}YA+)|AP#>)n29A@X`;8D@2|RZ!x$`;Ix7Eb}|f*~ZdEHYK&R z`z%HGz?Ki4;McTcEoaht5q~Q7u#kgv+Ng(`m%Eo#OAfhA7}0K|yta%bpekPeC7I*F zvO#~27So!y`>c}6`YzZ~tAF=x>(30GFJqM<^jUkTBfgu()KtN+rrrmF`j3bo@cMW1zDN?;fX0V@d}kSLJ&kr7TP-%$GbuB(j2oN$*1- zxLw{T!=L9fSnm&~@1uDG+m5!g#zEJX7bAnuzLBKdq_gg(XG==SG|;PGu0CuOvFm(I zh>JV_|Z?8A~0=WpyqpS=ApK0T(R?(aD_ z>H>7UD|;tPP42wkZf^$(8Ckxw#%P?+80cYFnhso0RkFsq= zOh!$rLx!i0h8F93W**;UcOYlHlW=pD>_mOL^+aT>IRi<~pMh znFizM@JlA{erd%j2RAX8P~CBQLqy$W0WsPsl_G9!(llqxHx$g(=h+~X>@bgGyEC(S zn6Q67erk4iVj5t$pAhO5vk{^xr}w?VV9a=QoB`BX{*|GE|6TwONWX+dy7e3ne7M>d zK<+-QW+rb*6cBAVb;?vas3G|FLsol$oN&0o9MNXNuMRzDg-MZ+9&Xykyr`LCU!98Q z=9B5KELo}*w}jFUp^HwAQQ2f=W%5E8!q!`I z%*$Xdy%z!txKFUztrVE{OzRBoS##ui=01wa)M#Bn-_GH)b&F@Me1sF{@AZy{jtT8T zD#~rtp$Hq0x64N+^WyS6y%sI&nG6KCYG?k2a1z^dbi0&yL34u_vKH%lnHLdCx3RlvK(d<0Vv7X1ivYO{R zcWao&2`g`5E^PDuTC2rKN=TV~oyue7$l+-y=7-H<%F&>qmXORU-F?r-!TWa&R?fj^ z${r$p9Jhz%RghBhk|VxTthFUtDNPPwy}B02lbT|SOJqAq1 z8;R)T2eZ1`Jc+Dp$Y)p?nLA)ENuJaQ<7G(a?RY#w_MkWI4=y(mLw$Xh{O<62jEOFV z)SIaq$vK;*L+4M=K3!fv3IFpsfv$e})alg$``?dsKgIZLNj??@-?x8bEFp$vDi>+& zTRI?(P;Zy&@o6b$2@vDsluY=PSrhD;SE$~#w8@~hf(gAkew#(Co|EAB>9oacHYDd? z8qny+#G2;~=e71wVoK~UmzY?_Fd_REf$RNm0n%J)bMXSTYLAA&Y$T1nj~XOi@wudf zgITp7bQ)+!Q5gFjO#GMp^_ls1S`s zJU)-oMKZIZ%>3q%f>IR6>WrbaaOR+Dy%1N!Ug-~9@Dcnr} zOL9B`Y+G#4O-!6FSj$V#6G+GW;SF0nv`MXS(m5~W4eEB3+stjq^8Gts3ZxP-(5RW)%RH|TfWG`m zTnSkJT>aDTANyb4Z4}p(>MKf>$A9qm&Vt(H-%Yl)6m_wuiNDhfr#h9+8R24j5Pmbj z@|}<~F5mmWgi7Q#HgJMnvG?AU8?}GiC42-cq&~l!B2(N7`^igh=$yXltU%X zG{|-AI?Tq$0l0 z@BI&UUC+Mvb;miMBdd}uIM<3i9Md^vH=7k#lt%gU`agSSmuAVIkrJ6$w%9lDsbxt} zoZaH=7w|R!ai!_gGHj6yIfL(@R2Jw<{|>zHa7^u#*Q^g8uB%33rsP#lD#mohqle26ls803Np-CAeT{1SY(Lk2_pM6E{k>UnP+8ZT zl`CW-5%>7btG9Es=(Nxj9S6Bg$^KW{;PA6j>AOIsPn#8}ZEY5toD}X?`^Gw% zcz8@qua=)s9Sw(GP!UC2J`TSa)Zt0gaP(CvD8uA*rw{bm{%QUULe{-GDV$FJ<}b%* z((4*?rvwlDZK6RbWOCL}O6Op;@9%$DX7^cf1_QEBELWzLDZc1X_m-Wi9V+y9P;TJlh{nn*}51J6!P z`3(A6x=i0G%W-n}+SGZ`@!H;3%kc>hRN(WsD&4RV?vZ{FFC$r_O)fOpL=-0(DB=+c zTXG%x^b@<+*O7Ig-FNEy59iD^=a8B=WwiL7deqD?KPMzi+Mlz`)1l(3Ri$A8rSd4( z8h_v?4<>9Uy$s4c39em7iK{sZF5KL;%%P0mJkOA)zD)V5j#TH)Phn>5Ub*;_YFQN; zi}rHJ=M*N0y_a2^TV~#fOx1u1OU^C3Iz{lcY-pO=87bRqhqVcu#jnGbIw$QPvt;D` zzqz_(s|2Qa{bhPFwZ(}+#vFzXu|PF#>zsTA5TES4*XU~RWhBUj&F+o1xyXWDwcApg z@0zOcx@6k=#1hy~$nX^hQ*26+iTc6b9(U0MY_N*GnSRHUFqNPfHTo9!Idbi`m9NIH zP+8^Omdn7-n6-|nWI-sexpVb5HC!IMlND?4q^iDh(=eCt29Ak=SytXlvl@sOZ6OKl zqiJu%T%@$m(PB$pQKaYg5xYNMf#_6BEVkodrNZ7EXUK+Zr-USvdq%&XTaW84cKo5=v(}T@M3%Wb zBv>9lHa*HovI`MC8*frIuQr%m_qdESCE$n|Z>~J7lyb&x_`~n}mT*vlVP?Ddr za<$fW_fndm~ghfr#XilL#!nK4zF%7IFPfULkKRR+z;Op61@=247 zYh`GyWGSN(2k=ME4ROe_)r^^h8YeI|4|{#T)~u&o`efIF>}te zlsGYTZ{0QPvUiU-Z9+>zdvfNJt`z2YcFx!A<_)f1dSYHn2a^COZ) z_}zaPWK`s^FSWGlHO6Ke2f)C>uX;t6(zYU9!|`-KX+K)7--%Xdywz{e_KiZ)@KThO zRu##{nCEWfzuJ-0sY}RIA~9Q-nxf4g{$5a8oT|iH6Yja|r8nCw&H?1RsuQb?XdT+b zoJs?eP1Rrq5f-x>y|_Fjf|`Nc-?94MDWnfZVt#@gNxMJaq5|gkV%Gg1*Xqp@)p#6j z<`AaGtNwH< z)vTGI;d+l1J2;YI$=a&o^92b$H#CcXzIdKza2cwt(7CX`&C3|>-g~y~Fg%{s7AkhP zzY@r^^`f7d3ebUP>Wtd$FOLS|njLBD!!JC~d*DaAtanFcF{TGXEQB-~J66^29bo+* zlSRq1CCv_7)%duYJh)Vui7djCwb%~D>0RX6m3^?4`V%AEnuIP4)~EaQR9x&xe6g&N z(Mle4#BA96_*T_{nKL7SHQ@(rT3_(qP?(sPddvZirXTbcU|{u_hSAauGX|Y~bU(wG@~oMU=%N z`&mBHSUar~?0(t%9Q@32Wn5uRHM6nt&F;z*En$^DOB3!KzbQO`u_LmLsI+xwk9Pds z>6TopVpP9xXk$@g=j|@rKDT<6q;Zvzsnp@t4}h#Dfn3(u%7ph!)?N?cDi$DcR%!d0 z$BYI$ehE?DL^jjLPS5@}ol;fp)1Z)O%jpZ|-F z;zz#nh#1+K@YLttz}`60)dITAgAikZ`3(|#``pqQR8U-YWL{2poF>1odC211kiW+x zsv@6rkic*qX%4b7&(8YYBOCVTU8&d?GWc82zV(CHig7%rVho6DLV^+OUi88u&sRnM z&^=fuDN|4QPk^1Ex+PTJtatX8?2knUvCWFe%Q3ow;%O^yox-F!_i{`2D{c52B?7H4 znft5k_@HM1wK#j8LWbTQcsie9t?j>)W=#|FOI(zsMHNd5e1vb@liwze!RN=uY&>a4 z?GlKYoc*GMQ!uICD`V55JDgFnv)cGIK8k)C8^mFSD`#ZMD zm*%GR1LkzYUDixyqApLS-Z6m{I3B*3&QO%%MXPe4$19SC1>5b4<4fswpYFPzPFM*` zBc%lkM?IP{)f*f0POKsFH=wNKWUlnweLCE9##ZBGAG3IQcH(3~flzS@Y>!AmK)dM) zvms}J{w*@}FTKF)j(zmH-*R>Jnzz)Dz{OGDW?U%hY47D+3DS7U0Nv$F@(PPVZHt5Da zV035)!<{Rh7(2y%p8+x4>mtk6Mn}wq)@k;plkELmQ z_Z|eMtr|^J*mw00hpVdFUS?z|^P@P0>Qrk$-ag^lZ7(l+Az z!Xh2RrkJ%lUzcqyVu{@~NGj*EP{UkwfeBabiyP*@7H5G|ZHtxguHV1&N-N`VKnej; zKCWbyx=fL2CXc35_zPKKWOtuS!74Ase%O{{PWDkbBtjV=YPr_J2*{k>zRiC2%H#?5x$NR1umfq@$y}IIe9V zE-NW+redtDbZm6=>pPAcAzOdK@Lad1pcINB46_dC9F0$BlIH?L*$>_573j&zs?Fm& z_|U^HQ?zo2O$!6K3|yS5Dnk0bkFmT(81eWm8Y-WVttn9`<@TwXB5jzgDzS@%>f>0C zY@`QUN;XW#m{#?T#jXigoX8w!NEuw3AM3Z|Esd9Op6d_hV*^4dIDr)Al9uk!1V6_w2ROiz6V7Mp&4 zJgB%C*KgyNsPZD2gcNeeBh*G-#8$!?jgOC*S(~sNzVz~O4>eS>53qF6^*kuZkSYATC_rd9D zf|A{fMEu9Bbo&-_={W>>yRJ9CtVPL*;upkPjsweg$laC+_vx=HT({}w_SGCsvy*=h zFsw^jOppw{aa)e1>38n%VWxFK<*vnuQhMj}J=qpE8hbts*NvD4pM}_kug%5AT~k2e zHIz$+qg{6oJO`dMG1-9DQju7ZeX`4)!p}0aD_!>A!l-1brFm&JK0LJ1UYS*+y@tbSRlb6wF~n)IV(G~+Qk2jZVqYjhq6=z(+Q=A9N125 zFG;XnJo&j!_d-_+zH#e8B$K1#+;nT_+HTB5s?`KB&l(^DXiSgmPVRGJhOH{Dl@1G-52uZRu_?FQR&N;^Yn!VyHJ6F_`OsVm z0~(UoKuK1dA;1=8uBbDHSX*o})Xeae+vLA79KyJtY`gK~WEB?HJ%HVQLfGZxhWUa5 zJOa|+b3d_B;^$Mqs&6H&s3w#{FC8%+QjspgcD5AXgj#2jn#-T!N!wIK?j(iMN!hYA(LF3xP$|^*OrJ8Ox(0w%w{Nju`ZtK8u|7@SHUT zj28{=Y_}y_eNYD&Mj(z^F2hR`Sde2>_N*q&dtFZtf+g>#d;;~5-hGn~K9TxT3D!l# z)3r~~LkD%<&RaXRl1vOAP(G1rm9%vR2!3Vh+|QN!gY;4nG5HZa;t%_Up*sD&SA+nb zDSfP$3)ts7D{4fbWjx7ahYo8O4}L*tKPJUJVB#u519SleIA6s8?huCy5PUWt90T`# zXRaKs%hlldtxHLJLo;i%+hl|%*9np=JT%LCWNxrd8nrektSU?CB@PIlvO8C-H}$Wd zP$~VxiN`!Eb^Bw7eSY|qU?qz7ZYxsoB!51J;ng&fbayVFjx}hV?-<)%!o^u0qUY*= z*fxU9b1?UnB-q3DnjklW&dEE4>teFn2{v=Pic$@P>iCJdjaTH%G*sPtap`wb84`Um z+I%!4A>fn5taSG!&4ME?OqU~1z@l$Zc_7aoH%2TXOqc*&z%aO{yxnUR*3|f{3o`Ac z<$9IyVQJF0{sS}HA6p=l_lU=_2A#;eU4+6#(3YJ6m@}mR7x73%vTk^qZP`1?z*_qF zY^3|+$Yh#HqxBNcJ{Io+9N;#rt)y&+yd7-kG5}}j=R7qjsSifO6e)bmI z0UV9NbRa+!1vIrg#CGxVAC9g=@_9tJ8WkR!6clG$G!XJ8Gy(#(%LXh>UzCB(n+PM^ zFY&`(zCRjnugcqkQC}Fn?->}((A13gf{Sz0-$DC_FUa-2Bd7!e3wuGE)bJ-Y|2N4? zKi@6;g>c*|^z$ObPo)bt6$@xoM_8DgR;>iSd%Xlnm-of7P_%kJKiJw|zz@X%wxqY?n3F0TZNUYV)Hot6_P13dih1)yA z@ZhVMHDMG{#hBhnyCa6@+xzq^QSY-|OZeEBYt~<}`-WfjB_O5v&cM;l^xRO)Kqe-9|{3#N37L-e=E$2hthRTJEn=$-m|EO}K_ zPpdhZbd~DdPzetHwHwqY|GO%A*)>bzzXC76w`XsQ>nJiuv`-8}V}QtX}i7aBddkJrz}>fmK%-7+Ywt=R!_ zjxKyK5(OPX2bD#Z8>(kWwsm#`p#MdY=Sam_;U_1G%;onRRg%V~W|hAf4Anl$3Y(OmTvz${hR z(=}gT>go$w2(k$m>)SCl29J*k($PXWzMV%8m_H53c2v)kJLV_Fc;ED= zHU7g{y|2ot88!B3dEk_8{^V9Q@}a9O=1VbJEFL+t_%)&M3kJ*1PMjXDuU98)mOGlm zFu1(Tj?2LLDxn@w{^=}Ux#pIousUr!_)r-Y^k8l08M&i8T0oYJF$>2F64VZj#3Ph4}vsBfDF?Q0DsR z%|%-}_pMYKk&tAOH9SSBy;HBtwq`5P`d)pI%+j8j(;4Cczf}Sj5bDV;9zNrLV&O#4 z&GaBt{M?$>uMY9iy(zn8y|hL<=2*;0{l)7GPohy*l}j_R)IX6i8(EQ)3WZGUiW%gk zIat&C>N{Rbp}FVBq>f(jZusaJxmp&*kePgY;}q60AeGDt0hv(dP|P6nQZGllJftKG zuQgdn7ZDgF;Mj*=hWvQJ`x(Rgg9i6HU6vB6{R7NoNwL8p;Q~ti<2OU`_t0?ItKBNR z^MkE#&7t<5>#%j7mxM2Y8s!**#GR{_`hVcl@Qu7LNo z^F;L6n%g<$+dnK~oWi}ZgWiTC-*OIg|2CP)IuZ1=U)(2}1Ps4oSTM4OAAZB6cdm=X>qqy()fHC9r72oOFd1q^OU;rGfog#ARFDj8BPwlHU3e0 zExE=G@zT$dD|sKPR}~rEXA~YPYCf7rYiqDfSQ5f(wt*VJqC&bOdU zdARe7#E|#T_J4;zzE8`1C37l~72Yy{_(96QS%KY#yh7N+3Lj%O#TaE8uK2*LDM}6- zUeOYwn)PTeE2rE}gp9h@m_(QLS|Qqra{5A)nWNAg8041C!H~wR8+EyE8PPWA@cAUxx5%49hIgUT^WIP z4{H((il5(Vu`Nv}*HRS?R8=(+5=K1rwUZi%MRzkKZ;F|q)Db`IAFv0WJGo*`9Mhe5 zPQ)ZWz9lZN@BD|;R5~AfEm#~Rp$Gz0xoq8%#A$V6ApH|ISkaYi``M&Bl=;PWosWTd zJOut3D*X2438bQxo9vP-6R<%@^JoPdG{EFqcKkqnu7@o9gX)H~VTP|;ZVh4oK3 zHex4amfh=)2&_ra>{jJsMsYH&YR$#sk)87LvLO~xondY3#FjR37w@Bs%&^bga{9@$ zr()u}Gy>+^Ol{hN?2=+5$dzPuFI2=M*5zmV`V{Sho!9TPLI z@drC6{+yL+vw8XsfTU#4;+E<}QRVjsX<*;Hyb@e9R@IbgRHlfGdyq1}Z)c+|x0Clv zxaE53oh|x(WrQP^OP<<@B}3idxBtC|>Z_5B{O9Iw(KX$afis5E>_sSZ_UNP~5fM8~ zaw&cuGII;bzfss-^yabi_bbVT1YM8v!HNU3E{DSP*tW-!cD{|mz72)wZeyygU!Kdv zNl8#J89ZW8g3aco6@}2R0X#nc-96M6I08DXmY_(*@~+D#>>rK~th}xJztG$xAWuvai!_JZ$sr7s%~{*?Oo6UF!OKlSxy@I(o+P39j4s@jnDtyNW?{bsXp%>ReO)f~!8 zvmc|h+Fi+Vw0X2>&kv>M=fHjQVnvl7PvB`6|KoSw-!*m4228DJcN}JxmBPljzOK3b zZH>z5NpRLc-~+Wcl?K=;TwGc86N$PWK3P#WiqU@lz>Fv;7Ld>UDUCl6M^qN|GU6YVg&N9}=9e)H61?=Dss=Ys z^qSt0ifXFsUEwk<$?Lpy7I{o%q`Fm_%w@3vm|$2vh0pgkz>@TUnvLVm}N-!JGW z${tI6pL5wP;<7Df95Zmkm!g&hjQtxN_dYBBf_4o^C7Py9uRXe#{53Gsb%&C}eNW#?h=k5Gs|lr%>t`AN%V30H z!!;GyuI(NWW`ikXJhRG53P&Yd8q5@qvKkMnoEQTXxQMu_T;BLI939A|S(KMbWEv#o zAY+qwDm4ZghFfY5BDWCip77U$D8I#+QtzEQr2^}Ir z88!>Pi`PH+>FDwzM2TM(5BHOYkmPki){EY`MUE=VsLqE$C@oc z8(8zfM8VfN4vX{dSY77epE*%IG{j82FhG)4^fM{XZOr1Y%yHOZphm>0)l@oZgptl~ zf5utj@YcZ8g}J4_^aT2A)y?MZf=$g*89k|f8o3vFo#pk46$IhQJikV1~q(&du7-;6xYZSHb+pH&ySHJo2orK%1 zAcHBPCuv+vYzWM^063@)|9op(6oku(T2=K1tm>bC1SFkDF4BbMcNdZJ7ufv2T7P>h zM`hTyiG=J=<=+?11(Q`=S7}G+3dF2BWttCPu>q!)Y;oAc`nQYdC8HX{)#KMc46-(D z3mYY1IVUQSvFO5bVmC57^1*hF_>4ksco)OT#^~>16Lj7NU>Ttp{)h8{aV$Dqu$xxU zPGfmGNMT)zi>@Oj^!1tu`OEcrT7{#USdZ@@Gr^;cv@NH7%(D(>wxjGSXDwO(DIYzHXR-d5i;&l$F-&~oElr#&}bX#Dq+&(H4o<9v;K-ceBz_YkoMDV~-t<1Xz-4k=jwxbDv-B_+w>v$U-Y7j~?QjN&Jr{BnW7 z2;}l(TIH$|NW7HreLC3dYm(~&J(wy_6d|Egp-@(PZYt4q8tLd%g-pZKkAOlh`k@2c zWoH6kYjghCm2&r#T4>UqKGoW60n4Ac%t76*PCkx0L+Qr^&88p|L*FMLe%7?Y(;DV+DyaK)nikKV#}6p0vb_N*`MDL&+Qfq+Sx^a+QEJ)F7zOE&5?MP_ur~DS@XS2 z%$DW{`rpIS#B{G+{Xrg)m@)GV{AZUZ8p1*v8jk3l!r&|pa%Fd*Q@(@NS~gIgy*$}n zQV6Gx{Nh@&K|-Hu zK}DEI6|pHrNVH6ODydI(J0RTY6Pa(1I}x2nLXLh|dH3-6kbiqtypI-(eMrUp$V3=A z+W$WB;eS@dGi!rIJ|Y|KlyZbywklJqya)py3;A-B&vPB&@WtPsR0)3F;85f%4@>v} zn|0)SbyfTtifw^zcoY(aA0}%1s>Ohk6x%u?7BaZ$Sn>O|jl?2TC9*5{QIk%_^Cj8E ztNS~fxoDKN9gy>e>rS^{=rRD1x`WPw0*N{*MXh} z6_V3_Wy>J9>dsDA(O_8G{o$k$;-F`PQI z;895~p8P%Mu0Z8yUV;3wIL*Hsi>Lxy8I_)SMTm3X;^b$;VmABHCi4qDbea8RSbaq{BoHI z6_jzXna+x6>?|>c5)|fS^S(sbaF8%>MSUX#5vKbKwd+lElxeLWm7~KoVE0q10OzBs zxyII#v|9x^HH*c6%&~P5x)45dG1X>O-yT(p6=%F@IRdEolT|mn&SKTRRKJ_b_VI*R z>I!XBnPpG%uilJ`T^{x?UjYtzbEFcK1(9#?YB$p)#G783zEi%TbUw=OJU8UYFFlq& z==ry29z_gHR4VM!NA#WmbL2vx%{!7_*u2r%#I{cXjZAQC|$hbxc<^=MaZOxOp(0LPA|@(A4zcK9Yd52Ax~qf=UH<>meu zvu^-WHtTNOYfwH3Xjeat!KlD;nue1Z&(T;g>vl&%KE${xXvQx#-ozpDxxXLQT+2zqAC3tK9`x~%4lQ@WfJ2d zxkGiTuO>W9X=xK5@5R>L1tQU zVh!>F*|iRmQinBEf!^1iW6H9U>#iyC0z+3?5og(Cn2L6sV1&jD44R{5 z5kHc!VJ{1Sq~>?;@#4g`^3q4MXdhB^mj=1Ps~4@S$99%QkAQ6ZB)`lRaVtcY)Jn5lkEeMt+KAyrEza}IL^IoY9(B6Jm}NqN^?gAw%wK|?V3mj z05y?<*}U<55%iqlLspjT;xo)<@=Q$FAQ8Sj(KP9k+G{zcez$@g=BzG2r(bIlVFye# zMy*}Js4It(f&xE&->`4>!;%?rQFF>~WBh?3w(^%TFx?199A`w%{+=SiQe@sNx&Iva z?HM5C3-Ds>vJ$hr1)1z&%ozKUnY8iwhLdrG9GAEP4AkiI`Nj40bqx+GE0T3Xm378B zoxku@1~@7-$$~mP2yJHkiPq9-V+yL&jmOen^Fh2c@0=q0=DQsR z5s{~mDYXoqf6k~@zB{Ai34R^0$<#~0^A=R4SJHao$kAl*b?qRwx&9rr~iIiEtMevDODBYzoSspR) zu0Nj|m_&|#G>vVVJEC@Q-HV-!^;rO3*c8e8p01+TZ~%`1xmGs2TpeTxhfi6Hn%!iH ziBsZo5=)zKcY_>>f3OD81E<#}djP&QVjW zPakyGlovi8VGgH4X3PxKGI@xzZ7`Uf14gMw$4nV~Q)lh#vvs#BG!M;79u;GQ^ODmy zd>_C3mBUqYanSXZScDauR~zu*O}~cJKb-gFy|Uw(!(uPi`I20J()oxnGK(jb56=n? z`x=S$0ho4lS7FBB2yy-wm*wu?r3UKWt}y~5m>U4UeL>lKDywR#WFhVoZxgIT%ziH+ z{mA#=3tMjAKhP^_sr-F`!H+wf7hBPVIoTrm1t`q8S zTh+@eVJ;?UWGy21{h8Wb zou#v81rw*$9ZC>V?O^s+8+%&H|ERd%a(J=B6Lg(^WSd5P# z{&Z$^=HV>do1LFe@&`Bus*46Z-?9D;@~b_<>waFuF-$=cAI-C|^Obx88b;*fzRYjg z=#A~zc?li(B8GV%{Vb6Nxansi@0o4oy8=4fn*A5G%Rx}hR-!>kOFAdi&u30g%j~v` zuwO}o7`3NAzI}&x19FxM2*tGq5pg9%+n0O>MSDu99L37y|M2O!`7k9B!^|}@ZS6T= z`a$!akWwGr0|u6pgIo*>*H=;uoAAvBV`ntAR;AoS!|;e*k*Zcnm2VTjMkoOl)37b> zqw`FS@1rf?ENwgzHwhZT0+Oy&*yeb0eF&Qnw67_l1V8?R3< zhBT!_hei?EA^0qzF~fUubk(*Pl({8)WW&YHc_M8A0$M3no~iad%31A(#z-5f{)h+x zk^yO2*ds~*xIDj`9f*`U>HOZ?ir|<&=C;pXTI2yS;V!tMta=ba8E(8h%euU2YZaC) zF11G_8*D-Ux$81;F-dl*Yqer<3E!_Fr>E-&*k<va>G}c<(m(qq-pm z9FAQo0XndOAG=sMQC_a=S=CE(aBS5J>-^Ua+2x3V=l)z*JC)`9GyCDRsGRq=9z#mX z5!HcetMiTsF0{XMG%HJ*oU*9*)SW+ST=i3o9OKsxUWGN=ZV5AYq6-};ZKnGNCSk=C ze@@Blb)%hIdNY;0q!XDrD17eokX{F6ji1Q(m`!efI;Z!Q)HhE$Zyq_(yD5d%l#x&E zf+}G=3=M_%bd5NUI7grTXn2bvfSIegzo;+ONcG|D7xI*Z5`oI| zb5owcDZM|4I2o^^+{c8g!~?_iWATq!^dm;NVkAv0 z%rW9TAATE_vSjHD{F+Jw-;4*I( znlezwJy`v8!&-1QZ2*Ugau@@@_M8$|7TlEujh%Ddqotk47N#v9bD9D7AWX``aFw6* z&W5O(jL7Y%nbpzP!r;tPU?JS>Z-reiy&$ssl zMPs6JMLO2lSlGRf(0zO~E=Lz3HYx5SYDg?29`u%6TqK5M@hJhs4>HYR+^9-SO~Y(I zH((hxAls#l`tO>2$8v@#6l^9dbSNzxmK4+`EXS=_wTIF z`yq0j9wZBE_^oJg+g@63#&vd7@_kfdUF)q_+W`JKLjcb4M?+o}66HUVADxVk_azwI z{d(*p{Ek694$}uJoDibz8{PV5sQbqv~ufYpl!v&K%GJMS+Sd2 zVL#0Up^XN^%}w!xyQ6jt^Z~n$=?z%=8Te>atQ;4xSuLx$uTj{Eb&QR!w$AIS*xoMo z3*0~5f%R!D#iZmJo2MUfT5joCRZlQBR12P;)0PN#zVd>8*H!zeHX=Q9!*=u?Pcv-b zIVDQmU-jjp&}gtBt4MeBg2nBWXREMp#xoBt4RNT;FlQI+w7D?Eu2uX6FwC7wf@BQx zl6L>p1tP7i#u(BaD^Q;#()*YV@U7{a)*uqwOUIMeLP+~Ob3 zTC#2NG#qMjv{XqXW+W}+k-*-$Z{Y&~eDx-sWE!GQ#a`GJz7th9l0mg9#x@UbRsGQ! zx=qW5tt!B5op_&$OnUXWUxE8KTp3GJgG;@>IdDwAu1TC9bv+D?Bm6RxXTGuBw@5pn ziVNc^!(`2-7*T4EO#e9<-L+txK#V&9c&&$?xMog}au; z1koD4wk{;X`VTU7IUr!L)u|hNLL<_cXT|jCQQVcqXM0O8#-|;)OT#~$ zPa+UljJ6~($uJ-c_YiXHv7pYJ~nuDl>{u zK6k=$HMOj1b8xf^lJVekh8UM&>pO2qUzg?4U$5C zIeAfh0(%Oq4T*KTPk^(2EO2wMnog!^PB+xLj6hz9; z>Um>yBt*vn6ivM~QWq5qarXJ1V4BRwns4M2 zGBq1IrZ6`RYKp;JX7ztKHxh(@HS#;+;kdEjDvNMFJ`02)>t75DTXvPB(f!FU2XmPL z##LhqGP*-4`>78wnW1aRW#L3!VVi8~yn^#A9XJ5)J_vc_Ax%2=04t-D7r#E*^!9MB zIlws45biZ0ZMdu4szsSF>Zcfq%ua6DXDTz~d)QS(O`1HxP=EUlD(gM9qvX31*&N*9 z_{{FzzQB)@+@c_Khu-Uv*DF&Fpi>6~LnHd0>LIb-8>Y#RN`djt z{Ld(=hpv$OhtO!QFGJz$sBdaoYQfit8@kE+TG zsVlj1#b682eu0EVSaeVBvM!v96RD|k=HlDX(V{Z+DCVN3QWcK^s|<0*uZ$OF>wNAQ z6U%a?2EERtQhm?XDOEVntu#ZGTMR0F*pYH61lH;$cw_&@rEb>QqgAPMTH)hVeRwv` zyaVE$a~x?1w-IEU0zZC$ed&nJHSXFu{=Yt01h!&##A}+B?Dq;v{NsD`6F%Lr8ylCV zTKx_V^sr-5=Uhj2@jLGBwIYT_LyTi#TEbOO#^J+ZucB^1E^Hwtm5@?lkM=dv*5u;| z2XawHs(@oD91M{zQt49;>w5!hBkSczOZhhc)!)Bij!{*>8(1mJKOXfPdNHhJ{!y9V zf<|AvtXgEr>(Olnuk-!i#>xs{ajrRWlACwrhA+>XW+iAq|p>T~i|3XDGW3&$(N55H zQ&~dR`}e;zd74&q2V>u~dxaZA{y?M!Hr$#2-cHvLA*s{tE;Z*bS0%jYIAbhpXz`SW zPPO=g%$OD++jJIL|8P>QerH1f`O2Un>D{!gSiFWIiLe_KxA@8@VNA;rN+KR+#d`3* zG{)+i$Vtms0XNs&S`c!C29icOF+XuqC&qvjA2=@Uv(G>ZC!_rty=aovBN};ybO7IC zkKxD#&-AI3xUFBM{A%nLGpj7aQJ=&7uAm=~Uo%ir5wYh}Ye!a^!LZ_dAKjU$;vbX2 z3FUn3+4(xh1kor+0y ziR;HnW@6H6A>v6hWv9cUi2-gyh8o-GUL`2qn`Dsjs>w*|?~pSqUN|Jp_Wp0Hq;*3# zlP9WMi%sQo4vvn*c|bRS+f@CN%L!MINSJg2-_D2YIh8vfBR6dkipT7VTmje>QAd`2 zmvJgMQMRN$&GPKF@<%YJnN7K?{jw8DCL#Z0m(}rZOyLzjg>Yj&WwtrJXPu-};@Qgj zOsaV1Mb(+lU`GX!E{ivs^`zy)rBbEFtzj#J`XOnZNSa7*%A>kpe}v;m9o+Bc8~cwP zf>RGU)@h;nrIBysRF$NOprrCwVw9u?7%_D6>%Y$xpqgp4YGEy|^TVt*AgEBWv!R*B_3XVHF`J)ptXjIg*2u zVsyyIg1N_~z5CqbMZA`P>uMu77YuF9nnh>_3vdiEh?!t{5KBtPoVCSqW6|R{!(@e+ zl=sBCj6Sr=vR-e_1*Yp?-JA_|KD7GcwjXTB$*~|ZC%M&UnfNxDSnG4+fv2rGzqKbs zMD&8zCYo+ta?V8MW6-+^e-I``S1YqX{$KfK!Pio&zzc=@3u`p_qt zOH{^^#GS+L_)+O~?w|ZR6u>`51hDheTO0R)g-xV&STqatkC_Dc4eqFoV}icE1t(A6 zY3djd=B7c~t=~xPdr_8;vt+r%cL9|J0m`|b{gUZ9?i*S;=I88naTyB_-x;fu#Z(*W zN^%(!7(sk=LJjMwPAX00Z>A@uvQhrl>?ppGXT`;+HmA9^pxTHSLWy8Tamv@QO7#AiR^K8d(8>BI{s4&(8 zG|*Y_6;l|W(7PjeZZQX*61q-)AquS@QP$Nw0BiON&?{vn_c+U0%hI1r}$|FYrgDZx=)0JW5vZS)88g*Tgu9%HoeX3 zez=$dXjdd_4*@6-u}rnp2m&G@&E}9&q+lA=qcutOj4+_Vj-%yD8GsJoM)x^GCN(F3 zO-~8YWIqrp%MdsRWUGu(GkNi(vj z6T8o!AZ+*gJZZ_N`s1xdZyLA^bdB|? z_=p5j=S#_D9ugMixmHIOi&r0(_LxUaDV-R~A5u}@)8Qd}Dq6 z5ol#hGf?d5|i+H6V$4V9r05SyZct{0iqk9#Qfst8d5DLd$_ zgqpWgGEy1`_a>A*5!G%RgFmI`mo$ISzM@bs%^rVUp4;b$@jQXcwQE~g6=xT{d04wL z+Vx)d!CdeaWZDzg>8b1UGlAk$?oK7k67B+IO~!&1E;{T~qGr8s+p^*I8NxPmzDy5( z0v4B5N><^Ux!=!VD{`iFFy`@0CRSO`+EeMIaKmOUn2TfNko|)tSXERw$_CD|o-|?N zN{H@W86~Kn`>kQpyMuOjDnh5=+E*M93!Fh{wQ8t2+_&;N=gXpgo<=wlIGNkEZ1wH* zg~`n(uYI^%*8GQ4Ibk)nS7Z%ik*Q*6+5B&HOjfZFa}Zd^ri=T;-ew2T+;CKC>8&EO8nS! z?~cP=QxG8Ahd+{qlJu|4Dk!X03xB!HR`jgL5u^=t5Y{ojFv3_V zT@FENnjwh_tWR`iFzAmL0d^la(8PJ@VCu)7tdZ(Ib=lK@k(WuuN|1j}fz| z!BWf+u}HkMsUu6<$I->=#{dC@q zrxkOPRxU}8up1<#oyL+QYTo%BrZ_8*v(lcmB6wt)F$6jw9pP`Z=$65$HR;oQyLgL| z3Rijx^$^2mNbInCO7*q&ul%yB-Os?oWEhD*bHS%kd=7pQi0(@#8&G}>0|^qMVr)K_ z{JyPU*_JKGgV#iteW)H9qxvFpMlK1QYFz&&f2PsX(-vH_i}?>tg0&^ME+xA^2n)@w z3W%-CstioD&wR_HB(7w zY+Rrofh*NJL+k6V+(_|9jzwQCjqq0QRa(tuUYFTi_>eTM-gQ=dwW^Hs+HjZLS^Z=F zG1}>V$$M1)c1pvDdnHh$eI}r<`9i^O_$=>touWl+DnbvW3G=&Xw+&v=7rai8WRqzr zMAnAWmgQso1ztl|m$*D{oc-bTpj2|3vL&b^=ww;^+pV_OW9_*9+8LD{;FRH>cPIIc zG&>=zHv0a4ri$^vOnc&w2Or#vJ<>6;1_#3lDdALhXZPv&%^#eXvmVH=8{?;%bm+ki zrkG6aYNqNV&>rh356}$nThH z>?71_!?%$Q+auH9HSj>mwF%poyq}WGasul@FTZziOsJP+>UuNqNwti}GlnBu->EI% z%49F0helY^m6QRm5f!Jj>$P>>rO|`HtZ^fw=s;cA4swX~G9U#T^(Y@*CZ1ta(Ifpo zvfctJ%Aji;M*%^)JEU2VHXAISQ@0I1O@4arMp=~x+SE$LqJe!DG})g{|~!hsw=tdS;^Fg0nG>L^7MhE(Lh8#lT#u~qK_!_WzX3QxB*OEnWaFJT`lRj5} zP&-@LTzTADpv@m=x66OGlQBv!HwY0!PRN}*rGKuixC6Um@SbNJijB3IeA_~rRLB! zqDSr-Y>MkM$J~t)cCe{v7Vt-XYlOG0un>vG6rcFadeYb+(jjY`7I4x1z+k=*(m|3{ zgCi;SreJf;LTR+A;9T_aK1o4qel9&lkJN<$I`aWzY+_JsC_f-4Ci}ZdhNb%8Iv{eE zogWtlW?|BXO!(e=AO_>co{s711|#kaOE6V2ZUfH|m-*#@ofIlYRSz9@Wrcy|{FwePM&4{T<~j0uP~FMh%- zDIc;Jr#xK3?BG`G(%pcbeuL4VaAawg7iAr18s+@{i}KZO*cq#0=AkmS`9vyLF3C?Q~m-k`gt(GGiT~O~dm*v^L zr3yYVN<_@kIaww_0}_VIm1H|##<#HQ>>CC;QqH%F%>tEMrL7zQ^Vt*eTtYnan!^4? zL3z^8qf4WdiS-lmt6L+)>#hzK7{#dD%cfp#5PqE@6%NWNSIJV# ziug0Y`EdSi-kZ+!*3+pu6VK}N58XB~HR3`bDu)b@^`pr8WxE=uiHM*f^i z?f&y+)?j5OB55sKHow|fvarV5+}Sz40nUt9f9{3h0u(tkb76V|##+ruIox5bIl3>V zd%5*O`xUoO>yxxTj`5NNbGogaGMR`@M7Kr9M}Ii(0KaX{*_KIomyaw}2>TWm)4TPH zpy${`zt!lw`<12$RxnLjw|%;f7K1}IzAb`($?P%a6?=>tsZLg%e_${t_36_0C zn1~-Fy0Lw3>YGaL;=Z+{J8tCXRw=3X(zhwXs3F$42INuE=KFnoOXX|`bAO^Cw1SDh zD5zsP7DO-He*AW(CgY?#mL|uyF~%UNY|d!RiM9$U6xQ&VA%1HYc=QOWpLH5bY3j9| z*H3AKGUQO5zRTm}6|dyaTK^>;N+H>RCf*ZvEy*LpcztW}I_kY6;Cd0rgN3jgTL4zi z_Vb$vGdx81kFFfz?2zKNzK)k{TCM3Tzi#`%%%NkjIT2I{OEw#-f(Wr}Mdc4^$3P(7 zGl5xIWlmo9=EnlPGkrb1?1TYz-INXQ$LzUBJLSMplQQR;1w8fo7a|+2$Uwhrw>UMw zsLUmPDx-M*No8pCYcdRpLejF#7l5S(&usFW!`JIGE@hmVoNooW)bP2U4_*--n>@uT zAs#ckEcPPQA3~VOp1)br=Wj-}s`vDc797FuRWPS;oUGR}T)%ORv+lrE-aIN@+Z5K& z@84NbPf}qMEGR*IV-7LUfvSP^(2ej$SkR5c8mURueEGA4FRgiBDXzix(Jgmtp4#CD zeJU&H1!ZgSyV&-q$Gx9bHey2rC4|0?c-?=aqbK_+QbP(~>k`K@yHKvEdoz&ulwe(G z!O|_*^G~b&!U{QyDVT}QMA+`ssIwoq8^?L(^ApjMtukxaOJc2;Gg8Fx*=f1;MJlDY z8Kp%jZ&BvUx#+08BJ4@M_-VNLA^IE{L)NaN$cdbBQ=Yv{f(+C-uOG4Podv2{(%$%- z^)X@XanpC^gig|LgeZ*juri+_a!Ukl5A7w)Gb+t;qqL`R;PFBBH5sij*n@b0`Kgvm znTh^ZX8GI3@?6l|OW9U<6r)sNZ^y`f*QZ>Z{IKKzs^Nu;sTf6iHI2UK4XI?2j}kMU zgZ8$|L=(1tu6?&O(U-N)`wRn%3RHmcR}#HwEz~hi> z`kO>suSW`7^v${H(y*h~lUk*Ql~v-VS9n7UbL=hjEPJPY4Smz)Bp0bi|)g;1o$n%_XvWGxD>z>#&#t0JnmWcGd?((KFMsR?VM z-N!$eUrHurHXC$KttISQ0KLUv2<5Ot?jUfrzNIw?QKM9Cl^PRQTwLL7|Mp3%uZkJ7 zRQB~2R;1*f7tAkg_)#Dh7HCTnha-3KB32?bRA}hq+8sBM^e6iC9%ft|$7F$Fz*n)d zvNFttOQ2}rnVp0Mv|IV&vu3l%)keqQ@`>5^GQM(;6g^|&ZL@vM25#ti*NZL5)6K3k z3_#1^uqzxNyXGlvOXq0J(sio^7%TJ28Pof>&3l=wMj<+@M-WjT?N2pnYj%-Y9Fa|< z>qhI9fxZ7a8B8mpQMR?*meF50Nz_Mm6|&|JSl(NFZo)~e*QFTlGh0zbZvUXBrp|Gk zPqcGUMKnjbHPlnUfIxFRHfnJS1S2Y#_GP{0w`#f#t`rO4?Iu% zL9_+Pdr~;GcBGI3D)zz*@OGR8ym0wb$gDjT*>prwt9jYdwbgfi&`ekFmArx+SZzmX zOXSGC7LwVxDns(e%N3Q!)^g9b{n|0sTz|2)u#mM;Q1Ep-r*EuIL7F;L`{wZ%!J6Oy0iKF)634JRUxUs+8bhzftYn@%9!vDvKUlO({ zZK$MT3_%-%Oa8>|wR4Sh)zW|B2wCh=KH+luf@XlvEUniF*5K-W(g0a=47Vxc@K>br z+I;UIWmQMrGMIT$nP29wiC!{{HOOhuTGnVk%70{hwAVc?GY~6EuFpl3k{^pVEBX$z z+P&{6MRSY1*Ll%>ivhS_Dwpd7NbKx^kB$A)EJGmT6l%ipx(*#&KCJ zXTMQ?)g+S|VQm-~0*=>azjMLBA9_&63|tgy@!Ic*y?L~-+peqyDd6~UOJw**~8FT*n#kXtm?1DLRD?|OFn0~_a8?b zXzNMmC}x&o)<~FM&CgKz7=1$v91OCNUtL9%#&&SeX6;oEQL+@0lcb?dXHDbeDTSJU z{!(!HSm?$zIyHJH$JJnqaF>c{$0wQlUTBOxxAMH8hG3zE4@88VaaZNGp3be0Sgyahe^| zQ~wP$?}FC7WeQz*SVTm#KyGB7zxN;TnjB?)?@Gv2E|u>Y1=6Qr`J7=1tRmX4{-Oj8 zz<{Ig7a5YV`pKXeg8;^*E&FZJ;$wX%Q5S9mx2+ters z7lc(^4wp<}-kSu^mmua=zQsx(*KgapuT(S-{F!yv)u+23B9SdS13Eo=Ld(EMuIK29 z7BokNxk{meYUJNGh%}Y)rjE(opYtI0kfko}G;+m#cE1c-Ds0;Wo;YJ0mJBfr>Ppi?r-Wcz$*i6nC}>`3WXvhOS3aZ_ z#dcq7wshp${$@F&(nC1BQ1dk7w(pyI3YQ`^UHOve*OQWz1GtU?i5vsaW(^Eb-oJPj zgvt6G)U2;5WX=fkgn3b;`}Xn8s`<50nFz89?j~tuhh`bN7ML6S-a(Iykdzax0qYB8 zENj~86!fKvICBf}iXHbXscf`{x?A%~D3=VGwe9|*gUiq^jq?ho%*F~vOIu_~&vw}} z{t$EMlbB#{&rgXI6jpwZgfGv)5mszWRv9XIHqD7u_kfpMclTahlLs<#8tG*wbnx8_ zz5gpa-(EyV=95WX-RPOYr>SIC{v@NdIkk2TevvIqTV4DSYhTN;%6=g(cqP^sd_EjJ zvwLHDs94TnP%Pr0T6q3${Lx8P{tUoMCkniG*3WgMmIh#siS66rzl+N~@X>&y=xC8n zS{Uc<4_PX@H3ixb65u`$+XVtyG=dlL)omuKHcy%lC)6||d)h-6_jZsaJ`Nf0Ij}(1 zn{#-y=zJ66ScRlkmMmfr=n#ly;PwTe$m)@?8ys%UHINWOo8D=9rjYlpe?uQZq79CW zy@yh8ZZZ5Jd!i~SBE_B2Ns;3A!YekuGk0(fti#MRI-ozb;W{fM`juMYp z?lQt5^UdM!WkRVRsz&cB0G~o!lFKpq#GvhDnKS5HJ)IQg)rhNC4`yP;c3QZfN;|XV z4Vw~{?~)jRLppTX;KAQk;!1ZNAAO`w){mcuN)s|y`FzO^JapOX(RwDT<*1*`eL7~S zy2I?kwDsK)IOcZjCn~$keTFehlYi#zMfF`CHP;|kOULD#K@|{HaG)J9m}EvHc`Ftz zZBTA!j5^4EfyG`BDfQh66KI0Zf_iEw?M%KuR~*oamVJl15>%U^HZlY3J&p}wl9zo> zaYsxeO>edJxIN(G+oh)q@Blg0a!R`uixteKz~i9e{EWPvIi-(1%3a(F$zH-V8vI=a zIh86AEQ7h};K&K(wLY&O!Q?hcELGiX#mq~;xtVVHNQIfU7`wn=67@&7!==BlqHcsE zy!+^n4Q8uj^x`r25&oeik z41dhxcKEII+CqRn*la-r9bfqUF*f-jR{1YCna#Bhrk4z>An}}wjAI?okl-t>S3Mj7 zg(q$E)awm;f!5`|+4uOBgV!mvgB&^4oqu~#o)e%vdW?$k7#(<@pgwwpf`al$=P{o- z0pT+Sep%fYZm2{SuKA1!!~$~KQ#D;^Ob|1d4}#!=ptZkCD40NgT$x7);lK-N(cFpM z=}cx~4AcLVf;{B=pZ6jEf3Wy}J^>{E1M|Om|55P2kpG|T|0M;0 z{vVhRc>b&Aft-J6|Cbg2CF1|6{D137e^MXV@sHsD!~1_ScP9M@ z^h1XK;Q3!Z0|9$hSVeE04C#y=i&_`8F7x4+8M}W`NSaTm{CL;~WK&?OfnCu;^9G}h zx=u1{Ip8rErHurxNTaT&YT#G=m{`u3quS`YMWxJRXwe?9&2$^4f*%22Z(ep(FU(i> z?B5YM!!Ev}+xUHdZC)f4%gIpFYA-Newj-*X-pgnV(^Ae2ym&c&`Tt+P-Ayfb0de z4_+b_)2c^;0Y2sklfSECWD#G%>(F6Sxc2>tNoBDFt7wqoj!-BhIwk%l>uvllL#%eL z*q(%PCFfwBLLZ*Jx35`*leelp>&Q*AD(Rj*q|9cgg`u`648)XZFLG>1i=Py&>%iMP zPH|ZiI79Z0hOxU9(Oy%|fW!V#htX^^BgLIV1(Q(T3=vKo zVSr!K(QN9^Nr&8FUggr>MC?MMJgtr@^2;t7ExD1Eqvv!lrC8({PU0!xDsM zB??zp;lvV=sXifwe;QJ8l|FiV?$~R?K&&k!oT0^7@Exf*KPlvF36-VipsiAP0clsO ziWj*;;;}cP3nrAK4UZqOEQ(8thImDU+-Z;6`Nuo;xAxAF7k+L4@r0<7f>=7OjTI7G z^Ezqixd$|gj+2KlLcw^!MX{V|yLr3Zo*I!F=F>JGb8Pj5UsG{Fea_gSdBeIIR?vK? z)G%qLnymml;g97n*5+4GGR>WUve#-fR@L9Rsy=KUgW&>3CAB`RrKH~wMWIc@MbeDP zR5%mPaSH`8Mdjc1*rCPsl&Uri{Yh#Zr9}w!;@J|Ge=uN91jhl|PSa{QwfdVGcqtI- zlEOEj)N^)8VLjb5y$EH%nR;C9b7T)_70vcx*A>w*nojyj-!n z-xW=ZO34F0Ty6_UJhuQ%eiaxkhKhRKZs^ItUzDM@#J9RdCT!K=Irg6;x!aOl>D_|u zc+!5{gwFRHtJ?7tW3A^+kxXFQ*S)LOwT~(o)?kDWQ0mS8pq-(0%tJCC8DlJLCfW-O zdr*2N8PxaYCevyOU)8LY&iqArS>j+NVYp^6WV3e42Meq7LQESLjh=e`)#& zGX0;nfR%GRyXaIRRbcL6U??x4x+!5`fG42>lP1~BP`;q3(lF8keMlLg(VDUNY47c= z$HNczdKG4(=lRdLA__-gU$jI2h_NV?QsDTu&3A0H&9 zS@#*?l-yGdn_zkjVdmVmV!zi1ub;hcib-4sqd>)haE$uTaAvT06t4v}` z{%((4F?Uh^J{JUxN_np zqMsXM36g)pv@{&uikCj@L8Y2Y1`5354|LGMBtMXVH%wMknV-oeW@Evnz+j+tHMQ(7`*p7m%fWVKnu+Jc~a%FM(WkT$?CX<1I*-+*+}8H|Q4 zBW=nq=_rMyRDJPZmX<(DRE2CxpK}P{)vdTu(F}x}15z$3r_0Z$<8rm)iQ*UWiM%n8 zE3MD{CD58b;E{YmZ9Wi!@1r;S7e$lXeOiJQn|P1!*dHakHm;{68y3ES@+$+La3PnL>ADVb!ir5Z*D988tc5`PbklP! z8Y|13GmFOHKAG|vpn8=OES7;!0%-=CoPwL{t)rVM0?&>>^6|rg$C6?YQY1_w%Tt_H ziFDprNzGn}38Ahhy#pb|*B(lw7nwKK)Q%xIi4QwrUYWGw@KagApL@bV+CBURh!eak zrFiHp)Cm_mcsWc8in$Q0EPF)?J&hxSae2%-ZMlcBQ2V_}JdEkUcT0b^ms&|?G_H+X zFmzFi@ZJ@ig{TE!DI<icv<@O9*y&cMRL^2`BJJn z{7&o7TICZtv_EGh!tf09Mn30SN$=@`4_u>+Jm}FCd(+r`Q`YjOl!_`!!f(2Lovi2> z)5?84q^(3Y`h}R-52IVSTH(NKTa>XSNy$DHhv!YJrHL6brAj*dawniD!3dKzL9#U` zVcz)=(HUA`C|64>M9V*?_<@&>g+0YxL-7YplOHdG6vTME&jGd7J=)O8SLX@lson9< zvY7Np)lGrgxseeYM7(VE}@W2r1y{|q1Wt^+yB-Fo^r zsgpux()F54ny(mC+SWi7{o-;^TW#;Cks^T|w=lVk^}Ia@D*JBHg!{rfRj%P&4-ic8 zqvyPWr}U04*x4=IDpSpZT;)@Bqc1SY8~F_yn?t+}NnLbTCVx-{^g(}+OjZULXn0I| zbjh{A2gV845{yE_gz5P6)#yf!Ohdz%XE(?iB}U%$hkJB?+j<3_4r}i~dQx#?go*1> z5w26)!KIWT;2#6wmr??hs%rArP!(aww4}WbI%8(5_;p@wSXt5M0udt~znp~iF-7%i z|Ge)-^PJDKn2I~u7O~h)7)S|O{F6bJ!07L$K5Ypa5~6&lez2lad$W{c;OoY-Bb+pL zXk`eCct4?N9Avr$r}eZ9T#1F)1<#f?3X}T?v%j3Z$|=ULr2`_WWq@ioZY+WBSRPX| zxM-FV6~GaL`72}463!x=0EuQ}?^pi}qh%GyTBUZKwMy4b3s8!ZO~M8ZKPnjX*s(9z z3zzr9Ng^>O$Hf4~BMnA6r5Zgd;#r+1q9Hb%S+Ag+Ha|=S(rkflkvEE@n6!F1zu+;6 z#0fEp4c;Z}=5{=%;|hI`B(v95VpseDL8wu24ckI=>!h``Cvz4@k$9@E(cbReJ%ARK zelGh>Al;BmZuhylhh8?&fEBs`mTo|%!#cPN$Q3fdwv)z;DisoVLQMTFba1K7Bcn-= zDNU6~LRSHx3Af@CEf?aINlIvxeaB1V%$wZE-}|;|TMy`$M;e+s(J8Y<-=?&*Gv)l3 zj;sY#LZp*Zjp&OtQXByNr?Qxpr+-)6Gn6S1*ZN)jyM9RIIBfEgmX9M<#aq>~vUCw& z^U1>Yqm$=}xX%-<06C}wlU6G(PFw=KC>63=oIG1;D|I{>BB)LxrNF#jvTzr<2@xnJ z7W~mSM`E=Uu*1AW?H%q!WOgp4D#X;65uI*PSxTBJ*C@Pbu**B8`-WXBlUk3C`^g6$ zPR8H^*XVaOvn3&Xz|6^dU4JEdOgej+aT7zRj^hSszUZ@&fxCw9B4aR*5W6ba6&wL& zY`+O>$A8c&%me)>+E#bH_KLOnmzBdO1~1pjPZ1182NOi=?$J!WrA4s$w-j|US9H`I z0&1J->{Iv*P2%zbFb8>vTvCfI-|nO~p!E>6YF-d%tW&bR!4r-DLfr$JwHmEd*5O)` z8rE{1w4!53T#%H6k`ui?!hJL>IcwE@ll9V}Y$$)6IP#Y`++(u1ieM;Tg@m(gBm*BO zV`0dlq)=YM5}_tl1&@eNlrq3cU;OR6sDn1;i*{AF^!BW0-w!^70c}_8Bs;7Ob}Sqz zj}DF&Sc~(J`s`f?i3>h0?V`GqX4cYiS5OC`12zBjAG4&S{sXk>n%)hn}F}RTrQT*s#M@3U2 zy(6+4(GQ;Wb@k73b<@2mjQ+BBL~b|+Ut71%JuMCd?MB`b;)@qhdeCyY<^z;#QPj~2 z(NO}#{mPp}Ia^K|`Ar>eP8AosByG7?E_dtcc2OGMd?!zD5hSE>1QVjB-o_fZl3 z#9I(Rp)K1J(4WG`3mY=Y2>Zks<#Q-eyutcXA+Oa#D5t8r1sU;;AxW4sUw-tW?@lSB zCzsu|Q)oxD>FWg1Z_F<|r46|yLQ_tarI^J6_ym*4;6#$FXy2rpsB2P9-{>TLZ^fU) zG^w=sFAI5sdvd{hgSJ|_aCKX`^rt+{6dI5`V;zNgY7TABF;y6^ZPBc)Qj|ct`I6A` z5_49qD8xHehr9I>2)F6nWvq3&K3hqyo9H7aa{Pm7^|UyK%!b`Vip9n^bip-JV>2Dp z&p)H?uU!@bjM`^8>EVplX?wbwyU%9}<=*&sxs<*DT3cu<34HhsT4@wRp13?q2cwzR zXn{o}vPbqp45XWs>mIr8Db_80EM8JdQESjZs|ZfS0ldTE7!545hJHy{m<%OZcoJr-%*CMOa$w5K!e$7AarESC}8C=ngP(rWJ- z3CYqfu9s2(+MzB;kNp)6C{?aC_*FDxj0X22KL90>3Zx&*qba^QUmq~nz$xWW7s48#)Cw8mBADf{++_-Qb zycULj(sre2TQ|<&$SirlB>~w69cCWmQ=%O-1Byfpiil>^_y%(z?k$C};TooYi$emoNJ~ z#e)H8rbjV&y7d$8K|8L7{=)5OH(3^j@EE~k2iNTmAP!azl}|v?7v`F(QPa8R^46tc z4jZhk&`)cC!=%hOoBxI`Y>yi3N%?o>1ks}s~%%TV+<9#OVOP-i`b#E>uUXBpgkToqfqq$-4NHB zHbL7kggYqj!rCuWUIu(vvRj)xtpF_v5vF5lvsKcm02Uy03ivvQzN%~6Pj`1{@mr%y z2W0ytCqArAVE2j*v4nku4Z(~>!agzN(+N(8AQwyJ3l@P<51~?)>!aS9i%v}H%PcFb zNa-mXX<>1cM61aeGQ06~$P@mZW{s9rVo}ZBRp>LFk+#GQ^C z%feV;2k8E?9?%`_MU7N!R(nZ61x}%IaF&4cbg93}u`_BS0s03{QrcE<56i4o|FAz^ z(wb@@=peTvc3j*6|3)Sz zhy6kDlx2hpHK=|MpRoVYtWg#D^J}T;B~ocDOJzPE$L7|Nk&z?uK0F&~Z!vDN`!k(B$v5Jo~;hz>*D#evOx2R8o`hMz@&Y;%mgzr>M_4 zD_FM8cafDA@4~(a{*c=XJ&bq|RQ#3HxZEGkL1P$l{z`XbWZ%11tXpa^=Y-vD`3k;G zLQhUnCyl4QJvn(L?aLa)zczVV33HV!CiCkBkki)j*qK3fekB+2+#_XA06#)8X|u~S zJS>@yHJH_yoSBwiB3kzqMO<*A-SbHN;ArrvQU2^|>(Ysigb;&f%&>J~uaFdxB`}mq zjNI2sHvaH&9Bkp^uns$;FEEFDG|c#lM3mz}Bi$-%{=xNcJQY`2 zmLK=)OXx?1gTcuW(@uo73wuIr?2(#F42I-u-+hp3&T7t*DPk+cxvs4u;p?q<85Zlh zhM+@9@o`6-maKd!iR6@spwiyUS~A04pk%1J%2Z4B7x+$^qfD67b~1PC_c-nfv0d@` zVZ%Y^m;$k~9q4*CDrg8x)qK-U9d3)12v3hKWK-~x+c;yN+>t&$*rCMGO{w(psDde* zwcfmuM8I}~hkaaKQwCTl`Hy913S1kgWv```MJ|8l1FDz+UNP2bxLg`X%i_M@xJpd( z9)zeF5cHe80fzWv@Cf2% zzz~1>^8BM)#MB>-#8jxLW>CSqNWh+n87oZ^VUy11qpJq$Us_Z-6XR+!1jTKxwo?N) z5KP7vGAEPg0i6sOdoe2&rvC&1E1^-o#1&1xxL9`H##5khHuqo)Hqwpu$<7sS%zVGf zo0cx$CtZ-p*ldLvc1Z&oGjT;Baq}+>z6uM*^1F0urs=~B`NLXk-+k%i>&BcF1~QaL znmi!hz9z~NhK7`E+V94(EBfiF<^A!p1)|tyg_tVsbyc5bWA_T^7o3JW7VI?BF=lAo z^6s0$c6@XvMCc{fJZqB38MB%6H`tU$+#`RnAZueY@Ul5rIS?9bf5d04=&??huw223 z6Sus--((OEr}%t6Vaf4q2s1D&h?bGxV99z_^f0;I@iI&M3X_~m_1=(xf94s5*y&MX z7;x|^9f*C2nhYR(m*c^sCOLl<-`uH3$#zWT7ZVo0!pkh{MP0NR{AN-j`Xi4or1%Si zt3;NzCnYO8FdfJq@(lAJWDX}gCL~#3nlxVJ*JfkuA761*Doc1cG7LTWS^RFCllvP} zw0Kql6}9uOwtT~^l{ov*Bzv7$UT!%<65Wn*hUNp}>)<=5cBd1Dw`#rW>TXn)+_BlH zYg=IxYDNJSv3&SsFo8bTK#leugl2+5f+0v?w=>k#CWn5{*xKtVj#qtaIHhJJAJJzG zb*-rx!*ZCp4%NW;mFCILtH0+e`^{j-xMHG*w{8OetakmZX_V1pZv%h&m`abOW(do} zWV=hVqnK}HhNVIxI-5F=o?PMyuKGt1_O2a&G3>5hK%g|NxZ5Ef`avenCp^e?A8 z0yOaACTfpi9b1|&DNUL^vDwdK#4+Nha3s{s zXN!_|7yQW2sWcB0EFuD{qmYN@QT7p6b;E>=`ivj7#_)HKFa=`K8-H1hj9|7F1bBka zIJRp15~^~$)#_hLilvbHM^zn%H*s)Rr2Q-{>-M;94TX60_aIc7JUof9*k1RXa;|f4 z$DMlf&7K%jwb?7(+eG{!jStWw6ul5SaUxB{Y5`+mUlX5D_jjgijiB)q1tz@dAY}-F zM)gG~d&CkF?;hpp;71KbuhoaoAqW? zsJj&kjBpb{UU7nuw=(1{!jj6J#69Oxg5GZ-ee+n+wcm>zNyW}c#ZKpT=N%r-s{Tx0 z=3eS}h3vy>(b`UY%)_GDQV(wh`@{B3_{u&#y{G2=i^3crI_J72fAp-A<>L{9a{g=3 zx?FWHT!-nz+x`%-70T2VL+B^3HJF=rc>F=sU}Dy7&&asbzO7ZQtY=K_AR%nGjZ`6S z4lIAd-Mxcwx@Jo79`0a^@Y3$_S(QZ|u5Fe^klxSozCY?5j=LW;yEVoBL32##4Ebux zVcI_^!)13BA9cc+AXkmlr8x62INN)xKz#@ADI1zxnXQtgTG@~F*1iG)6of|v#NlDz z#xtOaZwKDJo?=M|3%q3dNoEKhF4wosbDSawu=150~@d^ zV$23}$L7`>JIRA?SXj9JqEHGHv3<<(UelT{5HM7e1e9iBh^^B}kIiLn>0gw2cFW@c z)^EA%lEP}!r6E*3=R00H*P({=Wr%@u-)ONFxnc9`W=7?P9rS-urbP7^=Zt+7_7*hrQwB7PXU+T7-+I~yp3#(W%f_H;i?-(nzw}NkUiL%PNKF z6d6xK&Lx)GUvGF4I}-HyayZIjq>wB=NkX%GXKt)Th87{S|3fm~sO@Jl9_R{kp&BzM zwou8NtRq6_x$=DV%Q;p!B+s&^^RRAV3DcwYR~31AKy5Z;+`M0FVcGnsz_Wq4tV(}H zPjN3}K+U;F2P-=#`)xkKVylmds0slsD+@$i)!c0-X=ho|h!CzCx_p^4p{QY|e zzjy{rQvUqlxm&3SC^@M8b1AZZp9z>LuoFB&dHnAIY!rY8uu?!F1Pl~(%v}jSB;-Gv zI;`p9TRZx%O~NA?03&u!e@M{~H$X5c>HMZBNgx%+4B5kKKV zU}^JT6vSVY>g~TM^zL^uwSl?kYM%FW+b#Egxf^#$cb$!aspmaOw^Ch4_qpzkvcf>x zO`zjnl)(KkwrjtztK1uzfg20wz@Fv6$-gLz_dmaj%d8zxC1P6MN)0#NcWm6_-w%Y{ zzWa*v7p1DjJnrm*Q}Djyp5h;b%iJ40+brJ03xdBWrZ>bgJr^<~H|FbWnyAGnLVd*IqbLg~5I**$E7mk;u!e$^SjKq8=K2Opwc(L zbCGv|=bGSO6r1Zn$3G7Ox$XQ1=mN-@`(0)Nz%S78*F!?n-MgOK+`9)6#J14?zN@>F zX{6u$i?S~>^uU|k?|7!C7xZT`=aQyt2Z3?t6NmSU_w?U6eqP=@koLfes&l2dJE@-A zpLb!rfFQ4PZ|IK$&n0Dk9lYuUSa>9J{-4&u`#X2{uC5TMF!xUC0cpj*C<8#)K&}0- z8+w2l5B~#sw(sIgegbs@FdkTX3e@+&VE~2y;i2hwK)4U2$&CI*Ss162Il6fM0LuH} zL*_xIKkrSDei8Ski$~iJf(QiiJwW+>h%Szyoc+QZHcQV3p~ESRi$1Vv4z&T5XzSEU zGtRT*9YE|nbehlbV8^QVb+qqIl#SdN?Xl7rx@AqQyY=|VYYk?8$mRT6 zOWAvD^lCvxFC-d%bX2yWlZr zPj)G8(VA>atdE;LytS3(d>WC&!Z$Bt_y_ zKJ~;K+)4=K9CgF=hM?T9`J)mWrI|kBC%$`Dmh8ok)J3?K^my3IXtNGcdr7O660mlH=XE2>QybNXNW^>j zOWXuJ$(#ZqDYwJk(V6t2N^S!mE9Zo_glBnbv_o1j*~a`$PX@+{T5^$r-5t{R{6~(X zEpA)Sh}!&(i`%uz!raB%&8{M>y6AZJ*#)Uf1m zmG00hLa((|vCQ`{2i&j1K%oQSF*B;s0Fd?Y(QN`f@ zG}>oj&bw9YZv>2p z@UAWWD8feA&t_V+K3PAwV6(Hiv0@cY897IBGou&F%U5t>SqY=Q5sBIX0vc%Dp!~sN zaHA?aEv8~NpQPJ z&~{l#HOY^%)gOnGrrbVz@x)ZOf%2r5eE8Z-)o4G64-`JP?n?GJ&kw~X*tJUMWlb5X z3RMk?73-r~Th;xj^$*k>;*YF#QHIDwoFvdSna*f;qx@2yU=H_PJTDrpUwra<^!8P5 zW-9MiN#~8}A;MIYx7YMz@`=q*GlDyP4^6U|nAV@0d&{&BRLV8J3ie59pg6#ifZ0dq zL5sRAC}AlgUp;c6RjX1R3g_yW4ZSCr7!k3^)X>L!YcFAW0TP$Q@2zcGTpD2^-`(0q z-O+_CS38gynC*E#9@%iqizFaYwh}((M{dkH>!p%9a`pJ6SiS$$8cLa_Ce`m5krsQP z`xUqPSkXKeTir4&7RRMk!Alogl$+2WCCv2~J(q10+ z^8A7HmC$fMDahqt!%^g2D1m^2#XXMI>N4!j22*iCljFy#&AjRbFWM58gss=|$cqdM zjQehS!A`w=IG9k$XthW~xL@+jRU|75LGAA_y^#P48};%Z*52tsBWVF|*6;a2mz_~S zJTj|}4)ROO%kRsbkrd3!4u=fnU6}2+HdRrY7Fh}P1MWPEqJqn#8xW8ZU-lMO->64& zT4=v|m(eKCQyXE7`dqim}=bt0MWP_^Z>eQ*cWXDD+ zOR#v4xDH%qUCRu6u|r?Gb1oNve1mvCmd1VX47DxLZ6LMSd&EReVpo=)=8__*aHC4j zJo1P@+y<(o$F+mm$W`3MJ%S@Uxx5(kl?$1W{vsqR_^6&J2@aBIA1jy9yfE&w$06Oui#rv#IUGJ){e8PS)#YSif0x2R)^E@ z!IM^enO&!)rX`jiTRe4nB<(}Rgxd-4`p-x|_+J$4)Y}7tzbO64%T(@^)FbuLlR(ZR zfz0joaLTq@(blq5QAum6XsSyRiw*;`I0M;65;6TkZC`r^PxRl;uw*PQ$L9huMc)KE zrf3K?gy}&_%Qd5v1+UACsV!IYaKTg|-Zu5k7~xuIUtsDg1W1)x-t>g>^y6U$I!0fA z!CA12`^revTi#KgCVmIEv3L_WZxY=qF5Hc*7A`QdP~c4XX>=gnzV}$*hq;Dq_cxua z8jnOcdn%bLsf(OmuDtBlaplc9s5V!9dhCZe_9zpasS9I z@twLr=jFRV>=d=k%g)Rcb^mw%??zx*iKzmWX)PCMDa7v-`Z}yFH&ds$Q&Qa2cE&$O zuxF26r7w$rByKL*N!}km2u3+kD(m=Z=hfRpqJ}Cd*uy&tim$-XhsbvXX7l=N7RS+gV7-F}-Zb zxt}+}3M*i}+%+(H@*fuLid)S&&s_Q=wFtdll_tqx%`SXEm7gcF9{4R$B-wHKIbC zPj(i-O0Hs_r%dtoz3g%tV&Z`F3q~}uu%9b0*M!f|V6m)V?)m!5^5U&HVssxIRBX5J z^ov5D*s#IzU1B-8R)VT*X&{r!h_|U$S~##`@Op>b(lh4~EykxzQo6nb7WeW!3CXWn(`DX3~*dFcTKR<$h~iqMv9dsH3H2ZH#Iu{b=PC zn(#W6qy&Mj4X2gqetrDFMdB_KaX+S5`K|s7wQW8F$6E6FzJ^(y`bVUZ>{i} z3Kl}`BFE`7?gXN8v9t^P9s(`5>9eGyuIKmZLx-rp*jV6a+Q_G+PbutcZ>OpbI-o?^ zg|vb*v{zL#*21aY*Qs-V*H%1{)N*aWj0TYCoV06d(XC2e_3B zULjfb$3Q#~@*xDhQ*cP9T!T?6mbxk}gk@#C#vt;kWuCXrB(y4ayv4TRrCCEL63KuufP-HGC{5f;8YAO+foW+nV5UbmEZ8I298cIV zjlws{OyIMIQss*)m?7gO5>fF5@!n9Q=-o+V88&Ok1Q!AvH!TgtTrnGxJ0p{Xo}v(>)v+)zOQnP; z#2kTv>-w0TL=s!Xw(Jsa6s^}26lxJZvi3n*Hav(`P^&1;ptDe1BGWEZ9YVT3(@S8F;UrQZ5{8rtur@Oh6w#d9LrsqC2_f7i{+P-IDm!*Adkp|@Jq}EGa!p5CHf%2CfI`$x?H3! zF;xesz{s4K7Bv^_kMki+`8ZbvAC@~dePL8u`itWNCAR_C!fbG>0!2UYnqvlOhnA*fhlb*1PK+z_E6_Y6Z!DrFNo)%U zv(uFrj>&O|I1Cmki&OW@3;`_D47*CPEIDcrD~pX9Gc9zhbV0eC;Sk_>GZ3U_D-L0N z5pVmVxfcM$Ujq&ML|lj$LlN85^}aW7&H|)Rw27!z#?LHUk&I}`4#HKl6zO3~lnK1^ zJ$eIVHzv|W7ih$KXAUK3k$$7(LOv{Mnpre9WB!=rVj3YLoWGDE!_ zrY|rprck>Xyf$6NRj5Gc2RJat>qJH&wctwvF6IX9IPC?{m5qW{sb!MY)Ms!~{{Rnh zx|Jhs=2&wn*#(Q#_6bnEl4Jd6F>KS`k){eSfs1wzq*DWz27qBcLU*iF_qKe~y2RhOQJTv^1e-SBULPWyP16B41<& zx=J{1FG+r)*sh3Ds5H6v6xMo%wHdKr#NmG2M7!Q13$`)P+%>O4rM79zAWqM?yc0UY zSTVWeKp+z}@o`=-wk%8<^xQC(zXswN!VtT4ENUPAh&VF!EI^&%DS-}#Wxk|;_i*74 zNR_!F)0gBS6?A+`3^DT=$_Kf_e~1?^?qjfEt=U@^e9Mp2Dk9CHw2RCbNaR$(GsdCJ zzT{DVctN+}0}FkMO7aug!a)1#H{5+`%Q90Fa5kyM!~}yaWLlM?8ySll($xH z5xIA8fIhf`0$I|L-NZIPhF^kf1TUx!2|Y&yeWs>ZCfsyxHlnZ08H4V01L`Trie*_s z?q-lV*n)*|vW4i=>NeFlaV!}`1B|-oFftlwDp2j|q_LU4h^Bye+-%_Y9BjBbTuq#v zz#No$W0YruJxMu3Vt-INUxzV0B}=|wTrg$=`;=}44KRXHp__9Z5W=g3J*HMY5P8{G zM^KehGJr(>NqhIx(9E$*Xk<$zE^Tjx{eif-CMNiu4%o z2cs3TOQx)Y$_zSa^C&v}DUN`q4a?$LLgV49u!A6&VyN{F$oT{zka)<5@Mn6K zzR;L&f;WVI;_(rbFtY6iXe14Sdyf_#Anqe-!h4imK8Qm~4`04wN+v0gAp9584EH@) z{V^?h_|%t~K+^3oF>l5U!?gI+4Z(ZL34r<{PHPmzYT&?_Lk1Z8680RxTSOVp)U%Lz zxP>XuP7VFawL{dj+GENw9wn|>)f%iy&5##F8+d`HD5NE#KwH8nERLB`v?aQQiDS9y zTdKHoVI@Dz@W(AYrwL$eR#%a*rm&BMmFfySB_+cKtMebTc65?j3CRW=RK>$Pah3+y zBCjA(?~q|V_qA*2V5WnYr|M(@_cOB? zqi!H!4&iY`m)X0vP$ck8l7>|0=RMY79Ybqv%0;|Y{0UAVVshKrZQd;-#aOR^Gj z+_?P539_?wMOp5 z#4<5}ww*J&yC($^U##YA6c;OvTY&B*fy45!14pQThFd%Lca|35$YVZYzkc=LwW|NE{@1AQ^Q9fQe-!=P#%Z(<;*n=tUe> zEVPOP!aR`ylH`i@6q&OSRh&Q@lABnsrI(p~<|QbcxF4t-K?2(=h?D9i(+Ab#s$Ipy z6Y{~P4zf7NOzLc_QUTEQ5u!fh-r>TbRrU}P-$j^&PD4kri@;|@sGjjMnT-Q01=y8y zng>~U#8~|6m@>VO@t3O39gEP2^$)_tzG_GrA=kg8Zn8@Y>7%{2*Frse6Okxfy4|HXaFvb8(tN z1kqU$Hhe!f1ckKDT8eC`LvZ)t#2>ivo{(8Di)uf=`Hlh~sJ=<^n|NZp!#vIt62vA_ zYP05C43W)6b*V|oaid=f#+{PTp!k?b9c2cgSJ;6Bzru}l{<4;NUka75UZGe=LmkPB z6jd-RXB6Nev_=h?iEUf}%$BSJ%PZr35!)6{HNq&2TPMm#As#F+;Y;NVSC)N8dz4ZZ z#Qy-22n;3KA_}{t?lQ>mUvS#35Q;m7@Q6K&?iA#E7c1HOCLU4#BOKHo*FW)MZ8PPL z17DXsZ)yjbWjFOOSR^fW{CZ-E zys@wb7BT4|rqB)j#lWt!lalO1SN6eGNZ)8Z5mm3|E81b%m_B{kBlsXDSjr&fD3mlt zl#R-h3lfwFU)G^RgI7^3or=@h9>eig%7a0#sjz3fL3Iw282Bnncl1FKXzPRqPe7dh zrbbf(7RHzuuMzui4j?06EGz@$20s~ZL+Oshf~qV_1T_>YdFA^93a=lSi<#kw`j$5W zD~Nr?*p~@GNR7&a4x?MVf-IGzLxQEftz z>o9Ul7+a_e&DFs|FWPGJ(751c?J+dt8@;Oc|19&{+Dn7D< zx6esK_tCogn2on{%P!cxGgYI|jt$P#P5Kj%fbThJ9D>L&%(SK8R4FXW5q*~x!1Ppb zG&5r!mZBWj%wdeDY*nrRnq(U3Ibbs<*#(vFNr}6_IWfvd)Tzmn+bYtjwS^lX&`F&G z<|fOE*B{oY9I$&QY;^!Cm&|$p09a;F_Xo$bBUWm8Oci_?XrJNCPi{&MnQO!)-&WCx`=!DGVGuO{iv)tZ}UO8ign?L`K}8q{q--c?MHU%c1obsPM!b zKy@zT5w4j>a*c~)aZNda9fFyLoTIT4G^e;6F<8vW8@)`JOdD6IV$0qkP5uutH||0< z42NuZB(bb!;Y{8odyC~R0eeY^m(QjaCfe5x1xBC*wTnYa=SoZ80wZPaAONeKMjU-d z;f4@&@hY&eqEKQO=?S17W-U`lm}ajrc=x!t=4WLBWb6wyMEv*3-1=QAnc8DQ-0%n$_^JY>p_6J9(A%6#$-W&#~dtxlYhcM+oaXC!S73wa_`IRRA(jAq$O_N!vYOK9X z*;a$$Q_K>oAHpFu85nKBgN`0$YiF@Mh^u!WQ3Mw7MVU&<1O_^qs`Z7+gR~o!*<;X0 z1|?Y;O3?HsMf}pkFn2v?hYZ6Nab7;jW>t}|@C@s#JxWhFyF_Jo1uA) z3?9j_Z_EM%$keT{?YL3n*$az$V4n*JFTo!c76K?hW$+?_o{W=_Mv-j4F>VTTDbw(7 zk?sqk&dBIRdI_Ew^NIT7mHkIe{ZvN~Z|w$JJyik9>dsoPz5(0mV1H!BH7y>=Mpc*% z$FPdxlM|=emKK`ha;szP#1i2#t10SSX+=1xmx*E90D|F&2wjY{3Hz|lzc`m3nl#(x zQXiARvqhh7er1AO*o9@dGgJz;9g)l<)N=rOU{;suj#*NzZv@IBcTlhc%PO)U^u@)o z#zE?%nCEILusx$RztA0jQwSo%aAkPwC%3g~Ea!WnzBz0gdSeK0l4A%gc zP1Lsb#0;wlCLav@X;~PRd+bWSY>HC+Yu16yU z(DU+-eF2e!o0S`Yx`ORgfD|K?8<{5naU?buXKkB93&&5U58XF~zg(Ov{U*h`@*37UNI25}x>BRZ7iK znu+T%k{v(Q;s8gH2OwOUVPXs$fJ00*$Y0EB2zl^<6({&qMWe$hYfz6#QBEg=;KN>4 zu@bimy@$=PBo#zpa(j!Gh5MpJ>#7WXW5I52z$?KRLeG-S)*#Zy2PtkE#v?W$+>f}uyXtIR zo*1o-`jklbVodj~NHY~&tloZtIn3=Uaz2T&SlR8u%c5C?j#_axj$fH&W)zpy(^B5> zJA>#*p0O{t)KrQXNn^ERxSenymW<3gB=PAV&jCwUXM6oPhT#XrATVx6vAn zh@#0XqyaRLO~)4mUpinwyi>_6>N?9U=2J3kV3kH{xJvXq?3d>Hp_OfR6kri+O2XTQ zFw856WNv8O&BL{klrDZ7OgI(EFlY=+#;?H?52`(m z3<#Bhm}QOCg3QniD67Jz0RkICAqOKCe5<(U{s;PFnLLb=ueeE=;S~|)T!gi9?JP?nhV_F08;WQoROmcuf6SIWDo2GHxY-7BDL7fb2su3Ik5lznscT!0M*Y%oc9rxb2aFz0|g& z7|Z4cLv(HxTKBYt71TLI$i(QxyNLc}*e6`YTT=Oy_XV?MN>^~G(iJ^%m=-*c+Ajkw zp?-v@oCkJ;uAfxi;8FKKT6rc~9>K`R^fDkme&XW9cj95ME@o$3yfUo~lII#!RR=kp!pRkIf?64!SqcSWQZ%O|EWFZW2`nF}sd=NSOkO4vZ zi`ou&Fu+yeyDrdOU_=7&!yo*ZTS9cGCRkSYHcg?BsQ5+|=7UJ3PpHhVwummLsJSg- zqm&~2%sj&*RUXUj2#aC;6FeLd#*!p5AQ-rYrfUKKbsMEET4tBnKrUdKBLHnnb1fD; zGXo|e0(DW?aR6PBkc?QE*iq!Ud^F}Q#C#i=*1I4M<5MbbNnn>*$#$SrHLraLPXQ&_ zxl>H4a+E^oov&9Y0xb)cDGG{hbZ7*(l;BU ziBl#IO#PG@h$#-^O!!zb<1b4G>Ne9pN#KD_Z`4G$<-z-b!oFYdxL^azf+GWu0?i)u zFm-Hp#|+77Sk~ZY5o>T+%n;3eO&At5VA83b0q$XBPFCtl^p;zI zhT#d=tjlS}L)1aZ{{ZpgtQnMXE|j6V5}u~deNGG=;Woa5aWDi01Lg^xqCsUM`^9a^1cTqekj8J))46npD_N3MsdfY%>U_&zW8)7FY zRK8m?#1RFv9*zk`GXcP1GcD#27c^PFR8-<56f|)W)Vve)w>Juevu$3;6<>=u<6M~b zFMXw<bvLYqMx$J`zdp_x#+^g$fI)V~be{{SQg7rc_A>564reqfRj zO2kW%f|lclBoy*&AJlS|e$ct-seci~Te4*^Td>}T%LP+Dp*_yomp_&<)DN^(8Jjda5&+8! zh4CnL8VafoLLu5=`r6@5=YtcOKQ3UZU*Hm&AKhFzGCWFi?rUS=fh`HpA-5gjF5#Xw zM5=c{xVsD46Q|TioJSj3lRL1F@Z79g1Je@%OoZn$g5R;9r&&Qkxgg)?pZJno-7?*z zpdd&oS%cb-hmd+nh9?mlAP*BzaO5l82XYeKuyT<1IILkXi8ZRGS8FUi64MZaFtZTR zQv;~HNA5&`5z~XG+}wj%h~~W#R@(BEp*fgpc!;v!xQYg)wRSSz<)!vGVT0mYWgbqPdYBmUd(_Rh>`UA+=7cYC2Md7n zE-Ef^%T{7l)x-t$nnv?;vR&c$P?za5M^|bMw|BHJBA3b`h)F11sle=px;bl<=ohHu6jjhDG|GpCO!97+yzVgv|_mflPaR32u(+ZQosj3t_g>ZM_4%wMu+m>TVK z2qp#75eCi%GmCRD>&TKFQ@?k`IX;rN6Ftw1i|4@ z3nj3>+@uUnYlNV58kZi_${Zz`i-?GQZw$=4J*WjstciA|ca+XR_D^^rDVWz}r_FVc7)dqqe) zT(g0|977c}UK;sOfHC)%2hRF1{q%&2oq>XlLMK+nk}5YlFcW-1Qgx}(B~yZDrahPt z69YMXiC4P}k?JzK8jENF9S0U^hBH~1QryQA9I*zWOe=`Yle4%J-W3e=MeuuS0)Bm8 z)L&|y5N1cphPL{Pd^X2@9t=eo*SB!s zq2VaMLfPaGGYtYIXqI4dERnQJzY`9w^3RI>?hguqFmg~l${8=H_mSfQVJ|ymjShZ^ zPs&d}W0(~}CwPEv;*(G%nNB6+b0#OEB|v!t{>&0yGXu5aH34XIjR^Wo%0Aeq7Xo;> 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≷cF6T3 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 bO4CN>`XD%)o9X~l=Tx9&Dacy?_v-%ujVfr8 literal 0 HcmV?d00001 diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 9317e9d805f3..cf929b4a4b08 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -100,44 +100,48 @@ for the lift-cube environment: .. table:: :widths: 33 37 30 - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | World | Environment ID | Description | - +====================+=========================+=============================================================================+ - | |reach-franka| | |reach-franka-link| | Move the end-effector to a sampled target pose with the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |reach-ur10| | |reach-ur10-link| | Move the end-effector to a sampled target pose with the UR10 robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |lift-cube| | |lift-cube-link| | Pick a cube and bring it to a sampled target position with the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |stack-cube| | |stack-cube-link| | Stack three cubes (bottom to top: blue, red, green) with the Franka robot. | - | | | Blueprint env used for the NVIDIA Isaac GR00T blueprint for synthetic | - | | |stack-cube-bp-link| | manipulation motion generation | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |cabi-franka| | |cabi-franka-link| | Grasp the handle of a cabinet's drawer and open it with the Franka robot | - | | | | - | | |franka-direct-link| | | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |cube-allegro| | |cube-allegro-link| | In-hand reorientation of a cube using Allegro hand | - | | | | - | | |allegro-direct-link| | | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |cube-shadow| | |cube-shadow-link| | In-hand reorientation of a cube using Shadow hand | - | | | | - | | |cube-shadow-ff-link| | | - | | | | - | | |cube-shadow-lstm-link| | | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |cube-shadow| | |cube-shadow-vis-link| | In-hand reorientation of a cube using Shadow hand using perceptive inputs. | - | | | Requires running with ``--enable_cameras``. | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |gr1_pick_place| | |gr1_pick_place-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |gr1_pp_waist| | |gr1_pp_waist-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | - | | | with waist degrees-of-freedom enables that provides a wider reach space. | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | World | Environment ID | Description | + +======================+===========================+=============================================================================+ + | |reach-franka| | |reach-franka-link| | Move the end-effector to a sampled target pose with the Franka robot | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |reach-ur10| | |reach-ur10-link| | Move the end-effector to a sampled target pose with the UR10 robot | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |deploy-reach-ur10e| | |deploy-reach-ur10e-link| | Move the end-effector to a sampled target pose with the UR10e robot | + | | | This policy has been deployed to a real robot | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |lift-cube| | |lift-cube-link| | Pick a cube and bring it to a sampled target position with the Franka robot | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |stack-cube| | |stack-cube-link| | Stack three cubes (bottom to top: blue, red, green) with the Franka robot. | + | | | Blueprint env used for the NVIDIA Isaac GR00T blueprint for synthetic | + | | |stack-cube-bp-link| | manipulation motion generation | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |cabi-franka| | |cabi-franka-link| | Grasp the handle of a cabinet's drawer and open it with the Franka robot | + | | | | + | | |franka-direct-link| | | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |cube-allegro| | |cube-allegro-link| | In-hand reorientation of a cube using Allegro hand | + | | | | + | | |allegro-direct-link| | | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |cube-shadow| | |cube-shadow-link| | In-hand reorientation of a cube using Shadow hand | + | | | | + | | |cube-shadow-ff-link| | | + | | | | + | | |cube-shadow-lstm-link| | | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |cube-shadow| | |cube-shadow-vis-link| | In-hand reorientation of a cube using Shadow hand using perceptive inputs. | + | | | Requires running with ``--enable_cameras``. | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |gr1_pick_place| | |gr1_pick_place-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |gr1_pp_waist| | |gr1_pp_waist-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | + | | | with waist degrees-of-freedom enables that provides a wider reach space. | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ .. |reach-franka| image:: ../_static/tasks/manipulation/franka_reach.jpg .. |reach-ur10| image:: ../_static/tasks/manipulation/ur10_reach.jpg +.. |deploy-reach-ur10e| image:: ../_static/tasks/manipulation/ur10e_reach.jpg .. |lift-cube| image:: ../_static/tasks/manipulation/franka_lift.jpg .. |cabi-franka| image:: ../_static/tasks/manipulation/franka_open_drawer.jpg .. |cube-allegro| image:: ../_static/tasks/manipulation/allegro_cube.jpg @@ -148,6 +152,7 @@ for the lift-cube environment: .. |reach-franka-link| replace:: `Isaac-Reach-Franka-v0 `__ .. |reach-ur10-link| replace:: `Isaac-Reach-UR10-v0 `__ +.. |deploy-reach-ur10e-link| replace:: `Isaac-Deploy-Reach-UR10e-v0 `__ .. |lift-cube-link| replace:: `Isaac-Lift-Cube-Franka-v0 `__ .. |lift-cube-ik-abs-link| replace:: `Isaac-Lift-Cube-Franka-IK-Abs-v0 `__ .. |lift-cube-ik-rel-link| replace:: `Isaac-Lift-Cube-Franka-IK-Rel-v0 `__ @@ -786,7 +791,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Direct - - * - Isaac-Forge-GearMesh-Direct-v0 + * - Isaac-Forge-GearMesh-Direct-v0 - - Direct - **rl_games** (PPO) @@ -882,6 +887,10 @@ inferencing, including reading from an already trained checkpoint and disabling - Isaac-Reach-UR10-Play-v0 - Manager Based - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + * - Isaac-Deploy-Reach-UR10e-v0 + - Isaac-Deploy-Reach-UR10e-Play-v0 + - Manager Based + - **rsl_rl** (PPO) * - Isaac-Repose-Cube-Allegro-Direct-v0 - - Direct diff --git a/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py b/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py index 565d8f3f7583..02b19cd20031 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py @@ -15,7 +15,7 @@ import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets.articulation import ArticulationCfg -from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR ## # Configuration @@ -50,4 +50,55 @@ ), }, ) + +UR10e_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/UniversalRobots/ur10e/ur10e.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=True, + max_depenetration_velocity=5.0, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, solver_position_iteration_count=16, solver_velocity_iteration_count=1 + ), + activate_contact_sensors=False, + ), + init_state=ArticulationCfg.InitialStateCfg( + joint_pos={ + "shoulder_pan_joint": 3.141592653589793, + "shoulder_lift_joint": -1.5707963267948966, + "elbow_joint": 1.5707963267948966, + "wrist_1_joint": -1.5707963267948966, + "wrist_2_joint": -1.5707963267948966, + "wrist_3_joint": 0.0, + }, + pos=(0.0, 0.0, 0.0), + rot=(1.0, 0.0, 0.0, 0.0), + ), + actuators={ + # 'shoulder_pan_joint', 'shoulder_lift_joint', 'elbow_joint', 'wrist_1_joint', 'wrist_2_joint', 'wrist_3_joint' + "shoulder": ImplicitActuatorCfg( + joint_names_expr=["shoulder_.*"], + stiffness=1320.0, + damping=72.6636085, + friction=0.0, + armature=0.0, + ), + "elbow": ImplicitActuatorCfg( + joint_names_expr=["elbow_joint"], + stiffness=600.0, + damping=34.64101615, + friction=0.0, + armature=0.0, + ), + "wrist": ImplicitActuatorCfg( + joint_names_expr=["wrist_.*"], + stiffness=216.0, + damping=29.39387691, + friction=0.0, + armature=0.0, + ), + }, +) + """Configuration of UR-10 arm using implicit actuator models.""" diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 0299870aca2e..95a1930a30f4 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.10.47" +version = "0.10.48" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index c33f645b33d2..41732b6f8319 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.10.48 (2025-09-03) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added ``Isaac-Deploy-Reach-UR10e-v0`` environment. + + 0.10.47 (2025-07-25) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/__init__.py new file mode 100644 index 000000000000..eceb73b9ca1f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Deployment environments for manipulation tasks. + +These environments are designed for real-world deployment of manipulation tasks. +They containconfigurations and implementations that have been tested +and deployed on physical robots. + +The deploy module includes: +- Reach environments for end-effector pose tracking + +""" + +from .reach import * # noqa: F401, F403 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.py new file mode 100644 index 000000000000..6686f9f52766 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""This sub-module contains the functions that are specific to the locomotion environments.""" + +from isaaclab.envs.mdp import * # noqa: F401, F403 + +from .rewards import * # noqa: F401, F403 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py new file mode 100644 index 000000000000..0d14620e225e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/mdp/rewards.py @@ -0,0 +1,231 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import torch +from typing import TYPE_CHECKING + +from isaacsim.core.utils.torch.transformations import tf_combine + +from isaaclab.managers import SceneEntityCfg +from isaaclab.sensors.frame_transformer.frame_transformer import FrameTransformer + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + +def get_keypoint_offsets_full_6d(add_cube_center_kp: bool = False, device: torch.device | None = None) -> torch.Tensor: + """Get keypoints for pose alignment comparison. Pose is aligned if all axis are aligned. + + Args: + add_cube_center_kp: Whether to include the center keypoint (0, 0, 0) + device: Device to create the tensor on + + Returns: + Keypoint offsets tensor of shape (num_keypoints, 3) + """ + if add_cube_center_kp: + keypoint_corners = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]] + else: + keypoint_corners = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + + keypoint_corners = torch.tensor(keypoint_corners, device=device, dtype=torch.float32) + keypoint_corners = torch.cat((keypoint_corners, -keypoint_corners[-3:]), dim=0) # use both negative and positive + + return keypoint_corners + + +def compute_keypoint_distance( + current_pos: torch.Tensor, + current_quat: torch.Tensor, + target_pos: torch.Tensor, + target_quat: torch.Tensor, + keypoint_scale: float = 1.0, + add_cube_center_kp: bool = True, + device: torch.device | None = None, +) -> torch.Tensor: + """Compute keypoint distance between current and target poses. + + This function creates keypoints from the current and target poses and calculates + the L2 norm distance between corresponding keypoints. The keypoints are created + by applying offsets to the poses and transforming them to world coordinates. + + Args: + current_pos: Current position tensor of shape (num_envs, 3) + current_quat: Current quaternion tensor of shape (num_envs, 4) + target_pos: Target position tensor of shape (num_envs, 3) + target_quat: Target quaternion tensor of shape (num_envs, 4) + keypoint_scale: Scale factor for keypoint offsets + add_cube_center_kp: Whether to include the center keypoint (0, 0, 0) + device: Device to create tensors on + + Returns: + Keypoint distance tensor of shape (num_envs, num_keypoints) where each element + is the L2 norm distance between corresponding keypoints + """ + if device is None: + device = current_pos.device + + num_envs = current_pos.shape[0] + + # Get keypoint offsets + keypoint_offsets = get_keypoint_offsets_full_6d(add_cube_center_kp, device) + keypoint_offsets = keypoint_offsets * keypoint_scale + num_keypoints = keypoint_offsets.shape[0] + + # Create identity quaternion for transformations + identity_quat = torch.tensor([1.0, 0.0, 0.0, 0.0], device=device).unsqueeze(0).repeat(num_envs, 1) + + # Initialize keypoint tensors + keypoints_current = torch.zeros((num_envs, num_keypoints, 3), device=device) + keypoints_target = torch.zeros((num_envs, num_keypoints, 3), device=device) + + # Compute keypoints for current and target poses + for idx, keypoint_offset in enumerate(keypoint_offsets): + # Transform keypoint offset to world coordinates for current pose + keypoints_current[:, idx] = tf_combine( + current_quat, current_pos, identity_quat, keypoint_offset.repeat(num_envs, 1) + )[1] + + # Transform keypoint offset to world coordinates for target pose + keypoints_target[:, idx] = tf_combine( + target_quat, target_pos, identity_quat, keypoint_offset.repeat(num_envs, 1) + )[1] + # Calculate L2 norm distance between corresponding keypoints + keypoint_dist_sep = torch.norm(keypoints_target - keypoints_current, p=2, dim=-1) + + return keypoint_dist_sep + + +def keypoint_command_error( + env: ManagerBasedRLEnv, + command_name: str, + asset_cfg: SceneEntityCfg, + keypoint_scale: float = 1.0, + add_cube_center_kp: bool = True, +) -> torch.Tensor: + """Compute keypoint distance between current and desired poses from command. + + The function computes the keypoint distance between the current pose of the end effector from + the frame transformer sensor and the desired pose from the command. Keypoints are created by + applying offsets to both poses and the distance is computed as the L2-norm between corresponding keypoints. + + Args: + env: The environment containing the asset + command_name: Name of the command containing desired pose + asset_cfg: Configuration of the asset to track (not used, kept for compatibility) + keypoint_scale: Scale factor for keypoint offsets + add_cube_center_kp: Whether to include the center keypoint (0, 0, 0) + + Returns: + Keypoint distance tensor of shape (num_envs, num_keypoints) where each element + is the L2 norm distance between corresponding keypoints + """ + # extract the frame transformer sensor + asset: FrameTransformer = env.scene[asset_cfg.name] + command = env.command_manager.get_command(command_name) + + # obtain the desired pose from command (position and orientation) + des_pos_b = command[:, :3] + des_quat_b = command[:, 3:7] + + # transform desired pose to world frame using source frame from frame transformer + des_pos_w = des_pos_b + des_quat_w = des_quat_b + + # get current pose in world frame from frame transformer (end effector pose) + curr_pos_w = asset.data.target_pos_source[:, 0] # First target frame is end_effector + curr_quat_w = asset.data.target_quat_source[:, 0] # First target frame is end_effector + + # compute keypoint distance + keypoint_dist_sep = compute_keypoint_distance( + current_pos=curr_pos_w, + current_quat=curr_quat_w, + target_pos=des_pos_w, + target_quat=des_quat_w, + keypoint_scale=keypoint_scale, + add_cube_center_kp=add_cube_center_kp, + device=curr_pos_w.device, + ) + + # Return mean distance across keypoints to match expected reward shape (num_envs,) + return keypoint_dist_sep.mean(-1) + + +def keypoint_command_error_exp( + env: ManagerBasedRLEnv, + command_name: str, + asset_cfg: SceneEntityCfg, + kp_exp_coeffs: list[tuple[float, float]] = [(1.0, 0.1)], + kp_use_sum_of_exps: bool = True, + keypoint_scale: float = 1.0, + add_cube_center_kp: bool = True, +) -> torch.Tensor: + """Compute exponential keypoint reward between current and desired poses from command. + + The function computes the keypoint distance between the current pose of the end effector from + the frame transformer sensor and the desired pose from the command, then applies an exponential + reward function. The reward is computed using the formula: 1 / (exp(a * distance) + b + exp(-a * distance)) + where a and b are coefficients. + + Args: + env: The environment containing the asset + command_name: Name of the command containing desired pose + asset_cfg: Configuration of the asset to track (not used, kept for compatibility) + kp_exp_coeffs: List of (a, b) coefficient pairs for exponential reward + kp_use_sum_of_exps: Whether to use sum of exponentials (True) or single exponential (False) + keypoint_scale: Scale factor for keypoint offsets + add_cube_center_kp: Whether to include the center keypoint (0, 0, 0) + + Returns: + Exponential keypoint reward tensor of shape (num_envs,) where each element + is the exponential reward value + """ + # extract the frame transformer sensor + asset: FrameTransformer = env.scene[asset_cfg.name] + command = env.command_manager.get_command(command_name) + + # obtain the desired pose from command (position and orientation) + des_pos_b = command[:, :3] + des_quat_b = command[:, 3:7] + + # transform desired pose to world frame using source frame from frame transformer + des_pos_w = des_pos_b + des_quat_w = des_quat_b + + # get current pose in world frame from frame transformer (end effector pose) + curr_pos_w = asset.data.target_pos_source[:, 0] # First target frame is end_effector + curr_quat_w = asset.data.target_quat_source[:, 0] # First target frame is end_effector + + # compute keypoint distance + keypoint_dist_sep = compute_keypoint_distance( + current_pos=curr_pos_w, + current_quat=curr_quat_w, + target_pos=des_pos_w, + target_quat=des_quat_w, + keypoint_scale=keypoint_scale, + add_cube_center_kp=add_cube_center_kp, + device=curr_pos_w.device, + ) + + # compute exponential reward + keypoint_reward_exp = torch.zeros_like(keypoint_dist_sep[:, 0]) # shape: (num_envs,) + + if kp_use_sum_of_exps: + # Use sum of exponentials: average across keypoints for each coefficient + for coeff in kp_exp_coeffs: + a, b = coeff + keypoint_reward_exp += ( + 1.0 / (torch.exp(a * keypoint_dist_sep) + b + torch.exp(-a * keypoint_dist_sep)) + ).mean(-1) + else: + # Use single exponential: average keypoint distance first, then apply exponential + keypoint_dist = keypoint_dist_sep.mean(-1) # shape: (num_envs,) + for coeff in kp_exp_coeffs: + a, b = coeff + keypoint_reward_exp += 1.0 / (torch.exp(a * keypoint_dist) + b + torch.exp(-a * keypoint_dist)) + + return keypoint_reward_exp diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/__init__.py new file mode 100644 index 000000000000..09b49256388b --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""end-effector pose tracking tasks that have been deployed on a real robot.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/__init__.py new file mode 100644 index 000000000000..2f9df802cd45 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Configuration package for manipulation tasks that have been deployed on a real robot.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/__init__.py new file mode 100644 index 000000000000..11548d2c3732 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/__init__.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import gymnasium as gym + +from . import agents + +## +# Register Gym environments. +## + +gym.register( + id="Isaac-Deploy-Reach-UR10e-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.joint_pos_env_cfg:UR10eReachEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:URReachPPORunnerCfg", + }, +) + +gym.register( + id="Isaac-Deploy-Reach-UR10e-Play-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.joint_pos_env_cfg:UR10eReachEnvCfg_PLAY", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:URReachPPORunnerCfg", + }, +) + +gym.register( + id="Isaac-Deploy-Reach-UR10e-ROS-Inference-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.ros_inference_env_cfg:UR10eReachROSInferenceEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:URReachPPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/__init__.py new file mode 100644 index 000000000000..bcc238c84a98 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 000000000000..40fe884ed007 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,37 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.utils import configclass + +from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg, RslRlPpoActorCriticCfg, RslRlPpoAlgorithmCfg + + +@configclass +class URReachPPORunnerCfg(RslRlOnPolicyRunnerCfg): + num_steps_per_env = 512 + max_iterations = 1500 + save_interval = 50 + experiment_name = "reach_ur10e" + empirical_normalization = True + policy = RslRlPpoActorCriticCfg( + init_noise_std=1.0, + actor_hidden_dims=[256, 128, 64], + critic_hidden_dims=[256, 128, 64], + activation="elu", + ) + algorithm = RslRlPpoAlgorithmCfg( + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.0, + num_learning_epochs=8, + num_mini_batches=8, + learning_rate=5.0e-4, + schedule="adaptive", + gamma=0.99, + lam=0.95, + desired_kl=0.008, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py new file mode 100644 index 000000000000..e21bc6a7f9f0 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py @@ -0,0 +1,112 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import math + +from isaaclab.managers import SceneEntityCfg +from isaaclab.markers.config import FRAME_MARKER_CFG +from isaaclab.sensors.frame_transformer.frame_transformer_cfg import FrameTransformerCfg, OffsetCfg +from isaaclab.utils import configclass + +import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp +from isaaclab_tasks.manager_based.manipulation.deploy.reach.reach_env_cfg import ReachEnvCfg + +## +# Pre-defined configs +## +from isaaclab_assets import UR10e_CFG # isort: skip + + +## +# Environment configuration +## + + +@configclass +class UR10eReachEnvCfg(ReachEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + self.events.robot_joint_stiffness_and_damping.params["asset_cfg"].joint_names = [ + "shoulder_.*", + "elbow_.*", + "wrist_.*", + ] + self.events.joint_friction.params["asset_cfg"].joint_names = ["shoulder_.*", "elbow_.*", "wrist_.*"] + + # switch robot to ur10e + self.scene.robot = UR10e_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # The real UR10e robots polyscore software uses the "base" frame for reference + # But the USD model and UR10e ROS interface uses the "base_link" frame + # We are training this policy to track the end-effector pose in the "base" frame + # The base frame is 180 offset from the base_link frame + # And hence the source_frame_offset is set to 180 degrees around the z-axis + self.rewards.end_effector_keypoint_tracking.params["asset_cfg"] = SceneEntityCfg("ee_frame_wrt_base_frame") + self.rewards.end_effector_keypoint_tracking_exp.params["asset_cfg"] = SceneEntityCfg("ee_frame_wrt_base_frame") + self.scene.ee_frame_wrt_base_frame = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base_link", + visualizer_cfg=FRAME_MARKER_CFG.replace(prim_path="/Visuals/FrameTransformer"), + source_frame_offset=OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0)), + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/wrist_3_link", + name="end_effector", + ), + ], + ) + # Disable visualization for the goal pose because the commands are generated wrt to the base frame + # But the visualization will visualizing it wrt to the base_link frame + self.commands.ee_pose.debug_vis = False + + # Incremental joint position action configuration + self.actions.arm_action = mdp.RelativeJointPositionActionCfg( + asset_name="robot", joint_names=[".*"], scale=0.0625, use_zero_offset=True + ) + # override command generator body + # end-effector is along x-direction + self.target_pos_centre = (0.8875, -0.225, 0.2) + self.target_pos_range = (0.25, 0.125, 0.1) + self.commands.ee_pose.body_name = "wrist_3_link" + self.commands.ee_pose.ranges.pos_x = ( + self.target_pos_centre[0] - self.target_pos_range[0], + self.target_pos_centre[0] + self.target_pos_range[0], + ) + self.commands.ee_pose.ranges.pos_y = ( + self.target_pos_centre[1] - self.target_pos_range[1], + self.target_pos_centre[1] + self.target_pos_range[1], + ) + self.commands.ee_pose.ranges.pos_z = ( + self.target_pos_centre[2] - self.target_pos_range[2], + self.target_pos_centre[2] + self.target_pos_range[2], + ) + + self.target_rot_centre = (math.pi, 0.0, -math.pi / 2) # end-effector facing down + self.target_rot_range = (math.pi / 6, math.pi / 6, math.pi * 2 / 3) + self.commands.ee_pose.ranges.roll = ( + self.target_rot_centre[0] - self.target_rot_range[0], + self.target_rot_centre[0] + self.target_rot_range[0], + ) + self.commands.ee_pose.ranges.pitch = ( + self.target_rot_centre[1] - self.target_rot_range[1], + self.target_rot_centre[1] + self.target_rot_range[1], + ) + self.commands.ee_pose.ranges.yaw = ( + self.target_rot_centre[2] - self.target_rot_range[2], + self.target_rot_centre[2] + self.target_rot_range[2], + ) + + +@configclass +class UR10eReachEnvCfg_PLAY(UR10eReachEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + # make a smaller scene for play + self.scene.num_envs = 50 + self.scene.env_spacing = 2.5 + # disable randomization for play + self.observations.policy.enable_corruption = False diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py new file mode 100644 index 000000000000..4a57028c9808 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/ros_inference_env_cfg.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.utils import configclass + +from .joint_pos_env_cfg import UR10eReachEnvCfg + + +@configclass +class UR10eReachROSInferenceEnvCfg(UR10eReachEnvCfg): + """Exposing variables for ROS inferences""" + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Variables used by Isaac Manipuulator for on robot inference + # TODO: @ashwinvk: Remove these from env cfg once the generic inference node has been implemented + self.obs_order = ["arm_dof_pos", "arm_dof_vel", "target_pos", "target_quat"] + self.policy_action_space = "joint" + self.arm_joint_names = [ + "shoulder_pan_joint", + "shoulder_lift_joint", + "elbow_joint", + "wrist_1_joint", + "wrist_2_joint", + "wrist_3_joint", + ] + self.policy_action_space = "joint" + self.action_space = 6 + self.state_space = 0 + self.observation_space = 19 + + # Set joint_action_scale from the existing arm_action.scale + self.joint_action_scale = self.actions.arm_action.scale + + self.action_scale_joint_space = [ + self.joint_action_scale, + self.joint_action_scale, + self.joint_action_scale, + self.joint_action_scale, + self.joint_action_scale, + self.joint_action_scale, + ] diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py new file mode 100644 index 000000000000..767de2160e5e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/reach_env_cfg.py @@ -0,0 +1,215 @@ +# Copyright (c) 2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from dataclasses import MISSING + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import ActionTermCfg as ActionTerm +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 RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.noise import AdditiveUniformNoiseCfg as Unoise + +import isaaclab_tasks.manager_based.manipulation.deploy.mdp as mdp + +## +# Scene definition +## + + +@configclass +class SceneCfg(InteractiveSceneCfg): + """Configuration for the scene with a robotic arm.""" + + # world + ground = AssetBaseCfg( + prim_path="/World/ground", + spawn=sim_utils.GroundPlaneCfg(), + init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -1.05)), + ) + + # robots + robot: ArticulationCfg = MISSING + + # lights + light = AssetBaseCfg( + prim_path="/World/light", + spawn=sim_utils.DomeLightCfg(color=(0.75, 0.75, 0.75), intensity=2500.0), + ) + + table = AssetBaseCfg( + prim_path="{ENV_REGEX_NS}/Table", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/Stand/stand_instanceable.usd", scale=(2.0, 2.0, 2.0) + ), + ) + + +## +# MDP settings +## + + +@configclass +class CommandsCfg: + """Command terms for the MDP.""" + + ee_pose = mdp.UniformPoseCommandCfg( + asset_name="robot", + body_name=MISSING, + resampling_time_range=(4.0, 4.0), + debug_vis=True, + ranges=mdp.UniformPoseCommandCfg.Ranges( + pos_x=(0.35, 0.65), + pos_y=(-0.2, 0.2), + pos_z=(0.15, 0.5), + roll=(0.0, 0.0), + pitch=MISSING, # depends on end-effector axis + yaw=(-3.14, 3.14), + ), + ) + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + arm_action: ActionTerm = MISSING + gripper_action: ActionTerm | None = None + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group.""" + + # observation terms (order preserved) + joint_pos = ObsTerm(func=mdp.joint_pos, noise=Unoise(n_min=-0.0, n_max=0.0)) + joint_vel = ObsTerm(func=mdp.joint_vel, noise=Unoise(n_min=-0.0, n_max=0.0)) + pose_command = ObsTerm(func=mdp.generated_commands, params={"command_name": "ee_pose"}) + + def __post_init__(self): + self.enable_corruption = True + self.concatenate_terms = True + + # observation groups + policy: PolicyCfg = PolicyCfg() + + +@configclass +class EventCfg: + """Configuration for events.""" + + reset_robot_joints = EventTerm( + func=mdp.reset_joints_by_offset, + mode="reset", + params={ + "position_range": (-0.125, 0.125), + "velocity_range": (0.0, 0.0), + }, + ) + + robot_joint_stiffness_and_damping = EventTerm( + func=mdp.randomize_actuator_gains, + min_step_count_between_reset=200, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "stiffness_distribution_params": (0.9, 1.1), + "damping_distribution_params": (0.75, 1.5), + "operation": "scale", + "distribution": "uniform", + }, + ) + + joint_friction = EventTerm( + func=mdp.randomize_joint_parameters, + min_step_count_between_reset=200, + mode="reset", + params={ + "asset_cfg": SceneEntityCfg("robot"), + "friction_distribution_params": (0.0, 0.1), + "operation": "add", + "distribution": "uniform", + }, + ) + + +@configclass +class RewardsCfg: + """Reward terms for the MDP.""" + + end_effector_keypoint_tracking = RewTerm( + func=mdp.keypoint_command_error, + weight=-1.5, + params={ + "asset_cfg": SceneEntityCfg("ee_frame"), + "command_name": "ee_pose", + "keypoint_scale": 0.45, + }, + ) + end_effector_keypoint_tracking_exp = RewTerm( + func=mdp.keypoint_command_error_exp, + weight=1.5, + params={ + "asset_cfg": SceneEntityCfg("ee_frame"), + "command_name": "ee_pose", + "kp_exp_coeffs": [(50, 0.0001), (300, 0.0001), (5000, 0.0001)], + "kp_use_sum_of_exps": False, + "keypoint_scale": 0.45, + }, + ) + + action_rate = RewTerm(func=mdp.action_rate_l2, weight=-0.005) + action = RewTerm(func=mdp.action_l2, weight=-0.005) + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + time_out = DoneTerm(func=mdp.time_out, time_out=True) + + +## +# Environment configuration +## + + +@configclass +class ReachEnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the end-effector pose tracking environment that has been deployed on a real robot.""" + + # Scene settings + scene: SceneCfg = SceneCfg(num_envs=4096, env_spacing=2.5) + # Basic settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + # MDP settings + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + events: EventCfg = EventCfg() + + def __post_init__(self): + """Post initialization.""" + # general settings + self.decimation = 2 + self.sim.render_interval = self.decimation + self.episode_length_s = 12.0 + self.viewer.eye = (3.5, 3.5, 3.5) + # simulation settings + self.sim.dt = 1.0 / 120.0 From d7613ce815069f3648c4e99528b660717dce0267 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Thu, 4 Sep 2025 17:25:34 -0700 Subject: [PATCH 21/47] Supports rl games wrapper with dictionary observation (#3340) # Description This PR opens the possibility to use dictionary observation for rl-games application. This benefits: 1. combination of high + low dim observations percolate into actor and critic in rl-games 2. avoid double computation if actor and critic share the same observation ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../reinforcement_learning/rl_games/play.py | 4 +- .../reinforcement_learning/rl_games/train.py | 4 +- source/isaaclab_rl/config/extension.toml | 2 +- source/isaaclab_rl/docs/CHANGELOG.rst | 9 + source/isaaclab_rl/isaaclab_rl/rl_games.py | 190 ++++++++++++------ 5 files changed, 142 insertions(+), 67 deletions(-) diff --git a/scripts/reinforcement_learning/rl_games/play.py b/scripts/reinforcement_learning/rl_games/play.py index dcaa02a48ee0..dd2185b82b07 100644 --- a/scripts/reinforcement_learning/rl_games/play.py +++ b/scripts/reinforcement_learning/rl_games/play.py @@ -134,6 +134,8 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen rl_device = agent_cfg["params"]["config"]["device"] clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) + obs_groups = agent_cfg["params"]["env"].get("obs_groups") + concate_obs_groups = agent_cfg["params"]["env"].get("concate_obs_groups", True) # create isaac environment env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None) @@ -155,7 +157,7 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen env = gym.wrappers.RecordVideo(env, **video_kwargs) # wrap around environment for rl-games - env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions) + env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) # register the environment to rl-games registry # note: in agents configuration: environment name must be "rlgpu" diff --git a/scripts/reinforcement_learning/rl_games/train.py b/scripts/reinforcement_learning/rl_games/train.py index c3dd2064f034..cc1e54b17563 100644 --- a/scripts/reinforcement_learning/rl_games/train.py +++ b/scripts/reinforcement_learning/rl_games/train.py @@ -148,6 +148,8 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen rl_device = agent_cfg["params"]["config"]["device"] clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) + obs_groups = agent_cfg["params"]["env"].get("obs_groups") + concate_obs_groups = agent_cfg["params"]["env"].get("concate_obs_groups", True) # set the IO descriptors output directory if requested if isinstance(env_cfg, ManagerBasedRLEnvCfg): @@ -178,7 +180,7 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen env = gym.wrappers.RecordVideo(env, **video_kwargs) # wrap around environment for rl-games - env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions) + env = RlGamesVecEnvWrapper(env, rl_device, clip_obs, clip_actions, obs_groups, concate_obs_groups) # register the environment to rl-games registry # note: in agents configuration: environment name must be "rlgpu" diff --git a/source/isaaclab_rl/config/extension.toml b/source/isaaclab_rl/config/extension.toml index e63c469d4f71..26a2675f9221 100644 --- a/source/isaaclab_rl/config/extension.toml +++ b/source/isaaclab_rl/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.2.4" +version = "0.3.0" # Description title = "Isaac Lab RL" diff --git a/source/isaaclab_rl/docs/CHANGELOG.rst b/source/isaaclab_rl/docs/CHANGELOG.rst index e39f2f20f5d0..d0252ca0dba9 100644 --- a/source/isaaclab_rl/docs/CHANGELOG.rst +++ b/source/isaaclab_rl/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.3.0 (2025-09-03) +~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Enhanced rl-games wrapper to allow dict observation. + + 0.2.4 (2025-08-07) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_rl/isaaclab_rl/rl_games.py b/source/isaaclab_rl/isaaclab_rl/rl_games.py index 3cc574fdb243..d24dc9d0d846 100644 --- a/source/isaaclab_rl/isaaclab_rl/rl_games.py +++ b/source/isaaclab_rl/isaaclab_rl/rl_games.py @@ -37,6 +37,7 @@ import gym.spaces # needed for rl-games incompatibility: https://github.com/Denys88/rl_games/issues/261 import gymnasium import torch +from collections.abc import Callable from rl_games.common import env_configurations from rl_games.common.vecenv import IVecEnv @@ -60,12 +61,14 @@ class RlGamesVecEnvWrapper(IVecEnv): observations. This dictionary contains "obs" and "states" which typically correspond to the actor and critic observations respectively. - To use asymmetric actor-critic, the environment observations from :class:`ManagerBasedRLEnv` or :class:`DirectRLEnv` - must have the key or group name "critic". The observation group is used to set the - :attr:`num_states` (int) and :attr:`state_space` (:obj:`gym.spaces.Box`). These are - used by the learning agent in RL-Games to allocate buffers in the trajectory memory. - Since this is optional for some environments, the wrapper checks if these attributes exist. - If they don't then the wrapper defaults to zero as number of privileged observations. + To use asymmetric actor-critic, map privileged observation groups under ``"states"`` (e.g. ``["critic"]``). + + The wrapper supports **either** concatenated tensors (default) **or** Dict inputs: + when wrapper is concate mode, rl-games sees {"obs": Tensor, (optional)"states": Tensor} + when wrapper is not concate mode, rl-games sees {"obs": dict[str, Tensor], (optional)"states": dict[str, Tensor]} + - Concatenated mode (``concate_obs_group=True``): ``observation_space``/``state_space`` are ``gym.spaces.Box``. + - Dict mode (``concate_obs_group=False``): ``observation_space``/``state_space`` are ``gym.spaces.Dict`` keyed by + the requested groups. When no ``"states"`` groups are provided, the states Dict is omitted at runtime. .. caution:: @@ -79,7 +82,15 @@ class RlGamesVecEnvWrapper(IVecEnv): https://github.com/NVIDIA-Omniverse/IsaacGymEnvs """ - def __init__(self, env: ManagerBasedRLEnv | DirectRLEnv, rl_device: str, clip_obs: float, clip_actions: float): + def __init__( + self, + env: ManagerBasedRLEnv | DirectRLEnv, + rl_device: str, + clip_obs: float, + clip_actions: float, + obs_groups: dict[str, list[str]] | None = None, + concate_obs_group: bool = True, + ): """Initializes the wrapper instance. Args: @@ -87,6 +98,9 @@ def __init__(self, env: ManagerBasedRLEnv | DirectRLEnv, rl_device: str, clip_ob rl_device: The device on which agent computations are performed. clip_obs: The clipping value for observations. clip_actions: The clipping value for actions. + obs_groups: The remapping from isaaclab observation to rl-games, default to None for backward compatible. + concate_obs_group: The boolean value indicates if input to rl-games network is dict or tensor. Default to + True for backward compatible. Raises: ValueError: The environment is not inherited from :class:`ManagerBasedRLEnv` or :class:`DirectRLEnv`. @@ -105,11 +119,36 @@ def __init__(self, env: ManagerBasedRLEnv | DirectRLEnv, rl_device: str, clip_ob self._clip_obs = clip_obs self._clip_actions = clip_actions self._sim_device = env.unwrapped.device - # information for privileged observations - if self.state_space is None: - self.rlg_num_states = 0 - else: + + # resolve the observation group + self._concate_obs_groups = concate_obs_group + self._obs_groups = obs_groups + if obs_groups is None: + self._obs_groups = {"obs": ["policy"], "states": []} + if not self.unwrapped.single_observation_space.get("policy"): + raise KeyError("Policy observation group is expected if no explicit groups is defined") + if self.unwrapped.single_observation_space.get("critic"): + self._obs_groups["states"] = ["critic"] + + if ( + self._concate_obs_groups + and isinstance(self.state_space, gym.spaces.Box) + and isinstance(self.observation_space, gym.spaces.Box) + ): self.rlg_num_states = self.state_space.shape[0] + elif ( + not self._concate_obs_groups + and isinstance(self.state_space, gym.spaces.Dict) + and isinstance(self.observation_space, gym.spaces.Dict) + ): + space = [space.shape[0] for space in self.state_space.values()] + self.rlg_num_states = sum(space) + else: + raise TypeError( + "only valid combination for state space is gym.space.Box when concate_obs_groups is True, " + " and gym.space.Dict when concate_obs_groups is False. You have concate_obs_groups: " + f" {self._concate_obs_groups}, and state_space: {self.state_space.__class__}" + ) def __str__(self): """Returns the wrapper name and the :attr:`env` representation string.""" @@ -135,19 +174,18 @@ def render_mode(self) -> str | None: return self.env.render_mode @property - def observation_space(self) -> gym.spaces.Box: - """Returns the :attr:`Env` :attr:`observation_space`.""" + def observation_space(self) -> gym.spaces.Box | gym.spaces.Dict: + """Returns the :attr:`Env` :attr:`observation_space` (``Box`` if concatenated, otherwise ``Dict``).""" # note: rl-games only wants single observation space - policy_obs_space = self.unwrapped.single_observation_space["policy"] - if not isinstance(policy_obs_space, gymnasium.spaces.Box): - raise NotImplementedError( - f"The RL-Games wrapper does not currently support observation space: '{type(policy_obs_space)}'." - f" If you need to support this, please modify the wrapper: {self.__class__.__name__}," - " and if you are nice, please send a merge-request." - ) - # note: maybe should check if we are a sub-set of the actual space. don't do it right now since - # in ManagerBasedRLEnv we are setting action space as (-inf, inf). - return gym.spaces.Box(-self._clip_obs, self._clip_obs, policy_obs_space.shape) + space = self.unwrapped.single_observation_space + clip = self._clip_obs + if not self._concate_obs_groups: + policy_space = {grp: gym.spaces.Box(-clip, clip, space.get(grp).shape) for grp in self._obs_groups["obs"]} + return gym.spaces.Dict(policy_space) + else: + shapes = [space.get(group).shape for group in self._obs_groups["obs"]] + cat_shape, self._obs_concat_fn = make_concat_plan(shapes) + return gym.spaces.Box(-clip, clip, cat_shape) @property def action_space(self) -> gym.Space: @@ -193,23 +231,18 @@ def device(self) -> str: return self.unwrapped.device @property - def state_space(self) -> gym.spaces.Box | None: - """Returns the :attr:`Env` :attr:`observation_space`.""" - # note: rl-games only wants single observation space - critic_obs_space = self.unwrapped.single_observation_space.get("critic") - # check if we even have a critic obs - if critic_obs_space is None: - return None - elif not isinstance(critic_obs_space, gymnasium.spaces.Box): - raise NotImplementedError( - f"The RL-Games wrapper does not currently support state space: '{type(critic_obs_space)}'." - f" If you need to support this, please modify the wrapper: {self.__class__.__name__}," - " and if you are nice, please send a merge-request." - ) - # return casted space in gym.spaces.Box (OpenAI Gym) - # note: maybe should check if we are a sub-set of the actual space. don't do it right now since - # in ManagerBasedRLEnv we are setting action space as (-inf, inf). - return gym.spaces.Box(-self._clip_obs, self._clip_obs, critic_obs_space.shape) + def state_space(self) -> gym.spaces.Box | gym.spaces.Dict | None: + """Returns the privileged observation space for the critic (``Box`` if concatenated, otherwise ``Dict``).""" + # # note: rl-games only wants single observation space + space = self.unwrapped.single_observation_space + clip = self._clip_obs + if not self._concate_obs_groups: + state_space = {grp: gym.spaces.Box(-clip, clip, space.get(grp).shape) for grp in self._obs_groups["states"]} + return gym.spaces.Dict(state_space) + else: + shapes = [space.get(group).shape for group in self._obs_groups["states"]] + cat_shape, self._states_concat_fn = make_concat_plan(shapes) + return gym.spaces.Box(-self._clip_obs, self._clip_obs, cat_shape) def get_number_of_agents(self) -> int: """Returns number of actors in the environment.""" @@ -270,7 +303,7 @@ def close(self): # noqa: D102 Helper functions """ - def _process_obs(self, obs_dict: VecEnvObs) -> torch.Tensor | dict[str, torch.Tensor]: + def _process_obs(self, obs_dict: VecEnvObs) -> dict[str, torch.Tensor] | dict[str, dict[str, torch.Tensor]]: """Processing of the observations and states from the environment. Note: @@ -280,32 +313,61 @@ def _process_obs(self, obs_dict: VecEnvObs) -> torch.Tensor | dict[str, torch.Te Args: obs_dict: The current observations from environment. - Returns: - If environment provides states, then a dictionary containing the observations and states is returned. - Otherwise just the observations tensor is returned. + Returns: + A dictionary for RL-Games with keys: + - ``"obs"``: either a concatenated tensor (``concate_obs_group=True``) or a Dict of group tensors. + - ``"states"`` (optional): same structure as above when state groups are configured; omitted otherwise. """ - # process policy obs - obs = obs_dict["policy"] # clip the observations - obs = torch.clamp(obs, -self._clip_obs, self._clip_obs) - # move the buffer to rl-device - obs = obs.to(device=self._rl_device).clone() - - # check if asymmetric actor-critic or not - if self.rlg_num_states > 0: - # acquire states from the environment if it exists - try: - states = obs_dict["critic"] - except AttributeError: - raise NotImplementedError("Environment does not define key 'critic' for privileged observations.") - # clip the states - states = torch.clamp(states, -self._clip_obs, self._clip_obs) - # move buffers to rl-device - states = states.to(self._rl_device).clone() - # convert to dictionary - return {"obs": obs, "states": states} + for key, obs in obs_dict.items(): + obs_dict[key] = torch.clamp(obs, -self._clip_obs, self._clip_obs) + + # process input obs dict + rl_games_obs = {"obs": {group: obs_dict[group] for group in self._obs_groups["obs"]}} + if len(self._obs_groups["states"]) > 0: + rl_games_obs["states"] = {group: obs_dict[group] for group in self._obs_groups["states"]} + + if self._concate_obs_groups: + rl_games_obs["obs"] = self._obs_concat_fn(list(rl_games_obs["obs"].values())) + if "states" in rl_games_obs: + rl_games_obs["states"] = self._states_concat_fn(list(rl_games_obs["states"].values())) + + return rl_games_obs + + +def make_concat_plan(shapes: list[tuple[int, ...]]) -> tuple[tuple[int, ...], Callable]: + """ + Given per-sample shapes (no batch dim), return: + - the concatenated per-sample shape + - a function that concatenates a list of batch tensors accordingly. + + Rules: + 0) Empty -> (0,), No-op + 1) All 1D -> concat features (dim=1). + 2) Same rank > 1: + 2a) If all s[:-1] equal -> concat along last dim (channels-last, dim=-1). + 2b) If all s[1:] equal -> concat along first dim (channels-first, dim=1). + """ + if len(shapes) == 0: + return (0,), lambda x: x + # case 1: all vectors + if all(len(s) == 1 for s in shapes): + return (sum(s[0] for s in shapes),), lambda x: torch.cat(x, dim=1) + # case 2: same rank > 1 + rank = len(shapes[0]) + if all(len(s) == rank for s in shapes) and rank > 1: + # 2a: concat along last axis (…C) + if all(s[:-1] == shapes[0][:-1] for s in shapes): + out_shape = shapes[0][:-1] + (sum(s[-1] for s in shapes),) + return out_shape, lambda x: torch.cat(x, dim=-1) + # 2b: concat along first axis (C…) + if all(s[1:] == shapes[0][1:] for s in shapes): + out_shape = (sum(s[0] for s in shapes),) + shapes[0][1:] + return out_shape, lambda x: torch.cat(x, dim=1) else: - return obs + raise ValueError(f"Could not find a valid concatenation plan for rank {[(len(s),) for s in shapes]}") + else: + raise ValueError("Could not find a valid concatenation plan, please make sure all value share the same size") """ From 6c06a58bb00679a060cf6d29a1ebab22cd1fd256 Mon Sep 17 00:00:00 2001 From: Clemens Schwarke <96480707+ClemensSchwarke@users.noreply.github.com> Date: Fri, 5 Sep 2025 02:27:21 +0200 Subject: [PATCH 22/47] Adds a configuration example for Student-Teacher Distillation (#3100) # Description This PR adds a configuration class to distill a walking policy for ANYmal D as an example. The training is run almost the same way as a normal PPO training. The only difference is that a policy checkpoint needs to be passed via the `--load_run` CLI argument, to serve as the teacher. Additionally, the `RslRlDistillationRunnerCfg` got moved to the correct file. ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- CONTRIBUTORS.md | 2 +- .../rl_existing_scripts.rst | 34 ++++++++++++++++++ .../isaaclab_rl/rsl_rl/distillation_cfg.py | 21 +++++++++++ .../isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py | 15 -------- .../velocity/config/anymal_d/__init__.py | 6 ++++ .../agents/rsl_rl_distillation_cfg.py | 35 +++++++++++++++++++ 6 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b652b4b54bb7..aaef502a2e82 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -21,6 +21,7 @@ Guidelines for modifications: * Antonio Serrano-Muñoz * Ben Johnston +* Clemens Schwarke * David Hoeller * Farbod Farshidian * Hunter Hansen @@ -54,7 +55,6 @@ Guidelines for modifications: * Calvin Yu * Cheng-Rong Lai * Chenyu Yang -* Clemens Schwarke * Connor Smith * CY (Chien-Ying) Chen * David Yang diff --git a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst index a3f3261c4fb3..c879e9977409 100644 --- a/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst +++ b/docs/source/overview/reinforcement-learning/rl_existing_scripts.rst @@ -87,6 +87,40 @@ RSL-RL :: run script for recording video of a trained agent (requires installing `ffmpeg`) isaaclab.bat -p scripts\reinforcement_learning\rsl_rl\play.py --task Isaac-Reach-Franka-v0 --headless --video --video_length 200 +- Training and distilling an agent with + `RSL-RL `__ on ``Isaac-Velocity-Flat-Anymal-D-v0``: + + .. tab-set:: + :sync-group: os + + .. tab-item:: :icon:`fa-brands fa-linux` Linux + :sync: linux + + .. code:: bash + + # install python module (for rsl-rl) + ./isaaclab.sh -i rsl_rl + # run script for rl training of the teacher agent + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --headless + # run script for distilling the teacher agent into a student agent + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --headless --agent rsl_rl_distillation_cfg_entry_point --load_run teacher_run_folder_name + # run script for playing the student with 64 environments + ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-D-v0 --num_envs 64 --agent rsl_rl_distillation_cfg_entry_point + + .. tab-item:: :icon:`fa-brands fa-windows` Windows + :sync: windows + + .. code:: batch + + :: install python module (for rsl-rl) + isaaclab.bat -i rsl_rl + :: run script for rl training of the teacher agent + isaaclab.bat -p scripts\reinforcement_learning\rsl_rl\train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --headless + :: run script for distilling the teacher agent into a student agent + isaaclab.bat -p scripts\reinforcement_learning\rsl_rl\train.py --task Isaac-Velocity-Flat-Anymal-D-v0 --headless --agent rsl_rl_distillation_cfg_entry_point --load_run teacher_run_folder_name + :: run script for playing the student with 64 environments + isaaclab.bat -p scripts\reinforcement_learning\rsl_rl\play.py --task Isaac-Velocity-Flat-Anymal-D-v0 --num_envs 64 --agent rsl_rl_distillation_cfg_entry_point + SKRL ---- diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py index d4153d5cf2b0..7cdcbfe0c5e6 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/distillation_cfg.py @@ -10,6 +10,8 @@ from isaaclab.utils import configclass +from .rl_cfg import RslRlBaseRunnerCfg + ######################### # Policy configurations # ######################### @@ -93,3 +95,22 @@ class RslRlDistillationAlgorithmCfg: loss_type: Literal["mse", "huber"] = "mse" """The loss type to use for the student policy.""" + + +######################### +# Runner configurations # +######################### + + +@configclass +class RslRlDistillationRunnerCfg(RslRlBaseRunnerCfg): + """Configuration of the runner for distillation algorithms.""" + + class_name: str = "DistillationRunner" + """The runner class name. Default is DistillationRunner.""" + + policy: RslRlDistillationStudentTeacherCfg = MISSING + """The policy configuration.""" + + algorithm: RslRlDistillationAlgorithmCfg = MISSING + """The algorithm configuration.""" diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py index 90ef6c026652..5b03a7c639b4 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/rl_cfg.py @@ -10,7 +10,6 @@ from isaaclab.utils import configclass -from .distillation_cfg import RslRlDistillationAlgorithmCfg, RslRlDistillationStudentTeacherCfg from .rnd_cfg import RslRlRndCfg from .symmetry_cfg import RslRlSymmetryCfg @@ -237,17 +236,3 @@ class RslRlOnPolicyRunnerCfg(RslRlBaseRunnerCfg): algorithm: RslRlPpoAlgorithmCfg = MISSING """The algorithm configuration.""" - - -@configclass -class RslRlDistillationRunnerCfg(RslRlBaseRunnerCfg): - """Configuration of the runner for distillation algorithms.""" - - class_name: str = "DistillationRunner" - """The runner class name. Default is DistillationRunner.""" - - policy: RslRlDistillationStudentTeacherCfg = MISSING - """The policy configuration.""" - - algorithm: RslRlDistillationAlgorithmCfg = MISSING - """The algorithm configuration.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/__init__.py index 5a93627006d7..05fa5ca36f30 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/__init__.py @@ -18,6 +18,9 @@ kwargs={ "env_cfg_entry_point": f"{__name__}.flat_env_cfg:AnymalDFlatEnvCfg", "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:AnymalDFlatPPORunnerCfg", + "rsl_rl_distillation_cfg_entry_point": ( + f"{agents.__name__}.rsl_rl_distillation_cfg:AnymalDFlatDistillationRunnerCfg" + ), "rsl_rl_with_symmetry_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:AnymalDFlatPPORunnerWithSymmetryCfg", "skrl_cfg_entry_point": f"{agents.__name__}:skrl_flat_ppo_cfg.yaml", }, @@ -30,6 +33,9 @@ kwargs={ "env_cfg_entry_point": f"{__name__}.flat_env_cfg:AnymalDFlatEnvCfg_PLAY", "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:AnymalDFlatPPORunnerCfg", + "rsl_rl_distillation_cfg_entry_point": ( + f"{agents.__name__}.rsl_rl_distillation_cfg:AnymalDFlatDistillationRunnerCfg" + ), "rsl_rl_with_symmetry_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:AnymalDFlatPPORunnerWithSymmetryCfg", "skrl_cfg_entry_point": f"{agents.__name__}:skrl_flat_ppo_cfg.yaml", }, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py new file mode 100644 index 000000000000..fd68b9a8959e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/agents/rsl_rl_distillation_cfg.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.utils import configclass + +from isaaclab_rl.rsl_rl import ( + RslRlDistillationAlgorithmCfg, + RslRlDistillationRunnerCfg, + RslRlDistillationStudentTeacherCfg, +) + + +@configclass +class AnymalDFlatDistillationRunnerCfg(RslRlDistillationRunnerCfg): + num_steps_per_env = 120 + max_iterations = 300 + save_interval = 50 + experiment_name = "anymal_d_flat" + obs_groups = {"policy": ["policy"], "teacher": ["policy"]} + policy = RslRlDistillationStudentTeacherCfg( + init_noise_std=0.1, + noise_std_type="scalar", + student_obs_normalization=False, + teacher_obs_normalization=False, + student_hidden_dims=[128, 128, 128], + teacher_hidden_dims=[128, 128, 128], + activation="elu", + ) + algorithm = RslRlDistillationAlgorithmCfg( + num_learning_epochs=2, + learning_rate=1.0e-3, + gradient_length=15, + ) From 7af1d72af242628695129daed137166007f3901d Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Thu, 4 Sep 2025 18:12:11 -0700 Subject: [PATCH 23/47] Fixes invalid callbacks for debug vis when simulation is restarted (#3338) # Description In some teleoperation scripts, we restart the simulation to ensure determinism across captured trajectories. However, stopping the simulation invalidates callbacks used for updating debug visualization, which do not get re-initialized after simulation is restarted. Adds a fix for re-initializing the callbacks when we re-initialize the simulation for sensors and deformable objects. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../isaaclab/assets/deformable_object/deformable_object.py | 5 +++++ source/isaaclab/isaaclab/sensors/sensor_base.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py b/source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py index 50ba1dcfd00e..05211af0d2a0 100644 --- a/source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py +++ b/source/isaaclab/isaaclab/assets/deformable_object/deformable_object.py @@ -358,6 +358,11 @@ def _initialize_impl(self): # update the deformable body data self.update(0.0) + # Initialize debug visualization handle + if self._debug_vis_handle is None: + # set initial state of debug visualization + self.set_debug_vis(self.cfg.debug_vis) + def _create_buffers(self): """Create buffers for storing data.""" # constants diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index eb97072c167a..aab993cc526b 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -216,6 +216,11 @@ def _initialize_impl(self): # Timestamp from last update self._timestamp_last_update = torch.zeros_like(self._timestamp) + # Initialize debug visualization handle + if self._debug_vis_handle is None: + # set initial state of debug visualization + self.set_debug_vis(self.cfg.debug_vis) + @abstractmethod def _update_buffers_impl(self, env_ids: Sequence[int]): """Fills the sensor data for provided environment ids. From 73aa877dc7ad751b7ac32f32bb4e7cc435e13a35 Mon Sep 17 00:00:00 2001 From: Giulio Romualdi Date: Fri, 5 Sep 2025 11:38:50 +0200 Subject: [PATCH 24/47] Change GLIBC version requirement to 2.35 for pip (#3360) Updated GLIBC version requirement for pip installation. # Description Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes the documentation by taking the info from https://docs.isaacsim.omniverse.nvidia.com/5.0.0/installation/install_python.html ## Type of change - A small fix in the documentation ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there Signed-off-by: Giulio Romualdi --- docs/source/setup/installation/pip_installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/setup/installation/pip_installation.rst b/docs/source/setup/installation/pip_installation.rst index 11fedf6afcd0..48952959e59a 100644 --- a/docs/source/setup/installation/pip_installation.rst +++ b/docs/source/setup/installation/pip_installation.rst @@ -15,7 +15,7 @@ If you encounter any issues, please report them to the .. attention:: - Installing Isaac Sim with pip requires GLIBC 2.34+ version compatibility. + Installing Isaac Sim with pip requires GLIBC 2.35+ version compatibility. To check the GLIBC version on your system, use command ``ldd --version``. This may pose compatibility issues with some Linux distributions. For instance, Ubuntu 20.04 LTS has GLIBC 2.31 From 3422782833ee14642ac8b331de2143dda94001d1 Mon Sep 17 00:00:00 2001 From: rebeccazhang0707 <168459200+rebeccazhang0707@users.noreply.github.com> Date: Sat, 6 Sep 2025 02:05:06 +0800 Subject: [PATCH 25/47] Adds surface gripper support in manager-based workflow (#3174) # Description We only have a surface gripper sample with direct workflow, whereas in manager-based workflow it is not supported yet. - Add surface gripper as an asset instance to the scene (CPU only) - Add SurfaceGripperAction and SurfaceGripperActionCfg - Add two TaskEnvs for testing: 1. Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 2. Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 You can test recording demos by: `./isaaclab.sh -p scripts/tools/record_demos.py --task Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 --teleop_device keyboard --device cpu` ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshot environments_surface_gripper ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: rebeccazhang0707 <168459200+rebeccazhang0707@users.noreply.github.com> Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- .../ur10_stack_surface_gripper.jpg | Bin 0 -> 76607 bytes docs/source/overview/environments.rst | 17 +- scripts/demos/pick_and_place.py | 2 +- .../01_assets/run_surface_gripper.py | 2 +- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 14 ++ .../assets/surface_gripper/surface_gripper.py | 38 +++- .../surface_gripper/surface_gripper_cfg.py | 9 +- .../isaaclab/envs/mdp/actions/actions_cfg.py | 31 ++- .../mdp/actions/surface_gripper_actions.py | 106 +++++++++ .../isaaclab/scene/interactive_scene.py | 11 +- .../robots/universal_robots.py | 22 +- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 8 + .../stack/config/ur10_gripper/__init__.py | 35 +++ .../config/ur10_gripper/agents/__init__.py | 4 + .../ur10_gripper/stack_ik_rel_env_cfg.py | 80 +++++++ .../ur10_gripper/stack_joint_pos_env_cfg.py | 206 ++++++++++++++++++ 18 files changed, 574 insertions(+), 15 deletions(-) create mode 100644 docs/source/_static/tasks/manipulation/ur10_stack_surface_gripper.jpg create mode 100644 source/isaaclab/isaaclab/envs/mdp/actions/surface_gripper_actions.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py diff --git a/docs/source/_static/tasks/manipulation/ur10_stack_surface_gripper.jpg b/docs/source/_static/tasks/manipulation/ur10_stack_surface_gripper.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c92b4a32f8c9723d85a89954f1bcfdb4cc39f704 GIT binary patch literal 76607 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
lp_kN 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≫jsvVDy-(MFn@wxj(z3854 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%0D54-1Lbvh531{`|+zZAR zjg*qTsMVvu01CtxGfydR3Y&1)?^(GB4(yjcg{Uwms-iqw#Z%+F*T6%*RV{2Nya7aT zZjf;$9vcY?bF*Wj)}gFA%aE;;l4 zRi}z7F1eUt&)TcIpYAaL*m|x#4`>8wmfoL|S$He3VeZ_dh;AXmmE)iB2hgBq#0L@< zv~>S+7c#k^ybjqM#{KB{J{`y^X!9MOf=WpIqwz+U(vpfeyZ>M}u?#s+nHC5v0jb$T zxA<{z@-n3-iJ77`;eA`qUX^5Mtg6eqED0<$PrT>fT&mw+t`6gsF$DWbM6Wl9t<|xi zxj0gU1@(9O_*;vU{<;PjU(n7$wl>}Kr%-tem~sDL^e0PY#*AT4{S0NH^L@Y!N zFA>uXkIG=8GVA;MEALDP@NK%s&6`4|b+DD>GP=@*_(_Q=-!aBDU^h`_U7Tl+iIL98 zet1MrjWZTSD-uQce!2x3SVMF7xaZqZA-Pb)v^YJ6$S9wAEZwh!US znz)Pb%@gi8j(C8w4>u=!3S!FBN_@u0xJjqrp^k~_R(0TX-`mG`DoUAHHDi#YB_jkug)V%xrR4xc1g8p)8>r9^=Ro;!eldl)m#IB`82L zew{pV9dZVq-&bnpL$;t}m3hH09Tr?C7?%6Xt7QR!Lzv8+TzeZUX%_kVnsR>f{PS5@ z$O~@vGKD_R#2Dvmh;R4HBJr)=`IKyDieoo1iH-Osy~3sJXlj9qz-nUQ(^P)4(=6?x z@n`hkfyvq$f)t&n^0EW_N!4*oq8yR4YWE1fgLl zDz?&FE4mJU)Rrh=fsb-Sxxs4O|lWr`H>%#qLi1SVXn%2gUc zG7PXwRI9ADCD1W)41yOGlr^H6nN;AgA-s^ge zLaT0+Z2P`EAJgZUC2;rrEZGrI`t9)bOOtmvjetKvir2+TbP_mV8Q1V79kTqEa5JD_ z=2EAYEk~(Quimw*G`uon64pPkSpYlu(=pOdp(ntH6M54B|}2ySo`0x>?9(dLZs?VWsomL zqkwn)sG0CT{z2XO)s}}3NLOGVf$jBCK>O@W$J)2BwfSGg6Rij>9;x1ixHAT}EX|&; z82ERR8VtnRf}TTPgJ|2FppIpUHw*6!Sc&Zu+krB~Yb3j9(e8deD@?K>sz0f}!auF* zw0yCKEeD`+(N&-v`X5V%dvZyQJC&Z{+8Haom3(qBmWDK`YyVCshTAsPPgbuIY08l3 zBD*$vxx7<$Sk}qAIdS4XF8L2v^whi-tJp*+JKn6auVXo=z*AjsRdT=B+P=QNE+S0y zhL7@f*|Ci!EIhFNE3a^GQG-8~)igf-Bkr6DqLAlhmmku^yhV<+-d3GNqUC+@>u%+r zqq(#3U{zWGFr){?gl-jmYQ!dzikaEAoZ9r4J_jIGx-!jM;=d$x-#hj8Es2Cz=Co=O+@P(m-`jnOnf-n550ZX~K_?th_DB98~eEd<_ zi~H_vPZ}ICA_}DeY)+!OWmxD#87n@4eHSul3OPBM@BOn-Z|lLz|m@%bTZJu59pWalY{$!oj@~de=jMFUzI8NYxbaMzd^H*sSFT zHkAA~)}YxTcXHUbrF`-L7H)CJYDKDcCgRBj0#%qZD1$9XzCSsowN348FokhtoF2&( z{bhZby_r9#{q5YWnrn=CGs$z3yd=o8-29=Skn*c%-=JL>A+usA*k7<$C2D!qBSr!e ziDMio!?ot|?`dThd5F^}FOnuk1iJ&MG6fYaYZ5J{fo0ycOaEW@@-DAhZ3PsGgvICF zW1dH=6mjHhv9{^>dFjziR)?IT0St>!m=q<+rCPG-1gf0{974q{mbD-~OlbH?uG`DK zeZCK*dT(&mADFki!1>|cDSXg=#k4ZQJp#Szxn@wzGX{O~C*XF;TsDmp@UL?hEVSz; zvJXV^|NE!{i#0IEzmctQy4r)!-7GOP=ewR_FCs6oC&doM9Fil&r@C9~ zn!NH5bEOm{m_c0aVtVFSEi|4ySt~O-)0H8aSt$JiXuT;WY)QHFR0Mkba6Aj?VKLpz zMPLYvu|u#tZO^@b!MP*MkFKIYy=O{`Z(T5jVXQ;rHoe@~Okx|M(OksPhoSIra5aa% zR2XLRV^9)b|KAG`epYpgitx263cCA+m7ubiOEVqjSZ93Mri~|QSH4IxvOLKS<>N65 z=EG8W)UBf*c=3UZEtvEiKxs@)B8!NKy7U@U{5F6)P1u^1DEZSMg-6Z0cxhXuy4WIM zZCHT1*8Vv%kZ+DG8?UxC>eRL$Ia6xTvK($m00RcXMlkQ3KpccYtJpMNCwo|PVee+b zU_n>kJd@rpcb0-UFV|}OX)KXb$$VxLy0I{_R!CIX?DZ<>6!7LVR!!9CfaJ(N_tX%lx2LD|SD<`4m0nrxpmf|QJn!^tS2GW2vz`0lr!u25 z&K&g{lfA2LHPHcDSL6t5>M@&H;$Ag?5u5U(-gRUTqwnwGm%kO7j%2z9P$p4)R~6P^ z4>#SLHNV6u<2nZhd{wBnGJzA>vp5)tA({&eIdWAtznvNYqnw7xj;vM+PeU=#s-x zlI)9B6O)(s~iKLriVgi+{#f6A+eK0SaR zc<=NSaq$%Azeo+R7T9%>1!C)kwlFP77;PSEzoiLvo#y7y0xLsl1p6Qfl>R;|M>M8h zo9GoJy5@eQvzI>A2@*QC!e3L{1yl8v*`9kz1&53e8X-o6LIKif9kSAfUJ^UQ#qTwa z*PwmwL@N;YnoP8FyelxD44vEmM@MaaGGuDdb^afGA^$&;G#_#FsV}BbLi5m?+$eVN zAi0N_3_jRSN34Df3o-bIOsapA$pmnam71NZ%c_Q}9=0RqJlsrtUTCNEdL1Ixv<6}Q72Z`@sK*<0agTXVW$h4x+nU1vmX}P8#&0vqhm-cH2h-N+D-aE1 zq=n-6G2|a@EI`XKd+V7@K^y)X5%+iyGTQP;Ph)s|DAI_deL`}Q6spzHWyJ$XV68f| z0fFqFZXJ>cr{9NLkTvzXMNzP2t-^Koe<=~$_a@ngn~*h3)qWWXHMwmY4(KraW_%<` z{b~-G%j&y^#&mbw_sJ!(kltjjNuczCmUN-WM!;Ao^uWLivBn=1JCPUM^qu?S^KXvT zbz#JbK{gY>`N&gn`rD14%atRqtXb#C{r5}nT%V1Qc#l%`f?@Q-;rMX?O(i6D zXNZKA)AYP{8nEG!sVD$8%y8riPH6ez99@b}!reBFZ*~-j9TI~sc97ghWn#^q*Vk+` zOh7_F*hEUF9nD7VxzXZ=fETQAv;ATo0K$eKH^M5Dr5%;<7z)T}i~ZG_l1h>uPJX)# ze}SQ}R#z~FhIG&uhDQ}R@K&45=ulTQaPKp`+b{~~7+{`ZH+JAGly%Gf52qdPzLhj> zgO!zj%!b4MX>Re%QnSotV! zgmn=Y?A+X2)s16GQ{C(Nsvz~W2P}mT1Kp%Q>!T&J+=|_Oqd*-@L@{pWJM977M$cx! zqmj&7=iGQPEyK$Jfn%d}=(a=FBK2jOgmlwfGCC1e-mP}fKQuVqz9hC{GQ%2Bv~xsJ z>>z2@BW)4PGTDf)7u}s$O-kv62&TT9|9&yWlh@5-; zy(bJ0gs$J$(m|o!`(UL4I_BS?%vSC~K6vtlE^{#QtE0Zkvh0PdYMZPdo;3qA{lHcv zM^9t^J7=#wxN?31=5XO42CbDxuWXTB%zJlGq5YGhPg#dZCOm@pqENm5D7ratLGu z%Kk_QEx_zNMylxBcurdtg~$1J$tU1W*d_0r{sp zP{`!5V-UdadHfm&of06ZK+TUYwtN@S)v0yj zD8_q=5eb>0pf~6)kmo9Js0Q^zDSwZSUBs?jVSStf3FrharsqP{OY>l$OhAZ*2fZ^5 z(5@O-eBr~*ML%(pmG>ADuL`-K3$7Gf`SBI~AT=u+@Z%^i;uK;mJSeP8!XDpBl|zKj z0p}ZC-f{|2hYqwkSa|>zYI9Uyn(NQ|SQeC7$FL0JVyMFc%@HeoET0LyjfuRyThohy z?hOiT;9mq;2t4@9t@-SQF02-Q?EZU=K@4*Nq4N2u_9<(7@*w|(`K7vHV&F>wEMr~x z$_y=EAOXPZGFR(j6daX1%5NKXFhnOV>$G4$ZaA!*n8 zxA~;qLOUwkK)I3fm+Tw3mtss)lFlErmCYY`h|*F>hb#wEOy!N6i`7}=VG)!VD*O4%%6>WM-XrZ&w^JbZ!UHx6JUVkKp`SI9>! zqT2Lpct#?}I>bR@=yUjfaLU#pWm!JHh=oN z{rJsaFOvl1@sp_@WNM!Nb>?#8Vf{Cc;~+@)Dtjm&WKwt#b|H{nP+Bw-DKO>8+B>~Q zyj>#(f@#1leC1q;*UIEQHXA01V;JAUkQkxe`U)L{j*tApajl(QC9HdGx#w51jC*?T zb`S<_RJskDxT1#LIr=dm0M#N=l!|=4z5e-JDf(>SKFNtKJ?Pm8@*`^gT!eZAcG76Z zwtAKDGtGaV*?8dR@FwEiBX06A_LhIzvtv`TZVbLu&01+)4V(-S)6p9`6ggM+^qad# z42G3Y?og+RTU#Oz;#4>C58(vG8Nn`0zjP%MF6tEcYxcl@f-w82UB^$01zYK7U@`30 zL1$wz(qIt>4>y);1{*{oH<^r8L}^AI?%{(Igh8}u>SH`X0>{rr-`_fiV)gX=Yx$no zkn@BS-NQ;iiej7{E!9;PwQbqvSb-P=i@8`+y$*(s)1m#5gmNcOUs4k1z3}4K_fX)L zKD7r#n}K@-#^OVr^CcOO0|30CiAY4>hpnaEm!#qpBbc+t@8F@WQU0WCK_xxTe0gq2 zN_0v@2qQs*!|~Cyl5g*pRWHx2KZ}Cdl!a$W)9?8zGt^BmyHN2Z4CHtv6dONtXPh_# z@^nc`REGd}jyVVna(JCtHD|G?c5XU#l0E#JUCUq=tUK3XnYe9}GtopaF z^txsW`36TOwzuf1%3W(d0%LcSnB-)J2MVh3=aL6@TeRZyf=9?Hc@4w|V41AR> z-F{2K9qP1jJXwNk-)*WJs)a8MvOS~O(Za~SKPazTjN=0NRi}wL2v7%ts#u#1B!-1T z!aIld&(rR3yeZ(Qa8^FM&y3wMZL|*|u;_Rq3FNV^I<^6uQ4j5G!t_bl-o8gZQe+XV zY%rCH8HcJWLU!3|)}Q&cB`G=@s+#?=;`j<570)=P*JT1{siU$kDe2Il2ZrhjcEb$2 zV{%O`P#vvFv)>=33rOn%X`WgWkriDQq}-qWSH5y9FS5L`+Y^};vT_A(sfV#em#*W4 zZP}~S$G6!qsi)$Au^#Yq_T!Z=5+2`nQ2l8m`s<%SD%=@-jOa@|kyrjlqcq^#qqtQh z+Ty>D%YW7612_r3?aWW9UoEU;_YR(Pw>7M08*gx+lA(ryRmr0>O-k5X1-2Pl=ClWi>wL!gBZdC(e74{Rh;w=zmoG8IbqC+Tc13B#J@> zps3d5?Ni9oYb*cVa^L~1sbl=+K#~ejk6c#wUwspRoDFfjOs-1#`lJrYWCgpaCM7wQ z;8&tuBuMpsjaL3!_1+e4X*}DQM)DZMY9q<)mNvtQR31JNDLrOYs5x6m3FCnXxWb)C zvl|dv`r(m7+sCqp<;I?39@Xc6>ee!|_*v)@BMw?<{c?u?oftjadp`f2RC_Lbd++3l{TQ7SSU*Q~VoW%mW!|-6*5>QIaeK&gdl;#{{$DYHa1f*o$`U?M`IH+}~Yc?;@vFMFq-_n@4 zA!Si#cnAv^Y2ViH9`1P5mUJ=y?qR}jhW^g4?dNq2+^+XMwZq2@^&A(i)Q^HUJsj44 z0+n4aa*Q>#^YEQxhVQvG1%CQWjYiIARz#NB7j7L)Ipu#I`SL0UTjC#1^s1boEY`Tk zuyl4az+tOem1$Wa`c)<0E07c4n>Lp3BfHzxwz>Nln;}NdV<%2-*bXqanh%)me`^v| zyGBwN;2DZ3wKeB9s(U0f%v6-f^ukh_8!SC0EX(T>Ek$@N;R<|Wd;p9m4Mw$%n@%yx zXwXiNf)A0+Gp6V_t_M@G&bxCm(K-CqijS{7%qX8nt$Vme2>xF1KoL}= z=F>wapY1Rb5o%7D4Gz0iLi`En1Lhl(zJDXTs>(1T`SE}&zJ1#nu7$$Ar0D^L6G7Fq zTn2f~uh<;rM;P6M`dTxvpKZ%V8Te#$vg6Y&^qZEOX4RCe$KA{!s+3fdbW%t~VaRN) zby9XarX+n+Ng$16wvyO#xPOd((%QAcPtDI=u`!nF|u2W%FufhXA3=H=(_zw5F} z6cu!uMD5ACSa<9F-RMuBrp* z=Xi9H5LoIhYBj(U8qVMiUF2ox5(vu%ddDg4(NCz#%$uyrK0J(z{#MO|%J-|j5QkT7 zkLZ4W0ZYU7ZEETRAK_wIIU>|Mg%40t)-^2?&T>9qo6k`aZN=w*!kC;w`4U_@QYAfG z_EUaS9Q{+@blh)qkz@=GaQUr_U8ca`^wdw?*S@9d)l{~sDC1*thve)nyLvX0IemN* zNxE^1w^Gfd#pr8RaA=S|SXB2&=Y~nBF+f~5^VoR%GAr0M71-FT zCw{&icP*FC+x6qv0X*^K+=Wc&oyH?rrLJi7Jy*St`HpOiY;mk~Jv8_;06`btTj8fr zqCXWVQuM+9VcJ!kBc zBfQXM$!On_$8r(Lb6K<~JWE_s0d03@c(}!3K|c8ZVv*HHB%Ak7LvN;8^_F+4A~!bF zZZOluvERsBuc)GiZQY>CAbG32;?Xb9@`#TzCVW**w+Eq1rmVFj92?5P_^d5xL@{8> z+At7pre?7NHu^4~zyGE6YoY$72KB~2$lcWCO%+o|V;A|Qc=)Qtn?-6&?mfX?D#F7r zIpnLE9zgK_9_){V(S-bLsl3UyWT?^k?XTYi!a zJNT>Fcm&kl7+QEbDi)LKAaryrUH71ID(7Vd`cWT`m@aw#FFlH1@SB|0USA)HL`1e+ zg-8|V#5_A~K%-C=1{f^eW4f?m4i27fd&lr@Lk{Rms{UHuI&NmO$Vb&<9Lv<5lq=*L zKfVyT`-Nv*B#ze~7!V@_Yo8WnYj$N^qJZ<>B6;2o>QAadMh2AI(!F469v_XcArQ$e z?|!^-<`A?nl@}0>`PaXlzMgqiqu_Fddl#sqw^A1s1nbI|mdHX?u0TBu2HZ_TxR!Tc zHC^}r=%yn1?D2wgQE{|&@kYo1B~2iNG#eoXD)DF~sH5sX#ZtPS30=_f8u4_jOU32a zrjeH!XG7up)92c^>C&}*eS56ZEhGvZ^dO$Ee0arxmg&&8{?|uPS$K*b5^Plt{sgWX zTo^)0;(C=+Mv{NSy=};IMzPPlu~>jji*YDbASfj}pt|~`Ki6?lW@y-X(L$xetv0=O zD(EZ)h`Ss4(An*FSK}vJ6#L^D*xh( zpN|l5PDkIdeJQ(%x^fO1IY`-Ft((AyNEDA|IzpoC5p?%F^16__dFRtX2UTWGM_dWH-^7xc>DDz zwKj3DrWwkzh^XS0RF@|4fn>c}agV+a9F?W4WE zP6vGj>igmq8Zv(fh1+usZWiMA@fr(-etw987cQ3Je?84YX)!1V-C-gPq2xaFknmZ~ zqJ5gHwRW1qcjQcAo=-T_d_4VRGoWE)9<=8dE`+f@IL@^}@QTF(AEYdynT>sx%~&az zBBBqESf%2zC*~<)UpNxGhmh%aX*iYI%nE;0uqtD-(eL)78s;A(@WSE!w{dK`Kkzd` zJL=x5Yu}vgfEI~3JUf=}nq1G}V%Q#~tXv!=`er@W0geh=r57ou%}aY-=w0T5|63>_ z-$@a4X_I)x>dFRv-!LnVKA&g#Dqlw74U^Qf+BAf+VxC8_foT7ec?W0th~lMqeJl6? zBe>$k^4Xs*GUiL3|JM$nV%4l`?X2}2z+|ij2JZVzS~mNx;4J=wENtdpWld7fto#}5 zvS4}`M|6GXIokKWNUan4F1Vp9j8*uj$k;q{5Dj`bN1eRQE^Kt24{ghf`nIR|^~m>R zRu$~CGlLw-9FlONAZ{d3=X~-i=+xF^(*7sE1K6kz0IJ#Yxzg|??e9A6-><%oF{TvC zR$DTD&U3&l-MeLCqzX>0mtaaPb&>P zAVd4|6HvOZUoVuL+ErTEbF9chUqCT1Pknfcv7ox;DUiLpnt;Uo@e#{-|Oj@on{ z7IxYayH~4S-L%7o4&-c}US57a`-unk@iRyJF>jBHO zx0zwxoVMqCP{Tn+=U7$Tic5$5@GTGpJ98ZmB1+iy#jm03z}Rh;Zv*L2J~HC{$e*2i z6%uWiKHS?ea%|Q^IVk`QYp`@NmV69TL_Tz%vw!fCI%@FF zn*4*+mp=?PS4W4%UYBHIV68IlmEPIDTM=8yHL_ezIjTEJSCzVHu(D8HpWX!d%fz3xgNC zkR5{#{TN0n8I1v=l-eqi!FO^W5bOqm(BGCxi-==y^1@O9l;I?9y5{|smv4Wkg!#iW z1Xz=EWBH<1BTf=iS?M7=rtr6a^~4z)8w1xEz~>0$9f|q^`+&$X_NYLgjwhp7Q=aKk zgJb{|;eLHfq%?X+UB%QlRwEfmwKwmD+KG6P1RvAb?^O~VPGK4W2ezM)NTZy81{}J= z?zpK8S@C=Wg}$M_UM~tBLN0lheC_Z`gv@41a?e}VMh}(y^_L29^&%e#MFEmm)KC(dr*Xx((?3I zxf)S)z1bwa^=73aBuXtN1Y2~*vehMJSmb;J^8;@g;m>7{)!QJ&5|`ruu7l}urt%ua za)VBcEQfU$g*8=^pbW2-wFnIL^0*fopnU)^kpRjVVEWw|O&|mQvZZ#>FD&Q2ZGanm zD|ehxp2~%G4&LpYQMp>&S-)t^PYG_8P$r+aV<;#jOk^eLOi2=IsU2r4GgUeFGcROr zr5`Gz;zIa)yBe_)X`{=+^F=^@oQ{@iV=SIZLA+d1g3FJlRb>%Z_fb~tou|M5dk$6^ ziA$HEOW(FjM{c753sAlJA1q^kl+m+`8K}PN?dv@gzZQEpNJ>zG)eht*FfJ?8so5F$ zcAe&@qT(6LD$NNHf^}1GA%Ko?t;td+PVXM0$)t(M5{B<6QlnE-+C)&6sAhyN!oGhh zVnv*D(b#Z_kl4p_TYl*m2RV7*f<8l^z}-*CXdHc?sOayScPi5!P_YH~@cOJ0muTiJ zDKYR9C*CQC{=>x3R$hW-(*S@;LKmJ-&*%90o`Ov2F$)TB&)>G4fsaZL5Dcg(Kag`) zxSWg(wi^9W6_XX>`<=4OnsRo+Pt@gHL)<|r<|5rfT&xUAUUR0fc^hnH#ohfK9@GhOhtDe=3&j|L*k$LIXu_ zG(viWN|f7R{Yo!t8Iz!n9j4HG*wrIHmJ1cmi)Rh(w1xi`20<4)lqcx?bzpkJfa!|4 zrzvCHa946!b+CtZftcvPY=uY(rQOhzZ-7nb7X>OY$lLx>u0mAn+mt)VFgC@Q)Scdb z%sE|S=Y`DTT@W_m7F|MAbskw@_`D~s4bqY2`Y&&&t>_6Q-jJRqPC!Uzq>8nSCba;S zEcJHLlT~4Mu-eq?-1%1%=ScG{?uLW@ zoo)aOSA@BBJ>kV7yO7wgsJ!qY&O4ZJGu zAkIfw>RC%Kc8Y+w}ckmZQSkcbkV!Ex;G)E9jb9v$08_Mq|uh9lX$ zlIiDvC7X?QCu}9L&?^) z1vETI51iD}#(W=kROSi~2T{ShRC7vj_-4tBplN)SzXw-Jh~5d1R4b4i-Gfqq#)|NN zFZF?x$FTtF|G$tvKsy^;S;=rRrh$aH66DXiPGZVc)IepDO84KEP+qVQX=sd6%eV}h zF8@w(W~$mZ3!xluuwsJWUwWnJ1Ky+WX|kk+5S_)YDX@y--#}AB8B==nosza~Yo z9_#c&+jmSs7=_4h_H1X1bvA<&AI@I?y-P2h*@X9;JAz^#%x~T&x&s#KAPGrL7k?trPx#ts*OXYv3`FGyiti#& z%OTFJeEbSuL(|^m*=#7?8|!m)=H7a>pkwXE>Rt{1N%+vm{8dLwy%mD=^dbW`ySubt zArd}-pZITjB&2x5UA$G0CpRa*57A#~EOwhV=UeQz9DNk?eIzan#B@yqm831 z^WBMj$+O4wWqXkq442W)N3S#6<=xZwZZ!w=)$dC=!5IapU4YoAbuI z)~rWmXHsdc2Nk}mu-&lc!wNlh^W&#TMS1h}2^qmDC!Fe(V3|Yhbb25GZjLT#GC8pL zXA(M-)15LZZy!5&G*HcQRFSu2kZ@Cio9KCb^deTF z&7H*uiY8g#&W!_q-5V;Di!NYp$U}jX*lie{{3EP*CEwGmZ8_8EE^qQlKzhnHlO^%o zTu&nMO^h?qe%h7Me-HmGhY~2^q#TqwuuE+@57WdI1+fs5!U?!nx6+o*pb1+FtG-U0 zFrYeMXRA`h?DJa#!?+*tBENi$0A+}i=ZyIU1=j#&-;@BnpZEUgbrBYB%CJ07MF}OD z6ikKATAYjj}W5-S8oQqt|z|(!6UA=wfWmAY& zPD!SpFJAR2*(~Tz8v;2lh_0L%nIs5xZQR#y79NFzYo4o6OjAeysI2LvD=FBwcFg30@HR#nP zs{cIn83B^nl`{&d^FeADu@~iiwkP%+lEHXTI6b0KhzWB{O~EI25a{s*>a7!7+qbhh zjxIqCuW4>yAx15AijWqd4CA$RC&*s0m9t|w$tM4*saw7+xTrg_xKj(HHQ`C$*`wZ# z+F*zRB~LgIa0Rgr8t<7-`N@=Gn(trUH_tdzcDL8bA@BTPEFqaXX9*pYZ;aGXPiy8f z?(&l44_(m%H7=hBzqQ4e9pv9v@U3%F-JF|4{dH=~Ayo(S)ioF}HR!N3Y+krrzWNx= zewp>!y@^4}K%;LX*8nR*;wL6gw_RR({SbETiMM(o+i^#jxFoGu|2Q#n7Lr}Dh*~%4 z#QF8D;IOf_N>_$PF)$G|q$q9xhMYZgZDO^)Xv(fzccd()Xf8G1_;E8_RQ zPnT)eCQyR7-}iC84U9|w+3tWQnrA<^jTpBnSAjBt0_jCY?yX8+St!Pqp5KJazdGJz z$7?Yq0$`Tq>eS7y+|7wvF`Z@gYcwA<;8GZ&I4)+Q zoE~5pn-W;6stLx*grQ3SfmEWl4cu*GV;!ap%d(M0?B(i^X<+-Ke7s;=uHj98q^uzE z{xS8rDIGG3W9<4iWgKVG2j<ZgVZY!*pZ~Ux-Ms(-b9VE zg-3Z~>m|MN3m6r@ZSzCJQ0Gzusk}VqXb24P`bnvtOYm3 z$KI2z=gVkou%-mJPR%am->*5u1BFu4oiIsm40nXDgGKo_{?N`FgolG1Uu9#2wjsQ?xrI&?k==>8fWx$ku8bvO*IBfH->Fk*IrdLQrxfT3k3jL^?;yH|%WF;wF=gEx60l+{pNG{ZPB35k%1Nu7C+swyMBc+ zeiO3#{!`90Ue^WQqyTf^|7?W2Zv5waH)htQv9>E7_mVBwY-v2nglR|;lUgCj0J6-% zOHkPK&Ij;TpCbbt+i#2;rl@r?+gMej{Zio=5V|N1^Ew`*(4+G5cxRT8sj`5tTJ9MY zRmzpHsD`M)`XVSAa$Zax&&swot#+fR?E7E{%eTZW^E1cz32Ma9tJ1|35>&QHF^6#L z;i^*NHb#l}dW$O(z$E9{Mf>*l<)PRJ02t;2r3e4|fdX|=K+fjtHe?;6>yk>_&>4#-8jHK!u|ZH4I%f=SCN&3*A8m`FEP2UWin#&pXErv5o_ zkVyFQ&m0|lvKe+gNgc5PwUGJPL+4#@x`Oy${z<}*KE?Xe#7;NRv}fC}+YiYV51?F& zj~I>b&*QJw!?3}|P<#K@@N86ChJo%~rf2>~BBsI-TU;nCNh5X75E4E3ev$|r=tj7d zTn~A(YR9HW-U_Ppwnr!$(bH-0200e08kx5?c59ZbiXsyw#O^6){0at!)$kcWTJqDo zD}7jl*UQ4mIL)6ahD_|d>V(2TPffmB;^hr$VJE*T#BF$fA6l`?ap-Eqj&-E|i$QB? zFSyX|bpLvzvpMYO@K3&@%!avP6RKS3kU`|QzRCLe)bHoYiWmPpm}d~E!|bWU1x}d9 z&KT8eMoT-@MOJPR=&eW3AqA%j7b!DA)z&qpJi~!wn#L?Mee8IR7oXk8hX(2O%t)}j zBfdA&V&MqH;wU}*&wGEkqb*Oe)AB|Bb-Z`Xz>=9Rchs?Ml=mQ@SaPG57|UD47yuUn z^fAV_SL1&_|6^?M3~moPqm(zHTCP|QQ|eKkI#*q+Je(l(T}eFYy%&wm^r&=WZ!pAp z^+vi@uA3zyyqeIv&nX{g8VtARIr5V262*b96=C-vz>@S6^lj!$qsjAYppL25C=W0j zi841|b8Z&xO~6b?%|E_^#(y6pKFtH3U4y`LTwFw3Imaa{D0q5#1E0H>3;k27ip_vp zO=ZhdWXqXt1jJVZ=0<}Wwybpi$w>dxcTqqg0MF}Tk`Jcj>t34~p9MG*B>XdiN)6GP z$mT-ehR9}RtcGMi!8vrAw?`Ahv#M;{iz0PZdadGWHait9OS2*7uaur6J2S<;DcFzn z{Q)}_H2(b$NuNRO6+rGieyKkf0BnQZegEs5Gd3=0U%4p&oyF0q zZ`5JAzPk&1eK%??naap+nM9-d*@uw3(LRuDzMSKd^$kZfdL)&ys+#F?V!cBnCaC=j z`3sjBh9Kit-e-bZkE#NsaT=flg&<7>2xf z=Y^5=M6Sxavp4&kjjgNjI%WFabF8vv!sQ&){DS=J^ZEh+^_{6q^WXHou_3s%wbk(N z!&4BW{T4<1nf~gh=RLd3$fLQ{=GhlpYN=_-Z!DzubD_aikuf6aq0#PVZ2LH&_HlDM z&YzJI70c{veS}O{#1evwWA#pp&r=h-W;Ss6^R-;uJgPR+ywn*qK9;MKVF2phK`yXX zY}meZ1+1rCh6C^_Qad%BN|2HvdHgRyXlsYc~f4HtK`CX(PXHkT_N!hqJ?)XbCFZuG~SIO`I zH^I8#Tg%3-Tknn!J$zhRUaxnHM)U(HLviRVg&vh;`0F>_!JynQFTAuNGV64pqH=~t zgYf>r=cpLXEgd^_a_p3xqkaz~A91#%`x(Yo?vLYLc(Ltd+s|{H$B^dyqlttN==JlY zmX*$QiDScNJH+GI2)pWxTg1l=)D<5mNz#a93?b2ToMa$@c>f#@_#I=vpGz7j(vYp& zl{kTtqdtAxKB&F{@nAktEEXo2S}QD(1~xBqz{YqCI&O)B*Q~E2&5aBD*I;` zy$DMnOrxtiDo!-0K6G&%_yQE{jwsGE!9M5`%epB1D>E4%Mr~Lyf88+x8+62r_K}UJVUr_hg^>wgVakdVg@3;*9s;mC7_ep_q1h-1s9I$ldOz+J zc6Oem$MMP~+OqM7z?w4s+;lK-7Bqj%4>ZD2-}Z_ZPxW(&p1hm%!ff^}7wZbHcz9zk z%NwAORBE0#Hsj#8Kzafky``)wxu$AW|FxgFecN2bHZnH8-oM9fenxrRAR)W76uTNd zwibrfYlUN;;bPglHzEBs-zXHjjA!zLYMxdd6XV)f$%rQKZ#V>v4=8I{?;$@2G<@8vaKbBygiyr!R?l7~LT1JMKxzF@D)c@@Ji@FW({3t?SR1T>fZsgtb@1_z`%oC&UR za#NX4q&OU=#eE$MJYDsFR~fM2$hW_`H<;NCgbSD$70~}3%y>jauLVP_UU$a5mVOrU z`3ocGgq~R3O{(|m4(n?hL87Ceadnf;L4m(!H;1B1F_{IKNChdPf4Y!fxrEy#ba#BD zAeI}yej({ohY^saP2-f9$xz^Ta3HW@D5#Bzay03epsC`O5y`UrX-ThYHbW3}OG1rO zci^s#Z7sUoZ7U&2{Lbzs{efn7eJY#W;lGGZ5rY zE$Y&l$P_!O>NnC})r|9gtNfs7V3ri!R(#3p%|_SAhYjTLF>WBcD9noMkY@Dgjbd)D zcez9HuwYy}KN@+HO*N5gtsMA)m|vDU;pB4S+L5XRrCbhRy`(PYjMJdSSI(0Wh7@L? zGLQNVp$_7%rx3?*PEiiH1ZPQEcxvn-<0c0V*u$-K{TJUesS$j;%PBDZGj z)8@+%`9($RbxW3~w_@dE@NL*#9D(7ZjB!KP4ZEZ6n%T-QnX5cD zR}MAgTk}57eF^1kocSP;>{CPgV-1e3h>Rwd_!q4Beud0-zBLEN*+7EZ19-3hN^wq3 z;iO`|f~698Ek6STSY7CA)idk&vMG+ZKtzZbb3YA~nViXctvaaAa9CD4~`- zk9?c46c?QAR&xUBef#lKl5lKw0Y|ssJ&|$0{`@zMjxWs98#K*^lwfd+TZ>w2eBWU zTB09vuo4^Kwas)zUZjrF4X5hhP=qaF9PdOE;fOQJDOmZWyPHkEWZiccH4x++LjN@sH{;h1`ppwWe z5ra6@&^mFzrpkgHvn zY>nKqmkQ=iu^BO4-@ZoYiw5kDBxt!MyP&OKZMmpl!vfCTT*pz`!+wZ*Kb5+H<{$6p zjrlNK7R~r3_#8R&(giwCyHD=47rFJ@n&TE~w-nw=X>`u9ZijZX1};~ytp>y?^VRSP zsl_fR%9^XoP%^+H#DQ6?Pe~iH{N=)%WM+c8ohe5-EnF=yB&*-FyGQE-OqKxt%odrX z7_J5T@PUslj>xkh&Ry$x?yJ{<9qct$EW~#vG2d+04yoJZ2H6fAjPJe)-j}U#?($q9 zTEECgCahu5QuR?K`rvr7vG77exqk)hD_?U$(huj_)=xu>ph;+Y-{<^S^4rqGk;q$u zf{UK}8eEd&OuVKrJ4HB+TDex4t^#dHn`)SQ7`fYkI#(C432I4yf1;h(gE}KCkjU+g zdIDrrk6jN%pcL+d4tRl?iW-ev2`oMJ{gdF-5$5BY4ly#F;3v4AK7(=FlP3~rWA+X0 zn7|p;-VhvVyEUfj^>;u8#`+(R!sHWjXU%7xmg3`yJ>g5X#tvymy?R6j)K3)d+-LJz zr%c0dlS^=y*jre`sk_DTF!s#X<)7i;q`(K8@6!ZyZ@|Z30;%ZrnHGN{K+sOV3j*53 z;s(4keT4ja+2CIKDG@%Gvb>84u=lTCLM{HrU3&R5#Wd^J)ZdeTd(u<48s|^f(7Bf; z=HS~MrP#H|KJ>Z-;`w-@1PwI(%1i8Z4Hz;sMLA|lQW$~bl>nSUF|UeSTeJB->n!FK zS3cb=YY~S`5#dUJ;J04CdK_)XFCGYZyXJoN5Fq)F-q&t8vvq|4mFkqWB>?ffZUx~m zNd-IFND6VXhlOb}Mt6+I$Q1H0eT+#dwvFr8g0)1Ct~tG})uN0@1ZM2IzA{?a2NJxo zReK~C%}~HzUAA!*LMtbV5eE7gBNmOrbHK;Fp<&%hTDADo8z5#Ay}mI-3GG~fVw-Yy ze~8Lyq<83&;z=Z}=0Q@HZD3Jjo}LWf>8AQqm}W3HkBnl0Q1aB0Uq~UIg~~$i8Wn z_8;jR{wvCH4J%T?8}Py9#l#7g2Jq_{fsH0W^RA z_6AR`wvIjtjrokqqtrF4Hs1f6!Q$QWNRbYkp#aN3xt!WI^uT68_qU-#NVsH(Kf>mq zJ$zM{NxJ!M#NK`Z`zjqTFEK(g(?wkt7FY`WM}odPLRF;21d#dvjr;YFvL}4dVVeCO z{I9N8MCbTQE@p)jO_JKslu|G1u@?K%_toHKuXl`*4n&3Hg8~m4F29A=slVcw# zidl>29@)EJoDOs{=gh%oVNH0fiMYm+wrOf(b@{*ds?4z5?vKa@w~OM|n_+5QHh(4j zkeu>C@+GDv07os+@O{m zI668$zdda|fR!_TuX9f43zq@Af5+Op=K`i>#uVmQBe`e#_LW*b9nVesiVp}^OmMPt zD=^;|A@$>E_1MPoTgX`0#lMv_sUYR&e;>J}9l6G%(q)ER!Zb^fGm@s{=f9$%MhD(* zDM1S^@Mgy*ROZa1e`iILtT!ty*BtQi{nVr_CctA`~NtYf=zqb z)YZy^L69NrOAz&O^^Pe5y2|U(s2*RpCIwi&Vlfh9bfqiHaLVs9|6%uO6p zJ@_OV8JB0%8S9tQHDQsmgkHfn)Q5`CM;LAwJy46GPRU^#IsFWV`lnf04p`aH1r=F2+Q4@iD)+Su1*0odBu^tvA zAre$~^6S4oRIz)7^en4=BNIs90#i7G9%eD?wYTu)*k-N~}V`3Q{6KtMXUSEPRH>g*&WU~n)A zLsawEK_tf=PnYA{#2aW{cFgD)TFo%24JtH}h5j=H$)B~@Q$jtPG|KqeOjw4DASPGz zI4n{iuaQN}rEe-EUyBPs zh=C!8eocCV6%(q*Yt^zIvv1vaG_xk_m}C*Np=<$jfOw9UIEP{UAJ6jXee~P_K666k zT*bjkyE&N5VzqdTu7;>SAKsVwzjBqQj^Hc)E$yw3ehpxf9~19C;$kC<^uVR^QvYzm z#g=Nw>P$oylzE0b@+MtO=bCsB`}+I;mVd;57rxt9**;S94Nb3M!hMbkNc5zO7zkU} z08_CAn=h@u3cDe|SOW8uGvvIQ)((=`C-L;ndjWse5H2&t&M!nREs}j0ts`Doo+e+rz*xWT z6|xR2v-hD?sYcKH55;5FF6kn=usCJVEK~Mp-DQYRT)-`bax-B5G8OU5EBCaiPCMeZY{xOB7rc#DQ*mzXpPDU^ z-63Ee)wabltj=DmdeP1R>zM8jzalfZ(CVw0$$iuAl1 z0D)Oz1dsMS_z`4I+AqhH{w$!}5qpTWT0In%LxB-ImOXS>OmK{@YOXwf&ZsGt1&s!! zTecs&-jpx?LLojdr#h%-A_*-;|H=YOqo9*ejEs9s64Kt15SI=q7kv!YD#lB_VLkw{ z1^KV-;#xn-UM%fZdh;*>3#>f@=eUN$3~e{hS%a;NFkO@6x)80wDRpoBm>8mX@s~Qz zPm%;$plvn@Od2gYnbHna`zIcLtX&xLAd->(CL1>=+&romZF$yRqh_h((V6%+OsHjp z_Gs_zrB;NxJb+Ju{WQ>%lI=7v5Ub9$oX8*mI%T<}BlQ@cDe4sZXp7dg+gg6>`eMTG&VR z_+$FJiJj%wxBIQn4_>hUK%8f9gb|V}7{Fdjz;f2z-~t^5JP9n>^^$S~JCsZaK__tb zOkgQG!?s`(5>`vH*;#N6+rs#JbT{IyV&Sbu}?uQMIj zsa$vdV`YqnwXc7NQSe4XRuX3(oeZ0oq5H~r3G#SNyLawNa%<0n@$3fxh6WOT7IXB#yB#&#P-eYN;lIJwov(xR>6hAte$G`+R?Dl=E4B~zdSAc zLA7^2;lZ&G)8JmcgTIvPEufk?=zbGq0~@80uO%Hm95qXvuhTs_xUSth0loi;TmR$1 z0M8GHF%2FXyKN_}qTHUNdnfCF+<{-3m*m%uwtX7|tWL_Czvv9k`~r)W?GpNB2<&Mh z&5g;A()%ZIekAHFdbKo+3;4*aK!Mr9P7GBqotr`honuws0`Uk%ISL1wrpK^TLEq;m%g(L+v>9<(D?Qz|ni9-2if5&7IuCoUfwEP~%pYl?#p zvt39wHEE6?T0c_QG8Nf#l_!2|F9Gt&8%&_+3!3?_rV4y&y!m9 z+O7~{iw|6tDHEZN##cK{(~lFcoaqaISFgQJJWHVksPumj9!(N0J}v`U#`;dY1~nGr z?RfxuAE2sf+qwcE_5udlyK&T@9zWv*GQ_W=8T*XiaSSy_#M&JB10{s1V>qL(|es01$?~Tee z>qEHhuP=aGH8O8|WrfP)_*J2NJ?4P2O|pkxKo>(2m%lVa_{hvCZ}QA#`8^InvJ*A| z_ATJ;>s17-se4VPYxH1wpWt7Z2k)!-Zs!<^?0zaGnA4WJ80f__?tqWiC?E%O-l7LI zD*^lR`v-`Sl#~>xLJWdDYH3FZw(eP*6LWuCHXlWR>|v=%jguKCPIB=`7OMU1palGD zrSdS!Lb|v%>+a%%MgFb0%MAntmlU==hECsr;VYx)UTp6lp|@fAU};-Is#YH~W;Cl?N@muU19y(Yu(x2HKq_lP{Vhq9%(TawN_F8k-h&_E= z%{t*-s19uNJCZc7O_K{~Bri;JkYh>#8+EsO3ki?{8-2o9ZSYwqcxW}}O13RJdFM0~NKMk~00kX0#?FDBj+Dy0|u9Lu!6BIB4s8mV3 zmNNBy1J`;Q+=o1d;3ufc85Yll(?A}e}+C_~ZbV<#CM`J&LzjG59 zaubkLAVzU#KIy)4zYj!0DCiyGwm*a{iU3WwtnNlNm+s0^lb3^9-CU?bM1?U&zmxtt zdy)8EB2xApS=4vdZR>@7NBnS~%2xYtMLmw-r_|@@6t;0%@7#1kf)7U(Z`Q4U0ZiJ} zZ?;SV(k)5N53WpY9aNv$=sFJcRQDyYB91`(eLNyBw2(^yMg9V)a6CQ}f?vX-lXQ87 ztiyv(r0gqCJRkn!}e)C>EM!_tWY`Q8u^RF(r=Im721m|Pk ze>(JJkTnKskNPZT=3uM)u{_ zn;c#7(?wLsQc~sbxiJO%T^ihhv760uLhyoNER`-oQ5g;kHWq}F^f1c)nLUPjBfk6l znzMfd@22z>b1qx5mi=b5&CWgrZ-@iWWrf`d=)3!g0KUi>%m~j;104;YPVC+&->8+{ z0Ep!7t|a6YaiR71tvjDN7VQ1V{wubHWQTQWOk|`5h~Ax zXxCLwrg`k~smQ3f@!RGvW$mQEdn0>Mi>-&Q)HdhvZLpuyC20Lk6CH659Q1Bx*&g~D zi#Yb#M1s84ThFEf=@@LO7i2Hsp#?|5n3fb0PA*VouH`5zv67dNByeqi#?#e*T`BEM zSJ5$p&(T7&o=W0*QI*Zuv^xjm8-4Jy+A#|@Z^e;GPfW~+?dCN5=5Z>0!*S&r#BmQu zcKBTgHqAP1(+A%NBr-;hZ~54l3@i9IC`xP2H%* zD0|q%o98>5!G+iUnwuboPIp`{1VoO^a4|JNoH9P;e!pBOL3^9p-eXBWWbOL~-hz`f zBr4Y(^B;aHux@o74*_^_JDzIasC3Lwl?E|CuSp{qlLc0CR1jQfdy9rF<6Y*ORteSn z&DVbI3Q1(~Ht7(y=(U2^jh_a4{%;H%zex359R9I6#QTlg*fJ|M;xrxaO`$!e!&iHt z)s4T)ARb!T#x?BHWs9`wsDy-<)FNH+_|`Y}vR+Klx1$hy%RJBv4*hcZEVG}winGgK z)5^z$CbmuWot4kfIoX6q9Uc zGvv_9JQq^Ki!?$anNK!{;=a#$5*SC5Evgbq^@AT1Q$pf7;dM`T2yY)=lk%&QObARR z*;o7_&4Tf#{jXCMw(@??#Vc$uH~m^$*Nm}E+w3Qe;x_eK@Fr< zOMg)b+}Z+9;eVAR0m4i-@9(iJ?itgTUY{BN-E@4qm%;0hH@L;wPWovPx7gyNPBBM~ zi;%WFpry7MS8Xvwz&l!GH`8SWf2+wm; zX~^E=eifmf=RmA2_+#9vsq3EyJ=mQfa6f=E(nn5(y7=7TLFT>F8k_}6-JEv5a@nnr zO;0o3xLjlm;zLFLDOu^Z!!-0|qKC1LxB&(Q5%D9*klVxW-vBQ3w?jG_O_77ZRKjUr zmGYqO6~EvMhlu~iq^~cS*?&8xE*RnawgfPYEv1?u5P^`buudit%j342I~*tZNj&v< z!s=c|<=mLYVPT9fM_Ar5(_mui{*-Tke_D>*67>ebTo7dMw_KNo23eb8fo)=$v19w4 z3g)y5eX=~Ztz~m7L2hsCBUNzOZEvXSQS-ll-Lg(wf@}dkHjrL(Gx&4j#=HiTuk3Cw zwqo;$9%K)yu_t1NXho^vVB$@>Yw}e-Ut^j*yTf zW0_;dIKCaISSu~-r(#7z{LlCzY4d#3GOukGv!wVJVIm4ydYhf~~B7KecOVzsuDWT)BXwL{M5Zy6nE&Xrd5y>hl1- zW?-0Vu0-yMA#V@`2Tv$!RR!l0g=HFm2IiT;y$87y6&~k$)jK+*oi&P5=x5&fHQS-m z?{LnX(Y_(PnDbYbp-pk-e7_x=t+2fBluj$m?bx4)sj&?U!eg%e(8QkQJJU|AJ{xm$ zq$AGP4%7JykC6wDq5sxS@AsiERj~x_$qkh=WM@!TYVEKW5Ho)r)Z^x2Nr%D@h|{Fg znUtk6;Q{yvw4k%OOXnD&9>&j;%jMokHGU$AT44o}MoZ!bCKrF2pBnM4Tvl5}euGiqYu&mn}kK5cfz5X6Zdm;gU zR=grxAa@1l<3QMxlIV{QQuQq=aZyozf$r-02_`jP{4M~IqLvJXODp6N)LnY;1)@AG zds99r9d{^c@WqVrX9kr}buBr6=NP|jz$+a;jIgM}Xa{_WyUOu}2Oxn6bNIL8Lr)uV z_4M{H`)e!GR4_O@n4q^R$W;5>TDhD_>)<=}*QP5Nri&f$8 zPj#a2C>?ZtVP4QwgPfXG#49#GgCy*&ejq!fH3&oWkyVEGHXmW=Ph(SP%T;v7(;{yj z*NzC8-=}_nsb(C0n>5z5rNPiQ0_o}IfbFvOGTs#NXq;I=n==|7@*H)P|5#~1F|dbi zr`T$VCBOTn_S2XgD`8J+Teeb|!58fqe*+F!gEl}AG~VM!&`a_$RrCoMd;?uM+o^;!Eq2!RcL ztE~=S&zk*zjlw_+z-Nregkw&A^WwI6VPQY<{WlLHs}ik-jJor7=FPzLh?Zx#Kyl6) z`U?NnxwEoDE-Ru)xb7`SOZ%wgLM_b2t?-e)pxDI|LStXy>uWr%GJ9rT0yV3pNW@@J z?>lDT6c4W^Y^u@>x)h%Tk#kW7{?AQGXFv7Le2D6%sT2<+8VrBj0sov03!mf_-jZL` zMAd@z9HXGyji-#TCi-Fv-&{0OD)0nW@i3CUSZ_TfV}V=%G5jAy=`2ADs2l=<)3yx< zAkPSd^ay)IM4kCa&gg@?A#T*8p&%AtL>WInORy6yl#4P*QK@CXnfA~>A)w4>ggXF^ zHu#Urgwq1!-LWr2C{WtYeFr>|$?3a4b9XxP;AH)NcJh&2We>YAN~kl8nXTYBh5faN zTk#9e3BG#)B;p#la7Jg&C*WR|C>+0f(9DX;O&K7f0Jq7uw;3+LdYp8_Q?fU({O6l#nr;Q znsy#FE6hmtT(EyJ1F6_T9tDoydWH>f1|vY?yg7hMe!1!kg{lWe5?@mLi45wS`yq00 zqMR?cfhN7u_Y@Ir{^n$b=S1~?vtu_0rjvNax(Rp3AedbI0&B@aS>|$69V;TlxePJq zS@524y=lWGqDbf?$OzRa&rKkJO%{_N^4JW zQQrJ@r1U-WIonM&j0DG&3tL1wq%8UJ8vORR_;AKzQ7f0S6Gz|(uIBl@vevZ9GY_cD z59q#l3Bzl)al9A{ln=d&q8%_@nB<7YNkmR{n>Q&IfE9Bfmf#uci1AyD)5Vl*mneS& zlhxvA$~tS4ana$3{j(N<02%&Et6-$Nj{YA;Ut}(s*5w3!PEfu`-+hQ^%#IxKA2%Hh z{L@d<-%&tbN_%52UqkA&!=!MGfK%CqN3zn|iA1{JkHG5H%&OH7QrTo`WPNCGK>$ueUlg!t?LbLNkS%aHA!n4;&qq}) z6}P-ph(gY|_7?4|<}LMBxVe}EJ4%;t;{b`4N*Xk((rMl|IRf>`?@r9NY~@JY@T~)% z$tjbmx#8;!{M-Om;|f;g0Rr&z>;a+m4duxfpZh<27b=gEoEHyF%NXdc3uO$PJ=$J% zh)n4B7D=#YW8fC}zMuF(==a3%%^@hlYp_{&tdi3NjFX~_^l`B~YRU;nB))GiY_)PS zReA@m6dMXP$P!?83jS`p%#CA#?iR58sK>rKQhp<#O#r$E``3U)>;3fsa^e7^asgvP zn!l{&>aAnLZY3?{C6lO^B0uxH=ql%<`(Plwq}eJqh_J$l@%&OwbmVdTp1(s*WqG<$ z7*=1|o?Vo_%Yr{kNcToobj5WP`z+KT8bT!IJz8rD|LeDCxNR`-<+AZF+k#oWQNptR9s&8}|HiL(0Q#d68%U{{1oj;*0oRsIV5=WCcN5uj33Hbr zx#(eSyMnrs!oi3c2u=^~GG*kKln{QJD`j^4f+)5QL1NX;uObX^f|U5%2>|FZxqeaLfYC*EeL zHok%TttUBXr>6?#%gQJh_&2-kq$C@yEpHy5rWC>9F&KH)dcTor#D!Ot=@Rf=Gno~> zj5zI2FBL8q3p#~b>cDC)QY8mM5b=v9RjaMJr)dvUsoSJ&%CX`S(&Mf+ux6E^7#E5+ z3SGL#injU_jrg|LinPxHJ|OT*Ad6JWiPA`!HX+Qj#(W5DD=WDjTJ?m3+*CZwUbn$E zAO*h)ge}5k5X|9uLp~+#1w+0jux?%`-^<%L2VurN@S%#^&y+(gBt~ZQy%04=SkF+j zziUrZ_MW>3tqtE=CzKVPLLda2`^yaw&-hKUn5=^p7DvWigsWJ0>c`4mNmx$VuhdSgMjh zJoy@FQQF?5CaWszm2a<&waKsT@#WJsBQa>h;uSa)LBiCr0*xuK0Z=>6WZdS;Vkg8l zc+N-%^|JjrwHpbsk@*{QYEyB^rleoMIEFJqthh>+yAnX9Yiye|kR`N)_Ze zZ;P3nx=lU#boZZ~Ap+T9fJX51E@)sU^1pun;~ViCh~(%FzjbXP_&TB+=ABuO41t2)GOyE_C5MI~)}uPCEmkay1{(npt6?o zsRoBWy?u&QL8X@GI-+4xf;t2uB_im@L`yq^+BrUiSn4i&;3s@P1%Db~%eG!4ae>r$ zsy&*-SXLTSN?V3*+Iyy>5E97R9}#L9wdzWoG=J;>EG=Txx59I zVOA+u6TU-+sC)k#%47J{pC;U%a8vUV*bbQHBY6aXh|Nd6=R8lZx~0^B9A2q3!;QG7 z7ZV68XBtX<9nKT!$hm?J< zaBFJy27f{sbc6d8iU-3Name~is{U2#oER;{(o0yU9PiXVqL5W#(zbGFrhT9YMnM~+ zI(8j`as4K#DP-7$EB?ur@~bjKK58~v`zVKs;_I&nx%Hm1T zA6H8H*hLLhb)*&||Bd1rJLK2**z_f>a}T|$G)5qi1q={@IU+D|Qs4>bOl#jFHXnHM zH9ESBL(NNUDNS)xh`(xvy8{hiWms8V8k*}v!8n&xAwKTYQM0QXs`rVd@~baiAWuvu z_?}?HmHdwR4aavC14f>L_vGV>hQuwW}KP{9}ew}S& z_4=#L6lC~p-i(Kb%()ew;DcOkcJan~uj zd#mR67!k4r$V^b>T8O-4NC+vyaJfw2?i(lYuglJ^J!ju}6BtR+#*y+59-tR2_!fnz z8!;flNA!C@Mw_EG|FXXq_v>TZ_UQiJyOr(^0W26}8ra-`z_pvu!rbvikx5URKRxY( zy^D&kKJ0DcKaseQJ^Gl(g%Dw77JXTAl|VIiIoe$+W7w=kI(PAR?>~JF1j8;Ytq7l~ zB)f|I@a^xreQoj!tCU=2M%Qg4)ThA~E>PX--h#0rY%k+?xGq^S{eafFXALF9lg zT1%fLokO5*V|S?*Xc@;$jam;%ieC23@IZZ@zNFa%BvlA*TWes5wXKv4{J@WKlvl3R zUu*8hiU}8ecB(1-meZI+#+<8ZamDbp9(y6Mv@ef0mDL+BTBdv|-P0_QpY_jE7j>4c zY~iSffKL`H^I1O^$q1A^ZJ;XTl0uk#Tm=-JeEX$xlHt~Ao9#q+KWwV=-U#>vG!2Ha zI)Os>>lvL>#+`$Kor7*5Xcv*8}F{bmI`C_bwu|CE5l6|i%P z0?->>z1@3mhZkgx3q#@phOwjHol24BZgPl1kE8{k@n&9*7#Ahi-5TQJ#Yn~B_)yQl zck2x2wRp;8x#A&mW>QR)GuLLlTDy{nK)M#p(U?^Ak}xevLDRU1RrixY_~ALu0{IgT zWDig~JtXE1`(B++54_%TDD^++Y@@HzpK19=Ink+PM4;ygZl=|iz}7xRaUtD|(m}%? zUqaN9d6oi;dn5C(+E<%sVR*&#EYDjXk1TvgX`6$*=f4K9jH?Bh8cp})y8k${3ik0f zOu{#bdp#%fO|Rn;TW`C$9T_3E)=L+N7VaUzXUDq}TNPS#MAiSh6{CW0i}}{Hzqa>o z+`iWj565{Jp>UnhL9THK{`6MOL?lAQRx8{Mm}3A4=FtUy>l?8o$*DJ2legTpHN&lH$*mOOC-~z{ ziC2XR+%zfC68bmOB6fPQpoQ92zbCf)HUrN1_B?@&r{lMO-u(H0ubb8u49>=nfu#T7 zfy=^)^zayrQX$|U28}&=cfz;~#il{=_d;BmBL9o7e!C?mK}z*SA}m7tb_Slnmi^IY*mtdj<<$rRy@A#_RmXY4UU2TP31TT zY=29t!NW_EUMJ154U%~*35Qkcjiq}_*z!7v8$#LEm zZyX<1pKNy5Qm2j6Eb~j621)V>60jE*H8i>bDyTqUb<)Ds4Y$Rj z-bO>5;KPk}{7r+ovI=q;=R67J6C~NG<0(4y%olSe9s}hi=Dut5O7x3yEA~(dQ*xxY z9v4kN5=sdM3C#0&8t~J5qsb-;lR?@U88PdbW%+d~6dccQgmEo_4wd)a&yj?vz$gaV zl8~$%brk@>2So2*pAaBs3Iv@2juPktcvw(XN$xr6Qpy(5Oz>5KH)tOC^^11uFzG%I zrKw>pr*k|rJh!=#>^TmOr`=Gn-zTUhgzV+^5WL@lUnaD{Uh z7CG|yyG);s|A*H`bZJ~tw24^FSEvmg=GjXbW-EP4J8Hk1f{K~p?(hq8e~0Xr(torC zp<6p@aSi5I=Eb~gr3z6m{|>A>R=SC=kJy#YRKorHpy zZxE(XIyjq>tW2L|Su{0f_FWGpg38!T>mj$sA+jG24!rAcf|srT-DkRY0TuJk%L)9aE+9T)sn3|cd3+=Ucq`n8X`HrHk0K36^}XQC zPLK9Gcvkniu~!`^n7VZn?2@t)Wey=RXqtD*lO>bMwe0Z50H2iT7ZGR}W*r^t`G;>`St#xcX5|R3*+wC@%BP4A4lrl^(Z(lH4z|+s8$0EeM`ZP4FL)I=DnW}i&34|JJmpEGQXWzg`?+RM2 zI+~d&6N#X`JHqd4{0cL9q^Kv-4@d)&2C0yz3(M{l)kv`2m9$HieZmIHr z6XHEo8I;=>cWPLKY${Bci0dD*Y^_i@7q52_9T0mmZw8S!5`QR~a<#3DT{>3A;M=%6+(AZS_R&>l!!=va+GB zvIUcb3yh2a-~PE7W`8x+Xu|HGX%qr+%5qWBh{vEBjc0*L#lD&0(vJJImgG8BCMrK9 z$g7mZo25lM&$=bKpXuMRVSU7Exe(R5gBB6W+ba6X9?=m755u}-aJ-dD4_d+Ze(4laGow%R zE4Ibn!(tHvRes9?HzDVCE`R2iX?8>Q7AGNzS#)0RQT|gH)diU1{S0H|n0}(vYlfC{ zhoC0nbDXUB7(#Mo&GFw)lU8g9ht& zF}`h`nS7{JLC9!HzK=SE6-d0vQB+{N4dCiY$@&_p^igUf#=YnT`$37~O8#Cyz3ET( zr^&~ZLF{dJV1-|Hm2K@3ed_yaz~r5roukgO3D>*Z2No8vrJz*)v$40040!1ucjSE$ zk-9Bgu!!*Xt5IfK-i!BLBI+6z9hj{oB6tRE-l@HmRonWpKCN5)u~O9gZ9u=C>mZaQE^&+WF+thn{rBO3ytl93&v=a-8=z?K46c1z1qR?ESl*4CGGIz7&w?v zpFkTBxFaaXEb+4Io9o*uC3&|nf6oPIVRS6brVszYK@W5iW6h> znI=?~ns;7&Ru(2~L<4{3MZUxse~zalT60=1%QML&ps!Z3>UpFhCJ0D;34DrYh?jR` z!J(y4>MPH!t6|K`%X1RQzrLT};=cI+y?sdiLIp~ReGNp{4{KW6Bd9_m5R6_iHjTfv z#dAy`V?f|d3pZW3U#-kLuOF(>nqtWZ-j#}Bu(sNJUmC_yx{3X*z^H2JIE*SvZ#=Djham4Kx1X(1`=#+R5Tz$Y5EcYllG zAMcf_Jl~&SiUfEi960sO&2jely+9g$JVP4wejuVgJ$d>0h1Nh+Rk9wy9$Da1&vC@^ z^VvxWux}mPy?%F_L(a9HR9#To_D6A%>|Nxx3z?L#N%2{}k?3*mzlFNJ<#{75%UXMxE>UueFzWcu&I7@yf~H zKLqHa&7H))MZgNJi*CP^UrnE1tzEbOy&Jqc?-K?mcn+{m6^Eio^mt&M!^8Ua(4im- zQ8(MZq{&S9R?K!ZrT;*EpWb6>d9gO#BJSP7U(7loUA~gk`|^*P<)*Txvgd_d#?-NQ zid9}6OiJ%v01#S%R(3aJURyfvR{USuW;U@{g6tdhN2ktPB@9^@4SB!DWFAqqA_}Pq zHSGIJVlxmQTn(18Ae*I@#o60EtkrRA*H2*Y{yjD#0=qhwc12(-MD`Ze!qF#j zg>*tLRt+*}wFh(a0opo%K3?Nnr5CwlI?t(hKsfQKf=((`gWGSKIf{(N{>Ehc1KES z{RHxz1uq@DIy#1dYej(Qf3IR*p}2^wCK58p5g*gKsrpxYe;egKD9i^1pFu<#rUz$@ z*`>2i{Ug3B4)u4Oi~6h48D`Cho@|LVq~ks>>cQC4D0lnK_(>7ADhA{?8{IZLwqot% z4^nKhp@QP=DRVOv%@I7A;tNib$I!4L*rVbXY|xa~#@Q;g^bdzPqJiybaPYX=h)sLe z`>LbsHaT}F<}3J+he}M#Jt#PtJzfz~WIqV3xlo1;R}N#XCK&t#kB*+6;f)Q#uE0-{ zE77izY+g?^bfNVvK&0yUe0zZE2YSQ?l;n`>!g{I?lnf~o%rxLNO%wXfN+bJ51Q2-h zB8~bDe`I~M|LSJgCS$H|pFP$KB!UmZIB@1IXKGyJvVw$_9ihK55X7N_wHOLLpqr=k zo5L{`c0z-Cf+{N+|2ujf4`I^xhFC-cMYcaa0=mSXSMCi$eiYs4VVRA>Nc>LvQNxKV z-nHtw)SPOD3u%A``vM_6-;Gy?R1m3^1_zhY)RudG@^#S&SC%Qo?#vAef`7ISB?N2P zGHHr>g^i)g3LShEMdTor^DY50$f=iM+Rlh)TAsDijAL}btl9jhYjIjaRpq)C7<&qn z5*Ou^<3VXvFF9jNv!VdxZ1k$ou$1j-W#H&qeh#d4=I0*|y}P%qd1dd-+%Dw&rFB`W zhQ)6=(;xHoXW6z)G26Sl5Usqt04<#4^I;^a27X4V0pUi&-RQ^912^UpGS5l3T|*)WJ^NMv!VFIWKfs{Jm}KjiwNFF z0)bizGU0r=^47XSwg%N7dH|Cyl^B<0)yi8S_xff4c71bm_wf;?PgY-Fe|xuK>&Pn* z$Y@wYZY>US)wwk}4kdyrx3(}b2L7mo1YEi9+WRRJEV>7atmZxp9H7(u>cc0<)`)oS zL9G8>jcwubX+P0RyKeQm{YtW%0{ew8#Hety|Zi5!Cnhm!M>UjXQ4* zdV!6eCY=5sN%_QNSJ;{_mP1=v3714#_YwA1s4rPNcv+!@GD;>f>^d#(+w`sI9mwT0vh%zM!j?_JPWZ>oFVs23FIAHRcYN9G+=s(;aaN|6Q z+So|dAYZJ>mfS^H~|7?;7kg|uEcVLj42ds_=$g(p?e-+4z`1Hh^qpi7a-dZqta zDe<6-N2$jYSV6VwFJc0eg;g zzb3?L{js`A_C*pAoVZ_0!fe&`l95wQt09&lH^uF1N5eQHjmJN!pGj3XZ-?Ihe0%Pf z{Jxf-6cJ5cF*!P*0thDC%Pl;QEoJ> z$b^WmB5E4ASj_8m*Z>D0>di^>Zs=HT*m{PB!unN+cO}pHYe~RzVS3d(ArPThQdL2O z*v2q;Io_}lHF>(;bMy*7a$ebWeQEKLUw*AwfXuEoY@0Jl(D#hoGLykbeD& zh_%mI`UT>GU*Lg1E+`=xEIqhQD0z~<$=PX2HWLZ0`zxDDmqx_6k$2{y&cXL39$QW( z%ipQse<%Eq2(9f2^g^7QBzM2Mtqq%K95~z@{?%Jwzh@R#p`_c|+FJWpnYAh~Z1w|R zMo}4wqr$1`3dJzD)=IX!TbK@6_@x?**uT_jpeIN??VvqXIn*Yt*R}75%Po24=Ih<= zl;55zX?j8p^IaOBvRkUm(OoupIX?Kv3!2ylCFkLSZUhEOKmN7I~9xl_!PFVDNE3S=8z5LtZaKOdIC9bZo@!|b@eE9eQ z@87?}<;5jVrxU(^|BgTY==TKN-~Sl*1E^sTaB!kr4lT(Ao_q!BNP+ZwoD-b~0`uyK zu-HwRQ8c8$JW)X^^p|ugS9_-9k**gbO`J|=jIEQJsa~@mh)asu`L*~@NNR~`*Q+Gs z@*v0Hz|EaWms?8kXk1+hxVpZ^&FwATym^DG%PWWgKOZ0Q@bG|#v3-oUz-=YMB|vEn zNPCveX5o()t#n+SCi`0VC2H7Y;0yo}!Cr5hJn%Mds*|)toBOeR#e8_c+di12t%M`@ znG;5dRzdn;jqEXiHlLA+&7+}>7d!d)iU$S%niOy>D7Zjb!5$+_XWC3^^c8bUPS}$$ zg07S{Nquh0NWAZmpbh`3F_4?@gZ6wT(O&>7ZO7fZo%J7cOfBtVqGgV)+t&|(f@seM z)}`Noe;P%GtIPhvsH>|h91aKE+}z;Jn>(COC!9|wJU;^sCkH8f6TA80%#%~1Jd4u2 z;itT4GQI=BlHKnGDfH{6w@f>*Z9^6jz|oZ)AFHH&Vv2O!?5V~+Itw8t1D2@4dt%Z< zf@MBSV>Z&<4~2883LmG}NVGM@kjtWu!VfO0=^p=eUGd|`kFG!Gv)xDV=FJ`6ym^D; z#Sw?Y5y!(3-@kvuwY@ks_#e+kcmSZr}l)IYk9s`q5- zW7%b?=R1#^b#BYM>tnUqH-y;J8U%~ULyx0kYi`7WF$bq+d%|GiD62TcjJ`d!4WYHw zm;Mfi#gA)M+irNf2ho@lpAywr2M#@Ndj(cc4W?2PVBx(~!}x%Y(Lm5ixu(3Ok(q*&Q1(qLxqm zC{G?pe8s1hA!XL``d-FuN`H9XcQCMAH&A zwRZoo1e;?i>}CS+DKk(4b0XvU z^3%f);+R#Eq5f%ULUcCw3q%tKAWmsLxr=sQC7BH1%W4)~my_K|UW2}TPW}HhKFtM# zX;%tpAhg~pJ zud=>%IoH^2GhqM#AOJ~3K~yVt${WWId%={3$)uwWu3`dF(d`t^g$LgS5Otbw?p3=F zbBpNNrbIc66JMB>`BhBSG0JN5y3l=KOY-^jjCS8} zKCN9xem>%S{*1S8`~8r5pWE@`*zX(o@nftebSe!m4x&Xcuw?WvVJAb=xYPGM3E1E2 z&JCXn-i8z3I}w3&TURV&<)iWo6&(!?hjtjWcYInUwLIr#f({UN^5qMD-2cGo`59MO>bC&6qpogL&rumV+;YvmB+ML6;XTFf@O9%=vsay15dg zRczkZZjvetTSjp8?ftFE6mJE1pl!c+ypXG3McT#6{9o zrHA`>8WlLJUe!T)cwtY>h%X>N6=yXC%d%kpLc&*ooC@R`y|U1G(y|!!tPhc1v`|WH zPw4PP2IjPq(^zwLlos`F!@2V@2P=dZNQGjB>ek?WN%N?4SnG+?!7q-xxV*&W<>h#s zpO|ebABSbZ<>duV&zB|xEv#t20i2YIt)xZi3jhZ&J2Y+GKwesf?K+m@LtSKXxlz(g zvN8WBz9q4(E1sU8@c8)HpAmmN;`;g;=eK8^PA3DR{B9eM`C_!i1gF{TYyln9A=V?Y zf2QqPs=vA=H{QtXD*&u3T!3^MX}E-y*5TW-KKN&bE^#ltZnq9PCA#w0#?!yNs!LY4 z=@;FD`#fe@<+i2&7%wgp0L$SJbjPsUfeXjXgH5E3-=8YhA7atr%wlo!^5O#T-@h~8 zb37jV*4FUu8>iQAxTQj|@#1=`_(hM*gM^;r9FGPzi7eylX+AvPI;K8R6Yy=}K=-j~ zPr&vgqM61JtB3sOr^-=5OJDM5*r?CT@%@MQwk*@ud7q^_%_xH`oNioa1OrlMN1!1S zSb=s<4)i+5umHGpQW1!@TZ#)60D%Diz2)y>boVUx%OWfSt z;O)D&c=zrt-o1SX5$SK~8;{*OolZC&4!F6w#?{rOJMokV^f{B^G~#|*17zWRBWOQ> z3@e+rPIKpmWd;F+o3*ptV*6gbu%ye{4=|P=g{6*g&GYJSmt8VgLp0XFvC2#&CN~UZ+Ux* z>#IvVJw4<3u;AjN2W6l?u|Y&|aXcEJQ*J7dW9Nn*ha098kSX}(;4|#Mt*zvjX~%Fo zi}?I;!e{eF$j=~+h|jsvuYDg}*C!9p!Cy=lSoa$x0(xK5nI5+avSaVnWx+IOU&szR zCjtiWnP&on6~JJh1i;=Jex{q{H2_5awcvPhF$xiPwzWf(-iwRl(5tt&xVXUc^D}^E zev)N}Hl{Mo83)T{k&tx9xfpGm%W}+6=xd;?68rXGNIE1jIj?}B$%er=1$?IAr6bS3 zp3}r?y4qrd&UvGK??uwlm1x*r0&wahC_y`~=b^uTV=3FM-d}b|70sq{u!4sJoiojS zx0waUW7i>lW9!Am5f=l%9S%Ll-QQp1d_I}aF`su>!W@TrX#@O@{pPGsY9}6Vhp$mx zl=)sAZp+E#x;6($hB;!gXz_F4B~HA10`0Lx<(~nmCY7ELA|wJ{;9?A<{$X|95b3dY z1G$EnLNdz!gJqKW&$3n;&=4Nqu6l4-4z4$xKqUXq=S((B$2*UYPk4TM!rk2+Zf>q| zeRb6zO?!ETZ(qOR=g)_sLlHAB2Q45wKXya9^Z?^Bos(XTwQ3t`DJ-M>*;e@z{)#f| zKdwZZ_H8!(P%xL`m4H6r)PIQfxOuZXplex9{~u=LQH++ zsrIlmB}WngPCiW0A)sx^2LAQ0>k!gvkWD@X2<3ekwgNbH#0CILKN+}E12N-RqO;0L zT>}~YNFcJKP=_)lrbtAIS1k0T1~o#w9=uzd`?O%xZx&t-%c!>W0HberJfBy4Z@@BM zWe){bnXXw-HI|mh+oX?^jw!Grn@aY`KErwU${+g}PGG|bLWxt0edn=MhM91`)C(Ko zk;Hl>(X@4N0B{ra5~|ACcW?tCzUff#q#&5jXRYLK?kYwlJ`+hk+GuODN1fVv2T{Js z7W~S>sC4d|&%L-pyr2^~MMCs73`B<@$UAwlhUT$veTXX+Ccx1*$BypfU;w5An+3lJvvldOzR!fj8EFH4H2ICmy|LPZULjRurrkx92wN68i%k-E<`o;dB^gVLfdm zgTSN9qea2@Hd;U#vUU?`fp4WXf^9l43yy~)E-sEZUL400rdFIzXFNSW+k0<#Y}3sN zL#|<-Eq)NsRsyBDX5KP=A%@3<{QUWI42-1M)iF6}k_RgmCjojPXZmb})>eaB1_2uxEMpfNipkU8Qo!cF zB}k+8T$MoTD=R(QEJue*W-2)gqs6uQxjzb$nIs1xkrxIV(f(_I5jre*RNe!fZI)Xd z09}vbyA%qzKx23P7(E4A6fn=pkTJ_TW=@>P?BlYOtWui-F^R7{a`wLk(032jVbMLB zjeY>oB0a`ivLEBCrQlD&#A3yujh{X=(G)(gO}JtQw^8-8dBh-Dfj*{F+qlJ)8Z?hm z_A_5&#R+j;@If?CcweDE7wVr%1lSa|D$SKI0MNHp^7+>6@voVVHWZ*5f%PpE4iJ6G z;dsE+^_7p0TJ9BabsGG44$70y2!y&m`xztL=zQsjcEx(3K{j-`d2l>vY)9i zMKoo(goL9x4HZTUe21T;6mu*{tB3@0IN&%Qf?;X*hF;JJo?N}?BO*N+6BvKw-=e*a zL|}TESEQ@dnZjpslCIZGNeCaozeyO=k|4fZ@f^jpqfV#`q$_Av7VDsxSXPEUNOJH5 z9=V#K#58qM9Qb(bB7@OPvrig3DC$GAEE`7wqK}a)s#zIrEbDVkiY3-(j5jR=mt3Jk z^L=9drE5A=bY51eNeg>z{>LoiH+(OPmQ zZN6r3 z7FIIMKCQJjIbbw?Gj9t#IX_-$){(;7qN`*?1E>WTmkoo`IfxBy8FHBPj`+&*G*=Ur z`?Zbt&$6lu7||xm3j)8CfF@tTRnUe8eu`<8#A#6DSg-}i?6)B+_NQL3N_Lih&q0;t zOl-KX(DsGD4ijviInGtS-hse;|A+&!h846bwqg5fXS}MY*Qnl4`_cnV4J#IVLRJ1< z`?7+bxcpoAoot)+QY6SzX_)5B`cAsRbE3Z$a~FIs$H3GN;|bQse7{nZQS3|G1z$A9 zGar>OcI><2V+;`9!$iON)bn^RCo?R&&TAW9hj>cyDsU9!(zmeAiI{Ki&Ghiwj_qY_ z6f4z!c)O`6-GL2?#e=SOZS}of#Z%6F?sbc6>@)J}W3U{fU!P-TK#|7FoNTnj+-`x3 zY~1CBV28Pnwl*9c(7}XL0T~6z43;u8Ie;@0RaV~8wHgJjGE3L7psnJmzo{mq!<-Vg zg@f3<6chkf?8#bLhDAUW~OsZbKV?hxGJD+kqb9LSjX z2|XG>`MQZ*rYT<^WE2gd4yYq62jAS{n1v3(#fe@aq6Eu8fE%%nSXEj|owqS)3qVLn zU5^-;c_q#{ocZ5Q7-knsZ3#r{W!*`-BMCA>1_u48-v7~Bv-eO`G{-o`wrl^`*ctqX zOHGey!nb4*f@{>BwfHT!oxm~bttV4IB5?9zfbVfa?M%WDWpLl!rbKQ6JU*d`S9TWxa-3;%JGZsH`s0bFFVGKuGm41 zMvPNXHcY{K`{+{J=5wx$y)Fvofu#mB=|Tn4jzVG((-o9P!98|W>FgGP5q#tT-DZWR zCe4E`(@xiuzXFEs_Em{H-JBcc?6)By00=3(j&RI8=)LEBaYo60QHyS4; zi8F9<{slA5yQ7-zX1_x8kY7~1!+>E12GsXnlDDO^fsVSbH2Z_Z9q(GrO;($VwioOm zf@JgHWtT6>!O_4rX3*;lckXm(*JV>7T1uag{%+A*7b9p@tFdhrcqom?1-$@dv*&F3 zU!(Zb!r#h|&HA$+jlF3sGIIZ{!h2DkL42?mVA8>KiM7`sO-5D0k*~KVtY8I}tL)eG zU;nrdh;EX;>?Sayvle+%r0B^JsaN#U$>#H!K(6r^cq;nMV`jQT$D^Mk|EM4r=uZa8 z&mFFM&##pB&n}Q`pi@>whgGkT93w?t>!*0$E)?i7&Dz-zWM1N#u0^NaCPl6+2Te1zY0!`pL08@Lf#VnWG>12cBc;m=S)rW0PdNFbNIvN1d9Ic3dec;!&68vLxg+dcEyT{2)CN z8EQOqxmDK#p5+T5ynH)5ley^@-!s5f-LF>ltUn&x=$|X&Oj#wPT;cXA4=t^$dbN43 zx!jx;h^SfCp)1t*c0~y_Yv#ZKaHeIEccucvMuO2-fE#$YW79? zq%AWueh5Jd#=MFN_{&O}2ND6ex@+x8Ho+xjV@kiX?wP11R_TC^`(0j4T;YCKdjkL%6lNlzQkZ`OOf_+FOmZ`J3j>Sz=Vz=P(uN`SRbdlJ;-L;GAT;!kzr zn9Q_!n?6_7OD4I!#zplTrVIK{i1{%4RgfHRxZd&UfuBKNesGET8%V7W3O-e*#dX}B z_1IQJVvaKK`5J`VQ`8!RVw&e^S3#1s&9$zK5uOmx{3EuX<<+FkRJjOIt z8&I3_phU*Xtvi?M4g?9p)AvV7;x%vun-if96%!7(wMbkEW<1)|cUrvCI1pEBXS_W* z2#Cqq>51``wz~@;jqVc|JOo=)EZ7D;61NnVNxO6kkF~hB=Zufd%7gySfVaqW_K#N@ zGk=aKBM|5lM;8rY))fT4Zo_G2o{Bh>4jmmZB?8I+lw+TK@$lmELChw&YqTJ9%uC*!QaX+B1}(E1BWz+}@3%<3%?=DSkDrJu!a?sT9RJMO(ize!*; zvr0aGvct;x7D>IcI@^f;eT@z&t5zTq(h%8BE|t{1J4=TLz>_Lp|Bkvs+{d;KdJ*RWBXoZid$EX8Wz8Mfk09wX7pDaba|lhqbc-peNA1 zb!=f4D>|}BHgwzRnsLX}XKKeqjER!1_|WPb?1Oo2=nE%W)`x-kihz1QRVh+RMAZLr z5uhrKg`tQ>wf#Sh28M6WiCKU}Ov2O}YX;>B8?B8{{OGk71t1mj&T2-pSs#RuYCYBd`&6%>v2RoX&nSu$mHO4Uk)_PLi6r z%h~|2`mc4Pa+`g)e(rB@^%!V$sJjxrz9CUohMXKQk9_pYOp)jLue=75P7&x;{R+$5#9-)s=vy!orGt4e8x_AMjjPwQ zJx>XBqCcKfxy}0SdVq9&n;f$yE_oFs4Iadr1HR6&dA$N)O>JAynCbV_h)_tRZ7Y$j z^ol{bE}?snj(rj97WgKzFoTOtKbwHXTrgp#>US|HogpG;nxrrb;Xg%Pvw7gtu_k?s zE--gp&=~e6s;5aZ&(#a4x$XweXmgUdBFcI#Mq*0Qmk|f#FLjs)@l4<2L z^6D(dUyV=m;d3`(siaSN&NOZqCB>XFzbN|Bu{cHs>gW-u;pLry@A)ua91@7}J>i7Z z5DHlB#ft))PsNad2ARj(ioZsK3Xc8;nPjkLWOy+l$G7lK40V9qZ-d7$87WX03* z7CSK-ab`GrXEw+0F7UlAG`fGR6c~K0aDGq7gPv)U+w`GsTaZ#M$M03>w)olA4(_v% zn}M|=`};FPWguu{gzH}#fD~wNGWhx;NWG#J`Sd)WdE7!zFwc`GKi;MSe0pT(NZvaQ zTPaS|UvuFGAk*;*`Rv)tpE&{m-5J;P&Is7Mc0O0rnjqL~RHz;?_F96}xlQ&!-qQ?R z{_Me_n(0ztv=v;(my+U0sneS)ycxJ}^$`7-+X4f921i`|n}O`MgkK}a%D*#hd;RS8 zo&flM67ce2G9Vo0fStbNw6nq*CfCurXuC&W1^B1mHgqf(Nv6aBEFHgAHt6&*=U%Gu z_u`kpO(0kLITlpFb=!#);AY~W_=P}wjvy}`x!e^i)*+$*6p7+0NTSeeu#QbOj$UiN zL^c!nnYqG$?j@GR?};FrmT3cNij9S!701?dtDv@M*9ql*T9|{D$F2@m;e2W7XO zrU$zwF>2LDY8Mg(Q`ET{7VK%hW=9>|;S2H<>03U!qYLe} z+FFrAXzPHa>qO^y*8dnU$^KfB;h0m?ruL1%B~M3d8%A&N4pSjm3-{8eZnxKv(`*y%^QRy)~kS{|yvg1&!W z?1>Yu9Jz8cIU*%0@f%kW;Mz+1W(tU69&CWP*~^U16c3?&aM2W4)p2DYG%Xiy0Kh7C z+AJ$z!p2RO1b8xw>b#VPMs4((dG&#ZRvt-8GXfr`o!f>+H(AcXH3n$W|4;o@-?u70 zIY@>68Gjg^Ol|ICoNoYm(4n&_7B@_`O21|*OWLR3tIMRGD_u3*GR@7L!}#+7#TrE2 zj{PN@;Xw+AJ&_!2w+JpJo!fGYKczY!`^`Krs^dl9(aeN=OpGOB8=5niiHWO?U(ymr zojz|DxKis`(A#~q{JGNW*>;l?E@_$RZK-3wjscVd911tW@mOM?<4kKYK5G05$y4Zb zeiw=MQ}H77x#Cj*ewY$0DH!rpTIQsi#$HqP)bk7Dc=sS*?SJMtVdE8i)Bu&{iITR` zX3PhWJk|a37zzqUpV^-{NLL;d6xNJ4ZkUc(Tum`y<6vMO)?nHY;h^2YIZ!GO|4zhb zAF*h$r2(2+g+whRB#AF&o9BPxo4=EvH{$xYwXM)Zl5HvD=;)h*;A}nvZQR7jYGFD| z-c#b3+KyP;q|0StLYK5Z-C7APAx{pUiB)0nKC>b~Rl>~^lR&Lzb(@FeFJgb}XQh4o z=dM#W`f0Oq7vk9?W0&T-i}@g6<{59k2GiVCveavfkHO*l?`?8e0i1x8IFYsmIz(nI zE0ul#&%MB0_Fc)GoE+O!*>{_+3jG(Ol5W91^Xi3HXmpqjSyL-MAHWawVOO*UeuU^A zt&TM#7z?zuR;U@-fC7uczB!Sos&W}{c3!(AJx- zAOd~NqD|Z2)A}k%Yk&cqESEOG6Q9#IEQ88#Fvwz>GZkBjbN8JfpO$p5`E@yFh>g#_ zwD4`1Wy~v3n{frRkDg@FVs*VbOwV7i=?SwnmZJo~8_49r+4c?B4Vf0hyRRTz(dKz5 zS7`Y6W!{A{(b?Rp_jX+bdoM*M9hb+|Dc1<;$e?9;J zAOJ~3K~#`oF3D(&ljNZK_d!8ngqh9^s;cETCYfm}on~@|^QUyfk2=f5xf+;&o!syT z;_{pTQsPT#;wrIyj}2&k=4}WO@E7A1&hItJ6yTa`awW}|)TdsbX_&#WgzIt=gcfx< zC%NU^7fyTTame1E`@m{scDmkD;fvOjK;L$21 zsYx0_)}%$86>ehasp7T@bFfG?G+&IJgs)ZL0R51XkaCS zMe!ib%i|3#yjiqfff<@*F%Hlb4ECv6?)$dj+}O5-5L=sZX7P$?&yup=e7jxP3IL!r znXY%6h&1W^IDd9Kp^{g~D=4;|dyO9#EW_VL9a`6Q2Gzl*in>ZYfe{R&FDH34H=NDG zW1Z^>Q|nkMc4YGxSWP<0{505OA1k}DtszSgD#EI{^p0MRlL9PG<2vFr`wlVs;?_eY zHpqc>OZjWgZ&e7&`X))1aTQwG^|>%AQaoT;jf*BD3EOOj+&Lzg&4l-Yh;laZ(#A@? zO2KLB|D>xOqZ;@E*%6ONJfK+|H~ElU0NDGqD$Jo{>lnOCrBOdKQVT?FA08wf_$UzF zs#0;_;z_NDe+2<`!a!%{PcI)1XuM@k1o}L`LP&w!?F!j0JvJ^3{FN8LmCgP)<|jMv zPSD1=7f7^f&yG+P%)hQ8m`KWwBM`ecrndMKhsd_F3;oAViZ3DHxTU^T?^UM1E6+iP zL!B5fay2FQH?zpLQQP01t_Djh5~P|3qFLK**u(?}cYwgoh?~Edjc;F3j^jZyo!w=9 zu8!?fmneA^uXClcxMcK4G#Idr{%vwG8F-~W%io~DyXpfl!2S}!<8x-M;*{!vo4rmp z{3pP>CK5qEHtZ(%Qmu^Dbha0Mk3R<~8EpkkQtRoa-6ofkJxtx~Wt-bSBG}!$$^fX# zx>*xPRy%HQd@cO&CS@D*SJIhu%tSAalL|Wvv~3h(v_rLBn}_1k+EvZ^S*US|r{)zF z|0R!E6-zvTIY!w!IEkBVr=jpkyj?frtz;7t<6e}r#s;6`+LmQ%5yl~2{W_j=E=X<3 zVaxvegq15Eb_vlj{;MLbUUv+W7&20Ke4&C;RBf^xC&p5xAnw(gfka+W2#ig1(mpYy z%zZ|a{J)0$dTza5zLMAS zjx-MU&kmJrDqMqlZmZw-0S&C0)%CU8msK)RuU42t6_x>m5qXhLCoRd4t7YU?ax-!t zz$>I%$|)sLIVn}A=z+HAon0T|{42Pzb~cZD^w#pT{?Ki_1Si%$+E^~!tgE+~bvpLx zq{pgzJ$C$_3}G$Cml#UNh2sARyl zt9_&R9#?~0-?HxwmE!3I$HQ+YDk@o*K6^u!72kGdC_Pt`C#q5$*`mYkBw>)E-;AZQ z-u5Yp7=^c@=s5%416y#c3drJB$zOqsu!S`vc3?da8B?q6m;L$USgpshWPKS`e1%N| zSFv2|jn+Bg5no{d+dm}=J%(nirV?#7KEKC;F`)GmVyVZXh?T@}N|Z&Hfxl3!071bK zr2%WFGyXVl5oEw8$-O1DFMv>`N-h2~6R`C&Dv+FhXFybpZP$=7xtcg%^O7~paOi|l zrOKV1U?M&q75nO7co~?$MrfD%+pmWC@|-6W%!78x@=eAE-MW&4Vl$a;_ zrfghf1TTTx$nWfoVBo>~50SYOVkg+K=$IKEG?Jf#a|v@%C5(FxHiFabYuv^7%29SA zRa?3s`iL=Tb#(?(l~Z=-*#FJ?!q-lRGE9HBY__aQAvw?@Na&=@mYn(&P{1IbRPP3T zRnVWeFGZ7@0%GMK28wZC+l>AIM4Qrzs4#mzC-FPEAl}wcIPCx`GZGq~RWi%680ci3 zZ%M)HWPQh%zrY*0?agGYOs>rKXP>pHD^nkOjrxpEki~*3>8M%QB68|O8`MP?;*IoK z$7OF06#5XZD^_xXFU~d;dD!v?{`+dZWFKm@5TNF3?Bz@2!ceq=#P1t%WK7irUKOm` zb-}k_0K*|4AnJMgJjJdyw)7|;G-5dS4b!_RC_=i9w1E2tsb;w9pBkQ%# z&71IFJ}eNv@}NA|+k=#(aL~Jl@lge=-ahov=v*Oh%Ss2Wd&_KrNYjn9v@`+7s1r3$ z)v0|U&wl6SNeOhcs3$Fz=sDA`s&v~l&isdSrh~*pRUz8!kwlz-cxmeP=Ah_scg|@3 z{gebC7ZOI2YX~}=?Y)Y|SI{;EtQSC3`um!(_w;iPj5DZ9Jp#e$Q>#DWYhF6$V7=dY z2SBr5Xxi5qiPs*Z|vmGMTD+ z8<%bOFi3G=@wx`FWssO`<*S=mud}!fj1wlL}B3vX_FWl$}aXqkNw-^;9AQ*~f;#ty!d;>r}A_!xP-+E6Q~P22cDMvV5o zq}Qzb{5Wk)XHWRF4{#E)-N~15mZV5Ml zOZ=S!!iMwOs`#r&75vxB+s*^)WA1lrSvmB=Ag*d>{tH9K5@Z^$S6zW$BLupuxyJBC zkVLO;jmd2^=z70FGQ;l#-v|N`Sq5ZNtj|@UL+vvr3B$$>c+~!s>0_I>>!`h-Bc6yJ zmPKhlMZ0QcmT9!0N&Z8*GksROM2t;ZZB=O5$ZzCW8Y-g1yQ!7L8zM7}`csUHDQ*@v z(5vQi?24-y{@7%VA3$^1be@5x(J|WP#Cu(}%FD4d#}<{J|DyV!H`)u$ zMer5O!myP|_f?n4|M~UgkKwwyl`OJ5nJ@-1VB?K>W#ze z8#s&k+*=i?4t`AO7$kgHFdozaUX6&hsq|Ps_Os7f_0AVQJXWbEruW38usN@oMVmbu zVcT&tDs7fovw!>NkxLaUr|Jasa4HAnfD!XtV zI2gn$YT9gT+78v_&^7@+6G3w+X=u}Ay(FiN@sscA(>Ri50+b;Jg=v=Exuub|eLG(E zvo4J^2V042^;^JOb3c696n+%Uy)?$3JQz6TbJdk08iZuZ!9WCZ8agR9@{j4rY%&1a zBd5UtqFD7V~HwSWrfXr2g8k7-{`SD=lWU8ZR7KHs8bH8dd{2!0Q z#)kxcl^51G5BwLfH94T4NKDi+~Z-3^{s#d6;kn!Xh*h_92H0Vg2 zN6J1i9E4U~a?lM9Q@=M27%GamMJ#5?b-?w&JY?1o+5%&b9Gwr9xTK@K3!jIVs@S2e zRY&H4Dm2&JE`Ar2Lq3$w?2sZKq%YYfl_jpaBpVXrBaUZP-|bi%ooS2r^F&VL{Jy68 zQeET8Xi)1wOC(SkOoWLC*@`C*x^)1RENe})oY3G;tlC`kwK2?Y5VG;XzA@^btuSzZ zYSc{3mfYy~NeAKjV!mu3)UAN3bGBuN)e-cYMt2g^1aI_jl~mNW^inb#0hae;^oyR& z`or;}=2OYJ4n%<|Q)_`L9s#t$Z0(uhOeWE_+PwDGs1 zX)_B}X9AajjhURfj^pQ}f=N2V3R@BSk5{o~!RWL_-u!6?9?YWJx`XgeM8{?4NjB;93fZ5!KEKgXkG&*< z53iE22&Du+?pUjNsRDc>LX=+GzBlD<;1OUM5NrJy41Ju`SGi+}LIv3xTYqpVSV48En5vI?D6}Z`wgT+T}4~~ueAP{1E zJ=Z>({S9btXnf{X|L!G@k=JF|YXjC5vMdnMq_P>$R<$(}XBn>Nv&lN!VdpWuxz@b> zn7i;ccm%yLziA?I%+uNmoHdip0{V} z*x4U1;i)%{7o!d9_B5w4%bou&vRKj2WIH*kBVZhw~s~Z=DC0aC4z9?IDkm)}SxCtX{ldzSTFp27n$fhd0rr1>2 zL2{fg$t(o_R|NC=idXv!>u-BF?Qs|URG+8gRrFkU0t)CJ#Uslt$RphF!1EP=BZe6_~L{=+ppi%OABe#x~ zz|kOn=ifIIP#Vr?mH;>sfio>xpNoVgV+I?Kg~b1b{xS(#RnPGug0n36miSfRAFtm# zVl`L$^CVy+d2i0M|2|jDz12t_9Qj8X(^y^sZqs?ElPlPMu^n; z`y*#&Rb_RqnC%FpsIY8Tfpkni=MK}^$=M4_OyD=Ltc3!HoU_QlSb?}L(t^b+QDb!mFu{3V75T3cC3RGuB{#dTW2-vWc$@+x@ceosr^&P6e#4Q? z8ITl|bw2XUn;>oxPb^*htNR|&}!u=AHR&w*}s{EDzhmR0;U zhg$H!j!ws9t(t6m5W==UaU*`UZ9ri?;4$*KFlUiws}d#oHuH9Kn?7`LgIJh{LPzI~ z{M45JQe`Ok$^Knf91?>&^ChAcv9_sqnGg0o-tQito#^Zb^5@F-Y;KaDpOXTWgOk#2 zyr16@tB*N!K*Mk8can{6$DE-v8VFyq*YtCqukah(<7z(|JbmTG`E5gf%40dtK{ym2 zSfs-GMtwtLllN~-Og>G3b?y85*?${Qb${AFdsls&WM4xXAubNR-UlAz#?MSYR0G^Q zx(-68RJ#DLYJ`;+>NA=)gM=6f33()6>D$G&fK%o%tByTxlkJY)`pU7L69w%{W8Xs0 zzz@EiKZs+rE~YuIiqft2L9~-&D|y>og;afLedR!g6Vw%uhzV*x*D=ouEhH_=!D!U+ zwlF8pKg&>QVES`ATXn$p!zLL21-VzTkp8l5l*v?>yzOg4>kpE}sPCje#e*@VdL=0FpS zRWg+b1~^xfVn)`eeY=120 z_zCG88{SM~PSQ4MXJ8X(bC8se3DEA&Z=|bJE)d~4r|Yq&vEEmhOQfKw0V>h&eG}L& z5{>`eqhqH8FiS@A*V+DJuYS_2aNV=Br`rPi(Fbklcy!{Mq{pT5>U_F>TsbFa0wI5| z$xlu`^EjpdB)X&j=)cA!MjY62djmMlfzUWVGbNL~?I&7n>lB|-Qn}fdGYjsI8IU%$ zsb`9knaKycM(NXU`ne8tcz%>$zHgxa*?BMs;g#S6L2*2|{Oy%sib$LKy6gY6^$xB2 zDpp2d&|H#OwKmZyXXuW;?R;($LmI2P-74^N~UD zpsg2iXbv@&XtLE(0$m(aG8N9p`0-~B#m8!bu4{@Zit!u#vFhXd)}b-xn17gqCTby2 zRw+rTa#pCB2SEG8*@10@QU>KMeGkl-W7a*rEu6y`6Q)>sJg1GT+vqf87p=x<>3&K)Fe_@zkYW^Bdqtdr7r zS*+txoQ4SDCQD!;@Z$pqIyREIJ-{~GI?=WX7W%(dj8PqV!9s%N@ui_~l=Z&?+p{P2 z3Tv{h@cUc|b{=JFTU8Q)=wIe*J%*?b@%&k`N#GntJ1ofyI7*~f=qJGMPE#fsBrn?< zuq>NO>i34!veogbN~!Z#Kdw*#wSps7kxFBtuX6g!6(jP4KEE>^9F}7dtzm7x0&b5> zwcz2p$yjD>Ymn87_?=~>zX^n10@Y>Ww2%!nppBr;j{&#D1U$3ZU$)6Ny2bMRx(ssh zbv>_Lw-=9kdM`limsYum7A^V+#$z&rFK4P6lWq7Z+Ehx4l((Q7gFkkUg9hbZ)TT&e ziY2WdcfrnETf1#FK@;YeD*sHAVnz}0&Uxd){8?1wAlKZl6BVp8G#NApw>q?l=eo;f zv0_B*i}a+KBnb=aSgsd3){%91auS0=8G}p!h`AB3bg=V`Tb&8M!~Xy?!R?oKV3aB}X|SdqWT z4*%9=qR;3hgCpsOidrMKNAQyTNLYzV4Wl;&-(*vj+#GOfq87Dao)dEE$zU9$dEY#4 zCa9S%*8$j=qANV0`{y3$^P2==5pG`nDeSQTRO~<^P3iAAGzn1mUi293(RBcU&H!jw z-ab9$Ty9mMs^4?l>`Xb#i0K=FwxU06H=PS0t=g_K^V8HOTbw)A`Ot=fbwR+y_f)+K z+;7=)oK+5BLkqjbbKvL>XPS=~2~wa7h+udLZZAzCu{p}E#3WbiD?gTHuPZB%NTRpIby92)nnR#hr2Q?wIYti9J_-m7?#-IUWVr7d{WkmJSczwZ^m?%|)!GQB zoyG7M`!-Rbne16-U4|h{!T)*$(SCjVQ@8tZPTL7}1dl7ygePwZqxqby5!Xm-I8$u0 zgq!qAyIG0&W$1ld8sbIf_jd-c-3{J{7Y(|hz)0y30P8x=t$iCE%T)X6 z7+Z$y{5SW{&G_wOttMjvL6e?j9JHG7y?~)3O)CQI^tYU+4$LHbmqr89sqI4zVq?sz ztF3ddt!&Q6%&9pFZ0}W7Xp#$tP1^%ivnZVB%09^Bm} zxVr~;2=0FS{i^OiiW-_`*mKU>YoC5riM+gHeJZjH_P<+29TuLSEsNNHzA2(7CVjzV zE@-7@fkI?K!CgnfJ$@U*Xs4-~JBNF2sETRez_En6x-DH7X(g5-?st$CM?=>NXk&cVXBfox(B zVQ<{5G@#g-Z$=v=g1`E&%QQqCJLR}4bCkojAmH{UeF{B*>9)91B;`D`8K11LOy^%~ z?HC8QD-VOq%G3F3tzex53aNr*d8j)WEsXL|*rP@kkg!$8%sVYbZu`^h^5Clcn!aM^ zD~RNZfk0pvlw~x8qbd zf1Dwgcf_Fds+39(`W=+llEqG6cye=Es_bB-5`Hs{2rYTKvP7o1bpFg)+=G(It}#lQ zCp(1i;hQOjm~7$679*3kTUX(owm0I87*z;aYU%zBF6Q z*)?i0aT7hajD|*VQ$JFhIJe{J(!cD4?`f0`pCW*jr z{yC9)Y*jYn^X7bn?ezN+%kJ=g3yM%A|42vT9+zsbf4jALz#giTT%C_qg-kil?l`NM zqYC-?gH=5feX;_QJdOLm=7k2Kvc*{p%CAdmGY`_sAC}$<7~4~yoFS(KKzMdwHSQ4e^CUhaW7U*1b4MkqnzmI$(n*B^$5iYl22M%rKhF+q%H@0qooj zhiQ65h&+0Nci~iB`bEdIk7^QUSf@}kxh4(}ikq$EZ{5vgaE)-1(K5Sk=B=a_2jdSl zDbg;5+?#Ilzh}*W0Tr16Q!$FC6)G)M8j+YXX;U2rXs2A{kC~C-&(L9S<($0ZjjG?r zIryRer|Kov?n?WiAtYCvp$T&b`wiZFg-%aO1kYG4XmvGYfm7hw&y`ML>CBmUPW_~R zGklt(4iR%@rAXzcG^NQ{XTk#~I8#42V}vy5&AXx9@NN^M7;o!=;f2Qh4}{?0P1g3B zMBxMH!>F1hi9r61!^{2V6x3AuXOyfCSzff_q<^@hJ+e{t-J9biEcK}^(=HBH-{OHo24}GGBrCBKxj~r_CM;b14v#w=4v9zZok3A?ec(bilFXZG2-I-NN|wn9gk~@T+_Fq@`xoN!tr$XTfd_@ z@VbpCx+UT=bm|yx_Xv{%d7rp@n2$U}aD|G3$5yIa{^kGpV0q2Xi`8Rk7lB)GGiY&N z5bt03@GYX~P5lVA)wbS_0+fkg% zC^R#oOt80+;EB%-B$A;!$P&2?ah2soB^kc?zL-LC{)(*`XktsMDUi-5DWA3(vuuvV zINEo~1uc%19-E$PYAmq-rd(uxma@>n#9K%4G~2JPFk@`eaLJY-cp{X0-@QV2|wj<#?0w6dc}4 z6etc&y4&6eii2(d*fcE^Dh-WMM~efkT!;l`%ljKmku!>kJcM!p67&tAJ^PHew^Qt5E$3Qby>ke^ByC= zi~I^D=-V>N>-Sm`zpA0zcra3&cV;}J4yP4WQvaa?Rh}ls_=lUgZdfQ4KY?D81iP@u zX7riqJialAB3(C8{KK+E6LKZd9R?g3*X7G$s2`IATAJ#LSi4&8J?%pVP4=Y}Iqusg)j2|Yfr2tG#rwPy@n(7B^nIx5tY z&5hH>1RJ@F39XAFTdN(9wOaZWXVGMtjU^~I$LyL-Y2$3q{a?Gr4s%|U(6O_2DwS!< z!5`Yr;|HR0D`kdp_cE_uXeX0LS!Fe6WRrFqp55hBX)j#Z$OJ*9(VYi7v%Cqu4(B>w zL{<1J=M_J5;(*SRhCl}Gpz_iN|FKQCAX2kO78R9SZsQTU^7D9YMNytH_cW~SK}ka* zdOzFVf-?N_Z7ww8_>PjmML(ux!*Hdg!vv_{Uk5%5N5^`ts{@ZecIm8rMi+K?Rj~^u z!hPID3$n`wQVtX+zgPRsW1iJLuC#cNw3%*nv(~<_kP)j~{}7WSK%$DBp-Wds{eIrw z>c0?>FxQb9LC^jrn%`+byn*Ca3FQ)>IDYc8Olw2I^R#S4NQ5Uy?R^KivSN$1ar&kv z*-jh8hgV*0D^c&PsVD%P&sElRJ%xOo$Uv|@A$YA28*P>Xnb-ds0v<2AFoZ>lUtYSe zU=o5s(hqoZVv~oO<6hF$x7Wsve3b7!wJlJw@^d?kf~(NsFpJBTlRjR|)aq)r;VF6;h0$4-M()JuA9UbredcKlv1 zzpH73-p_^XdcUr=ZZ1bco)h^i*RnwkO~1C82ftJM7oQF1KPol(UqeQ3P^81)1ue_8 zu?iM{R4H3wX7M@|sGTwW-Dfpnue=C}XXef*3+pV$Un|B`;>mkV$t(-RO2z4$cH~Vi zUJ3DlLC4pgJPq-OIn?34Qi1~Stl8DUk{iEH z#)j(Htrz0wTK@oj!~Mb9JnJ|FnJ?Hser3-*E*_}@5F?7t4d34X@lC$=v~5QgOfO#p zq*b5Su-Qjs8DvmIg;G14;bx=O3n?+5oRm-77aetf%bUO3oOjYn-*bj3`wL|~u^+4ZT(WdDpUUSx`8S4!gZ?<;(IMU7%IHP3~a#0bx<&$oW zl50EYOsi5Fe7jxnWTA;c)`5bFafDbRq^WdhP;QkNwopNvr#s~0Ci`?QPW2l_EP%$@ zU`xF{1kH5Ql19TVew!?MvZKUt=C>ni{|>{a4>glGnCL7 z)rna#zm<=^H`LPZcR63l?R#k`LH!ko+smA5&;) zjwiNb=RW9BUA6(npjtYNzht8wWJa^}P>ecoQC{;`hH`vWCU(v}_&*E1%zhiI<33L$ z_KyjuW6TYydIkyPM7x^J4Lq#z1aW@hji+ssg8%T;?C?`Rn|94?Y=U8A)p&s4N8?sn z2wv#a>4@`=58Sk1)sm$eD&-#S__{xVGHr~1JA0qp^Uj=H}{`)EUnle;{ z#kFEA`C-HKv18(6s1W}QI+!KW`blFnD|$b$&AMJB{MFerOO+G5-zg|eg7FcWbqO~z z(TjREeQ?<8{FCG}V#4J8dIp=H+vn|om6VCs?Ylo-hnyB09uw{3tl36N=ACsm0x#`@ z0FTXoG+bIsTMMK6mgy-NR&G-6p~?4M*UjyL%PWkUDjWolaWQh)T8TS~y-9zE3ZwB^ z2wsu6GR-|p?D9v!bFH(rT2lW@-EjIpTB{8Oc~eqV@7xIbj~$U~@pPR#Ta0(9O}AFo z8sW4YCz}nEaspj+NJ+g=ZPee4raE1^eg%nn9rEY~_$t0zd2trjnp^G247Vy;T9v&8 z*u@UIod#CL17Vv;B7NJc1m3IdL)&UGhjt(V6UTpPYK!v+ejsa~k8q(rLxa!C8zCq} zxOYSmgJu%ojJvHW>jpuR)^D$6KC~0z5X(dO6rai>-R z1d%*c5NECDCbEz(BB6<5XX!_Oxx$!;$rvgr$UGcBF8#G%OgoKf!b0L3iI zmEe&cK3FMY9@MfObW)}-S4jwL$Y-;AY&_m8z7pNMer%yT9#9Qiheh8a;2m8eWrJxy zLF}{Mo6~l9ZQ*4mcO%0ncPsSfaX8`Vf-La-O#QZ*W$JN2;Qqh6y+R`Q$F+2Vmkw2L zi%np>;>Jm)Lo#2>w{a+}q4gV{xt40eFU<*R4_2{(l0I8J+R5&T zUa;ezj>76v;u-EJVheFW#kfMOAbC)3!LLBMPyO=B%-xt(IMa`*t1DkXycbD`KWckd zChgD*Faj+jJu@DcplH}Hc$a>Ho{qnpFSTl<9 zVK7Ebd7Hzrgx_na;%$8Dv-6>erP}b~{|K*v56#}m**&u{TqG+RAi4i~KN=1YZP&pd z*#j(8P8d)5<#1L2P~`P|M|3aXlhXi3kDoN%EZ9`*%N}s#q~pVbIOu{`TGw-;V^nJp z`<~R3U{BmnH*Yo@G+?W&Hb!%w*JqFZ9TaoU&yl8o6j`{O>FM1%%#kg)K&gc0_I+BK z?|)dd{BiI3{$J=#xZ&c)H6DPeX8eHPQPnVra>dJ> zX+TeWmNo18vn(H5gX8gfd9 z5HdNCO~HaBr)r0FaicYyE}xBK4p`Q$p)#OaFS!C?_y$oO;V9g{@7Rm`7kx1_q9Od^ z3=VG&806W}PbWk}ej*}Jb!32+?~J(-wV*bYWet9?`!u=aOb2;|dM ztO(fMQSNkuHyb#CTkCaKE<^XM4;n4)GoRAa^UBvHxW<_nQdN-XYTPBuxw^EX^{9+L zRT!$j`Cr$M5exWH zqhKIfa_oP?+# z{4CkYW`3QW?JG>d-s>YgURy~zKwx?V#fm>( z10@^#oNEgTQZF$L**v&w_^LAT8N08PGX3c;&lcffH&8!E=X{kh28|xBnc?V402(s9 zCM;@t#Yg!GVc?lfm^_EOvJ6AEy;ZUa>m%o=a?;mT;N8kM!J&G|P@x3l+Y6JrK`~lH zH1t{-ial~v2Egma$ANI_J<#{({oOLOba0jL|1eHxIwSG9qlDgZ%o-tJeZeaggOqK4 z&vcgen4lC5X#~Vy*L=^);{r0nm|PA`C1^bHkID58Xv)7$x&HJDlJcWg0q%wXFB=vz zCXV3LN1;mT0v40j6y9VZI7ly^bp*UIoIn%rFoZNde!6M={W@qBK`oP|xD?|y!1P;CV_8a=0R&)%Q7>AmVeHrdF5A5d#3C#9>F*{9K>Lv3s=UZ%bm!wt^kjU(C(JAq{>+nG@_*HOf1f( zCfJVU3~r0;ol{*Fyh+0U@FmdG1jB?5{!a5(Pt6=n($kU&9%#C5-99=({EtovAeC)w zZTz4&7BjrV&X-GA-^UlEtn91{lK02+hnFK3e%~h~pzz)v7D*ne0JZsS|9al_{t@_= zjqTaRqV8LmG1unX7CiBPq*A+R;HvXY4Z`7o7wL#XfKyl3=ezBU0Y5b z16}WfT{}C2at{}8&^7>Z_4L`*>+yiAu3DnbMt4q)1 z?&0;v-AeN56%5q22SD_yCZ+8NR(mli3%SJJfWwo2$_PmVNNN zxwEzB(dkbz!}~9QMB46oPfQ}YvXYuazLej)cS@qC+2L3b<&44)=9TA{U!b^paYhX) zo4(Q4)-DVjqgfGPfIR1_2Z%MzY|}^)#fW)d#;URUNiA;#1h3PV$v7tze0Z#uSsCpV z+WsE;6Nw_I(7&EBRq+>YBrw5$2~uED6MG}(LCy)l3`$8hraFoklK23Tr=U^FpS=~T zL;6Wc3Dqhnj5yO6P@b-RR@5){EvD`V`_p`ZEp>a7(`&dm^QQM$*QH@P*l_H{IoqAd zgm(Ws>^djgRUp{ij!u-(HdFv)E;{et*pc5W)(9d#p~$r-XFN6o80JrT+TD9BJ6Q#1 zj@fOk3Vv%-Dy_&a6-6=jvKbv;GB68kyWB*78+P)_Me2YHpk?eXuzfsh814<$aAxD4 z1{zpMggbxZ=fZ-HuJ(2f`TuC049%X`g=lT;0QCF68f~p~yT2pi z_j&m#m(6dej;p1ky>t8oC*T8Qu)ez1-$tNi^Y7UDzVfVhdc()U+GekB-!uHLy65+B z0FGer{kgzqmG3LT`rnRI$@#~hK{(OT0yT{|B>Y}|48D)BKqda!yM%IWvbFPc4EObZ z{&;roySaS@l`Y^sP}TJYZM)G9OX}w68tnVF=)1kO1unWq(h4)`s!`wO28@JoCm^YPU6=Q+7#b0(dD-3(6i{!~%lun@s2W;31ciS#(HC`$X zjkElUM4!DS{DL>kf8s+u7Aid|`xrEczdxi@cMr5A$m}2HS9;M@d~cNFg?D+Y$^Lp* zJUjpUx^TF%%+T?6NHoT#5Q*6GImL@(9J|V-kKI#x+n&pk)?Z#;y<(+r&As}2;xp1n zS7WHE&|H4n7WQ)5B$BE~#bjPyru!_?Rao#!vP2TCCG2-wwMjP+oCxZY1kxeCPNqJW z-S6d2BpEMv1SeQG$qOstR1byg{;k|&-X>c z#J7?UTM`SzI*i!4bSqf@Y(9_M5WRL+!5TK^V@o?di!Pj6Jj?rY$Z*0j`}sD=v+PSO z^^7x7b?X_9t?dCJdap}d z558|3?*s?eEbCpK(XO@|Tc_zwW`bP`PryF4*wwi?cyAlixZMj-j0daU^HqO=%TNB> za2ABFZrK3jdxY=HlYpJ4XZX$~)Jf|$@WViC0QmiF^1Zv3?qT$xYjYsas@cy&$es|I=mjh7a;ksVm2z-D)Z)Kvn^T|(_|2@Ffc75yk z_yYS16aa`W_02TJ$cHkg;j4(r3mc2?m?1IZ2Nn zS{iDbnKf~_u{qEBA!wIGs+N(Z#i66-i~Tf9{S{TO=t^q|GeO(aYK+ z-{U~!Q2rqdP$2!|MP9d+BqOe;e1^=ckXe_T!;UA0DKH>7u~Ia&Q0U0Hd{p<~mdLoZDRW90RN z_M-nip9dU}dB)qpn|sa!u8oJ$M?c=1$2H>=P>HX9sN`!z1&BU>IyC4H=^VF0(x<2*ipnM=P}H6QiruCY zFDN4S+yh&b><+u2=B~@;&jiO9whMNnLIiBh80{4Qs1)*vo;}@>p;!5Bh5rK+%y zEB697^0&?L(Q!pb?+u5^#@(LJm4tOA=N60btn{MWHh43Xh70CZmHCEx0FKqx=oq?6f-`FsQMHRPrQ zdnySyV`?ha-?KZcyjcG#>1E-~>rZU-cGiIJwAH^5xuc;WL+zRoIS`}mvcE;FyS6BE zZ?-;Ord-*;HPdlyk+fP>lW4RuFs6}xhve_oD4kt;^IVtv40qIqT~ zB+1jcuH5R0h^*OsTmKyZ$H7!EEdOLm{`qRjHV|9YZ-{u%9@#IS4(aa|RNE=)yh%7^ z=x8ZBcxYj4@{=A7`1nL1jrYqH7&&OSyQIYl*|*a21E zp)+;M$9_Wphf!Er)hr#ZX%&aC`_XG)&!NYOyVs#+PTDqV5Sq%obn(P-qMtux{(}N` zKAm)dU_qkA8`Hz*>=fmmJ<`=6_) zLCK4z)ui1v&wk@3)c3_9qtzu8AS^>9Ig3Ym=C*O5V;Y zL-Gwfe?q}?=>9SMo-Xb9mU?m$0zN?1{;GTyWANpcDKId1k?a&a(9MiL!%(Mc z6OeF>1dW4!ZYz{1_={DUZcRQ6#5++E(pguDRyk*=hWJo{;tF`7ItE!7UH74RETy6U` zzyvMij|cyz7D@iyzMOdv$Ab0qj7j~WcB|1)UQfihumn`h+kAwD7Mb)9foP5ja~_A@ z!LaKknIxp`i9P9(V;D&)kngM3DhEWoo|DbbR-V3fh^Q{6dujBA=mlmX#?>^xp? z4~S*+`0qO;?`UtYt6e|_)4M-#7&6-T4f;P-9~_M4=H}L|U%iPs_y1=Bx&|*Vz9e13 zfPp;8*2-^5#t&DINPignZXJ5)ySBZ!D8k6KCjQHdetA8AM{;R{XJqtx-Qjq7I@hgP zJ(FK#>*Vw9{qMqFZha0df3B~wI=$b6mT!JCA_Jb)&&HFO!q3FS0;C@(0L}ly`B&bT zX;-2v;jz|Ylz|8)FWNbd;~jNWg^(Z?Bvgpne@W z1r4r2Z`1sF^b%fluI<2VT18P--{-yR@? ziZRvbiB?`ciA+*y%!I|sJG#)kUf6NzP311;uFPT#He$F+#GbiwZ(n1ePm$5R;-gE&C97ib$bmU`i=@S+j z0{txD7fvolh=6rKi(JlN?v!rx`ve(Hb5li~Zd?OwvM3yc0P{&vs%#TMxJjaCY62md|D3foGo&|O-Q;PIURqr#%7N}#dQ}D7 z3TvSJKIJ!Vo(y-q%|(=mYKz^8>NHas-N=HlEjCcC6t8S_W6(H;LR5>2+A^`Uq_NXx zwZ^`KzVNJbj3D<%m)6v5pi48`EJ-qLuZ>lry1D6jeY7>{opI`_#bvFRaGjg}Q8EDD zMSNB49U>72bG=Ncj~!S8QouIo@`W|*-gw_Rzlu+2%!KFsL)s1e@4KIgyWXy|x21(N z`h?-g=I&oZZN3Q541Q|JjfkKyUg&{QL;i#A|MzBv9Ysess|U=>@6+Au61-;+N{Yak zJvr@6V#iAy^O0+%vfrhy*XG`jQWAqP=+P@`_VH)0esrTXQ|83QPPA{oK}}400iMY$ zUKsh{=qM8BN4zH>rnz6s2%vW1_&i)eS2od()S|PXj^ePdW@_PM`&DqbxqJ5jwXD2I z4tczRyYgRW@(6~qVnoID!Lnx(ej4_@{8P3E3%SbJ#yGEm;THBUM zk{TfZ6yA@?P&LU(9BtNyYo4}!eL%vP&4510lqX@uSb21aqad=qC$y)>LVUwTPbusXZ_4&@O;D#&8UQ89Ad1&u{#FZydhrH|OZmp^FS zk*SD`ZF4NUuUVtH=Lj^Oq1^?W@JhT~((_r4s6Vp_Tde`P^v>HANwfaYD%UWjV@mt} zUcMx_3V+XSmQTu>(Q3(0bMoh3FtRNNb9+KI!Lw#GQqj17oqYhK*jo+@dPe8&99NDE zo-Y))b};+C1e**oqiL;S0dd~-$kvp;J|PDN3yKs-{B4K(3|`~@_V#Nw#qAQfj6^Vk z!z`J^j1l~@fR$azGOyamE6xQ0=hb@1WLJ7c{K80`e(RLxC>i{S4GB2CPd(_gHhATB zZ_<{&v}!%qKA$F-op}EVj$QOTJvGbQmDPq`o?TZm@(h}Vh_aeBuT}*>(ut*mPw$;h zlU}`oUpBmYeWp2{ohBEi4!%ydZr}|#ORh)%L0G2jWlcATGX|=7aTP~SZo_`m)@*g5 zJ~wUGz{H_#Z3}Qx;w7cnIm4Zg$*&n||8$@>CYsjMIyg1#<7@ z6F=cz!|q?hQelD3@@WQ~*uUxEa%M#pk6;2XY0)~uvJ4~@nPqL>(oMJ>XzN`@9;vJvuO+i6H1d4Wj?t zdI%{ihvY$4$5G9>r=R|~N4J{WwS>H4k3M3~N(Rv&^z4}r0;{u(;!l0jdqSsLp7Lq) zPiS6-<1nKpkCWZ=YWyL`41 zR;IS_%XYFXCj5GW;4juA>cp^3ys?R(!R<{fsj@7l+4^N~6bHHK5|uorZchocS100m zGAu?G3T5h(@^$4?TE3dy++r$jVRepO`FsTH1mp)Q!=PRh?b)7RV`@1ZI*5Gobpl$x z6w4M`Dw7pq!2{+)W!~zk_R+JK4Ae~Rv)yxzJbjfkMDb^K*GY4Y#T5aaX^=!TRu_IxPMQ2#~Mb6rz8~K_A_OCYQODIyMGqfql z)ol6OBHAh={wWB26ZMEKO}N?VtU)j(9wogJiepOymrRs41kdM{ zGmL1=dH#Mq&pu*ePh6p**JV!XrRf_)%kG9?f#hu*)=2I`;$hlOCzd?zjtn5(eNA^H z0$zQ9l#nfo7h{z2ZAohGG5$O4_>7nQHgQNE%=x#Z^b0Us+B!NSsFj4ng>mzCAD~MI zgnSRA_tvG~=RK_RJVsVgME_^!`5w3DrE|vhUPL<`usRYG6US10zI)~azKDSZr#1HP zdGrAC-J(Tov4NBDUX5N=ZEPPDxTM$aE4pzXvI8T8o@1@~Lm6>^8R#y6nbY-NK%qI~ zcp*Z!U1vr$>Vh7fC~eoyMm-AqD$yTC5q0I_bg_(_F)kpSyHmgy{y;EE=GJ7tuo7dC zX{5IM%6e6gUzb!8bJ}Ls?1DSB0O(!p;#mltXZ>Ko zf2O!bUG%gNhN#~(!H-!4tHys5dnmAVOL~myPN&utDEB75wC=(pVET@w_R5ZliT%g&loz~cc;dBTD z2TLG~hCGM%{P6RdQ3+AnVks8~^5?8#fge-AeeJJC$xQVtdXx1>QXB22!_Y8?v|EgA zDXFoFISUZ1WYpP&e}SIukV1MXfKq5HAYtD&cL-*ag5Z-!MBEFVN{NdHpwe5MX?L2e z5XLaQG5^zfsnWVryFNMEjOra+bs)dxe07p5@RH$6$C)&4Oys? z6^CYM@Q=*x+0M;wqqMD^0{}k`co2Ag56t0GRJWkMl7Ay#m7u9kkj7Y8P(@z789McV zq#?Tj3}Xer`>0vt^CJ_~`3tO{t;y6z@rXd2A7EGjOS;RjY~U0iG?uNX*muu#V<{1V zr5=z1X{~@T-*RcoEB*4rIkdGjR)!f6xAoTH*Ly$Phb~_qdA2zRfLD058L(#>Hbiu* zA>hJrW=<9#;{#rb8#g&J6mZXUAEm2FV|3wK$!Q#ZD2EnS_EYWkV6v_m*?o4?R=MUmoPr-+BH4)Ed=iUw-9}A34&J3mM z{Yx;HvL{y=zu**cc-(RDp<4bj1r!YP?aI|MXt+c?^Qt%-sXq~suQmE-8f&O)LFba) zDCBGDLNA`+69yDqj8gPDEPIj`X(l=c?_u1WTL8*Jdb9|$2s>PR8}~NS+<1AoyZBns zod$>G`r?yRP%vzQF&=k@w^wWOsou8eLqRb$>8|h7AJB1?=#@?b3BY!fz&q1>KbnKZ@sdGu+06fR zbuM0RepcGvCf7F$UXh#WRXb&r@I#>w5ha3e!{hcVPhce~q_Ewig4cjS(Hl5@96G=+ zmk#OWxyg6y=#dBL{%qW%G<|w?eO~~C78=mt`FcGQO)|(8ALEp^{&}k&Wbl0j=2KoG zX@#7bQQLi+qj=VK??H5SwT^W+xcyAm zp7S5D8UqF?u=WEIx$RUIUITD{GA5|!fCjZ$pGjA*W8I)*h%dSkpk3`lIXB!K6EE=1a`>+sHxmu}2_%0*JUQ9>GNSWD; z&*pE~4Cgn9JqNWrgP_Q31n*UCB!?2xPhtKoQh(N!z3Qu70lIt2Wsb0ek?14kZ5&CV zs8WUdS%j+CI#WBRe?}6_S!kn#h{DXw?$&2k3}43TY^ z_45e^`^FjDl=OJAaEi<7eSnl=*(-q~vHdfeF1kTR)d`o?YmjUV+hV`4Z1*LA8@Pj=4zBFJ?78RZA3Hiy92JQL84NmzUn?lyMP0Lcz2GiFlaj zKu+Dw>%mF2ueb)+b`WyF{4ZhwPP^eOuEHun$YiM1zN)kW4!Zw~i>Sh`SsUufZ%#q_ z^zRsO80dDC_5q{_mxK2LF_eh>zK;R@UZI?_-K6ZVvUQ)cvb*wWkSNWAmYst7t;hoT zqF7~&vy*`a1!yW{lylD|41&Jd;1}*J%@|lygVPG~o<3|VC7j{C)-xRtb&F~4cZ9N0 z?kk-s!RJJ2&k-bTMX>#ea=DLq_`>z(Nra3M^g~mHFOD6W?$%Mvm)e1%S97#CL!(@T z!ej|bA($K)RnQLRcRq&$lT7I1;YNqkvgdjAeTQMlPug!M(**v0mPZNp%ErN|Sjn7= zu)}TGLmpXO_a5;+2d%3lBQ+9f1B7_yFGMJ%Bya^$9XkQpO-=_L^(}fcaU4VvhPNBJ zhB2oUU8#b>xECFkO+qi_;+a7-J}LH2byP0TV|kdUzHh(i4Tb!q(|)sH8f_dov@vHx z=NKn!wX^7~vn17fc`@Hlae*o+Y&%^gW0aeszfQ9F!t&e6Nzah_^;FEGK+bjkKAQ76 z#8g~m*)B(ePXnomF;heXiRd=6doFZc4=D`1NJw1tbxks-xryE-P1&5N6#q`$n+NzU zcqV(%nj7SB!y+XFtsik5pLkifcvQXTIbjm76pH$cPTv#SXOM;&?uzDz!t{PA9cM2#wa2*ha3T_ z+K(*C;5!vw9wX)xCE7Knbr{M*JS)zqJGmsw$KZg_zyS{!p%6xF_&ECsz%c}_y2@_w z>a%guD9p7w=bY!Y+GxNav_TPu^;4fxxU@6=fRYs$3dzipB18A-)3LlbylXP7Y->?+ zLtxO5x6c`od!S65u{zA!EyluHB?EX#{sq*I+z$rxVvIxCqGyf!hrVIf_ik~HNCKNChAeI-@G?G^BSlx zsP?j3u8+dxv?yVad`#+Frn>s505st^GXIgp1Tv8|XcEIzFF~*MaRTGs#C!A?q$jN+ z-i*)oMMy3BW4WsmBUF18J}S;`0TS*TAmtJ+3evu0jDj5z0z{Mh-!J`Nb581rI%&H+ zU&UBLEdHUG#IX|9%3jU(DCFc!Qt~Bu15l~t)#xDE*;W9Sna4aCA>;TI`ci2!=(WhF zDSDd-`vD!IeZ|Efoz%%*P*S~$qgCq%a*}GB2U%qwL#d0qHs6pUPvs2YpG2K0cpT9M zJ*@$GA?7Ot)gzW8aJ_LyBpFjoyPB~7?i_^J9QU)?YNgPu5WQy`bms=~NR009m;O~$ zbwPsahz8)&-ebAA72oYbV=-FzPB;NZkM_AHqQGJnv7GqJ-LowqMC~d0$<#U60BacN z-fFbI5_K?jmC5{0KEZR=&aEVf^#8udQp^JBWB!7+={Jpp%P68B0$6e5CgFPyk)(RX zmX|o$rInXgS3h?yK@*-{FBg&-NOm1zGIc_JwO=k@j{ss81K^PN88P=9If(!(V;nXz za`Gr1AU*Wx1lfmU3h|%@rW)yID1}y?IWzmOa%qq+A1*Kyu(trGTt-PmA*8jkTA+Q~ zjbEQjmt00s>zX!?Mi@I>?pM|XH7}p9IOOM0Z3!I#GVpxd3_!vH0}2F!g#3DHM>^U5ggIB{=3&$`;#^+|>?M)a*mGm1bEG@=KgM@mH? zK|r$z0v(KsPGQ0TJ4LP*KIK2y>5nGN|HPsxlMIYPlN)avQGOeyKcT^u5|pZwh%u$* zdxH!$HV%2Dr17B5Cu`V7x)`Y*ey2#)8edEOBh3Ak6dNM+hl7k^=bmkNCKDA)AcEix zlH0S~ih{CvuA9Vj`$PY%(=*8rY`5^b4jYZVi#A;QleZxDS%>AGrkf}f9k%4J$Gf)I z%&40lHQi;$PLeS`shz{5YX0*WtLR>LWbO^?3IEP6_3syYL}bBA+&cTKyS5$Bkw5ga}T3Ku5gmr2Ml`%-V6@P zeo8C#3+}5#d-@VI?i&lREd0TweHAiI;^TN69^`+4pg(`75x77NlW&kBoMn;GK?A2| zm&qn0i9j@^z%7|qnE_FBnBvk85llwvaKPB}1K?qh$tZcbz4&oPm1B$*bV$r1c6vP` zJyi}qdSUP~)|8Wj5*uK*kfqbaw@L7Now)l=I{KZszh&vh9B^dB!-%SHKF^YU}G4x-P3ot1NK2*wJ5HkSuRh=zSV=~HQ` zFt=pvf$EKQ_~{*3ogJ2R;kc{4lXR5Xe&zDj;Ci+Q## z=|n1J4G%YFu{m_o34ogd8L6E+K>A+yIRNlXlH7cJ;^2QdH}9#|j9Ih*!_@v;57Ajx ziP;|+pJHUETo{jsWtA<&r59NYJKeJDbo-pC*|EB{KZPcgrsOFDuo_tq_M%e7l`t*19JxRyjbC&cM zy)gWs+-l+JlDF-byvdD%`3_ruc(l8`S2Fm>nU5AehfUajJBd1)D@detZ_KBX=BaPF z{Dvd6oHwxbjqY)piu+>JUx}&xLp4~e5n;LYuYx8#7~on-G_Gw2N^06V^|~jtmU-1$ zHSRq!xj!zYOtFkkl4UreY_jKzbNw8LY!z(_< zBqTh!{QyeFyEX-O_i+#UE?wIPRo!UGZ5ZHGp^!TE!|kGO!#MQ8ra$ycC<$}x=&++Q zL?RyWK0X27M{6T)x_Pwc-zx&yB8pD0HzZ)Z@{uuB*;FXRR?c^5%p+WmgnwstP%dTM z1Yy_X{T(<3CdVW_f@@3pSAcHLj=>#M7>o)3%= zqwEi0%U|bQscLaw5dcPQ3V4czdqe==vdfZ}`ZKI2=aRilcRV|l&HL2zuFnui6)T77|WKD zr)f;$fN*_3ce&0UnP|odMR4=12&#S%STFHGMv)`(H>Eu;CRG5VCMCJ8|;QpkGP z5Y=;n2!6%ECSu?MZ9Moukc-e&GQ;YREO3VpVl-mE(|-s#FzmA9(^+p_1qFb^Ui(pe z-Csa*i^9fh&VNxoEmuO%{zBfvR@&+C(jYN15;0j~D8}&2qSCotLc-%l$eZjBu8sJx z96;%AQe;DLht|iUjV)MO1u9OVaA-1(OAj!ZKLRHl2;$R~qHvh%smk;)FLY66DBs_) z7=b|tr`HI+%|dkFnVG^RpU5Lb&D*>;`HD44rm@VpH`_;;9$CK+2ciiKjW54+gup+w zF*<5(y26A+x`Vr#-<|!SdMMz8#RV!r#n;fM!v`GpL7uVx^b?%O!3FnI_)Rf7972q_ zgcqo!!n2F|YWoBezrA~>^Sx%7HV)L6$vRwU)K4JcqW41n#OE|mnK*{e+CW8m>ST%v zl>5i~-mG)klrgt%;uX;*H@^N|uxbxwh;1Ocx>7I64^ux>^SQVUu=O96Sw*&bONewr z60PbD2I00~xeZIQtT!QL)GQ9Ybc^L2g8Wy#V=5{fx+3f*Bbg9~#N(JtD*^#`d*?Ql z2}od!-s-rsY!;r1Cluiz;jWv|)iH~L*t)TD4KXQTMfrO=LH;Y>GEGDa_cU<~aq~u< zbeM3u7)*Jw4iU>-u8%3j&`Y_A6jrP`pWdEf4)u$f{>K8yQepV$4O?>#c(x7ybH{{n zu5L*yN*`8=3U}F1Qwx{n$E7-kr1!Abobh^Bt8A}{Kjf-Xrw7I;Jcq1;OD_P~Wf*X5 z8$3OY)F^3OHB2zog6U`O|Cx|~o}+~QYel6uZS!n`2Zx~Cgd{~Re9AO*B%7N=WVBT( zqbO|#r*&icrI)21Ow&3|{txE*`HvamtQoWO<1-M>028NY2qn@q$A=qBoj#%$PBA%; zz|7*JX0`RlO~AH=pn+k)s7<&-sDJ^1Um*PV-RacQ^fW$0EP$vm>H7++sg2rkAw@a& z_6O*lr>XXS4`B6C|48g~)(tJ7Q^NekFTf>=DG5y0KFftQmJa>4y7{AaVg0bT&!Tp5 zUnjsVcty6{XeyOSAhAP?h`bqdVMM~3v1E*J2N|R=R|ymO^|XWw)a8cu$s$`Y zS~e#@O#a%0cV7AO)lVeXK6zB7fd$e**bh7tA`E2A%#&p7$|Zn^6k!LSh+Z|q;*>X- z7mImhk}`|9f^EhQO^GG7Z%{%OZUAfZQkrap2V?t#GxSQhkXPb&&fVP2E^JzS{ud0Q zMx+3?DzoMdTHHHuNYfb!tK}e>R5yT| zQp6UIL-2%SSd59VGQ0e-aAJ{LSvC0!HaLaO2(`hW`y-t%{oF7pKzbj#RIg?tQ}8tZ zowj_GQNe_*%FW*4qQ`ErERd@(6_e%|kyrL1x4ZHz)mJXDoc)g;cnKqy3eTu@WGbQF z+wcq?-fkzav0q+Zdn~PgOXKR2Y#3Gp55*il)r0Aep&4N=kvd#jAwv3}ZCJwNbej0Q zNAGT6R=8$|up@6rCeWKFh(|pZnfA`#-!=zQkfRZ0i8d!&cY;*9+2>D{Zn>UA0O4i z#LaaMhcBDSn>j()ChGse&!m|2i+dDS76x28!{E18J_Fr`E!W#^?x#5B(8{@M(N~^> zBAbjE({G8GTRlFBdGYD{MP11HsH64s^YzuZ4#A5}Uo+<6n3!f-^=6))4|@Vwwp~8S zk&ocPf6n7EreXkcm~q$U_WT|jD31Zb+wUa=(C!M)c{@7=cRjnvVJY68cJ;5@{f7KL zdp?=o>^@IFW9~hpPwxYlmjMV12Y7o1#0_Ngn5S4Nfzu$YJ#Tn)&*>um`uz`x699rd zbLDfk30;1tuV@zqSgxyo6t>HiD^c0QnLyv_{tTEQ@#}TwmC)O; zZ_80i;vse;=>sD~hGA*MklL~Oz73qxN9OS)l=3cl3YbOxK?YsTHmP3p;iBm`oY>sbo|h-|xHQy(73-kb%K86XGK)CR z3vk_?HD`s=@ey8auxt)*%$X>CVVCq7C>epi^M$yWY7K|UFz6B&-UZ>aJ4`h?CPhd^ z+MVB*lTpVcBuHh$RSz(-+J8Kzn9WvPnx07ofl3LL1T&Qj=ph#^sCPjQ5BN(h3GubEmu;m-#W8C46rHKG-$4Oqx37)TMY%4_!jIacJ?~R7vx1Pz#^>l2=UYVS z9orG65Xpsp7{P4G=gbsx5GWdA0GjjB{JIGitsIioB=X$F6=X0OqM*ll*S{O^h`XMH4yq01xj%~hWXk%wX?TZGwJl(`vIkccfW{m%i4-abmS zD^WViU9YXvidmE8O%(8SR#Vvatff7qshCc>o}gWn&!)1oS)Px2SbOvFGVV}P34T2? z?yGlNvO%%&pLhA8q%T0#s`U3_ZM^%^p*zdIO-CF&v`Jr`BPNsr6hjlCnDnJ)nclXA zp6XcKG_|e%OF{_1{9VsegK6dFDlPwhAWEVB<2UQ89?W{xJ+SX6xxQeYfp5Xv2ZZJ@ zepGVc8h_h?{Eyu0_8V~Kl+aZJAuwl7)ul0gp*aU;^Q#6wku4XRNN|8)nqMUZL~np2 z_k7FaTKM7eizep!=?SfJ@q5-6REg1B@a5VbGC7PUn;>c4KBI16fkJSPTzdlq3c$ci ztv3TEjs|0j21CjVbKP?lKyj*5^$oS?hf5oC=O{|f(prYO~XEY+o%Z+wL0 zWJha8%EbPHap<@o>_zJ`#0*}N?szG?DcL0f16Qnnl;BWgu)8!5#HOuscl)h!Vnp0L(9JqV!QrIM#3WyXOwH%=m;+|h4V-Z_&mr++(_*DZsWWpih7q7X?)eT{Z7-${a(~bNXU3gaKuJQkt=^nl)E> z>7~PHrenv7N*Y*hg$66zfapL-G2^6Trw;64z~1>&iE_^5ZH(+gAde z`WrP~gEZ9xvVDwIR^Fu-;5&j%qm472Sab4Szj}jB^B{)`+>pZ@3N&XD0rnyA0nyJa zoFRYp`LXM_y?>8qLC(3$cz`fz>IA4H-2R=TfD9fp62zUGx0@z!wlaz|YO`!quVLV~ z0{jQgKB2%T$-jXCnEHcU7C=`Aj+gX$v9*e?$OjG444Hwq#1fhS$U+zc^BleiYms2Y znMoEw4*c2}<-qk5osaN$k47fcC@q?f}nXff?{ zu#+?$1CJ)7HPgb0$9``DQ(n1HX*^owv@QMILAYZtd2?6XEeWl`!k|dt)e-QsQ&R%0 zm#2y>H>>>pJ1fU-aM`8^=7&Sa$6kzhY}V&EDt)%NuR!FLJ>N*8%p_q+$#4AV4O~*Y zqhY3oSy3Q<#760A7dwk7O>Efr4kTl+XR_>qI3yZ3>znZL1@MIuLS)a9)|vFcdQE}o|P`;1I@##5%g zb+3!T@jd&ECSnRq*Qb{2(Q$LvvCW{vr`NSn3|I1K4cTB;CXR6#0ibbRyF_q<4NwDQ zP$9#1aRJE^uqIQsHgZi>m_fSq$Xn=JJ1D@0A8(=p7?XnzRa~`_hQZu14*W#DsDKeq z8Y7F$g(ya85m92JG|us1lq&{4oW}pHdKsp8(IlEOqCV|E?iEyDB7}(7PcwLZA4U^1 z^bF^K$RSybpI2d(q!yrP6wC}O{psZ48}bxvp_JmO8CBPH18EVvdF-$Dtk`DQEAI{q zb>dOWPvJQ|HHnMRoA$>lum?6tHIh3`QQ7#Dx8}5oKgpOAhTdQ=vP&?1Wk^icx-&&k zje_0M?<*py-?_jT=){T0j|QB93@17F0*twQ!gT+aNMgZ+CmO8zNJ+IoXX^PRKT{O$ z%N*a@CO+gbeq#&k7N$YlMQ01Us^odzVF0ytxesU$*Jo$5Zf-WiQ4Ue;n4?ar;# zKL6=HB0m!IS%(&zj;IznQ=!Hb*ef^1nb5vKnThwP%dU#H$H&Lc%Z{`|E~VLz#g?Oy zDNQo(_uZoVyeqsH_o^|jbTr{r8eevnYd7S=0Dz>96Jz?KYi#L$xG&_!R z4`peS3b`scE#&t+d(3Sw3!=o-s9kyTw%_t&VZ(-bE7!ISjXoad^MNwkq}x0j;d;t* zL2P{SHP|lfOP#`O|2OHo9;7rn1kan~yLw*6m*vEM)1-x!?b*${3tqGesk^>I z$u^oe2C11cRxf7unhu(urxok&>44qi|5WH^ z3;Xu0Id+bQG0aMn2$~omtA;j#7u0O!(50)CjFoa*+`8;qmVuxA25GLh2b5d@bXVjv zxDrFkA)$g45CQ@bO2ijhX#>MHLne8Y(HV+YmAVyWxCWi`L@h89N3Mbs3eU5bWdd*& z1?qDKye96IDgGJkONe+X7W31F80XLajRAREi37%7O7^(+jwt?+wK*wIv?-#!x> zq5|lPYGU|y8yFAA!ZZ}rCYMIP%R2Q05*bQiGYP=+u*v|zZQrGXI+62@8qmxs z{~EvY6$>NI%H-oYb5Y0B%WMd(E?#JA^BRz?b-d~-s&Cg^lOg%Q+p2_s9(E?4 z?W!`+^I?fj7cUK?JoE8!{uPn0!d9Bj3>LCY@iy}byoM>yqgcA6Mcx%Uhnv`L(fI6z zf+mB^<84b6Ri7@(9fw!Sn|-!r;DdfTGGr(0o0B+{A_18u$PvQ2Cx_3!w}CgNMd6NYhp^VZ-OjGwNmQDNpgX!%34%9z-obOPP(f?zvgGFouR&*gXT+v zWO=6Oqj+;0O`H8}D&`4Br6A0SY&lCke#X3;t&3-|2{DFHe=p0!wI&qf8>@?*Z4G-7 zjo8{8l(fl0D(~TM3=!JsTHy2jR73dR$(Js(<--*P9Ktv+#wp9FOs}0m)#Kvda!UU9 zrzAZNc&kqc;8fXh4sp=P186=Ep#mkV zm^RBh_g8@xHO%RuNV5T{B|L_oDiOX-Nk>j(A1Zb(%K&%fBWKeRcKX+HseOi&Z7r>n%d`3aqgecSU|U zZH>YJzep*zB1$`FD&Ze9MR*I-P*~vkxPN=pQF%fAOL~5Gw+tD=l%BG`q}3jL5_hb1 zK3&G-)`c^&@txfGnK*Qsc=3Cm+)-UBx@W%# z>(*F-Y&`ipPm~89%N!I}!7^ERBPC8*XDoEPmiXwyG1#af?+KShJ>7LTqSY zz7VKWL-WFxG6X#v1r>NQC>aJ&m|wvneUC=$JzijOgQwd}f6pngSs4+_oBp>3Hbrp` z$q|E@PR!vJQ1XRDBG;L&f?hX$jeVu6{lz>ycMfSdiB419?lE0A^%#RlakdoOCIM&o zTV^}{`8MslovxOxF{jvTVE*PQM9TQR3ltXQG4G^yiKMmYeJ!a2GXz9Ea20GRiPnv31q>$_0!i zz-sqd2BM$a$NO+#2+Y#dD>fu?a#SFoZ(zTcTQ}xucb&v&4C!#CiW6YOJ$V%8*@55< zQ?0^IC6iAq@O%M)6Qy*$UTt5WLx-L{?*%ZeCr}Qd_&qC~;cXKNecmBNGypwJZpX$$ zzQNDlWDiWEcGLPbNP3S*TP2C0aX*5cTC*x{fttah5H+YH4#XL%U}v z8V7dnu}L*&#$DdU4Tll5p^^;1zEg&f;%cBF&(3W=?h%4ht?xbO=9 z-3Q%H#d2enTXb>FWr<=8g4*DV@ef?dn0oK7^Z9ZD8NP#qMHo94_a5YY`|I1euJ9Ao ziYJgsT7rTzFR||zGH;ANw{ATA4f1@w6xxz1&{gzsBD3>Xe4HXlqxt%qnt8y;Z0fd5pW=@WRlJetq# z#-yWyUilFJT0CT2KbqIE{-M#6NNNdtHg+T8R~%#sBfJU+?F{`sJ<2A(U}n`;IPzgI zMCp)J+Ta4p6T!wpvB^Z6lhLO5B%UggxeyM#Mx!#xWig(RX3m%2CWfT6oT@Jdhp&3# z`!3W~N}9o>Mh&)?wW5D471C_x?NPOE@@QS!{CiDkMQ66hdW?H}L@cOct2&f^@LX`& zQY;X)jkx=e@O4?BfVqF)aoDIHung-0C#_ja0%2L`fwmy2q~a%8+Oin7dCnH|?Tjn` zUqGY=5z$4rkVXMer{`tQ#L{LC4P=Qg2gf`D#-)aB$|x0v$4ctT)B3$p5DRFs0T1B6QznuxCdS0A5xaGm zfE_!wLyF&;B#x36*Zn*J zhRjN;YMK&6ZG|S#P=u4{Tv0v;#uv<}9-_qvd1_wIe02Xa2}Ta%$zT1e(r|AZcM~Kb ztcGw_UgO(H*aEuHy=Rl}YpG1SWkCPWd7%ho_0WxUxPOO(0>9_J*L@)*rS_W_9;<*6 zcXK9%>R?=XKfU2jTWe%A=!cR5|gJ3!O<38#iyZimQPQDJ*8L=C~eJ(by2 z^zHjy;*pS2{2yJdM^aMow$OhJ{_Dg<^jeKo^#bJh2OsB}yrO9izn9K2UUXt@U}*4t z@bJw9-48b;*s@(!|*!4mMfESzW?Id@RlItD`U@r4$aV56DP0Bxe*<2$tP z_zMm^&!$sLf;o5Q8klHg8zvh*{a1wHr++iFA|rd+Gjc*!)}rl81PomJ1G0Dn!>n^O z|5uIKWtXq*-N|O-cR;j0D*PROJl9zlhNCI79lDo!j1`h$mPGC!BA{%msf{rmkM>Hw zoRwH8VzvoBRi1h>Q*jc($WD?jxP0Z(e%uxSC@p|IA>^idK5566Q;GCJRQu)3DndAf zZ*~01lFtEHOxMGJ0$I?h63glRe}+Wk6+o2PGm*nMFvU9KAK=&m zxl15;;o4PDV^vnwA%SofONATVY!i>`NnBNNncjWAj|Z}Jw^|uIe$^pH1f8=jWIAWv zIbyV(zq*`4WH>9%I=zzgF>k|*tvAao58(Ai>mEsGVkap#S=pobhieXy!h)!#gOy5^ z=eN_GVn)s0bP{Ewhfw)76qN|3M0^%N+(K%9*V!e9EBejk&| z196n)RbyNvf#;Ay9FG{N>Zm_)6fO80`r;EMwH`_A&|HXh+lIMA+|TWl$ZpPZq-W9} zX_F%WjkFtjl*n*nUIMrjyO^kH=G}N~5)xU8sqp*Nfkbs-KmBBdaTKS?$dUVlZb?Oa zu?=Ndq47q=0K%jVa#uK5KI#q1{^Hw}rq}{k5ZvcJRAvU}YX7Kv)6uJnO`5NYQ2H(L zp%@XK;BAodP-u+^3R{QoS0(6d@7bC2?*jKdU+iX_Z0fRdDTYGWAlZBCx7gU>YH0o2 z4iC#dNwNOm$UdIFxUix3;?@lqbQ=}yReWIzil{k-q)Eln2lDSPr#--KS*KZx7Mu_5 zq!XIlHK!IC+j~w-NBLXtM6&2#V()HOFsE}Ye`Z@KQc5%fRcRJpc zJvH=I%T~;gM_vxS_{LxbH2rF(+xJuldZ6|9Q~DCN`iD zqm@$z5^JaDAte^qbw0wVkl?{MTwO3tGzeKtX7$0;^0RJ4fcG{6Vc-Gj-`RAz)bqBM`^abIdlnXY*i@FmmW&o!+`{b*k1mE|mAVPJYOU-5a5d>pd zGXx#a`>2qb0gwU)SuCJv$c7aOIH6!(>@c*Sn*G%D!!?!RzTD`plO z>kTmN@0RA&)INwW$zgYjny+PIiM~CL3t`TZC42F0>-j%$euDrLFF8~E3}>WkV>5Ka zOl+RIKNZ5ou(C>TxL3?N{UFYE@xNJi&DC&5dKr<|L-ZhgK1t{{n|Ubre9akX{?q}7 z)J}yOWS5Byv0l9oq9@=@`WNSF<{i&jj#RYV^??U{-<-Rr^K&3wT+Gt@)taFE*Ojr|_1>~+6Y_W;4m6pd_YilbFSjK#M5oz4sgf!v4pU^I*fk_W{?}0P!YL ziUm6o>c<2YFv1&tMm&25Fh?k=K~=O%_KJg`rt5{y_8G*($9@Jj&?%*m!veaHAt9Ko zwr&Has+g&zJ9Z_DKzJ%7@W^jzE3zblmR}MunT(^y*yU8S5_n zPljwjI;C0=s=^+B0WqxK2;NDBg!UXBFEWCP3zpeEXoX_>?m>y;0pdP1p}eY}S21dU z8>!R+sLRoa7py0uY}B6<9#3-k#WOk^9~r=DG8xuxcXea0TecJZQ7zKETV<2UZM*T6 zSPg6PD~t?_#oy*z?CcU;HF<5UhK~o-8{#_SPbBru=E4Vm_!H2jWD91|tMB1}8&K2~ z_l*c{UO!vOAlM4eiCKWC%6;!eS0$A|5s|bOf*0ma#^Z^xx;;tZovk*FP6YcI5p zHX7Ui!2i&oaPM0NEy9OEP7)SKtwb z-JH*Ol0Gbk<%W?uVrR+x_diIgsBr`4lu_J+jb^O6kqj$s?>s$rLsq#cq`qR>ofY#% zyQI47ntJDDd^fFxh5R+|S;#N#o79{iRLuLd0F_S3u zSyE4L28~Y3UC}ToXrDurgtLcp?LnRxtj4p79yzY^-J3y7?wyT)8sl>RKrmM*?(7ho z59e9h6N&7V`0PqsrO-Wd1k(Y{r&n(`QU5&_LT~#_X>z48{6nuIs0N(5hV?o|oVocu zVP81beptpk8k@ZkmrlU7>_Bfmy1T!TnnDzy0zcTLw|jit@sxGEAIvRZw=NK7?T+hT zk86>TSMeRl;P8Q1oR~9bSTd)2`{GS70N4-=$d}f>;y{7{zUe^dB7VHJ*<;;+Vb93F zIv7w$|VA2=0&0P=7W$B0M}0r$@s&?J>iPBVpUicv(?v#ly$f4?lv4JG@!Qm8{^6^DLEF-KLBR6TWH!V*CU-7hFO6Y-f z({)07LQ2%vJNnc$E5zosf-DeoBt~cFU@Ys6YcqZ^o@78R=Qi~BNYDxQaTPryn&SZUO()pmvs(+DRyor_8 z&y;+W``ona1N~!xA@fsgb37gCU`*ZY0D}$7uFtq*GMjh(Gsl53i829<6U{bzWHQRo z&dj_J!SR+&c;`G1uMoh-#|LC_OOdVraISPR^Vb0CNg0XfYa+yC%8$(1m(2xRSOnwB!6bK0o zaSd3dmAH^Jm_M%SQLfT5iOzcOLln-?W37eG;nkhhpbdJU!@^Bx9 z^()#T{^+w;+H2LBdC8`fcJ3K*fE@NODF^rjr6j0+Ve6AkYfkbjSh07(bGO3ygPPg4 zoG1!Lp9*hnu$KTydMw-#i)IUTFI`W8>FDmnRd%-p8aHD$R$Qi~s!-bRM@6mu4?L=r zo^UKgiTDBu*Ar7h9r!mjzP~8`;IVuq6y=tc3<>&)o|bm8obJZuR7g&eiuc|z*4-)- zNrRLWqjY#+I{lk4WYtP7_c};2Eoxz4%t@=TL@Gk|)ztK)c!JbC)A;VSB>HQ&a5wA@ z%~CHjUqJBq5hAO_4HEPp^$j74Stm1moi2oi3zf=+y0;b8HVK55*VSzKgA<-azb8=f zU;ke&;@SQe?9VY=x4iKF*=2t0+}Fv;y#Idk)AUr-v3@u9pWr0U`65o*x-gEoHmS%e zl`3+yEa$pXN*#2p@&|dUd&R_Az5nQG_Cw=sl$TcQ`$+gppW&iqCYa;RR6Vx$%A@V< z(RlF51@o;XCrTzF;<-}?QhS@ONts-BFi(F^7un|i*v@ok>94`xj4C;m}(1E(P zc_QO_ajeF!Aq}Rs8}l->UhU6vFAb#3A5wFsGYM&Q44Zd;A@AZIE~gG7p(4M)djSPWjyf%0!sp@U#X`YBrN|<NGo%}5TOG{6 z5C^ZlMnZQPKp6oiJ4S_hn4Kxh=}wor;jb>m)4tJ+$d)VUa?T;9#A+zO5~zwgpIyev zD8Xu${HD4=4}W z(5KW|CgbYw-ID;4F^)4@>hfuxihs?8`jZcZPhZbr*XO#=YihJhi)8nSm&F_W{7FR^ zFOp6kBH~z`kSM;|iMUDAestYcQjZ==Ag90JvoCcAtz=A2elU7H?TSUf|A%_z&AgE^$|YbL2kqdtAn{O~4a} zCP#_r=~Z`o-QsV1=v(}ta++Wnok)S-&gqn$arD$oSNqkH1jq5Ls^`${H&|N$CglFO zd5Kt4$Pl?(i#~JHMG}`K{{38{r+~~@1lxACv7gpKYN=+F=DEXNiudSGq03hBwL$~q zG44NZx^B}hrQ9tJt+(vg`{xb@Hrsjsmdo0-5;W9Z%FGSUuTj#W2_A&Yl6Bs+--&`0+Ou#vs6GP1~fmjVP#}ai3%S|;I2Ik>s=Rxim z=6|c~(e;xa>3}cqz?*%v0XAzfIBZT+Z9H5Ih=X75(w(rQErgq+N7!dt|Jt_xtw~G8 zL;RU)QXtmc8H*P(oyvx944NJHM-FxUnlcV>C3TTVk_AC5&|336Y?0Xx1h%a}W;2!b zgD2L35NKQ6U6JGuV@Fmpg+oO1;GN{Lh*M6pBGye9v(2YR=l)=`4qu|M!rm6G|bOn!uKv}%B z%^o1+-^C~7V^SLejcqbG)s;MRsuy~r@cb2_qM?Z}*eRaJLz+X_dWYt^1Cef!oXql{WEOutEHg#F^9H`Y<|{iraF<_ zOcXv;M+?^Rt-@TK9McdVi(c{-XDoE{bW zdWROowiQi$u$yYXnR_u(I8x2z$DEYRyf3J?H$Q${jys(Sxpx;l?mjFX>aepMKjxlu z2Hf0ombarm^Pn4}u|9C%N1^gYk@zvIs_hQ>RUujWc*m0L@zeH1_AG~$MO7{U_q+lR zjva5enIc*qcJDRyGbKji(A$SuZEUyUUwv04+SBxewc0!1ey%U;)$KwA6uv1J*B}1C zCe!>i#U3T=PrcE`_dT)+;t{ydS0HKgU!dT80*8IA*Sw27X}~-uC+9j;-?xuF2Dg+p zF&Z7@kYSCx?e0C&FbP!rg9~RShdSiVQe8R_+2V!}WDFZ{A=^*yyuT9x9ym%UOz%9` z$UGV`@^BGS+jn~2{ACr`+0#=lU~v|+ml(q0`IpoY%EZxufw=< z;d(Uw8%cWZUj4Dy-_sEDmFSRR|x=Eoe;srho$P zTR5D{R-^Wvc8Sl@xV%=F$&>h-04H(13hv#{0|~V<>h^I(aLrVN(i@g+djF#*zWQgL z%bHmWxH@oNW|<0AwUg;GwJ&k9ckq7ZE`%4M)eF4}Mt}BL{)LK;-g+HVtUX_>zL}G2!`m`o~b$9P$H_}2ZQd~^^0K0L-@nHX^{Rwy25Xwy=UBi zWX+G93zwI0WVntt2One#~@YCVP&f20V4qa9<+O9+3{SG4bur69BWK9v z%>TQ`ALe+^?}+H*0V!{!#|iH_l+F4AQZC+a6*hRwN$0WqBRCBn$@96X@h+kLmO8t9 z1%4VoC@ILM)Mzs`higT?&w43+u+E~IxGVf)n9jG-wtgW_ESSw0YLAN_I$RXJ_~Ha83|2LV^4W@lu15{0Ck=!bKejH3t4h__%)TiR7$%L-pC`CuHb!YUPQw z(6aA90;wV<2Y{DYlqlwz^lkZl5>XZPWSPNNrwzB+I2IfTVRT&xxl6k^v=d)mp-_=AJ%h68sY=}wopQ^ ztwP!d8)o?;N)2>3vWRBL!pVo-Qh%;3km25XSvhyzazD(XhM{3l*@B-H{j&6qA()4Y zc7g_2`13xDiN?)`MlK7Ywoxfbdc-G9w0TAoCw@e1l{_X?7ZG#T~@lGdi3<(U0YUn&ylL8R%l%$+rW#>N2^x-op!R2^L zD%El(Ep_J$6(^BW6ju3okVu@>Kh#!jDj$MdUEyMqK%*qsxfnZ8bKnS)5|pcg&t@0w zlwMpP3CTqCaa));6RjL5kstRPDff-&2Adtqx4yk25(4yEgKF_VK{u0H9N}q?U8t31 zxVi~7JFl;)AO{+c#*ccObR%>DAvcNb8#c%vFORwR56fGO)Pfh`*Y0fd`5YhRa(l6= zHbb-lqPA!>2n{2ZM*^kJS!};L%!6u94bIVf(YEErwT2~sVM8k@6T$Urb!?xXBYnQ~ zd|&}Ln!F*bDdbX^ClM>70Qf&4G4a*&ElHh5qwpspf$@Qjxq|D$O;y4p#$#|`2QBg)3v>Foo=GDw{Yz6rkh_HvIFOu{OkwGI_ z!+w?AjLcdHMzuJ2oZT+ys9 z9U*CX0!z-sBV=AeRZ3%$@3L?O%5iJ{+H`w2`f<^5I;7qpSOYtY(9h#Htway7-Wzg{ma^OOyjV~YWeetm zBM;S8sUm5ENIzY>y1FZ0ZQ)&xsUB0lYys%eq{V1)y0h??nRZfn@S)UFTKAoUeZkR= zV{l)7(iuwrH;Bg(HH^N6ZgVL(#A46XL^kk#z6!^C^|BGRMz7p^kAwQ!S#k@S9^C+o ztA(0bY%|xB^$H~l%=Rj3xR9SjrAV9Kh$*U&((v_bqY%OTi$w7KP_UA19?(_G2>oWW zt_}WT7RgkJIgr2y+!xAt)>zq8c}PCXl$0MlM>VM`7DBdH6eWM5zO8%`+yxKkAKFr? z8AYk}KD^~QJ)kM;cZ(Q_+IPb2%^?Tvn01QMy3pNMI1%4A3_gojLWfbq zCr&erqoXzP^DpsgWb*2O&4%nd%tAyWP~)G1N;FH!!KP+4&Q&y$viRt5^~I$uV>dL2 zqRCYEaAR>OLzK|e-4zI8F=E4>8Ni}|q7x%hJcjYx@Yb;RgM5R)4W~CbM6nvk z_wFFfNwfZxqCwD%*lt^~ywMH7-?vrk0{|dRsKPcYf9pc#-wd zRE7OIbl5a3ox>vdcLp<$*)i$S;l_(3#UE-aU3h8ePmbu_5|y1a`B0<~Ie*;Jv+lq@ z!K1iKdQv_Sk7AIfDkez3yF=L@tBz9c6c!c3l;$K_i=QCq?mPNfWjyvRV@d~jLL02X zFXehia1N_tvNB9Ax_m3Qnzah)Y{>Y1|6H>~+yWABL6ywy_whu979qjCy)ZEkN=>2n?J}|vTxLW2w zvuGVDE;wR{K&QfRHA;4EQ{c@VN*-!HQ1Q6Dtlrt#x&6HVVhutJ1=mSRl#}_L z=5_~zRGxEB)e*XKyV9>w;9@J_@8@80_4iLF3DrzL(r9-y?bhV{lLS19y{cmBjLu{bJSB5SGF zqwQY67N3>u%DY*{E%P3YOBM{;yNhjAq*;Tfk#>eBqf9p4uDoM}qttIAW50k&RqYQ{ z_yJXYEHiVd-hd2eSa&$Kt_%h*bo7KpME}8nbTtV_3N?gZH~1-JyR>Hi{4eOGBj>jS zWs!F=^IPw9LY`~zpvf=l@K`57APYMBCZcXu8RDXphN3QE@E4+4=x8UHC%ZF#yyH^Q za9?$ywqgB=1MZ078u|^@hrQ>8Ez?3yN?H5Aur$d2 z7}T;dY=fA*qF0D4i}6In1uwb;1t|E9|Gl@QQOIb~xUD;D0gm*&$|db`tmQWU7TO*?GMpbgm&5g)BilhDjYeoYA4s?u4=j6u3_NIm8$QF=L zk5yty1j+<3gtTIR9?r~H8Q0?%67e!u%;iOl1_#MYG7Z01mYAG`gVa3toyU6r{#@-< zEkweSSRm0w|DIJts_6TV?@Z61k`zfP+3trJR*9Pu<`E$d#L_q2*m@9qmbtDbI-JWP z23IjMGOv_{&mTW4i%>B7C;$mnq9mAlZ{$d&09GNrKCCBsQckpcY-5?z!@WQ$_wOEE zn_Gd5^`%ndBzKX#0;}$5)bGc3^3s5oyT9ipe7v~Vznz%>*~J^XWox9@y2cMwaLZl< zZhC5#LhQx=naFNNTTLH$VM-XZra5Jj(xUiN)mng>^(1TqCw~zTOzlb{`Yn#jQ!8h> zRkKquM7Hy}Y*|TuCky=J|7pOuhwkK8tS$%#h(&psiy;r>UeK7RJ^v`#63iLxSKW^S z7rKEt9M%aWPs|g@{S=2C&N8-=4Spm4Vw9)g`h$Jy0$i}Ta*r^F6dXf z&GF+;YbiSMZps~C@9UyxtPS>Y6`7*{vj9@89cHGrwDr@pEZd2|EEXRRGwCu_665%- zdUJW%ClvR*+fLd@)%!0``sHhqFB-veu=UOi1B(m?^26$51;WnqEYuWS=t=o{RPd2R z*7R;b-TUBmZI2B+$TippxAm=m+K}NllO=7B&x1zzFkFH|vHOSBYSKZN+&(0>d^3{A;4xOO))FcvfKlSk^ zlC;FASCvRg;hkGHy0Nti>W7T&z-Cg-BWBbe9?j+-o$cju?3*GMzKDxhvr{}3tYgfr zoD;NuL=mjQe>dY8H)k^;3k{Rm{MhG^Ph8ee#+_O(*?|u5e%d_bc5Vs0nM-0Y56T@M`p(a7W590X-)Vf&SAny+kkEc+gHpA|!yUf|L2AbbHyh`XRjL zNaNi1RuKkrG)w0+Nzl?;j7m*q=otMI7DMdm-&QWlo*rtbZi7RBgv@S-`WgFFBWU8I z`Bm$)vm|AwV&w~q*(LMcQ~btt11^V+_x))jm6>~Q!rqb|%2MmDS{hE*J;AIUBusuo zB#&f<0C&0j-HoMxyJMeiwr;4=J-LMR zf0+&xA+XWO$}N+$jv{1mMP74HPdHn~B4VNz?wBEF5;T%SjxOkJWpG`74dX3YT9ydm zBi=Qy04HeRafu7n5EE($Ofyx zvPEv~P3D(L{ClspmZdh*!Tn*8-+f~(D}%zk@6pd!;rfB<5Nz!s=4gQ!BOsuqlTFr( zL<{RjUD)B2!8P^5VWU>49BxKGsi8C^yjEXgRsG2LR>sBjI7Vy!uw0r%+4i_!NrSzx zkj;_;KcV}Zq{o0)gZsB!+@#h6k<>f?x%A%|@r7uU@ELn=xC)HXzF_|`AsoII$;zZU zW`mFDi61ZhEAI~)nKx0}0kbLP{i}6P7E`ZeO3quTX8T{#a*Bp?%uF zH+4%i@Ro`TQ$+7h7nlZ@jCwT0b_opA?or@tiAb)?*4ufs=05@mD z(a}nSzZ~7fBzw>X!%8L%T4W~_BgU6IaggAOtNMjTidF7sEBn0)DMh65-Vt*d^>h7) z_Aw*nd=mgNMm7_9z9aC4>_xECmv8}P_WN4jZV;fYcle3V==<3ds6jiI zjg0T&T%h8*K@8-HCF?R}5o9Yw?u5+PoD#v!)ad!Y5+4xo#8D#})2}y)nCC#HOd2Wf}XJrrWXw54_#G$|C0c|lCVat@{wu6>n zcaRP@U#U#Tce&CuEpK|%N|!q3)c83|!_#HT^7|W#(Pn05vY&K8Pz1WHsGRNXK%OkK za5yQ@s2T>`49IhH=EIHxMrvqi+jD;RC(*a1s@zw4avN?{yg&XB5%t_E!3OyACq85> zsycnt1NY|7noW*a)#NJ)=FBKULD6-g$zU4^4*mCHf6wk!n^Hybv)vdIkks)B_FftJ zJjmr$_M?Ef@f{F#SCvX|ClC56Y)Lbp!(=0U3l@~i8zzAK4JK5IXh!UB#UGLt&s1N! zi|5eYq-N9~ndE?a5-&fwKkX#U)ERcANl=OJx76BBPPTeLMIJvry*1R@W!YxI%op6^ z-7LWr$8zLw(#Joe?D*WRpN0fKd#?TasY|v%D%`?#PKzs@Be&Hpe-&%{JlI3=CsOCp z5bvkyRo-MuD@6bBS6`!zXu7unVb$I1%kpf%&HhuCQKdBLAaNV_KPiqmKaIi-I|CIu1pzPgEmk6%>kw6ko*J$mOe!f{6) z)){Qzb=oZ{qD5_q)vfX+a>0s1rM3ZbU`bmtp%$mjc&9HL(Ed-WHi&)JAT{PUwC zjqNCM{+ag)$9C4^W4zzLq%2)!N@7J9 z()U$Kuh)AYW9w2h$@o!`I{f}lvpe;ST+6sx}Sc*2r@kYl2z}sOd=p5kviE$N{zD{zN@4@fb ztRJ)apiQMTX(jXTmRnKHJQ{Z*ODuOszf7;7@ywzX`gWN%-ZyN|`A`&57N4)@m``Ms z$}B}@HCR+mM-0O#*_#|j>Vrl_|Iq*INXP0b&NGWs9NU+TSeS_TLO#FcCuQAchO2HH zkNySPWo+AUX;bnw@h*4CgPt0tEhTOnw9PM&@$t-->7QM#bK^QZ05F`xDF6Jx)6P~%RNCOwyoQ>=Ph zJqK*Z;S`Yfc!ewIAFG0D1@Xz4@QNfZthoB#@4K3x*FVI#Ha1q!`MfS(mJc8FH&6m+ydZM_5F=rE8JdI5H?)s4@)V zz-x--$OdsoeY$lxpk%HC5yK&@Y4jMN5fla;ds(I8U}GXiY7s+VrdlMJqW$xC>!sJ* z=c-^bAy)PVol#k+WuFW-CH>{0l9ol;Zuhyec*9_bJNz>uq-{d+8>@!Wh}+2UppPk< zGkRfbZZ$${XDJ)zRf|S%sP!BlTZu!Zg1jKPU1oI_s@a)^J%g-h7xFfvP0c6kEWY4a ze^ROr?rAa^5lf0FN+V?pjMh=Py$A4=h0A;e9o8P4skt|=pfZa4gQ^5m7 z&grSdA9j%;i{;QjFH<5?kbougfuliUSJ1asY##shmE4(>-vY)vs~#`7>A6vvJK)u6UPq$zvvo05z1tR4{*U4LrQVJuI%=m(DB%f z@o(E|9FHQ^9P>EGxSxNJ_|52$~KThXKUd3Yd`M8mlb__j`P-lO~JuF?WaDZW>_7AmaTzLY6PdE=80D?EdBAk zm`;qN55-CCg&dBCG;cHLAg*F7e_5Pw{8}50s>DCHnUeNO5P2dcnFzx4Vtt%osKWHu zt9XA>YD_|MogG(sjMKQ7FX%<;H3DTZ>qglDTLj=vw`)}tWgOF+CxM9_fq{E-Dff6W|V+sVfln$dpkuy zL}UkR;2E}(@$}Ej3o*G%9GOP@UdAVk+gP}@hDbg{KAThF&uVRXtX7SrNM&yKzy&S}(>FvbuZ`J+mH zk#A1PvhY3nkft!=>Fx0%!|C0VgO|!a^2kaQh6`hEOnQw|M?q8C)^0%G9%132>SD>Z zQ`0t-bC!oBIXe-RZFoNu>0;PU`iVrUhIEl9iMBiWkr@p`DaTxK*-4ECr65qrs`7h8 zn*|zJw;X$LAu?OrSKa_+H^X24e*Mx;@-VYP0i9B8 z^&h@G5rn%fTr&MseTG^_Sxd1*I}F#b850vDe>yUQ8YNgcV>f0oEehf?FcvaU{sXe^ zJWgq0Wdh!UPRow|l_ambG5FL-(@H^SS~Tc^>9@>+zu`T;l|V)GOaG7c(VY%fiYCR; zPmw?s&u^sqH{Htl$`#j^!QYVjdlfF0|4mhZLT+4)qLFK=WD?Mo_7BCY1pB>-K+(OU$hF@)HQQoAA^)G>Q23XUlSReGV~(uKaqD}nX1e)`_Y z64}kz^V9d_FWRH-n#Bz0NzFGK@kRcnNH2>Kaexc{IloHQn)Ff&a_{GZ$mG|BJ@a=Y z=&FqMAIG;zvLb}og%;K>lLm)%_6j`RFIW|Ki^Kr>7l;p5;#Yy`7&71*M#6RjfL81k zaexdgR5(i=7T5z*wD#R6>kiX;{BXdO&_f|FM#_f>c<03cZSz&&lJ?29b5xX7?6mjyH^*Dbsi#3E|G3`*`XzWf5SUrFGwk;3BqFy^S8~C6( z$9V6e$E=6>n>7iO?}(0mSKp7?^+3D0`ku9varJ{8tqKhJ*JRPUscDdMM-0I^yh4CN zo9)PHhaY zt`!Ip16`R)U*?=+IPMu^kq?zza+$Gjws`*EG%Pip}OpO0f-1eI3EbtiPUFF<|2b5jQ6NhhQSMxZ( z!t~|G(u7c%$7IHMg|exYpW6-6^w2#s+yvtrZM@DoP-VCO5hVBF#y&3|K{Qu8nn1Ls3*z46GTYnV)!d`(B2%5@PVlnA3}4 zTPD!i1~4;HOGb$(lxi7T5@f5Y`ALn*~N@9tIQr2aoqm> zE+!!~-^^!*z5QOgyM!NET`=V# z8(DvxgNOPmR=1tlRG)H(#*yUOd!i%GvGdZ*3pUF8MD& zu?N3K7Mn<)ekQNjRh9C+hMa+zF^G;U8`>F))@i)7Iy^FtyHXoX=JXKe&5lraCBp8w zFf30KdOR6^nd4I+QB%2In4RkJ{b7#!`Wq%j-CS9fd{K$W-jV9vI>_ zHrc>%c9c?5LDkfq9EsJyDOY`uC!J%Pl+s&)53_NC;aU{-x_IUJ&I~5=8a58c0o5-k zV7U10+5R~NFc|_CB|uAyP~T*@CsL0Y+?5C}=k4y0-*ztJRay-b0g7RPPQtDJzM(dZ z+a3xtv0%vJ6|ll1IZ2nH9kQC7TpT~pPsngBE%G98Tmu1O#6{v>kpYWPzd@JIkro3# zQ1Aea^f27hW)Y~4a~fo>XEeJO zIWg~FUp{-?hQw~VB=kd@&?Y4EvvH#sE1S1PqcGO+_uh@99Ok~bg)NY-ZxUi*0qEwS zK|it*(6uSqJ?T+?kykT!T9Q`$HA2n_L4xU=EAXHgpNfYfwg0fvaoe%G>tj7&wFiKIl3 zi&6Z;IsTKo4L?)Yd0c|V2Y*ji3JN~Q#g;%vrx-Ncpn*!lu3mASGW8drBg|Ra^`aOO zXDSJTgsKQ_gqVcW>4*3ebL}tFf%D5JjZ)k?9Y4DyHnCo@OTX{xWyHbsju1 z%o@7jYn~5{;WE!jj1LnY+$AHR{MoVr>AxS}hF^w5BO>O+3E-F;doUrjTT@twJ+Pfk z`S%D+cY&9Y@gKb=xOqk{XM`U@#TgF8PUYTq(F9AXhL5VBXY8SJzv)jJBkW*ap{mhJ zFt~+VPpM{_jy`z9GzE)awSImM?_xmvH+$S0oFErOODxluVd181-1wOwzrK8Ub~H>ZYdghZ4O2ZO#V;V_9cYy|_dSuCkXRJ^WYRy|A}%$& z{Hjsjq}&{ou{+vFchp^O<2Gj?mc*4r3w)tty9CO=T4jRSOxgnjA>N1~JEK9r>**L7 z*{1~r+O?S8jrB83XJwj>G+*+waDzvZAN51dbn5qh))iL&6|AWwY=iTO4TNiK0l4Y8?M|H2gKmlhAqHY8$%$-kzIoywp-4ixqpyZN|!w z^^Yw?VBV16%Bs}DG7+-4Xw91l_5tTMl+IB%gVzwHEzImt9gV8OFmn75wU_VQ5#S(j z7*aKSva*AkU8@W1&2RPIfFb@$<}SZOa^Tof1#U5S16xCctTm9lEQ2qOzR^!MHRfpn zZqzEmdb0N15c%pWzf4#+K1*DfqmEFKv59WjPP8QL2~&UCw*NyqM_7|a4C*%kBA#pU zW9BOH$%m|O&9fDi<{IBf zx!LJvzfQ+_g=Kz|zE!_9QlisHH*i(2>{ePm<{)^Z&z>r^P{p(psmNkv5hT0(Rr9^z zCm+HLD?!3tx_P9;4@8UL;5P2mJLRnybQvU4YuB2JZTzL6^mwBa;*MXQim8oK*)5`r zx?0tGYuYCXD^DbEdJuI4XQD?#rZ0=0hG}FZVt-v9O!~EFbbS`O=6vcu0Cd1*Z!X=o z_BN8`5o;s6H7dfdIvNri>M>o1(*$IarTF;`qldGhVOnh{%MPea4}ghSEch|8<4lSy z$O-FYufe$#3+X>A(#dk;z4_Z?qsWDE+giamC0J?P7r*F?sWUn#w ziKWgMm}ha^A1s?+bgf56Hb{__^8;3ARj+^v>5czTx#hH+^Z)Y#P*MV0@P%vW8-LK# zBgVeK&g|PI(es5H&=wP|g*qhyG%~@8jn0g<6h_{Wo%#l4^;t7`}tL`UM7#IQLq$Ztit*n9sF23x%)T zH^Fo&$F>P#Ajn@_n5LZh#JUp${nHn>F9fZo7KxA?4)dnXe-0TR$VtoRauJij`=#dW z0+^Ta|>VeZrdP?$#i=d;??*Jo;`_#OJLU=` zu&*ZcL4eip;!;<{&=0e~>d#Ji?$y&(qHU4oVYCN52hXX>TXuBOkErUTmH?I^%STh~r>#3)Qxa05pkpa7)h)t(s&sMdmH^b943RYLc1ZrT6)Me3%p$@=e;QkTRCGS z?AN{VQMKv}SZZ=1tzaXd+mBb3O!VBy5hx@^?QkBELn2;y z9XJYLcWYhUX%d%kjJ7r8Ol-Ss+SD3XF8@SXzB*M8|Ef(>kW|&uVSH_hRgV!vmQ)-e zwLZ_Bj+vJ}u1Ml%T>b6cQy;X(74+Q!Z87(Vo`F17?M~NSjE*5%FT5cHAg!SZFCv}* zh#yC1k*-ATFsXCeee|jy#F3CWfsS2`j;rG+aG7df@9OF}^n1{OCnFTY;&oIPc8Ky1$H|BCobI& zYX<_^KNb87$n@@*o|?ANwz8ae*BrN({sex@u|!A;zM$Sz-K|7gug9mm+r<=o*I|pI;5Jx|aUf@6!4p{j~8F=d@mpk2jP^i_%q#Ijq z;o(f9oB!Mz%J0(S-(oLVCuizHkMpdSpZco}Zj_1{D=#jkxSl%K!$P0dw>g1^0yUml zp3$04wzX`lroO4C9FBU$<oM&+_( z((#WWk&Wg9=?#CzL6+Z`*)Y)~2bsm9?VS0@fLjp&RJyzKoh;dRV*M`2+=)?OeysD= zqbRV=G~vuAVQfskw?Aa$7^sW)7~MU`Uf;=#^hP7$jTe2O2@NKN2OdT~=esVG83D@e z=4rHSpCLV0rZO7xON4`cNegb4Qmyzz8ot69$|S*k55fJ9t}9M~4vQrOEI+GuuTq9l zW(z6sn2CikIttjT{D*O-ziEMLq%G#r-Eh zWeTvlk?O|+EaZDmVZOLkLD{Wf*ejze#5)YW;FOb_n5mlxp#RE0AB54C6uxA-1A}zv zETp1#;ZH!C#0j5mJH)FB)Y*1z&H&SxHbVn7r&(^}O0}=3O}{=5M#0i?l{e;yHZ${} zT-&(Wpja@&>Z_f4D@f+oDPDXz64+!nJj@F?%7_C}oqoLKzSoW|iR7UqAu%Tha)SwdUd(K6fT_{d@RL=!z7vJrlW zz{E@RxS)3!S=S1~zABA4PO>;gj5jeaw;>&_{`(bTPJLr4GqQxt|PAFfggXHUv81RtIDgIoz3lih2*4 zDE5M>9$e2p+xMmOwJf1u^Foz;eiP8a)_pzNUxO+=E4=lC)tQ_=^|M`UM7$0>wBXFK7Iy z)>#txV3ABM;COjIW&R2a72Vn%CD{wUI zXTWaEM7f_Hc|@4vwI0}>uezgFZqq(45#S4vo)&Z6`}_yT3--f=Sp4r29`bIN3}I5( zq#%&ty3Yy}?CG}bLwv|}S+o>0LlTD6224x#5*T}QRUmtP^0iDVle}K9;d%h)g&M}c zaI?J@5noC0UHx(o)^dr`r_M@}GTd+kCU035$ zn0vO`KiTtFVJv6+wkvdu=uzG-r_QE8jP_Njyi_TbG%GJquU6C6NUTUZXuKe_Z+OGS zEr;j&230i4pbfIX+XS)DL`4Jr;+0sGYUgKFO=t#Nc4G>iQaN<~Ssj%$%)A-o$1_W5;Um#1&stOO}$}6wR`PV<}x&@vHm_GSc@4BSVeZ~E$ zBe0$D0RJ)XOrgJmN8^$;G{KLwH}pa9d{E zoJq7^}tQ*C3SH*;)LC3K-W#Gb~04LEJslWot5zT~CE%Pp|t!z=7Cl3AARx z+43zQI@M{vG02vbSg1t)Ks<-56u$x0RpRFWn*xLwV3?L-9UJ!D67!s9?F zTxQwF@_@kxpL+t?v*;EZ*t#{W2$W-oqA&c#i#R#ZfSXoGC@`r-BaYJ}O+;z)_xbL3 z9gK9y=Cc1GX6wflK4u|3oY#Ney7g$$92a!{bfKc_5XDtiS0NIM2=ogRS#c#RRk}rBd}&eH?m6z{JfU0}%Qa+)`OH8tI|Cqp?XoAt%P;CT7)1NoI@!GrmnV z8eY_+&pDL22*@Pj^m5;j zFm?FsbC*jz3}~;}3}z<;H3NKCpGFZ`Q{ZMBo!V7FW50A&#&F5&`-+Z*5eQmeCVd){ z=pMt?)Baf8SL5~>n(N3Q4D5`ewF^={mEULE>izT+X$aFv#yxeneQ~hle4dNmgba zPD%X5#r&KK*I`S^wfo=YpNFi~`ueAK%^b1H&tXfyP0r`T43Ddjo1oB))P0i-mWe^4 zYZNiLC8RH>0{!WfhQ444cs%jaroYpAT3JvJky0RgsBfi{OH1m z78v1QIx0^EsuxL|x92T&J?KFPCbNek{7Xi$fOHJ(I{O|%`vKoT8g1ql`JC!_PU@>Z z#4<2aJ`pWgXSc#iQ(e=55O(DbdAhrEtH}fkOotlHLx3jjIX;TD_jOC|kt6pliHk(CN(m?7JI_va7*kjV0Se!}6lwSN$;he;}Jn6>9;cV!$cJvDB~7u)m|oa-i7JYkHH z0x)QtI(vu9Hb<*!;3#hK98?c4r>_!$n^b2f^#1lS-nJR&k1p8cD+(jyZ~cgI zk3h)xkLjqJJ;Dr2Df034ML1?R#+|q{^w$wT8E3avxS%LME{ry@(A*RUKBB5UBdcE^ z?aNu;*wrE&Aek^83hV^0JylDW;hj)?)^Hk^o!CEAbx)b=!MywVd>vEN|1% z%2k}@sT1KuSh>$NgtG={Z)sVLe{tK+>8z%}`R@UpA}I7J*>&3?N092CfdBVeD#mza zl7^SgHE!$6eUtx3Lqa1%gFk+$D+0;?HJOazObHAo({2b1Cc$-srTCNQI$3;1FgQjq zQaV{~J7Io^jI1NHwgXD6IS2pqna9vFco_JA?#JDMhrNx?zw-7oC6%|Xi1zI~EOV?- z0K;`?^X6I8`uyy@YY)aMz6j9%nY_Jy@#6RWsb!}Qzri^YYP63r=zHR3LTFM20Q5BK zY)S(8F*T-OS$U$_5u|5@&<@kIWWSmm*MM~H-Oy1`d+%tPl{NmFjXEi`J^M=nDz>k1 zX~nXHfRq4?50oTEYqt@k08(b&S1>9! zsr!Y(L;7bqxFbTf6wkrn3s>)d2r!S`r-sImL0sK)1)CU|=J zN=5gP>8(Rdejy=`qjqU2;?FPf8sRAAcT3)N{a=DY`TMt9m&GQQc=>^LJm=p}Mv@VC z3H`SFwC}s!GIxdpv3gjEV{z6u5lxqeJvd3)5?apNC-NAlA-+?yBWT?Er z;s58{SDkh~kMe4FO@wqJ6*FLCkh|#bpjQ%*peHZ*55obD5`j}JOImJo2GN+~DdIUm(?WaB~D;DMyATSoz#kNMSZMX=EcbgPPIHWC|c_~|^Vcp!@b+F5sN zBNKKalScl;MFleXB$P1oF=F>WBw~>zXF`M8J_Bble0sjlv9@^^FE7FnOJp(|RTV`N zfBa7k?&QEM=us8Pl_HT3_%j^x34P4m#LPL9JAeP=CWX`>%VcBd^IYisB?a1MD;?No!g%2Ar zf_zn!7rS!!^t*;%kR7>2vv+hio(%^oa%7mfOH0JN48GB_(2`(Mtx%Vl+O7#xdEUr3 zQS!a2;YGu2y1h!2=X}D8>~q2Jb(3T;#Dx%Z15jfg-vEI30(TcohF7cSK7l(a2dAfU*uq~>)gZru1%w1&`zRathfvFbX zi;5fsyZLJlwklfb2fF`vf6V~DgvPANqeO31!*?+9Nf3_%nj3^!Z_UUyocMRa7fQDh z2OPhx%}@GK$BAD2W|7Y|;=+3r_6r47P$PNFn`L^5C_ZQ^6W|lxEd?1JS6qS(A+hGq zZ#@P-o?8)}d;g+V*X_=uHEdeqPUje6_}ecc$XwQ&?Y$1ApTZd^2G^1LzP zCZ%s|nABFP^Qq;Xqh1p{wc7i+P~p<*mW@TWZ!5=0kZzM2KHtVB7W^8uD>t}8l>)Q| za?E;;zoRXbFZ-p$blUM7;mrjIU;>}zuvCv61$IH_1rc+L%&NA6Cko7`;OSgENoU4I zVmP9iz14p{E7!)1_2(f$V_Z&aqTRIFqkDj|4ScxtNLyloM8eR+eg_*DslZM9JZef%t%~pdb{38-EL=UvR6?J~oVnbjmKmOJm|2 z*;155aaATk>{~A*&RC;Q{p-do2#Nf%Zb2NIio0w@ zE(dD+SanF^JWgEE9=^n@pI;ST^r5%1MP+dZa2G5IO=|IOa3;c4xx@Cx#z-y3EPgXy zE8J+qC<*hanxprN^)CW)R2?sfPxTN^bifI?C}L^ISdiUHsOnGvCjHA(Z)s#i|54$h zQWftf40!LLR9F$5RWu#vjQT)ULK>v0*8uWVJk{29&(La+S+-D)p{h_aaC;Ahh$67} z)y?22Fbt|M?F*#JP9lYmZafb~{wNh+AdYjs9n!*JK`30xNJ6)#*kJ|YzixyD{xSLQ z2N&6|FeQ3eITzy+t^^RUM!lQw|A`_xav8=>1eQ3KK2kP-=g#i@fd)6=z)0iv$J8^kK`-yV=RY`?~@O!835uyF4IQ8w_A+r#<#)qGnUh{7(4RjVg?bxg!<21ylgT4XeWMcepfn?j4dNr_4q;e zMB%mSW--JApW2wufm2i4(*0ubYm~FK$E&saV9KB=L6h|dBKR)^v+gqkcbVoS!+Jb% zQ%xnf&vtTgob2zKGIj0cqBuD(NzpL!T^Km%@rHvl7VD%am~GPL1=sUKm2-;DDkNrX|)yRb~CpU$Da z*^<$nqLCwuj3I%0L-tDu!%xI7SOUG&Fltn-o)ayOmWp0fhN5{JJO2BNfz3sX+|M7K zDy#eLh_RclXig~hL>KreoyQ^8L}W81yXXS$R{9*!V38v&eG57)YII87l85&(S;aNmzqB zhk$1uNZ!}qQf&i%)c@QK)ng33y(0UI3>XxcoIJ@=cHJFG49IY(Rp6%M0ATt$CNDI$ z<0Z7`Cu9{+4ESizkMV%l(5(tu&%ZD;7(H~$jlm6bdc?rQLW&QkLhKzF*!Sul9N9g7 zzyVGXE8Y}3<)wrPOCOp#!bx#Hhr5;hLf(v0?L+KFXgjd{nf6x@w z^5vxOGC%s{GnmW}CGLkUcXp9?CWBa(ys%g{jsX7v%QuPG zdNn-2_;H|O>9WRndK;)zVzriQ)}A&s;_;9~kjIQ46OXE|vT~Y*zx(!@0q?%CVkJNcGxm;y8Z=kIPnr zndYJvB3jx5yN8n9zNIFy@LKY~kTAb&dfR*>ex$S(r~Yg7+cdTUnuZRyU@0M#JPv_b zvvcEvc?yAOnm`Lw7?Y7j3-Am`~^EzmKs%&hHxzKLbD2zC?)Qt&R=v zk?+b4ik7v@2_#F3J@PGQ|P8NuSFLcq(59c#KLN(r_ZT7@Zh6;=frfe zw%N7hr?rDec}?pbbyj8?+7p2MIKyaYXdu7TOn#+*y>c~zeHr3uq3LzFy(;<~HlqrnxtKcU5uL(56I@5YxN#|tMq>VR9m^s zkI6bt`bjmTva^l9d)DlKUH~e!JEhP*1dB~A=Tq47Lqfva%7>eF6N2Prf$rehl?bEB z21$fyg%-pcwx2Z`b!fSkGG{lm8{48HB?Jq+Ljlr(#>?Ic8JQ+pF)&Kt3UCas@;oob z>*b;Nega27b)G9qjC>>fs9ff;J^aU?h5wA)OR6QwWd(jC%p8!YZthfLQMrkIK7YJ% ztXn4Q0JWw~ChA$vg08SYwA0W&jADa?^S?gj9Pnxs7X#Up;Zx_9QX90 zg`7BPcjJYmOo3LBNnTu+@&9N#%b>QpwhIR>1S=3AI7N!P77etxyE_#3;_ehJ-r`P+ zLveR4UZl9YyYrprn|WvQiy0C~&dJ_;-Pc;{qqxqb z?MkgPDw#qZ!n3G0ezGaAp3lHo#^-BeQ1`$o97?!=!8#in_bts%wbqp)03Cw;r)6o-0hop0TiM(XM*Y?iWd;98|FV9F`&?;XK}HuVlBl z0Z6I?{;%#CuaEwc&p-@f4;k-ffV{r(Of3}dOMlJ?Unx$fq#MzKB{~`36!zP9U`QZ# z0EeECHsej@5pdlxn*yC2=?fzl@yCJV7OY!-*#SIWI-}Zl4@(w!p;f-CdAkVrYi)_# zoC4LCUBAisXwnWz900kQ|xG;z$)y%%Ej0FcN=E)zIawE`-MINbHzsRjB7vD zR%5DN>pZ|Q3(4byIs4JS-L2`uk!fQOZyo-jFFC7iRJCj)(sOFc>VhJ&OWYV9g(u3x z(wpPWwbi}ExaJ@BI-=u$h;VM}8dhr;s4p1YIhP=?>POZwqxo0}Zr8n0oY4i%U)C4j zDs}8;j|V<%*EhwWT@0KHUUrRtlb){g|H?D4vGSaFUQ_FRxxMo?#lMIa>Baz4Wk$wW zjYX;2%KCde%xWAqVyE1S9E?hvnnEpIwRrMYoCxUX9@LzOFM_0O4)&EoN=kbF9}VjJ z+_D`lxPBM=*^Gb)z6XjIW^dL&K=IYIOf$*q{WuyRA}>w{_85IbGh?wz^ zIv^S5{0XL}s&ISl6;oVHdxSMUFCvtG=+!G7Ir@1NsYmQ%D&*a?UKQi86^Ujke4lkp zk8#{Uu_VdO8{jET_ul9k8df7|z0+VXwnm~P)kP9ticO&ID$&Y4ZTNXeDuh5!AF?jE(daH(MXOAgSIpRkXiIO5u*(rycp}RSCfq$c+6t05+HN zWr)O6D~K;ZEjFe(6GjilXnk)^E0ML$mXjFVT=EmMYyT6f`U6?muJDj*fGK7UgpX~C zLB6_3trkg>#a@8nySpG+nIw2JMHflt_eh+w0_LQStgVk2KRP6Mp`I@*j&39&y2Lk* zDF*v_SLA73QPyagbdi7kO|PD&=3{AKH0BH12%WfQ)v(M zxetR)r(ty%Q`6Yup8d^Mj=GwSR=S@)hwSs$s>kfs_bKX!i!_kFOvT+91?*CbwitXg zG~7HntUNz<@C$wg41$~4fR@*L`f^Twez^LCyy|m_VbJCkdTcLAMAZWNC*u$QzO_2h zX9%b}+q9LK)ftrYWl_{B2m74&5qv=(78|oaT6s@JG4fJMLM8Q9^$#AnvZC@FK-o4= zjs>=O=lGl$W*e=(S;u@LOxlcq3s7e9T1glbl#+=}f5pzlfe#>Hx-Ma01i=ipDjEqd zQ#Ra@p0431TD~;X`21Q!LR4539~_-ZmBp95CtGpJR#h?^dv@!V+_4!RBTo^X#sO)= z+opnsXhv-3Yn?T!r2Y49_g52}zF~-uo;H2jOj5A%)&D;IE5Empz3r|YiI6_{B<^+U ziG{wXW)@!Vgf|j3+iTUU9%_@BW-#4!VSYQcE7LSRH-2tu6sI}X$YIDDo9f$+nZ&Qy z^o^apK8~T$Kb|a<06V`ETuyCmMgoPDsOPn$#8+3 zAPI{G_q6VV?-%L%wt}Yzd-jb5D=50Ic`AE|k#`@Sbxba9aIC(0Y03Lr)CwLZ2}W!( zRLzCulX^9+vWgt4nA~E1cy;yM3dW>ePKFAdmPO^ynS8My``m-@e2f@5l)&1kTC&rS z!)tyY3+ZG7s>!qfD>ST1RJ!j=E$1W-?{r_<~0)Z0Le4(wr#ciF~! zP{EAV$FqT&0?7-Qk3s{~-yUVVE5-`uWY(26Z5gNH%hNK72(vSK0TzA$7mqgaOKE{t z?};a=r|EW_Zb-;gUzttQG<{= z{LXEgZSwuoEm^sbGldYVf5Z=#U_E}B>}15f+4#TbU$x0itUG3(>@hkEhAnHjvuQDpJz z?$J>Udj|)-2X+G{*|d8z3d=WZO~j)Bx?7I37A#i?`Hxkt8w8-pQKG(*D&n~Hb=v#Q zB)F!f_v4`Sdr?NDQCd9D6jOI`apij6Il6iQE)idl@UrPoneQb*C;aI7T|XiG8Vnl| zOmCn+53*E0fl&WIl+1XgzHWq5!l`N8-JMgdbohrejqGf49*3hob}cuI|0-lk( z%jC;b9dzHJ!+qb4Q-%N0-!`b>F6ytI?XHDB!FC9$ANTYvUyRXIR%iw14|aFvZZr9? zQ?#GhjMJ1_@I^CR)sDO7BF_6^`3_cczKpjjvf1t@7kgUPv|bMfG-8euO8@$Um>+BtCdK5KNceyj*LKu{BFiaDE9 z2&f_{X;bmhsxqAdH>0eWVnj)B12kon>oc2sC$Q+oX=+slqu{kqR5D~>)pRC)azPnN z(z)fA_r2$|Nv;-9?t^AA5x#{4zZ}(A>d)JfqE3x5Dqp~eoy}U(F^CLGl2)gio%~fl zue~+uf88k)Al9HVlM7pd7cm$048Dt{&$ZSDHz}pnpEk(990&ofHSxMs8l;%N+-YM- zpb>#S5!sLwAq=)WNPx@ptr}&^DLW>q+BC(ETDJ1)#~1`0g-Na}(zJ?v*lPPg6Zsim z?9b$G=)l%*SC<&gN`v*C8~g@$_q&S`Rwq|ibE?UGIu1n9nt)=-rk8GfHd`aMn}WA9 z@;1KI;;0ik`6Y~B?4npZC|6}1Y&<+$W8jV^_bxJq3X{9tS^)5+Pt(vpcpy$>Jseph^gg((%7vzr{;RQfDvQ4m3%z30(?*9;f84ZpOkzv7>09B#YV<7i#PcNoQRk)Z zFNRgE!qQC#mM)t+j{81OhV2<4ykz4{nM8C^ktAou_&YA8_%CO>p2HUHhfjNO8~=Qb zcuNCTQ*ZNS+eq7-wE30OE|g%wIl7$dikr9pyko=XGXKgK2#%0&u8T?;%Ra(ChHsJ< zqQc^cWQZ+I**HK^CKZ4Azlr|_%$f=`Y6ZEbnwfYjDw8r`;mMZAET884I?IE3c#W_A zHtERPDaI{rV*0#5hEnI~47Z6}s`;*zqQVp&I~y@S|F$MW%_fUCCOsT|{URw)t}49j z-yJ9N5^;EF^ZlGig1BUfD1I?2mdhxE2_Bu2Y%v1PIqlo()4flB$LkpakQMRmGwQAv z>o)?kzO3^Yl?sVYsV8?A-{51fU>EI;mvu6cmpcT?QLFct87^xA2zjOYxED!~p2&P? zsY&Sa4LB|ii;q;X9y(`TS5Gfd9zPdQLK?iJM%^eBNo4~n`>%PB6Zo6*5UJEduby=a ztPU28$#BKt!7F;5@VCGWyWZ;T>@Hw)KjOlte{`9-<_Ry4TB6`?D@4@oTcHd>8g3MD zc-|5Z9QBH-5!`f~->edW2PC^z&lpR2yqdZl%Ad&ft8Oe>$d={t1-x37bB*oI8?arf zgI$X7tli zBSD$9R)-Q2v&RhG1u3J_Vet#D~D^4^$fA}1(CPA3V zyC2}yk6e>>n|yhvbi3thugtQM(BhI#41QL$w{S0A4^6sjBF$n1YEL%3^DYnL?H~EC zv|LH^nkA-qq*^eobKr3^XXhJ8F-g_m**}oYuu%+w&E{-cJDR#44#4Tln82xF)GeyC zl$7-)Xhgd>!*I^(1T!aW^Vp$vx|%1a_9xz(-$rKg%FdX?Dmb+&U!GES>%DU1Vk)T@ zuFUz-INRUb3)eeP-%SPhMapsgMSESH<_t%~5n zK?9?_^Bps*8O!Cutb_cHJ~*I{O(TDpV=hQoik_0Wk79-kIuKdjgZn<6S9dNyj=5vk zI$jjsBA#;ncDfSaMzE7L@{wj4)z$M6MfnB?!BnOURs0lw~r? z>fdugYvpCaR%pkAQ~&H(IDR)2Vv0i-;fLiPW{{Ke;+$tW+n#SFc2R%^$A~D{>9Ti8 z#Dd_x`TRQdn2OGoYG%qu`#0eE(c&!eEz?8S)NRBnW^fjh>XaMejc3j|W|^T2YN@E%b48rP4c(nrH!B1V^ur)~l@ zQ^c^q<=}!3$6!+$X)xR`hm}@Qkt?lA-Uv#D_iilX5&6)$7Dk)-$S+*VJ3q^%YyuQ% zE5dDt4U&qbR8@k~Ql3T=sl`*w;O^=llXwl^^YHBPMGh*>yOGkrWwSg8!}X#wqc;sx zhSh{=sNGGodGN@*lT`(Vi!f!TH?T%iE`&d9CVNB6bh4aKy68=#-$FkohZsJfey#1l zzQzD_!SgRc>`6fHO&%l-l~9$Y)Ed-`0T+fsglgfTqC>@>IpK!!kW~U-th7{7Q_RTY zN;J6GJvJl-7ESv+Z|5GdiCBzl&QS87)MNrRiS7xAu;KZnGnKCT>g)NDR+c_8$V@+F z2Vhh-QNGx{`Mu#GSJ433M~S%m)M@!sIn)P7JU|9pk>CR}xR^P7#Mj9p+c3&7zHZB6 z)gc*9G2uLHSC=+9uEa;FOHNM{@4s@}3q^h9g%Q6Tblx@;;_7}VF)l@Kj9{+No0ixf z6m*0ytM3kU_KNzfkK)})H=5tO=j2Lq##-h$#!@adkdi%QIp6IfCFFJPJRtMByL(*T zV4R#7tUo-Y#YHuZ*nkq@q?F%+33%7r{sd{ozn zsFi4E`1aeCCL_(!Gz1)&UY*WCa(2Co0l%hSCxb*W>O!p@#wU-Bhh&Wq?f=Y}_sp3v z3FyUY&qk`l2U5E0ir%r0i#0R73p4{B{ZPPh!T;X-c8qLu3@)?EXx;*@#^m{T`y{LF zL@KCv%0<}dQFlSF=0NYuhj^ON#LxGi+Ro77^{tTEAdn{FCv+nVt4<`P8yB+Mhr?)m z%#erWoJ5S%LfN@mRav^>c4N3N-RJ!mE>l~2^$LpDM-8~9=xwHy!9r`dy!xMPCuY^) zntrOSaZW#VH~!d{^*iC-Y`7oDRt;NP*$6g%179q2qaw0hVD%Zg`B;q1-9aiafQyf# z^xIO3?K6Hy2*JXGrzV@V!Vh#W!BZf@YVhH#D^xOrTkeBvf4L?cWghy~$2Xj@TS4*f zS&SuDdjfuAW8^14>ew(u)X~_mff)Yw&O{bcRw7>VxW>d~3scINz~_ivnjH_)Yd|C; z5+asD?>{?vCJ6T`qnj0gIzgTWFa=`ZGzJvBOxz0bEHYnkeI%iw5PBE=0dCFbnkpuJ zGKZL-ny~5R#y9Tyl1xTfK97?1n|Tw`Y0#UDWL+v1@Glvl+}4c$Ev=ejZUqq_)%-qG zi(R%4=NfwJ!taF9m$PW31~bbzjHiP=_Q2^VTdNGQaa{?y36#jcm0TH$qFmszxJ<04 zR*eU=h`J=A4EEuBwQgwe@#iS6ZKiOyA?zN;#%X`R=xyT_M@PRgyL^i*C@0%2_Ihsr$u zt2N*tj7<&{OWD@55fx=5M*{`y+`VnAQ=Q?{r2X<7V9dt0N4R^P;g>WuM;ZA{BX!x; z$cm3)@cs`+LdR@*03w14>-CQ^&j_SshYWBbTF`J)bXB8LfoNo0yZ7wx01csYZqT~_29nJn&VC= z5&L^PL8XmU;?#S|wr0Az=>$C!g#HDyDTe^g@=CvRjF%Vpjvn9u2&AjjF#YcSHe1D5 zu^u+w7$Zq<^XGE6pN!BOoamo{3DTd-XOdMwvtDZ9vWo>|?#+QXV26AA-%YCiE{U)P zq!ju}H2jz9^it9NNRRzePM@j(tL7hEr}giy7Q-j_#YNqx`;HgQr|s95jw*YGPaos3 zGU#a{>CgBg>9lkcYlHq85lU{d3ko(zxYx}BA`@)Rt+A=C@UKD2dpM_IFxyD!R2eUR zg`L08o#&}bcuOkB(;eC;ZGH=*@z~Tq4WOlOVIV}OSBuDqi=EG+fm+szNeUfUIwOzk zijBwP&H}l0s?7&@DOtg+r?&*md8`4B`xPzBwRz)N;008=P8z7~>C{4CG=UXXGH7%3 zH^M>H#Y9l)+vkN-c+&{lX$#X6JGKgD;kfhJU!+uztu=ys{S&8At?vp(+sx8Z@G=O+ zRj>s-9CXjK3hl($U-{d)QarooqIA2zIHViv5fUrPRzI=)Wvc2?6K<@J(btLmsR>B>?zF_*Os#;~A1<|n5s$N8b0AD^IW3q)` zLT0>TSljP{zOGMN@TMrUp$|UO#!_(&Z^zB0;TA@#@D9#LwkM?N2CC?t-g-aXufAXa z5U|Jf0bcvlh3xkv9GzZ%WEv14f#uXmn4wsfiZbjmTrn3yg)(FB&IicPXEY7hN7H!= zsvMKehayB`ZOfpLs+60yHG`+fN}3r4I`Yo`bJMUR>TGr zkT5D&Ti3O~Dm#>;FMjc@ZVve^F6a@dd|w^lA}j|eLL_( zR;a{7L(iaRRLQ_7xFkX$ud27Y1X$DGO@o68-_obLf+a-bsPaT!&g1-@C6iKEX>EjR zu@ubsL^wR9^CQxSB(NEBEhrIgyFn70oySl!3+CIPw{Gg$sVcx!BP#e3-s_Q*WAOo!I$0r;2 zrjZ#6qubjKfdvJR)O7PjW1^qg^gDf8j7OU(opWz&YFf{ydjmZx(>7q&7}SN?1f?JQ z3P0oD4Ov7pn7IINzEzegNS=g>3^`g~cA(Y?9hWvk@q@≤_(X$?3tWfQI(zmpgV? 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 P0)7)VLLxcVYx$YgK literal 0 HcmV?d00001 diff --git a/docs/source/how-to/cloudxr_teleoperation.rst b/docs/source/how-to/cloudxr_teleoperation.rst index 0b7c8c9c017c..3a03b2835896 100644 --- a/docs/source/how-to/cloudxr_teleoperation.rst +++ b/docs/source/how-to/cloudxr_teleoperation.rst @@ -382,6 +382,18 @@ Back on your Apple Vision Pro: motion of the dots and the robot may be caused by the limits of the robot joints and/or robot controller. + .. note:: + When the inverse kinematics solver fails to find a valid solution, an error message will appear + in the XR device display. To recover from this state, click the **Reset** button to return + the robot to its original pose and continue teleoperation. + + .. figure:: ../_static/setup/cloudxr_avp_ik_error.jpg + :align: center + :figwidth: 80% + :alt: IK Error Message Display in XR Device + + + #. When you are finished with the example, click **Disconnect** to disconnect from Isaac Lab. .. admonition:: Learn More about Teleoperation and Imitation Learning in Isaac Lab diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index d9bacd5c2537..ec01ffaaf8db 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -469,16 +469,24 @@ def stop_recording_instance(): label_text = f"Recorded {current_recorded_demo_count} successful demonstrations." print(label_text) + # Check if we've reached the desired number of demos + if args_cli.num_demos > 0 and env.recorder_manager.exported_successful_episode_count >= args_cli.num_demos: + label_text = f"All {current_recorded_demo_count} demonstrations recorded.\nExiting the app." + instruction_display.show_demo(label_text) + print(label_text) + target_time = time.time() + 0.8 + while time.time() < target_time: + if rate_limiter: + rate_limiter.sleep(env) + else: + env.sim.render() + break + # Handle reset if requested if should_reset_recording_instance: success_step_count = handle_reset(env, success_step_count, instruction_display, label_text) should_reset_recording_instance = False - # Check if we've reached the desired number of demos - if args_cli.num_demos > 0 and env.recorder_manager.exported_successful_episode_count >= args_cli.num_demos: - print(f"All {args_cli.num_demos} demonstrations recorded. Exiting the app.") - break - # Check if simulation is stopped if env.sim.is_stopped(): break @@ -506,6 +514,10 @@ def main() -> None: # if handtracking is selected, rate limiting is achieved via OpenXR if args_cli.xr: rate_limiter = None + from isaaclab.ui.xr_widgets import TeleopVisualizationManager, XRVisualization + + # Assign the teleop visualization manager to the visualization system + XRVisualization.assign_manager(TeleopVisualizationManager) else: rate_limiter = RateLimiter(args_cli.step_hz) diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 8b426e2d302b..e8b3ffbfd56a 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.13" +version = "0.45.14" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 6789061e9143..8aa0aef67677 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.45.14 (2025-09-08) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* * Added :class:`~isaaclab.ui.xr_widgets.TeleopVisualizationManager` and :class:`~isaaclab.ui.xr_widgets.XRVisualization` + classes to provide real-time visualization of teleoperation and inverse kinematics status in XR environments. + 0.45.13 (2025-09-08) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py index f37ebe163e19..6bb4228e4e87 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py @@ -173,6 +173,11 @@ def compute( "Warning: IK quadratic solver could not find a solution! Did not update the target joint" f" positions.\nError: {e}" ) + + if self.cfg.xr_enabled: + from isaaclab.ui.xr_widgets import XRVisualization + + XRVisualization.push_event("ik_error", {"error": e}) return torch.tensor(curr_joint_pos, device=self.device, dtype=torch.float32) # Discard the first 6 values (for root and universal joints) diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py index 5add83a59168..d5f36a91523a 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py @@ -62,3 +62,6 @@ class PinkIKControllerCfg: """If True, the Pink IK solver will fail and raise an error if any joint limit is violated during optimization. PinkIKController will handle the error by setting the last joint positions. If False, the solver will ignore joint limit violations and return the closest solution found.""" + + xr_enabled: bool = False + """If True, the Pink IK controller will send information to the XRVisualization.""" diff --git a/source/isaaclab/isaaclab/ui/xr_widgets/__init__.py b/source/isaaclab/isaaclab/ui/xr_widgets/__init__.py index 5b9b39ec156c..4375724f08f8 100644 --- a/source/isaaclab/isaaclab/ui/xr_widgets/__init__.py +++ b/source/isaaclab/isaaclab/ui/xr_widgets/__init__.py @@ -2,4 +2,6 @@ # All rights reserved. # # SPDX-License-Identifier: BSD-3-Clause -from .instruction_widget import SimpleTextWidget, show_instruction +from .instruction_widget import hide_instruction, show_instruction, update_instruction +from .scene_visualization import DataCollector, TriggerType, VisualizationManager, XRVisualization +from .teleop_visualization_manager import TeleopVisualizationManager diff --git a/source/isaaclab/isaaclab/ui/xr_widgets/instruction_widget.py b/source/isaaclab/isaaclab/ui/xr_widgets/instruction_widget.py index 65de79f155b2..ec084098dcb9 100644 --- a/source/isaaclab/isaaclab/ui/xr_widgets/instruction_widget.py +++ b/source/isaaclab/isaaclab/ui/xr_widgets/instruction_widget.py @@ -22,21 +22,63 @@ class SimpleTextWidget(ui.Widget): - def __init__(self, text: str | None = "Simple Text", style: dict[str, Any] | None = None, **kwargs): + """A rectangular text label widget for XR overlays. + + The widget renders a centered label over a rectangular background. It keeps + track of the configured style and an original width value used by + higher-level helpers to update the text. + """ + + def __init__( + self, + text: str | None = "Simple Text", + style: dict[str, Any] | None = None, + original_width: float = 0.0, + **kwargs + ): + """Initialize the text widget. + + Args: + text (str): Initial text to display. + style (dict[str, Any]): Optional style dictionary (for example: ``{"font_size": 1, "color": 0xFFFFFFFF}``). + original_width (float): Width used when updating the text. + **kwargs: Additional keyword arguments forwarded to ``ui.Widget``. + """ super().__init__(**kwargs) if style is None: style = {"font_size": 1, "color": 0xFFFFFFFF} self._text = text self._style = style self._ui_label = None + self._original_width = original_width self._build_ui() def set_label_text(self, text: str): - """Update the text displayed by the label.""" + """Update the text displayed by the label. + + Args: + text (str): New label text to display. + """ self._text = text if self._ui_label: self._ui_label.text = self._text + def get_font_size(self): + """Return the configured font size. + + Returns: + float: Font size value. + """ + return self._style.get("font_size", 1) + + def get_width(self): + """Return the width used when updating the text. + + Returns: + float: Width used when updating the text. + """ + return self._original_width + def _build_ui(self): """Build the UI with a window-like rectangle and centered label.""" with ui.ZStack(): @@ -47,14 +89,20 @@ def _build_ui(self): def compute_widget_dimensions( text: str, font_size: float, max_width: float, min_width: float -) -> tuple[float, float, list[str]]: - """ - Estimate widget dimensions based on text content. +) -> tuple[float, float, str]: + """Estimate widget width/height and wrap the text. + + Args: + text (str): Raw text to render. + font_size (float): Font size used for estimating character metrics. + max_width (float): Maximum allowed widget width. + min_width (float): Minimum allowed widget width. Returns: - actual_width (float): The width, clamped between min_width and max_width. - actual_height (float): The computed height based on wrapped text lines. - lines (List[str]): The list of wrapped text lines. + tuple[float, float, str]: A tuple ``(width, height, wrapped_text)`` where + ``width`` and ``height`` are the computed widget dimensions, and + ``wrapped_text`` contains the input text broken into newline-separated + lines to fit within the width constraints. """ # Estimate average character width. char_width = 0.6 * font_size @@ -66,7 +114,8 @@ def compute_widget_dimensions( actual_width = max(min(computed_width, max_width), min_width) line_height = 1.2 * font_size actual_height = len(lines) * line_height - return actual_width, actual_height, lines + wrapped_text = "\n".join(lines) + return actual_width, actual_height, wrapped_text def show_instruction( @@ -77,29 +126,29 @@ def show_instruction( max_width: float = 2.5, min_width: float = 1.0, # Prevent widget from being too narrow. font_size: float = 0.1, + text_color: int = 0xFFFFFFFF, target_prim_path: str = "/newPrim", ) -> UiContainer | None: - """ - Create and display the instruction widget based on the given text. + """Create and display an instruction widget with the given text. - The widget's width and height are computed dynamically based on the input text. - It automatically wraps text that is too long and adjusts the widget's height - accordingly. If a display duration is provided (non-zero), the widget is automatically - hidden after that many seconds. + The widget size is computed from the text and font size, wrapping content + to respect the width limits. If ``display_duration`` is provided and + non-zero, the widget is hidden automatically after the duration elapses. Args: - text (str): The instruction text to display. - prim_path_source (Optional[str]): The prim path to be used as a spatial sourcey - for the widget. - translation (Gf.Vec3d): A translation vector specifying the widget's position. - display_duration (Optional[float]): The time in seconds to display the widget before - automatically hiding it. If None or 0, the widget remains visible until manually - hidden. - target_prim_path (str): The target path where the copied prim will be created. - Defaults to "/newPrim". + text (str): Instruction text to display. + prim_path_source (str | None): Optional prim path used as a spatial source for the widget. + translation (Gf.Vec3d): World translation to apply to the widget. + display_duration (float | None): Seconds to keep the widget visible. If ``None`` or ``0``, + the widget remains until hidden manually. + max_width (float): Maximum widget width used for wrapping. + min_width (float): Minimum widget width used for wrapping. + font_size (float): Font size of the rendered text. + text_color (int): RGBA color encoded as a 32-bit integer. + target_prim_path (str): Prim path where the widget prim will be created/copied. Returns: - UiContainer: The container instance holding the instruction widget. + UiContainer | None: The container that owns the instruction widget, or ``None`` if creation failed. """ global camera_facing_widget_container, camera_facing_widget_timers @@ -121,9 +170,7 @@ def show_instruction( if get_prim_at_path(target_prim_path): delete_prim(target_prim_path) - # Compute dimensions and wrap text. - width, height, lines = compute_widget_dimensions(text, font_size, max_width, min_width) - wrapped_text = "\n".join(lines) + width, height, wrapped_text = compute_widget_dimensions(text, font_size, max_width, min_width) # Create the widget component. widget_component = WidgetComponent( @@ -131,7 +178,7 @@ def show_instruction( width=width, height=height, resolution_scale=300, - widget_args=[wrapped_text, {"font_size": font_size}], + widget_args=[wrapped_text, {"font_size": font_size, "color": text_color}, width], ) copied_prim = omni.kit.commands.execute( @@ -160,17 +207,24 @@ def show_instruction( # Schedule auto-hide after the specified display_duration if provided. if display_duration: - timer = asyncio.get_event_loop().call_later(display_duration, functools.partial(hide, target_prim_path)) + timer = asyncio.get_event_loop().call_later( + display_duration, functools.partial(hide_instruction, target_prim_path) + ) camera_facing_widget_timers[target_prim_path] = timer return container -def hide(target_prim_path: str = "/newPrim") -> None: - """ - Hide and clean up a specific instruction widget. - Also cleans up associated timer. +def hide_instruction(target_prim_path: str = "/newPrim") -> None: + """Hide and clean up a specific instruction widget. + + Args: + target_prim_path (str): Prim path of the widget to hide. + + Returns: + None: This function does not return a value. """ + global camera_facing_widget_container, camera_facing_widget_timers if target_prim_path in camera_facing_widget_container: @@ -180,3 +234,44 @@ def hide(target_prim_path: str = "/newPrim") -> None: if target_prim_path in camera_facing_widget_timers: del camera_facing_widget_timers[target_prim_path] + + +def update_instruction(target_prim_path: str = "/newPrim", text: str = ""): + """Update the text content of an existing instruction widget. + + Args: + target_prim_path (str): Prim path of the widget to update. + text (str): New text content to display. + + Returns: + bool: ``True`` if the widget existed and was updated, otherwise ``False``. + """ + global camera_facing_widget_container + + container_data = camera_facing_widget_container.get(target_prim_path) + if container_data: + container, current_text = container_data + + # Only update if the text has actually changed + if current_text != text: + # Access the widget through the manipulator as shown in ui_container.py + manipulator = container.manipulator + + # The WidgetComponent is stored in the manipulator's components + # Try to access the widget component and then the actual widget + components = getattr(manipulator, "_ComposableManipulator__components") + if len(components) > 0: + simple_text_widget = components[0] + if simple_text_widget and simple_text_widget.component and simple_text_widget.component.widget: + width, height, wrapped_text = compute_widget_dimensions( + text, + simple_text_widget.component.widget.get_font_size(), + simple_text_widget.component.widget.get_width(), + simple_text_widget.component.widget.get_width(), + ) + simple_text_widget.component.widget.set_label_text(wrapped_text) + # Update the stored text in the global dictionary + camera_facing_widget_container[target_prim_path] = (container, text) + return True + + return False diff --git a/source/isaaclab/isaaclab/ui/xr_widgets/scene_visualization.py b/source/isaaclab/isaaclab/ui/xr_widgets/scene_visualization.py new file mode 100644 index 000000000000..2cac77b859bc --- /dev/null +++ b/source/isaaclab/isaaclab/ui/xr_widgets/scene_visualization.py @@ -0,0 +1,609 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import contextlib +import inspect +import numpy as np +import threading +import time +import torch +from collections.abc import Callable +from enum import Enum +from typing import Any, Union + +import omni.log +from pxr import Gf + +from isaaclab.sim import SimulationContext +from isaaclab.ui.xr_widgets import show_instruction + + +class TriggerType(Enum): + """Enumeration of trigger types for visualization callbacks. + + Defines when callbacks should be executed: + - TRIGGER_ON_EVENT: Execute when a specific event occurs + - TRIGGER_ON_PERIOD: Execute at regular time intervals + - TRIGGER_ON_CHANGE: Execute when a specific data variable changes + - TRIGGER_ON_UPDATE: Execute every frame + """ + + TRIGGER_ON_EVENT = 0 + TRIGGER_ON_PERIOD = 1 + TRIGGER_ON_CHANGE = 2 + TRIGGER_ON_UPDATE = 3 + + +class DataCollector: + """Collects and manages data for visualization purposes. + + This class provides a centralized data store for visualization data, + with change detection and callback mechanisms for real-time updates. + """ + + def __init__(self): + """Initialize the data collector with empty data store and callback system.""" + self._data: dict[str, Any] = {} + self._visualization_callback: Callable | None = None + self._changed_flags: set[str] = set() + + def _values_equal(self, existing_value: Any, new_value: Any) -> bool: + """Compare two values using appropriate method based on their types. + + Handles different data types including None, NumPy arrays, PyTorch tensors, + and standard Python types for accurate change detection. + + Args: + existing_value: The current value stored in the data collector + new_value: The new value to compare against + + Returns: + bool: True if values are equal, False otherwise + """ + # If both are None or one is None + if existing_value is None or new_value is None: + return existing_value is new_value + + # If types are different, they're not equal + if type(existing_value) is not type(new_value): + return False + + # Handle NumPy arrays + if isinstance(existing_value, np.ndarray): + return np.array_equal(existing_value, new_value) + + # Handle torch tensors (if they exist) + if hasattr(existing_value, "equal"): + with contextlib.suppress(Exception): + return torch.equal(existing_value, new_value) + + # For all other types (int, float, string, bool, list, dict, set), use regular equality + with contextlib.suppress(Exception): + return existing_value == new_value + # If comparison fails for any reason, assume they're different + return False + + def update_data(self, name: str, value: Any) -> None: + """Update a data field and trigger change detection. + + This method handles data updates with intelligent change detection. + It also performs pre-processing and post-processing based on the field name. + + Args: + name: The name/key of the data field to update + value: The new value to store (None to remove the field) + """ + existing_value = self.get_data(name) + + if value is None: + self._data.pop(name) + if existing_value is not None: + self._changed_flags.add(name) + return + + # Todo: for list or array, the change won't be detected + # Check if the value has changed using appropriate comparison method + if self._values_equal(existing_value, value): + return + + # Save it + self._data[name] = value + self._changed_flags.add(name) + + def update_loop(self) -> None: + """Process pending changes and trigger visualization callbacks. + + This method should be called regularly to ensure visualization updates + are processed in a timely manner. + """ + if len(self._changed_flags) > 0: + if self._visualization_callback: + self._visualization_callback(self._changed_flags) + self._changed_flags.clear() + + def get_data(self, name: str) -> Any: + """Retrieve data by name. + + Args: + name: The name/key of the data field to retrieve + + Returns: + The stored value, or None if the field doesn't exist + """ + return self._data.get(name) + + def set_visualization_callback(self, callback: Callable) -> None: + """Set the VisualizationManager callback function to be called when data changes. + + Args: + callback: Function to call when data changes, receives set of changed field names + """ + self._visualization_callback = callback + + +class VisualizationManager: + """Base class for managing visualization rules and callbacks. + + Provides a framework for registering and executing callbacks based on + different trigger conditions (events, time periods, data changes). + """ + + # Type aliases for different callback signatures + StandardCallback = Callable[["VisualizationManager", "DataCollector"], None] + EventCallback = Callable[["VisualizationManager", "DataCollector", Any], None] + CallbackType = Union[StandardCallback, EventCallback] + + class TimeCountdown: + """Internal class for managing periodic timer-based callbacks.""" + + period: float + countdown: float + last_time: float + + def __init__(self, period: float, initial_countdown: float = 0.0): + """Initialize a countdown timer. + + Args: + period: Time interval in seconds between callback executions + """ + self.period = period + self.countdown = initial_countdown + self.last_time = time.time() + + def update(self, current_time: float) -> bool: + """Update the countdown timer and check if callback should be triggered. + + Args: + current_time: Current time in seconds + + Returns: + bool: True if callback should be triggered, False otherwise + """ + self.countdown -= current_time - self.last_time + self.last_time = current_time + if self.countdown <= 0.0: + self.countdown = self.period + return True + return False + + # Widget presets for common visualization configurations + @classmethod + def message_widget_preset(cls) -> dict[str, Any]: + """Get the message widget preset configuration. + + Returns: + dict: Configuration dictionary for message widgets + """ + return { + "prim_path_source": "/_xr/stage/xrCamera", + "translation": Gf.Vec3f(0, 0, -2), + "display_duration": 3.0, + "max_width": 2.5, + "min_width": 1.0, + "font_size": 0.1, + "text_color": 0xFF00FFFF, + } + + @classmethod + def panel_widget_preset(cls) -> dict[str, Any]: + """Get the panel widget preset configuration. + + Returns: + dict: Configuration dictionary for panel widgets + """ + return { + "prim_path_source": "/XRAnchor", + "translation": Gf.Vec3f(0, 2, 2), # hard-coded temporarily + "display_duration": 0.0, + "font_size": 0.13, + "max_width": 2, + "min_width": 2, + } + + def display_widget(self, text: str, name: str, args: dict[str, Any]) -> None: + """Display a widget with the given text and configuration. + + Args: + text: Text content to display in the widget + name: Unique identifier for the widget. If duplicated, the old one will be removed from scene. + args: Configuration dictionary for widget appearance and behavior + """ + widget_config = args | {"text": text, "target_prim_path": name} + show_instruction(**widget_config) + + def __init__(self, data_collector: DataCollector): + """Initialize the visualization manager. + + Args: + data_collector: DataCollector instance to access the data for visualization use. + """ + self.data_collector: DataCollector = data_collector + data_collector.set_visualization_callback(self.on_change) + + self._rules_on_period: dict[VisualizationManager.TimeCountdown, VisualizationManager.StandardCallback] = {} + self._rules_on_event: dict[str, list[VisualizationManager.EventCallback]] = {} + self._rules_on_change: dict[str, list[VisualizationManager.StandardCallback]] = {} + self._rules_on_update: list[VisualizationManager.StandardCallback] = [] + + # Todo: add support to registering same callbacks for different names + def on_change(self, names: set[str]) -> None: + """Handle data changes by executing registered callbacks. + + Args: + names: Set of data field names that have changed + """ + for name in names: + callbacks = self._rules_on_change.get(name) + if callbacks: + # Create a copy of the list to avoid modification during iteration + for callback in list(callbacks): + callback(self, self.data_collector) + if len(names) > 0: + self.on_event("default_event_has_change") + + def update_loop(self) -> None: + """Update periodic timers and execute callbacks as needed. + + This method should be called regularly to ensure periodic callbacks + are executed at the correct intervals. + """ + + # Create a copy of the list to avoid modification during iteration + for callback in list(self._rules_on_update): + callback(self, self.data_collector) + + current_time = time.time() + # Create a copy of the items to avoid modification during iteration + for timer, callback in list(self._rules_on_period.items()): + triggered = timer.update(current_time) + if triggered: + callback(self, self.data_collector) + + def on_event(self, event: str, params: Any = None) -> None: + """Handle events by executing registered callbacks. + + Args: + event: Name of the event that occurred + """ + callbacks = self._rules_on_event.get(event) + if callbacks is None: + return + # Create a copy of the list to avoid modification during iteration + for callback in list(callbacks): + callback(self, self.data_collector, params) + + # Todo: better organization of callbacks + def register_callback(self, trigger: TriggerType, arg: dict, callback: CallbackType) -> Any: + """Register a callback function to be executed based on trigger conditions. + + Args: + trigger: Type of trigger that should execute the callback + arg: Dictionary containing trigger-specific parameters: + - For TRIGGER_ON_PERIOD: {"period": float} + - For TRIGGER_ON_EVENT: {"event_name": str} + - For TRIGGER_ON_CHANGE: {"variable_name": str} + - For TRIGGER_ON_UPDATE: {} + callback: Function to execute when trigger condition is met + - For TRIGGER_ON_EVENT: callback(manager: VisualizationManager, data_collector: DataCollector, event_params: Any) + - For others: callback(manager: VisualizationManager, data_collector: DataCollector) + + Raises: + TypeError: If callback signature doesn't match the expected signature for the trigger type + """ + # Validate callback signature based on trigger type + self._validate_callback_signature(trigger, callback) + + match trigger: + case TriggerType.TRIGGER_ON_PERIOD: + period = arg.get("period") + initial_countdown = arg.get("initial_countdown", 0.0) + if isinstance(period, float) and isinstance(initial_countdown, float): + timer = VisualizationManager.TimeCountdown(period=period, initial_countdown=initial_countdown) + # Type cast since we've validated the signature + self._rules_on_period[timer] = callback # type: ignore + return timer + case TriggerType.TRIGGER_ON_EVENT: + event = arg.get("event_name") + if isinstance(event, str): + callbacks = self._rules_on_event.get(event) + if callbacks is None: + # Type cast since we've validated the signature + self._rules_on_event[event] = [callback] # type: ignore + else: + # Type cast since we've validated the signature + self._rules_on_event[event].append(callback) # type: ignore + return event + case TriggerType.TRIGGER_ON_CHANGE: + variable_name = arg.get("variable_name") + if isinstance(variable_name, str): + callbacks = self._rules_on_change.get(variable_name) + if callbacks is None: + # Type cast since we've validated the signature + self._rules_on_change[variable_name] = [callback] # type: ignore + else: + # Type cast since we've validated the signature + self._rules_on_change[variable_name].append(callback) # type: ignore + return variable_name + case TriggerType.TRIGGER_ON_UPDATE: + # Type cast since we've validated the signature + self._rules_on_update.append(callback) # type: ignore + return None + + # Todo: better callback-cancel method + def cancel_rule(self, trigger: TriggerType, arg: str | TimeCountdown, callback: Callable | None = None) -> None: + """Remove a previously registered callback. + + Periodic callbacks are not supported to be cancelled for now. + + Args: + trigger: Type of trigger for the callback to remove + arg: Trigger-specific identifier (event name or variable name) + callback: The callback function to remove + """ + callbacks = None + match trigger: + case TriggerType.TRIGGER_ON_CHANGE: + callbacks = self._rules_on_change.get(arg) + case TriggerType.TRIGGER_ON_EVENT: + callbacks = self._rules_on_event.get(arg) + case TriggerType.TRIGGER_ON_PERIOD: + self._rules_on_period.pop(arg) + case TriggerType.TRIGGER_ON_UPDATE: + callbacks = self._rules_on_update + if callbacks is not None: + if callback is not None: + callbacks.remove(callback) + else: + callbacks.clear() + + def set_attr(self, name: str, value: Any) -> None: + """Set an attribute of the visualization manager. + + Args: + name: Name of the attribute to set + value: Value to set the attribute to + """ + setattr(self, name, value) + + def _validate_callback_signature(self, trigger: TriggerType, callback: Callable) -> None: + """Validate that the callback has the correct signature for the trigger type. + + Args: + trigger: Type of trigger for the callback + callback: The callback function to validate + + Raises: + TypeError: If callback signature doesn't match expected signature + """ + try: + sig = inspect.signature(callback) + params = list(sig.parameters.values()) + + # Remove 'self' parameter if it's a bound method + if params and params[0].name == "self": + params = params[1:] + + param_count = len(params) + + if trigger == TriggerType.TRIGGER_ON_EVENT: + # Event callbacks should have 3 parameters: (manager, data_collector, event_params) + expected_count = 3 + expected_sig = ( + "callback(manager: VisualizationManager, data_collector: DataCollector, event_params: Any)" + ) + else: + # Other callbacks should have 2 parameters: (manager, data_collector) + expected_count = 2 + expected_sig = "callback(manager: VisualizationManager, data_collector: DataCollector)" + + if param_count != expected_count: + raise TypeError( + f"Callback for {trigger.name} must have {expected_count} parameters, " + f"but got {param_count}. Expected signature: {expected_sig}. " + f"Actual signature: {sig}" + ) + + except Exception as e: + if isinstance(e, TypeError): + raise + # If we can't inspect the signature (e.g., built-in functions), + # just log a warning and proceed + omni.log.warn(f"Could not validate callback signature for {trigger.name}: {e}") + + +class XRVisualization: + """Singleton class providing XR visualization functionality. + + This class implements the singleton pattern to ensure only one instance + of the visualization system exists across the application. It provides + a centralized API for managing XR visualization features. + + When manage a new event ordata field, please add a comment to the following list. + + Event names: + "ik_solver_failed" + + Data fields: + "manipulability_ellipsoid" : list[float] + "device_raw_data" : dict + "joints_distance_percentage_to_limit" : list[float] + "joints_torque" : list[float] + "joints_torque_limit" : list[float] + "joints_name" : list[str] + "wrist_pose" : list[float] + "approximated_working_space" : list[float] + "hand_torque_mapping" : list[str] + """ + + _lock = threading.Lock() + _instance: XRVisualization | None = None + _registered = False + + def __init__(self): + """Prevent direct instantiation.""" + raise RuntimeError("Use VisualizationInterface classmethods instead of direct instantiation") + + @classmethod + def __create_instance(cls, manager: type[VisualizationManager] = VisualizationManager) -> XRVisualization: + """Get the visualization manager instance. + + Returns: + VisualizationManager: The visualization manager instance + """ + with cls._lock: + if cls._instance is None: + # Bypass __init__ by calling __new__ directly + cls._instance = super().__new__(cls) + cls._instance._initialize(manager) + return cls._instance + + @classmethod + def __get_instance(cls) -> XRVisualization: + """Thread-safe singleton access. + + Returns: + XRVisualization: The singleton instance of the visualization system + """ + if cls._instance is None: + return cls.__create_instance() + elif not cls._instance._registered: + cls._instance._register() + return cls._instance + + def _register(self) -> bool: + """Register the visualization system. + + Returns: + bool: True if the visualization system is registered, False otherwise + """ + if self._registered: + return True + + sim = SimulationContext.instance() + if sim is not None: + sim.add_render_callback("visualization_render_callback", self.update_loop) + self._registered = True + return self._registered + + def _initialize(self, manager: type[VisualizationManager]) -> None: + """Initialize the singleton instance with data collector and visualization manager.""" + + self._data_collector = DataCollector() + self._visualization_manager = manager(self._data_collector) + + self._register() + + self._initialized = True + + # APIs + + def update_loop(self, event) -> None: + """Update the visualization system. + + This method should be called regularly (e.g., every frame) to ensure + visualization updates are processed and periodic callbacks are executed. + """ + self._visualization_manager.update_loop() + self._data_collector.update_loop() + + @classmethod + def push_event(cls, name: str, args: Any = None) -> None: + """Push an event to trigger registered callbacks. + + Args: + name: Name of the event to trigger + args: Optional arguments for the event (currently unused) + """ + instance = cls.__get_instance() + instance._visualization_manager.on_event(name, args) + + @classmethod + def push_data(cls, item: dict[str, Any]) -> None: + """Push data to the visualization system. + + Updates multiple data fields at once. Each key-value pair in the + dictionary will be processed by the data collector. + + Args: + item: Dictionary containing data field names and their values + """ + instance = cls.__get_instance() + for name, value in item.items(): + instance._data_collector.update_data(name, value) + + @classmethod + def set_attrs(cls, attributes: dict[str, Any]) -> None: + """Set configuration data for the visualization system. Not currently used. + + Args: + attributes: Dictionary containing configuration keys and values + """ + + instance = cls.__get_instance() + for name, data in attributes.items(): + instance._visualization_manager.set_attr(name, data) + + @classmethod + def get_attr(cls, name: str) -> Any: + """Get configuration data for the visualization system. Not currently used. + + Args: + name: Configuration key + """ + instance = cls.__get_instance() + return getattr(instance._visualization_manager, name) + + @classmethod + def register_callback(cls, trigger: TriggerType, arg: dict, callback: VisualizationManager.CallbackType) -> None: + """Register a callback function for visualization events. + + Args: + trigger: Type of trigger that should execute the callback + arg: Dictionary containing trigger-specific parameters: + - For TRIGGER_ON_PERIOD: {"period": float} + - For TRIGGER_ON_EVENT: {"event_name": str} + - For TRIGGER_ON_CHANGE: {"variable_name": str} + callback: Function to execute when trigger condition is met + """ + instance = cls.__get_instance() + instance._visualization_manager.register_callback(trigger, arg, callback) + + @classmethod + def assign_manager(cls, manager: type[VisualizationManager]) -> None: + """Assign a visualization manager type to the visualization system. + + Args: + manager: Type of the visualization manager to assign + """ + if cls._instance is not None: + omni.log.error( + f"Visualization system already initialized to {type(cls._instance._visualization_manager).__name__}," + f" cannot assign manager {manager.__name__}" + ) + return + + cls.__create_instance(manager) diff --git a/source/isaaclab/isaaclab/ui/xr_widgets/teleop_visualization_manager.py b/source/isaaclab/isaaclab/ui/xr_widgets/teleop_visualization_manager.py new file mode 100644 index 000000000000..eb424ae91916 --- /dev/null +++ b/source/isaaclab/isaaclab/ui/xr_widgets/teleop_visualization_manager.py @@ -0,0 +1,67 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from typing import Any + +from isaaclab.ui.xr_widgets import DataCollector, TriggerType, VisualizationManager +from isaaclab.ui.xr_widgets.instruction_widget import hide_instruction + + +class TeleopVisualizationManager(VisualizationManager): + """Specialized visualization manager for teleoperation scenarios. + For sample and debug use. + + Provides teleoperation-specific visualization features including: + - IK error handling and display + """ + + def __init__(self, data_collector: DataCollector): + """Initialize the teleop visualization manager and register callbacks. + + Args: + data_collector: DataCollector instance to read data for visualization use. + """ + super().__init__(data_collector) + + # Handle error event + self._error_text_color = 0xFF0000FF + self.ik_error_widget_id = "/ik_solver_failed" + + self.register_callback(TriggerType.TRIGGER_ON_EVENT, {"event_name": "ik_error"}, self._handle_ik_error) + + def _handle_ik_error(self, mgr: VisualizationManager, data_collector: DataCollector, params: Any = None) -> None: + """Handle IK error events by displaying an error message widget. + + Args: + data_collector: DataCollector instance (unused in this handler) + """ + # Todo: move display_widget to instruction_widget.py + if not hasattr(mgr, "_ik_error_widget_timer"): + self.display_widget( + "IK Error Detected", + mgr.ik_error_widget_id, + VisualizationManager.message_widget_preset() + | {"text_color": self._error_text_color, "display_duration": None}, + ) + mgr._ik_error_widget_timer = mgr.register_callback( + TriggerType.TRIGGER_ON_PERIOD, {"period": 3.0, "initial_countdown": 3.0}, self._hide_ik_error_widget + ) + if mgr._ik_error_widget_timer is None: + mgr.cancel_rule(TriggerType.TRIGGER_ON_PERIOD, mgr._ik_error_widget_timer) + mgr.cancel_rule(TriggerType.TRIGGER_ON_EVENT, "ik_solver_failed") + raise RuntimeWarning("Failed to register IK error widget timer") + else: + mgr._ik_error_widget_timer.countdown = 3.0 + + def _hide_ik_error_widget(self, mgr: VisualizationManager, data_collector: DataCollector) -> None: + """Hide the IK error widget. + + Args: + data_collector: DataCollector instance (unused in this handler) + """ + + hide_instruction(mgr.ik_error_widget_id) + mgr.cancel_rule(TriggerType.TRIGGER_ON_PERIOD, mgr._ik_error_widget_timer) + delattr(mgr, "_ik_error_widget_timer") diff --git a/source/isaaclab/test/visualization/check_scene_xr_visualization.py b/source/isaaclab/test/visualization/check_scene_xr_visualization.py new file mode 100644 index 000000000000..dd614082b8ea --- /dev/null +++ b/source/isaaclab/test/visualization/check_scene_xr_visualization.py @@ -0,0 +1,257 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +""" +This script checks if the XR visualization widgets are visible from the camera. + +.. code-block:: bash + + # Usage + ./isaaclab.sh -p source/isaaclab/test/visualization/check_scene_visualization.py + +""" + +"""Launch Isaac Sim Simulator first.""" + +import argparse + +from isaaclab.app import AppLauncher + +# add argparse arguments +parser = argparse.ArgumentParser(description="Check XR visualization widgets in Isaac Lab.") +parser.add_argument("--num_envs", type=int, default=2, help="Number of environments to spawn.") +# append AppLauncher cli args +AppLauncher.add_app_launcher_args(parser) +# parse the arguments +args_cli = parser.parse_args() + +# launch omniverse app with XR support +args_cli.xr = True +app_launcher = AppLauncher(args_cli) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import time +from typing import Any + +from pxr import Gf + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.ui.xr_widgets import DataCollector, TriggerType, VisualizationManager, XRVisualization, update_instruction +from isaaclab.utils import configclass + +## +# Pre-defined configs +## + + +@configclass +class SimpleSceneCfg(InteractiveSceneCfg): + """Design the scene with sensors on the robot.""" + + # ground plane + ground = AssetBaseCfg(prim_path="/World/defaultGroundPlane", spawn=sim_utils.GroundPlaneCfg()) + + # lights + dome_light = AssetBaseCfg( + prim_path="/World/Light", spawn=sim_utils.DomeLightCfg(intensity=3000.0, color=(0.75, 0.75, 0.75)) + ) + + +def get_camera_position(): + """Get the current camera position from the USD stage. + + Returns: + tuple: (x, y, z) camera position or None if not available + """ + try: + import isaacsim.core.utils.stage as stage_utils + from pxr import UsdGeom + + stage = stage_utils.get_current_stage() + if stage is not None: + # Get the viewport camera prim + camera_prim_path = "/OmniverseKit_Persp" + camera_prim = stage.GetPrimAtPath(camera_prim_path) + + if camera_prim and camera_prim.IsValid(): + # Get the camera's world transform + camera_xform = UsdGeom.Xformable(camera_prim) + world_transform = camera_xform.ComputeLocalToWorldTransform(0) # 0 = current time + + # Extract position from the transform matrix + camera_pos = world_transform.ExtractTranslation() + return (camera_pos[0], camera_pos[1], camera_pos[2]) + return None + except Exception as e: + print(f"[ERROR]: Failed to get camera position: {e}") + return None + + +def _sample_handle_ik_error(mgr: VisualizationManager, data_collector: DataCollector, params: Any = None) -> None: + error_text_color = getattr(mgr, "_error_text_color", 0xFF0000FF) + mgr.display_widget( + "IK Error Detected", + "/ik_error", + VisualizationManager.message_widget_preset() + | { + "text_color": error_text_color, + "prim_path_source": "/World/defaultGroundPlane/GroundPlane", + "translation": Gf.Vec3f(0, 0, 1), + }, + ) + + +def _sample_update_error_text_color(mgr: VisualizationManager, data_collector: DataCollector) -> None: + current_color = getattr(mgr, "_error_text_color", 0xFF0000FF) + new_color = current_color + 0x100 + if new_color >= 0xFFFFFFFF: + new_color = 0xFF0000FF + mgr.set_attr("_error_text_color", new_color) + + +def _sample_update_left_panel(mgr: VisualizationManager, data_collector: DataCollector) -> None: + left_panel_id = getattr(mgr, "left_panel_id", None) + + if left_panel_id is None: + return + + left_panel_created = getattr(mgr, "_left_panel_created", False) + if left_panel_created is False: + # create a new left panel + mgr.display_widget( + "Left Panel", + left_panel_id, + VisualizationManager.panel_widget_preset() + | { + "text_color": 0xFFFFFFFF, + "prim_path_source": "/World/defaultGroundPlane/GroundPlane", + "translation": Gf.Vec3f(0, -3, 1), + }, + ) + mgr.set_attr("_left_panel_created", True) + + updated_times = getattr(mgr, "_left_panel_updated_times", 0) + # Create a simple panel content since make_panel_content doesn't exist + content = f"Left Panel\nUpdated #{updated_times} times" + update_instruction(left_panel_id, content) + mgr.set_attr("_left_panel_updated_times", updated_times + 1) + + +def _sample_update_right_panel(mgr: VisualizationManager, data_collector: DataCollector) -> None: + right_panel_id = getattr(mgr, "right_panel_id", None) + + if right_panel_id is None: + return + + updated_times = getattr(mgr, "_right_panel_updated_times", 0) + # Create a simple panel content since make_panel_content doesn't exist + right_panel_data = data_collector.get_data("right_panel_data") + if right_panel_data is not None: + assert isinstance(right_panel_data, (tuple, list)), "Right panel data must be a tuple or list" + # Format each element to 3 decimal places + formatted_data = tuple(f"{x:.3f}" for x in right_panel_data) + content = f"Right Panel\nUpdated #{updated_times} times\nData: {formatted_data}" + else: + content = f"Right Panel\nUpdated #{updated_times} times\nData: None" + + right_panel_created = getattr(mgr, "_right_panel_created", False) + if right_panel_created is False: + # create a new left panel + mgr.display_widget( + content, + right_panel_id, + VisualizationManager.panel_widget_preset() + | { + "text_color": 0xFFFFFFFF, + "prim_path_source": "/World/defaultGroundPlane/GroundPlane", + "translation": Gf.Vec3f(0, 3, 1), + }, + ) + mgr.set_attr("_right_panel_created", True) + + update_instruction(right_panel_id, content) + mgr.set_attr("_right_panel_updated_times", updated_times + 1) + + +def apply_sample_visualization(): + # Error Message + XRVisualization.register_callback(TriggerType.TRIGGER_ON_EVENT, {"event_name": "ik_error"}, _sample_handle_ik_error) + + # Display a panel on the left to display DataCollector data + # Refresh periodically + XRVisualization.set_attrs({ + "left_panel_id": "/left_panel", + "left_panel_translation": Gf.Vec3f(-2, 2.6, 2), + "left_panel_updated_times": 0, + "right_panel_updated_times": 0, + }) + XRVisualization.register_callback(TriggerType.TRIGGER_ON_PERIOD, {"period": 1.0}, _sample_update_left_panel) + + # Display a panel on the right to display DataCollector data + # Refresh when camera position changes + XRVisualization.set_attrs({ + "right_panel_id": "/right_panel", + "right_panel_translation": Gf.Vec3f(1.5, 2, 2), + }) + XRVisualization.register_callback( + TriggerType.TRIGGER_ON_CHANGE, {"variable_name": "right_panel_data"}, _sample_update_right_panel + ) + + # Change error text color every second + XRVisualization.set_attrs({ + "error_text_color": 0xFF0000FF, + }) + XRVisualization.register_callback(TriggerType.TRIGGER_ON_UPDATE, {}, _sample_update_error_text_color) + + +def run_simulator( + sim: sim_utils.SimulationContext, + scene: InteractiveScene, +): + """Run the simulator.""" + + # Define simulation stepping + sim_dt = sim.get_physics_dt() + + apply_sample_visualization() + + # Simulate + while simulation_app.is_running(): + if int(time.time()) % 10 < 1: + XRVisualization.push_event("ik_error") + + XRVisualization.push_data({"right_panel_data": get_camera_position()}) + + sim.step() + scene.update(sim_dt) + + +def main(): + """Main function.""" + + # Initialize the simulation context + sim_cfg = sim_utils.SimulationCfg(dt=0.005) + sim = sim_utils.SimulationContext(sim_cfg) + # Set main camera + sim.set_camera_view(eye=(8, 0, 4), target=(0.0, 0.0, 0.0)) + # design scene + scene = InteractiveScene(SimpleSceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0)) + # Play the simulator + sim.reset() + # Now we are ready! + print("[INFO]: Setup complete...") + # Run the simulator + run_simulator(sim, scene) + + +if __name__ == "__main__": + # run the main function + main() + # close sim app + simulation_app.close() diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py index 8b35bf2c3cb9..0a3cb26b4d3e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause +import carb from pink.tasks import DampingTask, FrameTask import isaaclab.controllers.utils as ControllerUtils @@ -171,6 +172,7 @@ def __post_init__(self): # orientation_cost=0.05, # [cost] / [rad] # ), ], + xr_enabled=bool(carb.settings.get_settings().get("/app/xr/enabled")), ), ) # Convert USD to URDF and change revolute joints to fixed diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py index d18b4866d155..b7e1ff3ddecf 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause +import carb from pink.tasks import DampingTask, FrameTask import isaaclab.controllers.utils as ControllerUtils @@ -169,6 +170,7 @@ def __post_init__(self): # orientation_cost=0.05, # [cost] / [rad] # ), ], + xr_enabled=bool(carb.settings.get_settings().get("/app/xr/enabled")), ), ) # Convert USD to URDF and change revolute joints to fixed diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index 6192f3e58836..9343db5ffc58 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -6,6 +6,7 @@ import tempfile import torch +import carb from pink.tasks import DampingTask, FrameTask import isaaclab.controllers.utils as ControllerUtils @@ -255,6 +256,7 @@ class ActionsCfg: ), ], fixed_input_tasks=[], + xr_enabled=bool(carb.settings.get_settings().get("/app/xr/enabled")), ), ) From bc7e5d7b4dab9cdd90ed783ddf8ace3c74c223b4 Mon Sep 17 00:00:00 2001 From: rebeccazhang0707 <168459200+rebeccazhang0707@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:39:01 +0800 Subject: [PATCH 35/47] Adds galbot stack cube tasks, with left_arm_gripper and right_arm_suction, using RMPFlow controller (#3210) # Description Adds galbot_stack_cube tasks and mimic tasks, using RMPFlow controller: - add galbot robot config and .usd asset - add RMPFlowAction and RMPFlowActionCfg - add motion_policy_configs and .urdf for both 'galbot_left_arm_gripper' and 'galbot_right_arm_suction' - add new task: galbot stack_rmp_rel_env_cfg - add new mimic task: galbot_stack_rmp_abs/rel_mimic_env - add mdp.observations/terminations/events for galbot: support gripper_state checking for both suction_cup and parallel_gripper, get obs_in_base_frame - add gripper_configs (gripper_joint_names, gripper_open_val, gripper_threshold) in galbot/franka tasks: to make mdp functions universal to varied robots - fix a bug (eef_name) in franka_stack_ik_rel_mimic_env.py - add new device_name in se3_spacemouse.py Notes: This PR relies on PR (https://github.com/isaac-sim/IsaacLab/pull/3174) for surface gripper support in manager-based workflow. You can test the whole gr00t-mimic workflow by: 1. record demos: `./isaaclab.sh -p scripts/tools/record_demos.py --task Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 --teleop_device spacemouse --num_demos 1 --device cpu --dataset_file datasets/recorded_demos_galbot_suction_rel.hdf5` 2. replay demos: `./isaaclab.sh -p scripts/tools/replay_demos.py --task Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 --num_envs 1 --device cpu --dataset_file datasets/recorded_demos_galbot_suction_rel.hdf5` 3. annotate demos: `./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/annotate_demos.py --task Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-Rel-Mimic-v0 --auto --device cpu --input_file datasets/recorded_demos_galbot_suction_rel.hdf5 --output_file datasets/annotated_demos_galbot_suction_rel.hdf5` 4. generate dataset: `./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py --task Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-Rel-Mimic-v0 --num_envs 16 --device cpu --generation_num_trials 10 --input_file datasets/annotated_demos_galbot_suction_rel.hdf5 --output_file datasets/generated_demos_galbot_suction_rel.hdf5` ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) ## Screenshot environments_galbot ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: rebeccazhang0707 <168459200+rebeccazhang0707@users.noreply.github.com> Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- .../tasks/manipulation/galbot_stack_cube.jpg | Bin 0 -> 57825 bytes docs/source/overview/environments.rst | 22 +- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 12 +- .../isaaclab/controllers/config/rmp_flow.py | 37 +++ .../isaaclab/isaaclab/controllers/rmp_flow.py | 13 +- .../devices/spacemouse/se3_spacemouse.py | 35 ++- .../isaaclab/envs/mdp/actions/__init__.py | 1 + .../isaaclab/envs/mdp/actions/actions_cfg.py | 35 +++ .../envs/mdp/actions/binary_joint_actions.py | 44 +++ .../envs/mdp/actions/rmpflow_actions_cfg.py | 52 ++++ .../mdp/actions/rmpflow_task_space_actions.py | 214 +++++++++++++ .../isaaclab_assets/robots/__init__.py | 1 + .../isaaclab_assets/robots/galbot.py | 102 +++++++ .../isaaclab_mimic/envs/__init__.py | 51 ++++ .../envs/franka_stack_ik_rel_mimic_env.py | 2 +- .../envs/galbot_stack_rmp_abs_mimic_env.py | 47 +++ .../galbot_stack_rmp_abs_mimic_env_cfg.py | 254 ++++++++++++++++ .../envs/galbot_stack_rmp_rel_mimic_env.py | 48 +++ .../galbot_stack_rmp_rel_mimic_env_cfg.py | 254 ++++++++++++++++ source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 8 + .../config/franka/stack_joint_pos_env_cfg.py | 3 + .../stack/config/galbot/__init__.py | 74 +++++ .../config/galbot/stack_joint_pos_env_cfg.py | 278 +++++++++++++++++ .../config/galbot/stack_rmp_rel_env_cfg.py | 282 ++++++++++++++++++ .../manipulation/stack/mdp/observations.py | 246 +++++++++++++-- .../manipulation/stack/mdp/terminations.py | 38 ++- 28 files changed, 2112 insertions(+), 45 deletions(-) create mode 100644 docs/source/_static/tasks/manipulation/galbot_stack_cube.jpg create mode 100644 source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py create mode 100644 source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py create mode 100644 source/isaaclab_assets/isaaclab_assets/robots/galbot.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py diff --git a/docs/source/_static/tasks/manipulation/galbot_stack_cube.jpg b/docs/source/_static/tasks/manipulation/galbot_stack_cube.jpg new file mode 100644 index 0000000000000000000000000000000000000000..72b7321a5d6e1806a48e451fe404b4be9d02f3f8 GIT binary patch literal 57825 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 b%5dA$di56`Te=SA=q3-ibKz^qf4}`dFu;?l literal 0 HcmV?d00001 diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 43994eca5476..c4925adfb941 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -3,7 +3,7 @@ Available Environments ====================== -The following lists comprises of all the RL or IL tasks implementations that are available in Isaac Lab. +The following lists comprises of all the RL and IL tasks implementations that are available in Isaac Lab. While we try to keep this list up-to-date, you can always get the latest list of environments by running the following command: @@ -142,6 +142,9 @@ for the lift-cube environment: | |gr1_pp_waist| | |gr1_pp_waist-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | | | | with waist degrees-of-freedom enables that provides a wider reach space. | +----------------------+---------------------------+-----------------------------------------------------------------------------+ + | |galbot_stack| | |galbot_stack-link| | Stack three cubes (bottom to top: blue, red, green) with the left arm of | + | | | a Galbot humanoid robot | + +----------------------+---------------------------+-----------------------------------------------------------------------------+ .. |reach-franka| image:: ../_static/tasks/manipulation/franka_reach.jpg .. |reach-ur10| image:: ../_static/tasks/manipulation/ur10_reach.jpg @@ -154,6 +157,8 @@ for the lift-cube environment: .. |gr1_pick_place| image:: ../_static/tasks/manipulation/gr-1_pick_place.jpg .. |surface-gripper| image:: ../_static/tasks/manipulation/ur10_stack_surface_gripper.jpg .. |gr1_pp_waist| image:: ../_static/tasks/manipulation/gr-1_pick_place_waist.jpg +.. |surface-gripper| image:: ../_static/tasks/manipulation/ur10_stack_surface_gripper.jpg +.. |galbot_stack| image:: ../_static/tasks/manipulation/galbot_stack_cube.jpg .. |reach-franka-link| replace:: `Isaac-Reach-Franka-v0 `__ .. |reach-ur10-link| replace:: `Isaac-Reach-UR10-v0 `__ @@ -171,6 +176,9 @@ for the lift-cube environment: .. |long-suction-link| replace:: `Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 `__ .. |short-suction-link| replace:: `Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 `__ .. |gr1_pp_waist-link| replace:: `Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0 `__ +.. |galbot_stack-link| replace:: `Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 `__ +.. |long-suction-link| replace:: `Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 `__ +.. |short-suction-link| replace:: `Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 `__ .. |cube-shadow-link| replace:: `Isaac-Repose-Cube-Shadow-Direct-v0 `__ .. |cube-shadow-ff-link| replace:: `Isaac-Repose-Cube-Shadow-OpenAI-FF-Direct-v0 `__ @@ -954,6 +962,18 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - + * - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 + - + - Manager Based + - + * - Isaac-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow-v0 + - + - Manager Based + - + * - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-v0 + - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-Play-v0 + - Manager Based + - * - Isaac-Velocity-Flat-Anymal-B-v0 - Isaac-Velocity-Flat-Anymal-B-Play-v0 - Manager Based diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index e8b3ffbfd56a..a53b7e970cbc 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.14" +version = "0.45.15" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 8aa0aef67677..91d5e1ab1ed4 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,15 +1,25 @@ Changelog --------- +0.45.15 (2025-09-05) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added action terms for using RMPFlow in Manager-Based environments. + + 0.45.14 (2025-09-08) ~~~~~~~~~~~~~~~~~~~~ Added ^^^^^ -* * Added :class:`~isaaclab.ui.xr_widgets.TeleopVisualizationManager` and :class:`~isaaclab.ui.xr_widgets.XRVisualization` +* Added :class:`~isaaclab.ui.xr_widgets.TeleopVisualizationManager` and :class:`~isaaclab.ui.xr_widgets.XRVisualization` classes to provide real-time visualization of teleoperation and inverse kinematics status in XR environments. + 0.45.13 (2025-09-08) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/controllers/config/rmp_flow.py b/source/isaaclab/isaaclab/controllers/config/rmp_flow.py index be139ee3f740..e1b18350e144 100644 --- a/source/isaaclab/isaaclab/controllers/config/rmp_flow.py +++ b/source/isaaclab/isaaclab/controllers/config/rmp_flow.py @@ -8,6 +8,9 @@ from isaacsim.core.utils.extensions import get_extension_path_from_name from isaaclab.controllers.rmp_flow import RmpFlowControllerCfg +from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR + +ISAACLAB_NUCLEUS_RMPFLOW_DIR = os.path.join(ISAACLAB_NUCLEUS_DIR, "Controllers", "RmpFlowAssets") # Note: RMP-Flow config files for supported robots are stored in the motion_generation extension _RMP_CONFIG_DIR = os.path.join( @@ -35,3 +38,37 @@ evaluations_per_frame=5, ) """Configuration of RMPFlow for UR10 arm (default from `isaacsim.robot_motion.motion_generation`).""" + +GALBOT_LEFT_ARM_RMPFLOW_CFG = RmpFlowControllerCfg( + config_file=os.path.join( + ISAACLAB_NUCLEUS_RMPFLOW_DIR, + "galbot_one_charlie", + "rmpflow", + "galbot_one_charlie_left_arm_rmpflow_config.yaml", + ), + urdf_file=os.path.join(ISAACLAB_NUCLEUS_RMPFLOW_DIR, "galbot_one_charlie", "galbot_one_charlie.urdf"), + collision_file=os.path.join( + ISAACLAB_NUCLEUS_RMPFLOW_DIR, "galbot_one_charlie", "rmpflow", "galbot_one_charlie_left_arm_gripper.yaml" + ), + frame_name="left_gripper_tcp_link", + evaluations_per_frame=5, + ignore_robot_state_updates=True, +) + +GALBOT_RIGHT_ARM_RMPFLOW_CFG = RmpFlowControllerCfg( + config_file=os.path.join( + ISAACLAB_NUCLEUS_RMPFLOW_DIR, + "galbot_one_charlie", + "rmpflow", + "galbot_one_charlie_right_arm_rmpflow_config.yaml", + ), + urdf_file=os.path.join(ISAACLAB_NUCLEUS_RMPFLOW_DIR, "galbot_one_charlie", "galbot_one_charlie.urdf"), + collision_file=os.path.join( + ISAACLAB_NUCLEUS_RMPFLOW_DIR, "galbot_one_charlie", "rmpflow", "galbot_one_charlie_right_arm_suction.yaml" + ), + frame_name="right_suction_cup_tcp_link", + evaluations_per_frame=5, + ignore_robot_state_updates=True, +) + +"""Configuration of RMPFlow for Galbot humanoid.""" diff --git a/source/isaaclab/isaaclab/controllers/rmp_flow.py b/source/isaaclab/isaaclab/controllers/rmp_flow.py index 8fa34bcad8a6..b9ce875c390c 100644 --- a/source/isaaclab/isaaclab/controllers/rmp_flow.py +++ b/source/isaaclab/isaaclab/controllers/rmp_flow.py @@ -20,6 +20,7 @@ from isaacsim.robot_motion.motion_generation.lula.motion_policies import RmpFlow, RmpFlowSmoothed from isaaclab.utils import configclass +from isaaclab.utils.assets import retrieve_file_path @configclass @@ -95,11 +96,17 @@ def initialize(self, prim_paths_expr: str): # add robot reference robot = SingleArticulation(prim_path) robot.initialize() + # download files if they are not local + + local_urdf_file = retrieve_file_path(self.cfg.urdf_file, force_download=True) + local_collision_file = retrieve_file_path(self.cfg.collision_file, force_download=True) + local_config_file = retrieve_file_path(self.cfg.config_file, force_download=True) + # add controller rmpflow = controller_cls( - robot_description_path=self.cfg.collision_file, - urdf_path=self.cfg.urdf_file, - rmpflow_config_path=self.cfg.config_file, + robot_description_path=local_collision_file, + urdf_path=local_urdf_file, + rmpflow_config_path=local_config_file, end_effector_frame_name=self.cfg.frame_name, maximum_substep_size=physics_dt / self.cfg.evaluations_per_frame, ignore_robot_state_updates=self.cfg.ignore_robot_state_updates, diff --git a/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py b/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py index 092844ef114c..feb366ee4cdd 100644 --- a/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py +++ b/source/isaaclab/isaaclab/devices/spacemouse/se3_spacemouse.py @@ -144,6 +144,7 @@ def _find_device(self): if ( device["product_string"] == "SpaceMouse Compact" or device["product_string"] == "SpaceMouse Wireless" + or device["product_string"] == "3Dconnexion Universal Receiver" ): # set found flag found = True @@ -152,6 +153,7 @@ def _find_device(self): # connect to the device self._device.close() self._device.open(vendor_id, product_id) + self._device_name = device["product_string"] # check if device found if not found: time.sleep(1.0) @@ -166,19 +168,32 @@ def _run_device(self): # keep running while True: # read the device data - data = self._device.read(7) + if self._device_name == "3Dconnexion Universal Receiver": + data = self._device.read(7 + 6) + else: + data = self._device.read(7) if data is not None: # readings from 6-DoF sensor - if data[0] == 1: - self._delta_pos[1] = self.pos_sensitivity * convert_buffer(data[1], data[2]) - self._delta_pos[0] = self.pos_sensitivity * convert_buffer(data[3], data[4]) - self._delta_pos[2] = self.pos_sensitivity * convert_buffer(data[5], data[6]) * -1.0 - elif data[0] == 2 and not self._read_rotation: - self._delta_rot[1] = self.rot_sensitivity * convert_buffer(data[1], data[2]) - self._delta_rot[0] = self.rot_sensitivity * convert_buffer(data[3], data[4]) - self._delta_rot[2] = self.rot_sensitivity * convert_buffer(data[5], data[6]) * -1.0 + if self._device_name == "3Dconnexion Universal Receiver": + if data[0] == 1: + self._delta_pos[1] = self.pos_sensitivity * convert_buffer(data[1], data[2]) + self._delta_pos[0] = self.pos_sensitivity * convert_buffer(data[3], data[4]) + self._delta_pos[2] = self.pos_sensitivity * convert_buffer(data[5], data[6]) * -1.0 + + self._delta_rot[1] = self.rot_sensitivity * convert_buffer(data[1 + 6], data[2 + 6]) + self._delta_rot[0] = self.rot_sensitivity * convert_buffer(data[3 + 6], data[4 + 6]) + self._delta_rot[2] = self.rot_sensitivity * convert_buffer(data[5 + 6], data[6 + 6]) * -1.0 + else: + if data[0] == 1: + self._delta_pos[1] = self.pos_sensitivity * convert_buffer(data[1], data[2]) + self._delta_pos[0] = self.pos_sensitivity * convert_buffer(data[3], data[4]) + self._delta_pos[2] = self.pos_sensitivity * convert_buffer(data[5], data[6]) * -1.0 + elif data[0] == 2 and not self._read_rotation: + self._delta_rot[1] = self.rot_sensitivity * convert_buffer(data[1], data[2]) + self._delta_rot[0] = self.rot_sensitivity * convert_buffer(data[3], data[4]) + self._delta_rot[2] = self.rot_sensitivity * convert_buffer(data[5], data[6]) * -1.0 # readings from the side buttons - elif data[0] == 3: + if data[0] == 3: # press left button if data[1] == 1: # close gripper diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/__init__.py b/source/isaaclab/isaaclab/envs/mdp/actions/__init__.py index 21be87102d06..ad1178be44d0 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/__init__.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/__init__.py @@ -10,3 +10,4 @@ from .joint_actions import * from .joint_actions_to_limits import * from .non_holonomic_actions import * +from .surface_gripper_actions import * diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py index 3698f2511262..19a84846a521 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py @@ -188,6 +188,41 @@ class BinaryJointVelocityActionCfg(BinaryJointActionCfg): class_type: type[ActionTerm] = binary_joint_actions.BinaryJointVelocityAction +@configclass +class AbsBinaryJointPositionActionCfg(ActionTermCfg): + """Configuration for the absolute binary joint position action term. + + This action term is used for robust grasping by converting continuous gripper joint position actions + into binary open/close commands. Unlike directly applying continuous gripper joint position actions, this class + applies a threshold-based decision mechanism to determine whether to + open or close the gripper. + + The action works by: + 1. Taking a continuous input action value + 2. Comparing it against a configurable threshold + 3. Mapping the result to either open or close commands based on the threshold comparison + 4. Applying the corresponding gripper open/close commands + + This approach provides more predictable and stable grasping behavior compared to directly applying + continuous gripper joint position actions. + + See :class:`AbsBinaryJointPositionAction` for more details. + """ + + joint_names: list[str] = MISSING + """List of joint names or regex expressions that the action will be mapped to.""" + open_command_expr: dict[str, float] = MISSING + """The joint command to move to *open* configuration.""" + close_command_expr: dict[str, float] = MISSING + """The joint command to move to *close* configuration.""" + threshold: float = 0.5 + """The threshold for the binary action. Defaults to 0.5.""" + positive_threshold: bool = True + """Whether to use positive (Open actions > Close actions) threshold. Defaults to True.""" + + class_type: type[ActionTerm] = binary_joint_actions.AbsBinaryJointPositionAction + + ## # Non-holonomic actions. ## diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/binary_joint_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/binary_joint_actions.py index 9b8666e4464c..501c221dec6a 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/binary_joint_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/binary_joint_actions.py @@ -164,3 +164,47 @@ class BinaryJointVelocityAction(BinaryJointAction): def apply_actions(self): self._asset.set_joint_velocity_target(self._processed_actions, joint_ids=self._joint_ids) + + +class AbsBinaryJointPositionAction(BinaryJointAction): + """Absolute Binary joint action that sets the binary action into joint position targets. + + This class extends BinaryJointAction to accept absolute position control + for gripper joints. It converts continuous input actions into binary open/close commands + using a configurable threshold mechanism. + + The key difference from the base BinaryJointAction is that this class: + - Receives absolute joint position actions for gripper control + - Implements a threshold-based decision system to determine open/close state + + The action processing works by: + 1. Taking a continuous input action value + 2. Comparing it against the configured threshold value + 3. Based on the threshold comparison and positive_threshold flag, determining + whether to open or close the gripper + 4. Setting the target joint positions to either the open or close configuration + + """ + + cfg: actions_cfg.AbsBinaryJointPositionActionCfg + """The configuration of the action term.""" + + def process_actions(self, actions: torch.Tensor): + # store the raw actions + self._raw_actions[:] = actions + # compute the binary mask + if self.cfg.positive_threshold: + # true: open 0.785, false: close 0.0 + binary_mask = actions > self.cfg.threshold + else: + # true: close 0.0, false: open 0.785 + binary_mask = actions < self.cfg.threshold + # compute the command + self._processed_actions = torch.where(binary_mask, self._open_command, self._close_command) + if self.cfg.clip is not None: + self._processed_actions = torch.clamp( + self._processed_actions, min=self._clip[:, :, 0], max=self._clip[:, :, 1] + ) + + def apply_actions(self): + self._asset.set_joint_position_target(self._processed_actions, joint_ids=self._joint_ids) diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py new file mode 100644 index 000000000000..00bcc453d9cf --- /dev/null +++ b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_actions_cfg.py @@ -0,0 +1,52 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +from dataclasses import MISSING + +from isaaclab.controllers.rmp_flow import RmpFlowControllerCfg +from isaaclab.managers.action_manager import ActionTerm, ActionTermCfg +from isaaclab.utils import configclass + +from . import rmpflow_task_space_actions + + +@configclass +class RMPFlowActionCfg(ActionTermCfg): + @configclass + class OffsetCfg: + """The offset pose from parent frame to child frame. + + On many robots, end-effector frames are fictitious frames that do not have a corresponding + rigid body. In such cases, it is easier to define this transform w.r.t. their parent rigid body. + For instance, for the Franka Emika arm, the end-effector is defined at an offset to the the + "panda_hand" frame. + """ + + pos: tuple[float, float, float] = (0.0, 0.0, 0.0) + """Translation w.r.t. the parent frame. Defaults to (0.0, 0.0, 0.0).""" + rot: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0) + + class_type: type[ActionTerm] = rmpflow_task_space_actions.RMPFlowAction + + joint_names: list[str] = MISSING + """List of joint names or regex expressions that the action will be mapped to.""" + body_name: str = MISSING + """Name of the body or frame for which IK is performed.""" + body_offset: OffsetCfg | None = None + """Offset of target frame w.r.t. to the body frame. Defaults to None, in which case no offset is applied.""" + scale: float | tuple[float, ...] = 1.0 + + controller: RmpFlowControllerCfg = MISSING + + articulation_prim_expr: str = MISSING # The expression to find the articulation prim paths. + """The configuration for the RMPFlow controller.""" + + use_relative_mode: bool = False + """ + Defaults to False. + If True, then the controller treats the input command as a delta change in the position/pose. + Otherwise, the controller treats the input command as the absolute position/pose. + """ diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py new file mode 100644 index 000000000000..b270a7b92c1c --- /dev/null +++ b/source/isaaclab/isaaclab/envs/mdp/actions/rmpflow_task_space_actions.py @@ -0,0 +1,214 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import torch +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import omni.log + +import isaaclab.utils.math as math_utils +import isaaclab.utils.string as string_utils +from isaaclab.assets.articulation import Articulation +from isaaclab.controllers.rmp_flow import RmpFlowController +from isaaclab.managers.action_manager import ActionTerm + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv + + from . import rmpflow_actions_cfg + + +class RMPFlowAction(ActionTerm): + + cfg: rmpflow_actions_cfg.RMPFlowActionCfg + """The configuration of the action term.""" + _asset: Articulation + """The articulation asset on which the action term is applied.""" + _scale: torch.Tensor + """The scaling factor applied to the input action. Shape is (1, action_dim).""" + _clip: torch.Tensor + """The clip applied to the input action.""" + + def __init__(self, cfg: rmpflow_actions_cfg.RMPFlowActionCfg, env: ManagerBasedEnv): + # initialize the action term + super().__init__(cfg, env) + + # resolve the joints over which the action term is applied + self._joint_ids, self._joint_names = self._asset.find_joints(self.cfg.joint_names) + self._num_joints = len(self._joint_ids) + # parse the body index + body_ids, body_names = self._asset.find_bodies(self.cfg.body_name) + if len(body_ids) != 1: + raise ValueError( + f"Expected one match for the body name: {self.cfg.body_name}. Found {len(body_ids)}: {body_names}." + ) + # save only the first body index + self._body_idx = body_ids[0] + self._body_name = body_names[0] + + # check if articulation is fixed-base + # if fixed-base then the jacobian for the base is not computed + # this means that number of bodies is one less than the articulation's number of bodies + if self._asset.is_fixed_base: + self._jacobi_body_idx = self._body_idx - 1 + self._jacobi_joint_ids = self._joint_ids + else: + self._jacobi_body_idx = self._body_idx + self._jacobi_joint_ids = [i + 6 for i in self._joint_ids] + + # log info for debugging + omni.log.info( + f"Resolved joint names for the action term {self.__class__.__name__}:" + f" {self._joint_names} [{self._joint_ids}]" + ) + omni.log.info( + f"Resolved body name for the action term {self.__class__.__name__}: {self._body_name} [{self._body_idx}]" + ) + # Avoid indexing across all joints for efficiency + if self._num_joints == self._asset.num_joints: + self._joint_ids = slice(None) + + # create the differential IK controller + self._rmpflow_controller = RmpFlowController(cfg=self.cfg.controller, device=self.device) + + # create tensors for raw and processed actions + self._raw_actions = torch.zeros(self.num_envs, self.action_dim, device=self.device) + self._processed_actions = torch.zeros_like(self.raw_actions) + + # save the scale as tensors + self._scale = torch.zeros((self.num_envs, self.action_dim), device=self.device) + self._scale[:] = torch.tensor(self.cfg.scale, device=self.device) + + # convert the fixed offsets to torch tensors of batched shape + if self.cfg.body_offset is not None: + self._offset_pos = torch.tensor(self.cfg.body_offset.pos, device=self.device).repeat(self.num_envs, 1) + self._offset_rot = torch.tensor(self.cfg.body_offset.rot, device=self.device).repeat(self.num_envs, 1) + else: + self._offset_pos, self._offset_rot = None, None + + # parse clip + if self.cfg.clip is not None: + if isinstance(cfg.clip, dict): + self._clip = torch.tensor([[-float("inf"), float("inf")]], device=self.device).repeat( + self.num_envs, self.action_dim, 1 + ) + index_list, _, value_list = string_utils.resolve_matching_names_values(self.cfg.clip, self._joint_names) + self._clip[:, index_list] = torch.tensor(value_list, device=self.device) + else: + raise ValueError(f"Unsupported clip type: {type(cfg.clip)}. Supported types are dict.") + + """ + Properties. + """ + + @property + def action_dim(self) -> int: + if self.cfg.use_relative_mode: + return 6 # delta_eef_xyz, delta_eef_rpy + else: + return 7 # absolute_eef_xyz, absolute_eef_quat + # self._rmpflow_controller.num_actions = 7 since it use quaternions (w,x,y,z) as command + + @property + def raw_actions(self) -> torch.Tensor: + return self._raw_actions + + @property + def processed_actions(self) -> torch.Tensor: + return self._processed_actions + + @property + def jacobian_w(self) -> torch.Tensor: + return self._asset.root_physx_view.get_jacobians()[:, self._jacobi_body_idx, :, self._jacobi_joint_ids] + + @property + def jacobian_b(self) -> torch.Tensor: + jacobian = self.jacobian_w + base_rot = self._asset.data.root_quat_w + base_rot_matrix = math_utils.matrix_from_quat(math_utils.quat_inv(base_rot)) + jacobian[:, :3, :] = torch.bmm(base_rot_matrix, jacobian[:, :3, :]) + jacobian[:, 3:, :] = torch.bmm(base_rot_matrix, jacobian[:, 3:, :]) + return jacobian + + """ + Operations. + """ + + # This is called each env.step() + def process_actions(self, actions: torch.Tensor): + # store the raw actions + self._raw_actions[:] = actions + self._processed_actions[:] = self.raw_actions * self._scale + if self.cfg.clip is not None: + self._processed_actions = torch.clamp( + self._processed_actions, min=self._clip[:, :, 0], max=self._clip[:, :, 1] + ) + # If use_relative_mode is True, then the controller will apply delta change to the current ee_pose. + if self.cfg.use_relative_mode: + # obtain quantities from simulation + ee_pos_curr, ee_quat_curr = self._compute_frame_pose() + + # compute ee_pose_targets use_relative_actions + if ee_pos_curr is None or ee_quat_curr is None: + raise ValueError( + "Neither end-effector position nor orientation can be None for `pose_rel` command type!" + ) + self.ee_pos_des, self.ee_quat_des = math_utils.apply_delta_pose( + ee_pos_curr, ee_quat_curr, self._processed_actions + ) + else: # If use_relative_mode is False, then the controller will apply absolute ee_pose. + self.ee_pos_des = self._processed_actions[:, 0:3] + self.ee_quat_des = self._processed_actions[:, 3:7] + + self.ee_pose_des = torch.cat([self.ee_pos_des, self.ee_quat_des], dim=1) # shape: [n, 7] + + # set command into controller + self._rmpflow_controller.set_command(self.ee_pose_des) + + # This is called each simulationcontext.step(), step *decimation* times when env.step() update actions + def apply_actions(self): + # obtain quantities from simulation + ee_pos_curr, ee_quat_curr = self._compute_frame_pose() + joint_pos = self._asset.data.joint_pos[:, self._joint_ids] + # compute the delta in joint-space + if ee_quat_curr.norm() != 0: + joint_pos_des, joint_vel_des = self._rmpflow_controller.compute() + else: + joint_pos_des = joint_pos.clone() + # set the joint position command + self._asset.set_joint_position_target(joint_pos_des, self._joint_ids) + self._asset.set_joint_velocity_target(joint_vel_des, self._joint_ids) + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + self._raw_actions[env_ids] = 0.0 + self._rmpflow_controller.initialize(self.cfg.articulation_prim_expr) + + """ + Helper functions. + """ + + def _compute_frame_pose(self) -> tuple[torch.Tensor, torch.Tensor]: + """Computes the pose of the target frame in the root frame. + + Returns: + A tuple of the body's position and orientation in the root frame. + """ + # obtain quantities from simulation + ee_pos_w = self._asset.data.body_pos_w[:, self._body_idx] + ee_quat_w = self._asset.data.body_quat_w[:, self._body_idx] + root_pos_w = self._asset.data.root_pos_w + root_quat_w = self._asset.data.root_quat_w + # compute the pose of the body in the root frame + ee_pose_b, ee_quat_b = math_utils.subtract_frame_transforms(root_pos_w, root_quat_w, ee_pos_w, ee_quat_w) + # account for the offset + if self.cfg.body_offset is not None: + ee_pose_b, ee_quat_b = math_utils.combine_frame_transforms( + ee_pose_b, ee_quat_b, self._offset_pos, self._offset_rot + ) + + return ee_pose_b, ee_quat_b diff --git a/source/isaaclab_assets/isaaclab_assets/robots/__init__.py b/source/isaaclab_assets/isaaclab_assets/robots/__init__.py index 4d57843d312c..a5996104680e 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/__init__.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/__init__.py @@ -14,6 +14,7 @@ from .cartpole import * from .fourier import * from .franka import * +from .galbot import * from .humanoid import * from .humanoid_28 import * from .kinova import * diff --git a/source/isaaclab_assets/isaaclab_assets/robots/galbot.py b/source/isaaclab_assets/isaaclab_assets/robots/galbot.py new file mode 100644 index 000000000000..d74543725916 --- /dev/null +++ b/source/isaaclab_assets/isaaclab_assets/robots/galbot.py @@ -0,0 +1,102 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +"""Configuration for the Galbot humanoid robot. + +The following configuration parameters are available: + +* :obj:`GALBOT_ONE_CHARLIE_CFG`: The galbot_one_charlie humanoid robot. + +""" + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR + +## +# Configuration +## + + +GALBOT_ONE_CHARLIE_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Robots/Galbot/galbot_one_charlie/galbot_one_charlie.usd", + variants={"Physics": "PhysX"}, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=True, + max_depenetration_velocity=5.0, + ), + activate_contact_sensors=True, + ), + init_state=ArticulationCfg.InitialStateCfg( + joint_pos={ + "leg_joint1": 0.8, + "leg_joint2": 2.3, + "leg_joint3": 1.55, + "leg_joint4": 0.0, + "head_joint1": 0.0, + "head_joint2": 0.36, + "left_arm_joint1": -0.5480, + "left_arm_joint2": -0.6551, + "left_arm_joint3": 2.407, + "left_arm_joint4": 1.3641, + "left_arm_joint5": -0.4416, + "left_arm_joint6": 0.1168, + "left_arm_joint7": 1.2308, + "left_gripper_left_joint": 0.035, + "left_gripper_right_joint": 0.035, + "right_arm_joint1": 0.1535, + "right_arm_joint2": 1.0087, + "right_arm_joint3": 0.0895, + "right_arm_joint4": 1.5743, + "right_arm_joint5": -0.2422, + "right_arm_joint6": -0.0009, + "right_arm_joint7": -0.9143, + "right_suction_cup_joint1": 0.0, + }, + pos=(-0.6, 0.0, -0.8), + ), + # PD parameters are read from USD file with Gain Tuner + actuators={ + "head": ImplicitActuatorCfg( + joint_names_expr=["head_joint.*"], + velocity_limit_sim=None, + effort_limit_sim=None, + stiffness=None, + damping=None, + ), + "leg": ImplicitActuatorCfg( + joint_names_expr=["leg_joint.*"], + velocity_limit_sim=None, + effort_limit_sim=None, + stiffness=None, + damping=None, + ), + "left_arm": ImplicitActuatorCfg( + joint_names_expr=["left_arm_joint.*"], + velocity_limit_sim=None, + effort_limit_sim=None, + stiffness=None, + damping=None, + ), + "right_arm": ImplicitActuatorCfg( + joint_names_expr=["right_arm_joint.*", "right_suction_cup_joint1"], + velocity_limit_sim=None, + effort_limit_sim=None, + stiffness=None, + damping=None, + ), + "left_gripper": ImplicitActuatorCfg( + joint_names_expr=["left_gripper_.*_joint"], + velocity_limit_sim=1.0, + effort_limit_sim=None, + stiffness=None, + damping=None, + ), + }, +) +"""Configuration of Galbot_one_charlie humanoid using implicit actuator models.""" diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py b/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py index dedd20c75bf2..84305a66745d 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py @@ -14,6 +14,13 @@ from .franka_stack_ik_rel_mimic_env_cfg import FrankaCubeStackIKRelMimicEnvCfg from .franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg import FrankaCubeStackIKRelVisuomotorCosmosMimicEnvCfg from .franka_stack_ik_rel_visuomotor_mimic_env_cfg import FrankaCubeStackIKRelVisuomotorMimicEnvCfg +from .galbot_stack_rmp_abs_mimic_env import RmpFlowGalbotCubeStackAbsMimicEnv +from .galbot_stack_rmp_abs_mimic_env_cfg import ( + RmpFlowGalbotLeftArmGripperCubeStackAbsMimicEnvCfg, + RmpFlowGalbotRightArmSuctionCubeStackAbsMimicEnvCfg, +) +from .galbot_stack_rmp_rel_mimic_env import RmpFlowGalbotCubeStackRelMimicEnv +from .galbot_stack_rmp_rel_mimic_env_cfg import RmpFlowGalbotLeftArmGripperCubeStackRelMimicEnvCfg ## # Inverse Kinematics - Relative Pose Control @@ -65,3 +72,47 @@ }, disable_env_checker=True, ) + + +## +# Galbot Stack Cube with RmpFlow - Relative Pose Control +## + +gym.register( + id="Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-Rel-Mimic-v0", + entry_point="isaaclab_mimic.envs:RmpFlowGalbotCubeStackRelMimicEnv", + kwargs={ + "env_cfg_entry_point": galbot_stack_rmp_rel_mimic_env_cfg.RmpFlowGalbotLeftArmGripperCubeStackRelMimicEnvCfg, + }, + disable_env_checker=True, +) + +gym.register( + id="Isaac-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow-Rel-Mimic-v0", + entry_point="isaaclab_mimic.envs:RmpFlowGalbotCubeStackRelMimicEnv", + kwargs={ + "env_cfg_entry_point": galbot_stack_rmp_rel_mimic_env_cfg.RmpFlowGalbotRightArmSuctionCubeStackRelMimicEnvCfg, + }, + disable_env_checker=True, +) + +## +# Galbot Stack Cube with RmpFlow - Absolute Pose Control +## +gym.register( + id="Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-Abs-Mimic-v0", + entry_point="isaaclab_mimic.envs:RmpFlowGalbotCubeStackAbsMimicEnv", + kwargs={ + "env_cfg_entry_point": galbot_stack_rmp_abs_mimic_env_cfg.RmpFlowGalbotLeftArmGripperCubeStackAbsMimicEnvCfg, + }, + disable_env_checker=True, +) + +gym.register( + id="Isaac-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow-Abs-Mimic-v0", + entry_point="isaaclab_mimic.envs:RmpFlowGalbotCubeStackAbsMimicEnv", + kwargs={ + "env_cfg_entry_point": galbot_stack_rmp_abs_mimic_env_cfg.RmpFlowGalbotRightArmSuctionCubeStackAbsMimicEnvCfg, + }, + disable_env_checker=True, +) diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py index ee442267e930..ceaeb36765ca 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py @@ -80,7 +80,7 @@ def target_eef_pose_to_action( # add noise to action pose_action = torch.cat([delta_position, delta_rotation], dim=0) if action_noise_dict is not None: - noise = action_noise_dict["franka"] * torch.randn_like(pose_action) + noise = action_noise_dict[eef_name] * torch.randn_like(pose_action) pose_action += noise pose_action = torch.clamp(pose_action, -1.0, 1.0) diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env.py new file mode 100644 index 000000000000..b92cd81d3ecc --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env.py @@ -0,0 +1,47 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Sequence + +import isaaclab.utils.math as PoseUtils + +from .franka_stack_ik_abs_mimic_env import FrankaCubeStackIKAbsMimicEnv + + +class RmpFlowGalbotCubeStackAbsMimicEnv(FrankaCubeStackIKAbsMimicEnv): + """ + Isaac Lab Mimic environment wrapper class for Galbot Cube Stack RmpFlow Absolute env. + """ + + def get_object_poses(self, env_ids: Sequence[int] | None = None): + """ + Rewrite this function to get the pose of each object in robot base frame, + relevant to Isaac Lab Mimic data generation in the current scene. + + Args: + env_ids: Environment indices to get the pose for. If None, all envs are considered. + + Returns: + A dictionary that maps object names to object pose matrix in base frame of robot (4x4 torch.Tensor) + """ + + if env_ids is None: + env_ids = slice(None) + + rigid_object_states = self.scene.get_state(is_relative=True)["rigid_object"] + robot_states = self.scene.get_state(is_relative=True)["articulation"]["robot"] + root_pose = robot_states["root_pose"] + root_pos = root_pose[env_ids, :3] + root_quat = root_pose[env_ids, 3:7] + + object_pose_matrix = dict() + for obj_name, obj_state in rigid_object_states.items(): + pos_obj_base, quat_obj_base = PoseUtils.subtract_frame_transforms( + root_pos, root_quat, obj_state["root_pose"][env_ids, :3], obj_state["root_pose"][env_ids, 3:7] + ) + rot_obj_base = PoseUtils.matrix_from_quat(quat_obj_base) + object_pose_matrix[obj_name] = PoseUtils.make_pose(pos_obj_base, rot_obj_base) + + return object_pose_matrix diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py new file mode 100644 index 000000000000..83746beff687 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py @@ -0,0 +1,254 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + + +from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.stack.config.galbot.stack_rmp_rel_env_cfg import ( + RmpFlowGalbotLeftArmCubeStackEnvCfg, + RmpFlowGalbotRightArmCubeStackEnvCfg, +) + + +@configclass +class RmpFlowGalbotLeftArmGripperCubeStackAbsMimicEnvCfg(RmpFlowGalbotLeftArmCubeStackEnvCfg, MimicEnvCfg): + """ + Isaac Lab Mimic environment config class for Galbot Gripper Cube Stack IK Rel env. + """ + + def __post_init__(self): + # post init of parents + super().__post_init__() + + # Override the existing values + self.datagen_config.name = "demo_src_stack_isaac_lab_task_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = True + self.datagen_config.generation_num_trials = 10 + self.datagen_config.generation_select_src_per_subtask = True + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.seed = 1 + + # The following are the subtask configurations for the stack task. + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # This key corresponds to the binary indicator in "datagen_info" that signals + # when this subtask is finished (e.g., on a 0 to 1 edge). + subtask_term_signal="grasp_1", + # Specifies time offsets for data generation when splitting a trajectory into + # subtask segments. Random offsets are added to the termination boundary. + subtask_term_offset_range=( + 18, + 25, + ), + # Selection strategy for the source subtask segment during data generation + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_1", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="stack_1", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=( + 18, + 25, + ), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_3", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="grasp_2", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=( + 25, + 30, + ), # this should be larger than the other subtasks, because the gripper should be lifted higher than 2 blocks + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # End of final subtask does not need to be detected + subtask_term_signal=None, + # No time offsets for the final subtask + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["galbot"] = subtask_configs + + +@configclass +class RmpFlowGalbotRightArmSuctionCubeStackAbsMimicEnvCfg(RmpFlowGalbotRightArmCubeStackEnvCfg, MimicEnvCfg): + """ + Isaac Lab Mimic environment config class for Galbot Suction Gripper Cube Stack RmpFlow Abs env. + """ + + def __post_init__(self): + # post init of parents + super().__post_init__() + + # Override the existing values + self.datagen_config.name = "demo_src_stack_isaac_lab_task_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = True + self.datagen_config.generation_num_trials = 10 + self.datagen_config.generation_select_src_per_subtask = True + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.seed = 1 + + # The following are the subtask configurations for the stack task. + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # This key corresponds to the binary indicator in "datagen_info" that signals + # when this subtask is finished (e.g., on a 0 to 1 edge). + subtask_term_signal="grasp_1", + # Specifies time offsets for data generation when splitting a trajectory into + # subtask segments. Random offsets are added to the termination boundary. + subtask_term_offset_range=(5, 10), + # Selection strategy for the source subtask segment during data generation + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_1", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="stack_1", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(2, 10), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_3", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="grasp_2", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(5, 10), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # End of final subtask does not need to be detected + subtask_term_signal=None, + # No time offsets for the final subtask + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["galbot"] = subtask_configs diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env.py new file mode 100644 index 000000000000..953a6f536ced --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env.py @@ -0,0 +1,48 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + + +from collections.abc import Sequence + +import isaaclab.utils.math as PoseUtils + +from .franka_stack_ik_rel_mimic_env import FrankaCubeStackIKRelMimicEnv + + +class RmpFlowGalbotCubeStackRelMimicEnv(FrankaCubeStackIKRelMimicEnv): + """ + Isaac Lab Mimic environment wrapper class for Galbot Cube Stack RmpFlow Rel env. + """ + + def get_object_poses(self, env_ids: Sequence[int] | None = None): + """ + Rewrite this function to get the pose of each object in robot base frame, + relevant to Isaac Lab Mimic data generation in the current scene. + + Args: + env_ids: Environment indices to get the pose for. If None, all envs are considered. + + Returns: + A dictionary that maps object names to object pose matrix in base frame of robot (4x4 torch.Tensor) + """ + + if env_ids is None: + env_ids = slice(None) + + rigid_object_states = self.scene.get_state(is_relative=True)["rigid_object"] + robot_states = self.scene.get_state(is_relative=True)["articulation"]["robot"] + root_pose = robot_states["root_pose"] + root_pos = root_pose[env_ids, :3] + root_quat = root_pose[env_ids, 3:7] + + object_pose_matrix = dict() + for obj_name, obj_state in rigid_object_states.items(): + pos_obj_base, quat_obj_base = PoseUtils.subtract_frame_transforms( + root_pos, root_quat, obj_state["root_pose"][env_ids, :3], obj_state["root_pose"][env_ids, 3:7] + ) + rot_obj_base = PoseUtils.matrix_from_quat(quat_obj_base) + object_pose_matrix[obj_name] = PoseUtils.make_pose(pos_obj_base, rot_obj_base) + + return object_pose_matrix diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py new file mode 100644 index 000000000000..77ba9c9f8d86 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py @@ -0,0 +1,254 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + + +from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.stack.config.galbot.stack_rmp_rel_env_cfg import ( + RmpFlowGalbotLeftArmCubeStackEnvCfg, + RmpFlowGalbotRightArmCubeStackEnvCfg, +) + + +@configclass +class RmpFlowGalbotLeftArmGripperCubeStackRelMimicEnvCfg(RmpFlowGalbotLeftArmCubeStackEnvCfg, MimicEnvCfg): + """ + Isaac Lab Mimic environment config class for Galbot Gripper Cube Stack IK Rel env. + """ + + def __post_init__(self): + # post init of parents + super().__post_init__() + + # Override the existing values + self.datagen_config.name = "demo_src_stack_isaac_lab_task_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = True + self.datagen_config.generation_num_trials = 10 + self.datagen_config.generation_select_src_per_subtask = True + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.seed = 1 + + # The following are the subtask configurations for the stack task. + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # This key corresponds to the binary indicator in "datagen_info" that signals + # when this subtask is finished (e.g., on a 0 to 1 edge). + subtask_term_signal="grasp_1", + # Specifies time offsets for data generation when splitting a trajectory into + # subtask segments. Random offsets are added to the termination boundary. + subtask_term_offset_range=( + 18, + 25, + ), + # Selection strategy for the source subtask segment during data generation + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_1", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="stack_1", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=( + 18, + 25, + ), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_3", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="grasp_2", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=( + 25, + 30, + ), # this should be larger than the other subtasks, because the gripper should be lifted higher than 2 blocks + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # End of final subtask does not need to be detected + subtask_term_signal=None, + # No time offsets for the final subtask + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["galbot"] = subtask_configs + + +@configclass +class RmpFlowGalbotRightArmSuctionCubeStackRelMimicEnvCfg(RmpFlowGalbotRightArmCubeStackEnvCfg, MimicEnvCfg): + """ + Isaac Lab Mimic environment config class for Galbot Suction Gripper Cube Stack RmpFlow Rel env. + """ + + def __post_init__(self): + # post init of parents + super().__post_init__() + + # Override the existing values + self.datagen_config.name = "demo_src_stack_isaac_lab_task_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = True + self.datagen_config.generation_num_trials = 10 + self.datagen_config.generation_select_src_per_subtask = True + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.seed = 1 + + # The following are the subtask configurations for the stack task. + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # This key corresponds to the binary indicator in "datagen_info" that signals + # when this subtask is finished (e.g., on a 0 to 1 edge). + subtask_term_signal="grasp_1", + # Specifies time offsets for data generation when splitting a trajectory into + # subtask segments. Random offsets are added to the termination boundary. + subtask_term_offset_range=(5, 10), + # Selection strategy for the source subtask segment during data generation + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_1", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="stack_1", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(2, 10), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_3", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="grasp_2", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(5, 10), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # End of final subtask does not need to be detected + subtask_term_signal=None, + # No time offsets for the final subtask + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.01, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=15, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["galbot"] = subtask_configs diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 88ba2eda5fcd..56634f323357 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.10.49" +version = "0.10.50" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index c0909d246577..e4e098deee7f 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,14 @@ Changelog --------- +0.10.50 (2025-09-05) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added stacking environments for Galbot with suction grippers. + 0.10.49 (2025-09-05) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py index 502f057d4a37..5d26f6ff0143 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py @@ -86,6 +86,9 @@ def __post_init__(self): open_command_expr={"panda_finger_.*": 0.04}, close_command_expr={"panda_finger_.*": 0.0}, ) + self.gripper_joint_names = ["panda_finger_.*"] + self.gripper_open_val = 0.04 + self.gripper_threshold = 0.005 # Rigid body properties of each cube cube_properties = RigidBodyPropertiesCfg( diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/__init__.py new file mode 100644 index 000000000000..06bc8dcbf061 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/__init__.py @@ -0,0 +1,74 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +import gymnasium as gym +import os + +from . import stack_rmp_rel_env_cfg + +## +# Register Gym environments. +## + +## +# RMPFlow (with Joint Limit Constraint and Obstacle Avoidance) for Galbot Single Arm Cube Stack Task +# you can use for both absolute and relative mode, by given the USE_RELATIVE_MODE environment variable +## +gym.register( + id="Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_rmp_rel_env_cfg.RmpFlowGalbotLeftArmCubeStackEnvCfg, + }, + disable_env_checker=True, +) + + +gym.register( + id="Isaac-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_rmp_rel_env_cfg.RmpFlowGalbotRightArmCubeStackEnvCfg, + }, + disable_env_checker=True, +) + + +## +# Visuomotor Task for Galbot Left ArmCube Stack Task +## +gym.register( + id="Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_rmp_rel_env_cfg.RmpFlowGalbotLeftArmCubeStackVisuomotorEnvCfg, + }, + disable_env_checker=True, +) + +## +# Policy Close-loop Evaluation Task for Galbot Left Arm Cube Stack Task (in Joint Space) +## +gym.register( + id="Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-Joint-Position-Play-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_rmp_rel_env_cfg.GalbotLeftArmJointPositionCubeStackVisuomotorEnvCfg_PLAY, + }, + disable_env_checker=True, +) + +## +# Policy Close-loop Evaluation Task for Galbot Left Arm Cube Stack Task (in Task Space) +## +gym.register( + id="Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-RmpFlow-Play-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_rmp_rel_env_cfg.GalbotLeftArmRmpFlowCubeStackVisuomotorEnvCfg_PLAY, + }, + disable_env_checker=True, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py new file mode 100644 index 000000000000..af7c2c07c4ac --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_joint_pos_env_cfg.py @@ -0,0 +1,278 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +from isaaclab.assets import RigidObjectCfg, SurfaceGripperCfg +from isaaclab.envs.mdp.actions.actions_cfg import SurfaceGripperBinaryActionCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.sensors import FrameTransformerCfg +from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg +from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg +from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + +from isaaclab_tasks.manager_based.manipulation.stack import mdp +from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events +from isaaclab_tasks.manager_based.manipulation.stack.stack_env_cfg import ObservationsCfg, StackEnvCfg + +## +# Pre-defined configs +## +from isaaclab.markers.config import FRAME_MARKER_CFG # isort: skip +from isaaclab_assets.robots.galbot import GALBOT_ONE_CHARLIE_CFG # isort: skip + + +@configclass +class EventCfg: + """Configuration for events.""" + + reset_all = EventTerm(func=mdp.reset_scene_to_default, mode="reset", params={"reset_joint_targets": True}) + + randomize_cube_positions = EventTerm( + func=franka_stack_events.randomize_object_pose, + mode="reset", + params={ + "pose_range": { + "x": (-0.2, 0.0), + "y": (0.20, 0.40), + "z": (0.0203, 0.0203), + "yaw": (-1.0, 1.0, 0.0), + }, + "min_separation": 0.1, + "asset_cfgs": [SceneEntityCfg("cube_1"), SceneEntityCfg("cube_2"), SceneEntityCfg("cube_3")], + }, + ) + + +@configclass +class ObservationGalbotLeftArmGripperCfg: + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group with state values.""" + + actions = ObsTerm(func=mdp.last_action) + joint_pos = ObsTerm(func=mdp.joint_pos_rel) + joint_vel = ObsTerm(func=mdp.joint_vel_rel) + + object = ObsTerm( + func=mdp.object_abs_obs_in_base_frame, + params={ + "robot_cfg": SceneEntityCfg("robot"), + }, + ) + cube_positions = ObsTerm( + func=mdp.cube_poses_in_base_frame, params={"robot_cfg": SceneEntityCfg("robot"), "return_key": "pos"} + ) + cube_orientations = ObsTerm( + func=mdp.cube_poses_in_base_frame, params={"robot_cfg": SceneEntityCfg("robot"), "return_key": "quat"} + ) + + eef_pos = ObsTerm( + func=mdp.ee_frame_pose_in_base_frame, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "ee_frame_cfg": SceneEntityCfg("ee_frame"), + "return_key": "pos", + }, + ) + eef_quat = ObsTerm( + func=mdp.ee_frame_pose_in_base_frame, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "ee_frame_cfg": SceneEntityCfg("ee_frame"), + "return_key": "quat", + }, + ) + gripper_pos = ObsTerm( + func=mdp.gripper_pos, + ) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = False + + @configclass + class SubtaskCfg(ObservationsCfg.SubtaskCfg): + """Observations for subtask group.""" + + grasp_1 = ObsTerm( + func=mdp.object_grasped, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "ee_frame_cfg": SceneEntityCfg("ee_frame"), + "object_cfg": SceneEntityCfg("cube_2"), + }, + ) + stack_1 = ObsTerm( + func=mdp.object_stacked, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "upper_object_cfg": SceneEntityCfg("cube_2"), + "lower_object_cfg": SceneEntityCfg("cube_1"), + }, + ) + grasp_2 = ObsTerm( + func=mdp.object_grasped, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "ee_frame_cfg": SceneEntityCfg("ee_frame"), + "object_cfg": SceneEntityCfg("cube_3"), + }, + ) + + def __post_init__(self): + super().__post_init__() + + @configclass + class RGBCameraPolicyCfg(ObsGroup): + """Observations for policy group with RGB images.""" + + table_cam = ObsTerm( + func=mdp.image, params={"sensor_cfg": SceneEntityCfg("table_cam"), "data_type": "rgb", "normalize": False} + ) + wrist_cam = ObsTerm( + func=mdp.image, params={"sensor_cfg": SceneEntityCfg("wrist_cam"), "data_type": "rgb", "normalize": False} + ) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = False + + subtask_terms: SubtaskCfg = SubtaskCfg() + policy: PolicyCfg = PolicyCfg() + rgb_camera: RGBCameraPolicyCfg = RGBCameraPolicyCfg() + + +@configclass +class GalbotLeftArmCubeStackEnvCfg(StackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + # MDP settings + + # Set events + self.events = EventCfg() + self.observations.policy = ObservationGalbotLeftArmGripperCfg().PolicyCfg() + self.observations.subtask_terms = ObservationGalbotLeftArmGripperCfg().SubtaskCfg() + + # Set galbot as robot + self.scene.robot = GALBOT_ONE_CHARLIE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # Set actions for the specific robot type (galbot) + self.actions.arm_action = mdp.JointPositionActionCfg( + asset_name="robot", joint_names=["left_arm_joint.*"], scale=0.5, use_default_offset=True + ) + # Enable Parallel Gripper + self.actions.gripper_action = mdp.BinaryJointPositionActionCfg( + asset_name="robot", + joint_names=["left_gripper_.*_joint"], + open_command_expr={"left_gripper_.*_joint": 0.035}, + close_command_expr={"left_gripper_.*_joint": 0.0}, + ) + self.gripper_joint_names = ["left_gripper_.*_joint"] + self.gripper_open_val = 0.035 + self.gripper_threshold = 0.010 + + # Rigid body properties of each cube + cube_properties = RigidBodyPropertiesCfg( + solver_position_iteration_count=16, + solver_velocity_iteration_count=1, + max_angular_velocity=1000.0, + max_linear_velocity=1000.0, + max_depenetration_velocity=5.0, + disable_gravity=False, + ) + + # Set each stacking cube deterministically + self.scene.cube_1 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_1", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.4, 0.0, 0.0203], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/blue_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=cube_properties, + ), + ) + self.scene.cube_2 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_2", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.55, 0.05, 0.0203], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/red_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=cube_properties, + ), + ) + self.scene.cube_3 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_3", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.60, -0.1, 0.0203], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/green_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=cube_properties, + ), + ) + + # Listens to the required transforms + self.marker_cfg = FRAME_MARKER_CFG.copy() + self.marker_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) + self.marker_cfg.prim_path = "/Visuals/FrameTransformer" + + self.scene.ee_frame = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base_link", + debug_vis=False, + visualizer_cfg=self.marker_cfg, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/left_gripper_tcp_link", + name="end_effector", + offset=OffsetCfg( + pos=[0.0, 0.0, 0.0], + ), + ), + ], + ) + + +@configclass +class GalbotRightArmCubeStackEnvCfg(GalbotLeftArmCubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + l, r = self.events.randomize_cube_positions.params["pose_range"]["y"] + self.events.randomize_cube_positions.params["pose_range"]["y"] = ( + -r, + -l, + ) # move to area below right hand + + # Set actions for the specific robot type (galbot) + self.actions.arm_action = mdp.JointPositionActionCfg( + asset_name="robot", joint_names=["right_arm_joint.*"], scale=0.5, use_default_offset=True + ) + + # Set surface gripper: Ensure the SurfaceGripper prim has the required attributes + self.scene.surface_gripper = SurfaceGripperCfg( + prim_path="{ENV_REGEX_NS}/Robot/right_suction_cup_tcp_link/SurfaceGripper", + max_grip_distance=0.02, + shear_force_limit=5000.0, + coaxial_force_limit=5000.0, + retry_interval=0.05, + ) + + # Set surface gripper action + self.actions.gripper_action = SurfaceGripperBinaryActionCfg( + asset_name="surface_gripper", + open_command=-1.0, + close_command=1.0, + ) + + self.scene.ee_frame.target_frames[0].prim_path = "{ENV_REGEX_NS}/Robot/right_suction_cup_tcp_link" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py new file mode 100644 index 000000000000..7aafc6990f36 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py @@ -0,0 +1,282 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +import os + +import isaaclab.sim as sim_utils +from isaaclab.devices.device_base import DevicesCfg +from isaaclab.devices.keyboard import Se3KeyboardCfg +from isaaclab.devices.spacemouse import Se3SpaceMouseCfg +from isaaclab.envs.mdp.actions.rmpflow_actions_cfg import RMPFlowActionCfg +from isaaclab.sensors import CameraCfg, FrameTransformerCfg +from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.stack import mdp + +from . import stack_joint_pos_env_cfg + +## +# Pre-defined configs +## +from isaaclab.controllers.config.rmp_flow import ( # isort: skip + GALBOT_LEFT_ARM_RMPFLOW_CFG, + GALBOT_RIGHT_ARM_RMPFLOW_CFG, +) +from isaaclab.markers.config import FRAME_MARKER_CFG # isort: skip + + +## +# RmpFlow Controller for Galbot Left Arm Cube Stack Task (with Parallel Gripper) +## +@configclass +class RmpFlowGalbotLeftArmCubeStackEnvCfg(stack_joint_pos_env_cfg.GalbotLeftArmCubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # read use_relative_mode from environment variable + # True for record_demos, and False for replay_demos, annotate_demos and generate_demos + use_relative_mode_env = os.getenv("USE_RELATIVE_MODE", "True") + self.use_relative_mode = use_relative_mode_env.lower() in ["true", "1", "t"] + + # Set actions for the specific robot type (Galbot) + self.actions.arm_action = RMPFlowActionCfg( + asset_name="robot", + joint_names=["left_arm_joint.*"], + body_name="left_gripper_tcp_link", + controller=GALBOT_LEFT_ARM_RMPFLOW_CFG, + scale=1.0, + body_offset=RMPFlowActionCfg.OffsetCfg(pos=[0.0, 0.0, 0.0]), + articulation_prim_expr="/World/envs/env_.*/Robot", + use_relative_mode=self.use_relative_mode, + ) + + # Set the simulation parameters + self.sim.dt = 1 / 60 + self.sim.render_interval = 6 + + self.decimation = 3 + self.episode_length_s = 30.0 + + self.teleop_devices = DevicesCfg( + devices={ + "keyboard": Se3KeyboardCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + "spacemouse": Se3SpaceMouseCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + } + ) + + +## +# RmpFlow Controller for Galbot Right Arm Cube Stack Task (with Surface Gripper) +## +@configclass +class RmpFlowGalbotRightArmCubeStackEnvCfg(stack_joint_pos_env_cfg.GalbotRightArmCubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # read use_relative_mode from environment variable + # True for record_demos, and False for replay_demos, annotate_demos and generate_demos + use_relative_mode_env = os.getenv("USE_RELATIVE_MODE", "True") + self.use_relative_mode = use_relative_mode_env.lower() in ["true", "1", "t"] + + # Set actions for the specific robot type (Galbot) + self.actions.arm_action = RMPFlowActionCfg( + asset_name="robot", + joint_names=["right_arm_joint.*"], + body_name="right_suction_cup_tcp_link", + controller=GALBOT_RIGHT_ARM_RMPFLOW_CFG, + scale=1.0, + body_offset=RMPFlowActionCfg.OffsetCfg(pos=[0.0, 0.0, 0.0]), + articulation_prim_expr="/World/envs/env_.*/Robot", + use_relative_mode=self.use_relative_mode, + ) + # Set the simulation parameters + self.sim.dt = 1 / 60 + self.sim.render_interval = 1 + + self.decimation = 3 + self.episode_length_s = 30.0 + + self.teleop_devices = DevicesCfg( + devices={ + "keyboard": Se3KeyboardCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + "spacemouse": Se3SpaceMouseCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + } + ) + + +## +# Visuomotor Env for Record, Generate and Replay (in Task Space) +## +@configclass +class RmpFlowGalbotLeftArmCubeStackVisuomotorEnvCfg(RmpFlowGalbotLeftArmCubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set left and right wrist cameras for VLA policy training + self.scene.right_wrist_cam = CameraCfg( + prim_path="{ENV_REGEX_NS}/Robot/right_arm_camera_sim_view_frame/right_camera", + update_period=0.0333, + height=256, + width=256, + data_types=["rgb", "distance_to_image_plane"], + spawn=sim_utils.PinholeCameraCfg( + focal_length=18.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(0.1, 1.0e5) + ), + offset=CameraCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.5, -0.5, 0.5, -0.5), convention="ros"), + ) + + self.scene.left_wrist_cam = CameraCfg( + prim_path="{ENV_REGEX_NS}/Robot/left_arm_camera_sim_view_frame/left_camera", + update_period=0.0333, + height=256, + width=256, + data_types=["rgb", "distance_to_image_plane"], + spawn=sim_utils.PinholeCameraCfg( + focal_length=18.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(0.1, 1.0e5) + ), + offset=CameraCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.5, -0.5, 0.5, -0.5), convention="ros"), + ) + + # Set ego view camera + self.scene.ego_cam = CameraCfg( + prim_path="{ENV_REGEX_NS}/Robot/head_camera_sim_view_frame/head_camera", + update_period=0.0333, + height=256, + width=256, + data_types=["rgb", "distance_to_image_plane"], + spawn=sim_utils.PinholeCameraCfg( + focal_length=18.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(0.1, 1.0e5) + ), + offset=CameraCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.5, -0.5, 0.5, -0.5), convention="ros"), + ) + + # Set front view camera + self.scene.front_cam = CameraCfg( + prim_path="{ENV_REGEX_NS}/front_camera", + update_period=0.0333, + height=256, + width=256, + data_types=["rgb", "distance_to_image_plane"], + spawn=sim_utils.PinholeCameraCfg( + focal_length=24.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(0.1, 1.0e5) + ), + offset=CameraCfg.OffsetCfg(pos=(1.0, 0.0, 0.6), rot=(-0.3799, 0.5963, 0.5963, -0.3799), convention="ros"), + ) + + marker_right_camera_cfg = FRAME_MARKER_CFG.copy() + marker_right_camera_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) + marker_right_camera_cfg.prim_path = "/Visuals/FrameTransformerRightCamera" + + self.scene.right_arm_camera_frame = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base_link", + debug_vis=False, + visualizer_cfg=marker_right_camera_cfg, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/right_arm_camera_sim_view_frame", + name="right_camera", + offset=OffsetCfg( + pos=[0.0, 0.0, 0.0], + rot=(0.5, -0.5, 0.5, -0.5), + ), + ), + ], + ) + + marker_left_camera_cfg = FRAME_MARKER_CFG.copy() + marker_left_camera_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) + marker_left_camera_cfg.prim_path = "/Visuals/FrameTransformerLeftCamera" + + self.scene.left_arm_camera_frame = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base_link", + debug_vis=False, + visualizer_cfg=marker_left_camera_cfg, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/left_arm_camera_sim_view_frame", + name="left_camera", + offset=OffsetCfg( + pos=[0.0, 0.0, 0.0], + rot=(0.5, -0.5, 0.5, -0.5), + ), + ), + ], + ) + + # Set settings for camera rendering + self.rerender_on_reset = True + self.sim.render.antialiasing_mode = "OFF" # disable dlss + + # List of image observations in policy observations + self.image_obs_list = ["ego_cam", "left_wrist_cam", "right_wrist_cam"] + + +## +# Task Env for VLA Policy Close-loop Evaluation (in Joint Space) +## + + +@configclass +class GalbotLeftArmJointPositionCubeStackVisuomotorEnvCfg_PLAY(RmpFlowGalbotLeftArmCubeStackVisuomotorEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + self.actions.arm_action = mdp.JointPositionActionCfg( + asset_name="robot", joint_names=["left_arm_joint.*"], scale=1.0, use_default_offset=False + ) + # Enable Parallel Gripper with AbsBinaryJointPosition Control + self.actions.gripper_action = mdp.AbsBinaryJointPositionActionCfg( + asset_name="robot", + threshold=0.030, + joint_names=["left_gripper_.*_joint"], + open_command_expr={"left_gripper_.*_joint": 0.035}, + close_command_expr={"left_gripper_.*_joint": 0.023}, + # real gripper close data is 0.0235, close to it to meet data distribution, but smaller to ensure robust grasping. + # during VLA inference, we set the close command to '0.023' since the VLA has never seen the gripper fully closed. + ) + + +## +# Task Envs for VLA Policy Close-loop Evaluation (in Task Space) +## +@configclass +class GalbotLeftArmRmpFlowCubeStackVisuomotorEnvCfg_PLAY(RmpFlowGalbotLeftArmCubeStackVisuomotorEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Enable Parallel Gripper with AbsBinaryJointPosition Control + self.actions.gripper_action = mdp.AbsBinaryJointPositionActionCfg( + asset_name="robot", + threshold=0.030, + joint_names=["left_gripper_.*_joint"], + open_command_expr={"left_gripper_.*_joint": 0.035}, + close_command_expr={"left_gripper_.*_joint": 0.023}, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/observations.py index 0d9d087a9c22..2f65cd916ee9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/observations.py @@ -6,8 +6,9 @@ from __future__ import annotations import torch -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal +import isaaclab.utils.math as math_utils from isaaclab.assets import Articulation, RigidObject, RigidObjectCollection from isaaclab.managers import SceneEntityCfg from isaaclab.sensors import FrameTransformer @@ -256,12 +257,35 @@ def ee_frame_quat(env: ManagerBasedRLEnv, ee_frame_cfg: SceneEntityCfg = SceneEn return ee_frame_quat -def gripper_pos(env: ManagerBasedRLEnv, robot_cfg: SceneEntityCfg = SceneEntityCfg("robot")) -> torch.Tensor: +def gripper_pos( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), +) -> torch.Tensor: + """ + Obtain the versatile gripper position of both Gripper and Suction Cup. + """ robot: Articulation = env.scene[robot_cfg.name] - finger_joint_1 = robot.data.joint_pos[:, -1].clone().unsqueeze(1) - finger_joint_2 = -1 * robot.data.joint_pos[:, -2].clone().unsqueeze(1) - return torch.cat((finger_joint_1, finger_joint_2), dim=1) + if hasattr(env.scene, "surface_grippers") and len(env.scene.surface_grippers) > 0: + # Handle multiple surface grippers by concatenating their states + gripper_states = [] + for gripper_name, surface_gripper in env.scene.surface_grippers.items(): + gripper_states.append(surface_gripper.state.view(-1, 1)) + + if len(gripper_states) == 1: + return gripper_states[0] + else: + return torch.cat(gripper_states, dim=1) + + else: + if hasattr(env.cfg, "gripper_joint_names"): + gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) + assert len(gripper_joint_ids) == 2, "Observation gripper_pos only support parallel gripper for now" + finger_joint_1 = robot.data.joint_pos[:, gripper_joint_ids[0]].clone().unsqueeze(1) + finger_joint_2 = -1 * robot.data.joint_pos[:, gripper_joint_ids[1]].clone().unsqueeze(1) + return torch.cat((finger_joint_1, finger_joint_2), dim=1) + else: + raise NotImplementedError("[Error] Cannot find gripper_joint_names in the environment config") def object_grasped( @@ -270,8 +294,6 @@ def object_grasped( ee_frame_cfg: SceneEntityCfg, object_cfg: SceneEntityCfg, diff_threshold: float = 0.06, - gripper_open_val: torch.tensor = torch.tensor([0.04]), - gripper_threshold: float = 0.005, ) -> torch.Tensor: """Check if an object is grasped by the specified robot.""" @@ -283,13 +305,33 @@ def object_grasped( end_effector_pos = ee_frame.data.target_pos_w[:, 0, :] pose_diff = torch.linalg.vector_norm(object_pos - end_effector_pos, dim=1) - grasped = torch.logical_and( - pose_diff < diff_threshold, - torch.abs(robot.data.joint_pos[:, -1] - gripper_open_val.to(env.device)) > gripper_threshold, - ) - grasped = torch.logical_and( - grasped, torch.abs(robot.data.joint_pos[:, -2] - gripper_open_val.to(env.device)) > gripper_threshold - ) + if hasattr(env.scene, "surface_grippers") and len(env.scene.surface_grippers) > 0: + surface_gripper = env.scene.surface_grippers["surface_gripper"] + suction_cup_status = surface_gripper.state.view(-1, 1) # 1: closed, 0: closing, -1: open + suction_cup_is_closed = (suction_cup_status == 1).to(torch.float32) + grasped = torch.logical_and(suction_cup_is_closed, pose_diff < diff_threshold) + + else: + if hasattr(env.cfg, "gripper_joint_names"): + gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) + assert len(gripper_joint_ids) == 2, "Observations only support parallel gripper for now" + + grasped = torch.logical_and( + pose_diff < diff_threshold, + torch.abs( + robot.data.joint_pos[:, gripper_joint_ids[0]] + - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device) + ) + > env.cfg.gripper_threshold, + ) + grasped = torch.logical_and( + grasped, + torch.abs( + robot.data.joint_pos[:, gripper_joint_ids[1]] + - torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device) + ) + > env.cfg.gripper_threshold, + ) return grasped @@ -302,7 +344,6 @@ def object_stacked( xy_threshold: float = 0.05, height_threshold: float = 0.005, height_diff: float = 0.0468, - gripper_open_val: torch.tensor = torch.tensor([0.04]), ) -> torch.Tensor: """Check if an object is stacked by the specified robot.""" @@ -316,11 +357,176 @@ def object_stacked( stacked = torch.logical_and(xy_dist < xy_threshold, (height_dist - height_diff) < height_threshold) - stacked = torch.logical_and( - torch.isclose(robot.data.joint_pos[:, -1], gripper_open_val.to(env.device), atol=1e-4, rtol=1e-4), stacked + if hasattr(env.scene, "surface_grippers") and len(env.scene.surface_grippers) > 0: + surface_gripper = env.scene.surface_grippers["surface_gripper"] + suction_cup_status = surface_gripper.state.view(-1, 1) # 1: closed, 0: closing, -1: open + suction_cup_is_open = (suction_cup_status == -1).to(torch.float32) + stacked = torch.logical_and(suction_cup_is_open, stacked) + + else: + if hasattr(env.cfg, "gripper_joint_names"): + gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) + assert len(gripper_joint_ids) == 2, "Observations only support parallel gripper for now" + stacked = torch.logical_and( + torch.isclose( + robot.data.joint_pos[:, gripper_joint_ids[0]], + torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), + atol=1e-4, + rtol=1e-4, + ), + stacked, + ) + stacked = torch.logical_and( + torch.isclose( + robot.data.joint_pos[:, gripper_joint_ids[1]], + torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), + atol=1e-4, + rtol=1e-4, + ), + stacked, + ) + else: + raise ValueError("No gripper_joint_names found in environment config") + + return stacked + + +def cube_poses_in_base_frame( + env: ManagerBasedRLEnv, + cube_1_cfg: SceneEntityCfg = SceneEntityCfg("cube_1"), + cube_2_cfg: SceneEntityCfg = SceneEntityCfg("cube_2"), + cube_3_cfg: SceneEntityCfg = SceneEntityCfg("cube_3"), + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + return_key: Literal["pos", "quat", None] = None, +) -> torch.Tensor: + """The position and orientation of the cubes in the robot base frame.""" + + cube_1: RigidObject = env.scene[cube_1_cfg.name] + cube_2: RigidObject = env.scene[cube_2_cfg.name] + cube_3: RigidObject = env.scene[cube_3_cfg.name] + + pos_cube_1_world = cube_1.data.root_pos_w + pos_cube_2_world = cube_2.data.root_pos_w + pos_cube_3_world = cube_3.data.root_pos_w + + quat_cube_1_world = cube_1.data.root_quat_w + quat_cube_2_world = cube_2.data.root_quat_w + quat_cube_3_world = cube_3.data.root_quat_w + + robot: Articulation = env.scene[robot_cfg.name] + root_pos_w = robot.data.root_pos_w + root_quat_w = robot.data.root_quat_w + + pos_cube_1_base, quat_cube_1_base = math_utils.subtract_frame_transforms( + root_pos_w, root_quat_w, pos_cube_1_world, quat_cube_1_world + ) + pos_cube_2_base, quat_cube_2_base = math_utils.subtract_frame_transforms( + root_pos_w, root_quat_w, pos_cube_2_world, quat_cube_2_world ) - stacked = torch.logical_and( - torch.isclose(robot.data.joint_pos[:, -2], gripper_open_val.to(env.device), atol=1e-4, rtol=1e-4), stacked + pos_cube_3_base, quat_cube_3_base = math_utils.subtract_frame_transforms( + root_pos_w, root_quat_w, pos_cube_3_world, quat_cube_3_world ) - return stacked + pos_cubes_base = torch.cat((pos_cube_1_base, pos_cube_2_base, pos_cube_3_base), dim=1) + quat_cubes_base = torch.cat((quat_cube_1_base, quat_cube_2_base, quat_cube_3_base), dim=1) + + if return_key == "pos": + return pos_cubes_base + elif return_key == "quat": + return quat_cubes_base + elif return_key is None: + return torch.cat((pos_cubes_base, quat_cubes_base), dim=1) + + +def object_abs_obs_in_base_frame( + env: ManagerBasedRLEnv, + cube_1_cfg: SceneEntityCfg = SceneEntityCfg("cube_1"), + cube_2_cfg: SceneEntityCfg = SceneEntityCfg("cube_2"), + cube_3_cfg: SceneEntityCfg = SceneEntityCfg("cube_3"), + ee_frame_cfg: SceneEntityCfg = SceneEntityCfg("ee_frame"), + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), +): + """ + Object Abs observations (in base frame): remove the relative observations, and add abs gripper pos and quat in robot base frame + cube_1 pos, + cube_1 quat, + cube_2 pos, + cube_2 quat, + cube_3 pos, + cube_3 quat, + gripper pos, + gripper quat, + """ + cube_1: RigidObject = env.scene[cube_1_cfg.name] + cube_2: RigidObject = env.scene[cube_2_cfg.name] + cube_3: RigidObject = env.scene[cube_3_cfg.name] + ee_frame: FrameTransformer = env.scene[ee_frame_cfg.name] + robot: Articulation = env.scene[robot_cfg.name] + + root_pos_w = robot.data.root_pos_w + root_quat_w = robot.data.root_quat_w + + cube_1_pos_w = cube_1.data.root_pos_w + cube_1_quat_w = cube_1.data.root_quat_w + + cube_2_pos_w = cube_2.data.root_pos_w + cube_2_quat_w = cube_2.data.root_quat_w + + cube_3_pos_w = cube_3.data.root_pos_w + cube_3_quat_w = cube_3.data.root_quat_w + + pos_cube_1_base, quat_cube_1_base = math_utils.subtract_frame_transforms( + root_pos_w, root_quat_w, cube_1_pos_w, cube_1_quat_w + ) + pos_cube_2_base, quat_cube_2_base = math_utils.subtract_frame_transforms( + root_pos_w, root_quat_w, cube_2_pos_w, cube_2_quat_w + ) + pos_cube_3_base, quat_cube_3_base = math_utils.subtract_frame_transforms( + root_pos_w, root_quat_w, cube_3_pos_w, cube_3_quat_w + ) + + ee_pos_w = ee_frame.data.target_pos_w[:, 0, :] + ee_quat_w = ee_frame.data.target_quat_w[:, 0, :] + ee_pos_base, ee_quat_base = math_utils.subtract_frame_transforms(root_pos_w, root_quat_w, ee_pos_w, ee_quat_w) + + return torch.cat( + ( + pos_cube_1_base, + quat_cube_1_base, + pos_cube_2_base, + quat_cube_2_base, + pos_cube_3_base, + quat_cube_3_base, + ee_pos_base, + ee_quat_base, + ), + dim=1, + ) + + +def ee_frame_pose_in_base_frame( + env: ManagerBasedRLEnv, + ee_frame_cfg: SceneEntityCfg = SceneEntityCfg("ee_frame"), + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + return_key: Literal["pos", "quat", None] = None, +) -> torch.Tensor: + """ + The end effector pose in the robot base frame. + """ + ee_frame: FrameTransformer = env.scene[ee_frame_cfg.name] + ee_frame_pos_w = ee_frame.data.target_pos_w[:, 0, :] + ee_frame_quat_w = ee_frame.data.target_quat_w[:, 0, :] + + robot: Articulation = env.scene[robot_cfg.name] + root_pos_w = robot.data.root_pos_w + root_quat_w = robot.data.root_quat_w + ee_pos_in_base, ee_quat_in_base = math_utils.subtract_frame_transforms( + root_pos_w, root_quat_w, ee_frame_pos_w, ee_frame_quat_w + ) + + if return_key == "pos": + return ee_pos_in_base + elif return_key == "quat": + return ee_quat_in_base + elif return_key is None: + return torch.cat((ee_pos_in_base, ee_quat_in_base), dim=1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/terminations.py index 6b0a2af3c014..e306f9eb4a0a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/mdp/terminations.py @@ -30,7 +30,6 @@ def cubes_stacked( xy_threshold: float = 0.04, height_threshold: float = 0.005, height_diff: float = 0.0468, - gripper_open_val: torch.tensor = torch.tensor([0.04]), atol=0.0001, rtol=0.0001, ): @@ -58,11 +57,36 @@ def cubes_stacked( stacked = torch.logical_and(pos_diff_c23[:, 2] < 0.0, stacked) # Check gripper positions - stacked = torch.logical_and( - torch.isclose(robot.data.joint_pos[:, -1], gripper_open_val.to(env.device), atol=atol, rtol=rtol), stacked - ) - stacked = torch.logical_and( - torch.isclose(robot.data.joint_pos[:, -2], gripper_open_val.to(env.device), atol=atol, rtol=rtol), stacked - ) + if hasattr(env.scene, "surface_grippers") and len(env.scene.surface_grippers) > 0: + surface_gripper = env.scene.surface_grippers["surface_gripper"] + suction_cup_status = surface_gripper.state.view(-1, 1) # 1: closed, 0: closing, -1: open + suction_cup_is_open = (suction_cup_status == -1).to(torch.float32) + stacked = torch.logical_and(suction_cup_is_open, stacked) + + else: + if hasattr(env.cfg, "gripper_joint_names"): + gripper_joint_ids, _ = robot.find_joints(env.cfg.gripper_joint_names) + assert len(gripper_joint_ids) == 2, "Terminations only support parallel gripper for now" + + stacked = torch.logical_and( + torch.isclose( + robot.data.joint_pos[:, gripper_joint_ids[0]], + torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), + atol=atol, + rtol=rtol, + ), + stacked, + ) + stacked = torch.logical_and( + torch.isclose( + robot.data.joint_pos[:, gripper_joint_ids[1]], + torch.tensor(env.cfg.gripper_open_val, dtype=torch.float32).to(env.device), + atol=atol, + rtol=rtol, + ), + stacked, + ) + else: + raise ValueError("No gripper_joint_names found in environment config") return stacked From 994979c2fa7dc7966d91685ffd7dd143624aa594 Mon Sep 17 00:00:00 2001 From: njawale42 Date: Mon, 8 Sep 2025 21:35:18 -0700 Subject: [PATCH 36/47] Adds SkillGen framework to Isaac Lab with cuRobo support (#3303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR introduces the SkillGen framework to Isaac Lab, integrating GPU motion planning with skill-segmented data generation. It enables efficient, high-quality dataset creation with robust collision handling, visualization, and reproducibility. **Note:** - Please look at the cuRobo usage license ![here](docs/licenses/dependencies/cuRobo-license.txt) - Please look at updated isaacsim license ![here](docs/licenses/dependencies/isaacsim-license.txt) ### Technical Implementation: **Annotation Framework:** - Manual subtask start annotations to cleanly separate skill execution from motion-planning segments - Consistent trajectory segmentation for downstream dataset consumers **Motion Planning:** - **Base Motion Planner (Extensible):** - Introduces a reusable planner interface for uniform integration: - `source/isaaclab_mimic/isaaclab_mimic/motion_planners/base_motion_planner.py` - Defines a minimal, consistent API for planners: - `update_world_and_plan_motion(...)`, `get_planned_poses(...)`, etc. - The cuRobo planner inherits from this base class. - New planners can be added by subclassing the base class and implementing the same API, enabling drop-in replacement without changes to the SkillGen pipeline. - **CuRobo Planner** (GPU-accelerated, collision-aware): - Multi-phase planning: approach → contact → retreat - Dynamic object attach/detach and contact-aware sphere management - Real-time world synchronization between Isaac Lab and cuRobo - Configurable collision filtering for contact phases - **Tests**: - `source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/test/test_curobo_planner_cube_stack.py` - `source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/test/test_curobo_planner_franka.py` - `source/isaaclab_mimic/test/test_generate_dataset_skillgen.py` **Data Generation Pipeline:** - Automated dataset generation with precise skill-based segmentation - Integrates with existing observation/action spaces - Supports multi-env parallel collection with cuRobo-backed planning **Visualization and Debugging:** - Rerun-based 3D visualization for trajectory/collision inspection - Real-time sphere visualization for collision boundaries and contact phases ### Dependencies: - **cuRobo**: motion planning and collision checking - **Rerun**: 3D visualization and debugging ### Integration: This extends the existing mimic pipeline and remains backward compatible. It integrates into the manager-based environment structure and existing observation/action interfaces without breaking current users. ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] This change requires a documentation update ## Screenshot ### SkillGen Data Generation
Cube Stacking SkillGen Data Generation Bin Cube Stacking SkillGen Data Generation (Using Vanilla Cube Stacking Source Demos)
Cube Stacking Data Generation Bin Cube Stacking Data Generation
### Bin Cube Stacking Behavior Cloned Policy ![bin_cube_stack_bc_policy](https://github.com/user-attachments/assets/d577d726-d623-4b27-90e5-a047cd67e4f9) ### Rerun Integration ![rerun_skillgen](https://github.com/user-attachments/assets/9c469bc4-d3f6-465a-8ca6-0ddfd85c6ad0) ### Motion Planner Tests
Obstacle Avoidance (cuRobo) Cube Stack End-to-End (cuRobo)
Obstacle Avoidance cuRobo Cube Stack End-to-End cuRobo
## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo Co-authored-by: Pascal Roth <57946385+pascal-roth@users.noreply.github.com> --- .github/actions/docker-build/action.yml | 2 +- CONTRIBUTORS.md | 1 + README.md | 2 + docker/Dockerfile.curobo | 144 ++ docs/licenses/dependencies/cuRobo-license.txt | 93 + .../overview/imitation-learning/index.rst | 1 + .../overview/imitation-learning/skillgen.rst | 503 +++++ pyproject.toml | 2 +- .../isaaclab_mimic/annotate_demos.py | 100 +- .../isaaclab_mimic/generate_dataset.py | 53 +- .../envs/manager_based_rl_mimic_env.py | 16 + .../isaaclab/isaaclab/envs/mimic_env_cfg.py | 9 + source/isaaclab_mimic/config/extension.toml | 2 +- source/isaaclab_mimic/docs/CHANGELOG.rst | 12 + .../isaaclab_mimic/datagen/data_generator.py | 421 +++- .../isaaclab_mimic/datagen/datagen_info.py | 18 + .../datagen/datagen_info_pool.py | 122 +- .../isaaclab_mimic/datagen/generation.py | 21 +- .../isaaclab_mimic/envs/__init__.py | 25 + .../franka_bin_stack_ik_rel_mimic_env_cfg.py | 93 + .../envs/franka_stack_ik_rel_mimic_env.py | 23 + .../franka_stack_ik_rel_skillgen_env_cfg.py | 137 ++ .../motion_planners/curobo/curobo_planner.py | 1950 +++++++++++++++++ .../curobo/curobo_planner_cfg.py | 459 ++++ .../motion_planners/curobo/plan_visualizer.py | 938 ++++++++ .../motion_planners/motion_planner_base.py | 133 ++ .../test/test_curobo_planner_cube_stack.py | 248 +++ .../test/test_curobo_planner_franka.py | 173 ++ .../test/test_generate_dataset_skillgen.py | 91 + source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 14 + .../stack/config/franka/__init__.py | 22 + .../config/franka/bin_stack_ik_rel_env_cfg.py | 35 + .../franka/bin_stack_joint_pos_env_cfg.py | 203 ++ .../franka/stack_ik_rel_env_cfg_skillgen.py | 167 ++ .../config/franka/stack_joint_pos_env_cfg.py | 1 + 36 files changed, 6061 insertions(+), 175 deletions(-) create mode 100644 docker/Dockerfile.curobo create mode 100644 docs/licenses/dependencies/cuRobo-license.txt create mode 100644 docs/source/overview/imitation-learning/skillgen.rst create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner_cfg.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/plan_visualizer.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/motion_planners/motion_planner_base.py create mode 100644 source/isaaclab_mimic/test/test_curobo_planner_cube_stack.py create mode 100644 source/isaaclab_mimic/test/test_curobo_planner_franka.py create mode 100644 source/isaaclab_mimic/test/test_generate_dataset_skillgen.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py diff --git a/.github/actions/docker-build/action.yml b/.github/actions/docker-build/action.yml index baa901265b4f..69a8db5ff0b6 100644 --- a/.github/actions/docker-build/action.yml +++ b/.github/actions/docker-build/action.yml @@ -18,7 +18,7 @@ inputs: required: true dockerfile-path: description: 'Path to Dockerfile' - default: 'docker/Dockerfile.base' + default: 'docker/Dockerfile.curobo' required: false context-path: description: 'Build context path' diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index d93e0ddf2718..ee6200de8694 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -101,6 +101,7 @@ Guidelines for modifications: * Miguel Alonso Jr * Mingyu Lee * Muhong Guo +* Neel Anand Jawale * Nicola Loi * Norbert Cygiert * Nuoyan Chen (Alvin) diff --git a/README.md b/README.md index 521ed3356eaf..bd176eef6b2d 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,8 @@ dependencies and assets are present in the [`docs/licenses`](docs/licenses) dire Note that Isaac Lab requires Isaac Sim, which includes components under proprietary licensing terms. Please see the [Isaac Sim license](docs/licenses/dependencies/isaacsim-license.txt) for information on Isaac Sim licensing. +Note that the `isaaclab_mimic` extension requires cuRobo, which has proprietary licensing terms that can be found in [`docs/licenses/dependencies/cuRobo-license.txt`](docs/licenses/dependencies/cuRobo-license.txt). + ## Acknowledgement Isaac Lab development initiated from the [Orbit](https://isaac-orbit.github.io/) framework. We would appreciate if diff --git a/docker/Dockerfile.curobo b/docker/Dockerfile.curobo new file mode 100644 index 000000000000..8e7ea4baffba --- /dev/null +++ b/docker/Dockerfile.curobo @@ -0,0 +1,144 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# Nvidia Dockerfiles: https://github.com/NVIDIA-Omniverse/IsaacSim-dockerfiles +# Please check above link for license information. + +# Base image +ARG ISAACSIM_BASE_IMAGE_ARG +ARG ISAACSIM_VERSION_ARG +FROM ${ISAACSIM_BASE_IMAGE_ARG}:${ISAACSIM_VERSION_ARG} AS base +ENV ISAACSIM_VERSION=${ISAACSIM_VERSION_ARG} + +# Set default RUN shell to bash +SHELL ["/bin/bash", "-c"] + +# Adds labels to the Dockerfile +LABEL version="2.1.1" +LABEL description="Dockerfile for building and running the Isaac Lab framework inside Isaac Sim container image." + +# Arguments +# Path to Isaac Sim root folder +ARG ISAACSIM_ROOT_PATH_ARG +ENV ISAACSIM_ROOT_PATH=${ISAACSIM_ROOT_PATH_ARG} +# Path to the Isaac Lab directory +ARG ISAACLAB_PATH_ARG +ENV ISAACLAB_PATH=${ISAACLAB_PATH_ARG} +# Home dir of docker user, typically '/root' +ARG DOCKER_USER_HOME_ARG +ENV DOCKER_USER_HOME=${DOCKER_USER_HOME_ARG} + +# Set environment variables +ENV LANG=C.UTF-8 +ENV DEBIAN_FRONTEND=noninteractive + +USER root + +# Install dependencies and remove cache +RUN --mount=type=cache,target=/var/cache/apt \ + apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + git \ + libglib2.0-0 \ + ncurses-term \ + wget && \ + apt -y autoremove && apt clean autoclean && \ + rm -rf /var/lib/apt/lists/* + +# Detect Ubuntu version and install CUDA 12.8 via NVIDIA network repo (cuda-keyring) +RUN set -euo pipefail && \ + . /etc/os-release && \ + case "$ID" in \ + ubuntu) \ + case "$VERSION_ID" in \ + "20.04") cuda_repo="ubuntu2004";; \ + "22.04") cuda_repo="ubuntu2204";; \ + "24.04") cuda_repo="ubuntu2404";; \ + *) echo "Unsupported Ubuntu $VERSION_ID"; exit 1;; \ + esac ;; \ + *) echo "Unsupported base OS: $ID"; exit 1 ;; \ + esac && \ + apt-get update && apt-get install -y --no-install-recommends wget gnupg ca-certificates && \ + wget -q https://developer.download.nvidia.com/compute/cuda/repos/${cuda_repo}/x86_64/cuda-keyring_1.1-1_all.deb && \ + dpkg -i cuda-keyring_1.1-1_all.deb && \ + rm -f cuda-keyring_1.1-1_all.deb && \ + wget -q https://developer.download.nvidia.com/compute/cuda/repos/${cuda_repo}/x86_64/cuda-${cuda_repo}.pin && \ + mv cuda-${cuda_repo}.pin /etc/apt/preferences.d/cuda-repository-pin-600 && \ + apt-get update && \ + apt-get install -y --no-install-recommends cuda-toolkit-12-8 && \ + apt-get -y autoremove && apt-get clean && rm -rf /var/lib/apt/lists/* + + +ENV CUDA_HOME=/usr/local/cuda-12.8 +ENV PATH=${CUDA_HOME}/bin:${PATH} +ENV LD_LIBRARY_PATH=${CUDA_HOME}/lib64:${LD_LIBRARY_PATH} +ENV TORCH_CUDA_ARCH_LIST=8.0+PTX + +# Copy the Isaac Lab directory (files to exclude are defined in .dockerignore) +COPY ../ ${ISAACLAB_PATH} + +# Ensure isaaclab.sh has execute permissions +RUN chmod +x ${ISAACLAB_PATH}/isaaclab.sh + +# Set up a symbolic link between the installed Isaac Sim root folder and _isaac_sim in the Isaac Lab directory +RUN ln -sf ${ISAACSIM_ROOT_PATH} ${ISAACLAB_PATH}/_isaac_sim + +# Install toml dependency +RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install toml + +# Install apt dependencies for extensions that declare them in their extension.toml +RUN --mount=type=cache,target=/var/cache/apt \ + ${ISAACLAB_PATH}/isaaclab.sh -p ${ISAACLAB_PATH}/tools/install_deps.py apt ${ISAACLAB_PATH}/source && \ + apt -y autoremove && apt clean autoclean && \ + rm -rf /var/lib/apt/lists/* + +# for singularity usage, have to create the directories that will binded +RUN mkdir -p ${ISAACSIM_ROOT_PATH}/kit/cache && \ + mkdir -p ${DOCKER_USER_HOME}/.cache/ov && \ + mkdir -p ${DOCKER_USER_HOME}/.cache/pip && \ + mkdir -p ${DOCKER_USER_HOME}/.cache/nvidia/GLCache && \ + mkdir -p ${DOCKER_USER_HOME}/.nv/ComputeCache && \ + mkdir -p ${DOCKER_USER_HOME}/.nvidia-omniverse/logs && \ + mkdir -p ${DOCKER_USER_HOME}/.local/share/ov/data && \ + mkdir -p ${DOCKER_USER_HOME}/Documents + +# for singularity usage, create NVIDIA binary placeholders +RUN touch /bin/nvidia-smi && \ + touch /bin/nvidia-debugdump && \ + touch /bin/nvidia-persistenced && \ + touch /bin/nvidia-cuda-mps-control && \ + touch /bin/nvidia-cuda-mps-server && \ + touch /etc/localtime && \ + mkdir -p /var/run/nvidia-persistenced && \ + touch /var/run/nvidia-persistenced/socket + +# installing Isaac Lab dependencies +# use pip caching to avoid reinstalling large packages +RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ + ${ISAACLAB_PATH}/isaaclab.sh --install + +# Install cuRobo from source (pinned commit); needs CUDA env and Torch +RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --no-build-isolation \ + "nvidia-curobo @ git+https://github.com/NVlabs/curobo.git@ebb71702f3f70e767f40fd8e050674af0288abe8" + +# HACK: Remove install of quadprog dependency +RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog + +# aliasing isaaclab.sh and python for convenience +RUN echo "export ISAACLAB_PATH=${ISAACLAB_PATH}" >> ${HOME}/.bashrc && \ + echo "alias isaaclab=${ISAACLAB_PATH}/isaaclab.sh" >> ${HOME}/.bashrc && \ + echo "alias python=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${HOME}/.bashrc && \ + echo "alias python3=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${HOME}/.bashrc && \ + echo "alias pip='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${HOME}/.bashrc && \ + echo "alias pip3='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${HOME}/.bashrc && \ + echo "alias tensorboard='${ISAACLAB_PATH}/_isaac_sim/python.sh ${ISAACLAB_PATH}/_isaac_sim/tensorboard'" >> ${HOME}/.bashrc && \ + echo "export TZ=$(date +%Z)" >> ${HOME}/.bashrc && \ + echo "shopt -s histappend" >> /root/.bashrc && \ + echo "PROMPT_COMMAND='history -a'" >> /root/.bashrc + +# make working directory as the Isaac Lab directory +# this is the default directory when the container is run +WORKDIR ${ISAACLAB_PATH} diff --git a/docs/licenses/dependencies/cuRobo-license.txt b/docs/licenses/dependencies/cuRobo-license.txt new file mode 100644 index 000000000000..2b76a56cbf86 --- /dev/null +++ b/docs/licenses/dependencies/cuRobo-license.txt @@ -0,0 +1,93 @@ +NVIDIA ISAAC LAB ADDITIONAL SOFTWARE AND MATERIALS LICENSE + +IMPORTANT NOTICE – PLEASE READ AND AGREE BEFORE USING THE SOFTWARE + +This software license agreement ("Agreement") is a legal agreement between you, whether an individual or entity, ("you") and NVIDIA Corporation ("NVIDIA") and governs the use of the NVIDIA cuRobo and related software and materials that NVIDIA delivers to you under this Agreement ("Software"). NVIDIA and you are each a "party" and collectively the "parties." + +By using the Software, you are affirming that you have read and agree to this Agreement. + +If you don't accept all the terms and conditions below, do not use the Software. + +1. License Grant. The Software made available by NVIDIA to you is licensed, not sold. Subject to the terms of this Agreement, NVIDIA grants you a limited, non-exclusive, revocable, non-transferable, and non-sublicensable (except as expressly granted in this Agreement), license to install and use copies of the Software together with NVIDIA Isaac Lab in systems with NVIDIA GPUs ("Purpose"). + +2. License Restrictions. Your license to use the Software is restricted as stated in this Section 2 ("License Restrictions"). You will cooperate with NVIDIA and, upon NVIDIA's written request, you will confirm in writing and provide reasonably requested information to verify your compliance with the terms of this Agreement. You may not: + +2.1 Use the Software for any purpose other than the Purpose, and for clarity use of NVIDIA cuRobo apart from use with Isaac Lab is outside of the Purpose; + +2.2 Sell, rent, sublicense, transfer, distribute or otherwise make available to others (except authorized users as stated in Section 3 ("Authorized Users")) any portion of the Software, except as expressly granted in Section 1 ("License Grant"); + +2.3 Reverse engineer, decompile, or disassemble the Software components provided in binary form, nor attempt in any other manner to obtain source code of such Software; + +2.4 Modify or create derivative works of the Software; + +2.5 Change or remove copyright or other proprietary notices in the Software; + +2.6 Bypass, disable, or circumvent any technical limitation, encryption, security, digital rights management or authentication mechanism in the Software; + +2.7 Use the Software in any manner that would cause them to become subject to an open source software license, subject to the terms in Section 7 ("Components Under Other Licenses"); or + +2.8 Use the Software in violation of any applicable law or regulation in relevant jurisdictions. + +3. Authorized Users. You may allow employees and contractors of your entity or of your subsidiary(ies), and for educational institutions also enrolled students, to internally access and use the Software as authorized by this Agreement from your secure network to perform the work authorized by this Agreement on your behalf. You are responsible for the compliance with the terms of this Agreement by your authorized users. Any act or omission that if committed by you would constitute a breach of this Agreement will be deemed to constitute a breach of this Agreement if committed by your authorized users. + +4. Pre-Release. Software versions identified as alpha, beta, preview, early access or otherwise as pre-release ("Pre-Release") may not be fully functional, may contain errors or design flaws, and may have reduced or different security, privacy, availability and reliability standards relative to NVIDIA commercial offerings. You use Pre-Release Software at your own risk. NVIDIA did not design or test the Software for use in production or business-critical systems. NVIDIA may choose not to make available a commercial version of Pre-Release Software. NVIDIA may also choose to abandon development and terminate the availability of Pre-Release Software at any time without liability. + +5. Updates. NVIDIA may at any time and at its option, change, discontinue, or deprecate any part, or all, of the Software, or change or remove features or functionality, or make available patches, workarounds or other updates to the Software. Unless the updates are provided with their separate governing terms, they are deemed part of the Software licensed to you under this Agreement, and your continued use of the Software is deemed acceptance of such changes. + +6. Components Under Other Licenses. The Software may include or be distributed with components provided with separate legal notices or terms that accompany the components, such as open source software licenses and other license terms ("Other Licenses"). The components are subject to the applicable Other Licenses, including any proprietary notices, disclaimers, requirements and extended use rights; except that this Agreement will prevail regarding the use of third-party open source software, unless a third-party open source software license requires its license terms to prevail. Open source software license means any software, data or documentation subject to any license identified as an open source license by the Open Source Initiative (http://opensource.org), Free Software Foundation (http://www.fsf.org) or other similar open source organization or listed by the Software Package Data Exchange (SPDX) Workgroup under the Linux Foundation (http://www.spdx.org). + +7. Ownership. The Software, including all intellectual property rights, is and will remain the sole and exclusive property of NVIDIA or its licensors. Except as expressly granted in this Agreement, (a) NVIDIA reserves all rights, interests and remedies in connection with the Software, and (b) no other license or right is granted to you by implication, estoppel or otherwise. + +8. Feedback. You may, but you are not obligated to, provide suggestions, requests, fixes, modifications, enhancements, or other feedback regarding the Software (collectively, "Feedback"). Feedback, even if designated as confidential by you, will not create any confidentiality obligation for NVIDIA or its affiliates. If you provide Feedback, you grant NVIDIA, its affiliates and its designees a non-exclusive, perpetual, irrevocable, sublicensable, worldwide, royalty-free, fully paid-up and transferable license, under your intellectual property rights, to publicly perform, publicly display, reproduce, use, make, have made, sell, offer for sale, distribute (through multiple tiers of distribution), import, create derivative works of and otherwise commercialize and exploit the Feedback at NVIDIA's discretion. + +9. Term and Termination. + +9.1 Term and Termination for Convenience. This license ends by July 31, 2026 or earlier at your choice if you finished using the Software for the Purpose. Either party may terminate this Agreement at any time with thirty (30) days' advance written notice to the other party. + +9.2 Termination for Cause. If you commence or participate in any legal proceeding against NVIDIA with respect to the Software, this Agreement will terminate immediately without notice. Either party may terminate this Agreement for cause if: + +(a) The other party fails to cure a material breach of this Agreement within ten (10) days of the non-breaching party's written notice of the breach; or + +(b) the other party breaches its confidentiality obligations or license rights under this Agreement, which termination will be effective immediately upon written notice. + +9.3 Effect of Termination. Upon any expiration or termination of this Agreement, you will promptly stop using and return, delete or destroy NVIDIA confidential information and all Software received under this Agreement. Upon written request, you will certify in writing that you have complied with your obligations under this Section 9.3 ("Effect of Termination"). + +9.4 Survival. Section 5 ("Updates"), Section 6 ("Components Under Other Licenses"), Section 7 ("Ownership"), Section 8 ("Feedback"), Section 9.3 ("Effect of Termination"), Section 9.4 ("Survival"), Section 10 ("Disclaimer of Warranties"), Section 11 ("Limitation of Liability"), Section 12 ("Use in Mission Critical Applications"), Section 13 ("Governing Law and Jurisdiction") and Section 14 ("General") will survive any expiration or termination of this Agreement. + +10. Disclaimer of Warranties. THE SOFTWARE IS PROVIDED BY NVIDIA AS-IS AND WITH ALL FAULTS. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, NVIDIA DISCLAIMS ALL WARRANTIES AND REPRESENTATIONS OF ANY KIND, WHETHER EXPRESS, IMPLIED OR STATUTORY, RELATING TO OR ARISING UNDER THIS AGREEMENT, INCLUDING, WITHOUT LIMITATION, THE WARRANTIES OF TITLE, NONINFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, USAGE OF TRADE AND COURSE OF DEALING. NVIDIA DOES NOT WARRANT OR ASSUME RESPONSIBILITY FOR THE ACCURACY OR COMPLETENESS OF ANY THIRD-PARTY INFORMATION, TEXT, GRAPHICS, LINKS CONTAINED IN THE SOFTWARE. WITHOUT LIMITING THE FOREGOING, NVIDIA DOES NOT WARRANT THAT THE SOFTWARE WILL MEET YOUR REQUIREMENTS, ANY DEFECTS OR ERRORS WILL BE CORRECTED, ANY CERTAIN CONTENT WILL BE AVAILABLE; OR THAT THE SOFTWARE IS FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS. NO INFORMATION OR ADVICE GIVEN BY NVIDIA WILL IN ANY WAY INCREASE THE SCOPE OF ANY WARRANTY EXPRESSLY PROVIDED IN THIS AGREEMENT. + +11. Limitations of Liability. + +11.1 EXCLUSIONS. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL NVIDIA BE LIABLE FOR ANY (I) INDIRECT, PUNITIVE, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, OR (II) DAMAGES FOR (A) THE COST OF PROCURING SUBSTITUTE GOODS, OR (B) LOSS OF PROFITS, REVENUES, USE, DATA OR GOODWILL ARISING OUT OF OR RELATED TO THIS AGREEMENT, WHETHER BASED ON BREACH OF CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR OTHERWISE, AND EVEN IF NVIDIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES AND EVEN IF A PARTY'S REMEDIES FAIL THEIR ESSENTIAL PURPOSE. + +11.2 DAMAGES CAP. ADDITIONALLY, TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, NVIDIA'S TOTAL CUMULATIVE AGGREGATE LIABILITY FOR ANY AND ALL LIABILITIES, OBLIGATIONS OR CLAIMS ARISING OUT OF OR RELATED TO THIS AGREEMENT WILL NOT EXCEED FIVE U.S. DOLLARS (US$5). + +12. Use in Mission Critical Applications. You acknowledge that the Software provided under this Agreement is not designed or tested by NVIDIA for use in any system or application where the use or failure of such system or application developed with NVIDIA's Software could result in injury, death or catastrophic damage (each, a "Mission Critical Application"). Examples of Mission Critical Applications include use in avionics, navigation, autonomous vehicle applications, AI solutions for automotive products, military, medical, life support or other mission-critical or life-critical applications. NVIDIA will not be liable to you or any third party, in whole or in part, for any claims or damages arising from these uses. You are solely responsible for ensuring that systems and applications developed with the Software include sufficient safety and redundancy features and comply with all applicable legal and regulatory standards and requirements. + +13. Governing Law and Jurisdiction. This Agreement will be governed in all respects by the laws of the United States and the laws of the State of Delaware, without regard to conflict of laws principles or the United Nations Convention on Contracts for the International Sale of Goods. The state and federal courts residing in Santa Clara County, California will have exclusive jurisdiction over any dispute or claim arising out of or related to this Agreement, and the parties irrevocably consent to personal jurisdiction and venue in those courts; except that either party may apply for injunctive remedies or an equivalent type of urgent legal relief in any jurisdiction. + +14. General. + +14.1 Indemnity. By using the Software you agree to defend, indemnify and hold harmless NVIDIA and its affiliates and their respective officers, directors, employees and agents from and against any claims, disputes, demands, liabilities, damages, losses, costs and expenses arising out of or in any way connected with (i) products or services that have been developed or deployed with or use the Software, or claims that they violate laws, or infringe, violate, or misappropriate any third party right; or (ii) your use of the Software in breach of the terms of this Agreement. + +14.2 Independent Contractors. The parties are independent contractors, and this Agreement does not create a joint venture, partnership, agency, or other form of business association between the parties. Neither party will have the power to bind the other party or incur any obligation on its behalf without the other party's prior written consent. Nothing in this Agreement prevents either party from participating in similar arrangements with third parties. + +14.3 No Assignment. NVIDIA may assign, delegate or transfer its rights or obligations under this Agreement by any means or operation of law. You may not, without NVIDIA's prior written consent, assign, delegate or transfer any of your rights or obligations under this Agreement by any means or operation of law, and any attempt to do so is null and void. + +14.4 No Waiver. No failure or delay by a party to enforce any term or obligation of this Agreement will operate as a waiver by that party, or prevent the enforcement of such term or obligation later. + +14.5 Trade Compliance. You agree to comply with all applicable export, import, trade and economic sanctions laws and regulations, as amended, including without limitation U.S. Export Administration Regulations and Office of Foreign Assets Control regulations. You confirm (a) your understanding that export or reexport of certain NVIDIA products or technologies may require a license or other approval from appropriate authorities and (b) that you will not export or reexport any products or technology, directly or indirectly, without first obtaining any required license or other approval from appropriate authorities, (i) to any countries that are subject to any U.S. or local export restrictions (currently including, but not necessarily limited to, Belarus, Cuba, Iran, North Korea, Russia, Syria, the Region of Crimea, Donetsk People's Republic Region and Luhansk People's Republic Region); (ii) to any end-user who you know or have reason to know will utilize them in the design, development or production of nuclear, chemical or biological weapons, missiles, rocket systems, unmanned air vehicles capable of a maximum range of at least 300 kilometers, regardless of payload, or intended for military end-use, or any weapons of mass destruction; (iii) to any end-user who has been prohibited from participating in the U.S. or local export transactions by any governing authority; or (iv) to any known military or military-intelligence end-user or for any known military or military-intelligence end-use in accordance with U.S. trade compliance laws and regulations. + +14.6 Government Rights. The Software, documentation and technology ("Protected Items") are "Commercial products" as this term is defined at 48 C.F.R. 2.101, consisting of "commercial computer software" and "commercial computer software documentation" as such terms are used in, respectively, 48 C.F.R. 12.212 and 48 C.F.R. 227.7202 & 252.227-7014(a)(1). Before any Protected Items are supplied to the U.S. Government, you will (i) inform the U.S. Government in writing that the Protected Items are and must be treated as commercial computer software and commercial computer software documentation developed at private expense; (ii) inform the U.S. Government that the Protected Items are provided subject to the terms of the Agreement; and (iii) mark the Protected Items as commercial computer software and commercial computer software documentation developed at private expense. In no event will you permit the U.S. Government to acquire rights in Protected Items beyond those specified in 48 C.F.R. 52.227-19(b)(1)-(2) or 252.227-7013(c) except as expressly approved by NVIDIA in writing. + +14.7 Notices. Please direct your legal notices or other correspondence to legalnotices@nvidia.com with a copy mailed to NVIDIA Corporation, 2788 San Tomas Expressway, Santa Clara, California 95051, United States of America, Attention: Legal Department. If NVIDIA needs to contact you, you consent to receive the notices by email and agree that such notices will satisfy any legal communication requirements. + +14.8 Severability. If a court of competent jurisdiction rules that a provision of this Agreement is unenforceable, that provision will be deemed modified to the extent necessary to make it enforceable and the remainder of this Agreement will continue in full force and effect. + +14.9 Construction. The headings in the Agreement are included solely for convenience and are not intended to affect the meaning or interpretation of the Agreement. As required by the context of the Agreement, the singular of a term includes the plural and vice versa. + +14.10 Amendment. Any amendment to this Agreement must be in writing and signed by authorized representatives of both parties. + +14.11 Entire Agreement. Regarding the subject matter of this Agreement, the parties agree that (a) this Agreement constitutes the entire and exclusive agreement between the parties and supersedes all prior and contemporaneous communications and (b) any additional or different terms or conditions, whether contained in purchase orders, order acknowledgments, invoices or otherwise, will not be binding and are null and void. + +(v. August 15, 2025) diff --git a/docs/source/overview/imitation-learning/index.rst b/docs/source/overview/imitation-learning/index.rst index 5c21b1f34066..1daf0968facc 100644 --- a/docs/source/overview/imitation-learning/index.rst +++ b/docs/source/overview/imitation-learning/index.rst @@ -9,3 +9,4 @@ with Isaac Lab. augmented_imitation teleop_imitation + skillgen diff --git a/docs/source/overview/imitation-learning/skillgen.rst b/docs/source/overview/imitation-learning/skillgen.rst new file mode 100644 index 000000000000..28d2dbe58052 --- /dev/null +++ b/docs/source/overview/imitation-learning/skillgen.rst @@ -0,0 +1,503 @@ +.. _skillgen: + +SkillGen for Automated Demonstration Generation +=============================================== + +SkillGen is an advanced demonstration generation system that enhances Isaac Lab Mimic by integrating motion planning. It generates high-quality, adaptive, collision-free robot demonstrations by combining human-provided subtask segments with automated motion planning. + +What is SkillGen? +~~~~~~~~~~~~~~~~~ + +SkillGen addresses key limitations in traditional demonstration generation: + +* **Motion Quality**: Uses cuRobo's GPU-accelerated motion planner to generate smooth, collision-free trajectories +* **Validity**: Generates kinematically feasible plans between skill segments +* **Diversity**: Generates varied demonstrations through configurable sampling and planning parameters +* **Adaptability**: Generates demonstrations that can be adapted to new object placements and scene configurations during data generation + +The system works by taking manually annotated human demonstrations, extracting localized subtask skills (see `Subtasks in SkillGen`_), and using cuRobo to plan feasible motions between these skill segments while respecting robot kinematics and collision constraints. + +Prerequisites +~~~~~~~~~~~~~ + +Before using SkillGen, you must understand: + +1. **Teleoperation**: How to control robots and record demonstrations using keyboard, SpaceMouse, or hand tracking +2. **Isaac Lab Mimic**: The complete workflow including data collection, annotation, generation, and policy training + +.. important:: + + Review the :ref:`teleoperation-imitation-learning` documentation thoroughly before proceeding with SkillGen. + +.. _skillgen-installation: + +Installation +~~~~~~~~~~~~ + +SkillGen requires Isaac Lab, Isaac Sim, and cuRobo. Follow these steps in your Isaac Lab conda environment. + +Step 1: Install and verify Isaac Sim and Isaac Lab +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Follow the official Isaac Sim and Isaac Lab installation guide `here `__. + +Step 2: Install cuRobo +^^^^^^^^^^^^^^^^^^^^^^ + +cuRobo provides the motion planning capabilities for SkillGen. This installation is tested to work with Isaac Lab's PyTorch and CUDA requirements: + +.. code:: bash + + # One line installation of cuRobo (formatted for readability) + conda install -c nvidia cuda-toolkit=12.8 -y && \ + export CUDA_HOME="$CONDA_PREFIX" && \ + export PATH="$CUDA_HOME/bin:$PATH" && \ + export LD_LIBRARY_PATH="$CUDA_HOME/lib:$LD_LIBRARY_PATH" && \ + export TORCH_CUDA_ARCH_LIST="8.0+PTX" && \ + pip install -e "git+https://github.com/NVlabs/curobo.git@ebb71702f3f70e767f40fd8e050674af0288abe8#egg=nvidia-curobo" --no-build-isolation + +.. note:: + * The commit hash ``ebb71702f3f70e767f40fd8e050674af0288abe8`` is tested with Isaac Lab - using other versions may cause compatibility issues. This commit has the support for quad face mesh triangulation, required for cuRobo to parse usds as collision objects. + + * cuRobo is installed from source and is editable installed. This means that the cuRobo source code will be cloned in the current directory under ``src/nvidia-curobo``. Users can choose their working directory to install cuRobo. + +Step 3: Install Rerun +^^^^^^^^^^^^^^^^^^^^^ + +For trajectory visualization during development: + +.. code:: bash + + pip install rerun-sdk==0.23 + +.. note:: + + **Rerun Visualization Setup:** + + * Rerun is optional but highly recommended for debugging and validating planned trajectories during development + * Enable trajectory visualization by setting ``visualize_plan = True`` in the cuRobo planner configuration + * When enabled, cuRobo planner interface will stream planned end-effector trajectories, waypoints, and collision data to Rerun for interactive inspection + * Visualization helps identify planning issues, collision problems, and trajectory smoothness before full dataset generation + * Can also be ran with ``--headless`` to disable isaacsim visualization but still visualize and debug end effector trajectories + +Step 4: Verify Installation +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Test that cuRobo works with Isaac Lab: + +.. code:: bash + + # This should run without import errors + python -c "import curobo; print('cuRobo installed successfully')" + +.. tip:: + + If you run into ``libstdc++.so.6: version 'GLIBCXX_3.4.30' not found`` error, you can try these commands to fix it: + + .. code:: bash + + conda config --env --set channel_priority strict + conda config --env --add channels conda-forge + conda install -y -c conda-forge "libstdcxx-ng>=12" "libgcc-ng>=12" + +Download the SkillGen Dataset +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We provide a pre-annotated dataset to help you get started quickly with SkillGen. + +Dataset Contents +^^^^^^^^^^^^^^^^ + +The dataset contains: + +* Human demonstrations of Franka arm cube stacking +* Manually annotated subtask boundaries for each demonstration +* Compatible with both basic cube stacking and adaptive bin stacking tasks + +Download and Setup +^^^^^^^^^^^^^^^^^^ + +1. Download the pre-annotated dataset by clicking `here `__. + +2. Prepare the datasets directory and move the downloaded file: + +.. code:: bash + + # Make sure you are in the root directory of your Isaac Lab workspace + cd /path/to/your/isaaclab/root + + # Create the datasets directory if it does not exist + mkdir -p datasets + + # Move the downloaded dataset into the datasets directory + mv /path/to/annotated_dataset_skillgen.hdf5 datasets/annotated_dataset_skillgen.hdf5 + +.. tip:: + + A major advantage of SkillGen is that the same annotated dataset can be reused across multiple related tasks (e.g., basic stacking and adaptive bin stacking). This avoids collecting and annotating new data per variant. + +.. admonition:: {Optional for the tasks in this tutorial} Collect a fresh dataset (source + annotated) + + If you want to collect a fresh source dataset and then create an annotated dataset for SkillGen, follow these commands. The user is expected to have knowledge of the Isaac Lab Mimic workflow. + + **Important pointers before you begin** + + * Using the provided annotated dataset is the fastest path to get started with SkillGen tasks in this tutorial. + * If you create your own dataset, SkillGen requires manual annotation of both subtask start and termination boundaries (no auto-annotation). + * Start boundary signals are mandatory for SkillGen; use ``--annotate_subtask_start_signals`` during annotation or data generation will fail. + * Keep your subtask definitions (``object_ref``, ``subtask_term_signal``) consistent with the SkillGen environment config. + + **Record demonstrations** (any teleop device is supported; replace ``spacemouse`` if needed): + + .. code:: bash + + ./isaaclab.sh -p scripts/tools/record_demos.py \ + --task Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0 \ + --teleop_device spacemouse \ + --dataset_file ./datasets/dataset_skillgen.hdf5 \ + --num_demos 10 + + **Annotate demonstrations for SkillGen** (writes both term and start boundaries): + + .. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/annotate_demos.py \ + --device cpu \ + --task Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0 \ + --input_file ./datasets/dataset_skillgen.hdf5 \ + --output_file ./datasets/annotated_dataset_skillgen.hdf5 \ + --annotate_subtask_start_signals + +Understanding Dataset Annotation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +SkillGen requires datasets with annotated subtask start and termination boundaries. Auto-annotation is not supported. + +Subtasks in SkillGen +^^^^^^^^^^^^^^^^^^^^ + +**Technical definition:** A subtask is a contiguous demo segment that achieves a manipulation objective, defined via ``SubTaskConfig``: + +* ``object_ref``: the object (or ``None``) used as the spatial reference for this subtask +* ``subtask_term_signal``: the binary termination signal name (transitions 0 to 1 when the subtask completes) +* ``subtask_start_signal``: the binary start signal name (transitions 0 to 1 when the subtask begins; required for SkillGen) + +The subtask localization process performs: + +* detection of signal transition points (0 to 1) to identify subtask boundaries ``[t_start, t_end]``; +* extraction of the subtask segment between boundaries; +* computation of end-effector trajectories and key poses in an object- or task-relative frame (using ``object_ref`` if provided); + +This converts absolute, scene-specific motions into object-relative skill segments that can be adapted to new object placements and scene configurations during data generation. + +Manual Annotation Workflow +^^^^^^^^^^^^^^^^^^^^^^^^^^ +Contrary to the Isaac Lab Mimic workflow, SkillGen requires manual annotation of subtask start and termination boundaries. For example, for grasping a cube, the start signal is right before the gripper closes and the termination signal is right after the object is grasped. You can adjust the start and termination signals to fit your subtask definition. + +.. tip:: + + **Manual Annotation Controls:** + + * Press ``N`` to start/continue playback + * Press ``B`` to pause + * Press ``S`` to mark subtask boundary + * Press ``Q`` to skip current demonstration + + When annotating the start and end signals for a skill segment (e.g., grasp, stack, etc.), pause the playback using ``B`` a few steps before the skill, annotate the start signal using ``S``, and then resume playback using ``N``. After the skill is completed, pause again a few steps later to annotate the end signal using ``S``. + +Data Generation with SkillGen +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +SkillGen transforms annotated demonstrations into diverse, high-quality datasets using motion planning. + +How SkillGen Works +^^^^^^^^^^^^^^^^^^ + +The SkillGen pipeline uses your annotated dataset and the environment's Mimic API to synthesize new demonstrations: + +1. **Subtask boundary use**: Reads per-subtask start and termination indices from the annotated dataset +2. **Goal sampling**: Samples target poses per subtask according to task constraints and datagen config +3. **Trajectory planning**: Plans collision-free motions between subtask segments using cuRobo (when ``--use_skillgen``) +4. **Trajectory stitching**: Stitches skill segments and planned trajectories into complete demonstrations. +5. **Success evaluation**: Validates task success terms; only successful trials are written to the output dataset + +Usage Parameters +^^^^^^^^^^^^^^^^ + +Key parameters for SkillGen data generation: + +* ``--use_skillgen``: Enables SkillGen planner (required) +* ``--generation_num_trials``: Number of demonstrations to generate +* ``--num_envs``: Parallel environments (tune based on GPU memory) +* ``--device``: Computation device (cpu/cuda). Use cpu for stable physics +* ``--headless``: Disable visualization for faster generation + +Task 1: Basic Cube Stacking +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Generate demonstrations for the standard Isaac Lab Mimic cube stacking task. In this task, the Franka robot must: + +1. Pick up the red cube and place it on the blue cube +2. Pick up the green cube and place it on the red cube +3. Final stack order: blue (bottom), red (middle), green (top). + +.. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/cube_stack_data_gen_skillgen.gif + :width: 75% + :align: center + :alt: Cube stacking task generated with SkillGen + :figclass: align-center + + Cube stacking dataset example. + +Small-Scale Generation +^^^^^^^^^^^^^^^^^^^^^^ + +Start with a small dataset to verify everything works: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py \ + --device cpu \ + --num_envs 1 \ + --generation_num_trials 10 \ + --input_file ./datasets/annotated_dataset_skillgen.hdf5 \ + --output_file ./datasets/generated_dataset_small_skillgen_cube_stack.hdf5 \ + --task Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0 \ + --use_skillgen + +Full-Scale Generation +^^^^^^^^^^^^^^^^^^^^^ + +Once satisfied with small-scale results, generate a full training dataset: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py \ + --device cpu \ + --headless \ + --num_envs 1 \ + --generation_num_trials 1000 \ + --input_file ./datasets/annotated_dataset_skillgen.hdf5 \ + --output_file ./datasets/generated_dataset_skillgen_cube_stack.hdf5 \ + --task Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0 \ + --use_skillgen \ + --headless + +.. note:: + + * Use ``--headless`` to disable visualization for faster generation. Rerun visualization can be enabled by setting ``visualize_plan = True`` in the cuRobo planner configuration with ``--headless`` enabled as well for debugging. + * Adjust ``--num_envs`` based on your GPU memory (start with 1, increase gradually). The performance gain is not very significant when num_envs is greater than 1. A value of 5 seems to be a sweet spot for most GPUs to balance performance and memory usage between cuRobo instances and simulation environments. + * Generation time: ~90 to 120 minutes for one environment for 1000 demonstrations on modern GPUs. Time depends on the GPU, the number of environments, and the success rate of the demonstrations (which depends on quality of the annotated dataset). + * cuRobo planner interface and configurations are described in :ref:`cuRobo-interface-features`. + +Task 2: Adaptive Cube Stacking in a Bin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +SkillGen can also be used to generate datasets for adaptive tasks. In this example, we generate a dataset for adaptive cube stacking in a narrow bin. The bin is placed at a fixed position and orientation in the workspace and a blue cube is placed at the center of the bin. The robot must generate successful demonstrations for stacking the red and green cubes on the blue cube without colliding with the bin. + +.. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/bin_cube_stack_data_gen_skillgen.gif + :width: 75% + :align: center + :alt: Adaptive bin cube stacking task generated with SkillGen + :figclass: align-center + + Adaptive bin stacking data generation example. + +Small-Scale Generation +^^^^^^^^^^^^^^^^^^^^^^ + +Test the adaptive stacking setup: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py \ + --device cpu \ + --num_envs 1 \ + --generation_num_trials 10 \ + --input_file ./datasets/annotated_dataset_skillgen.hdf5 \ + --output_file ./datasets/generated_dataset_small_skillgen_bin_cube_stack.hdf5 \ + --task Isaac-Stack-Cube-Bin-Franka-IK-Rel-Mimic-v0 \ + --use_skillgen + +Full-Scale Generation +^^^^^^^^^^^^^^^^^^^^^ + +Generate the complete adaptive stacking dataset: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py \ + --device cpu \ + --headless \ + --num_envs 1 \ + --generation_num_trials 1000 \ + --input_file ./datasets/annotated_dataset_skillgen.hdf5 \ + --output_file ./datasets/generated_dataset_skillgen_bin_cube_stack.hdf5 \ + --task Isaac-Stack-Cube-Bin-Franka-IK-Rel-Mimic-v0 \ + --use_skillgen + +.. warning:: + + Adaptive tasks typically have lower success rates and higher data generation time due to increased complexity. The time taken to generate the dataset is also longer due to lower success rates than vanilla cube stacking and difficult planning problems. + + +Learning Policies from SkillGen Data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Similar to the Isaac Lab Mimic workflow, you can train imitation learning policies using the generated SkillGen datasets with Robomimic. + +Basic Cube Stacking Policy +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Train a state-based policy for the basic cube stacking task: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/robomimic/train.py \ + --task Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0 \ + --algo bc \ + --dataset ./datasets/generated_dataset_skillgen_cube_stack.hdf5 + +Adaptive Bin Stacking Policy +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Train a policy for the more complex adaptive bin stacking: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/robomimic/train.py \ + --task Isaac-Stack-Cube-Bin-Franka-IK-Rel-Mimic-v0 \ + --algo bc \ + --dataset ./datasets/generated_dataset_skillgen_bin_cube_stack.hdf5 + +.. note:: + + The training script will save the model checkpoints in the model directory under ``IssacLab/logs/robomimic``. + +Evaluating Trained Policies +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Test your trained policies: + +.. code:: bash + + # Basic cube stacking evaluation + ./isaaclab.sh -p scripts/imitation_learning/robomimic/play.py \ + --device cpu \ + --task Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0 \ + --num_rollouts 50 \ + --checkpoint /path/to/model_checkpoint.pth + +.. code:: bash + + # Adaptive bin stacking evaluation + ./isaaclab.sh -p scripts/imitation_learning/robomimic/play.py \ + --device cpu \ + --task Isaac-Stack-Cube-Bin-Franka-IK-Rel-Mimic-v0 \ + --num_rollouts 50 \ + --checkpoint /path/to/model_checkpoint.pth + +.. _cuRobo-interface-features: + +cuRobo Interface Features +~~~~~~~~~~~~~~~~~~~~~~~~~ + +This section summarizes the cuRobo planner interface and features. The SkillGen pipeline uses the cuRobo planner to generate collision-free motions between subtask segments. However, the user can use cuRobo as a standalone motion planner for your own tasks. The user can also implement their own motion planner by subclassing the base motion planner and implementing the same API. + +Base Motion Planner (Extensible) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* Location: ``isaaclab_mimic/motion_planners/base_motion_planner.py`` +* Purpose: Uniform interface for all motion planners used by SkillGen +* Extensibility: New planners can be added by subclassing and implementing the same API; SkillGen consumes the API without code changes + +cuRobo Planner (GPU, collision-aware) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* Location: ``isaaclab_mimic/motion_planners/curobo`` +* Multi-phase planning: + + * Approach → Contact → Retreat phases per subtask + * Configurable collision filtering in contact phases + * For SkillGen, approach and retreat phases are collision-free. The transit phase is collision-checked. + +* World synchronization: + + * Updates robot state, attached objects, and collision spheres from the Isaac Lab scene each trial + * Dynamic attach/detach of objects during grasp/place + +* Collision representation: + + * Contact-aware sphere sets with per-phase enables/filters + +* Outputs: + + * Time-parameterized, collision-checked trajectories for stitching + +* Tests: + + * ``source/isaaclab_mimic/test/test_curobo_planner_cube_stack.py`` + * ``source/isaaclab_mimic/test/test_curobo_planner_franka.py`` + * ``source/isaaclab_mimic/test/test_generate_dataset_skillgen.py`` + +.. list-table:: + :widths: 50 50 + :header-rows: 0 + + * - .. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/cube_stack_end_to_end_curobo.gif + :height: 260px + :align: center + :alt: cuRobo planner test on cube stack using Franka Panda robot + + Cube stack planner test. + - .. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/obstacle_avoidance_curobo.gif + :height: 260px + :align: center + :alt: cuRobo planner test on obstacle avoidance using Franka Panda robot + + Franka planner test. + +These tests can also serve as a reference for how to use cuRobo as a standalone motion planner. + +.. note:: + + For detailed cuRobo config creation and parameters, please see the file ``isaaclab_mimic/motion_planners/curobo/curobo_planner_config.py``. + +Generation Pipeline Integration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When ``--use_skillgen`` is enabled in ``generate_dataset.py``, the following pipeline is executed: + +1. **Randomize subtask boundaries**: Randomize per-demo start and termination indices for each subtask using task-configured offset ranges. + +2. **Build per-subtask trajectories**: + For each end-effector and subtask: + + - Select a source demonstration segment (strategy-driven; respects coordination/sequential constraints) + - Transform the segment to the current scene (object-relative or coordination delta; optional first-pose interpolation) + - Wrap the transformed segment into a waypoint trajectory + +3. **Transition between subtasks**: + - Plan a collision-aware transition with cuRobo to the subtask's first waypoint (world sync, optional attach/detach), execute the planned waypoints, then resume the subtask trajectory + +4. **Execute with constraints**: + - Execute waypoints step-by-step across end-effectors while enforcing subtask constraints (sequential, coordination with synchronous steps); optionally update planner visualization if enabled + +5. **Record and export**: + - Accumulate states/observations/actions, set the episode success flag, and export the episode (the outer pipeline filters/consumes successes) + +Visualization and Debugging +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Users can visualize the planned trajectories and debug for collisions using Rerun-based plan visualizer. This can be enabled by setting ``visualize_plan = True`` in the cuRobo planner configuration. Note that rerun needs to be installed to visualize the planned trajectories. Refer to Step 3 in :ref:`skillgen-installation` for installation instructions. + +.. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/rerun_cube_stack.gif + :width: 80% + :align: center + :alt: Rerun visualization of planned trajectories and collisions + :figclass: align-center + + Rerun integration: planned trajectories with collision spheres. + +.. note:: + + Check cuRobo usage license in ``docs/licenses/dependencies/cuRobo-license.txt`` diff --git a/pyproject.toml b/pyproject.toml index beedbd16a9c2..aa5574018eb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,6 @@ reportPrivateUsage = "warning" skip = '*.usd,*.svg,*.png,_isaac_sim*,*.bib,*.css,*/_build' quiet-level = 0 # the world list should always have words in lower case -ignore-words-list = "haa,slq,collapsable,buss" +ignore-words-list = "haa,slq,collapsable,buss,reacher" # todo: this is hack to deal with incorrect spelling of "Environment" in the Isaac Sim grid world asset exclude-file = "source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py" diff --git a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py index 29a0f94885b6..17322c6e93ce 100644 --- a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py +++ b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py @@ -8,6 +8,7 @@ """ import argparse +import math from isaaclab.app import AppLauncher @@ -33,6 +34,12 @@ default=False, help="Enable Pinocchio.", ) +parser.add_argument( + "--annotate_subtask_start_signals", + action="store_true", + default=False, + help="Enable annotating start points of subtasks.", +) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) @@ -123,6 +130,20 @@ class PreStepDatagenInfoRecorderCfg(RecorderTermCfg): class_type: type[RecorderTerm] = PreStepDatagenInfoRecorder +class PreStepSubtaskStartsObservationsRecorder(RecorderTerm): + """Recorder term that records the subtask start observations in each step.""" + + def record_pre_step(self): + return "obs/datagen_info/subtask_start_signals", self._env.get_subtask_start_signals() + + +@configclass +class PreStepSubtaskStartsObservationsRecorderCfg(RecorderTermCfg): + """Configuration for the subtask start observations recorder term.""" + + class_type: type[RecorderTerm] = PreStepSubtaskStartsObservationsRecorder + + class PreStepSubtaskTermsObservationsRecorder(RecorderTerm): """Recorder term that records the subtask completion observations in each step.""" @@ -142,6 +163,7 @@ class MimicRecorderManagerCfg(ActionStateRecorderManagerCfg): """Mimic specific recorder terms.""" record_pre_step_datagen_info = PreStepDatagenInfoRecorderCfg() + record_pre_step_subtask_start_signals = PreStepSubtaskStartsObservationsRecorderCfg() record_pre_step_subtask_term_signals = PreStepSubtaskTermsObservationsRecorderCfg() @@ -189,11 +211,15 @@ def main(): env_cfg.terminations = None # Set up recorder terms for mimic annotations - env_cfg.recorders: MimicRecorderManagerCfg = MimicRecorderManagerCfg() + env_cfg.recorders = MimicRecorderManagerCfg() if not args_cli.auto: # disable subtask term signals recorder term if in manual mode env_cfg.recorders.record_pre_step_subtask_term_signals = None + if not args_cli.auto or (args_cli.auto and not args_cli.annotate_subtask_start_signals): + # disable subtask start signals recorder term if in manual mode or no need for subtask start annotations + env_cfg.recorders.record_pre_step_subtask_start_signals = None + env_cfg.recorders.dataset_export_dir_path = output_dir env_cfg.recorders.dataset_filename = output_file_name @@ -210,13 +236,36 @@ def main(): "The environment does not implement the get_subtask_term_signals method required " "to run automatic annotations." ) + if ( + args_cli.annotate_subtask_start_signals + and env.get_subtask_start_signals.__func__ is ManagerBasedRLMimicEnv.get_subtask_start_signals + ): + raise NotImplementedError( + "The environment does not implement the get_subtask_start_signals method required " + "to run automatic annotations." + ) else: # get subtask termination signal names for each eef from the environment configs subtask_term_signal_names = {} + subtask_start_signal_names = {} for eef_name, eef_subtask_configs in env.cfg.subtask_configs.items(): + subtask_start_signal_names[eef_name] = ( + [subtask_config.subtask_term_signal for subtask_config in eef_subtask_configs] + if args_cli.annotate_subtask_start_signals + else [] + ) subtask_term_signal_names[eef_name] = [ subtask_config.subtask_term_signal for subtask_config in eef_subtask_configs ] + # Validation: if annotating start signals, every subtask (including the last) must have a name + if args_cli.annotate_subtask_start_signals: + if any(name in (None, "") for name in subtask_start_signal_names[eef_name]): + raise ValueError( + f"Missing 'subtask_term_signal' for one or more subtasks in eef '{eef_name}'. When" + " '--annotate_subtask_start_signals' is enabled, each subtask (including the last) must" + " specify 'subtask_term_signal'. The last subtask's term signal name is used as the final" + " start signal name." + ) # no need to annotate the last subtask term signal, so remove it from the list subtask_term_signal_names[eef_name].pop() @@ -250,7 +299,7 @@ def main(): is_episode_annotated_successfully = annotate_episode_in_auto_mode(env, episode, success_term) else: is_episode_annotated_successfully = annotate_episode_in_manual_mode( - env, episode, success_term, subtask_term_signal_names + env, episode, success_term, subtask_term_signal_names, subtask_start_signal_names ) if is_episode_annotated_successfully and not skip_episode: @@ -362,6 +411,12 @@ def annotate_episode_in_auto_mode( if not torch.any(signal_flags): is_episode_annotated_successfully = False print(f'\tDid not detect completion for the subtask "{signal_name}".') + if args_cli.annotate_subtask_start_signals: + subtask_start_signal_dict = annotated_episode.data["obs"]["datagen_info"]["subtask_start_signals"] + for signal_name, signal_flags in subtask_start_signal_dict.items(): + if not torch.any(signal_flags): + is_episode_annotated_successfully = False + print(f'\tDid not detect start for the subtask "{signal_name}".') return is_episode_annotated_successfully @@ -370,6 +425,7 @@ def annotate_episode_in_manual_mode( episode: EpisodeData, success_term: TerminationTermCfg | None = None, subtask_term_signal_names: dict[str, list[str]] = {}, + subtask_start_signal_names: dict[str, list[str]] = {}, ) -> bool: """Annotates an episode in manual mode. @@ -381,16 +437,18 @@ def annotate_episode_in_manual_mode( episode: The recorded episode data to replay. success_term: Optional termination term to check for task success. subtask_term_signal_names: Dictionary mapping eef names to lists of subtask term signal names. - + subtask_start_signal_names: Dictionary mapping eef names to lists of subtask start signal names. Returns: True if the episode was successfully annotated, False otherwise. """ global is_paused, marked_subtask_action_indices, skip_episode # iterate over the eefs for marking subtask term signals subtask_term_signal_action_indices = {} + subtask_start_signal_action_indices = {} for eef_name, eef_subtask_term_signal_names in subtask_term_signal_names.items(): + eef_subtask_start_signal_names = subtask_start_signal_names[eef_name] # skip if no subtask annotation is needed for this eef - if len(eef_subtask_term_signal_names) == 0: + if len(eef_subtask_term_signal_names) == 0 and len(eef_subtask_start_signal_names) == 0: continue while True: @@ -398,6 +456,8 @@ def annotate_episode_in_manual_mode( skip_episode = False print(f'\tPlaying the episode for subtask annotations for eef "{eef_name}".') print("\tSubtask signals to annotate:") + if len(eef_subtask_start_signal_names) > 0: + print(f"\t\t- Start:\t{eef_subtask_start_signal_names}") print(f"\t\t- Termination:\t{eef_subtask_term_signal_names}") print('\n\tPress "N" to begin.') @@ -411,14 +471,24 @@ def annotate_episode_in_manual_mode( return False print(f"\tSubtasks marked at action indices: {marked_subtask_action_indices}") - expected_subtask_signal_count = len(eef_subtask_term_signal_names) + expected_subtask_signal_count = len(eef_subtask_term_signal_names) + len(eef_subtask_start_signal_names) if task_success_result and expected_subtask_signal_count == len(marked_subtask_action_indices): print(f'\tAll {expected_subtask_signal_count} subtask signals for eef "{eef_name}" were annotated.') for marked_signal_index in range(expected_subtask_signal_count): - # collect subtask term signal action indices - subtask_term_signal_action_indices[eef_subtask_term_signal_names[marked_signal_index]] = ( - marked_subtask_action_indices[marked_signal_index] - ) + if args_cli.annotate_subtask_start_signals and marked_signal_index % 2 == 0: + subtask_start_signal_action_indices[ + eef_subtask_start_signal_names[int(marked_signal_index / 2)] + ] = marked_subtask_action_indices[marked_signal_index] + if not args_cli.annotate_subtask_start_signals: + # Direct mapping when only collecting termination signals + subtask_term_signal_action_indices[eef_subtask_term_signal_names[marked_signal_index]] = ( + marked_subtask_action_indices[marked_signal_index] + ) + elif args_cli.annotate_subtask_start_signals and marked_signal_index % 2 == 1: + # Every other signal is a termination when collecting both types + subtask_term_signal_action_indices[ + eef_subtask_term_signal_names[math.floor(marked_signal_index / 2)] + ] = marked_subtask_action_indices[marked_signal_index] break if not task_success_result: @@ -443,6 +513,18 @@ def annotate_episode_in_manual_mode( subtask_signals = torch.ones(len(episode.data["actions"]), dtype=torch.bool) subtask_signals[:subtask_term_signal_action_index] = False annotated_episode.add(f"obs/datagen_info/subtask_term_signals/{subtask_term_signal_name}", subtask_signals) + + if args_cli.annotate_subtask_start_signals: + for ( + subtask_start_signal_name, + subtask_start_signal_action_index, + ) in subtask_start_signal_action_indices.items(): + subtask_signals = torch.ones(len(episode.data["actions"]), dtype=torch.bool) + subtask_signals[:subtask_start_signal_action_index] = False + annotated_episode.add( + f"obs/datagen_info/subtask_start_signals/{subtask_start_signal_name}", subtask_signals + ) + return True diff --git a/scripts/imitation_learning/isaaclab_mimic/generate_dataset.py b/scripts/imitation_learning/isaaclab_mimic/generate_dataset.py index 4ab8b309c269..a260151f4b15 100644 --- a/scripts/imitation_learning/isaaclab_mimic/generate_dataset.py +++ b/scripts/imitation_learning/isaaclab_mimic/generate_dataset.py @@ -39,6 +39,12 @@ default=False, help="Enable Pinocchio.", ) +parser.add_argument( + "--use_skillgen", + action="store_true", + default=False, + help="use skillgen to generate motion trajectories", +) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -96,27 +102,55 @@ def main(): generation_num_trials=args_cli.generation_num_trials, ) - # create environment + # Create environment env = gym.make(env_name, cfg=env_cfg).unwrapped if not isinstance(env, ManagerBasedRLMimicEnv): raise ValueError("The environment should be derived from ManagerBasedRLMimicEnv") - # check if the mimic API from this environment contains decprecated signatures + # Check if the mimic API from this environment contains decprecated signatures if "action_noise_dict" not in inspect.signature(env.target_eef_pose_to_action).parameters: omni.log.warn( f'The "noise" parameter in the "{env_name}" environment\'s mimic API "target_eef_pose_to_action", ' "is deprecated. Please update the API to take action_noise_dict instead." ) - # set seed for generation + # Set seed for generation random.seed(env.cfg.datagen_config.seed) np.random.seed(env.cfg.datagen_config.seed) torch.manual_seed(env.cfg.datagen_config.seed) - # reset before starting + # Reset before starting env.reset() + motion_planners = None + if args_cli.use_skillgen: + from isaaclab_mimic.motion_planners.curobo.curobo_planner import CuroboPlanner + from isaaclab_mimic.motion_planners.curobo.curobo_planner_cfg import CuroboPlannerCfg + + # Create one motion planner per environment + motion_planners = {} + for env_id in range(num_envs): + print(f"Initializing motion planner for environment {env_id}") + # Create a config instance from the task name + planner_config = CuroboPlannerCfg.from_task_name(env_name) + + # Ensure visualization is only enabled for the first environment + # If not, sphere and plan visualization will be too slow in isaac lab + # It is efficient to visualize the spheres and plan for the first environment in rerun + if env_id != 0: + planner_config.visualize_spheres = False + planner_config.visualize_plan = False + + motion_planners[env_id] = CuroboPlanner( + env=env, + robot=env.scene["robot"], + config=planner_config, # Pass the config object + env_id=env_id, # Pass environment ID + ) + + env.cfg.datagen_config.use_skillgen = True + # Setup and run async data generation async_components = setup_async_generation( env=env, @@ -124,6 +158,7 @@ def main(): input_file=args_cli.input_file, success_term=success_term, pause_subtask=args_cli.pause_subtask, + motion_planners=motion_planners, # Pass the motion planners dictionary ) try: @@ -147,6 +182,14 @@ def main(): print("Remaining async tasks cancelled and cleaned up.") except Exception as e: print(f"Error cancelling remaining async tasks: {e}") + # Cleanup of motion planners and their visualizers + if motion_planners is not None: + for env_id, planner in motion_planners.items(): + if getattr(planner, "plan_visualizer", None) is not None: + print(f"Closing plan visualizer for environment {env_id}") + planner.plan_visualizer.close() + planner.plan_visualizer = None + motion_planners.clear() if __name__ == "__main__": @@ -154,5 +197,5 @@ def main(): main() except KeyboardInterrupt: print("\nProgram interrupted by user. Exiting...") - # close sim app + # Close sim app simulation_app.close() diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_mimic_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_mimic_env.py index 07a33f416a7c..781f89ccbeb0 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_mimic_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_mimic_env.py @@ -117,6 +117,22 @@ def get_object_poses(self, env_ids: Sequence[int] | None = None): ) return object_pose_matrix + def get_subtask_start_signals(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]: + """ + Gets a dictionary of start signal flags for each subtask in a task. The flag is 1 + when the subtask has started and 0 otherwise. The implementation of this method is + required if intending to enable automatic subtask start signal annotation when running the + dataset annotation tool. This method can be kept unimplemented if intending to use manual + subtask start signal annotation. + + Args: + env_ids: Environment indices to get the start signals for. If None, all envs are considered. + + Returns: + A dictionary start signal flags (False or True) for each subtask. + """ + raise NotImplementedError + def get_subtask_term_signals(self, env_ids: Sequence[int] | None = None) -> dict[str, torch.Tensor]: """ Gets a dictionary of termination signal flags for each subtask in a task. The flag is 1 diff --git a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py index ecd2b4fdb2e3..53b48de13e11 100644 --- a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py @@ -73,6 +73,9 @@ class DataGenConfig: generation_interpolate_from_last_target_pose: bool = True """Whether to interpolate from last target pose.""" + use_skillgen: bool = False + """Whether to use skillgen to generate motion trajectories.""" + @configclass class SubTaskConfig: @@ -115,6 +118,12 @@ class SubTaskConfig: first_subtask_start_offset_range: tuple = (0, 0) """Range for start offset of the first subtask.""" + subtask_start_offset_range: tuple = (0, 0) + """Range for start offset of the subtask (only used if use_skillgen is True) + + Note: This value overrides the first_subtask_start_offset_range when skillgen is enabled + """ + subtask_term_offset_range: tuple = (0, 0) """Range for offsetting subtask termination.""" diff --git a/source/isaaclab_mimic/config/extension.toml b/source/isaaclab_mimic/config/extension.toml index 5fa8eb214513..0382ca89c189 100644 --- a/source/isaaclab_mimic/config/extension.toml +++ b/source/isaaclab_mimic/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "1.0.13" +version = "1.0.14" # Description category = "isaaclab" diff --git a/source/isaaclab_mimic/docs/CHANGELOG.rst b/source/isaaclab_mimic/docs/CHANGELOG.rst index a234c5cd3ab8..d25d7aefdeb1 100644 --- a/source/isaaclab_mimic/docs/CHANGELOG.rst +++ b/source/isaaclab_mimic/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +1.0.14 (2025-09-08) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added SkillGen integration for automated demonstration generation using cuRobo; enable via ``--use_skillgen`` in ``scripts/imitation_learning/isaaclab_mimic/generate_dataset.py``. +* Added cuRobo motion planner interface (:class:`CuroboPlanner`, :class:`CuroboPlannerCfg`) +* Added manual subtask start boundary annotation for SkillGen; enable via ``--annotate_subtask_start_signals`` in ``scripts/imitation_learning/isaaclab_mimic/annotate_demos.py``. +* Added Rerun integration for motion plan visualization and debugging; enable via ``visualize_plan = True`` in :class:`CuroboPlannerCfg`. + + 1.0.13 (2025-08-14) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_mimic/isaaclab_mimic/datagen/data_generator.py b/source/isaaclab_mimic/isaaclab_mimic/datagen/data_generator.py index 8e4d1c285d40..2dc31e1c1cf8 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/datagen/data_generator.py +++ b/source/isaaclab_mimic/isaaclab_mimic/datagen/data_generator.py @@ -9,6 +9,7 @@ import asyncio import numpy as np import torch +from typing import Any import isaaclab.utils.math as PoseUtils from isaaclab.envs import ( @@ -69,13 +70,13 @@ def transform_source_data_segment_using_object_pose( transformed_eef_poses: transformed pose sequence (shape [T, 4, 4]) """ - # transform source end effector poses to be relative to source object frame + # Transform source end effector poses to be relative to source object frame src_eef_poses_rel_obj = PoseUtils.pose_in_A_to_pose_in_B( pose_in_A=src_eef_poses, pose_A_in_B=PoseUtils.pose_inv(src_obj_pose[None]), ) - # apply relative poses to current object frame to obtain new target eef poses + # Apply relative poses to current object frame to obtain new target eef poses transformed_eef_poses = PoseUtils.pose_in_A_to_pose_in_B( pose_in_A=src_eef_poses_rel_obj, pose_A_in_B=obj_pose[None], @@ -159,7 +160,7 @@ def __init__( assert isinstance(self.env_cfg, MimicEnvCfg) self.dataset_path = dataset_path - # sanity check on task spec offset ranges - final subtask should not have any offset randomization + # Sanity check on task spec offset ranges - final subtask should not have any offset randomization for subtask_configs in self.env_cfg.subtask_configs.values(): assert subtask_configs[-1].subtask_term_offset_range[0] == 0 assert subtask_configs[-1].subtask_term_offset_range[1] == 0 @@ -191,13 +192,13 @@ def randomize_subtask_boundaries(self) -> dict[str, np.ndarray]: """ Apply random offsets to sample subtask boundaries according to the task spec. Recall that each demonstration is segmented into a set of subtask segments, and the - end index of each subtask can have a random offset. + end index (and start index when skillgen is enabled) of each subtask can have a random offset. """ randomized_subtask_boundaries = {} for eef_name, subtask_boundaries in self.src_demo_datagen_info_pool.subtask_boundaries.items(): - # initial subtask start and end indices - shape (N, S, 2) + # Initial subtask start and end indices - shape (N, S, 2) subtask_boundaries = np.array(subtask_boundaries) # Randomize the start of the first subtask @@ -208,27 +209,38 @@ def randomize_subtask_boundaries(self) -> dict[str, np.ndarray]: ) subtask_boundaries[:, 0, 0] += first_subtask_start_offsets - # for each subtask (except last one), sample all end offsets at once for each demonstration - # add them to subtask end indices, and then set them as the start indices of next subtask too - for i in range(subtask_boundaries.shape[1] - 1): + # For each subtask, sample all end offsets at once for each demonstration + # Add them to subtask end indices, and then set them as the start indices of next subtask too + for i in range(subtask_boundaries.shape[1]): + # If skillgen is enabled, sample a random start offset to increase demonstration variety. + if self.env_cfg.datagen_config.use_skillgen: + start_offset = np.random.randint( + low=self.env_cfg.subtask_configs[eef_name][i].subtask_start_offset_range[0], + high=self.env_cfg.subtask_configs[eef_name][i].subtask_start_offset_range[1] + 1, + size=subtask_boundaries.shape[0], + ) + subtask_boundaries[:, i, 0] += start_offset + elif i > 0: + # Without skillgen, the start of a subtask is the end of the previous one. + subtask_boundaries[:, i, 0] = subtask_boundaries[:, i - 1, 1] + + # Sample end offset for each demonstration end_offsets = np.random.randint( low=self.env_cfg.subtask_configs[eef_name][i].subtask_term_offset_range[0], high=self.env_cfg.subtask_configs[eef_name][i].subtask_term_offset_range[1] + 1, size=subtask_boundaries.shape[0], ) subtask_boundaries[:, i, 1] = subtask_boundaries[:, i, 1] + end_offsets - # don't forget to set these as start indices for next subtask too - subtask_boundaries[:, i + 1, 0] = subtask_boundaries[:, i, 1] - # ensure non-empty subtasks + # Ensure non-empty subtasks assert np.all((subtask_boundaries[:, :, 1] - subtask_boundaries[:, :, 0]) > 0), "got empty subtasks!" - # ensure subtask indices increase (both starts and ends) + # Ensure subtask indices increase (both starts and ends) assert np.all( (subtask_boundaries[:, 1:, :] - subtask_boundaries[:, :-1, :]) > 0 ), "subtask indices do not strictly increase" - # ensure subtasks are in order + # Ensure subtasks are in order subtask_inds_flat = subtask_boundaries.reshape(subtask_boundaries.shape[0], -1) assert np.all((subtask_inds_flat[:, 1:] - subtask_inds_flat[:, :-1]) >= 0), "subtask indices not in order" @@ -269,18 +281,18 @@ def select_source_demo( # demo, so that it can be used by the selection strategy. src_subtask_datagen_infos = [] for i in range(len(self.src_demo_datagen_info_pool.datagen_infos)): - # datagen info over all timesteps of the src trajectory + # Datagen info over all timesteps of the src trajectory src_ep_datagen_info = self.src_demo_datagen_info_pool.datagen_infos[i] - # time indices for subtask + # Time indices for subtask subtask_start_ind = src_demo_current_subtask_boundaries[i][0] subtask_end_ind = src_demo_current_subtask_boundaries[i][1] - # get subtask segment using indices + # Get subtask segment using indices src_subtask_datagen_infos.append( DatagenInfo( eef_pose=src_ep_datagen_info.eef_pose[eef_name][subtask_start_ind:subtask_end_ind], - # only include object pose for relevant object in subtask + # Only include object pose for relevant object in subtask object_poses=( { subtask_object_name: src_ep_datagen_info.object_poses[subtask_object_name][ @@ -290,17 +302,17 @@ def select_source_demo( if (subtask_object_name is not None) else None ), - # subtask termination signal is unused + # Subtask termination signal is unused subtask_term_signals=None, target_eef_pose=src_ep_datagen_info.target_eef_pose[eef_name][subtask_start_ind:subtask_end_ind], gripper_action=src_ep_datagen_info.gripper_action[eef_name][subtask_start_ind:subtask_end_ind], ) ) - # make selection strategy object + # Make selection strategy object selection_strategy_obj = make_selection_strategy(selection_strategy_name) - # run selection + # Run selection if selection_strategy_kwargs is None: selection_strategy_kwargs = dict() selected_src_demo_ind = selection_strategy_obj.select_source_demo( @@ -312,30 +324,48 @@ def select_source_demo( return selected_src_demo_ind - def generate_trajectory( + def generate_eef_subtask_trajectory( self, env_id: int, eef_name: str, subtask_ind: int, - all_randomized_subtask_boundaries: dict[str, np.ndarray], - runtime_subtask_constraints_dict: dict[tuple[str, int], dict], - selected_src_demo_inds: dict[str, int | None], - prev_executed_traj: dict[str, list[Waypoint] | None], - ) -> list[Waypoint]: + all_randomized_subtask_boundaries: dict, + runtime_subtask_constraints_dict: dict, + selected_src_demo_inds: dict, + ) -> WaypointTrajectory: """ - Generate a trajectory for the given subtask. + Build a transformed waypoint trajectory for a single subtask of an end-effector. + + This method selects a source demonstration segment for the specified subtask, + slices the corresponding EEF poses/targets/gripper actions using the randomized + subtask boundaries, optionally prepends the first robot EEF pose (to interpolate + from the robot pose instead of the first target), applies an object/coordination + based transform to the pose sequence, and returns the result as a `WaypointTrajectory`. + + Selection and transforms: + + - Source demo selection is controlled by `SubTaskConfig.selection_strategy` (and kwargs) and by + `datagen_config.generation_select_src_per_subtask` / `generation_select_src_per_arm`. + - For coordination constraints, the method reuses/sets the selected source demo ID across + concurrent subtasks, computes `synchronous_steps`, and stores the pose `transform` used + to ensure consistent relative motion between tasks. + - Pose transforms are computed either from object poses (`object_ref`) or via a delta pose + provided by a concurrent task/coordination scheme. + Args: - env_id: environment index - eef_name: name of end effector - subtask_ind: index of subtask - all_randomized_subtask_boundaries: randomized subtask boundaries - runtime_subtask_constraints_dict: runtime subtask constraints - selected_src_demo_inds: dictionary of selected source demo indices per eef, updated in place - prev_executed_traj: dictionary of previously executed eef trajectories + env_id: Environment index used to query current robot/object poses. + eef_name: End-effector key whose subtask trajectory is being generated. + subtask_ind: Index of the subtask within `subtask_configs[eef_name]`. + all_randomized_subtask_boundaries: For each EEF, an array of per-demo + randomized (start, end) indices for every subtask. + runtime_subtask_constraints_dict: In/out dictionary carrying runtime fields + for constraints (e.g., selected source ID, delta transform, synchronous steps). + selected_src_demo_inds: Per-EEF mapping for the currently selected source demo index + (may be reused across arms if configured). Returns: - trajectory: generated trajectory + WaypointTrajectory: The transformed trajectory for the selected subtask segment. """ subtask_configs = self.env_cfg.subtask_configs[eef_name] # name of object for this subtask @@ -357,7 +387,7 @@ def generate_trajectory( coord_transform_scheme = None if (eef_name, subtask_ind) in runtime_subtask_constraints_dict: if runtime_subtask_constraints_dict[(eef_name, subtask_ind)]["type"] == SubTaskConstraintType.COORDINATION: - # avoid selecting source demo if it has already been selected by the concurrent task + # Avoid selecting source demo if it has already been selected by the concurrent task concurrent_task_spec_key = runtime_subtask_constraints_dict[(eef_name, subtask_ind)][ "concurrent_task_spec_key" ] @@ -368,9 +398,10 @@ def generate_trajectory( (concurrent_task_spec_key, concurrent_subtask_ind) ]["selected_src_demo_ind"] if concurrent_selected_src_ind is not None: - # the concurrent task has started, so we should use the same source demo + # The concurrent task has started, so we should use the same source demo selected_src_demo_inds[eef_name] = concurrent_selected_src_ind need_source_demo_selection = False + # This transform is set at after the first data generation iteration/first run of the main while loop use_delta_transform = runtime_subtask_constraints_dict[ (concurrent_task_spec_key, concurrent_subtask_ind) ]["transform"] @@ -378,7 +409,7 @@ def generate_trajectory( assert ( "transform" not in runtime_subtask_constraints_dict[(eef_name, subtask_ind)] ), "transform should not be set for concurrent task" - # need to transform demo according to scheme + # Need to transform demo according to scheme coord_transform_scheme = runtime_subtask_constraints_dict[(eef_name, subtask_ind)][ "coordination_scheme" ] @@ -405,12 +436,12 @@ def generate_trajectory( for itrated_eef_name in self.env_cfg.subtask_configs.keys(): selected_src_demo_inds[itrated_eef_name] = selected_src_demo_ind - # selected subtask segment time indices + # Selected subtask segment time indices selected_src_subtask_boundary = all_randomized_subtask_boundaries[eef_name][selected_src_demo_ind, subtask_ind] if (eef_name, subtask_ind) in runtime_subtask_constraints_dict: if runtime_subtask_constraints_dict[(eef_name, subtask_ind)]["type"] == SubTaskConstraintType.COORDINATION: - # store selected source demo ind for concurrent task + # Store selected source demo ind for concurrent task runtime_subtask_constraints_dict[(eef_name, subtask_ind)][ "selected_src_demo_ind" ] = selected_src_demo_ind @@ -429,9 +460,7 @@ def generate_trajectory( subtask_len, concurrent_subtask_len ) - # TODO allow for different anchor selection strategies for each subtask - - # get subtask segment, consisting of the sequence of robot eef poses, target poses, gripper actions + # Get subtask segment, consisting of the sequence of robot eef poses, target poses, gripper actions src_ep_datagen_info = self.src_demo_datagen_info_pool.datagen_infos[selected_src_demo_ind] src_subtask_eef_poses = src_ep_datagen_info.eef_pose[eef_name][ selected_src_subtask_boundary[0] : selected_src_subtask_boundary[1] @@ -443,7 +472,7 @@ def generate_trajectory( selected_src_subtask_boundary[0] : selected_src_subtask_boundary[1] ] - # get reference object pose from source demo + # Get reference object pose from source demo src_subtask_object_pose = ( src_ep_datagen_info.object_poses[subtask_object_name][selected_src_subtask_boundary[0]] if (subtask_object_name is not None) @@ -452,10 +481,10 @@ def generate_trajectory( if is_first_subtask or self.env_cfg.datagen_config.generation_transform_first_robot_pose: # Source segment consists of first robot eef pose and the target poses. This ensures that - # we will interpolate to the first robot eef pose in this source segment, instead of the + # We will interpolate to the first robot eef pose in this source segment, instead of the # first robot target pose. src_eef_poses = torch.cat([src_subtask_eef_poses[0:1], src_subtask_target_poses], dim=0) - # account for extra timestep added to @src_eef_poses + # Account for extra timestep added to @src_eef_poses src_subtask_gripper_actions = torch.cat( [src_subtask_gripper_actions[0:1], src_subtask_gripper_actions], dim=0 ) @@ -466,19 +495,11 @@ def generate_trajectory( # Transform source demonstration segment using relevant object pose. if use_delta_transform is not None: - # use delta transform from concurrent task + # Use delta transform from concurrent task transformed_eef_poses = transform_source_data_segment_using_delta_object_pose( src_eef_poses, use_delta_transform ) - # TODO: Uncomment below to support case of temporal concurrent but NOT does not require coordination. Need to decide if this case is necessary - # if subtask_object_name is not None: - # transformed_eef_poses = PoseUtils.transform_source_data_segment_using_object_pose( - # cur_object_poses[task_spec_idx], - # src_eef_poses, - # src_subtask_object_pose, - # ) - else: if coord_transform_scheme is not None: delta_obj_pose = get_delta_pose_with_scheme( @@ -499,45 +520,90 @@ def generate_trajectory( ) else: print(f"skipping transformation for {subtask_object_name}") - # skip transformation if no reference object is provided + + # Skip transformation if no reference object is provided transformed_eef_poses = src_eef_poses + # Construct trajectory for the transformed segment. + transformed_seq = WaypointSequence.from_poses( + poses=transformed_eef_poses, + gripper_actions=src_subtask_gripper_actions, + action_noise=subtask_configs[subtask_ind].action_noise, + ) + transformed_traj = WaypointTrajectory() + transformed_traj.add_waypoint_sequence(transformed_seq) + + return transformed_traj + + def merge_eef_subtask_trajectory( + self, + env_id: int, + eef_name: str, + subtask_index: int, + prev_executed_traj: list[Waypoint] | None, + subtask_trajectory: WaypointTrajectory, + ) -> list[Waypoint]: + """ + Merge a subtask trajectory into an executable trajectory for the robot end-effector. + + This constructs a new `WaypointTrajectory` by first creating an initial + interpolation segment, then merging the provided `subtask_trajectory` onto it. + The initial segment begins either from the last executed target waypoint of the + previous subtask (if configured) or from the robot's current end-effector pose. + + Behavior: + + - If `datagen_config.generation_interpolate_from_last_target_pose` is True and + this is not the first subtask, interpolation starts from the last waypoint of + `prev_executed_traj`. + - Otherwise, interpolation starts from the current robot EEF pose (queried from the env) + and uses the first waypoint's gripper action and the subtask's action noise. + - The merge uses `num_interpolation_steps`, `num_fixed_steps`, and optionally + `apply_noise_during_interpolation` from the corresponding `SubTaskConfig`. + - The temporary initial waypoint used to enable interpolation is removed before returning. + + Args: + env_id: Environment index to query the current robot EEF pose when needed. + eef_name: Name/key of the end-effector whose trajectory is being merged. + subtask_index: Index of the subtask within `subtask_configs[eef_name]` driving interpolation parameters. + prev_executed_traj: The previously executed trajectory used to + seed interpolation from its last target waypoint. Required when interpolation-from-last-target + is enabled and this is not the first subtask. + subtask_trajectory: + Trajectory segment for the current subtask that will be merged after the initial interpolation segment. + + Returns: + list[Waypoint]: The full sequence of waypoints to execute (initial interpolation segment followed by the subtask segment), + with the temporary initial waypoint removed. + """ + is_first_subtask = subtask_index == 0 # We will construct a WaypointTrajectory instance to keep track of robot control targets - # that will be executed and then execute it. + # and then execute it once we have the trajectory. traj_to_execute = WaypointTrajectory() if self.env_cfg.datagen_config.generation_interpolate_from_last_target_pose and (not is_first_subtask): # Interpolation segment will start from last target pose (which may not have been achieved). - assert prev_executed_traj[eef_name] is not None - last_waypoint = prev_executed_traj[eef_name][-1] + assert prev_executed_traj is not None + last_waypoint = prev_executed_traj[-1] init_sequence = WaypointSequence(sequence=[last_waypoint]) else: # Interpolation segment will start from current robot eef pose. init_sequence = WaypointSequence.from_poses( poses=self.env.get_robot_eef_pose(env_ids=[env_id], eef_name=eef_name)[0].unsqueeze(0), - gripper_actions=src_subtask_gripper_actions[0].unsqueeze(0), - action_noise=subtask_configs[subtask_ind].action_noise, + gripper_actions=subtask_trajectory[0].gripper_action.unsqueeze(0), + action_noise=self.env_cfg.subtask_configs[eef_name][subtask_index].action_noise, ) traj_to_execute.add_waypoint_sequence(init_sequence) - # Construct trajectory for the transformed segment. - transformed_seq = WaypointSequence.from_poses( - poses=transformed_eef_poses, - gripper_actions=src_subtask_gripper_actions, - action_noise=subtask_configs[subtask_ind].action_noise, - ) - transformed_traj = WaypointTrajectory() - transformed_traj.add_waypoint_sequence(transformed_seq) - # Merge this trajectory into our trajectory using linear interpolation. # Interpolation will happen from the initial pose (@init_sequence) to the first element of @transformed_seq. traj_to_execute.merge( - transformed_traj, - num_steps_interp=self.env_cfg.subtask_configs[eef_name][subtask_ind].num_interpolation_steps, - num_steps_fixed=self.env_cfg.subtask_configs[eef_name][subtask_ind].num_fixed_steps, + subtask_trajectory, + num_steps_interp=self.env_cfg.subtask_configs[eef_name][subtask_index].num_interpolation_steps, + num_steps_fixed=self.env_cfg.subtask_configs[eef_name][subtask_index].num_fixed_steps, action_noise=( - float(self.env_cfg.subtask_configs[eef_name][subtask_ind].apply_noise_during_interpolation) - * self.env_cfg.subtask_configs[eef_name][subtask_ind].action_noise + float(self.env_cfg.subtask_configs[eef_name][subtask_index].apply_noise_during_interpolation) + * self.env_cfg.subtask_configs[eef_name][subtask_index].action_noise ), ) @@ -549,7 +615,7 @@ def generate_trajectory( # Return the generated trajectory return traj_to_execute.get_full_sequence().sequence - async def generate( + async def generate( # noqa: C901 self, env_id: int, success_term: TerminationTermCfg, @@ -557,20 +623,22 @@ async def generate( env_action_queue: asyncio.Queue | None = None, pause_subtask: bool = False, export_demo: bool = True, + motion_planner: Any | None = None, ) -> dict: """ Attempt to generate a new demonstration. Args: - env_id: environment index + env_id: environment ID success_term: success function to check if the task is successful env_reset_queue: queue to store environment IDs for reset env_action_queue: queue to store actions for each environment - pause_subtask: if True, pause after every subtask during generation, for debugging - export_demo: if True, export the generated demonstration + pause_subtask: whether to pause the subtask generation + export_demo: whether to export the demo + motion_planner: motion planner to use for motion planning Returns: - results: dictionary with the following items: + results (dict): dictionary with the following items: initial_state (dict): initial simulator state for the executed trajectory states (list): simulator state at each timestep observations (list): observation dictionary at each timestep @@ -580,6 +648,9 @@ async def generate( src_demo_inds (list): list of selected source demonstration indices for each subtask src_demo_labels (np.array): same as @src_demo_inds, but repeated to have a label for each timestep of the trajectory """ + # With skillgen, a motion planner is required to generate collision-free transitions between subtasks. + if self.env_cfg.datagen_config.use_skillgen and motion_planner is None: + raise ValueError("motion_planner must be provided if use_skillgen is True") # reset the env to create a new task demo instance env_id_tensor = torch.tensor([env_id], dtype=torch.int64, device=self.env.device) @@ -601,15 +672,18 @@ async def generate( # some eef-specific state variables used during generation current_eef_selected_src_demo_indices = {} - current_eef_subtask_trajectories = {} + current_eef_subtask_trajectories: dict[str, list[Waypoint]] = {} current_eef_subtask_indices = {} + next_eef_subtask_indices_after_motion = {} + next_eef_subtask_trajectories_after_motion = {} current_eef_subtask_step_indices = {} eef_subtasks_done = {} for eef_name in self.env_cfg.subtask_configs.keys(): current_eef_selected_src_demo_indices[eef_name] = None - # prev_eef_executed_traj[eef_name] = None # type of list of Waypoint - current_eef_subtask_trajectories[eef_name] = None # type of list of Waypoint + current_eef_subtask_trajectories[eef_name] = [] # type of list of Waypoint current_eef_subtask_indices[eef_name] = 0 + next_eef_subtask_indices_after_motion[eef_name] = None + next_eef_subtask_trajectories_after_motion[eef_name] = None current_eef_subtask_step_indices[eef_name] = None eef_subtasks_done[eef_name] = False @@ -619,35 +693,120 @@ async def generate( async with self.src_demo_datagen_info_pool.asyncio_lock: if len(self.src_demo_datagen_info_pool.datagen_infos) > prev_src_demo_datagen_info_pool_size: # src_demo_datagen_info_pool at this point may be updated with new demos, - # so we need to updaet subtask boundaries again + # So we need to update subtask boundaries again randomized_subtask_boundaries = ( self.randomize_subtask_boundaries() ) # shape [N, S, 2], last dim is start and end action lengths prev_src_demo_datagen_info_pool_size = len(self.src_demo_datagen_info_pool.datagen_infos) - # generate trajectory for a subtask for the eef that is currently at the beginning of a subtask + # Generate trajectory for a subtask for the eef that is currently at the beginning of a subtask for eef_name, eef_subtask_step_index in current_eef_subtask_step_indices.items(): if eef_subtask_step_index is None: - # current_eef_selected_src_demo_indices will be updated in generate_trajectory - subtask_trajectory = self.generate_trajectory( - env_id, - eef_name, - current_eef_subtask_indices[eef_name], - randomized_subtask_boundaries, - runtime_subtask_constraints_dict, - current_eef_selected_src_demo_indices, - current_eef_subtask_trajectories, - ) - current_eef_subtask_trajectories[eef_name] = subtask_trajectory - current_eef_subtask_step_indices[eef_name] = 0 - # current_eef_selected_src_demo_indices[eef_name] = selected_src_demo_inds - # two_arm_trajectories[task_spec_idx] = subtask_trajectory - # prev_executed_traj[task_spec_idx] = subtask_trajectory + # Trajectory stored in current_eef_subtask_trajectories[eef_name] has been executed, + # So we need to determine the next trajectory + # Note: This condition is the "resume-after-motion-plan" gate for skillgen. When + # use_skillgen=False (vanilla Mimic), next_eef_subtask_indices_after_motion[eef_name] + # remains None, so this condition is always True and the else-branch below is never taken. + # The else-branch is only used right after executing a motion-planned transition (skillgen) + # to resume the actual subtask trajectory. + if next_eef_subtask_indices_after_motion[eef_name] is None: + # This is the beginning of a new subtask, so generate a new trajectory accordingly + eef_subtask_trajectory = self.generate_eef_subtask_trajectory( + env_id, + eef_name, + current_eef_subtask_indices[eef_name], + randomized_subtask_boundaries, + runtime_subtask_constraints_dict, + current_eef_selected_src_demo_indices, # updated in the method + ) + # With skillgen, use a motion planner to transition between subtasks. + if self.env_cfg.datagen_config.use_skillgen: + # Define the goal for the motion planner: the start of the next subtask. + target_eef_pose = eef_subtask_trajectory[0].pose + target_gripper_action = eef_subtask_trajectory[0].gripper_action + + # Determine expected object attachment using environment-specific logic (optional) + expected_attached_object = None + if hasattr(self.env, "get_expected_attached_object"): + expected_attached_object = self.env.get_expected_attached_object( + eef_name, current_eef_subtask_indices[eef_name], self.env.cfg + ) + + # Plan motion using motion planner with comprehensive world update and attachment handling + if motion_planner: + print(f"\n--- Environment {env_id}: Planning motion to target pose ---") + print(f"Target pose: {target_eef_pose}") + print(f"Expected attached object: {expected_attached_object}") + + # This call updates the planner's world model and computes the trajectory. + planning_success = motion_planner.update_world_and_plan_motion( + target_pose=target_eef_pose, + expected_attached_object=expected_attached_object, + env_id=env_id, + step_size=getattr(motion_planner, "step_size", None), + enable_retiming=hasattr(motion_planner, "step_size") + and motion_planner.step_size is not None, + ) + + # If planning succeeds, execute the planner's trajectory first. + if planning_success: + print(f"Env {env_id}: Motion planning succeeded") + # The original subtask trajectory is stored to be executed after the transition. + next_eef_subtask_trajectories_after_motion[eef_name] = eef_subtask_trajectory + next_eef_subtask_indices_after_motion[eef_name] = current_eef_subtask_indices[ + eef_name + ] + # Mark the current subtask as invalid (-1) until the transition is done. + current_eef_subtask_indices[eef_name] = -1 + + # Convert the planner's output into a sequence of waypoints to be executed. + current_eef_subtask_trajectories[eef_name] = ( + self._convert_planned_trajectory_to_waypoints( + motion_planner, target_gripper_action + ) + ) + current_eef_subtask_step_indices[eef_name] = 0 + print( + f"Generated {len(current_eef_subtask_trajectories[eef_name])} waypoints" + " from motion plan" + ) + + else: + # If planning fails, abort the data generation trial. + print(f"Env {env_id}: Motion planning failed for {eef_name}") + return {"success": False} + else: + # Without skillgen, transition using simple interpolation. + current_eef_subtask_trajectories[eef_name] = self.merge_eef_subtask_trajectory( + env_id, + eef_name, + current_eef_subtask_indices[eef_name], + current_eef_subtask_trajectories[eef_name], + eef_subtask_trajectory, + ) + current_eef_subtask_step_indices[eef_name] = 0 + else: + # Motion-planned trajectory has been executed, so we are ready to move to execute the next subtask + print("Finished executing motion-planned trajectory") + # It is important to pass the prev_executed_traj to merge_eef_subtask_trajectory + # so that it can correctly interpolate from the last pose of the motion-planned trajectory + prev_executed_traj = current_eef_subtask_trajectories[eef_name] + current_eef_subtask_indices[eef_name] = next_eef_subtask_indices_after_motion[eef_name] + current_eef_subtask_trajectories[eef_name] = self.merge_eef_subtask_trajectory( + env_id, + eef_name, + current_eef_subtask_indices[eef_name], + prev_executed_traj, + next_eef_subtask_trajectories_after_motion[eef_name], + ) + current_eef_subtask_step_indices[eef_name] = 0 + next_eef_subtask_trajectories_after_motion[eef_name] = None + next_eef_subtask_indices_after_motion[eef_name] = None - # determine the next waypoint for each eef based on the current subtask constraints + # Determine the next waypoint for each eef based on the current subtask constraints eef_waypoint_dict = {} for eef_name in sorted(self.env_cfg.subtask_configs.keys()): - # handle constraints + # Handle constraints step_ind = current_eef_subtask_step_indices[eef_name] subtask_ind = current_eef_subtask_indices[eef_name] if (eef_name, subtask_ind) in runtime_subtask_constraints_dict: @@ -660,7 +819,7 @@ async def generate( or step_ind >= len(current_eef_subtask_trajectories[eef_name]) - min_time_diff ): if step_ind > 0: - # wait at the same step + # Wait at the same step step_ind -= 1 current_eef_subtask_step_indices[eef_name] = step_ind @@ -676,8 +835,8 @@ async def generate( task_constraint["coordination_synchronize_start"] and current_eef_subtask_indices[concurrent_task_spec_key] < concurrent_subtask_ind ): - # the concurrent eef is not yet at the concurrent subtask, so wait at the first action - # this also makes sure that the concurrent task starts at the same time as this task + # The concurrent eef is not yet at the concurrent subtask, so wait at the first action + # This also makes sure that the concurrent task starts at the same time as this task step_ind = 0 current_eef_subtask_step_indices[eef_name] = 0 else: @@ -685,7 +844,7 @@ async def generate( not concurrent_task_fulfilled and step_ind >= len(current_eef_subtask_trajectories[eef_name]) - synchronous_steps ): - # trigger concurrent task + # Trigger concurrent task runtime_subtask_constraints_dict[(concurrent_task_spec_key, concurrent_subtask_ind)][ "fulfilled" ] = True @@ -697,10 +856,16 @@ async def generate( current_eef_subtask_step_indices[eef_name] = step_ind # wait here waypoint = current_eef_subtask_trajectories[eef_name][step_ind] + + # Update visualization if motion planner is available + if motion_planner and motion_planner.visualize_spheres: + current_joints = self.env.scene["robot"].data.joint_pos[env_id] + motion_planner._update_visualization_at_joint_positions(current_joints) + eef_waypoint_dict[eef_name] = waypoint multi_waypoint = MultiWaypoint(eef_waypoint_dict) - # execute the next waypoints for all eefs + # Execute the next waypoints for all eefs exec_results = await multi_waypoint.execute( env=self.env, success_term=success_term, @@ -708,7 +873,7 @@ async def generate( env_action_queue=env_action_queue, ) - # update execution state buffers + # Update execution state buffers if len(exec_results["states"]) > 0: generated_states.extend(exec_results["states"]) generated_obs.extend(exec_results["observations"]) @@ -720,7 +885,7 @@ async def generate( subtask_ind = current_eef_subtask_indices[eef_name] if current_eef_subtask_step_indices[eef_name] == len( current_eef_subtask_trajectories[eef_name] - ): # subtask done + ): # Subtask done if (eef_name, subtask_ind) in runtime_subtask_constraints_dict: task_constraint = runtime_subtask_constraints_dict[(eef_name, subtask_ind)] if task_constraint["type"] == SubTaskConstraintType._SEQUENTIAL_FORMER: @@ -732,9 +897,9 @@ async def generate( elif task_constraint["type"] == SubTaskConstraintType.COORDINATION: concurrent_task_spec_key = task_constraint["concurrent_task_spec_key"] concurrent_subtask_ind = task_constraint["concurrent_subtask_ind"] - # concurrent_task_spec_idx = task_spec_keys.index(concurrent_task_spec_key) + # Concurrent_task_spec_idx = task_spec_keys.index(concurrent_task_spec_key) task_constraint["finished"] = True - # check if concurrent task has been finished + # Check if concurrent task has been finished assert ( runtime_subtask_constraints_dict[(concurrent_task_spec_key, concurrent_subtask_ind)][ "finished" @@ -762,11 +927,11 @@ async def generate( if all(eef_subtasks_done.values()): break - # merge numpy arrays + # Merge numpy arrays if len(generated_actions) > 0: generated_actions = torch.cat(generated_actions, dim=0) - # set success to the recorded episode data and export to file + # Set success to the recorded episode data and export to file self.env.recorder_manager.set_success_to_episodes( env_id_tensor, torch.tensor([[generated_success]], dtype=torch.bool, device=self.env.device) ) @@ -781,3 +946,33 @@ async def generate( success=generated_success, ) return results + + def _convert_planned_trajectory_to_waypoints( + self, motion_planner: Any, gripper_action: torch.Tensor + ) -> list[Waypoint]: + """ + (skillgen) Convert a motion planner's output trajectory into a list of Waypoint objects. + + The motion planner provides a sequence of planned 4x4 poses. This method wraps each + pose into a `Waypoint`, pairing it with the provided `gripper_action` and an optional + per-timestep noise value sourced from the planner config (`motion_noise_scale`). + + Args: + motion_planner: Planner instance exposing `get_planned_poses()` and an optional + `config.motion_noise_scale` float. + gripper_action: Gripper actuation to associate with each planned pose. + + Returns: + list[Waypoint]: Sequence of waypoints corresponding to the planned trajectory. + """ + # Get motion noise scale from the planner's configuration + motion_noise_scale = getattr(motion_planner.config, "motion_noise_scale", 0.0) + + waypoints = [] + planned_poses = motion_planner.get_planned_poses() + + for planned_pose in planned_poses: + waypoint = Waypoint(pose=planned_pose, gripper_action=gripper_action, noise=motion_noise_scale) + waypoints.append(waypoint) + + return waypoints diff --git a/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info.py b/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info.py index 5dcf5196d205..66faa8cc138e 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info.py +++ b/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info.py @@ -20,6 +20,7 @@ class DatagenInfo: Core Elements: - **eef_pose**: Captures the current 6 dimensional poses of the robot's end-effector. - **object_poses**: Captures the 6 dimensional poses of relevant objects in the scene. + - **subtask_start_signals**: Captures subtask start signals. Used by skillgen to identify the precise start of a subtask from a demonstration. - **subtask_term_signals**: Captures subtask completions signals. - **target_eef_pose**: Captures the target 6 dimensional poses for robot's end effector at each time step. - **gripper_action**: Captures the gripper's state. @@ -30,6 +31,7 @@ def __init__( eef_pose=None, object_poses=None, subtask_term_signals=None, + subtask_start_signals=None, target_eef_pose=None, gripper_action=None, ): @@ -38,6 +40,9 @@ def __init__( eef_pose (torch.Tensor or None): robot end effector poses of shape [..., 4, 4] object_poses (dict or None): dictionary mapping object name to object poses of shape [..., 4, 4] + subtask_start_signals (dict or None): dictionary mapping subtask name to a binary + indicator (0 or 1) on whether subtask has started. This is required when using skillgen. + Each value in the dictionary could be an int, float, or torch.Tensor of shape [..., 1]. subtask_term_signals (dict or None): dictionary mapping subtask name to a binary indicator (0 or 1) on whether subtask has been completed. Each value in the dictionary could be an int, float, or torch.Tensor of shape [..., 1]. @@ -53,6 +58,17 @@ def __init__( if object_poses is not None: self.object_poses = {k: object_poses[k] for k in object_poses} + # When using skillgen, demonstrations must be annotated with subtask start signals. + self.subtask_start_signals = None + if subtask_start_signals is not None: + self.subtask_start_signals = dict() + for k in subtask_start_signals: + if isinstance(subtask_start_signals[k], (float, int)): + self.subtask_start_signals[k] = subtask_start_signals[k] + else: + # Only create torch tensor if value is not a single value + self.subtask_start_signals[k] = subtask_start_signals[k] + self.subtask_term_signals = None if subtask_term_signals is not None: self.subtask_term_signals = dict() @@ -80,6 +96,8 @@ def to_dict(self): ret["eef_pose"] = self.eef_pose if self.object_poses is not None: ret["object_poses"] = deepcopy(self.object_poses) + if self.subtask_start_signals is not None: + ret["subtask_start_signals"] = deepcopy(self.subtask_start_signals) if self.subtask_term_signals is not None: ret["subtask_term_signals"] = deepcopy(self.subtask_term_signals) if self.target_eef_pose is not None: diff --git a/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info_pool.py b/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info_pool.py index 348f4dd4a2a3..3cb8d740a86f 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info_pool.py +++ b/source/isaaclab_mimic/isaaclab_mimic/datagen/datagen_info_pool.py @@ -40,10 +40,15 @@ def __init__(self, env, env_cfg, device, asyncio_lock: asyncio.Lock | None = Non # Subtask termination infos for the given environment self.subtask_term_signal_names: dict[str, list[str]] = {} self.subtask_term_offset_ranges: dict[str, list[tuple[int, int]]] = {} + self.subtask_start_offset_ranges: dict[str, list[tuple[int, int]]] = {} + for eef_name, eef_subtask_configs in env_cfg.subtask_configs.items(): self.subtask_term_signal_names[eef_name] = [ subtask_config.subtask_term_signal for subtask_config in eef_subtask_configs ] + self.subtask_start_offset_ranges[eef_name] = [ + subtask_config.subtask_start_offset_range for subtask_config in eef_subtask_configs + ] self.subtask_term_offset_ranges[eef_name] = [ subtask_config.subtask_term_offset_range for subtask_config in eef_subtask_configs ] @@ -86,16 +91,22 @@ def _add_episode(self, episode: EpisodeData): Add a datagen info from the given episode. Args: - episode (EpisodeData): episode to add + episode: Episode to add. + + Raises: + ValueError: Episode lacks 'datagen_info' annotations in observations. + ValueError: Subtask termination signal is not increasing. """ ep_grp = episode.data - # extract datagen info + # Extract datagen info if "datagen_info" in ep_grp["obs"]: eef_pose = ep_grp["obs"]["datagen_info"]["eef_pose"] object_poses_dict = ep_grp["obs"]["datagen_info"]["object_pose"] 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 is optional + subtask_start_signals_dict = ep_grp["obs"]["datagen_info"].get("subtask_start_signals") else: raise ValueError("Episode to be loaded to DatagenInfo pool lacks datagen_info annotations") @@ -105,63 +116,90 @@ def _add_episode(self, episode: EpisodeData): ep_datagen_info_obj = DatagenInfo( eef_pose=eef_pose, object_poses=object_poses_dict, + subtask_start_signals=subtask_start_signals_dict, subtask_term_signals=subtask_term_signals_dict, target_eef_pose=target_eef_pose, gripper_action=gripper_actions, ) self._datagen_infos.append(ep_datagen_info_obj) - # parse subtask ranges using subtask termination signals and store + # Parse subtask ranges using subtask termination signals and store # the start and end indices of each subtask for each eef for eef_name in self.subtask_term_signal_names.keys(): if eef_name not in self._subtask_boundaries: self._subtask_boundaries[eef_name] = [] - prev_subtask_term_ind = 0 + prev_subtask_term_index = 0 eef_subtask_boundaries = [] - for subtask_term_signal_name in self.subtask_term_signal_names[eef_name]: - if subtask_term_signal_name is None: - # None refers to the final subtask, so finishes at end of demo - subtask_term_ind = ep_grp["actions"].shape[0] + for eef_subtask_index, eef_subtask_signal_name in enumerate(self.subtask_term_signal_names[eef_name]): + if self.env_cfg.datagen_config.use_skillgen: + # For skillgen, the start of a subtask is explicitly defined in the demonstration data. + if ep_datagen_info_obj.subtask_start_signals is None: + raise ValueError( + "subtask_start_signals field is not present in datagen_info for subtask" + f" {eef_subtask_signal_name} in the loaded episode when use_skillgen is enabled" + ) + # Find the first time step where the start signal transitions from 0 to 1. + subtask_start_indicators = ( + ep_datagen_info_obj.subtask_start_signals[eef_subtask_signal_name].flatten().int() + ) + # Compute the difference between consecutive elements to find the transition point. + diffs = subtask_start_indicators[1:] - subtask_start_indicators[:-1] + # The first non-zero element's index gives the start of the subtask. + start_index = int(diffs.nonzero()[0][0]) + 1 + else: + # Without skillgen, subtasks are assumed to be sequential. + start_index = prev_subtask_term_index + + if eef_subtask_index == len(self.subtask_term_signal_names[eef_name]) - 1: + # Last subtask has no termination signal from the datagen_info + end_index = ep_grp["actions"].shape[0] else: - # trick to detect index where first 0 -> 1 transition occurs - this will be the end of the subtask - subtask_indicators = ( - ep_datagen_info_obj.subtask_term_signals[subtask_term_signal_name].flatten().int() + # Trick to detect index where first 0 -> 1 transition occurs - this will be the end of the subtask + subtask_term_indicators = ( + ep_datagen_info_obj.subtask_term_signals[eef_subtask_signal_name].flatten().int() ) - diffs = subtask_indicators[1:] - subtask_indicators[:-1] - end_ind = int(diffs.nonzero()[0][0]) + 1 - subtask_term_ind = end_ind + 1 # increment to support indexing like demo[start:end] + diffs = subtask_term_indicators[1:] - subtask_term_indicators[:-1] + end_index = int(diffs.nonzero()[0][0]) + 1 + end_index = end_index + 1 # increment to support indexing like demo[start:end] - if subtask_term_ind <= prev_subtask_term_ind: + if end_index <= start_index: raise ValueError( - f"subtask termination signal is not increasing: {subtask_term_ind} should be greater than" - f" {prev_subtask_term_ind}" + f"subtask termination signal is not increasing: {end_index} should be greater than" + f" {start_index}" ) - eef_subtask_boundaries.append((prev_subtask_term_ind, subtask_term_ind)) - prev_subtask_term_ind = subtask_term_ind - - # run sanity check on subtask_term_offset_range in task spec to make sure we can never - # get an empty subtask in the worst case when sampling subtask bounds: - # - # end index of subtask i + max offset of subtask i < end index of subtask i + 1 + min offset of subtask i + 1 - # - for i in range(1, len(eef_subtask_boundaries)): - prev_max_offset_range = self.subtask_term_offset_ranges[eef_name][i - 1][1] - assert ( - eef_subtask_boundaries[i - 1][1] + prev_max_offset_range - < eef_subtask_boundaries[i][1] + self.subtask_term_offset_ranges[eef_name][i][0] - ), ( - "subtask sanity check violation in demo with subtask {} end ind {}, subtask {} max offset {}," - " subtask {} end ind {}, and subtask {} min offset {}".format( - i - 1, - eef_subtask_boundaries[i - 1][1], - i - 1, - prev_max_offset_range, - i, - eef_subtask_boundaries[i][1], - i, - self.subtask_term_offset_ranges[eef_name][i][0], + eef_subtask_boundaries.append((start_index, end_index)) + prev_subtask_term_index = end_index + + if self.env_cfg.datagen_config.use_skillgen: + # With skillgen, both start and end boundaries can be randomized. + # This checks if the randomized boundaries could result in an invalid (e.g., empty) subtask. + for i in range(len(eef_subtask_boundaries)): + # Ensure that a subtask is not empty in the worst-case randomization scenario. + assert ( + eef_subtask_boundaries[i][0] + self.subtask_start_offset_ranges[eef_name][i][1] + < eef_subtask_boundaries[i][1] + self.subtask_term_offset_ranges[eef_name][i][0] + ), f"subtask {i} is empty in the worst case" + if i == len(eef_subtask_boundaries) - 1: + break + # Make sure that subtasks are not overlapped with the largest offsets + assert ( + eef_subtask_boundaries[i][1] + self.subtask_term_offset_ranges[eef_name][i][1] + < eef_subtask_boundaries[i + 1][0] + self.subtask_start_offset_ranges[eef_name][i + 1][0] + ), f"subtasks {i} and {i + 1} are overlapped with the largest offsets" + else: + # Run sanity check on subtask_term_offset_range in task spec + for i in range(1, len(eef_subtask_boundaries)): + prev_max_offset_range = self.subtask_term_offset_ranges[eef_name][i - 1][1] + # Make sure that subtasks are not overlapped with the largest offsets + assert ( + eef_subtask_boundaries[i - 1][1] + prev_max_offset_range + < eef_subtask_boundaries[i][1] + self.subtask_term_offset_ranges[eef_name][i][0] + ), ( + f"subtask sanity check violation in demo with subtask {i - 1} end ind" + f" {eef_subtask_boundaries[i - 1][1]}, subtask {i - 1} max offset {prev_max_offset_range}," + f" subtask {i} end ind {eef_subtask_boundaries[i][1]}, and subtask {i} min offset" + f" {self.subtask_term_offset_ranges[eef_name][i][0]}" ) - ) self._subtask_boundaries[eef_name].append(eef_subtask_boundaries) diff --git a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py index 2866a217f03e..6abdc088170d 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py +++ b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py @@ -31,6 +31,7 @@ async def run_data_generator( data_generator: DataGenerator, success_term: TerminationTermCfg, pause_subtask: bool = False, + motion_planner: Any = None, ): """Run mimic data generation from the given data generator in the specified environment index. @@ -42,6 +43,7 @@ async def run_data_generator( data_generator: The data generator instance to use. success_term: The success termination term to use. pause_subtask: Whether to pause the subtask during generation. + motion_planner: The motion planner to use. """ global num_success, num_failures, num_attempts while True: @@ -51,6 +53,7 @@ async def run_data_generator( env_reset_queue=env_reset_queue, env_action_queue=env_action_queue, pause_subtask=pause_subtask, + motion_planner=motion_planner, ) if bool(results["success"]): num_success += 1 @@ -190,7 +193,12 @@ def setup_env_config( def setup_async_generation( - env: Any, num_envs: int, input_file: str, success_term: Any, pause_subtask: bool = False + env: Any, + num_envs: int, + input_file: str, + success_term: Any, + pause_subtask: bool = False, + motion_planners: Any = None, ) -> dict[str, Any]: """Setup async data generation tasks. @@ -200,6 +208,7 @@ def setup_async_generation( input_file: Path to input dataset file success_term: Success termination condition pause_subtask: Whether to pause after subtasks + motion_planners: Motion planner instances for all environments Returns: List of asyncio tasks for data generation @@ -216,9 +225,17 @@ def setup_async_generation( data_generator = DataGenerator(env=env, src_demo_datagen_info_pool=shared_datagen_info_pool) data_generator_asyncio_tasks = [] for i in range(num_envs): + env_motion_planner = motion_planners[i] if motion_planners else None task = asyncio_event_loop.create_task( run_data_generator( - env, i, env_reset_queue, env_action_queue, data_generator, success_term, pause_subtask=pause_subtask + env, + i, + env_reset_queue, + env_action_queue, + data_generator, + success_term, + pause_subtask=pause_subtask, + motion_planner=env_motion_planner, ) ) data_generator_asyncio_tasks.append(task) diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py b/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py index 84305a66745d..5c80d5ddbcd6 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/__init__.py @@ -7,11 +7,13 @@ import gymnasium as gym +from .franka_bin_stack_ik_rel_mimic_env_cfg import FrankaBinStackIKRelMimicEnvCfg from .franka_stack_ik_abs_mimic_env import FrankaCubeStackIKAbsMimicEnv from .franka_stack_ik_abs_mimic_env_cfg import FrankaCubeStackIKAbsMimicEnvCfg from .franka_stack_ik_rel_blueprint_mimic_env_cfg import FrankaCubeStackIKRelBlueprintMimicEnvCfg from .franka_stack_ik_rel_mimic_env import FrankaCubeStackIKRelMimicEnv from .franka_stack_ik_rel_mimic_env_cfg import FrankaCubeStackIKRelMimicEnvCfg +from .franka_stack_ik_rel_skillgen_env_cfg import FrankaCubeStackIKRelSkillgenEnvCfg from .franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg import FrankaCubeStackIKRelVisuomotorCosmosMimicEnvCfg from .franka_stack_ik_rel_visuomotor_mimic_env_cfg import FrankaCubeStackIKRelVisuomotorMimicEnvCfg from .galbot_stack_rmp_abs_mimic_env import RmpFlowGalbotCubeStackAbsMimicEnv @@ -74,6 +76,28 @@ ) +## +# SkillGen +## + +gym.register( + id="Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0", + entry_point="isaaclab_mimic.envs:FrankaCubeStackIKRelMimicEnv", + kwargs={ + "env_cfg_entry_point": franka_stack_ik_rel_skillgen_env_cfg.FrankaCubeStackIKRelSkillgenEnvCfg, + }, + disable_env_checker=True, +) + +gym.register( + id="Isaac-Stack-Cube-Bin-Franka-IK-Rel-Mimic-v0", + entry_point="isaaclab_mimic.envs:FrankaCubeStackIKRelMimicEnv", + kwargs={ + "env_cfg_entry_point": franka_bin_stack_ik_rel_mimic_env_cfg.FrankaBinStackIKRelMimicEnvCfg, + }, + disable_env_checker=True, +) + ## # Galbot Stack Cube with RmpFlow - Relative Pose Control ## @@ -99,6 +123,7 @@ ## # Galbot Stack Cube with RmpFlow - Absolute Pose Control ## + gym.register( id="Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-Abs-Mimic-v0", entry_point="isaaclab_mimic.envs:RmpFlowGalbotCubeStackAbsMimicEnv", diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py new file mode 100644 index 000000000000..ba40bd620e0f --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py @@ -0,0 +1,93 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.stack.config.franka.bin_stack_ik_rel_env_cfg import FrankaBinStackEnvCfg + + +@configclass +class FrankaBinStackIKRelMimicEnvCfg(FrankaBinStackEnvCfg, MimicEnvCfg): + """ + Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel env. + """ + + def __post_init__(self): + + # post init of parents + super().__post_init__() + + # Override the existing values + self.datagen_config.name = "demo_src_stack_isaac_lab_task_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = True + self.datagen_config.generation_num_trials = 10 + self.datagen_config.generation_select_src_per_subtask = True + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.generation_relative = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.seed = 1 + + # The following are the subtask configurations for the stack task. + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + object_ref="cube_2", + subtask_term_signal="grasp_1", + subtask_term_offset_range=(0, 0), + selection_strategy="nearest_neighbor_object", + selection_strategy_kwargs={"nn_k": 3}, + action_noise=0.03, + num_interpolation_steps=0, + num_fixed_steps=0, + apply_noise_during_interpolation=False, + description="Grasp red cube", + next_subtask_description="Stack red cube on top of blue cube", + ) + ) + subtask_configs.append( + SubTaskConfig( + object_ref="cube_1", + subtask_term_signal="stack_1", + subtask_term_offset_range=(0, 0), + selection_strategy="nearest_neighbor_object", + selection_strategy_kwargs={"nn_k": 3}, + action_noise=0.03, + num_interpolation_steps=0, + num_fixed_steps=0, + apply_noise_during_interpolation=False, + next_subtask_description="Grasp green cube", + ) + ) + subtask_configs.append( + SubTaskConfig( + object_ref="cube_3", + subtask_term_signal="grasp_2", + subtask_term_offset_range=(0, 0), + selection_strategy="nearest_neighbor_object", + selection_strategy_kwargs={"nn_k": 3}, + action_noise=0.03, + num_interpolation_steps=0, + num_fixed_steps=0, + apply_noise_during_interpolation=False, + next_subtask_description="Stack green cube on top of red cube", + ) + ) + subtask_configs.append( + SubTaskConfig( + object_ref="cube_2", + subtask_term_signal="stack_2", + subtask_term_offset_range=(0, 0), + selection_strategy="nearest_neighbor_object", + selection_strategy_kwargs={"nn_k": 3}, + action_noise=0.03, + num_interpolation_steps=0, + num_fixed_steps=0, + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["franka"] = subtask_configs diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py index ceaeb36765ca..6090161adcbb 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env.py @@ -163,3 +163,26 @@ def get_subtask_term_signals(self, env_ids: Sequence[int] | None = None) -> dict signals["stack_1"] = subtask_terms["stack_1"][env_ids] # final subtask is placing cubeC on cubeA (motion relative to cubeA) - but final subtask signal is not needed return signals + + def get_expected_attached_object(self, eef_name: str, subtask_index: int, env_cfg) -> str | None: + """ + (SkillGen) Return the expected attached object for the given EEF/subtask. + + Assumes 'stack' subtasks place the object grasped in the preceding 'grasp' subtask. + Returns None for 'grasp' (or others) at subtask start. + """ + if eef_name not in env_cfg.subtask_configs: + return None + + subtask_configs = env_cfg.subtask_configs[eef_name] + if not (0 <= subtask_index < len(subtask_configs)): + return None + + current_cfg = subtask_configs[subtask_index] + # If stacking, expect we are holding the object grasped in the prior subtask + if "stack" in str(current_cfg.subtask_term_signal).lower(): + if subtask_index > 0: + prev_cfg = subtask_configs[subtask_index - 1] + if "grasp" in str(prev_cfg.subtask_term_signal).lower(): + return prev_cfg.object_ref + return None diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py new file mode 100644 index 000000000000..714412cb5536 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py @@ -0,0 +1,137 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_ik_rel_env_cfg_skillgen import ( + FrankaCubeStackSkillgenEnvCfg, +) + + +@configclass +class FrankaCubeStackIKRelSkillgenEnvCfg(FrankaCubeStackSkillgenEnvCfg, MimicEnvCfg): + """ + Isaac Lab Mimic environment config class for Franka Cube Stack IK Rel env. + """ + + def __post_init__(self): + # post init of parents + super().__post_init__() + # # TODO: Figure out how we can move this to the MimicEnvCfg class + # # The __post_init__() above only calls the init for FrankaCubeStackEnvCfg and not MimicEnvCfg + # # https://stackoverflow.com/questions/59986413/achieving-multiple-inheritance-using-python-dataclasses + + # Override the existing values + self.datagen_config.name = "demo_src_stack_isaac_lab_task_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = True + self.datagen_config.generation_num_trials = 10 + self.datagen_config.generation_select_src_per_subtask = True + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.generation_relative = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.seed = 1 + + # The following are the subtask configurations for the stack task. + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # This key corresponds to the binary indicator in "datagen_info" that signals + # when this subtask is finished (e.g., on a 0 to 1 edge). + subtask_term_signal="grasp_1", + # Specifies time offsets for data generation when splitting a trajectory into + # subtask segments. Random offsets are added to the termination boundary. + subtask_term_offset_range=(0, 0), # (10, 20), + # Selection strategy for the source subtask segment during data generation + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.03, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, # 5, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + description="Grasp red cube", + next_subtask_description="Stack red cube on top of blue cube", + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_1", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="stack_1", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(0, 0), # (10, 20), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.03, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, # 5, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + next_subtask_description="Grasp green cube", + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_3", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal="grasp_2", + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(0, 0), # (10, 20), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.03, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, # 5, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + next_subtask_description="Stack green cube on top of red cube", + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="cube_2", + # End of final subtask does not need to be detected for MimicGen + # Needs to be detected for SkillGen + # Setting this doesn't affect the data generation for MimicGen + subtask_term_signal="stack_2", + # No time offsets for the final subtask + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.03, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, # 5, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["franka"] = subtask_configs diff --git a/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner.py b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner.py new file mode 100644 index 000000000000..f9c6cf69cbdb --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner.py @@ -0,0 +1,1950 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +import logging +import numpy as np +import torch +from dataclasses import dataclass +from typing import Any + +from curobo.cuda_robot_model.cuda_robot_model import CudaRobotModelState +from curobo.geom.sdf.world import CollisionCheckerType +from curobo.geom.sphere_fit import SphereFitType +from curobo.geom.types import WorldConfig +from curobo.types.base import TensorDeviceType +from curobo.types.math import Pose +from curobo.types.state import JointState +from curobo.util.logger import setup_curobo_logger +from curobo.util.usd_helper import UsdHelper +from curobo.util_file import load_yaml +from curobo.wrap.reacher.motion_gen import MotionGen, MotionGenConfig, MotionGenPlanConfig + +import isaaclab.utils.math as PoseUtils +from isaaclab.assets import Articulation +from isaaclab.envs.manager_based_env import ManagerBasedEnv +from isaaclab.managers import SceneEntityCfg +from isaaclab.sim.spawners.materials import PreviewSurfaceCfg +from isaaclab.sim.spawners.meshes import MeshSphereCfg, spawn_mesh_sphere + +from isaaclab_mimic.motion_planners.curobo.curobo_planner_cfg import CuroboPlannerCfg +from isaaclab_mimic.motion_planners.motion_planner_base import MotionPlannerBase + + +class PlannerLogger: + """Logger class for motion planner debugging and monitoring. + + This class provides standard logging functionality while maintaining isolation from + the main application's logging configuration. The logger supports configurable verbosity + levels and formats messages consistently for debugging motion planning operations, + collision checking, and object manipulation. + """ + + def __init__(self, name: str, level: int = logging.INFO): + """Initialize the logger with specified name and level. + + Args: + name: Logger name for identification in log messages + level: Logging level (DEBUG, INFO, WARNING, ERROR) + """ + self._name = name + self._level = level + self._logger = None + + @property + def logger(self): + """Get the underlying logger instance, initializing it if needed. + + Returns: + Configured Python logger instance with stream handler and formatter + """ + if self._logger is None: + self._logger = logging.getLogger(self._name) + if not self._logger.handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s") + handler.setFormatter(formatter) + self._logger.addHandler(handler) + self._logger.setLevel(self._level) + return self._logger + + def debug(self, msg, *args, **kwargs): + """Log debug-level message for detailed internal state information. + + Args: + msg: Message string or format string + *args: Positional arguments for message formatting + **kwargs: Keyword arguments passed to underlying logger + """ + self.logger.debug(msg, *args, **kwargs) + + def info(self, msg, *args, **kwargs): + """Log info-level message for important operational events. + + Args: + msg: Message string or format string + *args: Positional arguments for message formatting + **kwargs: Keyword arguments passed to underlying logger + """ + self.logger.info(msg, *args, **kwargs) + + def warning(self, msg, *args, **kwargs): + """Log warning-level message for potentially problematic conditions. + + Args: + msg: Message string or format string + *args: Positional arguments for message formatting + **kwargs: Keyword arguments passed to underlying logger + """ + self.logger.warning(msg, *args, **kwargs) + + def error(self, msg, *args, **kwargs): + """Log error-level message for serious problems and failures. + + Args: + msg: Message string or format string + *args: Positional arguments for message formatting + **kwargs: Keyword arguments passed to underlying logger + """ + self.logger.error(msg, *args, **kwargs) + + +@dataclass +class Attachment: + """Stores object attachment information for robot manipulation. + + This dataclass tracks the relative pose between an attached object and its parent link, + enabling the robot to maintain consistent object positioning during motion planning. + """ + + pose: Pose # Relative pose from parent link to object + parent: str # Parent link name + + +class CuroboPlanner(MotionPlannerBase): + """Motion planner for robot manipulation using cuRobo. + + This planner provides collision-aware motion planning capabilities for robotic manipulation tasks. + It integrates with Isaac Lab environments to: + + - Update collision world from current stage state + - Plan collision-free paths to target poses + - Handle object attachment and detachment during manipulation + - Execute planned motions with proper collision checking + + The planner uses cuRobo for fast motion generation and supports + multi-phase planning for contact scenarios like grasping and placing objects. + """ + + def __init__( + self, + env: ManagerBasedEnv, + robot: Articulation, + config: CuroboPlannerCfg, + task_name: str | None = None, + env_id: int = 0, + collision_checker: CollisionCheckerType = CollisionCheckerType.MESH, + num_trajopt_seeds: int = 12, + num_graph_seeds: int = 12, + interpolation_dt: float = 0.05, + ) -> None: + """Initialize the motion planner for a specific environment. + + Sets up the cuRobo motion generator with collision checking, configures the robot model, + and prepares visualization components if enabled. The planner is isolated to CUDA device + regardless of Isaac Lab's device configuration. + + Args: + env: The Isaac Lab environment instance containing the robot and scene + robot: Robot articulation to plan motions for + config: Configuration object containing planner parameters and settings + task_name: Task name for auto-configuration + env_id: Environment ID for multi-environment setups (0 to num_envs-1) + collision_checker: Type of collision checker + num_trajopt_seeds: Number of seeds for trajectory optimization + num_graph_seeds: Number of seeds for graph search + interpolation_dt: Time step for interpolating waypoints + + Raises: + ValueError: If ``robot_config_file`` is not provided + """ + # Initialize base class + super().__init__(env=env, robot=robot, env_id=env_id, debug=config.debug_planner) + + # Initialize planner logger with debug level based on config + log_level = logging.DEBUG if config.debug_planner else logging.INFO + self.logger = PlannerLogger(f"CuroboPlanner_{env_id}", log_level) + + # Store instance variables + self.config: CuroboPlannerCfg = config + self.n_repeat: int | None = self.config.n_repeat + self.step_size: float | None = self.config.motion_step_size + self.visualize_plan: bool = self.config.visualize_plan + self.visualize_spheres: bool = self.config.visualize_spheres + + # Log the config parameter values + self.logger.info(f"Config parameter values: {self.config}") + + # Initialize plan visualizer if enabled + if self.visualize_plan: + from isaaclab_mimic.motion_planners.curobo.plan_visualizer import PlanVisualizer + + # Use env-local base translation for multi-env rendering consistency + env_origin = self.env.scene.env_origins[env_id, :3] + base_translation = (self.robot.data.root_pos_w[env_id, :3] - env_origin).detach().cpu().numpy() + self.plan_visualizer = PlanVisualizer( + robot_name=self.config.robot_name, + recording_id=f"curobo_plan_{env_id}", + debug=config.debug_planner, + base_translation=base_translation, + ) + + # Store attached objects as Attachment objects + self.attached_objects: dict[str, Attachment] = {} # object_name -> Attachment + + # Initialize cuRobo components - FORCE CUDA DEVICE FOR ISOLATION + setup_curobo_logger("warn") + + # Force cuRobo to always use CUDA device regardless of Isaac Lab device + # This isolates the motion planner from Isaac Lab's device configuration + self.tensor_args: TensorDeviceType + if torch.cuda.is_available(): + idx = self.config.cuda_device if self.config.cuda_device is not None else torch.cuda.current_device() + self.tensor_args = TensorDeviceType(device=torch.device(f"cuda:{idx}"), dtype=torch.float32) + self.logger.debug(f"cuRobo motion planner initialized on CUDA device {idx}") + else: + # Fallback to CPU if CUDA not available, but this may cause issues + self.tensor_args = TensorDeviceType() + self.logger.warning("CUDA not available, cuRobo using CPU - this may cause device compatibility issues") + + # Load robot configuration + if self.config.robot_config_file is None: + raise ValueError("robot_config_file is required") + robot_cfg_file = self.config.robot_config_file + robot_cfg: dict[str, Any] = load_yaml(robot_cfg_file)["robot_cfg"] + self.logger.info(f"Loaded robot configuration from {robot_cfg_file}") + + # Configure collision spheres + if self.config.collision_spheres_file: + robot_cfg["kinematics"]["collision_spheres"] = self.config.collision_spheres_file + + # Configure extra collision spheres + if self.config.extra_collision_spheres: + robot_cfg["kinematics"]["extra_collision_spheres"] = self.config.extra_collision_spheres + + self.robot_cfg: dict[str, Any] = robot_cfg + + # Load world configuration using the config's method + world_cfg: WorldConfig = self.config.get_world_config() + + # Create motion generator config with parameters from configuration + motion_gen_config: MotionGenConfig = MotionGenConfig.load_from_robot_config( + robot_cfg, + world_cfg, + tensor_args=self.tensor_args, + collision_checker_type=self.config.collision_checker_type, + num_trajopt_seeds=self.config.num_trajopt_seeds, + num_graph_seeds=self.config.num_graph_seeds, + interpolation_dt=self.config.interpolation_dt, + collision_cache=self.config.collision_cache_size, + trajopt_tsteps=self.config.trajopt_tsteps, + collision_activation_distance=self.config.collision_activation_distance, + position_threshold=self.config.position_threshold, + rotation_threshold=self.config.rotation_threshold, + ) + + # Create motion generator + self.motion_gen: MotionGen = MotionGen(motion_gen_config) + + # Set motion generator reference for plan visualizer if enabled + if self.visualize_plan: + self.plan_visualizer.set_motion_generator_reference(self.motion_gen) + + # Create plan config with parameters from configuration + self.plan_config: MotionGenPlanConfig = MotionGenPlanConfig( + enable_graph=self.config.enable_graph, + enable_graph_attempt=self.config.enable_graph_attempt, + max_attempts=self.config.max_planning_attempts, + enable_finetune_trajopt=self.config.enable_finetune_trajopt, + time_dilation_factor=self.config.time_dilation_factor, + ) + + # Create USD helper + self.usd_helper: UsdHelper = UsdHelper() + self.usd_helper.load_stage(env.scene.stage) + + # Initialize planning state + self._current_plan: JointState | None = None + self._plan_index: int = 0 + + # Initialize visualization state + self.frame_counter: int = 0 + self.spheres: list[tuple[str, float]] | None = None + self.sphere_update_freq: int = self.config.sphere_update_freq + + # Warm up planner + self.logger.info("Warming up motion planner...") + self.motion_gen.warmup(enable_graph=True, warmup_js_trajopt=False) + + # Read static world geometry once + self._initialize_static_world() + + # Defer object validation baseline until first update_world() call when scene is fully loaded + self._expected_objects: set[str] | None = None + + # Define supported cuRobo primitive types for object discovery and pose synchronization + self.primitive_types: list[str] = ["mesh", "cuboid", "sphere", "capsule", "cylinder", "voxel", "blox"] + + # Cache object mappings + # Only recompute when objects are added/removed, not when poses change + self._cached_object_mappings: dict[str, str] | None = None + + # ===================================================================================== + # DEVICE CONVERSION UTILITIES + # ===================================================================================== + + def _to_curobo_device(self, tensor: torch.Tensor) -> torch.Tensor: + """Convert tensor to cuRobo device for isolated device management. + + Ensures all tensors used by cuRobo are on CUDA device, providing device isolation + from Isaac Lab's potentially different device configuration. This prevents device + mismatch errors and optimizes cuRobo performance. + + Args: + tensor: Input tensor (may be on any device) + + Returns: + Tensor converted to cuRobo's CUDA device with appropriate dtype + """ + return tensor.to(device=self.tensor_args.device, dtype=self.tensor_args.dtype) + + def _to_env_device(self, tensor: torch.Tensor) -> torch.Tensor: + """Convert tensor back to environment device for Isaac Lab compatibility. + + Converts cuRobo tensors back to the environment's device to ensure compatibility + with Isaac Lab operations that expect tensors on the environment's configured device. + + Args: + tensor: Input tensor from cuRobo operations (typically on CUDA) + + Returns: + Tensor converted to environment's device while preserving dtype + """ + return tensor.to(device=self.env.device, dtype=tensor.dtype) + + # ===================================================================================== + # INITIALIZATION AND CONFIGURATION + # ===================================================================================== + + def _initialize_static_world(self) -> None: + """Initialize static world geometry from USD stage. + + Reads static environment geometry once during planner initialization to establish + the base collision world. This includes walls, tables, bins, and other fixed obstacles + that don't change during the simulation. Dynamic objects are synchronized separately + in update_world() to maintain performance. + """ + env_prim_path = f"/World/envs/env_{self.env_id}" + robot_prim_path = self.config.robot_prim_path or f"{env_prim_path}/Robot" + + ignore_list = self.config.world_ignore_substrings or [ + f"{env_prim_path}/Robot", + f"{env_prim_path}/target", + "/World/defaultGroundPlane", + "/curobo", + ] + + self._static_world_config = self.usd_helper.get_obstacles_from_stage( + only_paths=[env_prim_path], + reference_prim_path=robot_prim_path, + ignore_substring=ignore_list, + ) + self._static_world_config = self._static_world_config.get_collision_check_world() + + # Initialize cuRobo world with static geometry + self.motion_gen.update_world(self._static_world_config) + + # ===================================================================================== + # PROPERTIES AND BASIC GETTERS + # ===================================================================================== + + @property + def attached_link(self) -> str: + """Default link name for object attachment operations.""" + return self.config.attached_object_link_name + + @property + def attachment_links(self) -> set[str]: + """Set of parent link names that currently have attached objects.""" + return {attachment.parent for attachment in self.attached_objects.values()} + + @property + def current_plan(self) -> JointState | None: + """Current plan from cuRobo motion generator.""" + return self._current_plan + + # ===================================================================================== + # WORLD AND OBJECT MANAGEMENT, ATTACHMENT, AND DETACHMENT + # ===================================================================================== + + def get_object_pose(self, object_name: str) -> Pose | None: + """Retrieve object pose from cuRobo's collision world model. + + Searches the collision world model for the specified object and returns its current + pose. This is useful for attachment calculations and debugging collision world state. + The method handles both mesh and cuboid object types automatically. + + Args: + object_name: Short object name used in Isaac Lab scene (e.g., "cube_1") + + Returns: + Object pose in cuRobo coordinate frame, or None if object not found + """ + # Get cached object mappings + object_mappings = self._get_object_mappings() + world_model = self.motion_gen.world_coll_checker.world_model + + object_path = object_mappings.get(object_name) + if not object_path: + self.logger.debug(f"Object {object_name} not found in world model") + return None + + # Search for object in world model + for obj_list, _ in [ + (world_model.mesh, "mesh"), + (world_model.cuboid, "cuboid"), + ]: + if not obj_list: + continue + + for obj in obj_list: + if obj.name and object_path in str(obj.name): + if obj.pose is not None: + return Pose.from_list(obj.pose, tensor_args=self.tensor_args) + + self.logger.debug(f"Object {object_name} found in mappings but pose not available") + return None + + def get_attached_pose(self, link_name: str, joint_state: JointState | None = None) -> Pose: + """Calculate pose of specified link using forward kinematics. + + Computes the world pose of any robot link at the given joint configuration. + This is essential for attachment calculations where we need to know the exact + pose of the parent link to compute relative object positions. + + Args: + link_name: Name of the robot link to get pose for + joint_state: Joint configuration to use for calculation, uses current state if None + + Returns: + World pose of the specified link in cuRobo coordinate frame + + Raises: + KeyError: If link_name is not found in the computed link poses + """ + if joint_state is None: + joint_state = self._get_current_joint_state_for_curobo() + + # Get all link states using the robot model + link_state = self.motion_gen.kinematics.get_state( + q=joint_state.position.detach().clone().to(device=self.tensor_args.device, dtype=self.tensor_args.dtype), + calculate_jacobian=False, + ) + + # Extract all link poses + link_poses = {} + if link_state.links_position is not None and link_state.links_quaternion is not None: + for i, link in enumerate(link_state.link_names): + link_poses[link] = self._make_pose( + position=link_state.links_position[..., i, :], + quaternion=link_state.links_quaternion[..., i, :], + name=link, + ) + + # For attached object link, use ee_link from robot config as parent + if link_name == self.config.attached_object_link_name: + ee_link = self.config.ee_link_name or self.robot_cfg["kinematics"]["ee_link"] + if ee_link in link_poses: + self.logger.debug(f"Using {ee_link} for {link_name}") + return link_poses[ee_link] + + # Return directly for other links + if link_name in link_poses: + return link_poses[link_name] + raise KeyError(f"Link {link_name} not found in computed link poses") + + def create_attachment( + self, object_name: str, link_name: str | None = None, joint_state: JointState | None = None + ) -> Attachment: + """Create attachment relationship between object and robot link. + + Computes the relative pose between an object and a robot link to enable the robot + to carry the object consistently during motion planning. The attachment stores the transform + from the parent link frame to the object frame, which remains constant while grasped. + + Args: + object_name: Name of the object to attach + link_name: Parent link for attachment, uses default attached_object_link if None + joint_state: Robot configuration for calculation, uses current state if None + + Returns: + Attachment object containing relative pose and parent link information + """ + if link_name is None: + link_name = self.attached_link + if joint_state is None: + joint_state = self._get_current_joint_state_for_curobo() + + # Get current link pose + link_pose = self.get_attached_pose(link_name, joint_state) + self.logger.info(f"Getting object pose for {object_name}") + obj_pose = self.get_object_pose(object_name) + + # Compute relative pose + attach_pose = link_pose.inverse().multiply(obj_pose) + + self.logger.debug(f"Creating attachment for {object_name} to {link_name}") + self.logger.debug(f"Link pose: {link_pose.position}") + self.logger.debug(f"Object pose (ACTUAL): {obj_pose.position}") + self.logger.debug(f"Computed relative pose: {attach_pose.position}") + + return Attachment(attach_pose, link_name) + + def update_world(self) -> None: + """Synchronize collision world with current Isaac Lab scene state. + + Updates all dynamic object poses in cuRobo's collision world to match their current + positions in Isaac Lab. This ensures collision checking uses accurate object positions + after simulation steps, resets, or manual object movements. Static world geometry + is loaded once during initialization and not updated here for performance. + + The method validates that the set of objects hasn't changed at runtime, as cuRobo + requires world model reinitialization when objects are added or removed. + + Raises: + RuntimeError: If the set of objects has changed at runtime + """ + + # Establish validation baseline on first call, validate on subsequent calls + if self._expected_objects is None: + self._expected_objects = set(self._get_world_object_names()) + self.logger.debug(f"Established object validation baseline: {len(self._expected_objects)} objects") + else: + # Subsequent calls: validate no changes + current_objects = set(self._get_world_object_names()) + if current_objects != self._expected_objects: + added = current_objects - self._expected_objects + removed = self._expected_objects - current_objects + + error_msg = "World objects changed at runtime!\n" + if added: + error_msg += f"Added: {added}\n" + if removed: + error_msg += f"Removed: {removed}\n" + error_msg += "cuRobo world model must be reinitialized." + + # Invalidate cached mappings since object set changed + self._cached_object_mappings = None + + raise RuntimeError(error_msg) + + # Sync object poses with Isaac Lab + self._sync_object_poses_with_isaaclab() + + if self.visualize_spheres: + self._update_sphere_visualization(force_update=True) + + if torch.cuda.is_available(): + torch.cuda.synchronize() + + def _get_world_object_names(self) -> list[str]: + """Extract all object names from cuRobo's collision world model. + + Iterates through all supported primitive types (mesh, cuboid, sphere, etc.) in the + collision world and collects their names. This is used for world validation to detect + when objects are added or removed at runtime. + + Returns: + List of all object names currently in the collision world model + """ + try: + world_model = self.motion_gen.world_coll_checker.world_model + + # Handle case where world_model might be a list + if isinstance(world_model, list): + if len(world_model) <= self.env_id: + return [] + world_model = world_model[self.env_id] + + object_names = [] + + # Get all primitive object names using the defined primitive types + for primitive_type in self.primitive_types: + if hasattr(world_model, primitive_type) and getattr(world_model, primitive_type): + primitive_list = getattr(world_model, primitive_type) + for primitive in primitive_list: + if primitive.name: + object_names.append(str(primitive.name)) + + return object_names + + except Exception as e: + self.logger.debug(f"ERROR getting world object names: {e}") + return [] + + def _sync_object_poses_with_isaaclab(self) -> None: + """Synchronize cuRobo collision world with Isaac Lab object positions. + + Updates all dynamic object poses in cuRobo's world model to match their current + positions in Isaac Lab. This ensures accurate collision checking after simulation + steps or manual object movements. Static objects (bins, tables, walls) are skipped + for performance as they shouldn't move during simulation. + + The method updates both the world model and the collision checker to ensure + consistency across all cuRobo components. + """ + # Get cached object mappings and world model + object_mappings = self._get_object_mappings() + world_model = self.motion_gen.world_coll_checker.world_model + rigid_objects = self.env.scene.rigid_objects + + updated_count = 0 + + for object_name, object_path in object_mappings.items(): + if object_name not in rigid_objects: + continue + + # Skip static mesh objects - they should not be dynamically updated + static_objects = getattr(self.config, "static_objects", []) + if any(static_name in object_name.lower() for static_name in static_objects): + self.logger.debug(f"SYNC: Skipping static object {object_name}") + continue + + # Get current pose from Lab (may be on CPU or CUDA depending on --device flag) + obj = rigid_objects[object_name] + env_origin = self.env.scene.env_origins[self.env_id] + current_pos_raw = obj.data.root_pos_w[self.env_id] - env_origin + current_quat_raw = obj.data.root_quat_w[self.env_id] # (w, x, y, z) + + # Convert to cuRobo device and extract float values for pose list + current_pos = self._to_curobo_device(current_pos_raw) + current_quat = self._to_curobo_device(current_quat_raw) + + # Convert to cuRobo pose format [x, y, z, w, x, y, z] + pose_list = [ + float(current_pos[0].item()), + float(current_pos[1].item()), + float(current_pos[2].item()), + float(current_quat[0].item()), + float(current_quat[1].item()), + float(current_quat[2].item()), + float(current_quat[3].item()), + ] + + # Update object pose in cuRobo's world model + if self._update_object_in_world_model(world_model, object_name, object_path, pose_list): + updated_count += 1 + + self.logger.debug(f"SYNC: Updated {updated_count} object poses in cuRobo world model") + + # Sync object poses with collision checker + if updated_count > 0: + # Update individual obstacle poses in collision checker + # This preserves static mesh objects unlike load_collision_model which rebuilds everything + for object_name, object_path in object_mappings.items(): + if object_name not in rigid_objects: + continue + + # Skip static mesh objects - they should not be dynamically updated + static_objects = getattr(self.config, "static_objects", []) + if any(static_name in object_name.lower() for static_name in static_objects): + continue + + # Get current pose and update in collision checker + obj = rigid_objects[object_name] + env_origin = self.env.scene.env_origins[self.env_id] + current_pos_raw = obj.data.root_pos_w[self.env_id] - env_origin + current_quat_raw = obj.data.root_quat_w[self.env_id] + + current_pos = self._to_curobo_device(current_pos_raw) + current_quat = self._to_curobo_device(current_quat_raw) + + # Create cuRobo pose and update collision checker directly + curobo_pose = self._make_pose(position=current_pos, quaternion=current_quat) + self.motion_gen.world_coll_checker.update_obstacle_pose( # type: ignore + object_path, curobo_pose, update_cpu_reference=True + ) + + self.logger.debug(f"Updated {updated_count} object poses in collision checker") + + def _get_object_mappings(self) -> dict[str, str]: + """Get object mappings with caching for performance optimization. + + Returns cached mappings if available, otherwise computes and caches them. + Cache is invalidated when the object set changes. + + Returns: + Dictionary mapping Isaac Lab object names to their corresponding USD paths + """ + if self._cached_object_mappings is None: + world_model = self.motion_gen.world_coll_checker.world_model + rigid_objects = self.env.scene.rigid_objects + self._cached_object_mappings = self._discover_object_mappings(world_model, rigid_objects) + self.logger.debug(f"Computed and cached object mappings: {len(self._cached_object_mappings)} objects") + + return self._cached_object_mappings + + def _discover_object_mappings(self, world_model, rigid_objects) -> dict[str, str]: + """Build mapping between Isaac Lab object names and cuRobo world paths. + + Automatically discovers the correspondence between Isaac Lab's rigid object names + and their full USD paths in cuRobo's world model. This mapping is essential for + pose synchronization and attachment operations, as cuRobo uses full USD paths + while Isaac Lab uses short object names. + + Args: + world_model: cuRobo's collision world model containing primitive objects + rigid_objects: Isaac Lab's rigid objects dictionary + + Returns: + Dictionary mapping Isaac Lab object names to their corresponding USD paths + """ + mappings = {} + env_prefix = f"/World/envs/env_{self.env_id}/" + world_object_paths = [] + + # Collect all primitive objects from cuRobo world model + for primitive_type in self.primitive_types: + primitive_list = getattr(world_model, primitive_type) + for primitive in primitive_list: + if primitive.name and env_prefix in str(primitive.name): + world_object_paths.append(str(primitive.name)) + + # Match Isaac Lab object names to world paths + for object_name in rigid_objects.keys(): + # Direct name matching + for path in world_object_paths: + if object_name.lower().replace("_", "") in path.lower().replace("_", ""): + mappings[object_name] = path + self.logger.debug(f"MAPPING: {object_name} -> {path}") + break + else: + self.logger.debug(f"WARNING: Could not find world path for {object_name}") + + return mappings + + def _update_object_in_world_model( + self, world_model, object_name: str, object_path: str, pose_list: list[float] + ) -> bool: + """Update a single object's pose in cuRobo's collision world model. + + Searches through all primitive types in the world model to find the specified object + and updates its pose. Uses flexible matching to handle variations in path naming + between Isaac Lab and cuRobo representations. + + Args: + world_model: cuRobo's collision world model + object_name: Short object name from Isaac Lab (e.g., "cube_1") + object_path: Full USD path for the object in cuRobo world + pose_list: New pose as [x, y, z, w, x, y, z] list in cuRobo format + + Returns: + True if object was found and successfully updated, False otherwise + """ + # Handle case where world_model might be a list + if isinstance(world_model, list): + if len(world_model) > self.env_id: + world_model = world_model[self.env_id] + else: + return False + + # Update all primitive types + for primitive_type in self.primitive_types: + primitive_list = getattr(world_model, primitive_type) + for primitive in primitive_list: + if primitive.name: + primitive_name = str(primitive.name) + # Use bidirectional matching for robust path matching + if object_path == primitive_name or object_path in primitive_name or primitive_name in object_path: + primitive.pose = pose_list + self.logger.debug(f"Updated {primitive_type} {object_name} pose") + return True + + self.logger.debug(f"WARNING: Object {object_name} not found in world model") + return False + + def _attach_object(self, object_name: str, object_path: str, env_id: int) -> bool: + """Attach an object to the robot for manipulation planning. + + Establishes an attachment between the specified object and the robot's end-effector + or configured attachment link. This enables the robot to carry the object during + motion planning while maintaining proper collision checking. The object's collision + geometry is disabled in the world model since it's now part of the robot. + + Args: + object_name: Short name of the object to attach (e.g., "cube_2") + object_path: Full USD path for the object in cuRobo world model + env_id: Environment ID for multi-environment support + + Returns: + True if attachment succeeded, False if attachment failed + """ + current_joint_state = self._get_current_joint_state_for_curobo() + + self.logger.debug(f"Attaching {object_name} at path {object_path}") + + # Create attachment record (relative pose object-frame to parent link) + attachment = self.create_attachment( + object_name, + self.config.attached_object_link_name, + current_joint_state, + ) + self.attached_objects[object_name] = attachment + success = self.motion_gen.attach_objects_to_robot( + joint_state=current_joint_state, + object_names=[object_path], + link_name=self.config.attached_object_link_name, + surface_sphere_radius=self.config.surface_sphere_radius, + sphere_fit_type=SphereFitType.SAMPLE_SURFACE, + world_objects_pose_offset=None, + ) + + if success: + self.logger.debug(f"Successfully attached {object_name}") + self.logger.debug(f"Current attached objects: {list(self.attached_objects.keys())}") + + # Force sphere visualization update + if self.visualize_spheres: + self._update_sphere_visualization(force_update=True) + + self.logger.info(f"Sphere count after attach is successful: {self._count_active_spheres()}") + + # Deactivate the original obstacle as it's now carried by the robot + self.motion_gen.world_coll_checker.enable_obstacle(object_path, enable=False) + + return True + else: + self.logger.error(f"cuRobo attach_objects_to_robot failed for {object_name}") + # Clean up on failure + if object_name in self.attached_objects: + del self.attached_objects[object_name] + return False + + def _detach_objects(self, link_names: set[str] | None = None) -> bool: + """Detach objects from robot and restore collision checking. + + Removes object attachments from specified links and re-enables collision checking + for both the objects and the parent links. This is necessary when placing objects + or changing grasps. All attached objects are detached if no specific links are provided. + + Args: + link_names: Set of parent link names to detach objects from, detaches all if None + + Returns: + True if detachment operations completed successfully, False otherwise + """ + if link_names is None: + link_names = self.attachment_links + + self.logger.debug(f"Detaching objects from links: {link_names}") + self.logger.debug(f"Current attached objects: {list(self.attached_objects.keys())}") + + # Get cached object mappings to find the USD path for re-enabling + object_mappings = self._get_object_mappings() + + detached_info = [] + detached_links = set() + for object_name, attachment in list(self.attached_objects.items()): + if attachment.parent not in link_names: + continue + + # Find object path and re-enable it in the world + object_path = object_mappings.get(object_name) + if object_path: + self.motion_gen.world_coll_checker.enable_obstacle(object_path, enable=True) # type: ignore + self.logger.debug(f"Re-enabled obstacle {object_path}") + + # Collect the link that will need re-enabling + detached_links.add(attachment.parent) + + # Remove from attached objects and log info + del self.attached_objects[object_name] + detached_info.append((object_name, attachment.parent)) + + if detached_info: + for obj_name, parent_link in detached_info: + self.logger.debug(f"Detached {obj_name} from {parent_link}") + + # Re-enable collision checking for the attachment links (following the planning pattern) + if detached_links: + self._set_active_links(list(detached_links), active=True) + self.logger.debug(f"Re-enabled collision for attachment links: {detached_links}") + + # Call cuRobo's detach for each link + for link_name in link_names: + self.motion_gen.detach_object_from_robot(link_name=link_name) + self.logger.debug(f"Called cuRobo detach for link {link_name}") + + return True + + def get_attached_objects(self) -> list[str]: + """Get list of currently attached object names. + + Returns the short names of all objects currently attached to the robot. + These names correspond to Isaac Lab scene object names, not full USD paths. + + Returns: + List of attached object names (e.g., ["cube_1", "cube_2"])""" + return list(self.attached_objects.keys()) + + def has_attached_objects(self) -> bool: + """Check if any objects are currently attached to the robot. + + Useful for determining gripper state and collision checking configuration + before planning motions. + + Returns: + True if one or more objects are attached, False if no attachments exist + """ + return len(self.attached_objects) != 0 + + # ===================================================================================== + # JOINT STATE AND KINEMATICS + # ===================================================================================== + + def _get_current_joint_state_for_curobo(self) -> JointState: + """ + Construct the current joint state for cuRobo with zero velocity and acceleration. + + This helper reads the robot's joint positions from Isaac Lab for the current environment + and pairs them with zero velocities and accelerations as required by cuRobo planning. + All tensors are moved to the cuRobo device and reordered to match the kinematic chain + used by the cuRobo motion generator. + + Returns: + JointState on the cuRobo device, ordered according to + `self.motion_gen.kinematics.joint_names`, with position from the robot + and zero velocity/acceleration. + """ + # Fetch joint position (shape: [1, num_joints]) + joint_pos_raw: torch.Tensor = self.robot.data.joint_pos[self.env_id, :].unsqueeze(0) + joint_vel_raw: torch.Tensor = torch.zeros_like(joint_pos_raw) + joint_acc_raw: torch.Tensor = torch.zeros_like(joint_pos_raw) + + # Move to cuRobo device + joint_pos: torch.Tensor = self._to_curobo_device(joint_pos_raw) + joint_vel: torch.Tensor = self._to_curobo_device(joint_vel_raw) + joint_acc: torch.Tensor = self._to_curobo_device(joint_acc_raw) + + cu_js: JointState = JointState( + position=joint_pos, + velocity=joint_vel, + acceleration=joint_acc, + joint_names=self.robot.data.joint_names, + tensor_args=self.tensor_args, + ) + return cu_js.get_ordered_joint_state(self.motion_gen.kinematics.joint_names) + + def get_ee_pose(self, joint_state: JointState) -> Pose: + """Compute end-effector pose from joint configuration. + + Uses cuRobo's forward kinematics to calculate the end-effector pose + at the specified joint configuration. Handles device conversion to ensure + compatibility with cuRobo's CUDA-based computations. + + Args: + joint_state: Robot joint configuration to compute end-effector pose from + + Returns: + End-effector pose in world coordinates + """ + # Ensure joint state is on CUDA device for cuRobo + if isinstance(joint_state.position, torch.Tensor): + cuda_position = self._to_curobo_device(joint_state.position) + else: + cuda_position = self._to_curobo_device(torch.tensor(joint_state.position)) + + # Create new joint state with CUDA tensors + cuda_joint_state = JointState( + position=cuda_position, + velocity=( + self._to_curobo_device(joint_state.velocity.detach().clone()) + if joint_state.velocity is not None + else torch.zeros_like(cuda_position) + ), + acceleration=( + self._to_curobo_device(joint_state.acceleration.detach().clone()) + if joint_state.acceleration is not None + else torch.zeros_like(cuda_position) + ), + joint_names=joint_state.joint_names, + tensor_args=self.tensor_args, + ) + + kin_state: Any = self.motion_gen.rollout_fn.compute_kinematics(cuda_joint_state) + return kin_state.ee_pose + + # ===================================================================================== + # PLANNING CORE METHODS + # ===================================================================================== + + def _make_pose( + self, + position: torch.Tensor | np.ndarray | list[float] | None = None, + quaternion: torch.Tensor | np.ndarray | list[float] | None = None, + *, + name: str | None = None, + normalize_rotation: bool = False, + ) -> Pose: + """Create a cuRobo Pose with sensible defaults and device/dtype alignment. + + Auto-populates missing fields with identity values and ensures tensors are + on the cuRobo device with the correct dtype. + + Args: + position: Optional position as Tensor/ndarray/list. Defaults to [0, 0, 0]. + quaternion: Optional quaternion as Tensor/ndarray/list (w, x, y, z). Defaults to [1, 0, 0, 0]. + name: Optional name of the link that this pose represents. + normalize_rotation: Whether to normalize the quaternion inside Pose. + + Returns: + Pose: A cuRobo Pose on the configured cuRobo device and dtype. + """ + # Defaults + if position is None: + position = torch.tensor([0.0, 0.0, 0.0], dtype=self.tensor_args.dtype, device=self.tensor_args.device) + if quaternion is None: + quaternion = torch.tensor( + [1.0, 0.0, 0.0, 0.0], dtype=self.tensor_args.dtype, device=self.tensor_args.device + ) + + # Convert to tensors if needed + if not isinstance(position, torch.Tensor): + position = torch.tensor(position, dtype=self.tensor_args.dtype, device=self.tensor_args.device) + else: + position = self._to_curobo_device(position) + + if not isinstance(quaternion, torch.Tensor): + quaternion = torch.tensor(quaternion, dtype=self.tensor_args.dtype, device=self.tensor_args.device) + else: + quaternion = self._to_curobo_device(quaternion) + + return Pose(position=position, quaternion=quaternion, name=name, normalize_rotation=normalize_rotation) + + def _set_active_links(self, links: list[str], active: bool) -> None: + """Configure collision checking for specific robot links. + + Enables or disables collision sphere checking for the specified links. + This is essential for contact scenarios where certain links (like fingers + or attachment points) need collision checking disabled to allow contact + with objects being grasped. + + Args: + links: List of link names to enable or disable collision checking for + active: True to enable collision checking, False to disable + """ + for link in links: + if active: + self.motion_gen.kinematics.kinematics_config.enable_link_spheres(link) + else: + self.motion_gen.kinematics.kinematics_config.disable_link_spheres(link) + + def plan_motion( + self, + target_pose: torch.Tensor, + step_size: float | None = None, + enable_retiming: bool | None = None, + ) -> bool: + """Plan collision-free motion to target pose. + + Plans a trajectory from the current robot configuration to the specified target pose. + The method assumes that world updates and locked joint configurations have already + been handled. Supports optional linear retiming for consistent execution speeds. + + Args: + target_pose: Target end-effector pose as 4x4 transformation matrix + step_size: Step size for linear retiming, enables retiming if provided + enable_retiming: Whether to enable linear retiming, auto-detected from step_size if None + + Returns: + True if planning succeeded and a valid trajectory was found, False otherwise + """ + if enable_retiming is None: + enable_retiming = step_size is not None + + # Ensure target pose is on cuRobo device (CUDA) for device isolation + target_pose_cuda = self._to_curobo_device(target_pose) + + target_pos: torch.Tensor + target_rot: torch.Tensor + target_pos, target_rot = PoseUtils.unmake_pose(target_pose_cuda) + target_curobo_pose: Pose = self._make_pose( + position=target_pos, + quaternion=PoseUtils.quat_from_matrix(target_rot), + ) + + start_state: JointState = self._get_current_joint_state_for_curobo() + + self.logger.debug(f"Retiming enabled: {enable_retiming}, Step size: {step_size}") + + success: bool = self._plan_to_contact( + start_state=start_state, + goal_pose=target_curobo_pose, + retreat_distance=self.config.retreat_distance, + approach_distance=self.config.approach_distance, + retime_plan=enable_retiming, + step_size=step_size, + contact=False, + ) + + # Visualize plan if enabled + if success and self.visualize_plan and self._current_plan is not None: + # Get current spheres for visualization + self._sync_object_poses_with_isaaclab() + cu_js = self._get_current_joint_state_for_curobo() + sphere_list = self.motion_gen.kinematics.get_robot_as_spheres(cu_js.position)[0] + + # Split spheres into robot and attached object spheres + robot_spheres = [] + attached_spheres = [] + robot_link_count = 0 + + # Count robot link spheres + robot_links = [ + link + for link in self.robot_cfg["kinematics"]["collision_link_names"] + if link != self.config.attached_object_link_name + ] + for link_name in robot_links: + link_spheres = self.motion_gen.kinematics.kinematics_config.get_link_spheres(link_name) + if link_spheres is not None: + robot_link_count += int(torch.sum(link_spheres[:, 3] > 0).item()) + + # Split spheres + for i, sphere in enumerate(sphere_list): + if i < robot_link_count: + robot_spheres.append(sphere) + else: + attached_spheres.append(sphere) + + # Compute end-effector positions for visualization + ee_positions_list = [] + try: + for i in range(len(self._current_plan.position)): + js: JointState = self._current_plan[i] + kin = self.motion_gen.compute_kinematics(js) + ee_pos = kin.ee_position if hasattr(kin, "ee_position") else kin.ee_pose.position + ee_positions_list.append(ee_pos.cpu().numpy().squeeze()) + + self.logger.debug( + f"Link names from kinematics: {kin.link_names if len(ee_positions_list) > 0 else 'No EE positions'}" + ) + + except Exception as e: + self.logger.debug(f"Failed to compute EE positions for visualization: {e}") + ee_positions_list = None + + try: + world_scene = WorldConfig.get_scene_graph(self.motion_gen.world_coll_checker.world_model) + except Exception: + world_scene = None + + # Visualize plan + self.plan_visualizer.visualize_plan( + plan=self._current_plan, + target_pose=target_pose, + robot_spheres=robot_spheres, + attached_spheres=attached_spheres, + ee_positions=np.array(ee_positions_list) if ee_positions_list else None, + world_scene=world_scene, + ) + + # Animate EE positions over the timeline for playback + if ee_positions_list: + self.plan_visualizer.animate_plan(np.array(ee_positions_list)) + + # Animate spheres along the path for collision visualization + self.plan_visualizer.animate_spheres_along_path( + plan=self._current_plan, + robot_spheres_at_start=robot_spheres, + attached_spheres_at_start=attached_spheres, + timeline="sphere_animation", + interpolation_steps=15, # More steps for smoother animation + ) + + return success + + def _plan_to_contact_pose( + self, + start_state: JointState, + goal_pose: Pose, + contact: bool = True, + ) -> bool: + """Plan motion with configurable collision checking for contact scenarios. + + Plans a trajectory while optionally disabling collision checking for hand links and + attached objects. This is crucial for grasping and placing operations where contact + is expected and collision checking would prevent successful planning. + + Args: + start_state: Starting joint configuration for planning + goal_pose: Target pose to reach in cuRobo coordinate frame + contact: True to disable hand/attached object collisions for contact planning + retime_plan: Whether to apply linear retiming to the resulting trajectory + step_size: Step size for retiming if retime_plan is True + + Returns: + True if planning succeeded, False if no valid trajectory found + """ + # Use configured hand link names instead of hardcoded ones + disable_link_names: list[str] = self.config.hand_link_names.copy() + link_spheres: dict[str, torch.Tensor] = {} + + # Count spheres before planning + sphere_counts_before = self._count_active_spheres() + self.logger.debug( + f"Planning phase contact={contact}: Spheres before - Total: {sphere_counts_before['total']}, Robot:" + f" {sphere_counts_before['robot_links']}, Attached: {sphere_counts_before['attached_objects']}" + ) + + if contact: + # Store current spheres for the attached link so we can restore later + attached_links: list[str] = list(self.attachment_links) + for attached_link in attached_links: + link_spheres[attached_link] = self.motion_gen.kinematics.kinematics_config.get_link_spheres( + attached_link + ).clone() + + self.logger.debug(f"Attached link: {attached_links}") + # Disable all specified links for contact planning + self.logger.debug(f"Disable link names: {disable_link_names}") + self._set_active_links(disable_link_names + attached_links, active=False) + else: + self.logger.debug(f"Disable link names: {disable_link_names}") + + # Count spheres after link disabling + sphere_counts_after_disable = self._count_active_spheres() + self.logger.debug( + f"Planning phase contact={contact}: Spheres after disable - Total:" + f" {sphere_counts_after_disable['total']}, Robot: {sphere_counts_after_disable['robot_links']}," + f" Attached: {sphere_counts_after_disable['attached_objects']}" + ) + + planning_success = False + try: + result: Any = self.motion_gen.plan_single(start_state, goal_pose, self.plan_config) + + if result.success.item(): + if result.optimized_plan is not None and len(result.optimized_plan.position) != 0: + self._current_plan = result.optimized_plan + self.logger.debug(f"Using optimized plan with {len(self._current_plan.position)} waypoints") + else: + self._current_plan = result.get_interpolated_plan() + self.logger.debug(f"Using interpolated plan with {len(self._current_plan.position)} waypoints") + + self._current_plan = self.motion_gen.get_full_js(self._current_plan) + common_js_names: list[str] = [ + x for x in self.robot.data.joint_names if x in self._current_plan.joint_names + ] + self._current_plan = self._current_plan.get_ordered_joint_state(common_js_names) + self._plan_index = 0 + + planning_success = True + self.logger.debug(f"Contact planning succeeded with {len(self._current_plan.position)} waypoints") + else: + self.logger.debug(f"Contact planning failed: {result.status}") + + except Exception as e: + self.logger.debug(f"Error during planning: {e}") + + # Always restore sphere state after planning, regardless of success + if contact: + self._set_active_links(disable_link_names, active=True) + for attached_link, spheres in link_spheres.items(): + self.motion_gen.kinematics.kinematics_config.update_link_spheres(attached_link, spheres) + return planning_success + + def _plan_to_contact( + self, + start_state: JointState, + goal_pose: Pose, + retreat_distance: float, + approach_distance: float, + contact: bool = False, + retime_plan: bool = False, + step_size: float | None = None, + ) -> bool: + """Execute multi-phase contact planning with approach and retreat phases. + + Implements a planning strategy for manipulation tasks that require approach and contact handling. + Plans multiple trajectory segments with different collision checking configurations. + + Args: + start_state: Starting joint state for planning + goal_pose: Target pose to reach + retreat_distance: Distance to retreat before transition to contact + approach_distance: Distance to approach before final pose + contact: Whether to enable contact planning mode + retime_plan: Whether to retime the resulting plan + step_size: Step size for retiming (only used if retime_plan is True) + + Returns: + True if all planning phases succeeded, False if any phase failed + """ + self.logger.debug(f"Multi-phase planning: retreat={retreat_distance}, approach={approach_distance}") + + target_poses: list[Pose] = [] + contacts: list[bool] = [] + + if retreat_distance is not None and retreat_distance > 0: + ee_pose: Pose = self.get_ee_pose(start_state) + retreat_pose: Pose = ee_pose.multiply( + self._make_pose( + position=[0.0, 0.0, -retreat_distance], + ) + ) + target_poses.append(retreat_pose) + contacts.append(True) + contacts.append(contact) + if approach_distance is not None and approach_distance > 0: + approach_pose: Pose = goal_pose.multiply( + self._make_pose( + position=[0.0, 0.0, -approach_distance], + ) + ) + target_poses.append(approach_pose) + contacts.append(True) + + target_poses.append(goal_pose) + + current_state: JointState = start_state + full_plan: JointState | None = None + + for i, (target_pose, contact_flag) in enumerate(zip(target_poses, contacts)): + self.logger.debug( + f"Planning phase {i + 1} of {len(target_poses)}: contact={contact_flag} (collision" + f" {'disabled' if contact_flag else 'enabled'})" + ) + + success: bool = self._plan_to_contact_pose( + start_state=current_state, + goal_pose=target_pose, + contact=contact_flag, + ) + + if not success: + self.logger.debug(f"Phase {i + 1} planning failed") + return False + + if full_plan is None: + full_plan = self._current_plan + else: + full_plan = full_plan.stack(self._current_plan) + + last_waypoint: torch.Tensor = self._current_plan.position[-1] + current_state = JointState( + position=last_waypoint.unsqueeze(0), + velocity=torch.zeros_like(last_waypoint.unsqueeze(0)), + acceleration=torch.zeros_like(last_waypoint.unsqueeze(0)), + joint_names=self._current_plan.joint_names, + ) + current_state = current_state.get_ordered_joint_state(self.motion_gen.kinematics.joint_names) + + self._current_plan = full_plan + self._plan_index = 0 + + if retime_plan and step_size is not None: + original_length: int = len(self._current_plan.position) + self._current_plan = self._linearly_retime_plan(step_size=step_size, plan=self._current_plan) + self.logger.debug( + f"Retimed complete plan from {original_length} to {len(self._current_plan.position)} waypoints" + ) + + self.logger.debug(f"Multi-phase planning succeeded with {len(self._current_plan.position)} total waypoints") + + return True + + def _linearly_retime_plan( + self, + step_size: float = 0.01, + plan: JointState | None = None, + ) -> JointState | None: + """Apply linear retiming to trajectory for consistent execution speed. + + Resamples the trajectory with uniform spacing between waypoints to ensure + consistent motion speed during execution. + + Args: + step_size: Desired spacing between waypoints in joint space + plan: Trajectory to retime, uses current plan if None + + Returns: + Retimed trajectory with uniform waypoint spacing, or None if plan is invalid + """ + if plan is None: + plan = self._current_plan + + if plan is None or len(plan.position) == 0: + return plan + + path = plan.position + + if len(path) <= 1: + return plan + + deltas = path[1:] - path[:-1] + distances = torch.norm(deltas, dim=-1) + + waypoints = [path[0]] + for distance, waypoint in zip(distances, path[1:]): + if distance > 1e-6: + waypoints.append(waypoint) + + if len(waypoints) <= 1: + return plan + + waypoints = torch.stack(waypoints) + + if len(waypoints) > 1: + deltas = waypoints[1:] - waypoints[:-1] + distances = torch.norm(deltas, dim=-1) + cum_distances = torch.cat([torch.zeros(1, device=distances.device), torch.cumsum(distances, dim=0)]) + + if len(waypoints) < 2 or cum_distances[-1] < 1e-6: + return plan + + total_distance = cum_distances[-1] + num_steps = int(torch.ceil(total_distance / step_size).item()) + 1 + + # Create linearly spaced distances + sampled_distances = torch.linspace(cum_distances[0], cum_distances[-1], num_steps, device=cum_distances.device) + + # Linear interpolation + indices = torch.searchsorted(cum_distances, sampled_distances) + indices = torch.clamp(indices, 1, len(cum_distances) - 1) + + # Get interpolation weights + weights = (sampled_distances - cum_distances[indices - 1]) / ( + cum_distances[indices] - cum_distances[indices - 1] + ) + weights = weights.unsqueeze(-1) + + # Interpolate waypoints + sampled_waypoints = (1 - weights) * waypoints[indices - 1] + weights * waypoints[indices] + + self.logger.debug( + f"Retiming: {len(path)} to {len(sampled_waypoints)} waypoints, " + f"Distance: {total_distance:.3f}, Step size: {step_size}" + ) + + retimed_plan = JointState( + position=sampled_waypoints, + velocity=torch.zeros( + (len(sampled_waypoints), plan.velocity.shape[-1]), + device=plan.velocity.device, + dtype=plan.velocity.dtype, + ), + acceleration=torch.zeros( + (len(sampled_waypoints), plan.acceleration.shape[-1]), + device=plan.acceleration.device, + dtype=plan.acceleration.dtype, + ), + joint_names=plan.joint_names, + ) + + return retimed_plan + + def has_next_waypoint(self) -> bool: + """Check if more waypoints remain in the current trajectory. + + Returns: + True if there are unprocessed waypoints, False if trajectory is complete or empty + """ + return self._current_plan is not None and self._plan_index < len(self._current_plan.position) + + def get_next_waypoint_ee_pose(self) -> Pose: + """Get end-effector pose for the next waypoint in the trajectory. + + Advances the trajectory execution index and computes the end-effector pose + for the next waypoint using forward kinematics. + + Returns: + End-effector pose for the next waypoint in world coordinates + + Raises: + IndexError: If no more waypoints remain in the trajectory + """ + if not self.has_next_waypoint(): + raise IndexError("No more waypoints in the plan.") + next_joint_state: JointState = self._current_plan[self._plan_index] + self._plan_index += 1 + eef_state: CudaRobotModelState = self.motion_gen.compute_kinematics(next_joint_state) + return eef_state.ee_pose + + def reset_plan(self) -> None: + """Reset trajectory execution state. + + Clears the current trajectory and resets the execution index to zero. + This prepares the planner for a new planning operation. + """ + self._plan_index = 0 + self._current_plan = None + if self.visualize_plan and hasattr(self, "plan_visualizer"): + self.plan_visualizer.clear_visualization() + self.plan_visualizer.mark_idle() + + def get_planned_poses(self) -> list[torch.Tensor]: + """Extract all end-effector poses from current trajectory. + + Computes end-effector poses for all waypoints in the current trajectory without + affecting the execution state. Optionally repeats the final pose multiple times + if configured for stable goal reaching. + + Returns: + List of end-effector poses as 4x4 transformation matrices, with optional repetition + """ + if self._current_plan is None: + return [] + + # Save current execution state + original_plan_index = self._plan_index + + # Iterate through the plan to get all poses + planned_poses: list[torch.Tensor] = [] + self._plan_index = 0 + while self.has_next_waypoint(): + # Directly use the joint state from the plan to compute pose + # without advancing the main plan index in get_next_waypoint_ee_pose + next_joint_state: JointState = self._current_plan[self._plan_index] + self._plan_index += 1 # Manually advance index for this loop + eef_state: Any = self.motion_gen.compute_kinematics(next_joint_state) + planned_pose: Pose | None = eef_state.ee_pose + + if planned_pose is not None: + # Convert pose to environment device for compatibility + position = ( + self._to_env_device(planned_pose.position) + if isinstance(planned_pose.position, torch.Tensor) + else planned_pose.position + ) + rotation = ( + self._to_env_device(planned_pose.get_rotation()) + if isinstance(planned_pose.get_rotation(), torch.Tensor) + else planned_pose.get_rotation() + ) + planned_poses.append(PoseUtils.make_pose(position, rotation)[0]) + + # Restore the original execution state + self._plan_index = original_plan_index + + if self.n_repeat is not None and self.n_repeat > 0 and len(planned_poses) > 0: + self.logger.info(f"Repeating final pose {self.n_repeat} times") + final_pose: torch.Tensor = planned_poses[-1] + planned_poses.extend([final_pose] * self.n_repeat) + + return planned_poses + + # ===================================================================================== + # VISUALIZATION METHODS + # ===================================================================================== + + def _update_visualization_at_joint_positions(self, joint_positions: torch.Tensor) -> None: + """Update sphere visualization for the robot at specific joint positions. + + Args: + joint_positions: Joint configuration to visualize collision spheres at + """ + if not self.visualize_spheres: + return + + self.frame_counter += 1 + if self.frame_counter % self.sphere_update_freq != 0: + return + + original_joints: torch.Tensor = self.robot.data.joint_pos[self.env_id].clone() + + try: + # Ensure joint positions are on environment device for robot commands + env_joint_positions = ( + self._to_env_device(joint_positions) if joint_positions.device != self.env.device else joint_positions + ) + self.robot.set_joint_position_target(env_joint_positions.view(1, -1), env_ids=[self.env_id]) + self._update_sphere_visualization(force_update=False) + finally: + self.robot.set_joint_position_target(original_joints.unsqueeze(0), env_ids=[self.env_id]) + + def _update_sphere_visualization(self, force_update: bool = True) -> None: + """Update visual representation of robot collision spheres in USD stage. + + Creates or updates sphere primitives in the USD stage to show the robot's + collision model. Different colors are used for robot links (green) and + attached objects (orange) to help distinguish collision boundaries. + + Args: + force_update: True to recreate all spheres, False to update existing positions only + """ + # Get current sphere data + cu_js = self._get_current_joint_state_for_curobo() + sphere_position = self._to_curobo_device( + cu_js.position if isinstance(cu_js.position, torch.Tensor) else torch.tensor(cu_js.position) + ) + sphere_list = self.motion_gen.kinematics.get_robot_as_spheres(sphere_position)[0] + robot_link_count = self._get_robot_link_sphere_count() + + # Remove existing spheres if force update or first time + if (self.spheres is None or force_update) and self.spheres is not None: + self._remove_existing_spheres() + + # Initialize sphere list if needed + if self.spheres is None or force_update: + self.spheres = [] + + # Create or update all spheres + for sphere_idx, sphere in enumerate(sphere_list): + if not self._is_valid_sphere(sphere): + continue + + sphere_config = self._create_sphere_config(sphere_idx, sphere, robot_link_count) + prim_path = f"/curobo/robot_sphere_{sphere_idx}" + + # Remove old sphere if updating + if not (self.spheres is None or force_update): + if sphere_idx < len(self.spheres) and self.usd_helper.stage.GetPrimAtPath(prim_path).IsValid(): + self.usd_helper.stage.RemovePrim(prim_path) + + # Spawn sphere + spawn_mesh_sphere(prim_path=prim_path, translation=sphere_config["position"], cfg=sphere_config["cfg"]) + + # Store reference if creating new + if self.spheres is None or force_update or sphere_idx >= len(self.spheres): + self.spheres.append((prim_path, float(sphere.radius))) + + def _get_robot_link_sphere_count(self) -> int: + """Calculate total number of collision spheres for robot links excluding attached objects. + + Iterates through all robot collision links (excluding the attached object link) and + counts the active collision spheres for each link. This count is used to determine + which spheres in the visualization represent robot links vs attached objects. + + Returns: + Total number of active collision spheres for robot links only + """ + sphere_config = self.motion_gen.kinematics.kinematics_config + robot_links = [ + link + for link in self.robot_cfg["kinematics"]["collision_link_names"] + if link != self.config.attached_object_link_name + ] + return sum( + int(torch.sum(sphere_config.get_link_spheres(link_name)[:, 3] > 0).item()) for link_name in robot_links + ) + + def _remove_existing_spheres(self) -> None: + """Remove all existing sphere visualization primitives from the USD stage. + + Iterates through all stored sphere references and removes their corresponding + USD primitives from the stage. This is used during force updates or when + recreating the sphere visualization from scratch. + """ + stage = self.usd_helper.stage + for prim_path, _ in self.spheres: + if stage.GetPrimAtPath(prim_path).IsValid(): + stage.RemovePrim(prim_path) + + def _is_valid_sphere(self, sphere) -> bool: + """Validate sphere data for visualization rendering. + + Checks if a sphere has valid position coordinates (no NaN values) and a positive + radius. Invalid spheres are skipped during visualization to prevent rendering errors. + + Args: + sphere: Sphere object containing position and radius data + + Returns: + True if sphere has valid position and positive radius, False otherwise + """ + pos_tensor = torch.tensor(sphere.position, dtype=torch.float32) + return not torch.isnan(pos_tensor).any() and sphere.radius > 0 + + def _create_sphere_config(self, sphere_idx: int, sphere, robot_link_count: int) -> dict: + """Create sphere configuration with position and visual properties for USD rendering. + + Determines sphere type (robot link vs attached object), calculates world position, + and creates the appropriate visual configuration including colors and materials. + Robot link spheres are green with lower opacity, while attached object spheres + are orange with higher opacity for better distinction. + + Args: + sphere_idx: Index of the sphere in the sphere list + sphere: Sphere object containing position and radius data + robot_link_count: Total number of robot link spheres (for type determination) + + Returns: + Dictionary containing 'position' (world coordinates) and 'cfg' (MeshSphereCfg) + """ + + is_attached = sphere_idx >= robot_link_count + color = (1.0, 0.5, 0.0) if is_attached else (0.0, 1.0, 0.0) + opacity = 0.9 if is_attached else 0.5 + + # Calculate position in world frame (do not use env_origin) + root_translation = (self.robot.data.root_pos_w[self.env_id, :3]).detach().cpu().numpy() + position = sphere.position.cpu().numpy() if hasattr(sphere.position, "cpu") else sphere.position + if not is_attached: + position = position + root_translation + + return { + "position": position, + "cfg": MeshSphereCfg( + radius=float(sphere.radius), + visual_material=PreviewSurfaceCfg(diffuse_color=color, opacity=opacity, emissive_color=color), + ), + } + + def _is_sphere_attached_object(self, sphere_index: int, sphere_config: Any) -> bool: + """Check if a sphere belongs to attached_object link. + + Args: + sphere_index: Index of the sphere to check + sphere_config: Sphere configuration object + + Returns: + True if sphere belongs to an attached object, False if it's a robot link sphere + """ + # Get total number of robot link spheres (excluding attached_object) + robot_links = [ + link + for link in self.robot_cfg["kinematics"]["collision_link_names"] + if link != self.config.attached_object_link_name + ] + + total_robot_spheres = 0 + for link_name in robot_links: + try: + link_spheres = sphere_config.get_link_spheres(link_name) + active_spheres = torch.sum(link_spheres[:, 3] > 0).item() + total_robot_spheres += int(active_spheres) + except Exception: + continue + + # If sphere_index >= total_robot_spheres, it's an attached object sphere + is_attached = sphere_index >= total_robot_spheres + + if sphere_index < 5: # Debug first few spheres + self.logger.debug( + f"SPHERE {sphere_index}: total_robot_spheres={total_robot_spheres}, is_attached={is_attached}" + ) + + return is_attached + + # ===================================================================================== + # HIGH-LEVEL PLANNING INTERFACE + # ===================================================================================== + + def update_world_and_plan_motion( + self, + target_pose: torch.Tensor, + expected_attached_object: str | None = None, + env_id: int = 0, + step_size: float | None = None, + enable_retiming: bool | None = None, + ) -> bool: + """Complete planning pipeline with world updates and object attachment handling. + + Provides a high-level interface that handles the complete planning workflow: + world synchronization, object attachment/detachment, gripper configuration, + and motion planning. + + Args: + target_pose: Target end-effector pose as 4x4 transformation matrix + expected_attached_object: Name of object that should be attached, None for no attachment + env_id: Environment ID for multi-environment setups + step_size: Step size for linear retiming if retiming is enabled + enable_retiming: Whether to enable linear retiming of trajectory + + Returns: + True if complete planning pipeline succeeded, False if any step failed + """ + # Always reset the plan before starting a new one to ensure a clean state + self.reset_plan() + + self.logger.debug("=== MOTION PLANNING DEBUG ===") + self.logger.debug(f"Expected attached object: {expected_attached_object}") + + self.update_world() + gripper_closed = expected_attached_object is not None + self._set_gripper_state(gripper_closed) + current_attached = self.get_attached_objects() + gripper_pos = self.robot.data.joint_pos[env_id, -2:] + + self.logger.debug(f"Current attached objects: {current_attached}") + + # Attach object if expected but not currently attached + if expected_attached_object and expected_attached_object not in current_attached: + self.logger.debug(f"Need to attach {expected_attached_object}") + + object_mappings = self._get_object_mappings() + + self.logger.debug(f"Object mappings found: {list(object_mappings.keys())}") + + if expected_attached_object in object_mappings: + expected_path = object_mappings[expected_attached_object] + + self.logger.debug(f"Object path: {expected_path}") + + # Debug object poses + rigid_objects = self.env.scene.rigid_objects + if expected_attached_object in rigid_objects: + obj = rigid_objects[expected_attached_object] + origin = self.env.scene.env_origins[env_id] + obj_pos = obj.data.root_pos_w[env_id] - origin + self.logger.debug(f"Isaac Lab object position: {obj_pos}") + + # Debug end-effector position + ee_frame_cfg = SceneEntityCfg("ee_frame") + ee_frame = self.env.scene[ee_frame_cfg.name] + ee_pos = ee_frame.data.target_pos_w[env_id, 0, :] - origin + self.logger.debug(f"End-effector position: {ee_pos}") + + # Debug distance + distance = torch.linalg.vector_norm(obj_pos - ee_pos).item() + self.logger.debug(f"Distance EE to object: {distance:.4f}") + + # Debug gripper state + gripper_open_val = self.config.grasp_gripper_open_val + self.logger.debug(f"Gripper positions: {gripper_pos}") + self.logger.debug(f"Gripper open val: {gripper_open_val}") + + is_grasped = self._check_object_grasped(gripper_pos, expected_attached_object) + + self.logger.debug(f"Is grasped check result: {is_grasped}") + + if is_grasped: + self._attach_object(expected_attached_object, expected_path, env_id) + self.logger.debug(f"Attached {expected_attached_object}") + else: + self.logger.debug( + "Object not detected as grasped - attachment skipped" + ) # This will cause collision with ghost object! + else: + self.logger.debug(f"Object {expected_attached_object} not found in world mappings") + + # Detach objects if no object should be attached (i.e., placing/releasing) + if expected_attached_object is None and current_attached: + self.logger.debug("Detaching all objects as no object expected to be attached") + self._detach_objects() + + self.logger.debug(f"Planning motion with attached objects: {self.get_attached_objects()}") + + plan_success = self.plan_motion(target_pose, step_size, enable_retiming) + + self.logger.debug(f"Planning result: {plan_success}") + self.logger.debug("=== END POST-GRASP DEBUG ===") + + self._detach_objects() + + return plan_success + + # ===================================================================================== + # UTILITY METHODS + # ===================================================================================== + + def _check_object_grasped(self, gripper_pos: torch.Tensor, object_name: str) -> bool: + """Check if a specific object is currently grasped by the robot. + + Uses gripper position to determine if an object is grasped. + + Args: + gripper_pos: Gripper position tensor + object_name: Name of object to check (e.g., "cube_1") + + Returns: + True if object is detected as grasped + """ + gripper_open_val = self.config.grasp_gripper_open_val + object_grasped = gripper_pos[0].item() < gripper_open_val + + self.logger.info( + f"Object {object_name} is grasped: {object_grasped}" + if object_grasped + else f"Object {object_name} is not grasped" + ) + + return object_grasped + + def _set_gripper_state(self, has_attached_objects: bool) -> None: + """Configure gripper joint positions based on object attachment status. + + Sets the gripper to closed position when objects are attached and open position + when no objects are attached. This ensures proper collision checking and planning + with the correct gripper configuration. + + Args: + has_attached_objects: True if robot currently has attached objects requiring closed gripper + """ + if has_attached_objects: + # Closed gripper for grasping + locked_joints = self.config.gripper_closed_positions + else: + # Open gripper for manipulation + locked_joints = self.config.gripper_open_positions + + self.motion_gen.update_locked_joints(locked_joints, self.robot_cfg) + + def _count_active_spheres(self) -> dict[str, int]: + """Count active collision spheres by category for debugging. + + Analyzes the current collision sphere configuration to provide detailed + statistics about robot links vs attached object spheres. This is helpful + for debugging collision checking issues and attachment problems. + + Returns: + Dictionary containing sphere counts by category (total, robot_links, attached_objects) + """ + cu_js = self._get_current_joint_state_for_curobo() + + # Ensure position tensor is on CUDA for cuRobo + if isinstance(cu_js.position, torch.Tensor): + sphere_position = self._to_curobo_device(cu_js.position) + else: + # Convert list to tensor and move to CUDA + sphere_position = self._to_curobo_device(torch.tensor(cu_js.position)) + + sphere_list = self.motion_gen.kinematics.get_robot_as_spheres(sphere_position)[0] + + # Get sphere configuration + sphere_config = self.motion_gen.kinematics.kinematics_config + + # Count robot link spheres (excluding attached_object) + robot_links = [ + link + for link in self.robot_cfg["kinematics"]["collision_link_names"] + if link != self.config.attached_object_link_name + ] + robot_sphere_count = 0 + for link_name in robot_links: + if hasattr(sphere_config, "get_link_spheres"): + link_spheres = sphere_config.get_link_spheres(link_name) + if link_spheres is not None: + active_spheres = torch.sum(link_spheres[:, 3] > 0).item() + robot_sphere_count += int(active_spheres) + + # Count attached object spheres by checking actual sphere list + attached_sphere_count = 0 + + # Handle sphere_list as either a list or single Sphere object + total_spheres = len(list(sphere_list)) + + # Any spheres beyond robot_sphere_count are attached object spheres + attached_sphere_count = max(0, total_spheres - robot_sphere_count) + + self.logger.debug( + f"SPHERE COUNT: Total={total_spheres}, Robot={robot_sphere_count},Attached={attached_sphere_count}" + ) + + return { + "total": total_spheres, + "robot_links": robot_sphere_count, + "attached_objects": attached_sphere_count, + } diff --git a/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner_cfg.py new file mode 100644 index 000000000000..6755093f7046 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/curobo_planner_cfg.py @@ -0,0 +1,459 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +import os +import tempfile +import yaml + +from curobo.geom.sdf.world import CollisionCheckerType +from curobo.geom.types import WorldConfig +from curobo.util_file import get_robot_configs_path, get_world_configs_path, join_path, load_yaml + +from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path +from isaaclab.utils.configclass import configclass + + +@configclass +class CuroboPlannerCfg: + """Configuration for CuRobo motion planner. + + This dataclass provides a flexible configuration system for the CuRobo motion planner. + The base configuration is robot-agnostic, with factory methods providing pre-configured + settings for specific robots and tasks. + + Example Usage: + >>> # Use a pre-configured robot + >>> config = CuroboPlannerCfg.franka_config() + >>> + >>> # Or create from task name + >>> config = CuroboPlannerCfg.from_task_name("Isaac-Stack-Cube-Franka-v0") + >>> + >>> # Initialize planner with config + >>> planner = CuroboPlanner(env, robot, config) + + To add support for a new robot, see the factory methods section below for detailed instructions. + """ + + # Robot configuration + robot_config_file: str | None = None + """cuRobo robot configuration file (path defined by curobo api).""" + + robot_name: str = "" + """Robot name for visualization and identification.""" + + ee_link_name: str | None = None + """End-effector link name (auto-detected from robot config if None).""" + + # Gripper configuration + gripper_joint_names: list[str] = [] + """Names of gripper joints.""" + + gripper_open_positions: dict[str, float] = {} + """Open gripper positions for cuRobo to update spheres""" + + gripper_closed_positions: dict[str, float] = {} + """Closed gripper positions for cuRobo to update spheres""" + + # Hand link configuration (for contact planning) + hand_link_names: list[str] = [] + """Names of hand/finger links to disable during contact planning.""" + + # Attachment configuration + attached_object_link_name: str = "attached_object" + """Name of the link used for attaching objects.""" + + # World configuration + world_config_file: str = "collision_table.yml" + """CuRobo world configuration file (without path).""" + + # Static objects to not update in the world model + static_objects: list[str] = [] + """Names of static objects to not update in the world model.""" + + # Optional prim path configuration + robot_prim_path: str | None = None + """Absolute USD prim path to the robot root for world extraction; None derives it from environment root.""" + + world_ignore_substrings: list[str] | None = None + """List of substring patterns to ignore when extracting world obstacles (e.g., default ground plane, debug prims).""" + + # Motion planning parameters + collision_checker_type: CollisionCheckerType = CollisionCheckerType.MESH + """Type of collision checker to use.""" + + num_trajopt_seeds: int = 12 + """Number of seeds for trajectory optimization.""" + + num_graph_seeds: int = 12 + """Number of seeds for graph search.""" + + interpolation_dt: float = 0.05 + """Time step for interpolating waypoints.""" + + collision_cache_size: dict[str, int] = {"obb": 150, "mesh": 150} + """Cache sizes for different collision types.""" + + trajopt_tsteps: int = 32 + """Number of trajectory optimization time steps.""" + + collision_activation_distance: float = 0.0 + """Distance at which collision constraints are activated.""" + + approach_distance: float = 0.05 + """Distance to approach at the end of the plan.""" + + retreat_distance: float = 0.05 + """Distance to retreat at the start of the plan.""" + + grasp_gripper_open_val: float = 0.04 + """Gripper joint value when considered open for grasp detection.""" + + # Planning configuration + enable_graph: bool = True + """Whether to enable graph-based planning.""" + + enable_graph_attempt: int = 5 + """Number of graph planning attempts.""" + + max_planning_attempts: int = 15 + """Maximum number of planning attempts.""" + + enable_finetune_trajopt: bool = True + """Whether to enable trajectory optimization fine-tuning.""" + + time_dilation_factor: float = 1.0 + """Time dilation factor for planning.""" + + surface_sphere_radius: float = 0.005 + """Radius of surface spheres for collision checking.""" + + # Debug and visualization + n_repeat: int | None = None + """Number of times to repeat final waypoint for stabilization. If None, no repetition.""" + + motion_step_size: float | None = None + """Step size (in radians) for retiming motion plans. If None, no retiming.""" + + visualize_spheres: bool = False + """Visualize robot collision spheres. Note: only works for env 0.""" + + visualize_plan: bool = False + """Visualize motion plan in Rerun. Note: only works for env 0.""" + + debug_planner: bool = False + """Enable detailed motion planning debug information.""" + + sphere_update_freq: int = 5 + """Frequency to update sphere visualization, specified in number of frames.""" + + motion_noise_scale: float = 0.0 + """Scale of Gaussian noise to add to the planned waypoints. Defaults to 0.0 (no noise).""" + + # Collision sphere configuration + collision_spheres_file: str | None = None + """Collision spheres configuration file (auto-detected if None).""" + + extra_collision_spheres: dict[str, int] = {"attached_object": 100} + """Extra collision spheres for attached objects.""" + + position_threshold: float = 0.005 + """Position threshold for motion planning.""" + + rotation_threshold: float = 0.05 + """Rotation threshold for motion planning.""" + + cuda_device: int | None = 0 + """Preferred CUDA device index; None uses torch.cuda.current_device() (respects CUDA_VISIBLE_DEVICES).""" + + def get_world_config(self) -> WorldConfig: + """Load and prepare the world configuration. + + This method can be overridden in subclasses or customized per task + to provide different world configuration setups. + + Returns: + WorldConfig: The configured world for collision checking + """ + # Default implementation: just load the world config file + world_cfg = WorldConfig.from_dict(load_yaml(join_path(get_world_configs_path(), self.world_config_file))) + return world_cfg + + def _get_world_config_with_table_adjustment(self) -> WorldConfig: + """Load world config with standard table adjustments. + + This is a helper method that implements the common pattern of adjusting + table height and combining mesh/cuboid worlds. Used by specific task configs. + + Returns: + WorldConfig: World configuration with adjusted table + """ + # Load the base world config + world_cfg_table = WorldConfig.from_dict(load_yaml(join_path(get_world_configs_path(), self.world_config_file))) + + # Adjust table height if cuboid exists and has a pose + if world_cfg_table.cuboid and len(world_cfg_table.cuboid) > 0 and world_cfg_table.cuboid[0].pose: + world_cfg_table.cuboid[0].pose[2] -= 0.02 + + # Get mesh world for additional collision objects + world_cfg_mesh = WorldConfig.from_dict( + load_yaml(join_path(get_world_configs_path(), self.world_config_file)) + ).get_mesh_world() + + # Adjust mesh configuration if it exists + if world_cfg_mesh.mesh and len(world_cfg_mesh.mesh) > 0: + mesh_obj = world_cfg_mesh.mesh[0] + if mesh_obj.name: + mesh_obj.name += "_mesh" + if mesh_obj.pose: + mesh_obj.pose[2] = -10.5 # Move mesh below scene + + # Combine cuboid and mesh worlds + world_cfg = WorldConfig(cuboid=world_cfg_table.cuboid, mesh=world_cfg_mesh.mesh) + return world_cfg + + @classmethod + def _create_temp_robot_yaml(cls, base_yaml: str, urdf_path: str) -> str: + """Create a temporary robot configuration YAML with custom URDF path. + + Args: + base_yaml: Base robot configuration file name + urdf_path: Absolute path to the URDF file + + Returns: + Path to the temporary YAML file + + Raises: + FileNotFoundError: If the URDF file doesn't exist + """ + # Validate URDF path + if not os.path.isabs(urdf_path) or not os.path.isfile(urdf_path): + raise FileNotFoundError(f"URDF must be a local file: {urdf_path}") + + # Load base configuration + robot_cfg_path = get_robot_configs_path() + base_path = join_path(robot_cfg_path, base_yaml) + data = load_yaml(base_path) + print(f"urdf_path: {urdf_path}") + # Update URDF path + data["robot_cfg"]["kinematics"]["urdf_path"] = urdf_path + + # Write to temporary file + tmp_dir = tempfile.mkdtemp(prefix="curobo_robot_cfg_") + out_path = os.path.join(tmp_dir, base_yaml) + with open(out_path, "w") as f: + yaml.safe_dump(data, f, sort_keys=False) + + return out_path + + # ===================================================================================== + # FACTORY METHODS FOR ROBOT CONFIGURATIONS + # ===================================================================================== + """ + Creating Custom Robot Configurations + ===================================== + + To create a configuration for your own robot, follow these steps: + + 1. Create a Factory Method + --------------------------- + Define a classmethod that returns a configured instance: + + .. code-block:: python + + @classmethod + def my_robot_config(cls) -> "CuroboPlannerCfg": + # Option 1: Download from Nucleus (like Franka example) + urdf_path = f"{ISAACLAB_NUCLEUS_DIR}/path/to/my_robot.urdf" + local_urdf = retrieve_file_path(urdf_path, force_download=True) + + # Option 2: Use local file directly + # local_urdf = "/absolute/path/to/my_robot.urdf" + + # Create temporary YAML with custom URDF path + robot_cfg_file = cls._create_temp_robot_yaml("my_robot.yml", local_urdf) + + return cls( + # Required: Specify robot configuration file + robot_config_file=robot_cfg_file, # Use the generated YAML with custom URDF + robot_name="my_robot", + + # Gripper configuration (if robot has grippers) + gripper_joint_names=["gripper_left", "gripper_right"], + gripper_open_positions={"gripper_left": 0.05, "gripper_right": 0.05}, + gripper_closed_positions={"gripper_left": 0.01, "gripper_right": 0.01}, + + # Hand/finger links to disable during contact planning + hand_link_names=["finger_link_1", "finger_link_2", "palm_link"], + + # Optional: Absolute USD prim path to the robot root for world extraction; None derives it from environment root. + robot_prim_path=None, + + # Optional: List of substring patterns to ignore when extracting world obstacles (e.g., default ground plane, debug prims). + # None derives it from the environment root and adds some default patterns. This is useful for environments with a lot of prims. + world_ignore_substrings=None, + + # Optional: Custom collision spheres configuration + collision_spheres_file="spheres/my_robot_spheres.yml", # Path relative to curobo (can override with custom spheres file) + + # Grasp detection threshold + grasp_gripper_open_val=0.05, + + # Motion planning parameters (tune for your robot) + approach_distance=0.05, # Distance to approach before grasping + retreat_distance=0.05, # Distance to retreat after grasping + time_dilation_factor=0.5, # Speed factor (0.5 = half speed) + + # Visualization options + visualize_spheres=False, + visualize_plan=False, + debug_planner=False, + ) + + 2. Task-Specific Configurations + -------------------------------- + For task-specific variants, create methods that modify the base config: + + .. code-block:: python + + @classmethod + def my_robot_pick_place_config(cls) -> "CuroboPlannerCfg": + config = cls.my_robot_config() # Start from base config + + # Override for pick-and-place tasks + config.approach_distance = 0.08 + config.retreat_distance = 0.10 + config.enable_finetune_trajopt = True + config.collision_activation_distance = 0.02 + + # Custom world configuration if needed + config.get_world_config = lambda: config._get_world_config_with_table_adjustment() + + return config + + 3. Register in from_task_name() + -------------------------------- + Add your robot detection logic to the from_task_name method: + + .. code-block:: python + + @classmethod + def from_task_name(cls, task_name: str) -> "CuroboPlannerCfg": + task_lower = task_name.lower() + + # Add your robot detection + if "my-robot" in task_lower: + if "pick-place" in task_lower: + return cls.my_robot_pick_place_config() + else: + return cls.my_robot_config() + + # ... existing robot checks ... + + Important Notes + --------------- + - The _create_temp_robot_yaml() helper creates a temporary YAML with your custom URDF + - If using Nucleus assets, retrieve_file_path() downloads them to a local temp directory + - The base robot YAML (e.g., "my_robot.yml") should exist in cuRobo's robot configs + + Best Practices + -------------- + 1. Start with conservative parameters (slow speed, large distances) + 2. Test with visualization enabled (visualize_plan=True) for debugging + 3. Tune collision_activation_distance based on controller precision to follow collision-free motion + 4. Adjust sphere counts in extra_collision_spheres for attached objects + 5. Use debug_planner=True when developing new configurations + """ + + @classmethod + def franka_config(cls) -> "CuroboPlannerCfg": + """Create configuration for Franka Panda robot. + + This method uses a custom URDF from Nucleus for the Franka robot. + + Returns: + CuroboPlannerCfg: Configuration for Franka robot + """ + urdf_path = f"{ISAACLAB_NUCLEUS_DIR}/Controllers/SkillGenAssets/FrankaPanda/franka_panda.urdf" + local_urdf = retrieve_file_path(urdf_path, force_download=True) + + robot_cfg_file = cls._create_temp_robot_yaml("franka.yml", local_urdf) + + return cls( + robot_config_file=robot_cfg_file, + robot_name="franka", + gripper_joint_names=["panda_finger_joint1", "panda_finger_joint2"], + gripper_open_positions={"panda_finger_joint1": 0.04, "panda_finger_joint2": 0.04}, + gripper_closed_positions={"panda_finger_joint1": 0.023, "panda_finger_joint2": 0.023}, + hand_link_names=["panda_leftfinger", "panda_rightfinger", "panda_hand"], + collision_spheres_file="spheres/franka_mesh.yml", + grasp_gripper_open_val=0.04, + approach_distance=0.0, + retreat_distance=0.0, + max_planning_attempts=1, + time_dilation_factor=0.6, + enable_finetune_trajopt=True, + n_repeat=None, + motion_step_size=None, + visualize_spheres=False, + visualize_plan=False, + debug_planner=False, + sphere_update_freq=5, + motion_noise_scale=0.02, + # World extraction tuning for Franka envs + world_ignore_substrings=["/World/defaultGroundPlane", "/curobo"], + ) + + @classmethod + def franka_stack_cube_bin_config(cls) -> "CuroboPlannerCfg": + """Create configuration for Franka stacking cube in a bin.""" + config = cls.franka_config() + config.static_objects = ["bin", "table"] + config.gripper_closed_positions = {"panda_finger_joint1": 0.024, "panda_finger_joint2": 0.024} + config.approach_distance = 0.05 + config.retreat_distance = 0.07 + config.surface_sphere_radius = 0.01 + config.debug_planner = False + config.collision_activation_distance = 0.02 + config.visualize_plan = False + config.enable_finetune_trajopt = True + config.motion_noise_scale = 0.02 + config.get_world_config = lambda: config._get_world_config_with_table_adjustment() + return config + + @classmethod + def franka_stack_cube_config(cls) -> "CuroboPlannerCfg": + """Create configuration for Franka stacking a normal cube.""" + config = cls.franka_config() + config.static_objects = ["table"] + config.visualize_plan = False + config.debug_planner = False + config.motion_noise_scale = 0.02 + config.collision_activation_distance = 0.01 + config.approach_distance = 0.05 + config.retreat_distance = 0.05 + config.surface_sphere_radius = 0.01 + config.get_world_config = lambda: config._get_world_config_with_table_adjustment() + return config + + @classmethod + def from_task_name(cls, task_name: str) -> "CuroboPlannerCfg": + """Create configuration from task name. + + Args: + task_name: Task name (e.g., "Isaac-Stack-Cube-Bin-Franka-v0") + + Returns: + CuroboPlannerCfg: Configuration for the specified task + """ + task_lower = task_name.lower() + + if "stack-cube-bin" in task_lower: + return cls.franka_stack_cube_bin_config() + elif "stack-cube" in task_lower: + return cls.franka_stack_cube_config() + else: + # Default to Franka configuration + print(f"Warning: Unknown robot in task '{task_name}', using Franka configuration") + return cls.franka_config() diff --git a/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/plan_visualizer.py b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/plan_visualizer.py new file mode 100644 index 000000000000..b9658a502894 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/curobo/plan_visualizer.py @@ -0,0 +1,938 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Utility for visualizing motion plans using Rerun. + +This module provides tools to visualize motion plans, robot poses, and collision spheres +using Rerun's visualization capabilities. It helps in debugging and validating collision-free paths. +""" + +import atexit +import numpy as np +import os +import signal +import subprocess +import threading +import time +import torch +import weakref +from typing import TYPE_CHECKING, Any, Optional + +# Check if rerun is installed +try: + import rerun as rr +except ImportError: + raise ImportError("Rerun is not installed!") + +from curobo.types.state import JointState + +import isaaclab.utils.math as PoseUtils + +# Import psutil for process management +try: + import psutil + + PSUTIL_AVAILABLE = True +except ImportError: + PSUTIL_AVAILABLE = False + print("Warning: psutil not available. Process monitoring will be limited.") + +if TYPE_CHECKING: # For type hints only + import trimesh + + +# Global registry to track all PlanVisualizer instances for cleanup +_GLOBAL_PLAN_VISUALIZERS: list["PlanVisualizer"] = [] + + +def _cleanup_all_plan_visualizers(): + """Enhanced global cleanup function with better process killing.""" + global _GLOBAL_PLAN_VISUALIZERS + + if PSUTIL_AVAILABLE: + killed_count = 0 + for proc in psutil.process_iter(["pid", "name", "cmdline"]): + # Check if it's a rerun process + if (proc.info["name"] and "rerun" in proc.info["name"].lower()) or ( + proc.info["cmdline"] and any("rerun" in str(arg).lower() for arg in proc.info["cmdline"]) + ): + proc.kill() + killed_count += 1 + + print(f"Killed {killed_count} Rerun viewer processes on script exit") + else: + # Fallback to pkill + subprocess.run(["pkill", "-f", "rerun"], stderr=subprocess.DEVNULL, check=False) + print("Used pkill fallback to close Rerun processes") + + # Also clean up individual instances + for visualizer in _GLOBAL_PLAN_VISUALIZERS[:]: + if not visualizer._closed: + visualizer.close() + + _GLOBAL_PLAN_VISUALIZERS.clear() + + +# Register global cleanup on module import +atexit.register(_cleanup_all_plan_visualizers) + + +class PlanVisualizer: + """Visualizes motion plans using Rerun. + + This class provides methods to visualize: + 1. Robot poses along a planned trajectory + 2. Attached objects and their collision spheres + 3. Robot collision spheres + 4. Target poses and waypoints + """ + + def __init__( + self, + robot_name: str = "panda", + recording_id: str | None = None, + debug: bool = False, + save_path: str | None = None, + base_translation: np.ndarray | None = None, + ): + """Initialize the plan visualizer. + + Args: + robot_name: Name of the robot for visualization + recording_id: Optional ID for the Rerun recording + debug: Whether to print debug information + save_path: Optional path to save the recording + base_translation: Optional base translation to apply to all visualized entities + """ + self.robot_name = robot_name + self.debug = debug + self.recording_id = recording_id or f"motion_plan_{robot_name}" + self.save_path = save_path + self._closed = False + # Translation offset applied to all visualized entities (for multi-env setups) + self._base_translation = ( + np.array(base_translation, dtype=float) if base_translation is not None else np.zeros(3) + ) + + # Enhanced process management + self._parent_pid = os.getpid() + self._monitor_thread = None + self._monitor_active = False + + # Motion generator reference for sphere animation (set by CuroboPlanner) + self._motion_gen_ref = None + + # Register this instance globally for cleanup + global _GLOBAL_PLAN_VISUALIZERS + _GLOBAL_PLAN_VISUALIZERS.append(self) + + # Initialize Rerun + rr.init(self.recording_id, spawn=False) + + # Spawn viewer and keep handle if provided so we can terminate it later + try: + self._rerun_process = rr.spawn() + except Exception: + # Older versions of Rerun may not return a process handle + self._rerun_process = None + + # Set up coordinate system + rr.log("world", rr.ViewCoordinates.RIGHT_HAND_Y_UP) + + # Store visualization state + self._current_frame = 0 + self._sphere_entities: dict[str, list[str]] = {"robot": [], "attached": [], "target": []} + + # Start enhanced parent process monitoring + self._start_parent_process_monitoring() + + # Use weakref.finalize for cleanup when object is garbage collected + self._finalizer = weakref.finalize( + self, self._cleanup_class_resources, self.recording_id, self.save_path, debug + ) + + # Also register atexit handler as backup for normal script termination + # Store values locally to avoid capturing self in the closure + recording_id = self.recording_id + save_path = self.save_path + debug_flag = debug + atexit.register(self._cleanup_class_resources, recording_id, save_path, debug_flag) + + # Store original signal handlers so we can restore them after cleanup + self._original_sigint_handler = signal.signal(signal.SIGINT, signal.SIG_DFL) + self._original_sigterm_handler = signal.signal(signal.SIGTERM, signal.SIG_DFL) + + # Handle Ctrl+C (SIGINT) and termination (SIGTERM) signals + def signal_handler(signum, frame): + if self.debug: + print(f"Received signal {signum}, closing Rerun viewer...") + self._cleanup_on_exit() + + # Restore original signal handler and re-raise the signal + if signum == signal.SIGINT: + signal.signal(signal.SIGINT, self._original_sigint_handler) + elif signum == signal.SIGTERM: + signal.signal(signal.SIGTERM, self._original_sigterm_handler) + os.kill(os.getpid(), signum) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + if self.debug: + print(f"Initialized Rerun visualization with recording ID: {self.recording_id}") + if np.linalg.norm(self._base_translation) > 0: + print(f"Applying translation offset: {self._base_translation}") + if PSUTIL_AVAILABLE: + print("Enhanced process monitoring enabled") + + def _start_parent_process_monitoring(self) -> None: + """Start monitoring the parent process and cleanup when it dies.""" + if not PSUTIL_AVAILABLE: + if self.debug: + print("psutil not available, skipping parent process monitoring") + return + + self._monitor_active = True + + def monitor_parent_process() -> None: + """Monitor thread function that watches the parent process.""" + if self.debug: + print(f"Starting parent process monitor for PID {self._parent_pid}") + + # Get parent process handle + parent_process = psutil.Process(self._parent_pid) + + # Monitor parent process + while self._monitor_active: + try: + if not parent_process.is_running(): + if self.debug: + print(f"Parent process {self._parent_pid} died, cleaning up Rerun...") + self._kill_rerun_processes() + break + + # Check every 2 seconds + time.sleep(2) + + except (psutil.NoSuchProcess, psutil.AccessDenied): + if self.debug: + print(f"Parent process {self._parent_pid} no longer accessible, cleaning up...") + self._kill_rerun_processes() + break + except Exception as e: + if self.debug: + print(f"Error in parent process monitor: {e}") + break + + # Start monitor thread + self._monitor_thread = threading.Thread(target=monitor_parent_process, daemon=True) + self._monitor_thread.start() + + def _kill_rerun_processes(self) -> None: + """Enhanced method to kill Rerun viewer processes using psutil.""" + try: + if PSUTIL_AVAILABLE: + killed_count = 0 + for proc in psutil.process_iter(["pid", "name", "cmdline"]): + try: + # Check if it's a rerun process + is_rerun = False + + # Check process name + if proc.info["name"] and "rerun" in proc.info["name"].lower(): + is_rerun = True + + # Check command line arguments + if proc.info["cmdline"] and any("rerun" in str(arg).lower() for arg in proc.info["cmdline"]): + is_rerun = True + + if is_rerun: + proc.kill() + killed_count += 1 + if self.debug: + print(f"Killed Rerun process {proc.info['pid']} ({proc.info['name']})") + + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + # Process already dead or inaccessible + pass + except Exception as e: + if self.debug: + print(f"Error killing process: {e}") + + if self.debug: + print(f"Killed {killed_count} Rerun processes using psutil") + + else: + # Fallback to pkill if psutil not available + result = subprocess.run(["pkill", "-f", "rerun"], stderr=subprocess.DEVNULL, check=False) + if self.debug: + print(f"Used pkill fallback (return code: {result.returncode})") + + except Exception as e: + if self.debug: + print(f"Error killing rerun processes: {e}") + + @staticmethod + def _cleanup_class_resources(recording_id: str, save_path: str | None, debug: bool) -> None: + """Static method for cleanup that doesn't hold references to the instance. + + This is called by weakref.finalize when the object is garbage collected. + """ + if debug: + print(f"Cleaning up Rerun visualization for {recording_id}") + + # Disconnect from Rerun + rr.disconnect() + + # Save to file if requested + if save_path is not None: + rr.save(save_path) + if debug: + print(f"Saved Rerun recording to {save_path}") + + # Enhanced process killing + if PSUTIL_AVAILABLE: + killed_count = 0 + for proc in psutil.process_iter(["pid", "name", "cmdline"]): + if (proc.info["name"] and "rerun" in proc.info["name"].lower()) or ( + proc.info["cmdline"] and any("rerun" in str(arg).lower() for arg in proc.info["cmdline"]) + ): + proc.kill() + killed_count += 1 + + if debug: + print(f"Killed {killed_count} Rerun processes during cleanup") + else: + subprocess.run(["pkill", "-f", "rerun"], stderr=subprocess.DEVNULL, check=False) + + if debug: + print("Cleanup completed") + + def _cleanup_on_exit(self) -> None: + """Manual cleanup method for signal handlers.""" + if not self._closed: + # Stop monitoring thread + self._monitor_active = False + + self.close() + self._kill_rerun_processes() + + def close(self) -> None: + """Close the Rerun visualization with enhanced cleanup.""" + if self._closed: + return + + # Stop parent process monitoring + self._monitor_active = False + if self._monitor_thread and self._monitor_thread.is_alive(): + # Give the thread a moment to stop gracefully + time.sleep(0.1) + + # Disconnect from Rerun (closes connections, servers, and files) + rr.disconnect() + + # Save to file if requested + if self.save_path is not None: + rr.save(self.save_path) + if self.debug: + print(f"Saved Rerun recording to {self.save_path}") + + self._closed = True + + # Terminate viewer process if we have a handle + try: + process = getattr(self, "_rerun_process", None) + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except Exception: + process.kill() + except Exception: + pass + + # Enhanced process killing + self._kill_rerun_processes() + + # Remove from global registry + global _GLOBAL_PLAN_VISUALIZERS + if self in _GLOBAL_PLAN_VISUALIZERS: + _GLOBAL_PLAN_VISUALIZERS.remove(self) + + if self.debug: + print("Closed Rerun visualization with enhanced cleanup") + + def visualize_plan( + self, + plan: JointState, + target_pose: torch.Tensor, + robot_spheres: list[Any] | None = None, + attached_spheres: list[Any] | None = None, + ee_positions: np.ndarray | None = None, + world_scene: Optional["trimesh.Scene"] = None, + ) -> None: + """Visualize a complete motion plan with all components. + + Args: + plan: Joint state trajectory to visualize + target_pose: Target end-effector pose + robot_spheres: Optional list of robot collision spheres + attached_spheres: Optional list of attached object spheres + ee_positions: Optional end-effector positions + world_scene: Optional world scene to visualize + """ + if self.debug: + robot_count = len(robot_spheres) if robot_spheres else 0 + attached_count = len(attached_spheres) if attached_spheres else 0 + offset_info = ( + f"offset={self._base_translation}" if np.linalg.norm(self._base_translation) > 0 else "no offset" + ) + print( + f"Visualizing plan: {len(plan.position)} waypoints, {robot_count} robot spheres (with offset)," + f" {attached_count} attached spheres (no offset), {offset_info}" + ) + + # Set timeline for static visualization (separate from animation) + rr.set_time("static_plan", sequence=self._current_frame) + self._current_frame += 1 + + # Clear previous visualization of dynamic entities (keep static meshes) + self._clear_visualization() + + # If a scene is supplied and not yet logged, draw it once + if world_scene is not None: + self._visualize_world_scene(world_scene) + + # Visualize target pose + self._visualize_target_pose(target_pose) + + # Visualize trajectory (end-effector positions if provided) + self._visualize_trajectory(plan, ee_positions) + + # Visualize spheres if provided + if robot_spheres: + self._visualize_robot_spheres(robot_spheres) + if attached_spheres: + self._visualize_attached_spheres(attached_spheres) + else: + # Clear any existing attached sphere visualization when no objects are attached + self._clear_attached_spheres() + + def _clear_visualization(self) -> None: + """Clear all visualization entities.""" + # Clear dynamic trajectory, target, and finger logs to avoid artifacts between visualizations + dynamic_paths = [ + "trajectory", + "target", + "anim", + ] + + for path in dynamic_paths: + rr.log(f"world/{path}", rr.Clear(recursive=True)) + + for entity_type, entities in self._sphere_entities.items(): + for entity in entities: + rr.log(f"world/{entity_type}/{entity}", rr.Clear(recursive=True)) + self._sphere_entities[entity_type] = [] + self._current_frame = 0 + + def clear_visualization(self) -> None: + """Public method to clear the visualization.""" + self._clear_visualization() + + def _visualize_target_pose(self, target_pose: torch.Tensor) -> None: + """Visualize the target end-effector pose. + + Args: + target_pose: Target pose as 4x4 transformation matrix + """ + pos, rot = PoseUtils.unmake_pose(target_pose) + + # Convert to numpy arrays + pos_np = pos.detach().cpu().numpy() if torch.is_tensor(pos) else np.array(pos) + rot_np = rot.detach().cpu().numpy() if torch.is_tensor(rot) else np.array(rot) + + # Ensure arrays are the right shape + pos_np = pos_np.reshape(-1) + rot_np = rot_np.reshape(3, 3) + + # Apply translation offset + pos_np += self._base_translation + + # Log target position + rr.log( + "world/target/position", + rr.Points3D( + positions=np.array([pos_np]), + colors=[[255, 0, 0]], # Red + radii=[0.02], + ), + ) + + # Log target orientation as coordinate frame + rr.log( + "world/target/frame", + rr.Transform3D( + translation=pos_np, + mat3x3=rot_np, + ), + ) + + def _visualize_trajectory( + self, + plan: JointState, + ee_positions: np.ndarray | None = None, + ) -> None: + """Visualize the robot trajectory. + + Args: + plan: Joint state trajectory + ee_positions: Optional end-effector positions + """ + if ee_positions is None: + raw = plan.position.detach().cpu().numpy() if torch.is_tensor(plan.position) else np.array(plan.position) + if raw.shape[1] >= 3: + positions = raw[:, :3] + else: + raise ValueError("ee_positions not provided and joint positions are not 3-D") + else: + positions = ee_positions + + # Apply translation offset + positions = positions + self._base_translation + + # Log trajectory points + rr.log( + "world/trajectory", + rr.LineStrips3D( + [positions], # single strip consisting of all waypoints + colors=[[0, 100, 255]], # Blue + radii=[0.005], + ), + static=True, + ) + + # Log keyframes + for i, pos in enumerate(positions): + rr.log( + f"world/trajectory/keyframe_{i}", + rr.Points3D( + positions=np.array([pos]), + colors=[[0, 100, 255]], # Blue + radii=[0.01], + ), + static=True, + ) + + def _visualize_robot_spheres(self, spheres: list[Any]) -> None: + """Visualize robot collision spheres. + + Args: + spheres: List of robot collision spheres + """ + self._log_spheres( + spheres=spheres, + entity_type="robot", + color=[0, 255, 100, 128], # Semi-transparent green + apply_offset=True, + ) + + def _visualize_attached_spheres(self, spheres: list[Any]) -> None: + """Visualize attached object collision spheres. + + Args: + spheres: List of attached object spheres + """ + self._log_spheres( + spheres=spheres, + entity_type="attached", + color=[255, 0, 0, 128], # Semi-transparent red + apply_offset=False, + ) + + def _clear_attached_spheres(self) -> None: + """Clear all attached object spheres.""" + for entity_id in self._sphere_entities.get("attached", []): + rr.log(f"world/attached/{entity_id}", rr.Clear(recursive=True)) + self._sphere_entities["attached"] = [] + + # --------------------------------------------------------------------- + # PRIVATE UTILITIES + # --------------------------------------------------------------------- + + def _log_spheres( + self, + spheres: list[Any], + entity_type: str, + color: list[int], + apply_offset: bool = False, + ) -> None: + """Generic helper for sphere visualization. + + Args: + spheres: List of CuRobo ``Sphere`` objects. + entity_type: Log path prefix (``robot`` or ``attached``). + color: RGBA color for the spheres. + apply_offset: Whether to add ``self._base_translation`` to sphere positions. + """ + for i, sphere in enumerate(spheres): + entity_id = f"sphere_{i}" + # Track entities so we can clear them later + self._sphere_entities.setdefault(entity_type, []).append(entity_id) + + # Convert position to numpy and optionally apply offset + pos = ( + sphere.position.detach().cpu().numpy() + if torch.is_tensor(sphere.position) + else np.array(sphere.position) + ) + if apply_offset: + pos = pos + self._base_translation + pos = pos.reshape(-1) # Ensure 1-D + + # Log sphere via Rerun + rr.log( + f"world/{entity_type}/{entity_id}", + rr.Points3D(positions=np.array([pos]), colors=[color], radii=[float(sphere.radius)]), + ) + + def _visualize_world_scene(self, scene: "trimesh.Scene") -> None: + """Log world geometry and dynamic transforms each call. + + Geometry is sent once (cached), but transforms are updated every invocation + so objects that move (cubes after randomization) appear at the correct + pose per episode/frame. + """ + import trimesh # local import to avoid hard dependency at top + + def _to_rerun_mesh(mesh: "trimesh.Trimesh") -> "rr.Mesh3D": + # Basic conversion without materials + return rr.Mesh3D( + vertex_positions=mesh.vertices, + triangle_indices=mesh.faces, + vertex_normals=mesh.vertex_normals if mesh.vertex_normals is not None else None, + ) + + if not hasattr(self, "_logged_geometry"): + self._logged_geometry = set() + + for node in scene.graph.nodes_geometry: + tform, geom_key = scene.graph.get(node) + mesh = scene.geometry.get(geom_key) + if mesh is None: + continue + rr_path = f"world/scene/{node.replace('/', '_')}" + + # Always update transform (objects may move between calls) + # NOTE: World scene objects are already in correct world coordinates, no offset needed + rr.log( + rr_path, + rr.Transform3D( + translation=tform[:3, 3], + mat3x3=tform[:3, :3], + axis_length=0.25, + ), + static=False, + ) + + # Geometry: send only once per node to avoid duplicates + if rr_path not in self._logged_geometry: + if isinstance(mesh, trimesh.Trimesh): + rr_mesh = _to_rerun_mesh(mesh) + elif isinstance(mesh, trimesh.PointCloud): + rr_mesh = rr.Points3D(positions=mesh.vertices, colors=mesh.colors) + else: + continue + + rr.log(rr_path, rr_mesh, static=True) + self._logged_geometry.add(rr_path) + + if self.debug: + print(f"Logged/updated {len(scene.graph.nodes_geometry)} world nodes in Rerun") + + def animate_plan( + self, + ee_positions: np.ndarray, + object_positions: dict[str, np.ndarray] | None = None, + timeline: str = "plan", + point_radius: float = 0.01, + ) -> None: + """Animate robot end-effector and (optionally) attached object positions over time using Rerun. + + This helper logs a single 3-D point per timestep so that Rerun can play back the + trajectory on the provided ``timeline``. It is intentionally lightweight and does + not attempt to render the full robot geometry—only key points—keeping the data + transfer to the viewer minimal. + + Args: + ee_positions: Array of shape (T, 3) with end-effector world positions. + object_positions: Mapping from object name to an array (T, 3) with that + object's world positions. Each trajectory must be at least as long as + ``ee_positions``; extra entries are ignored. + timeline: Name of the Rerun timeline used for the animation frames. + point_radius: Visual radius (in metres) of the rendered points. + """ + if ee_positions is None or len(ee_positions) == 0: + return + + # Iterate over timesteps and log a frame on the chosen timeline + for idx, pos in enumerate(ee_positions): + # Set time on the chosen timeline (creates it if needed) + rr.set_time(timeline, sequence=idx) + + # Log end-effector marker (needs offset for multi-env) + rr.log( + "world/anim/ee", + rr.Points3D( + positions=np.array([pos + self._base_translation]), + colors=[[0, 100, 255]], # Blue + radii=[point_radius], + ), + ) + + # Optionally log attached object markers + # NOTE: Object positions are already in world coordinates, no offset needed + if object_positions is not None: + for name, traj in object_positions.items(): + if idx >= len(traj): + continue + rr.log( + f"world/anim/{name}", + rr.Points3D( + positions=np.array([traj[idx]]), + colors=[[255, 128, 0]], # Orange + radii=[point_radius], + ), + ) + + def animate_spheres_along_path( + self, + plan: JointState, + robot_spheres_at_start: list[Any] | None = None, + attached_spheres_at_start: list[Any] | None = None, + timeline: str = "sphere_animation", + interpolation_steps: int = 10, + ) -> None: + """Animate robot and attached object spheres along the planned trajectory with smooth interpolation. + + This method creates a dense, interpolated trajectory and computes sphere positions + at many intermediate points to create smooth animation of the robot moving along the path. + + Args: + plan: Joint state trajectory to animate spheres along + robot_spheres_at_start: Initial robot collision spheres (for reference) + attached_spheres_at_start: Initial attached object spheres (for reference) + timeline: Name of the Rerun timeline for the animation + interpolation_steps: Number of interpolated steps between each waypoint pair + """ + if plan is None or len(plan.position) == 0: + if self.debug: + print("No plan available for sphere animation") + return + + if self.debug: + robot_count = len(robot_spheres_at_start) if robot_spheres_at_start else 0 + attached_count = len(attached_spheres_at_start) if attached_spheres_at_start else 0 + print(f"Creating smooth animation for {robot_count} robot spheres and {attached_count} attached spheres") + print( + f"Original plan: {len(plan.position)} waypoints, interpolating with {interpolation_steps} steps between" + " waypoints" + ) + + # We need access to the motion generator to compute spheres at each waypoint + if not hasattr(self, "_motion_gen_ref") or self._motion_gen_ref is None: + if self.debug: + print("Motion generator reference not available for sphere animation") + return + + motion_gen = self._motion_gen_ref + + # Validate motion generator has required attributes + if not hasattr(motion_gen, "kinematics") or motion_gen.kinematics is None: + if self.debug: + print("Motion generator kinematics not available for sphere animation") + return + + # Clear static spheres to avoid visual clutter during animation + self._hide_static_spheres_for_animation() + + # Count robot link spheres (excluding attached objects) for consistent splitting + robot_link_count = 0 + if robot_spheres_at_start: + robot_link_count = len(robot_spheres_at_start) + + # Create interpolated trajectory for smooth animation + interpolated_positions = self._create_interpolated_trajectory(plan, interpolation_steps) + + if self.debug: + print(f"Created interpolated trajectory with {len(interpolated_positions)} total frames") + + # Animate spheres along the interpolated trajectory + for frame_idx, joint_positions in enumerate(interpolated_positions): + # Set time on the animation timeline with consistent timing + rr.set_time(timeline, sequence=frame_idx) + + # Create joint state for this interpolated position + if isinstance(joint_positions, torch.Tensor): + sphere_position = joint_positions + else: + sphere_position = torch.tensor(joint_positions) + + # Ensure tensor is on the right device for CuRobo + if hasattr(motion_gen, "tensor_args") and motion_gen.tensor_args is not None: + sphere_position = motion_gen.tensor_args.to_device(sphere_position) + + # Get spheres at this configuration + try: + sphere_list = motion_gen.kinematics.get_robot_as_spheres(sphere_position)[0] + except Exception as e: + if self.debug: + print(f"Failed to compute spheres for frame {frame_idx}: {e}") + continue + + # Handle sphere_list as either a list or single Sphere object + if hasattr(sphere_list, "__iter__") and not hasattr(sphere_list, "position"): + sphere_items = list(sphere_list) + else: + sphere_items = [sphere_list] + + # Separate robot and attached object spheres with different colors + robot_sphere_positions = [] + robot_sphere_radii = [] + attached_sphere_positions = [] + attached_sphere_radii = [] + + for i, sphere in enumerate(sphere_items): + # Convert position to numpy + pos = ( + sphere.position.detach().cpu().numpy() + if torch.is_tensor(sphere.position) + else np.array(sphere.position) + ) + pos = pos.reshape(-1) + radius = float(sphere.radius) + + if i < robot_link_count: + # Robot sphere - needs base translation offset + robot_sphere_positions.append(pos + self._base_translation) + robot_sphere_radii.append(radius) + else: + # Attached object sphere - already in world coordinates + attached_sphere_positions.append(pos) + attached_sphere_radii.append(radius) + + # Log robot spheres with green color + if robot_sphere_positions: + rr.log( + "world/robot_animation", + rr.Points3D( + positions=np.array(robot_sphere_positions), + colors=[[0, 255, 100, 220]] * len(robot_sphere_positions), # Bright green + radii=robot_sphere_radii, + ), + ) + + # Log attached object spheres with orange color (or clear if no attached objects) + if attached_sphere_positions: + rr.log( + "world/attached_animation", + rr.Points3D( + positions=np.array(attached_sphere_positions), + colors=[[255, 150, 0, 220]] * len(attached_sphere_positions), # Bright orange + radii=attached_sphere_radii, + ), + ) + else: + # Clear attached object spheres when no objects are attached + rr.log("world/attached_animation", rr.Clear(recursive=True)) + + if self.debug: + print( + f"Completed smooth sphere animation with {len(interpolated_positions)} frames on timeline '{timeline}'" + ) + + def _hide_static_spheres_for_animation(self) -> None: + """Hide static sphere visualization during animation to reduce visual clutter.""" + # Clear static robot spheres + for entity_id in self._sphere_entities.get("robot", []): + rr.log(f"world/robot/{entity_id}", rr.Clear(recursive=True)) + + # Clear static attached spheres + for entity_id in self._sphere_entities.get("attached", []): + rr.log(f"world/attached/{entity_id}", rr.Clear(recursive=True)) + + if self.debug: + print("Hidden static spheres for cleaner animation view") + + def _create_interpolated_trajectory(self, plan: JointState, interpolation_steps: int) -> list[torch.Tensor]: + """Create a smooth interpolated trajectory between waypoints. + + Args: + plan: Original joint state trajectory + interpolation_steps: Number of interpolation steps between each waypoint pair + + Returns: + List of interpolated joint positions + """ + if len(plan.position) < 2: + # If only one waypoint, just return it + return [ + plan.position[0] if isinstance(plan.position[0], torch.Tensor) else torch.tensor(plan.position[0]) + ] # type: ignore + + interpolated_positions = [] + + # Convert plan positions to tensors if needed + waypoints = [] + for i in range(len(plan.position)): + pos = plan.position[i] + if isinstance(pos, torch.Tensor): + waypoints.append(pos) + else: + waypoints.append(torch.tensor(pos)) + + # Interpolate between each pair of consecutive waypoints + for i in range(len(waypoints) - 1): + start_pos = waypoints[i] + end_pos = waypoints[i + 1] + + # Create interpolation steps between start and end + for step in range(interpolation_steps): + alpha = step / interpolation_steps + interpolated_pos = start_pos * (1 - alpha) + end_pos * alpha + interpolated_positions.append(interpolated_pos) + + # Add the final waypoint + interpolated_positions.append(waypoints[-1]) + + return interpolated_positions + + def set_motion_generator_reference(self, motion_gen: Any) -> None: + """Set the motion generator reference for sphere animation. + + Args: + motion_gen: CuRobo motion generator instance + """ + self._motion_gen_ref = motion_gen + + def mark_idle(self) -> None: + """Signal that the planner is idle, clearing animations. + + This method advances the animation timelines and logs empty data to ensure that + no leftover visualizations from the previous plan are shown. It's useful for + creating a clean state between planning episodes. + """ + # Advance plan timeline and emit empty anim so latest frame is blank + rr.set_time("plan", sequence=self._current_frame) + self._current_frame += 1 + empty = np.empty((0, 3), dtype=float) + rr.log("world/anim/ee", rr.Points3D(positions=empty)) + rr.log("world/robot_animation", rr.Points3D(positions=empty)) + rr.log("world/attached_animation", rr.Points3D(positions=empty)) + + # Also advance sphere animation timeline + rr.set_time("sphere_animation", sequence=self._current_frame) + rr.log("world/robot_animation", rr.Points3D(positions=empty)) + rr.log("world/attached_animation", rr.Points3D(positions=empty)) diff --git a/source/isaaclab_mimic/isaaclab_mimic/motion_planners/motion_planner_base.py b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/motion_planner_base.py new file mode 100644 index 000000000000..783363b73300 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/motion_planners/motion_planner_base.py @@ -0,0 +1,133 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +import torch +from abc import ABC, abstractmethod +from typing import Any + +from isaaclab.assets import Articulation +from isaaclab.envs.manager_based_env import ManagerBasedEnv + + +class MotionPlannerBase(ABC): + """Abstract base class for motion planners. + + This class defines the public interface that all motion planners must implement. + It focuses on the essential functionality that users interact with, while leaving + implementation details to specific planner backends. + + The core workflow is: + 1. Initialize planner with environment and robot + 2. Call update_world_and_plan_motion() to plan to a target + 3. Execute plan using has_next_waypoint() and get_next_waypoint_ee_pose() + + Example: + >>> from isaaclab_mimic.motion_planners.curobo.curobo_planner import CuroboPlanner + >>> from isaaclab_mimic.motion_planners.curobo.curobo_planner_cfg import CuroboPlannerCfg + >>> config = CuroboPlannerCfg.franka_config() + >>> planner = CuroboPlanner(env, robot, config) + >>> success = planner.update_world_and_plan_motion(target_pose) + >>> if success: + >>> while planner.has_next_waypoint(): + >>> action = planner.get_next_waypoint_ee_pose() + >>> obs, info = env.step(action) + """ + + def __init__( + self, env: ManagerBasedEnv, robot: Articulation, env_id: int = 0, debug: bool = False, **kwargs + ) -> None: + """Initialize the motion planner. + + Args: + env: The environment instance + robot: Robot articulation to plan motions for + env_id: Environment ID (0 to num_envs-1) + debug: Whether to print detailed debugging information + **kwargs: Additional planner-specific arguments + """ + self.env = env + self.robot = robot + self.env_id = env_id + self.debug = debug + + @abstractmethod + def update_world_and_plan_motion(self, target_pose: torch.Tensor, **kwargs: Any) -> bool: + """Update collision world and plan motion to target pose. + + This is the main entry point for motion planning. It should: + 1. Update the planner's internal world representation + 2. Plan a collision-free path to the target pose + 3. Store the plan internally for execution + + Args: + target_pose: Target pose to plan motion to (4x4 transformation matrix) + **kwargs: Planner-specific arguments (e.g., retiming, contact planning) + + Returns: + bool: True if planning succeeded, False otherwise + """ + raise NotImplementedError + + @abstractmethod + def has_next_waypoint(self) -> bool: + """Check if there are more waypoints in current plan. + + Returns: + bool: True if there are more waypoints, False otherwise + """ + raise NotImplementedError + + @abstractmethod + def get_next_waypoint_ee_pose(self) -> Any: + """Get next waypoint's end-effector pose from current plan. + + This method should only be called after checking has_next_waypoint(). + + Returns: + Any: End-effector pose for the next waypoint in the plan. + """ + raise NotImplementedError + + def get_planned_poses(self) -> list[Any]: + """Get all planned poses from current plan. + + Returns: + list[Any]: List of planned poses. + + Note: + Default implementation iterates through waypoints. + Child classes can override for a more efficient implementation. + """ + planned_poses = [] + # Create a copy of the planner state to not affect the original plan execution + # This is a placeholder and may need to be implemented by child classes + # if they manage complex internal state. + # For now, we assume the planner can be reset and we can iterate through the plan. + # A more robust solution might involve a dedicated method to get the full plan. + self.reset_plan() + while self.has_next_waypoint(): + pose = self.get_next_waypoint_ee_pose() + planned_poses.append(pose) + return planned_poses + + @abstractmethod + def reset_plan(self) -> None: + """Reset the current plan and execution state. + + This should clear any stored plan and reset the execution index or iterator. + """ + raise NotImplementedError + + def get_planner_info(self) -> dict[str, Any]: + """Get information about the planner. + + Returns: + dict: Information about the planner (name, version, capabilities, etc.) + """ + return { + "name": self.__class__.__name__, + "env_id": self.env_id, + "debug": self.debug, + } diff --git a/source/isaaclab_mimic/test/test_curobo_planner_cube_stack.py b/source/isaaclab_mimic/test/test_curobo_planner_cube_stack.py new file mode 100644 index 000000000000..844db6fafd5f --- /dev/null +++ b/source/isaaclab_mimic/test/test_curobo_planner_cube_stack.py @@ -0,0 +1,248 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +# Copyright (c) 2024-2025, The Isaac Lab Project Developers. +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import random +from typing import Any + +import pytest + +SEED: int = 42 +random.seed(SEED) + +from isaaclab.app import AppLauncher + +headless = True +app_launcher = AppLauncher(headless=headless) +simulation_app: Any = app_launcher.app + +import gymnasium as gym +import torch +from collections.abc import Generator + +import isaaclab.utils.math as math_utils +from isaaclab.assets import Articulation, RigidObject +from isaaclab.envs.manager_based_env import ManagerBasedEnv +from isaaclab.markers import FRAME_MARKER_CFG, VisualizationMarkers + +from isaaclab_mimic.envs.franka_stack_ik_rel_mimic_env_cfg import FrankaCubeStackIKRelMimicEnvCfg +from isaaclab_mimic.motion_planners.curobo.curobo_planner import CuroboPlanner +from isaaclab_mimic.motion_planners.curobo.curobo_planner_cfg import CuroboPlannerCfg + +GRIPPER_OPEN_CMD: float = 1.0 +GRIPPER_CLOSE_CMD: float = -1.0 + + +def _eef_name(env: ManagerBasedEnv) -> str: + return list(env.cfg.subtask_configs.keys())[0] + + +def _action_from_pose( + env: ManagerBasedEnv, target_pose: torch.Tensor, gripper_binary_action: float, env_id: int = 0 +) -> torch.Tensor: + eef = _eef_name(env) + play_action = env.target_eef_pose_to_action( + target_eef_pose_dict={eef: target_pose}, + gripper_action_dict={eef: torch.tensor([gripper_binary_action], device=env.device, dtype=torch.float32)}, + env_id=env_id, + ) + if play_action.dim() == 1: + play_action = play_action.unsqueeze(0) + return play_action + + +def _env_step_with_action(env: ManagerBasedEnv, action: torch.Tensor) -> None: + env.step(action) + + +def _execute_plan(env: ManagerBasedEnv, planner: CuroboPlanner, gripper_binary_action: float, env_id: int = 0) -> None: + """Execute planner's EEF planned poses using env.step with IK-relative controller actions.""" + planned_poses = planner.get_planned_poses() + if not planned_poses: + return + for pose in planned_poses: + action = _action_from_pose(env, pose, gripper_binary_action, env_id=env_id) + _env_step_with_action(env, action) + + +def _execute_gripper_action( + env: ManagerBasedEnv, robot: Articulation, gripper_binary_action: float, steps: int = 12, env_id: int = 0 +) -> None: + """Hold current EEF pose and toggle gripper for a few steps.""" + eef = _eef_name(env) + curr_pose = env.get_robot_eef_pose(eef_name=eef, env_ids=[env_id])[0] + for _ in range(steps): + action = _action_from_pose(env, curr_pose, gripper_binary_action, env_id=env_id) + _env_step_with_action(env, action) + + +DOWN_FACING_QUAT = torch.tensor([0.0, 1.0, 0.0, 0.0], dtype=torch.float32) + + +@pytest.fixture(scope="class") +def cube_stack_test_env() -> Generator[dict[str, Any], None, None]: + """Create the environment and motion planner once for the test suite and yield them.""" + random.seed(SEED) + torch.manual_seed(SEED) + + env_cfg = FrankaCubeStackIKRelMimicEnvCfg() + env_cfg.scene.num_envs = 1 + for frame in env_cfg.scene.ee_frame.target_frames: + if frame.name == "end_effector": + print(f"Setting end effector offset from {frame.offset.pos} to (0.0, 0.0, 0.0) for SkillGen parity") + frame.offset.pos = (0.0, 0.0, 0.0) + + env: ManagerBasedEnv = gym.make( + "Isaac-Stack-Cube-Franka-IK-Rel-Mimic-v0", + cfg=env_cfg, + headless=headless, + ).unwrapped + env.reset() + + robot: Articulation = env.scene["robot"] + planner_cfg = CuroboPlannerCfg.franka_stack_cube_config() + planner_cfg.visualize_plan = False + planner_cfg.visualize_spheres = False + planner_cfg.debug_planner = True + planner_cfg.retreat_distance = 0.05 + planner_cfg.approach_distance = 0.05 + planner_cfg.time_dilation_factor = 1.0 + + planner = CuroboPlanner( + env=env, + robot=robot, + config=planner_cfg, + env_id=0, + ) + + goal_pose_visualizer = None + if not headless: + marker_cfg = FRAME_MARKER_CFG.replace(prim_path="/World/Visuals/goal_pose") + marker_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) + goal_pose_visualizer = VisualizationMarkers(marker_cfg) + + yield { + "env": env, + "robot": robot, + "planner": planner, + "goal_pose_visualizer": goal_pose_visualizer, + } + + env.close() + + +class TestCubeStackPlanner: + @pytest.fixture(autouse=True) + def setup(self, cube_stack_test_env) -> None: + self.env: ManagerBasedEnv = cube_stack_test_env["env"] + self.robot: Articulation = cube_stack_test_env["robot"] + self.planner: CuroboPlanner = cube_stack_test_env["planner"] + self.goal_pose_visualizer: VisualizationMarkers | None = cube_stack_test_env["goal_pose_visualizer"] + + def _visualize_goal_pose(self, pos: torch.Tensor, quat: torch.Tensor) -> None: + """Visualize the goal frame markers at pos, quat (xyzw).""" + if headless or self.goal_pose_visualizer is None: + return + self.goal_pose_visualizer.visualize(translations=pos.unsqueeze(0), orientations=quat.unsqueeze(0)) + + def _pose_from_xy_quat(self, xy: torch.Tensor, z: float, quat: torch.Tensor) -> torch.Tensor: + """Build a 4×4 pose given xy (Tensor[2]), z, and quaternion.""" + device = xy.device + dtype = xy.dtype + pos = torch.cat([xy, torch.tensor([z], dtype=dtype, device=device)]) + rot = math_utils.matrix_from_quat(quat.to(device).unsqueeze(0))[0] + return math_utils.make_pose(pos, rot) + + def _get_cube_pos(self, cube_name: str) -> torch.Tensor: + """Return the current world position of a cube's root (x, y, z).""" + obj: RigidObject = self.env.scene[cube_name] + return obj.data.root_pos_w[0, :3].clone().detach() + + def _place_pose_over_cube(self, cube_name: str, height_offset: float) -> torch.Tensor: + """Compute a goal pose directly above the named cube using the latest pose.""" + base_pos = self._get_cube_pos(cube_name) + return self._pose_from_xy_quat(base_pos[:2], base_pos[2].item() + height_offset, DOWN_FACING_QUAT) + + def test_pick_and_stack(self) -> None: + """Plan and execute pick-and-place to stack cube_1 on cube_2, then cube_3 on the stack.""" + cube_1_pos = self._get_cube_pos("cube_1") + cube_2_pos = self._get_cube_pos("cube_2") + cube_3_pos = self._get_cube_pos("cube_3") + print(f"Cube 1 position: {cube_1_pos}") + print(f"Cube 2 position: {cube_2_pos}") + print(f"Cube 3 position: {cube_3_pos}") + + # Approach above cube_1 + pre_grasp_height = 0.1 + pre_grasp_pose = self._pose_from_xy_quat(cube_1_pos[:2], pre_grasp_height, DOWN_FACING_QUAT) + print(f"Pre-grasp pose: {pre_grasp_pose}") + if not headless: + pos_pg = pre_grasp_pose[:3, 3].detach().cpu() + quat_pg = math_utils.quat_from_matrix(pre_grasp_pose[:3, :3].unsqueeze(0))[0].detach().cpu() + self._visualize_goal_pose(pos_pg, quat_pg) + + # Plan to pre-grasp + assert self.planner.update_world_and_plan_motion(pre_grasp_pose), "Failed to plan to pre-grasp pose" + _execute_plan(self.env, self.planner, gripper_binary_action=GRIPPER_OPEN_CMD) + + # Close gripper to grasp cube_1 (hold pose while closing) + _execute_gripper_action(self.env, self.robot, GRIPPER_CLOSE_CMD, steps=16) + + # Plan placement with cube_1 attached (above latest cube_2) + place_pose = self._place_pose_over_cube("cube_2", 0.15) + + if not headless: + pos_place = place_pose[:3, 3].detach().cpu() + quat_place = math_utils.quat_from_matrix(place_pose[:3, :3].unsqueeze(0))[0].detach().cpu() + self._visualize_goal_pose(pos_place, quat_place) + + # Plan with attached object + assert self.planner.update_world_and_plan_motion( + place_pose, expected_attached_object="cube_1" + ), "Failed to plan placement trajectory with attached cube" + _execute_plan(self.env, self.planner, gripper_binary_action=GRIPPER_CLOSE_CMD) + + # Release cube 1 + _execute_gripper_action(self.env, self.robot, GRIPPER_OPEN_CMD, steps=16) + + # Go to cube 3 + cube_3_pos_now = self._get_cube_pos("cube_3") + pre_grasp_pose = self._pose_from_xy_quat(cube_3_pos_now[:2], pre_grasp_height, DOWN_FACING_QUAT) + print(f"Pre-grasp pose: {pre_grasp_pose}") + if not headless: + pos_pg = pre_grasp_pose[:3, 3].detach().cpu() + quat_pg = math_utils.quat_from_matrix(pre_grasp_pose[:3, :3].unsqueeze(0))[0].detach().cpu() + self._visualize_goal_pose(pos_pg, quat_pg) + + assert self.planner.update_world_and_plan_motion( + pre_grasp_pose, expected_attached_object=None + ), "Failed to plan retract motion" + _execute_plan(self.env, self.planner, gripper_binary_action=GRIPPER_OPEN_CMD) + + # Grasp cube 3 + _execute_gripper_action(self.env, self.robot, GRIPPER_CLOSE_CMD) + + # Plan placement with cube_3 attached, to cube 2 (use latest cube_2 pose) + place_pose = self._place_pose_over_cube("cube_2", 0.18) + + if not headless: + pos_place = place_pose[:3, 3].detach().cpu() + quat_place = math_utils.quat_from_matrix(place_pose[:3, :3].unsqueeze(0))[0].detach().cpu() + self._visualize_goal_pose(pos_place, quat_place) + + assert self.planner.update_world_and_plan_motion( + place_pose, expected_attached_object="cube_3" + ), "Failed to plan placement trajectory with attached cube" + _execute_plan(self.env, self.planner, gripper_binary_action=GRIPPER_CLOSE_CMD) + + # Release cube 3 + _execute_gripper_action(self.env, self.robot, GRIPPER_OPEN_CMD) + + print("Pick-and-place stacking test completed successfully!") diff --git a/source/isaaclab_mimic/test/test_curobo_planner_franka.py b/source/isaaclab_mimic/test/test_curobo_planner_franka.py new file mode 100644 index 000000000000..323caf99c284 --- /dev/null +++ b/source/isaaclab_mimic/test/test_curobo_planner_franka.py @@ -0,0 +1,173 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +import random +from collections.abc import Generator +from typing import Any + +import pytest + +SEED: int = 42 +random.seed(SEED) + +from isaaclab.app import AppLauncher + +headless = True +app_launcher = AppLauncher(headless=headless) +simulation_app: Any = app_launcher.app + +import gymnasium as gym +import torch + +import isaaclab.utils.assets as _al_assets +import isaaclab.utils.math as math_utils +from isaaclab.assets import Articulation, RigidObjectCfg +from isaaclab.envs.manager_based_env import ManagerBasedEnv +from isaaclab.markers import FRAME_MARKER_CFG, VisualizationMarkers +from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg +from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg + +ISAAC_NUCLEUS_DIR: str = getattr(_al_assets, "ISAAC_NUCLEUS_DIR", "/Isaac") + +from isaaclab_mimic.motion_planners.curobo.curobo_planner import CuroboPlanner +from isaaclab_mimic.motion_planners.curobo.curobo_planner_cfg import CuroboPlannerCfg + +from isaaclab_tasks.manager_based.manipulation.stack.config.franka.stack_joint_pos_env_cfg import FrankaCubeStackEnvCfg + +# Predefined EE goals for the test +# Each entry is a tuple of: (goal specification, goal ID) +predefined_ee_goals_and_ids = [ + ({"pos": [0.70, -0.25, 0.25], "quat": [0.0, 0.707, 0.0, 0.707]}, "Behind wall, left"), + ({"pos": [0.70, 0.25, 0.25], "quat": [0.0, 0.707, 0.0, 0.707]}, "Behind wall, right"), + ({"pos": [0.65, 0.0, 0.45], "quat": [0.0, 1.0, 0.0, 0.0]}, "Behind wall, center, high"), + ({"pos": [0.80, -0.15, 0.35], "quat": [0.0, 0.5, 0.0, 0.866]}, "Behind wall, far left"), + ({"pos": [0.80, 0.15, 0.35], "quat": [0.0, 0.5, 0.0, 0.866]}, "Behind wall, far right"), +] + + +@pytest.fixture(scope="class") +def curobo_test_env() -> Generator[dict[str, Any], None, None]: + """Set up the environment for the Curobo test and yield test-critical data.""" + random.seed(SEED) + torch.manual_seed(SEED) + + env_cfg = FrankaCubeStackEnvCfg() + env_cfg.scene.num_envs = 1 + + # Add a static wall for the robot to avoid + wall_props = RigidBodyPropertiesCfg(kinematic_enabled=True, disable_gravity=True) + wall_cfg = RigidObjectCfg( + prim_path="/World/envs/env_0/moving_wall", + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/red_block.usd", + scale=(0.5, 4.5, 7.0), + rigid_props=wall_props, + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.55, 0.0, 0.80)), + ) + setattr(env_cfg.scene, "moving_wall", wall_cfg) + + env: ManagerBasedEnv = gym.make("Isaac-Stack-Cube-Franka-v0", cfg=env_cfg, headless=headless).unwrapped + env.reset() + + robot = env.scene["robot"] + planner = CuroboPlanner(env=env, robot=robot, config=CuroboPlannerCfg.franka_config()) + + goal_pose_visualizer = None + if not headless: + goal_marker_cfg = FRAME_MARKER_CFG.replace(prim_path="/World/Visuals/goal_poses") + goal_marker_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) + goal_pose_visualizer = VisualizationMarkers(goal_marker_cfg) + + # Allow the simulation to settle + for _ in range(3): + env.sim.step(render=False) + + planner.update_world() + + # Default joint positions for the Franka arm (7-DOF) + home_j = torch.tensor([0.0, -0.4, 0.0, -2.1, 0.0, 2.1, 0.7]) + + # Yield the necessary objects for the test + yield { + "env": env, + "robot": robot, + "planner": planner, + "goal_pose_visualizer": goal_pose_visualizer, + "home_j": home_j, + } + + # Teardown: close the environment and simulation app + env.close() + + +class TestCuroboPlanner: + """Test suite for the Curobo motion planner, focusing on obstacle avoidance.""" + + @pytest.fixture(autouse=True) + def setup(self, curobo_test_env) -> None: + """Inject the test environment into the test class instance.""" + self.env: ManagerBasedEnv = curobo_test_env["env"] + self.robot: Articulation = curobo_test_env["robot"] + self.planner: CuroboPlanner = curobo_test_env["planner"] + self.goal_pose_visualizer: VisualizationMarkers | None = curobo_test_env["goal_pose_visualizer"] + self.home_j: torch.Tensor = curobo_test_env["home_j"] + + def _visualize_goal_pose(self, pos: torch.Tensor, quat: torch.Tensor) -> None: + """Visualize the goal pose using frame markers if not in headless mode.""" + if headless or self.goal_pose_visualizer is None: + return + pos_vis = pos.unsqueeze(0) + quat_vis = quat.unsqueeze(0) + self.goal_pose_visualizer.visualize(translations=pos_vis, orientations=quat_vis) + + def _execute_current_plan(self) -> None: + """Replay the waypoints of the current plan in the simulator for visualization.""" + if headless or self.planner.current_plan is None: + return + for q in self.planner.current_plan.position: + q_tensor = q if isinstance(q, torch.Tensor) else torch.as_tensor(q, dtype=torch.float32) + self._set_arm_positions(q_tensor) + self.env.sim.step(render=True) + + def _set_arm_positions(self, q: torch.Tensor) -> None: + """Set the joint positions of the robot's arm, appending default gripper values if necessary.""" + if q.dim() == 1: + q = q.unsqueeze(0) + if q.shape[-1] == 7: # Arm only + fingers = torch.tensor([0.04, 0.04], device=q.device, dtype=q.dtype).repeat(q.shape[0], 1) + q_full = torch.cat([q, fingers], dim=-1) + else: + q_full = q + self.robot.write_joint_position_to_sim(q_full) + + @pytest.mark.parametrize("goal_spec, goal_id", predefined_ee_goals_and_ids) + def test_plan_to_predefined_goal(self, goal_spec, goal_id) -> None: + """Test planning to a predefined goal, ensuring the planner can find a path around an obstacle.""" + print(f"Planning for goal: {goal_id}") + + # Reset robot to a known home position before each test + self._set_arm_positions(self.home_j) + self.env.sim.step() + + pos = torch.tensor(goal_spec["pos"], dtype=torch.float32) + quat = torch.tensor(goal_spec["quat"], dtype=torch.float32) + + if not headless: + self._visualize_goal_pose(pos, quat) + + # Ensure the goal is actually behind the wall + assert pos[0] > 0.55, f"Goal '{goal_id}' is not behind the wall (x={pos[0]:.3f})" + + rot_matrix = math_utils.matrix_from_quat(quat.unsqueeze(0))[0] + ee_goal = math_utils.make_pose(pos, rot_matrix) + + result = self.planner.plan_motion(ee_goal) + print(f"Planning result for '{goal_id}': {'Success' if result else 'Failure'}") + + assert result, f"Failed to find a motion plan for the goal: '{goal_id}'" + + if result and not headless: + self._execute_current_plan() diff --git a/source/isaaclab_mimic/test/test_generate_dataset_skillgen.py b/source/isaaclab_mimic/test/test_generate_dataset_skillgen.py new file mode 100644 index 000000000000..846604a1c0c2 --- /dev/null +++ b/source/isaaclab_mimic/test/test_generate_dataset_skillgen.py @@ -0,0 +1,91 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Test dataset generation with SkillGen for Isaac Lab Mimic workflow.""" + +from isaaclab.app import AppLauncher + +# Launch omniverse app +simulation_app = AppLauncher(headless=True).app + +import os +import subprocess +import tempfile + +import pytest + +from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path + +DATASETS_DOWNLOAD_DIR = tempfile.mkdtemp(suffix="_Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0") +NUCLEUS_SKILLGEN_ANNOTATED_DATASET_PATH = os.path.join( + ISAACLAB_NUCLEUS_DIR, "Mimic", "franka_stack_datasets", "annotated_dataset_skillgen.hdf5" +) + + +@pytest.fixture +def setup_skillgen_test_environment(): + """Prepare environment for SkillGen dataset generation test.""" + # Create the datasets directory if it does not exist + if not os.path.exists(DATASETS_DOWNLOAD_DIR): + print("Creating directory : ", DATASETS_DOWNLOAD_DIR) + os.makedirs(DATASETS_DOWNLOAD_DIR) + + # Download the SkillGen annotated dataset from Nucleus into DATASETS_DOWNLOAD_DIR + retrieve_file_path(NUCLEUS_SKILLGEN_ANNOTATED_DATASET_PATH, DATASETS_DOWNLOAD_DIR) + + # Set the environment variable PYTHONUNBUFFERED to 1 to get all text outputs in result.stdout + pythonunbuffered_env_var_ = os.environ.get("PYTHONUNBUFFERED") + os.environ["PYTHONUNBUFFERED"] = "1" + + # Automatically detect the workflow root (backtrack from current file location) + current_dir = os.path.dirname(os.path.abspath(__file__)) + workflow_root = os.path.abspath(os.path.join(current_dir, "../../..")) + + # Yield the workflow root for use in tests + yield workflow_root + + # Cleanup: restore the original environment variable + if pythonunbuffered_env_var_: + os.environ["PYTHONUNBUFFERED"] = pythonunbuffered_env_var_ + else: + del os.environ["PYTHONUNBUFFERED"] + + +def test_generate_dataset_skillgen(setup_skillgen_test_environment): + """Test dataset generation with SkillGen enabled.""" + workflow_root = setup_skillgen_test_environment + + input_file = os.path.join(DATASETS_DOWNLOAD_DIR, "annotated_dataset_skillgen.hdf5") + output_file = os.path.join(DATASETS_DOWNLOAD_DIR, "generated_dataset_skillgen.hdf5") + + command = [ + workflow_root + "/isaaclab.sh", + "-p", + os.path.join(workflow_root, "scripts/imitation_learning/isaaclab_mimic/generate_dataset.py"), + "--device", + "cpu", + "--input_file", + input_file, + "--output_file", + output_file, + "--num_envs", + "1", + "--generation_num_trials", + "1", + "--use_skillgen", + "--headless", + "--task", + "Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0", + ] + + result = subprocess.run(command, capture_output=True, text=True) + + print("SkillGen dataset generation result:") + print(result.stdout) + print(result.stderr) + + assert result.returncode == 0, result.stderr + expected_output = "successes/attempts. Exiting" + assert expected_output in result.stdout diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 56634f323357..f317365d688f 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.10.50" +version = "0.10.51" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index e4e098deee7f..ee84acbafd53 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.10.51 (2025-09-08) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added SkillGen-specific cube stacking environments: + * :class:`FrankaCubeStackSkillgenEnvCfg`; Gym ID ``Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0``. +* Added bin cube stacking environment for SkillGen/Mimic: + * :class:`FrankaBinStackEnvCfg`; Gym ID ``Isaac-Stack-Cube-Bin-Franka-IK-Rel-Mimic-v0``. + + 0.10.50 (2025-09-05) ~~~~~~~~~~~~~~~~~~~~ @@ -9,6 +21,7 @@ Added * Added stacking environments for Galbot with suction grippers. + 0.10.49 (2025-09-05) ~~~~~~~~~~~~~~~~~~~~ @@ -17,6 +30,7 @@ Added * Added suction gripper stacking environments with UR10 that can be used with teleoperation. + 0.10.48 (2025-09-03) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/__init__.py index 5f2480fd5b01..0e3db6206b77 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/__init__.py @@ -7,9 +7,11 @@ from . import ( agents, + bin_stack_ik_rel_env_cfg, stack_ik_abs_env_cfg, stack_ik_rel_blueprint_env_cfg, stack_ik_rel_env_cfg, + stack_ik_rel_env_cfg_skillgen, stack_ik_rel_instance_randomize_env_cfg, stack_ik_rel_visuomotor_cosmos_env_cfg, stack_ik_rel_visuomotor_env_cfg, @@ -105,3 +107,23 @@ }, disable_env_checker=True, ) + +gym.register( + id="Isaac-Stack-Cube-Franka-IK-Rel-Skillgen-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_ik_rel_env_cfg_skillgen.FrankaCubeStackSkillgenEnvCfg, + "robomimic_bc_cfg_entry_point": os.path.join(agents.__path__[0], "robomimic/bc_rnn_low_dim.json"), + }, + disable_env_checker=True, +) + +gym.register( + id="Isaac-Stack-Cube-Bin-Franka-IK-Rel-Mimic-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": bin_stack_ik_rel_env_cfg.FrankaBinStackEnvCfg, + "robomimic_bc_cfg_entry_point": os.path.join(agents.__path__[0], "robomimic/bc_rnn_low_dim.json"), + }, + disable_env_checker=True, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py new file mode 100644 index 000000000000..fd4b386249e6 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_ik_rel_env_cfg.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg +from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg +from isaaclab.utils import configclass + +from . import bin_stack_joint_pos_env_cfg + +## +# Pre-defined configs +## +from isaaclab_assets.robots.franka import FRANKA_PANDA_HIGH_PD_CFG # isort: skip + + +@configclass +class FrankaBinStackEnvCfg(bin_stack_joint_pos_env_cfg.FrankaBinStackEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set Franka as robot + self.scene.robot = FRANKA_PANDA_HIGH_PD_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # Set actions for the specific robot type (franka) + self.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.0]), + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py new file mode 100644 index 000000000000..fbc6454bba83 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/bin_stack_joint_pos_env_cfg.py @@ -0,0 +1,203 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObjectCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.sensors import FrameTransformerCfg +from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg +from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg +from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR + +from isaaclab_tasks.manager_based.manipulation.stack import mdp +from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events +from isaaclab_tasks.manager_based.manipulation.stack.stack_env_cfg import StackEnvCfg + +## +# Pre-defined configs +## +from isaaclab.markers.config import FRAME_MARKER_CFG # isort: skip +from isaaclab_assets.robots.franka import FRANKA_PANDA_CFG # isort: skip + + +@configclass +class EventCfg: + """Configuration for events.""" + + init_franka_arm_pose = EventTerm( + func=franka_stack_events.set_default_joint_pose, + # mode="startup", + mode="reset", + params={ + "default_pose": [0.0444, -0.1894, -0.1107, -2.5148, 0.0044, 2.3775, 0.6952, 0.0400, 0.0400], + }, + ) + + randomize_franka_joint_state = EventTerm( + func=franka_stack_events.randomize_joint_by_gaussian_offset, + mode="reset", + params={ + "mean": 0.0, + "std": 0.02, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + # Reset blue bin position + reset_blue_bin_pose = EventTerm( + func=franka_stack_events.randomize_object_pose, + mode="reset", + params={ + # Keep bin at fixed position - no randomization + "pose_range": {"x": (0.4, 0.4), "y": (0.0, 0.0), "z": (0.0203, 0.0203), "yaw": (0.0, 0.0)}, + "min_separation": 0.0, + "asset_cfgs": [SceneEntityCfg("blue_sorting_bin")], + }, + ) + + # Reset cube 1 to initial position (inside the bin) + reset_cube_1_pose = EventTerm( + func=franka_stack_events.randomize_object_pose, + mode="reset", + params={ + "pose_range": {"x": (0.4, 0.4), "y": (0.0, 0.0), "z": (0.0203, 0.0203), "yaw": (0.0, 0.0)}, + "min_separation": 0.0, + "asset_cfgs": [SceneEntityCfg("cube_1")], + }, + ) + + # Reset cube 2 and 3 to initial position (outside the bin, to the left and right) + reset_cube_pose = EventTerm( + func=franka_stack_events.randomize_object_pose, + mode="reset", + params={ + "pose_range": {"x": (0.65, 0.70), "y": (-0.18, 0.18), "z": (0.0203, 0.0203), "yaw": (-1.0, 1.0, 0)}, + "min_separation": 0.1, + "asset_cfgs": [SceneEntityCfg("cube_2"), SceneEntityCfg("cube_3")], + }, + ) + + +@configclass +class FrankaBinStackEnvCfg(StackEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set events + self.events = EventCfg() + + # Set Franka as robot + self.scene.robot = FRANKA_PANDA_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + self.scene.robot.spawn.semantic_tags = [("class", "robot")] + + # Add semantics to table + self.scene.table.spawn.semantic_tags = [("class", "table")] + + # Add semantics to ground + self.scene.plane.semantic_tags = [("class", "ground")] + + # Set actions for the specific robot type (franka) + self.actions.arm_action = mdp.JointPositionActionCfg( + asset_name="robot", joint_names=["panda_joint.*"], scale=0.5, use_default_offset=True + ) + self.actions.gripper_action = mdp.BinaryJointPositionActionCfg( + asset_name="robot", + joint_names=["panda_finger.*"], + open_command_expr={"panda_finger_.*": 0.04}, + close_command_expr={"panda_finger_.*": 0.0}, + ) + + # Rigid body properties of each cube + cube_properties = RigidBodyPropertiesCfg( + solver_position_iteration_count=40, + solver_velocity_iteration_count=1, + max_angular_velocity=1000.0, + max_linear_velocity=1000.0, + max_depenetration_velocity=5.0, + disable_gravity=False, + ) + + # Blue sorting bin positioned at table center + self.scene.blue_sorting_bin = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/BlueSortingBin", + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.4, 0.0, 0.0203), rot=(1.0, 0.0, 0.0, 0.0)), + spawn=UsdFileCfg( + usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Mimic/nut_pour_task/nut_pour_assets/sorting_bin_blue.usd", + scale=(1.1, 1.6, 3.3), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + ), + ) + + # Cube 1 positioned at the bottom center of the blue bin + # The bin is at (0.4, 0.0, 0.0203), so cube_1 should be slightly above it + self.scene.cube_1 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_1", + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.4, 0.0, 0.025), rot=(1.0, 0.0, 0.0, 0.0)), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/blue_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=cube_properties, + ), + ) + + # Cube 2 positioned outside the bin (to the right) + self.scene.cube_2 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_2", + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.85, 0.25, 0.0203), rot=(1.0, 0.0, 0.0, 0.0)), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/red_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=cube_properties, + ), + ) + + # Cube 3 positioned outside the bin (to the left) + self.scene.cube_3 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_3", + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.85, -0.25, 0.0203), rot=(1.0, 0.0, 0.0, 0.0)), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/green_block.usd", + scale=(1.0, 1.0, 1.0), + rigid_props=cube_properties, + ), + ) + + # Listens to the required transforms + marker_cfg = FRAME_MARKER_CFG.copy() + marker_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) + marker_cfg.prim_path = "/Visuals/FrameTransformer" + self.scene.ee_frame = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/panda_link0", + debug_vis=False, + visualizer_cfg=marker_cfg, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/panda_hand", + name="end_effector", + offset=OffsetCfg( + pos=(0.0, 0.0, 0.0), + ), + ), + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/panda_rightfinger", + name="tool_rightfinger", + offset=OffsetCfg( + pos=(0.0, 0.0, 0.046), + ), + ), + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/panda_leftfinger", + name="tool_leftfinger", + offset=OffsetCfg( + pos=(0.0, 0.0, 0.046), + ), + ), + ], + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py new file mode 100644 index 000000000000..b95640be8a7b --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_env_cfg_skillgen.py @@ -0,0 +1,167 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg +from isaaclab.devices.device_base import DevicesCfg +from isaaclab.devices.keyboard import Se3KeyboardCfg +from isaaclab.devices.openxr.openxr_device import OpenXRDevice, OpenXRDeviceCfg +from isaaclab.devices.openxr.retargeters.manipulator.gripper_retargeter import GripperRetargeterCfg +from isaaclab.devices.openxr.retargeters.manipulator.se3_rel_retargeter import Se3RelRetargeterCfg +from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils import configclass + +from ... import mdp +from . import stack_joint_pos_env_cfg + +## +# Pre-defined configs +## +from isaaclab_assets.robots.franka import FRANKA_PANDA_HIGH_PD_CFG # isort: skip + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP.""" + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group.""" + + actions = ObsTerm(func=mdp.last_action) + joint_pos = ObsTerm(func=mdp.joint_pos_rel) + joint_vel = ObsTerm(func=mdp.joint_vel_rel) + object = ObsTerm(func=mdp.object_obs) + cube_positions = ObsTerm(func=mdp.cube_positions_in_world_frame) + cube_orientations = ObsTerm(func=mdp.cube_orientations_in_world_frame) + eef_pos = ObsTerm(func=mdp.ee_frame_pos) + eef_quat = ObsTerm(func=mdp.ee_frame_quat) + gripper_pos = ObsTerm(func=mdp.gripper_pos) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = False + + @configclass + class RGBCameraPolicyCfg(ObsGroup): + """Observations for policy group with RGB images.""" + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = False + + @configclass + class SubtaskCfg(ObsGroup): + """Observations for subtask group.""" + + grasp_1 = ObsTerm( + func=mdp.object_grasped, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "ee_frame_cfg": SceneEntityCfg("ee_frame"), + "object_cfg": SceneEntityCfg("cube_2"), + }, + ) + stack_1 = ObsTerm( + func=mdp.object_stacked, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "upper_object_cfg": SceneEntityCfg("cube_2"), + "lower_object_cfg": SceneEntityCfg("cube_1"), + }, + ) + grasp_2 = ObsTerm( + func=mdp.object_grasped, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "ee_frame_cfg": SceneEntityCfg("ee_frame"), + "object_cfg": SceneEntityCfg("cube_3"), + }, + ) + stack_2 = ObsTerm( + func=mdp.object_stacked, + params={ + "robot_cfg": SceneEntityCfg("robot"), + "upper_object_cfg": SceneEntityCfg("cube_3"), + "lower_object_cfg": SceneEntityCfg("cube_2"), + }, + ) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = False + + # observation groups + policy: PolicyCfg = PolicyCfg() + rgb_camera: RGBCameraPolicyCfg = RGBCameraPolicyCfg() + subtask_terms: SubtaskCfg = SubtaskCfg() + + +@configclass +class FrankaCubeStackSkillgenEnvCfg(stack_joint_pos_env_cfg.FrankaCubeStackEnvCfg): + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Override observations with SkillGen-specific config + self.observations = ObservationsCfg() + + # Set Franka as robot + # We switch here to a stiffer PD controller for IK tracking to be better. + self.scene.robot = FRANKA_PANDA_HIGH_PD_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # Set actions for the specific robot type (franka) + self.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.0]), + ) + + self.teleop_devices = DevicesCfg( + devices={ + "handtracking": OpenXRDeviceCfg( + retargeters=[ + Se3RelRetargeterCfg( + bound_hand=OpenXRDevice.TrackingTarget.HAND_RIGHT, + zero_out_xy_rotation=True, + use_wrist_rotation=False, + use_wrist_position=True, + delta_pos_scale_factor=10.0, + delta_rot_scale_factor=10.0, + sim_device=self.sim.device, + ), + GripperRetargeterCfg( + bound_hand=OpenXRDevice.TrackingTarget.HAND_RIGHT, sim_device=self.sim.device + ), + ], + sim_device=self.sim.device, + xr_cfg=self.xr, + ), + "keyboard": Se3KeyboardCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + } + ) + + # Apply skillgen-specific cube position randomization + self.events.randomize_cube_positions.params["pose_range"] = { + "x": (0.45, 0.6), + "y": (-0.23, 0.23), + "z": (0.0203, 0.0203), + "yaw": (-1.0, 1, 0), + } + + # Set the offset for the end effector to be 0.0 + for f in self.scene.ee_frame.target_frames: + if f.name == "end_effector": + f.offset.pos = [0.0, 0.0, 0.0] + break diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py index 5d26f6ff0143..cc91754363d7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py @@ -59,6 +59,7 @@ class EventCfg: @configclass class FrankaCubeStackEnvCfg(StackEnvCfg): + def __post_init__(self): # post init of parent super().__post_init__() From 48054b69778bf13d56fdb5a9eee9e4c87908f3b4 Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Fri, 29 Aug 2025 09:32:11 -0700 Subject: [PATCH 37/47] feat: Add G1/GR1 IK environments, teleop system, and locomanipulation support - Implement fixed-base upper body IK and locomanipulation environments - Add G1 teleop with retargeter, hand rotation fixes, and comprehensive tests - Refactor locomotion to use retargeter instead of command manager - Enhance pink IK with kinematics model and LocalFrameTask for relative poses - Add locomanipulation policies with gravity compensation and tuned gains - Implement lower body standing retargeter with zero root velocity Co-authored-by: Michael Lin Co-authored-by: Huihua Zhao Co-authored-by: Rafael Wiltz Co-authored-by: Sergey Grizan --- CONTRIBUTORS.md | 1 + .../imitation-learning/teleop_imitation.rst | 121 +++- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 10 + .../controllers/pink_ik/local_frame_task.py | 116 +++ .../isaaclab/controllers/pink_ik/pink_ik.py | 137 ++-- .../controllers/pink_ik/pink_ik_cfg.py | 4 + .../pink_ik/pink_kinematics_configuration.py | 178 +++++ source/isaaclab/isaaclab/controllers/utils.py | 66 ++ .../devices/openxr/retargeters/__init__.py | 2 + .../dex-retargeting/g1_hand_left_dexpilot.yml | 23 + .../g1_hand_right_dexpilot.yml | 23 + .../unitree/g1_dex_retargeting_utils.py | 247 +++++++ .../unitree/g1_lower_body_standing.py | 28 + .../unitree/g1_upper_body_retargeter.py | 166 +++++ .../isaaclab/devices/teleop_device_factory.py | 6 + .../envs/mdp/actions/pink_actions_cfg.py | 6 +- .../mdp/actions/pink_task_space_actions.py | 303 +++++--- .../test/controllers/test_controller_utils.py | 659 ++++++++++++++++++ .../controllers/test_ik_configs/README.md | 119 ++++ .../pink_ik_g1_test_configs.json | 111 +++ .../pink_ik_gr1_test_configs.json | 47 +- .../test/controllers/test_local_frame_task.py | 450 ++++++++++++ .../isaaclab/test/controllers/test_pink_ik.py | 270 ++++--- .../controllers/test_pink_ik_components.py | 296 ++++++++ .../urdfs/test_urdf_two_link_robot.urdf | 88 +++ source/isaaclab_assets/config/extension.toml | 2 +- source/isaaclab_assets/docs/CHANGELOG.rst | 8 + .../isaaclab_assets/robots/unitree.py | 172 +++++ .../envs/pinocchio_envs/__init__.py | 9 + .../locomanipulation_g1_mimic_env.py | 129 ++++ .../locomanipulation_g1_mimic_env_cfg.py | 112 +++ .../__init__.py | 4 +- .../locomanipulation/pick_place/__init__.py | 31 + .../agents/robomimic/bc_rnn_low_dim.json | 117 ++++ .../pick_place/configs/action_cfg.py | 34 + .../agile_locomotion_observation_cfg.py | 84 +++ .../pick_place/configs/pink_controller_cfg.py | 126 ++++ .../fixed_base_upper_body_ik_g1_env_cfg.py | 213 ++++++ .../pick_place/locomanipulation_g1_env_cfg.py | 227 ++++++ .../pick_place/mdp/__init__.py | 12 + .../pick_place/mdp/actions.py | 125 ++++ .../pick_place/mdp/observations.py | 32 + .../tracking/__init__.py | 0 .../tracking/config/__init__.py | 0 .../tracking/config/digit/__init__.py | 0 .../tracking/config/digit/agents/__init__.py | 0 .../config/digit/agents/rsl_rl_ppo_cfg.py | 0 .../config/digit/loco_manip_env_cfg.py | 0 .../exhaustpipe_gr1t2_base_env_cfg.py | 19 +- .../exhaustpipe_gr1t2_pink_ik_env_cfg.py | 55 +- .../pick_place/mdp/observations.py | 59 +- .../pick_place/mdp/terminations.py | 6 +- .../pick_place/nutpour_gr1t2_base_env_cfg.py | 19 +- .../nutpour_gr1t2_pink_ik_env_cfg.py | 55 +- .../pick_place/pickplace_gr1t2_env_cfg.py | 76 +- .../pickplace_gr1t2_waist_enabled_env_cfg.py | 12 +- 57 files changed, 4730 insertions(+), 487 deletions(-) create mode 100644 source/isaaclab/isaaclab/controllers/pink_ik/local_frame_task.py create mode 100644 source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py create mode 100644 source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml create mode 100644 source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml create mode 100644 source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_dex_retargeting_utils.py create mode 100644 source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_lower_body_standing.py create mode 100644 source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_upper_body_retargeter.py create mode 100644 source/isaaclab/test/controllers/test_controller_utils.py create mode 100644 source/isaaclab/test/controllers/test_ik_configs/README.md create mode 100644 source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json rename source/isaaclab/test/controllers/{test_configs => test_ik_configs}/pink_ik_gr1_test_configs.json (76%) create mode 100644 source/isaaclab/test/controllers/test_local_frame_task.py create mode 100644 source/isaaclab/test/controllers/test_pink_ik_components.py create mode 100644 source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env.py create mode 100644 source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env_cfg.py rename source/isaaclab_tasks/isaaclab_tasks/manager_based/{loco_manipulation => locomanipulation}/__init__.py (62%) create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/agents/robomimic/bc_rnn_low_dim.json create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/pink_controller_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/observations.py rename source/isaaclab_tasks/isaaclab_tasks/manager_based/{loco_manipulation => locomanipulation}/tracking/__init__.py (100%) rename source/isaaclab_tasks/isaaclab_tasks/manager_based/{loco_manipulation => locomanipulation}/tracking/config/__init__.py (100%) rename source/isaaclab_tasks/isaaclab_tasks/manager_based/{loco_manipulation => locomanipulation}/tracking/config/digit/__init__.py (100%) rename source/isaaclab_tasks/isaaclab_tasks/manager_based/{loco_manipulation => locomanipulation}/tracking/config/digit/agents/__init__.py (100%) rename source/isaaclab_tasks/isaaclab_tasks/manager_based/{loco_manipulation => locomanipulation}/tracking/config/digit/agents/rsl_rl_ppo_cfg.py (100%) rename source/isaaclab_tasks/isaaclab_tasks/manager_based/{loco_manipulation => locomanipulation}/tracking/config/digit/loco_manip_env_cfg.py (100%) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ee6200de8694..fe1c0d52f0d3 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -120,6 +120,7 @@ Guidelines for modifications: * Ritvik Singh * Rosario Scalise * Ryley McCarroll +* Sergey Grizan * Shafeef Omar * Shaoshu Su * Shaurya Dewan diff --git a/docs/source/overview/imitation-learning/teleop_imitation.rst b/docs/source/overview/imitation-learning/teleop_imitation.rst index 859287560a84..2d415bbca44c 100644 --- a/docs/source/overview/imitation-learning/teleop_imitation.rst +++ b/docs/source/overview/imitation-learning/teleop_imitation.rst @@ -508,7 +508,126 @@ Visualize the results of the trained policy by running the following command, us The trained policy performing the pick and place task in Isaac Lab. -Demo 2: Visuomotor Policy for a Humanoid Robot +Demo 2: Data Generation and Policy Training for Humanoid Robot Locomanipulation with Unitree G1 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In this demo, we showcase the integration of locomotion and manipulation capabilities within a single humanoid robot system. +This locomanipulation environment enables data collection for complex tasks that combine navigation and object manipulation. +The demonstration follows a multi-step process: first, it generates pick and place tasks similar to Demo 1, then introduces +a navigation component that uses specialized scripts to generate scenes where the humanoid robot must move from point A to point B. +The robot picks up an object at the initial location (point A) and places it at the target destination (point B). + +.. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/locomanipulation-g-1_steering_wheel_pick_place.gif + :width: 100% + :align: center + :alt: G1 humanoid robot with locomanipulation performing a pick and place task + :figclass: align-center + +Generate the manipulation dataset +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The same data generation and policy training steps from Demo 1.0 can be applied to the G1 humanoid robot with locomanipulation capabilities. +This demonstration shows how to train a G1 robot to perform pick and place tasks with full-body locomotion and manipulation. + +The process follows the same workflow as Demo 1.0, but uses the ``Isaac-PickPlace-Locomanipulation-G1-Abs-v0`` task environment. + +Follow the same data collection, annotation, and generation process as demonstrated in Demo 1.0, but adapted for the G1 locomanipulation task. + +.. hint:: + + If desired, data collection and annotation can be done using the same commands as the prior examples for validation of the dataset. + + The G1 robot with locomanipulation capabilities combines full-body locomotion with manipulation to perform pick and place tasks. + + **Note that the following commands are only for your reference and dataset validation purposes - they are not required for this demo.** + + To collect demonstrations: + + .. code:: bash + + ./isaaclab.sh -p scripts/tools/record_demos.py \ + --device cpu \ + --task Isaac-PickPlace-Locomanipulation-G1-Abs-v0 \ + --teleop_device handtracking \ + --dataset_file ./datasets/dataset_g1_locomanip.hdf5 \ + --num_demos 5 --enable_pinocchio + + You can replay the collected demonstrations by running: + + .. code:: bash + + ./isaaclab.sh -p scripts/tools/replay_demos.py \ + --device cpu \ + --task Isaac-PickPlace-Locomanipulation-G1-Abs-v0 \ + --dataset_file ./datasets/dataset_g1_locomanip.hdf5 --enable_pinocchio + + To annotate the demonstrations: + + .. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/annotate_demos.py \ + --device cpu \ + --task Isaac-PickPlace-Locomanipulation-G1-Abs-Mimic-v0 \ + --input_file ./datasets/dataset_g1_locomanip.hdf5 \ + --output_file ./datasets/dataset_annotated_g1_locomanip.hdf5 --enable_pinocchio + + +If you skipped the prior collection and annotation step, download the pre-recorded annotated dataset ``dataset_annotated_g1_locomanip.hdf5`` from +`here `_. +Place the file under ``IsaacLab/datasets`` and run the following command to generate a new dataset with 1000 demonstrations. + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py \ + --device cpu --headless --num_envs 20 --generation_num_trials 1000 --enable_pinocchio \ + --input_file ./datasets/dataset_annotated_g1_locomanip.hdf5 --output_file ./datasets/generated_dataset_g1_locomanip.hdf5 + + +Train a manipulation-only policy +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +At this point you can train a policy that only performs manipulation tasks using the generated dataset: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/robomimic/train.py \ + --task Isaac-PickPlace-Locomanipulation-G1-Abs-v0 --algo bc \ + --normalize_training_actions \ + --dataset ./datasets/generated_dataset_g1_locomanip.hdf5 + +Visualize the results +^^^^^^^^^^^^^^^^^^^^^ + +Visualize the trained policy performance: + +.. code:: bash + + ./isaaclab.sh -p scripts/imitation_learning/robomimic/play.py \ + --device cpu \ + --enable_pinocchio \ + --task Isaac-PickPlace-Locomanipulation-G1-Abs-v0 \ + --num_rollouts 50 \ + --horizon 400 \ + --norm_factor_min \ + --norm_factor_max \ + --checkpoint /PATH/TO/desired_model_checkpoint.pth + +.. note:: + Change the ``NORM_FACTOR`` in the above command with the values generated in the training step. + +.. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/locomanipulation-g-1_steering_wheel_pick_place.gif + :width: 100% + :align: center + :alt: G1 humanoid robot performing a pick and place task + :figclass: align-center + + The trained policy performing the pick and place task in Isaac Lab. + +Generate the dataset with manipulation and point-to-point navigation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + +Demo 3: Visuomotor Policy for a Humanoid Robot ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Download the Dataset diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index a53b7e970cbc..7d8767e48bf1 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.15" +version = "0.45.16" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 91d5e1ab1ed4..b9714ffb3bcd 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.45.16 (2025-08-27) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added teleoperation environments for Unitree G1. This includes an environment with lower body fixed and upper body + controlled by IK, and an environment with the lower body controlled by a policy and the upper body controlled by IK. + + 0.45.15 (2025-09-05) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/local_frame_task.py b/source/isaaclab/isaaclab/controllers/pink_ik/local_frame_task.py new file mode 100644 index 000000000000..e46174bcaa50 --- /dev/null +++ b/source/isaaclab/isaaclab/controllers/pink_ik/local_frame_task.py @@ -0,0 +1,116 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np +from collections.abc import Sequence + +import pinocchio as pin +from pink.tasks.frame_task import FrameTask + +from .pink_kinematics_configuration import PinkKinematicsConfiguration + + +class LocalFrameTask(FrameTask): + """ + A task that computes error in a local (custom) frame. + Inherits from FrameTask but overrides compute_error. + """ + + def __init__( + self, + frame: str, + base_link_frame_name: str, + position_cost: float | Sequence[float], + orientation_cost: float | Sequence[float], + lm_damping: float = 0.0, + gain: float = 1.0, + ): + """ + Initialize the LocalFrameTask with configuration. + + This task computes pose errors in a local (custom) frame rather than the world frame, + allowing for more flexible control strategies where the reference frame can be + specified independently. + + Args: + frame: Name of the frame to control (end-effector or target frame). + base_link_frame_name: Name of the base link frame used as reference frame + for computing transforms and errors. + position_cost: Cost weight(s) for position error. Can be a single float + for uniform weighting or a sequence of 3 floats for per-axis weighting. + orientation_cost: Cost weight(s) for orientation error. Can be a single float + for uniform weighting or a sequence of 3 floats for per-axis weighting. + lm_damping: Levenberg-Marquardt damping factor for numerical stability. + Defaults to 0.0 (no damping). + gain: Task gain factor that scales the overall task contribution. + Defaults to 1.0. + """ + super().__init__(frame, position_cost, orientation_cost, lm_damping, gain) + self.base_link_frame_name = base_link_frame_name + self.transform_target_to_base = None + + def set_target(self, transform_target_to_base: pin.SE3) -> None: + """Set task target pose in the world frame. + + Args: + transform_target_to_world: Transform from the task target frame to + the world frame. + """ + self.transform_target_to_base = transform_target_to_base.copy() + + def set_target_from_configuration(self, configuration: PinkKinematicsConfiguration) -> None: + """Set task target pose from a robot configuration. + + Args: + configuration: Robot configuration. + """ + if not isinstance(configuration, PinkKinematicsConfiguration): + raise ValueError("configuration must be a PinkKinematicsConfiguration") + self.set_target(configuration.get_transform(self.frame, self.base_link_frame_name)) + + def compute_error(self, configuration: PinkKinematicsConfiguration) -> np.ndarray: + """ + Compute the error between current and target pose in a local frame. + """ + if not isinstance(configuration, PinkKinematicsConfiguration): + raise ValueError("configuration must be a PinkKinematicsConfiguration") + if self.transform_target_to_base is None: + raise ValueError(f"no target set for frame '{self.frame}'") + + transform_frame_to_base = configuration.get_transform(self.frame, self.base_link_frame_name) + transform_target_to_frame = transform_frame_to_base.actInv(self.transform_target_to_base) + + error_in_frame: np.ndarray = pin.log(transform_target_to_frame).vector + return error_in_frame + + def compute_jacobian(self, configuration: PinkKinematicsConfiguration) -> np.ndarray: + r"""Compute the frame task Jacobian. + + The task Jacobian :math:`J(q) \in \mathbb{R}^{6 \times n_v}` is the + derivative of the task error :math:`e(q) \in \mathbb{R}^6` with respect + to the configuration :math:`q`. The formula for the frame task is: + + .. math:: + + J(q) = -\text{Jlog}_6(T_{tb}) {}_b J_{0b}(q) + + The derivation of the formula for this Jacobian is detailed in + [Caron2023]_. See also + :func:`pink.tasks.task.Task.compute_jacobian` for more context on task + Jacobians. + + Args: + configuration: Robot configuration :math:`q`. + + Returns: + Jacobian matrix :math:`J`, expressed locally in the frame. + """ + if self.transform_target_to_base is None: + raise Exception(f"no target set for frame '{self.frame}'") + transform_frame_to_base = configuration.get_transform(self.frame, self.base_link_frame_name) + transform_frame_to_target = self.transform_target_to_base.actInv(transform_frame_to_base) + jacobian_in_frame = configuration.get_frame_jacobian(self.frame) + J = -pin.Jlog6(transform_frame_to_target) @ jacobian_in_frame + return J diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py index 6bb4228e4e87..344244d141b9 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik.py @@ -19,14 +19,12 @@ from typing import TYPE_CHECKING from pink import solve_ik -from pink.configuration import Configuration -from pink.tasks import FrameTask -from pinocchio.robot_wrapper import RobotWrapper from isaaclab.assets import ArticulationCfg from isaaclab.utils.string import resolve_matching_names_values from .null_space_posture_task import NullSpacePostureTask +from .pink_kinematics_configuration import PinkKinematicsConfiguration if TYPE_CHECKING: from .pink_ik_cfg import PinkIKControllerCfg @@ -47,7 +45,9 @@ class PinkIKController: Pink IK Solver: https://github.com/stephane-caron/pink """ - def __init__(self, cfg: PinkIKControllerCfg, robot_cfg: ArticulationCfg, device: str): + def __init__( + self, cfg: PinkIKControllerCfg, robot_cfg: ArticulationCfg, device: str, controlled_joint_indices: list[int] + ): """Initialize the Pink IK Controller. Args: @@ -56,14 +56,28 @@ def __init__(self, cfg: PinkIKControllerCfg, robot_cfg: ArticulationCfg, device: robot_cfg: The robot articulation configuration containing initial joint positions and robot specifications. device: The device to use for computations (e.g., 'cuda:0', 'cpu'). + controlled_joint_indices: A list of joint indices in the USD asset controlled by the Pink IK controller. Raises: - KeyError: When Pink joint names cannot be matched to robot configuration joint positions. + ValueError: When joint_names or all_joint_names are not provided in the configuration. """ - # Initialize the robot model from URDF and mesh files - self.robot_wrapper = RobotWrapper.BuildFromURDF(cfg.urdf_path, cfg.mesh_path, root_joint=None) - self.pink_configuration = Configuration( - self.robot_wrapper.model, self.robot_wrapper.data, self.robot_wrapper.q0 + if cfg.joint_names is None: + raise ValueError("joint_names must be provided in the configuration") + if cfg.all_joint_names is None: + raise ValueError("all_joint_names must be provided in the configuration") + + self.cfg = cfg + self.device = device + self.controlled_joint_indices = controlled_joint_indices + + # Validate consistency between controlled_joint_indices and configuration + self._validate_consistency(cfg, controlled_joint_indices) + + # Initialize the Kinematics model used by pink IK to control robot + self.pink_configuration = PinkKinematicsConfiguration( + urdf_path=cfg.urdf_path, + mesh_path=cfg.mesh_path, + controlled_joint_names=cfg.joint_names, ) # Find the initial joint positions by matching Pink's joint names to robot_cfg.init_state.joint_pos, @@ -73,16 +87,11 @@ def __init__(self, cfg: PinkIKControllerCfg, robot_cfg: ArticulationCfg, device: joint_pos_dict = robot_cfg.init_state.joint_pos # Use resolve_matching_names_values to match Pink joint names to joint_pos values - indices, names, values = resolve_matching_names_values( + indices, _, values = resolve_matching_names_values( joint_pos_dict, pink_joint_names, preserve_order=False, strict=False ) - if len(indices) != len(pink_joint_names): - unmatched = [name for name in pink_joint_names if name not in names] - raise KeyError( - "Could not find a match for all Pink joint names in robot_cfg.init_state.joint_pos. " - f"Unmatched: {unmatched}, Expected: {pink_joint_names}" - ) - self.init_joint_positions = np.array(values) + self.init_joint_positions = np.zeros(len(pink_joint_names)) + self.init_joint_positions[indices] = np.array(values) # Set the default targets for each task from the configuration for task in cfg.variable_input_tasks: @@ -94,27 +103,75 @@ def __init__(self, cfg: PinkIKControllerCfg, robot_cfg: ArticulationCfg, device: for task in cfg.fixed_input_tasks: task.set_target_from_configuration(self.pink_configuration) - # Map joint names from Isaac Lab to Pink's joint conventions - self.pink_joint_names = self.robot_wrapper.model.names.tolist()[1:] # Skip the root and universal joints - self.isaac_lab_joint_names = cfg.joint_names - assert cfg.joint_names is not None, "cfg.joint_names cannot be None" + # Create joint ordering mappings + self._setup_joint_ordering_mappings() - # Frame task link names - self.frame_task_link_names = [] - for task in cfg.variable_input_tasks: - if isinstance(task, FrameTask): - self.frame_task_link_names.append(task.frame) + def _validate_consistency(self, cfg: PinkIKControllerCfg, controlled_joint_indices: list[int]) -> None: + """Validate consistency between controlled_joint_indices and controller configuration. + + Args: + cfg: The Pink IK controller configuration. + controlled_joint_indices: List of joint indices in Isaac Lab joint space. + + Raises: + ValueError: If any consistency checks fail. + """ + # Check: Length consistency + if cfg.joint_names is None: + raise ValueError("cfg.joint_names cannot be None") + if len(controlled_joint_indices) != len(cfg.joint_names): + raise ValueError( + f"Length mismatch: controlled_joint_indices has {len(controlled_joint_indices)} elements " + f"but cfg.joint_names has {len(cfg.joint_names)} elements" + ) + + # Check: Joint name consistency - verify that the indices point to the expected joint names + actual_joint_names = [cfg.all_joint_names[idx] for idx in controlled_joint_indices] + if actual_joint_names != cfg.joint_names: + mismatches = [] + for i, (actual, expected) in enumerate(zip(actual_joint_names, cfg.joint_names)): + if actual != expected: + mismatches.append( + f"Index {i}: index {controlled_joint_indices[i]} points to '{actual}' but expected '{expected}'" + ) + if mismatches: + raise ValueError( + "Joint name mismatch between controlled_joint_indices and cfg.joint_names:\n" + + "\n".join(mismatches) + ) - # Create reordering arrays for joint indices + def _setup_joint_ordering_mappings(self): + """Setup joint ordering mappings between Isaac Lab and Pink conventions.""" + pink_joint_names = self.pink_configuration.all_joint_names_pinocchio_order + isaac_lab_joint_names = self.cfg.all_joint_names + + if pink_joint_names is None: + raise ValueError("pink_joint_names should not be None") + if isaac_lab_joint_names is None: + raise ValueError("isaac_lab_joint_names should not be None") + + # Create reordering arrays for all joints self.isaac_lab_to_pink_ordering = np.array( - [self.isaac_lab_joint_names.index(pink_joint) for pink_joint in self.pink_joint_names] + [isaac_lab_joint_names.index(pink_joint) for pink_joint in pink_joint_names] ) self.pink_to_isaac_lab_ordering = np.array( - [self.pink_joint_names.index(isaac_lab_joint) for isaac_lab_joint in self.isaac_lab_joint_names] + [pink_joint_names.index(isaac_lab_joint) for isaac_lab_joint in isaac_lab_joint_names] ) + # Create reordering arrays for controlled joints only + pink_controlled_joint_names = self.pink_configuration.controlled_joint_names_pinocchio_order + isaac_lab_controlled_joint_names = self.cfg.joint_names - self.cfg = cfg - self.device = device + if pink_controlled_joint_names is None: + raise ValueError("pink_controlled_joint_names should not be None") + if isaac_lab_controlled_joint_names is None: + raise ValueError("isaac_lab_controlled_joint_names should not be None") + + self.isaac_lab_to_pink_controlled_ordering = np.array( + [isaac_lab_controlled_joint_names.index(pink_joint) for pink_joint in pink_controlled_joint_names] + ) + self.pink_to_isaac_lab_controlled_ordering = np.array( + [pink_controlled_joint_names.index(isaac_lab_joint) for isaac_lab_joint in isaac_lab_controlled_joint_names] + ) def update_null_space_joint_targets(self, curr_joint_pos: np.ndarray): """Update the null space joint targets. @@ -149,13 +206,16 @@ def compute( The target joint positions as a tensor of shape (num_joints,) on the specified device. If the IK solver fails, returns the current joint positions unchanged to maintain stability. """ + # Get the current controlled joint positions + curr_controlled_joint_pos = [curr_joint_pos[i] for i in self.controlled_joint_indices] + # Initialize joint positions for Pink, change from isaac_lab to pink/pinocchio joint ordering. joint_positions_pink = curr_joint_pos[self.isaac_lab_to_pink_ordering] # Update Pink's robot configuration with the current joint positions self.pink_configuration.update(joint_positions_pink) - # pink.solve_ik can raise an exception if the solver fails + # Solve IK using Pink's solver try: velocity = solve_ik( self.pink_configuration, @@ -164,7 +224,7 @@ def compute( solver="osqp", safety_break=self.cfg.fail_on_joint_limit_violation, ) - Delta_q = velocity * dt + joint_angle_changes = velocity * dt except (AssertionError, Exception) as e: # Print warning and return the current joint positions as the target # Not using omni.log since its not available in CI during docs build @@ -178,21 +238,18 @@ def compute( from isaaclab.ui.xr_widgets import XRVisualization XRVisualization.push_event("ik_error", {"error": e}) - return torch.tensor(curr_joint_pos, device=self.device, dtype=torch.float32) - - # Discard the first 6 values (for root and universal joints) - pink_joint_angle_changes = Delta_q + return torch.tensor(curr_controlled_joint_pos, device=self.device, dtype=torch.float32) # Reorder the joint angle changes back to Isaac Lab conventions joint_vel_isaac_lab = torch.tensor( - pink_joint_angle_changes[self.pink_to_isaac_lab_ordering], + joint_angle_changes[self.pink_to_isaac_lab_controlled_ordering], device=self.device, - dtype=torch.float, + dtype=torch.float32, ) # Add the velocity changes to the current joint positions to get the target joint positions target_joint_pos = torch.add( - joint_vel_isaac_lab, torch.tensor(curr_joint_pos, device=self.device, dtype=torch.float32) + joint_vel_isaac_lab, torch.tensor(curr_controlled_joint_pos, device=self.device, dtype=torch.float32) ) return target_joint_pos diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py index d5f36a91523a..ed7e40b0c480 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_ik_cfg.py @@ -46,6 +46,10 @@ class PinkIKControllerCfg: """ joint_names: list[str] | None = None + """A list of joint names in the USD asset controlled by the Pink IK controller. This is required because the joint naming conventions differ between USD and URDF files. + This value is currently designed to be automatically populated by the action term in a manager based environment.""" + + all_joint_names: list[str] | None = None """A list of joint names in the USD asset. This is required because the joint naming conventions differ between USD and URDF files. This value is currently designed to be automatically populated by the action term in a manager based environment.""" diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py new file mode 100644 index 000000000000..b2cae80c1058 --- /dev/null +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py @@ -0,0 +1,178 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np + +import pinocchio as pin +from pink.configuration import Configuration +from pink.exceptions import FrameNotFound +from pinocchio.robot_wrapper import RobotWrapper + + +class PinkKinematicsConfiguration(Configuration): + """ + A configuration class that maintains both a "controlled" (reduced) model and a "full" model. + + This class extends the standard Pink Configuration to allow for selective joint control: + - The "controlled" model/data/q represent the subset of joints being actively controlled (e.g., a kinematic chain or arm). + - The "full" model/data/q represent the complete robot, including all joints. + + This is useful for scenarios where only a subset of joints are being optimized or controlled, but full-model kinematics + (e.g., for collision checking, full-body Jacobians, or visualization) are still required. + + The class ensures that both models are kept up to date, and provides methods to update both the controlled and full + configurations as needed. + """ + + def __init__( + self, + urdf_path: str, + mesh_path: str, + controlled_joint_names: list[str], + copy_data: bool = True, + forward_kinematics: bool = True, + ): + """ + Initialize PinkKinematicsConfiguration. + + Args: + urdf_path (str): Path to the robot URDF file. + mesh_path (str): Path to the mesh files for the robot. + controlled_joint_names (list[str]): List of joint names to be actively controlled. + copy_data (bool, optional): If True, work on an internal copy of the input data. Defaults to True. + forward_kinematics (bool, optional): If True, compute forward kinematics from the configuration vector. Defaults to True. + + This constructor initializes the PinkKinematicsConfiguration, which maintains both a "controlled" (reduced) model and a "full" model. + The controlled model/data/q represent the subset of joints being actively controlled, while the full model/data/q represent the complete robot. + This is useful for scenarios where only a subset of joints are being optimized or controlled, but full-model kinematics are still required. + """ + self._controlled_joint_names = controlled_joint_names + + # Build robot model with all joints + if mesh_path is not None: + self.robot_wrapper = RobotWrapper.BuildFromURDF(urdf_path, mesh_path) + else: + self.robot_wrapper = RobotWrapper.BuildFromURDF(urdf_path) + self.full_model = self.robot_wrapper.model + self.full_data = self.robot_wrapper.data + self.full_q = self.robot_wrapper.q0 + + self._all_joint_names = self.full_model.names.tolist()[1:] + # controlled_joint_indices: indices in all_joint_names for joints that are in controlled_joint_names, preserving all_joint_names order + self._controlled_joint_indices = [ + idx for idx, joint_name in enumerate(self._all_joint_names) if joint_name in self._controlled_joint_names + ] + + # Build the reduced model with only the controlled joints + joints_to_lock = [] + for joint_name in self._all_joint_names: + if joint_name not in self._controlled_joint_names: + joints_to_lock.append(self.full_model.getJointId(joint_name)) + + if len(joints_to_lock) == 0: + # No joints to lock, controlled model is the same as full model + self.controlled_model = self.full_model + self.controlled_data = self.full_data + self.controlled_q = self.full_q + else: + self.controlled_model = pin.buildReducedModel(self.full_model, joints_to_lock, self.full_q) + self.controlled_data = self.controlled_model.createData() + self.controlled_q = self.full_q[self._controlled_joint_indices] + + # Pink will should only have the controlled model + super().__init__(self.controlled_model, self.controlled_data, self.controlled_q, copy_data, forward_kinematics) + + def update(self, q: np.ndarray | None = None) -> None: + """Update configuration to a new vector. + + Calling this function runs forward kinematics and computes + collision-pair distances, if applicable. + + Args: + q: New configuration vector. + """ + if q is not None and len(q) != len(self._all_joint_names): + raise ValueError("q must have the same length as the number of joints in the model") + if q is not None: + super().update(q[self._controlled_joint_indices]) + + q_readonly = q.copy() + q_readonly.setflags(write=False) + self.full_q = q_readonly + pin.computeJointJacobians(self.full_model, self.full_data, q) + pin.updateFramePlacements(self.full_model, self.full_data) + else: + super().update() + pin.computeJointJacobians(self.full_model, self.full_data, self.full_q) + pin.updateFramePlacements(self.full_model, self.full_data) + + def get_frame_jacobian(self, frame: str) -> np.ndarray: + r"""Compute the Jacobian matrix of a frame velocity. + + Denoting our frame by :math:`B` and the world frame by :math:`W`, the + Jacobian matrix :math:`{}_B J_{WB}` is related to the body velocity + :math:`{}_B v_{WB}` by: + + .. math:: + + {}_B v_{WB} = {}_B J_{WB} \dot{q} + + Args: + frame: Name of the frame, typically a link name from the URDF. + + Returns: + Jacobian :math:`{}_B J_{WB}` of the frame. + + When the robot model includes a floating base + (pin.JointModelFreeFlyer), the configuration vector :math:`q` consists + of: + + - ``q[0:3]``: position in [m] of the floating base in the inertial + frame, formatted as :math:`[p_x, p_y, p_z]`. + - ``q[3:7]``: unit quaternion for the orientation of the floating base + in the inertial frame, formatted as :math:`[q_x, q_y, q_z, q_w]`. + - ``q[7:]``: joint angles in [rad]. + """ + if not self.full_model.existFrame(frame): + raise FrameNotFound(frame, self.full_model.frames) + frame_id = self.full_model.getFrameId(frame) + J: np.ndarray = pin.getFrameJacobian(self.full_model, self.full_data, frame_id, pin.ReferenceFrame.LOCAL) + return J[:, self._controlled_joint_indices] + + def get_transform_frame_to_world(self, frame: str) -> pin.SE3: + """Get the pose of a frame in the current configuration. + We override this method from the super class to solve the issue that in the default + Pink implementation, the frame placements do not take into account the non-controlled joints + being not at initial pose (which is a bad assumption when they are controlled by other controllers like a lower body controller). + + Args: + frame: Name of a frame, typically a link name from the URDF. + + Returns: + Current transform from the given frame to the world frame. + + Raises: + FrameNotFound: if the frame name is not found in the robot model. + """ + frame_id = self.full_model.getFrameId(frame) + try: + return self.full_data.oMf[frame_id].copy() + except IndexError as index_error: + raise FrameNotFound(frame, self.full_model.frames) from index_error + + def check_limits(self, tol: float = 1e-6, safety_break: bool = True) -> None: + """Check if limits are violated only if safety_break is enabled""" + if safety_break: + super().check_limits(tol, safety_break) + + @property + def controlled_joint_names_pinocchio_order(self) -> list[str]: + """Get the names of the controlled joints in the order of the pinocchio model.""" + return [self._all_joint_names[i] for i in self._controlled_joint_indices] + + @property + def all_joint_names_pinocchio_order(self) -> list[str]: + """Get the names of all joints in the order of the pinocchio model.""" + return self._all_joint_names diff --git a/source/isaaclab/isaaclab/controllers/utils.py b/source/isaaclab/isaaclab/controllers/utils.py index 70d627ac201a..f9624e29d196 100644 --- a/source/isaaclab/isaaclab/controllers/utils.py +++ b/source/isaaclab/isaaclab/controllers/utils.py @@ -9,6 +9,8 @@ """ import os +import re +import torch from isaacsim.core.utils.extensions import enable_extension @@ -98,3 +100,67 @@ def change_revolute_to_fixed(urdf_path: str, fixed_joints: list[str], verbose: b with open(urdf_path, "w") as file: file.write(content) + + +def change_revolute_to_fixed_regex(urdf_path: str, fixed_joints: list[str], verbose: bool = False): + """Change revolute joints to fixed joints in a URDF file. + + This function modifies a URDF file by changing specified revolute joints to fixed joints. + This is useful when you want to disable certain joints in a robot model. + + Args: + urdf_path: Path to the URDF file to modify. + fixed_joints: List of regular expressions matching joint names to convert from revolute to fixed. + verbose: Whether to print information about the changes being made. + """ + + with open(urdf_path) as file: + content = file.read() + + # Find all revolute joints in the URDF + revolute_joints = re.findall(r'', content) + + for joint in revolute_joints: + # Check if this joint matches any of the fixed joint patterns + should_fix = any(re.match(pattern, joint) for pattern in fixed_joints) + + if should_fix: + old_str = f'' + new_str = f'' + if verbose: + omni.log.warn(f"Replacing {joint} with fixed joint") + omni.log.warn(old_str) + omni.log.warn(new_str) + content = content.replace(old_str, new_str) + + with open(urdf_path, "w") as file: + file.write(content) + + +def load_torchscript_model(model_path: str, device: str = "cpu") -> torch.nn.Module: + """Load a TorchScript model from the specified path. + + This function only loads TorchScript models (.pt or .pth files created with torch.jit.save). + It will not work with raw PyTorch checkpoints (.pth files created with torch.save). + + Args: + model_path (str): Path to the TorchScript model file (.pt or .pth) + device (str, optional): Device to load the model on. Defaults to 'cpu'. + + Returns: + torch.nn.Module: The loaded TorchScript model in evaluation mode + + Raises: + FileNotFoundError: If the model file does not exist + """ + if not os.path.exists(model_path): + raise FileNotFoundError(f"TorchScript model file not found: {model_path}") + + try: + model = torch.jit.load(model_path, map_location=device) + model.eval() + print(f"Successfully loaded TorchScript model from {model_path}") + return model + except Exception as e: + print(f"Error loading TorchScript model: {e}") + return None diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py b/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py index b3a7401b522f..b1cbaaccb075 100644 --- a/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py @@ -5,6 +5,8 @@ """Retargeters for mapping input device data to robot commands.""" from .humanoid.fourier.gr1t2_retargeter import GR1T2Retargeter, GR1T2RetargeterCfg +from .humanoid.unitree.g1_lower_body_standing import G1LowerBodyStandingRetargeter, G1LowerBodyStandingRetargeterCfg +from .humanoid.unitree.g1_upper_body_retargeter import G1UpperBodyRetargeter, G1UpperBodyRetargeterCfg from .manipulator.gripper_retargeter import GripperRetargeter, GripperRetargeterCfg from .manipulator.se3_abs_retargeter import Se3AbsRetargeter, Se3AbsRetargeterCfg from .manipulator.se3_rel_retargeter import Se3RelRetargeter, Se3RelRetargeterCfg diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml new file mode 100644 index 000000000000..282b5d8438bf --- /dev/null +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml @@ -0,0 +1,23 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +retargeting: + finger_tip_link_names: + - thumb_tip + - index_tip + - middle_tip + low_pass_alpha: 0.2 + scaling_factor: 1.0 + target_joint_names: + - left_hand_thumb_0_joint + - left_hand_thumb_1_joint + - left_hand_thumb_2_joint + - left_hand_middle_0_joint + - left_hand_middle_1_joint + - left_hand_index_0_joint + - left_hand_index_1_joint + type: DexPilot + urdf_path: /tmp/G1_left_hand.urdf + wrist_link_name: base_link diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml new file mode 100644 index 000000000000..2629f9354fa6 --- /dev/null +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml @@ -0,0 +1,23 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +retargeting: + finger_tip_link_names: + - thumb_tip + - index_tip + - middle_tip + low_pass_alpha: 0.2 + scaling_factor: 1.0 + target_joint_names: + - right_hand_thumb_0_joint + - right_hand_thumb_1_joint + - right_hand_thumb_2_joint + - right_hand_middle_0_joint + - right_hand_middle_1_joint + - right_hand_index_0_joint + - right_hand_index_1_joint + type: DexPilot + urdf_path: /tmp/G1_right_hand.urdf + wrist_link_name: base_link diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_dex_retargeting_utils.py b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_dex_retargeting_utils.py new file mode 100644 index 000000000000..114f183be7ae --- /dev/null +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_dex_retargeting_utils.py @@ -0,0 +1,247 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np +import os +import torch +import yaml +from scipy.spatial.transform import Rotation as R + +import omni.log +from dex_retargeting.retargeting_config import RetargetingConfig + +from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR, retrieve_file_path + +# The index to map the OpenXR hand joints to the hand joints used +# in Dex-retargeting. +_HAND_JOINTS_INDEX = [1, 2, 3, 4, 5, 7, 8, 9, 10, 12, 13, 14, 15, 17, 18, 19, 20, 22, 23, 24, 25] + +# The transformation matrices to convert hand pose to canonical view. +_OPERATOR2MANO_RIGHT = np.array([ + [0, 0, 1], + [1, 0, 0], + [0, 1, 0], +]) + +_OPERATOR2MANO_LEFT = np.array([ + [0, 0, 1], + [1, 0, 0], + [0, 1, 0], +]) + +# G1 robot hand joint names - 2 fingers and 1 thumb configuration +_LEFT_HAND_JOINT_NAMES = [ + "left_hand_thumb_0_joint", # Thumb base (yaw axis) + "left_hand_thumb_1_joint", # Thumb middle (pitch axis) + "left_hand_thumb_2_joint", # Thumb tip + "left_hand_index_0_joint", # Index finger proximal + "left_hand_index_1_joint", # Index finger distal + "left_hand_middle_0_joint", # Middle finger proximal + "left_hand_middle_1_joint", # Middle finger distal +] + +_RIGHT_HAND_JOINT_NAMES = [ + "right_hand_thumb_0_joint", # Thumb base (yaw axis) + "right_hand_thumb_1_joint", # Thumb middle (pitch axis) + "right_hand_thumb_2_joint", # Thumb tip + "right_hand_index_0_joint", # Index finger proximal + "right_hand_index_1_joint", # Index finger distal + "right_hand_middle_0_joint", # Middle finger proximal + "right_hand_middle_1_joint", # Middle finger distal +] + + +class G1DexRetargeting: + """A class for hand retargeting with G1. + + Handles retargeting of OpenXRhand tracking data to G1 robot hand joint angles. + """ + + def __init__( + self, + hand_joint_names: list[str], + right_hand_config_filename: str = "g1_hand_right_dexpilot.yml", + left_hand_config_filename: str = "g1_hand_left_dexpilot.yml", + left_hand_urdf_path: str = f"{ISAACLAB_NUCLEUS_DIR}/Controllers/LocomanipulationAssets/unitree_g1_dexpilot_asset/G1_left_hand.urdf", + right_hand_urdf_path: str = f"{ISAACLAB_NUCLEUS_DIR}/Controllers/LocomanipulationAssets/unitree_g1_dexpilot_asset/G1_right_hand.urdf", + ): + """Initialize the hand retargeting. + + Args: + hand_joint_names: Names of hand joints in the robot model + right_hand_config_filename: Config file for right hand retargeting + left_hand_config_filename: Config file for left hand retargeting + """ + data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "data/")) + config_dir = os.path.join(data_dir, "configs/dex-retargeting") + + # Download urdf files from aws + local_left_urdf_path = retrieve_file_path(left_hand_urdf_path, force_download=True) + local_right_urdf_path = retrieve_file_path(right_hand_urdf_path, force_download=True) + + left_config_path = os.path.join(config_dir, left_hand_config_filename) + right_config_path = os.path.join(config_dir, right_hand_config_filename) + + # Update the YAML files with the correct URDF paths + self._update_yaml_with_urdf_path(left_config_path, local_left_urdf_path) + self._update_yaml_with_urdf_path(right_config_path, local_right_urdf_path) + + self._dex_left_hand = RetargetingConfig.load_from_file(left_config_path).build() + self._dex_right_hand = RetargetingConfig.load_from_file(right_config_path).build() + + self.left_dof_names = self._dex_left_hand.optimizer.robot.dof_joint_names + self.right_dof_names = self._dex_right_hand.optimizer.robot.dof_joint_names + self.dof_names = self.left_dof_names + self.right_dof_names + self.isaac_lab_hand_joint_names = hand_joint_names + + omni.log.info("[G1DexRetargeter] init done.") + + def _update_yaml_with_urdf_path(self, yaml_path: str, urdf_path: str): + """Update YAML file with the correct URDF path. + + Args: + yaml_path: Path to the YAML configuration file + urdf_path: Path to the URDF file to use + """ + try: + # Read the YAML file + with open(yaml_path) as file: + config = yaml.safe_load(file) + + # Update the URDF path in the configuration + if "retargeting" in config: + config["retargeting"]["urdf_path"] = urdf_path + omni.log.info(f"Updated URDF path in {yaml_path} to {urdf_path}") + else: + omni.log.warn(f"Unable to find 'retargeting' section in {yaml_path}") + + # Write the updated configuration back to the file + with open(yaml_path, "w") as file: + yaml.dump(config, file) + + except Exception as e: + omni.log.error(f"Error updating YAML file {yaml_path}: {e}") + + def convert_hand_joints(self, hand_poses: dict[str, np.ndarray], operator2mano: np.ndarray) -> np.ndarray: + """Prepares the hand joints data for retargeting. + + Args: + hand_poses: Dictionary containing hand pose data with joint positions and rotations + operator2mano: Transformation matrix to convert from operator to MANO frame + + Returns: + Joint positions with shape (21, 3) + """ + joint_position = np.zeros((21, 3)) + hand_joints = list(hand_poses.values()) + for i, joint_index in enumerate(_HAND_JOINTS_INDEX): + joint = hand_joints[joint_index] + joint_position[i] = joint[:3] + + # Convert hand pose to the canonical frame. + joint_position = joint_position - joint_position[0:1, :] + xr_wrist_quat = hand_poses.get("wrist")[3:] + # OpenXR hand uses w,x,y,z order for quaternions but scipy uses x,y,z,w order + wrist_rot = R.from_quat([xr_wrist_quat[1], xr_wrist_quat[2], xr_wrist_quat[3], xr_wrist_quat[0]]).as_matrix() + + return joint_position @ wrist_rot @ operator2mano + + def compute_ref_value(self, joint_position: np.ndarray, indices: np.ndarray, retargeting_type: str) -> np.ndarray: + """Computes reference value for retargeting. + + Args: + joint_position: Joint positions array + indices: Target link indices + retargeting_type: Type of retargeting ("POSITION" or other) + + Returns: + Reference value in cartesian space + """ + if retargeting_type == "POSITION": + return joint_position[indices, :] + else: + origin_indices = indices[0, :] + task_indices = indices[1, :] + ref_value = joint_position[task_indices, :] - joint_position[origin_indices, :] + return ref_value + + def compute_one_hand( + self, hand_joints: dict[str, np.ndarray], retargeting: RetargetingConfig, operator2mano: np.ndarray + ) -> np.ndarray: + """Computes retargeted joint angles for one hand. + + Args: + hand_joints: Dictionary containing hand joint data + retargeting: Retargeting configuration object + operator2mano: Transformation matrix from operator to MANO frame + + Returns: + Retargeted joint angles + """ + joint_pos = self.convert_hand_joints(hand_joints, operator2mano) + ref_value = self.compute_ref_value( + joint_pos, + indices=retargeting.optimizer.target_link_human_indices, + retargeting_type=retargeting.optimizer.retargeting_type, + ) + # Enable gradient calculation and inference mode in case some other script has disabled it + # This is necessary for the retargeting to work since it uses gradient features that + # are not available in inference mode + with torch.enable_grad(): + with torch.inference_mode(False): + return retargeting.retarget(ref_value) + + def get_joint_names(self) -> list[str]: + """Returns list of all joint names.""" + return self.dof_names + + def get_left_joint_names(self) -> list[str]: + """Returns list of left hand joint names.""" + return self.left_dof_names + + def get_right_joint_names(self) -> list[str]: + """Returns list of right hand joint names.""" + return self.right_dof_names + + def get_hand_indices(self, robot) -> np.ndarray: + """Gets indices of hand joints in robot's DOF array. + + Args: + robot: Robot object containing DOF information + + Returns: + Array of joint indices + """ + return np.array([robot.dof_names.index(name) for name in self.dof_names], dtype=np.int64) + + def compute_left(self, left_hand_poses: dict[str, np.ndarray]) -> np.ndarray: + """Computes retargeted joints for left hand. + + Args: + left_hand_poses: Dictionary of left hand joint poses + + Returns: + Retargeted joint angles for left hand + """ + if left_hand_poses is not None: + left_hand_q = self.compute_one_hand(left_hand_poses, self._dex_left_hand, _OPERATOR2MANO_LEFT) + else: + left_hand_q = np.zeros(len(_LEFT_HAND_JOINT_NAMES)) + return left_hand_q + + def compute_right(self, right_hand_poses: dict[str, np.ndarray]) -> np.ndarray: + """Computes retargeted joints for right hand. + + Args: + right_hand_poses: Dictionary of right hand joint poses + + Returns: + Retargeted joint angles for right hand + """ + if right_hand_poses is not None: + right_hand_q = self.compute_one_hand(right_hand_poses, self._dex_right_hand, _OPERATOR2MANO_RIGHT) + else: + right_hand_q = np.zeros(len(_RIGHT_HAND_JOINT_NAMES)) + return right_hand_q diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_lower_body_standing.py b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_lower_body_standing.py new file mode 100644 index 000000000000..9cf6ba09c426 --- /dev/null +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_lower_body_standing.py @@ -0,0 +1,28 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import torch +from dataclasses import dataclass + +from isaaclab.devices.retargeter_base import RetargeterBase, RetargeterCfg + + +@dataclass +class G1LowerBodyStandingRetargeterCfg(RetargeterCfg): + """Configuration for the G1 lower body standing retargeter.""" + + hip_height: float = 0.72 + """Height of the G1 robot hip in meters. The value is a fixed height suitable for G1 to do tabletop manipulation.""" + + +class G1LowerBodyStandingRetargeter(RetargeterBase): + """Provides lower body standing commands for the G1 robot.""" + + def __init__(self, cfg: G1LowerBodyStandingRetargeterCfg): + """Initialize the retargeter.""" + self.cfg = cfg + + def retarget(self, data: dict) -> torch.Tensor: + return torch.tensor([0.0, 0.0, 0.0, self.cfg.hip_height], device=self.cfg.sim_device) diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_upper_body_retargeter.py b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_upper_body_retargeter.py new file mode 100644 index 000000000000..5b6592908732 --- /dev/null +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_upper_body_retargeter.py @@ -0,0 +1,166 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import contextlib +import numpy as np +import torch +from dataclasses import dataclass + +import isaaclab.sim as sim_utils +import isaaclab.utils.math as PoseUtils +from isaaclab.devices import OpenXRDevice +from isaaclab.devices.retargeter_base import RetargeterBase, RetargeterCfg +from isaaclab.markers import VisualizationMarkers, VisualizationMarkersCfg + +# This import exception is suppressed because g1_dex_retargeting_utils depends on pinocchio which is not available on windows +with contextlib.suppress(Exception): + from .g1_dex_retargeting_utils import G1DexRetargeting + + +@dataclass +class G1UpperBodyRetargeterCfg(RetargeterCfg): + """Configuration for the G1UpperBody retargeter.""" + + enable_visualization: bool = False + num_open_xr_hand_joints: int = 100 + hand_joint_names: list[str] | None = None # List of robot hand joint names + + +class G1UpperBodyRetargeter(RetargeterBase): + """Retargets OpenXR data to G1 upper body commands. + + This retargeter maps hand tracking data from OpenXR to wrist and hand joint commands for the G1 robot. + It handles both left and right hands, converting poses of the hands in OpenXR format to appropriate wrist poses + and joint angles for the G1 robot's upper body. + """ + + def __init__( + self, + cfg: G1UpperBodyRetargeterCfg, + ): + """Initialize the G1 upper body retargeter. + + Args: + cfg: Configuration for the retargeter. + """ + + # Store device name for runtime retrieval + self._sim_device = cfg.sim_device + self._hand_joint_names = cfg.hand_joint_names + + # Initialize the hands controller + if cfg.hand_joint_names is not None: + self._hands_controller = G1DexRetargeting(cfg.hand_joint_names) + else: + raise ValueError("hand_joint_names must be provided in configuration") + + # Initialize visualization if enabled + self._enable_visualization = cfg.enable_visualization + self._num_open_xr_hand_joints = cfg.num_open_xr_hand_joints + if self._enable_visualization: + marker_cfg = VisualizationMarkersCfg( + prim_path="/Visuals/g1_hand_markers", + markers={ + "joint": sim_utils.SphereCfg( + radius=0.005, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(1.0, 0.0, 0.0)), + ), + }, + ) + self._markers = VisualizationMarkers(marker_cfg) + + def retarget(self, data: dict) -> torch.Tensor: + """Convert hand joint poses to robot end-effector commands. + + Args: + data: Dictionary mapping tracking targets to joint data dictionaries. + + Returns: + A tensor containing the retargeted commands: + - Left wrist pose (7) + - Right wrist pose (7) + - Hand joint angles (len(hand_joint_names)) + """ + + # Access the left and right hand data using the enum key + left_hand_poses = data[OpenXRDevice.TrackingTarget.HAND_LEFT] + right_hand_poses = data[OpenXRDevice.TrackingTarget.HAND_RIGHT] + + left_wrist = left_hand_poses.get("wrist") + right_wrist = right_hand_poses.get("wrist") + + # Handle case where wrist data is not available + if left_wrist is None or right_wrist is None: + # Set to default pose if no data available. + # pos=(0,0,0), quat=(1,0,0,0) (w,x,y,z) + default_pose = np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]) + if left_wrist is None: + left_wrist = default_pose + if right_wrist is None: + right_wrist = default_pose + + # Visualization if enabled + if self._enable_visualization: + joints_position = np.zeros((self._num_open_xr_hand_joints, 3)) + joints_position[::2] = np.array([pose[:3] for pose in left_hand_poses.values()]) + joints_position[1::2] = np.array([pose[:3] for pose in right_hand_poses.values()]) + self._markers.visualize(translations=torch.tensor(joints_position, device=self._sim_device)) + + # Compute retargeted hand joints + left_hands_pos = self._hands_controller.compute_left(left_hand_poses) + indexes = [self._hand_joint_names.index(name) for name in self._hands_controller.get_left_joint_names()] + left_retargeted_hand_joints = np.zeros(len(self._hands_controller.get_joint_names())) + left_retargeted_hand_joints[indexes] = left_hands_pos + left_hand_joints = left_retargeted_hand_joints + + right_hands_pos = self._hands_controller.compute_right(right_hand_poses) + indexes = [self._hand_joint_names.index(name) for name in self._hands_controller.get_right_joint_names()] + right_retargeted_hand_joints = np.zeros(len(self._hands_controller.get_joint_names())) + right_retargeted_hand_joints[indexes] = right_hands_pos + right_hand_joints = right_retargeted_hand_joints + retargeted_hand_joints = left_hand_joints + right_hand_joints + + # Convert numpy arrays to tensors and store in command buffer + left_wrist_tensor = torch.tensor( + self._retarget_abs(left_wrist, is_left=True), dtype=torch.float32, device=self._sim_device + ) + right_wrist_tensor = torch.tensor( + self._retarget_abs(right_wrist, is_left=False), dtype=torch.float32, device=self._sim_device + ) + hand_joints_tensor = torch.tensor(retargeted_hand_joints, dtype=torch.float32, device=self._sim_device) + + # Combine all tensors into a single tensor + return torch.cat([left_wrist_tensor, right_wrist_tensor, hand_joints_tensor]) + + def _retarget_abs(self, wrist: np.ndarray, is_left: bool) -> np.ndarray: + """Handle absolute pose retargeting. + + Args: + wrist: Wrist pose data from OpenXR. + is_left: True for the left hand, False for the right hand. + + Returns: + Retargeted wrist pose in USD control frame. + """ + wrist_pos = torch.tensor(wrist[:3], dtype=torch.float32) + wrist_quat = torch.tensor(wrist[3:], dtype=torch.float32) + + if is_left: + # Corresponds to a rotation of (0, 90, 90) in euler angles (x,y,z) + combined_quat = torch.tensor([0.7071, 0, 0.7071, 0], dtype=torch.float32) + else: + # Corresponds to a rotation of (0, -90, -90) in euler angles (x,y,z) + combined_quat = torch.tensor([0, -0.7071, 0, 0.7071], dtype=torch.float32) + + openxr_pose = PoseUtils.make_pose(wrist_pos, PoseUtils.matrix_from_quat(wrist_quat)) + transform_pose = PoseUtils.make_pose(torch.zeros(3), PoseUtils.matrix_from_quat(combined_quat)) + + result_pose = PoseUtils.pose_in_A_to_pose_in_B(transform_pose, openxr_pose) + pos, rot_mat = PoseUtils.unmake_pose(result_pose) + quat = PoseUtils.quat_from_matrix(rot_mat) + + return np.concatenate([pos.numpy(), quat.numpy()]) diff --git a/source/isaaclab/isaaclab/devices/teleop_device_factory.py b/source/isaaclab/isaaclab/devices/teleop_device_factory.py index f2a7eed32c6d..9c92a2489832 100644 --- a/source/isaaclab/isaaclab/devices/teleop_device_factory.py +++ b/source/isaaclab/isaaclab/devices/teleop_device_factory.py @@ -15,6 +15,10 @@ from isaaclab.devices.gamepad import Se2Gamepad, Se2GamepadCfg, Se3Gamepad, Se3GamepadCfg from isaaclab.devices.keyboard import Se2Keyboard, Se2KeyboardCfg, Se3Keyboard, Se3KeyboardCfg from isaaclab.devices.openxr.retargeters import ( + G1LowerBodyStandingRetargeter, + G1LowerBodyStandingRetargeterCfg, + G1UpperBodyRetargeter, + G1UpperBodyRetargeterCfg, GR1T2Retargeter, GR1T2RetargeterCfg, GripperRetargeter, @@ -50,6 +54,8 @@ Se3RelRetargeterCfg: Se3RelRetargeter, GripperRetargeterCfg: GripperRetargeter, GR1T2RetargeterCfg: GR1T2Retargeter, + G1UpperBodyRetargeterCfg: G1UpperBodyRetargeter, + G1LowerBodyStandingRetargeterCfg: G1LowerBodyStandingRetargeter, } diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py index 834d23d955a0..db478e7186e0 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/pink_actions_cfg.py @@ -26,15 +26,15 @@ class PinkInverseKinematicsActionCfg(ActionTermCfg): pink_controlled_joint_names: list[str] = MISSING """List of joint names or regular expression patterns that specify the joints controlled by pink IK.""" - ik_urdf_fixed_joint_names: list[str] = MISSING - """List of joint names that specify the joints to be locked in URDF.""" - hand_joint_names: list[str] = MISSING """List of joint names or regular expression patterns that specify the joints controlled by hand retargeting.""" controller: PinkIKControllerCfg = MISSING """Configuration for the Pink IK controller that will be used to solve the inverse kinematics.""" + enable_gravity_compensation: bool = True + """Whether to compensate for gravity in the Pink IK controller.""" + target_eef_link_names: dict[str, str] = MISSING """Dictionary mapping task names to controlled link names for the Pink IK controller. diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py index f1e9fd7a819c..79490c07e426 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/pink_task_space_actions.py @@ -5,7 +5,6 @@ from __future__ import annotations -import copy import torch from collections.abc import Sequence from typing import TYPE_CHECKING @@ -15,6 +14,7 @@ import isaaclab.utils.math as math_utils from isaaclab.assets.articulation import Articulation from isaaclab.controllers.pink_ik import PinkIKController +from isaaclab.controllers.pink_ik.local_frame_task import LocalFrameTask from isaaclab.managers.action_manager import ActionTerm if TYPE_CHECKING: @@ -27,8 +27,8 @@ class PinkInverseKinematicsAction(ActionTerm): r"""Pink Inverse Kinematics action term. - This action term processes the action tensor and sets these setpoints in the pink IK framework - The action tensor is ordered in the order of the tasks defined in PinkIKControllerCfg + This action term processes the action tensor and sets these setpoints in the pink IK framework. + The action tensor is ordered in the order of the tasks defined in PinkIKControllerCfg. """ cfg: pink_actions_cfg.PinkInverseKinematicsActionCfg @@ -46,53 +46,78 @@ def __init__(self, cfg: pink_actions_cfg.PinkInverseKinematicsActionCfg, env: Ma """ super().__init__(cfg, env) - # Resolve joint IDs and names based on the configuration - self._pink_controlled_joint_ids, self._pink_controlled_joint_names = self._asset.find_joints( + self._env = env + self._sim_dt = env.sim.get_physics_dt() + + # Initialize joint information + self._initialize_joint_info() + + # Initialize IK controllers + self._initialize_ik_controllers() + + # Initialize action tensors + self._raw_actions = torch.zeros(self.num_envs, self.action_dim, device=self.device) + self._processed_actions = torch.zeros_like(self._raw_actions) + + # PhysX Articulation Floating joint indices offset from IsaacLab Articulation joint indices + self._physx_floating_joint_indices_offset = 6 + + # Pre-allocate tensors for runtime use + self._initialize_helper_tensors() + + def _initialize_joint_info(self) -> None: + """Initialize joint IDs and names based on configuration.""" + # Resolve pink controlled joints + self._isaaclab_controlled_joint_ids, self._isaaclab_controlled_joint_names = self._asset.find_joints( self.cfg.pink_controlled_joint_names ) - self.cfg.controller.joint_names = self._pink_controlled_joint_names + self.cfg.controller.joint_names = self._isaaclab_controlled_joint_names + self._isaaclab_all_joint_ids = list(range(len(self._asset.data.joint_names))) + self.cfg.controller.all_joint_names = self._asset.data.joint_names + + # Resolve hand joints self._hand_joint_ids, self._hand_joint_names = self._asset.find_joints(self.cfg.hand_joint_names) - self._joint_ids = self._pink_controlled_joint_ids + self._hand_joint_ids - self._joint_names = self._pink_controlled_joint_names + self._hand_joint_names - # Initialize the Pink IK controller - assert env.num_envs > 0, "Number of environments specified are less than 1." + # Combine all joint information + self._controlled_joint_ids = self._isaaclab_controlled_joint_ids + self._hand_joint_ids + self._controlled_joint_names = self._isaaclab_controlled_joint_names + self._hand_joint_names + + def _initialize_ik_controllers(self) -> None: + """Initialize Pink IK controllers for all environments.""" + assert self._env.num_envs > 0, "Number of environments specified are less than 1." + self._ik_controllers = [] - for _ in range(env.num_envs): + for _ in range(self._env.num_envs): self._ik_controllers.append( - PinkIKController(cfg=self.cfg.controller.copy(), robot_cfg=env.scene.cfg.robot, device=self.device) + PinkIKController( + cfg=self.cfg.controller.copy(), + robot_cfg=self._env.scene.cfg.robot, + device=self.device, + controlled_joint_indices=self._isaaclab_controlled_joint_ids, + ) ) - # Create tensors to store raw and processed actions - self._raw_actions = torch.zeros(self.num_envs, self.action_dim, device=self.device) - self._processed_actions = torch.zeros_like(self.raw_actions) - - # Get the simulation time step - self._sim_dt = env.sim.get_physics_dt() - - self.total_time = 0 # Variable to accumulate the total time - self.num_runs = 0 # Counter for the number of runs - - # Save the base_link_frame pose in the world frame as a transformation matrix in - # order to transform the desired pose of the controlled_frame to be with respect to the base_link_frame - # Shape of env.scene[self.cfg.articulation_name].data.body_link_state_w is (num_instances, num_bodies, 13) - base_link_frame_in_world_origin = env.scene[self.cfg.controller.articulation_name].data.body_link_state_w[ - :, - env.scene[self.cfg.controller.articulation_name].data.body_names.index(self.cfg.controller.base_link_name), - :7, - ] + def _initialize_helper_tensors(self) -> None: + """Pre-allocate tensors and cache values for performance optimization.""" + # Cache frequently used tensor versions of joint IDs to avoid repeated creation + self._controlled_joint_ids_tensor = torch.tensor(self._controlled_joint_ids, device=self.device) - # Get robot base link frame in env origin frame - base_link_frame_in_env_origin = copy.deepcopy(base_link_frame_in_world_origin) - base_link_frame_in_env_origin[:, :3] -= self._env.scene.env_origins + # Cache base link index to avoid string lookup every time + articulation_data = self._env.scene[self.cfg.controller.articulation_name].data + self._base_link_idx = articulation_data.body_names.index(self.cfg.controller.base_link_name) - self.base_link_frame_in_env_origin = math_utils.make_pose( - base_link_frame_in_env_origin[:, :3], math_utils.matrix_from_quat(base_link_frame_in_env_origin[:, 3:7]) + # Pre-allocate working tensors + # Count only FrameTask instances in variable_input_tasks (not all tasks) + num_frame_tasks = sum( + 1 for task in self._ik_controllers[0].cfg.variable_input_tasks if isinstance(task, FrameTask) ) + self._num_frame_tasks = num_frame_tasks + self._controlled_frame_poses = torch.zeros(num_frame_tasks, self.num_envs, 4, 4, device=self.device) - # """ - # Properties. - # """ + # Pre-allocate tensor for base frame computations + self._base_link_frame_buffer = torch.zeros(self.num_envs, 4, 4, device=self.device) + + # ==================== Properties ==================== @property def hand_joint_dim(self) -> int: @@ -153,7 +178,7 @@ def IO_descriptor(self) -> GenericActionIODescriptor: self._IO_descriptor.shape = (self.action_dim,) self._IO_descriptor.dtype = str(self.raw_actions.dtype) self._IO_descriptor.action_type = "PinkInverseKinematicsAction" - self._IO_descriptor.pink_controller_joint_names = self._pink_controlled_joint_names + self._IO_descriptor.pink_controller_joint_names = self._isaaclab_controlled_joint_names self._IO_descriptor.hand_joint_names = self._hand_joint_names self._IO_descriptor.extras["controller_cfg"] = self.cfg.controller.__dict__ return self._IO_descriptor @@ -162,75 +187,175 @@ def IO_descriptor(self) -> GenericActionIODescriptor: # Operations. # """ - def process_actions(self, actions: torch.Tensor): + def process_actions(self, actions: torch.Tensor) -> None: """Process the input actions and set targets for each task. Args: actions: The input actions tensor. """ - # Store the raw actions + # Store raw actions self._raw_actions[:] = actions - # Make a copy of actions before modifying so that raw actions are not modified - actions_clone = actions.clone() - - # Extract hand joint positions (last 22 values) - self._target_hand_joint_positions = actions_clone[:, -self.hand_joint_dim :] - - # The action tensor provides the desired pose of the controlled_frame with respect to the env origin frame - # But the pink IK controller expects the desired pose of the controlled_frame with respect to the base_link_frame - # So we need to transform the desired pose of the controlled_frame to be with respect to the base_link_frame - - # Get the controlled_frame pose wrt to the env origin frame - all_controlled_frames_in_env_origin = [] - # The contrllers for all envs are the same, hence just using the first one to get the number of variable_input_tasks - for task_index in range(len(self._ik_controllers[0].cfg.variable_input_tasks)): - controlled_frame_in_env_origin_pos = actions_clone[ - :, task_index * self.pose_dim : task_index * self.pose_dim + self.position_dim - ] - controlled_frame_in_env_origin_quat = actions_clone[ - :, task_index * self.pose_dim + self.position_dim : (task_index + 1) * self.pose_dim - ] - controlled_frame_in_env_origin = math_utils.make_pose( - controlled_frame_in_env_origin_pos, math_utils.matrix_from_quat(controlled_frame_in_env_origin_quat) - ) - all_controlled_frames_in_env_origin.append(controlled_frame_in_env_origin) - # Stack all the controlled_frame poses in the env origin frame. Shape is (num_tasks, num_envs , 4, 4) - all_controlled_frames_in_env_origin = torch.stack(all_controlled_frames_in_env_origin) + # Extract hand joint positions directly (no cloning needed) + self._target_hand_joint_positions = actions[:, -self.hand_joint_dim :] - # Transform the controlled_frame to be with respect to the base_link_frame using batched matrix multiplication - controlled_frame_in_base_link_frame = math_utils.pose_in_A_to_pose_in_B( - all_controlled_frames_in_env_origin, math_utils.pose_inv(self.base_link_frame_in_env_origin) + # Get base link frame transformation + self.base_link_frame_in_world_rf = self._get_base_link_frame_transform() + + # Process controlled frame poses (pass original actions, no clone needed) + controlled_frame_poses = self._extract_controlled_frame_poses(actions) + transformed_poses = self._transform_poses_to_base_link_frame(controlled_frame_poses) + + # Set targets for all tasks + self._set_task_targets(transformed_poses) + + def _get_base_link_frame_transform(self) -> torch.Tensor: + """Get the base link frame transformation matrix. + + Returns: + Base link frame transformation matrix. + """ + # Get base link frame pose in world origin using cached index + articulation_data = self._env.scene[self.cfg.controller.articulation_name].data + base_link_frame_in_world_origin = articulation_data.body_link_state_w[:, self._base_link_idx, :7] + + # Transform to environment origin frame (reuse buffer to avoid allocation) + torch.sub( + base_link_frame_in_world_origin[:, :3], + self._env.scene.env_origins, + out=self._base_link_frame_buffer[:, :3, 3], ) - controlled_frame_in_base_link_frame_pos, controlled_frame_in_base_link_frame_mat = math_utils.unmake_pose( - controlled_frame_in_base_link_frame + # Copy orientation (avoid clone) + base_link_frame_quat = base_link_frame_in_world_origin[:, 3:7] + + # Create transformation matrix + return math_utils.make_pose( + self._base_link_frame_buffer[:, :3, 3], math_utils.matrix_from_quat(base_link_frame_quat) ) - # Loop through each task and set the target + def _extract_controlled_frame_poses(self, actions: torch.Tensor) -> torch.Tensor: + """Extract controlled frame poses from action tensor. + + Args: + actions: The action tensor. + + Returns: + Stacked controlled frame poses tensor. + """ + # Use pre-allocated tensor instead of list operations + for task_index in range(self._num_frame_tasks): + # Extract position and orientation for this task + pos_start = task_index * self.pose_dim + pos_end = pos_start + self.position_dim + quat_start = pos_end + quat_end = (task_index + 1) * self.pose_dim + + position = actions[:, pos_start:pos_end] + quaternion = actions[:, quat_start:quat_end] + + # Create pose matrix directly into pre-allocated tensor + self._controlled_frame_poses[task_index] = math_utils.make_pose( + position, math_utils.matrix_from_quat(quaternion) + ) + + return self._controlled_frame_poses + + def _transform_poses_to_base_link_frame(self, poses: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Transform poses from world frame to base link frame. + + Args: + poses: Poses in world frame. + + Returns: + Tuple of (positions, rotation_matrices) in base link frame. + """ + # Transform poses to base link frame + base_link_inv = math_utils.pose_inv(self.base_link_frame_in_world_rf) + transformed_poses = math_utils.pose_in_A_to_pose_in_B(poses, base_link_inv) + + # Extract position and rotation + positions, rotation_matrices = math_utils.unmake_pose(transformed_poses) + + return positions, rotation_matrices + + def _set_task_targets(self, transformed_poses: tuple[torch.Tensor, torch.Tensor]) -> None: + """Set targets for all tasks across all environments. + + Args: + transformed_poses: Tuple of (positions, rotation_matrices) in base link frame. + """ + positions, rotation_matrices = transformed_poses + for env_index, ik_controller in enumerate(self._ik_controllers): - for task_index, task in enumerate(ik_controller.cfg.variable_input_tasks): - if isinstance(task, FrameTask): + for frame_task_index, task in enumerate(ik_controller.cfg.variable_input_tasks): + if isinstance(task, LocalFrameTask): + target = task.transform_target_to_base + elif isinstance(task, FrameTask): target = task.transform_target_to_world - target.translation = controlled_frame_in_base_link_frame_pos[task_index, env_index, :].cpu().numpy() - target.rotation = controlled_frame_in_base_link_frame_mat[task_index, env_index, :].cpu().numpy() - task.set_target(target) + else: + continue + + # Set position and rotation targets using frame_task_index + target.translation = positions[frame_task_index, env_index, :].cpu().numpy() + target.rotation = rotation_matrices[frame_task_index, env_index, :].cpu().numpy() - def apply_actions(self): - # start_time = time.time() # Capture the time before the step + task.set_target(target) + + # ==================== Action Application ==================== + + def apply_actions(self) -> None: """Apply the computed joint positions based on the inverse kinematics solution.""" - all_envs_joint_pos_des = [] + # Compute IK solutions for all environments + ik_joint_positions = self._compute_ik_solutions() + + # Combine IK and hand joint positions + all_joint_positions = torch.cat((ik_joint_positions, self._target_hand_joint_positions), dim=1) + self._processed_actions = all_joint_positions + + # Apply gravity compensation to arm joints + if self.cfg.enable_gravity_compensation: + self._apply_gravity_compensation() + + # Apply joint position targets + self._asset.set_joint_position_target(self._processed_actions, self._controlled_joint_ids) + + def _apply_gravity_compensation(self) -> None: + """Apply gravity compensation to arm joints if not disabled in props.""" + if not self._asset.cfg.spawn.rigid_props.disable_gravity: + # Get gravity compensation forces using cached tensor + if self._asset.is_fixed_base: + gravity = torch.zeros_like( + self._asset.root_physx_view.get_gravity_compensation_forces()[:, self._controlled_joint_ids_tensor] + ) + else: + # If floating base, then need to skip the first 6 joints (base) + gravity = self._asset.root_physx_view.get_gravity_compensation_forces()[ + :, self._controlled_joint_ids_tensor + self._physx_floating_joint_indices_offset + ] + + # Apply gravity compensation to arm joints + self._asset.set_joint_effort_target(gravity, self._controlled_joint_ids) + + def _compute_ik_solutions(self) -> torch.Tensor: + """Compute IK solutions for all environments. + + Returns: + IK joint positions tensor for all environments. + """ + ik_solutions = [] + for env_index, ik_controller in enumerate(self._ik_controllers): - curr_joint_pos = self._asset.data.joint_pos[:, self._pink_controlled_joint_ids].cpu().numpy()[env_index] - joint_pos_des = ik_controller.compute(curr_joint_pos, self._sim_dt) - all_envs_joint_pos_des.append(joint_pos_des) - all_envs_joint_pos_des = torch.stack(all_envs_joint_pos_des) + # Get current joint positions for this environment + current_joint_pos = self._asset.data.joint_pos.cpu().numpy()[env_index] + + # Compute IK solution + joint_pos_des = ik_controller.compute(current_joint_pos, self._sim_dt) + ik_solutions.append(joint_pos_des) - # Combine IK joint positions with hand joint positions - all_envs_joint_pos_des = torch.cat((all_envs_joint_pos_des, self._target_hand_joint_positions), dim=1) - self._processed_actions = all_envs_joint_pos_des + return torch.stack(ik_solutions) - self._asset.set_joint_position_target(self._processed_actions, self._joint_ids) + # ==================== Reset ==================== def reset(self, env_ids: Sequence[int] | None = None) -> None: """Reset the action term for specified environments. diff --git a/source/isaaclab/test/controllers/test_controller_utils.py b/source/isaaclab/test/controllers/test_controller_utils.py new file mode 100644 index 000000000000..dd31e7929bb4 --- /dev/null +++ b/source/isaaclab/test/controllers/test_controller_utils.py @@ -0,0 +1,659 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Test cases for Isaac Lab controller utilities.""" + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +import os + +# Import the function to test +import tempfile +import torch + +import pytest + +from isaaclab.controllers.utils import change_revolute_to_fixed, change_revolute_to_fixed_regex, load_torchscript_model + + +@pytest.fixture +def mock_urdf_content(): + """Create mock URDF content for testing.""" + return """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +@pytest.fixture +def test_urdf_file(mock_urdf_content): + """Create a temporary URDF file for testing.""" + # Create a temporary directory for test files + test_dir = tempfile.mkdtemp() + + # Create the test URDF file + test_urdf_path = os.path.join(test_dir, "test_robot.urdf") + with open(test_urdf_path, "w") as f: + f.write(mock_urdf_content) + + yield test_urdf_path + + # Clean up the temporary directory and all its contents + import shutil + + shutil.rmtree(test_dir) + + +# ============================================================================= +# Test cases for change_revolute_to_fixed function +# ============================================================================= + + +def test_single_joint_conversion(test_urdf_file, mock_urdf_content): + """Test converting a single revolute joint to fixed.""" + # Test converting shoulder_to_elbow joint + fixed_joints = ["shoulder_to_elbow"] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the joint was converted + assert '' in modified_content + assert '' not in modified_content + + # Check that other revolute joints remain unchanged + assert '' in modified_content + assert '' in modified_content + + +def test_multiple_joints_conversion(test_urdf_file, mock_urdf_content): + """Test converting multiple revolute joints to fixed.""" + # Test converting multiple joints + fixed_joints = ["base_to_shoulder", "elbow_to_wrist"] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that both joints were converted + assert '' in modified_content + assert '' in modified_content + assert '' not in modified_content + assert '' not in modified_content + + # Check that the middle joint remains unchanged + assert '' in modified_content + + +def test_non_existent_joint(test_urdf_file, mock_urdf_content): + """Test behavior when trying to convert a non-existent joint.""" + # Try to convert a joint that doesn't exist + fixed_joints = ["non_existent_joint"] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the file content remains unchanged + assert modified_content == mock_urdf_content + + +def test_mixed_existent_and_non_existent_joints(test_urdf_file, mock_urdf_content): + """Test converting a mix of existent and non-existent joints.""" + # Try to convert both existent and non-existent joints + fixed_joints = ["base_to_shoulder", "non_existent_joint", "elbow_to_wrist"] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that existent joints were converted + assert '' in modified_content + assert '' in modified_content + + # Check that non-existent joint didn't cause issues + assert '' not in modified_content + + +def test_already_fixed_joint(test_urdf_file, mock_urdf_content): + """Test behavior when trying to convert an already fixed joint.""" + # Try to convert a joint that is already fixed + fixed_joints = ["wrist_to_gripper"] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the file content remains unchanged (no conversion happened) + assert modified_content == mock_urdf_content + + +def test_empty_joints_list(test_urdf_file, mock_urdf_content): + """Test behavior when passing an empty list of joints.""" + # Try to convert with empty list + fixed_joints = [] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the file content remains unchanged + assert modified_content == mock_urdf_content + + +def test_file_not_found(test_urdf_file): + """Test behavior when URDF file doesn't exist.""" + non_existent_path = os.path.join(os.path.dirname(test_urdf_file), "non_existent.urdf") + fixed_joints = ["base_to_shoulder"] + + # Should raise FileNotFoundError + with pytest.raises(FileNotFoundError): + change_revolute_to_fixed(non_existent_path, fixed_joints) + + +def test_preserve_other_content(test_urdf_file): + """Test that other content in the URDF file is preserved.""" + fixed_joints = ["shoulder_to_elbow"] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that other content is preserved + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + + # Check that the fixed joint remains unchanged + assert '' in modified_content + + +def test_joint_attributes_preserved(test_urdf_file): + """Test that joint attributes other than type are preserved.""" + fixed_joints = ["base_to_shoulder"] + change_revolute_to_fixed(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the joint was converted but other attributes preserved + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + + +# ============================================================================= +# Test cases for change_revolute_to_fixed_regex function +# ============================================================================= + + +def test_regex_single_joint_conversion(test_urdf_file, mock_urdf_content): + """Test converting a single revolute joint to fixed using regex pattern.""" + # Test converting shoulder_to_elbow joint using exact match + fixed_joints = ["shoulder_to_elbow"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the joint was converted + assert '' in modified_content + assert '' not in modified_content + + # Check that other revolute joints remain unchanged + assert '' in modified_content + assert '' in modified_content + + +def test_regex_pattern_matching(test_urdf_file, mock_urdf_content): + """Test converting joints using regex patterns.""" + # Test converting joints that contain "to" in their name + fixed_joints = [r".*to.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that all joints with "to" in the name were converted + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + + # Check that the fixed joint remains unchanged + assert '' in modified_content + + +def test_regex_multiple_patterns(test_urdf_file, mock_urdf_content): + """Test converting joints using multiple regex patterns.""" + # Test converting joints that start with "base" or end with "wrist" + fixed_joints = [r"^base.*", r".*wrist$"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that matching joints were converted + assert '' in modified_content + assert '' in modified_content + + # Check that non-matching joints remain unchanged + assert '' in modified_content + + +def test_regex_case_sensitive_matching(test_urdf_file, mock_urdf_content): + """Test that regex matching is case sensitive.""" + # Test with uppercase pattern that won't match lowercase joint names + fixed_joints = [r".*TO.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that no joints were converted (case sensitive) + assert modified_content == mock_urdf_content + + +def test_regex_partial_word_matching(test_urdf_file, mock_urdf_content): + """Test converting joints using partial word matching.""" + # Test converting joints that contain "shoulder" in their name + fixed_joints = [r".*shoulder.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that shoulder-related joints were converted + assert '' in modified_content + assert '' in modified_content + + # Check that other joints remain unchanged + assert '' in modified_content + + +def test_regex_no_matches(test_urdf_file, mock_urdf_content): + """Test behavior when regex patterns don't match any joints.""" + # Test with pattern that won't match any joint names + fixed_joints = [r"^nonexistent.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the file content remains unchanged + assert modified_content == mock_urdf_content + + +def test_regex_empty_patterns_list(test_urdf_file, mock_urdf_content): + """Test behavior when passing an empty list of regex patterns.""" + # Try to convert with empty list + fixed_joints = [] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the file content remains unchanged + assert modified_content == mock_urdf_content + + +def test_regex_file_not_found(test_urdf_file): + """Test behavior when URDF file doesn't exist for regex function.""" + non_existent_path = os.path.join(os.path.dirname(test_urdf_file), "non_existent.urdf") + fixed_joints = [r".*to.*"] + + # Should raise FileNotFoundError + with pytest.raises(FileNotFoundError): + change_revolute_to_fixed_regex(non_existent_path, fixed_joints) + + +def test_regex_preserve_other_content(test_urdf_file): + """Test that other content in the URDF file is preserved with regex function.""" + fixed_joints = [r".*shoulder.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that other content is preserved + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + + # Check that the fixed joint remains unchanged + assert '' in modified_content + + +def test_regex_joint_attributes_preserved(test_urdf_file): + """Test that joint attributes other than type are preserved with regex function.""" + fixed_joints = [r"^base.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the joint was converted but other attributes preserved + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + + +def test_regex_complex_pattern(test_urdf_file, mock_urdf_content): + """Test converting joints using a complex regex pattern.""" + # Test converting joints that have "to" and end with a word starting with "w" + fixed_joints = [r".*to.*w.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that matching joints were converted + assert '' in modified_content + assert '' in modified_content + + # Check that non-matching joints remain unchanged + assert '' in modified_content + + +def test_regex_already_fixed_joint(test_urdf_file, mock_urdf_content): + """Test behavior when regex pattern matches an already fixed joint.""" + # Try to convert joints that contain "gripper" (which is already fixed) + fixed_joints = [r".*gripper.*"] + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that the file content remains unchanged (no conversion happened) + assert modified_content == mock_urdf_content + + +def test_regex_special_characters(test_urdf_file, mock_urdf_content): + """Test regex patterns with special characters.""" + # Test with pattern that includes special regex characters + fixed_joints = [r".*to.*"] # This should match joints with "to" + change_revolute_to_fixed_regex(test_urdf_file, fixed_joints) + + # Read the modified file + with open(test_urdf_file) as f: + modified_content = f.read() + + # Check that joints with "to" were converted + assert '' in modified_content + assert '' in modified_content + assert '' in modified_content + + # Check that the fixed joint remains unchanged + assert '' in modified_content + + +# ============================================================================= +# Test cases for load_torchscript_model function +# ============================================================================= + + +@pytest.fixture +def policy_model_path(): + """Path to the test TorchScript model.""" + return "source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/policy/locomotion/agile_locomotion.pt" + + +def test_load_torchscript_model_success(policy_model_path): + """Test successful loading of a TorchScript model.""" + model = load_torchscript_model(policy_model_path) + + # Check that model was loaded successfully + assert model is not None + assert isinstance(model, torch.nn.Module) + + # Check that model is in evaluation mode + assert model.training is False + + +def test_load_torchscript_model_cpu_device(policy_model_path): + """Test loading TorchScript model on CPU device.""" + model = load_torchscript_model(policy_model_path, device="cpu") + + # Check that model was loaded successfully + assert model is not None + assert isinstance(model, torch.nn.Module) + + # Check that model is in evaluation mode + assert model.training is False + + +def test_load_torchscript_model_cuda_device(policy_model_path): + """Test loading TorchScript model on CUDA device if available.""" + if torch.cuda.is_available(): + model = load_torchscript_model(policy_model_path, device="cuda") + + # Check that model was loaded successfully + assert model is not None + assert isinstance(model, torch.nn.Module) + + # Check that model is in evaluation mode + assert model.training is False + else: + # Skip test if CUDA is not available + pytest.skip("CUDA not available") + + +def test_load_torchscript_model_file_not_found(): + """Test behavior when TorchScript model file doesn't exist.""" + non_existent_path = "non_existent_model.pt" + + # Should raise FileNotFoundError + with pytest.raises(FileNotFoundError): + load_torchscript_model(non_existent_path) + + +def test_load_torchscript_model_invalid_file(): + """Test behavior when trying to load an invalid TorchScript file.""" + # Create a temporary file with invalid content + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as temp_file: + temp_file.write(b"invalid torchscript content") + temp_file_path = temp_file.name + + try: + # Should handle the error gracefully and return None + model = load_torchscript_model(temp_file_path) + assert model is None + finally: + # Clean up the temporary file + os.unlink(temp_file_path) + + +def test_load_torchscript_model_empty_file(): + """Test behavior when trying to load an empty TorchScript file.""" + # Create a temporary empty file + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as temp_file: + temp_file_path = temp_file.name + + try: + # Should handle the error gracefully and return None + model = load_torchscript_model(temp_file_path) + assert model is None + finally: + # Clean up the temporary file + os.unlink(temp_file_path) + + +def test_load_torchscript_model_different_device_mapping(policy_model_path): + """Test loading model with different device mapping.""" + # Test with specific device mapping + model = load_torchscript_model(policy_model_path, device="cpu") + + # Check that model was loaded successfully + assert model is not None + assert isinstance(model, torch.nn.Module) + + +def test_load_torchscript_model_evaluation_mode(policy_model_path): + """Test that loaded model is in evaluation mode.""" + model = load_torchscript_model(policy_model_path) + + # Check that model is in evaluation mode + assert model.training is False + + # Verify we can set it to training mode and back + model.train() + assert model.training is True + model.eval() + assert model.training is False + + +def test_load_torchscript_model_inference_capability(policy_model_path): + """Test that loaded model can perform inference.""" + model = load_torchscript_model(policy_model_path) + + # Check that model was loaded successfully + assert model is not None + + # Try to create a dummy input tensor (actual input shape depends on the model) + # This is a basic test to ensure the model can handle tensor inputs + try: + # Create a dummy input tensor (adjust size based on expected input) + dummy_input = torch.randn(1, 75) # Adjust dimensions as needed + + # Try to run inference (this might fail if input shape is wrong, but shouldn't crash) + with torch.no_grad(): + try: + output = model(dummy_input) + # If successful, check that output is a tensor + assert isinstance(output, torch.Tensor) + except (RuntimeError, ValueError): + # Expected if input shape doesn't match model expectations + # This is acceptable for this test + pass + except Exception: + # If model doesn't accept this input format, that's okay for this test + # The main goal is to ensure the model loads without crashing + pass + + +def test_load_torchscript_model_error_handling(): + """Test error handling when loading fails.""" + # Create a temporary file that will cause a loading error + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".pt", delete=False) as temp_file: + temp_file.write(b"definitely not a torchscript model") + temp_file_path = temp_file.name + + try: + # Should handle the error gracefully and return None + model = load_torchscript_model(temp_file_path) + assert model is None + finally: + # Clean up the temporary file + os.unlink(temp_file_path) diff --git a/source/isaaclab/test/controllers/test_ik_configs/README.md b/source/isaaclab/test/controllers/test_ik_configs/README.md new file mode 100644 index 000000000000..ccbdae06b52e --- /dev/null +++ b/source/isaaclab/test/controllers/test_ik_configs/README.md @@ -0,0 +1,119 @@ +# Test Configuration Generation Guide + +This document explains how to generate test configurations for the Pink IK controller tests used in `test_pink_ik.py`. + +## File Structure + +Test configurations are JSON files with the following structure: + +```json +{ + "tolerances": { + "position": ..., + "pd_position": ..., + "rotation": ..., + "check_errors": true + }, + "allowed_steps_to_settle": ..., + "tests": { + "test_name": { + "left_hand_pose": [...], + "right_hand_pose": [...], + "allowed_steps_per_motion": ..., + "repeat": ... + } + } +} +``` + +## Parameters + +### Tolerances +- **position**: Maximum position error in meters +- **pd_position**: Maximum PD controller error in meters +- **rotation**: Maximum rotation error in radians +- **check_errors**: Whether to verify errors (should be `true`) + +### Test Parameters +- **allowed_steps_to_settle**: Initial settling steps (typically 100) +- **allowed_steps_per_motion**: Steps per motion phase +- **repeat**: Number of test repetitions +- **requires_waist_bending**: Whether the test requires waist bending (boolean) + +## Coordinate System + +### Robot Reset Pose +From `g1_locomanipulation_robot_cfg.py`: +- **Base position**: (0, 0, 0.75) - 75cm above ground +- **Base orientation**: 90° rotation around X-axis (facing forward) +- **Joint positions**: Standing pose with slight knee bend + +### EEF Pose Format +Each pose: `[x, y, z, qw, qx, qy, qz]` +- **Position**: Cartesian coordinates relative to robot base frame +- **Orientation**: Quaternion relative to the world. Typically you want this to start in the same orientation as robot base. (e.g. if robot base is reset to (0.7071, 0.0, 0.0, 0.7071), hand pose should be the same) + +**Note**: The system automatically compensates for hand rotational offsets, so specify orientations relative to the robot's reset orientation. + +## Creating Configurations + +### Step 1: Choose Robot Type +- `pink_ik_g1_test_configs.json` for G1 robot +- `pink_ik_gr1_test_configs.json` for GR1 robot + +### Step 2: Define Tolerances +```json +"tolerances": { + "position": 0.003, + "pd_position": 0.001, + "rotation": 0.017, + "check_errors": true +} +``` + +### Step 3: Create Test Movements +Common test types: +- **stay_still**: Same pose repeated +- **horizontal_movement**: Side-to-side movement +- **vertical_movement**: Up-and-down movement +- **rotation_movements**: Hand orientation changes + +### Step 4: Specify Hand Poses +```json +"horizontal_movement": { + "left_hand_pose": [ + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.28, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "right_hand_pose": [ + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.28, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "allowed_steps_per_motion": 100, + "repeat": 2, + "requires_waist_bending": false +} +``` + +## Pose Guidelines + +### Orientation Examples +- **Default**: `[0.7071, 0.0, 0.0, 0.7071]` (90° around X-axis) +- **Z-rotation**: `[0.5, 0.0, 0.0, 0.866]` (60° around Z) +- **Y-rotation**: `[0.866, 0.0, 0.5, 0.0]` (60° around Y) + +## Testing Process + +1. Robot starts in reset pose and settles +2. Moves through each pose in sequence +3. Errors computed and verified against tolerances +4. Sequence repeats specified number of times + +### Waist Bending Logic +Tests marked with `"requires_waist_bending": true` will only run if waist joints are enabled in the environment configuration. The test system automatically detects waist capability by checking if waist joints (`waist_yaw_joint`, `waist_pitch_joint`, `waist_roll_joint`) are included in the `pink_controlled_joint_names` list. + +## Troubleshooting + +- **Can't reach target**: Check if within safe workspace +- **High errors**: Increase tolerances or adjust poses +- **Test failures**: Increase `allowed_steps_per_motion` diff --git a/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json new file mode 100644 index 000000000000..f5d0d60717da --- /dev/null +++ b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_g1_test_configs.json @@ -0,0 +1,111 @@ +{ + "tolerances": { + "position": 0.003, + "pd_position": 0.002, + "rotation": 0.017, + "check_errors": true + }, + "allowed_steps_to_settle": 50, + "tests": { + "horizontal_movement": { + "left_hand_pose": [ + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.28, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "right_hand_pose": [ + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.28, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "allowed_steps_per_motion": 15, + "repeat": 2, + "requires_waist_bending": false + }, + "horizontal_small_movement": { + "left_hand_pose": [ + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.19, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "right_hand_pose": [ + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.19, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "allowed_steps_per_motion": 15, + "repeat": 2, + "requires_waist_bending": false + }, + "stay_still": { + "left_hand_pose": [ + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "right_hand_pose": [ + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "allowed_steps_per_motion": 20, + "repeat": 4, + "requires_waist_bending": false + }, + "vertical_movement": { + "left_hand_pose": [ + [-0.18, 0.15, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.18, 0.15, 0.85, 0.7071, 0.0, 0.0, 0.7071], + [-0.18, 0.15, 0.9, 0.7071, 0.0, 0.0, 0.7071], + [-0.18, 0.15, 0.85, 0.7071, 0.0, 0.0, 0.7071] + ], + "right_hand_pose": [ + [0.18, 0.15, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.18, 0.15, 0.85, 0.7071, 0.0, 0.0, 0.7071], + [0.18, 0.15, 0.9, 0.7071, 0.0, 0.0, 0.7071], + [0.18, 0.15, 0.85, 0.7071, 0.0, 0.0, 0.7071] + ], + "allowed_steps_per_motion": 30, + "repeat": 2, + "requires_waist_bending": false + }, + "forward_waist_bending_movement": { + "left_hand_pose": [ + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.18, 0.2, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.18, 0.3, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "right_hand_pose": [ + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.18, 0.2, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.18, 0.3, 0.8, 0.7071, 0.0, 0.0, 0.7071] + ], + "allowed_steps_per_motion": 60, + "repeat": 2, + "requires_waist_bending": true + }, + "rotation_movements": { + "left_hand_pose": [ + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.2, 0.11, 0.8, 0.6946, 0.1325, 0.1325, 0.6946], + [-0.2, 0.11, 0.8, 0.6533, 0.2706, 0.2706, 0.6533], + [-0.2, 0.11, 0.8, 0.5848, 0.3975, 0.3975, 0.5848], + [-0.2, 0.11, 0.8, 0.5, 0.5, 0.5, 0.5], + [-0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [-0.2, 0.11, 0.8, 0.6946, -0.1325, -0.1325, 0.6946], + [-0.2, 0.11, 0.8, 0.6533, -0.2706, -0.2706, 0.6533], + [-0.2, 0.11, 0.8, 0.5848, -0.3975, -0.3975, 0.5848], + [-0.2, 0.11, 0.8, 0.5, -0.5, -0.5, 0.5] + ], + "right_hand_pose": [ + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.2, 0.11, 0.8, 0.6946, -0.1325, -0.1325, 0.6946], + [0.2, 0.11, 0.8, 0.6533, -0.2706, -0.2706, 0.6533], + [0.2, 0.11, 0.8, 0.5848, -0.3975, -0.3975, 0.5848], + [0.2, 0.11, 0.8, 0.5, -0.5, -0.5, 0.5], + [0.18, 0.1, 0.8, 0.7071, 0.0, 0.0, 0.7071], + [0.2, 0.11, 0.8, 0.6946, 0.1325, 0.1325, 0.6946], + [0.2, 0.11, 0.8, 0.6533, 0.2706, 0.2706, 0.6533], + [0.2, 0.11, 0.8, 0.5848, 0.3975, 0.3975, 0.5848], + [0.2, 0.11, 0.8, 0.5, 0.5, 0.5, 0.5] + ], + "allowed_steps_per_motion": 25, + "repeat": 2, + "requires_waist_bending": false + } + } +} diff --git a/source/isaaclab/test/controllers/test_configs/pink_ik_gr1_test_configs.json b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_gr1_test_configs.json similarity index 76% rename from source/isaaclab/test/controllers/test_configs/pink_ik_gr1_test_configs.json rename to source/isaaclab/test/controllers/test_ik_configs/pink_ik_gr1_test_configs.json index b033b95b81f6..be40d7cf7abc 100644 --- a/source/isaaclab/test/controllers/test_configs/pink_ik_gr1_test_configs.json +++ b/source/isaaclab/test/controllers/test_ik_configs/pink_ik_gr1_test_configs.json @@ -5,30 +5,33 @@ "rotation": 0.02, "check_errors": true }, + "allowed_steps_to_settle": 5, "tests": { - "stay_still": { + "vertical_movement": { "left_hand_pose": [ [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], - [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5] + [-0.23, 0.32, 1.2, 0.5, 0.5, -0.5, 0.5] ], "right_hand_pose": [ [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], - [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5] + [0.23, 0.32, 1.2, 0.5, 0.5, -0.5, 0.5] ], - "allowed_steps_per_motion": 10, - "repeat": 2 + "allowed_steps_per_motion": 8, + "repeat": 2, + "requires_waist_bending": false }, - "vertical_movement": { + "stay_still": { "left_hand_pose": [ [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], - [-0.23, 0.32, 1.2, 0.5, 0.5, -0.5, 0.5] + [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5] ], "right_hand_pose": [ [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], - [0.23, 0.32, 1.2, 0.5, 0.5, -0.5, 0.5] + [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5] ], - "allowed_steps_per_motion": 15, - "repeat": 2 + "allowed_steps_per_motion": 8, + "repeat": 4, + "requires_waist_bending": false }, "horizontal_movement": { "left_hand_pose": [ @@ -39,8 +42,9 @@ [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], [0.13, 0.32, 1.1, 0.5, 0.5, -0.5, 0.5] ], - "allowed_steps_per_motion": 15, - "repeat": 2 + "allowed_steps_per_motion": 8, + "repeat": 2, + "requires_waist_bending": false }, "horizontal_small_movement": { "left_hand_pose": [ @@ -51,8 +55,9 @@ [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], [0.22, 0.32, 1.1, 0.5, 0.5, -0.5, 0.5] ], - "allowed_steps_per_motion": 15, - "repeat": 2 + "allowed_steps_per_motion": 8, + "repeat": 2, + "requires_waist_bending": false }, "forward_waist_bending_movement": { "left_hand_pose": [ @@ -63,24 +68,26 @@ [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], [0.23, 0.5, 1.05, 0.5, 0.5, -0.5, 0.5] ], - "allowed_steps_per_motion": 30, - "repeat": 3 + "allowed_steps_per_motion": 25, + "repeat": 3, + "requires_waist_bending": true }, "rotation_movements": { "left_hand_pose": [ [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], [-0.23, 0.32, 1.1, 0.7071, 0.7071, 0.0, 0.0], [-0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], - [-0.23, 0.32, 1.1, 0.0000, 0.0000, -0.7071, 0.7071] + [-0.23, 0.32, 1.1, 0.0, 0.0, -0.7071, 0.7071] ], "right_hand_pose": [ [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], - [0.23, 0.32, 1.1, 0.0000, 0.0000, -0.7071, 0.7071], + [0.23, 0.32, 1.1, 0.0, 0.0, -0.7071, 0.7071], [0.23, 0.28, 1.1, 0.5, 0.5, -0.5, 0.5], [0.23, 0.32, 1.1, 0.7071, 0.7071, 0.0, 0.0] ], - "allowed_steps_per_motion": 20, - "repeat": 2 + "allowed_steps_per_motion": 10, + "repeat": 2, + "requires_waist_bending": false } } } diff --git a/source/isaaclab/test/controllers/test_local_frame_task.py b/source/isaaclab/test/controllers/test_local_frame_task.py new file mode 100644 index 000000000000..c8c76dcf8be2 --- /dev/null +++ b/source/isaaclab/test/controllers/test_local_frame_task.py @@ -0,0 +1,450 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for LocalFrameTask class.""" + +import numpy as np +from pathlib import Path + +import pinocchio as pin +import pytest +from pink.exceptions import TargetNotSet + +from isaaclab.controllers.pink_ik.local_frame_task import LocalFrameTask +from isaaclab.controllers.pink_ik.pink_kinematics_configuration import PinkKinematicsConfiguration + + +class TestLocalFrameTask: + """Test suite for LocalFrameTask class.""" + + @pytest.fixture + def urdf_path(self): + """Path to test URDF file.""" + return Path(__file__).parent / "urdfs" / "test_urdf_two_link_robot.urdf" + + @pytest.fixture + def mesh_path(self): + """Path to mesh directory (empty for simple test).""" + return "" + + @pytest.fixture + def controlled_joint_names(self): + """List of controlled joint names for testing.""" + return ["joint_1", "joint_2"] + + @pytest.fixture + def pink_config(self, urdf_path, mesh_path, controlled_joint_names): + """Create a PinkKinematicsConfiguration instance for testing.""" + return PinkKinematicsConfiguration( + urdf_path=str(urdf_path), + mesh_path=mesh_path, + controlled_joint_names=controlled_joint_names, + copy_data=True, + forward_kinematics=True, + ) + + @pytest.fixture + def local_frame_task(self): + """Create a LocalFrameTask instance for testing.""" + return LocalFrameTask( + frame="link_2", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + lm_damping=0.0, + gain=1.0, + ) + + def test_initialization(self, local_frame_task): + """Test proper initialization of LocalFrameTask.""" + # Check that the task is properly initialized + assert local_frame_task.frame == "link_2" + assert local_frame_task.base_link_frame_name == "base_link" + assert np.allclose(local_frame_task.cost[:3], [1.0, 1.0, 1.0]) + assert np.allclose(local_frame_task.cost[3:], [1.0, 1.0, 1.0]) + assert local_frame_task.lm_damping == 0.0 + assert local_frame_task.gain == 1.0 + + # Check that target is initially None + assert local_frame_task.transform_target_to_base is None + + def test_initialization_with_sequence_costs(self): + """Test initialization with sequence costs.""" + task = LocalFrameTask( + frame="link_1", + base_link_frame_name="base_link", + position_cost=[1.0, 1.0, 1.0], + orientation_cost=[1.0, 1.0, 1.0], + lm_damping=0.1, + gain=2.0, + ) + + assert task.frame == "link_1" + assert task.base_link_frame_name == "base_link" + assert np.allclose(task.cost[:3], [1.0, 1.0, 1.0]) + assert np.allclose(task.cost[3:], [1.0, 1.0, 1.0]) + assert task.lm_damping == 0.1 + assert task.gain == 2.0 + + def test_inheritance_from_frame_task(self, local_frame_task): + """Test that LocalFrameTask properly inherits from FrameTask.""" + from pink.tasks.frame_task import FrameTask + + # Check inheritance + assert isinstance(local_frame_task, FrameTask) + + # Check that we can call parent class methods + assert hasattr(local_frame_task, "compute_error") + assert hasattr(local_frame_task, "compute_jacobian") + + def test_set_target(self, local_frame_task): + """Test setting target with a transform.""" + # Create a test transform + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + target_transform.rotation = pin.exp3(np.array([0.1, 0.0, 0.0])) + + # Set the target + local_frame_task.set_target(target_transform) + + # Check that target was set correctly + assert local_frame_task.transform_target_to_base is not None + assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) + + # Check that it's a copy (not the same object) + assert local_frame_task.transform_target_to_base is not target_transform + + # Check that values match + assert np.allclose(local_frame_task.transform_target_to_base.translation, target_transform.translation) + assert np.allclose(local_frame_task.transform_target_to_base.rotation, target_transform.rotation) + + def test_set_target_from_configuration(self, local_frame_task, pink_config): + """Test setting target from a robot configuration.""" + # Set target from configuration + local_frame_task.set_target_from_configuration(pink_config) + + # Check that target was set + assert local_frame_task.transform_target_to_base is not None + assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) + + def test_set_target_from_configuration_wrong_type(self, local_frame_task): + """Test that set_target_from_configuration raises error with wrong type.""" + with pytest.raises(ValueError, match="configuration must be a PinkKinematicsConfiguration"): + local_frame_task.set_target_from_configuration("not_a_configuration") + + def test_compute_error_with_target_set(self, local_frame_task, pink_config): + """Test computing error when target is set.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute error + error = local_frame_task.compute_error(pink_config) + + # Check that error is computed correctly + assert isinstance(error, np.ndarray) + assert error.shape == (6,) # 6D error (3 position + 3 orientation) + + # Error should not be all zeros (unless target exactly matches current pose) + # This is a reasonable assumption for a random target + + def test_compute_error_without_target(self, local_frame_task, pink_config): + """Test that compute_error raises error when no target is set.""" + with pytest.raises(ValueError, match="no target set for frame 'link_2'"): + local_frame_task.compute_error(pink_config) + + def test_compute_error_wrong_configuration_type(self, local_frame_task): + """Test that compute_error raises error with wrong configuration type.""" + # Set a target first + target_transform = pin.SE3.Identity() + local_frame_task.set_target(target_transform) + + with pytest.raises(ValueError, match="configuration must be a PinkKinematicsConfiguration"): + local_frame_task.compute_error("not_a_configuration") + + def test_compute_jacobian_with_target_set(self, local_frame_task, pink_config): + """Test computing Jacobian when target is set.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute Jacobian + jacobian = local_frame_task.compute_jacobian(pink_config) + + # Check that Jacobian is computed correctly + assert isinstance(jacobian, np.ndarray) + assert jacobian.shape == (6, 2) # 6 rows (error), 2 columns (controlled joints) + + # Jacobian should not be all zeros + assert not np.allclose(jacobian, 0.0) + + def test_compute_jacobian_without_target(self, local_frame_task, pink_config): + """Test that compute_jacobian raises error when no target is set.""" + with pytest.raises(TargetNotSet, match="no target set for frame 'link_2'"): + local_frame_task.compute_jacobian(pink_config) + + def test_error_consistency_across_configurations(self, local_frame_task, pink_config): + """Test that error computation is consistent across different configurations.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute error at initial configuration + error_1 = local_frame_task.compute_error(pink_config) + + # Update configuration + new_q = pink_config.full_q.copy() + new_q[1] = 0.5 # Change first revolute joint + pink_config.update(new_q) + + # Compute error at new configuration + error_2 = local_frame_task.compute_error(pink_config) + + # Errors should be different (not all close) + assert not np.allclose(error_1, error_2) + + def test_jacobian_consistency_across_configurations(self, local_frame_task, pink_config): + """Test that Jacobian computation is consistent across different configurations.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute Jacobian at initial configuration + jacobian_1 = local_frame_task.compute_jacobian(pink_config) + + # Update configuration + new_q = pink_config.full_q.copy() + new_q[1] = 0.3 # Change first revolute joint + pink_config.update(new_q) + + # Compute Jacobian at new configuration + jacobian_2 = local_frame_task.compute_jacobian(pink_config) + + # Jacobians should be different (not all close) + assert not np.allclose(jacobian_1, jacobian_2) + + def test_error_zero_at_target_pose(self, local_frame_task, pink_config): + """Test that error is zero when current pose matches target pose.""" + # Get current transform of the frame + current_transform = pink_config.get_transform_frame_to_world("link_2") + + # Set target to current pose + local_frame_task.set_target(current_transform) + + # Compute error + error = local_frame_task.compute_error(pink_config) + + # Error should be very close to zero + assert np.allclose(error, 0.0, atol=1e-10) + + def test_different_frames(self, pink_config): + """Test LocalFrameTask with different frame names.""" + # Test with link_1 frame + task_link1 = LocalFrameTask( + frame="link_1", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + ) + + # Set target and compute error + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.0, 0.0]) + task_link1.set_target(target_transform) + + error_link1 = task_link1.compute_error(pink_config) + assert error_link1.shape == (6,) + + # Test with base_link frame + task_base = LocalFrameTask( + frame="base_link", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + ) + + task_base.set_target(target_transform) + error_base = task_base.compute_error(pink_config) + assert error_base.shape == (6,) + + def test_different_base_frames(self, pink_config): + """Test LocalFrameTask with different base frame names.""" + # Test with base_link as base frame + task_base_base = LocalFrameTask( + frame="link_2", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + ) + + target_transform = pin.SE3.Identity() + task_base_base.set_target(target_transform) + error_base_base = task_base_base.compute_error(pink_config) + assert error_base_base.shape == (6,) + + # Test with link_1 as base frame + task_link1_base = LocalFrameTask( + frame="link_2", + base_link_frame_name="link_1", + position_cost=1.0, + orientation_cost=1.0, + ) + + task_link1_base.set_target(target_transform) + error_link1_base = task_link1_base.compute_error(pink_config) + assert error_link1_base.shape == (6,) + + def test_sequence_cost_parameters(self): + """Test LocalFrameTask with sequence cost parameters.""" + task = LocalFrameTask( + frame="link_2", + base_link_frame_name="base_link", + position_cost=[1.0, 2.0, 3.0], + orientation_cost=[0.5, 1.0, 1.5], + lm_damping=0.1, + gain=2.0, + ) + + assert np.allclose(task.cost[:3], [1.0, 2.0, 3.0]) # Position costs + assert np.allclose(task.cost[3:], [0.5, 1.0, 1.5]) # Orientation costs + assert task.lm_damping == 0.1 + assert task.gain == 2.0 + + def test_error_magnitude_consistency(self, local_frame_task, pink_config): + """Test that error computation produces reasonable results.""" + # Set a small target offset + small_target = pin.SE3.Identity() + small_target.translation = np.array([0.01, 0.01, 0.01]) + local_frame_task.set_target(small_target) + + error_small = local_frame_task.compute_error(pink_config) + + # Set a large target offset + large_target = pin.SE3.Identity() + large_target.translation = np.array([0.5, 0.5, 0.5]) + local_frame_task.set_target(large_target) + + error_large = local_frame_task.compute_error(pink_config) + + # Both errors should be finite and reasonable + assert np.all(np.isfinite(error_small)) + assert np.all(np.isfinite(error_large)) + assert not np.allclose(error_small, error_large) # Different targets should produce different errors + + def test_jacobian_structure(self, local_frame_task, pink_config): + """Test that Jacobian has the correct structure.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute Jacobian + jacobian = local_frame_task.compute_jacobian(pink_config) + + # Check structure + assert jacobian.shape == (6, 2) # 6 error dimensions, 2 controlled joints + + # Check that Jacobian is not all zeros (basic functionality check) + assert not np.allclose(jacobian, 0.0) + + def test_multiple_target_updates(self, local_frame_task, pink_config): + """Test that multiple target updates work correctly.""" + # Set first target + target1 = pin.SE3.Identity() + target1.translation = np.array([0.1, 0.0, 0.0]) + local_frame_task.set_target(target1) + + error1 = local_frame_task.compute_error(pink_config) + + # Set second target + target2 = pin.SE3.Identity() + target2.translation = np.array([0.0, 0.1, 0.0]) + local_frame_task.set_target(target2) + + error2 = local_frame_task.compute_error(pink_config) + + # Errors should be different + assert not np.allclose(error1, error2) + + def test_inheritance_behavior(self, local_frame_task): + """Test that LocalFrameTask properly overrides parent class methods.""" + # Check that the class has the expected methods + assert hasattr(local_frame_task, "set_target") + assert hasattr(local_frame_task, "set_target_from_configuration") + assert hasattr(local_frame_task, "compute_error") + assert hasattr(local_frame_task, "compute_jacobian") + + # Check that these are the overridden methods, not the parent ones + assert local_frame_task.set_target.__qualname__ == "LocalFrameTask.set_target" + assert local_frame_task.compute_error.__qualname__ == "LocalFrameTask.compute_error" + assert local_frame_task.compute_jacobian.__qualname__ == "LocalFrameTask.compute_jacobian" + + def test_target_copying_behavior(self, local_frame_task): + """Test that target transforms are properly copied.""" + # Create a target transform + original_target = pin.SE3.Identity() + original_target.translation = np.array([0.1, 0.2, 0.3]) + original_rotation = original_target.rotation.copy() + + # Set the target + local_frame_task.set_target(original_target) + + # Modify the original target + original_target.translation = np.array([0.5, 0.5, 0.5]) + original_target.rotation = pin.exp3(np.array([0.5, 0.0, 0.0])) + + # Check that the stored target is unchanged + assert np.allclose(local_frame_task.transform_target_to_base.translation, np.array([0.1, 0.2, 0.3])) + assert np.allclose(local_frame_task.transform_target_to_base.rotation, original_rotation) + + def test_error_computation_with_orientation_difference(self, local_frame_task, pink_config): + """Test error computation when there's an orientation difference.""" + # Set a target with orientation difference + target_transform = pin.SE3.Identity() + target_transform.rotation = pin.exp3(np.array([0.2, 0.0, 0.0])) # Rotation around X-axis + local_frame_task.set_target(target_transform) + + # Compute error + error = local_frame_task.compute_error(pink_config) + + # Check that error is computed correctly + assert isinstance(error, np.ndarray) + assert error.shape == (6,) + + # Error should not be all zeros + assert not np.allclose(error, 0.0) + + def test_jacobian_rank_consistency(self, local_frame_task, pink_config): + """Test that Jacobian maintains consistent shape across configurations.""" + # Set a target that we know can be reached by the test robot. + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.0, 0.0, 0.45]) + # 90 degrees around x axis = pi/2 radians + target_transform.rotation = pin.exp3(np.array([np.pi / 2, 0.0, 0.0])) + local_frame_task.set_target(target_transform) + + # Compute Jacobian at multiple configurations + jacobians = [] + for i in range(5): + # Update configuration + new_q = pink_config.full_q.copy() + new_q[1] = 0.1 * i # Vary first joint + pink_config.update(new_q) + + # Compute Jacobian + jacobian = local_frame_task.compute_jacobian(pink_config) + jacobians.append(jacobian) + + # All Jacobians should have the same shape + for jacobian in jacobians: + assert jacobian.shape == (6, 2) + + # All Jacobians should have rank 2 (full rank for 2-DOF planar arm) + for jacobian in jacobians: + assert np.linalg.matrix_rank(jacobian) == 2 diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index 3485f367e373..9cd989e6f826 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -22,11 +22,14 @@ import gymnasium as gym import json import numpy as np -import os +import re import torch +from pathlib import Path +import omni.usd import pytest from pink.configuration import Configuration +from pink.tasks import FrameTask from isaaclab.utils.math import axis_angle_from_quat, matrix_from_quat, quat_from_matrix, quat_inv @@ -35,30 +38,38 @@ from isaaclab_tasks.utils.parse_cfg import parse_env_cfg -@pytest.fixture(scope="module") -def test_cfg(): - """Load test configuration.""" - config_path = os.path.join(os.path.dirname(__file__), "test_configs", "pink_ik_gr1_test_configs.json") +def load_test_config(env_name): + """Load test configuration based on environment type.""" + # Determine which config file to load based on environment name + if "G1" in env_name: + config_file = "pink_ik_g1_test_configs.json" + elif "GR1" in env_name: + config_file = "pink_ik_gr1_test_configs.json" + else: + raise ValueError(f"Unknown environment type in {env_name}. Expected G1 or GR1.") + + config_path = Path(__file__).parent / "test_ik_configs" / config_file with open(config_path) as f: return json.load(f) -@pytest.fixture(scope="module") -def test_params(test_cfg): - """Set up test parameters.""" - return { - "position_tolerance": test_cfg["tolerances"]["position"], - "rotation_tolerance": test_cfg["tolerances"]["rotation"], - "pd_position_tolerance": test_cfg["tolerances"]["pd_position"], - "check_errors": test_cfg["tolerances"]["check_errors"], - } +def is_waist_enabled(env_cfg): + """Check if waist joints are enabled in the environment configuration.""" + if not hasattr(env_cfg.actions, "upper_body_ik"): + return False + + pink_controlled_joints = env_cfg.actions.upper_body_ik.pink_controlled_joint_names + + # Also check for pattern-based joint names (e.g., "waist_.*_joint") + return any(re.match("waist", joint) for joint in pink_controlled_joints) -def create_test_env(num_envs): +def create_test_env(env_name, num_envs): """Create a test environment with the Pink IK controller.""" - env_name = "Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0" device = "cuda:0" + omni.usd.get_context().new_stage() + try: env_cfg = parse_env_cfg(env_name, device=device, num_envs=num_envs) # Modify scene config to not spawn the packing table to avoid collision with the robot @@ -71,85 +82,133 @@ def create_test_env(num_envs): raise -@pytest.fixture(scope="module") -def env_and_cfg(): +@pytest.fixture( + scope="module", + params=[ + "Isaac-PickPlace-GR1T2-Abs-v0", + "Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0", + "Isaac-PickPlace-FixedBaseUpperBodyIK-G1-Abs-v0", + "Isaac-PickPlace-Locomanipulation-G1-Abs-v0", + ], +) +def env_and_cfg(request): """Create environment and configuration for tests.""" - env, env_cfg = create_test_env(num_envs=1) + env_name = request.param + + # Load the appropriate test configuration based on environment type + test_cfg = load_test_config(env_name) + + env, env_cfg = create_test_env(env_name, num_envs=1) + + # Get only the FrameTasks from variable_input_tasks + variable_input_tasks = [ + task for task in env_cfg.actions.upper_body_ik.controller.variable_input_tasks if isinstance(task, FrameTask) + ] + assert len(variable_input_tasks) == 2, "Expected exactly two FrameTasks (left and right hand)." + frames = [task.frame for task in variable_input_tasks] + # Try to infer which is left and which is right + left_candidates = [f for f in frames if "left" in f.lower()] + right_candidates = [f for f in frames if "right" in f.lower()] + assert ( + len(left_candidates) == 1 and len(right_candidates) == 1 + ), f"Could not uniquely identify left/right frames from: {frames}" + left_eef_urdf_link_name = left_candidates[0] + right_eef_urdf_link_name = right_candidates[0] # Set up camera view env.sim.set_camera_view(eye=[2.5, 2.5, 2.5], target=[0.0, 0.0, 1.0]) - return env, env_cfg + # Create test parameters from test_cfg + test_params = { + "position": test_cfg["tolerances"]["position"], + "rotation": test_cfg["tolerances"]["rotation"], + "pd_position": test_cfg["tolerances"]["pd_position"], + "check_errors": test_cfg["tolerances"]["check_errors"], + "left_eef_urdf_link_name": left_eef_urdf_link_name, + "right_eef_urdf_link_name": right_eef_urdf_link_name, + } + + try: + yield env, env_cfg, test_cfg, test_params + finally: + env.close() @pytest.fixture def test_setup(env_and_cfg): """Set up test case - runs before each test.""" - env, env_cfg = env_and_cfg + env, env_cfg, test_cfg, test_params = env_and_cfg - num_joints_in_robot_hands = env_cfg.actions.pink_ik_cfg.controller.num_hand_joints + num_joints_in_robot_hands = env_cfg.actions.upper_body_ik.controller.num_hand_joints # Get Action Term and IK controller - action_term = env.action_manager.get_term(name="pink_ik_cfg") + action_term = env.action_manager.get_term(name="upper_body_ik") pink_controllers = action_term._ik_controllers articulation = action_term._asset # Initialize Pink Configuration for forward kinematics - kinematics_model = Configuration( - pink_controllers[0].robot_wrapper.model, - pink_controllers[0].robot_wrapper.data, - pink_controllers[0].robot_wrapper.q0, + test_kinematics_model = Configuration( + pink_controllers[0].pink_configuration.model, + pink_controllers[0].pink_configuration.data, + pink_controllers[0].pink_configuration.q, ) - left_target_link_name = env_cfg.actions.pink_ik_cfg.target_eef_link_names["left_wrist"] - right_target_link_name = env_cfg.actions.pink_ik_cfg.target_eef_link_names["right_wrist"] + left_target_link_name = env_cfg.actions.upper_body_ik.target_eef_link_names["left_wrist"] + right_target_link_name = env_cfg.actions.upper_body_ik.target_eef_link_names["right_wrist"] return { "env": env, "env_cfg": env_cfg, + "test_cfg": test_cfg, + "test_params": test_params, "num_joints_in_robot_hands": num_joints_in_robot_hands, "action_term": action_term, "pink_controllers": pink_controllers, "articulation": articulation, - "kinematics_model": kinematics_model, + "test_kinematics_model": test_kinematics_model, "left_target_link_name": left_target_link_name, "right_target_link_name": right_target_link_name, + "left_eef_urdf_link_name": test_params["left_eef_urdf_link_name"], + "right_eef_urdf_link_name": test_params["right_eef_urdf_link_name"], } -def test_stay_still(test_setup, test_cfg): - """Test staying still.""" - print("Running stay still test...") - run_movement_test(test_setup, test_cfg["tests"]["stay_still"], test_cfg) - - -def test_vertical_movement(test_setup, test_cfg): - """Test vertical movement of robot hands.""" - print("Running vertical movement test...") - run_movement_test(test_setup, test_cfg["tests"]["vertical_movement"], test_cfg) - - -def test_horizontal_movement(test_setup, test_cfg): - """Test horizontal movement of robot hands.""" - print("Running horizontal movement test...") - run_movement_test(test_setup, test_cfg["tests"]["horizontal_movement"], test_cfg) - - -def test_horizontal_small_movement(test_setup, test_cfg): - """Test small horizontal movement of robot hands.""" - print("Running horizontal small movement test...") - run_movement_test(test_setup, test_cfg["tests"]["horizontal_small_movement"], test_cfg) - - -def test_forward_waist_bending_movement(test_setup, test_cfg): - """Test forward waist bending movement of robot hands.""" - print("Running forward waist bending movement test...") - run_movement_test(test_setup, test_cfg["tests"]["forward_waist_bending_movement"], test_cfg) - +@pytest.mark.parametrize( + "test_name", + [ + "horizontal_movement", + "horizontal_small_movement", + "stay_still", + "forward_waist_bending_movement", + "vertical_movement", + "rotation_movements", + ], +) +def test_movement_types(test_setup, test_name): + """Test different movement types using parametrization.""" + test_cfg = test_setup["test_cfg"] + env_cfg = test_setup["env_cfg"] + + if test_name not in test_cfg["tests"]: + print(f"Skipping {test_name} test for {env_cfg.__class__.__name__} environment (test not defined)...") + pytest.skip(f"Test {test_name} not defined for {env_cfg.__class__.__name__}") + return + + test_config = test_cfg["tests"][test_name] + + # Check if test requires waist bending and if waist is enabled + requires_waist_bending = test_config.get("requires_waist_bending", False) + waist_enabled = is_waist_enabled(env_cfg) + + if requires_waist_bending and not waist_enabled: + print( + f"Skipping {test_name} test because it requires waist bending but waist is not enabled in" + f" {env_cfg.__class__.__name__}..." + ) + pytest.skip(f"Test {test_name} requires waist bending but waist is not enabled") + return -def test_rotation_movements(test_setup, test_cfg): - """Test rotation movements of robot hands.""" - print("Running rotation movements test...") - run_movement_test(test_setup, test_cfg["tests"]["rotation_movements"], test_cfg) + print(f"Running {test_name} test...") + run_movement_test(test_setup, test_config, test_cfg) def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): @@ -167,8 +226,14 @@ def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): with contextlib.suppress(KeyboardInterrupt) and torch.inference_mode(): obs, _ = env.reset() + # Make the first phase longer than subsequent ones + initial_steps = test_cfg["allowed_steps_to_settle"] + phase = "initial" + steps_in_phase = 0 + while simulation_app.is_running() and not simulation_app.is_exiting(): num_runs += 1 + steps_in_phase += 1 # Call auxiliary function if provided if aux_function is not None: @@ -178,20 +243,40 @@ def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): setpoint_poses = np.concatenate([left_hand_poses[curr_pose_idx], right_hand_poses[curr_pose_idx]]) actions = np.concatenate([setpoint_poses, np.zeros(num_joints_in_robot_hands)]) actions = torch.tensor(actions, device=env.device, dtype=torch.float32) + # Append base command for Locomanipulation environments with fixed height + if test_setup["env_cfg"].__class__.__name__ == "LocomanipulationG1EnvCfg": + # Use a named variable for base height for clarity and maintainability + BASE_HEIGHT = 0.72 + base_command = torch.zeros(4, device=env.device, dtype=actions.dtype) + base_command[3] = BASE_HEIGHT + actions = torch.cat([actions, base_command]) actions = actions.repeat(env.num_envs, 1) # Step environment obs, _, _, _, _ = env.step(actions) + # Determine the step interval for error checking + if phase == "initial": + check_interval = initial_steps + else: + check_interval = test_config["allowed_steps_per_motion"] + # Check convergence and verify errors - if num_runs % test_config["allowed_steps_per_motion"] == 0: + if steps_in_phase % check_interval == 0: print("Computing errors...") errors = compute_errors( - test_setup, env, left_hand_poses[curr_pose_idx], right_hand_poses[curr_pose_idx] + test_setup, + env, + left_hand_poses[curr_pose_idx], + right_hand_poses[curr_pose_idx], + test_setup["left_eef_urdf_link_name"], + test_setup["right_eef_urdf_link_name"], ) print_debug_info(errors, test_counter) - if test_cfg["tolerances"]["check_errors"]: - verify_errors(errors, test_setup, test_cfg["tolerances"]) + test_params = test_setup["test_params"] + if test_params["check_errors"]: + verify_errors(errors, test_setup, test_params) + num_runs += 1 curr_pose_idx = (curr_pose_idx + 1) % len(left_hand_poses) if curr_pose_idx == 0: @@ -199,6 +284,10 @@ def run_movement_test(test_setup, test_config, test_cfg, aux_function=None): if test_counter > test_config["repeat"]: print("Test completed successfully") break + # After the first phase, switch to normal interval + if phase == "initial": + phase = "normal" + steps_in_phase = 0 def get_link_pose(env, link_name): @@ -225,15 +314,16 @@ def calculate_rotation_error(current_rot, target_rot): ) -def compute_errors(test_setup, env, left_target_pose, right_target_pose): +def compute_errors( + test_setup, env, left_target_pose, right_target_pose, left_eef_urdf_link_name, right_eef_urdf_link_name +): """Compute all error metrics for the current state.""" action_term = test_setup["action_term"] pink_controllers = test_setup["pink_controllers"] articulation = test_setup["articulation"] - kinematics_model = test_setup["kinematics_model"] + test_kinematics_model = test_setup["test_kinematics_model"] left_target_link_name = test_setup["left_target_link_name"] right_target_link_name = test_setup["right_target_link_name"] - num_joints_in_robot_hands = test_setup["num_joints_in_robot_hands"] # Get current hand positions and orientations left_hand_pos, left_hand_rot = get_link_pose(env, left_target_link_name) @@ -244,10 +334,6 @@ def compute_errors(test_setup, env, left_target_pose, right_target_pose): num_envs = env.num_envs left_hand_pose_setpoint = torch.tensor(left_target_pose, device=device).unsqueeze(0).repeat(num_envs, 1) right_hand_pose_setpoint = torch.tensor(right_target_pose, device=device).unsqueeze(0).repeat(num_envs, 1) - # compensate for the hand rotational offset - # nominal_hand_pose_rotmat = matrix_from_quat(torch.tensor(env_cfg.actions.pink_ik_cfg.controller.hand_rotational_offset, device=env.device)) - left_hand_pose_setpoint[:, 3:7] = quat_from_matrix(matrix_from_quat(left_hand_pose_setpoint[:, 3:7])) - right_hand_pose_setpoint[:, 3:7] = quat_from_matrix(matrix_from_quat(right_hand_pose_setpoint[:, 3:7])) # Calculate position and rotation errors left_pos_error = left_hand_pose_setpoint[:, :3] - left_hand_pos @@ -257,32 +343,24 @@ def compute_errors(test_setup, env, left_target_pose, right_target_pose): # Calculate PD controller errors ik_controller = pink_controllers[0] - pink_controlled_joint_ids = action_term._pink_controlled_joint_ids + isaaclab_controlled_joint_ids = action_term._isaaclab_controlled_joint_ids - # Get current and target positions - curr_joints = articulation.data.joint_pos[:, pink_controlled_joint_ids].cpu().numpy()[0] - target_joints = action_term.processed_actions[:, :num_joints_in_robot_hands].cpu().numpy()[0] + # Get current and target positions for controlled joints only + curr_joints = articulation.data.joint_pos[:, isaaclab_controlled_joint_ids].cpu().numpy()[0] + target_joints = action_term.processed_actions[:, : len(isaaclab_controlled_joint_ids)].cpu().numpy()[0] - # Reorder joints for Pink IK - curr_joints = np.array(curr_joints)[ik_controller.isaac_lab_to_pink_ordering] - target_joints = np.array(target_joints)[ik_controller.isaac_lab_to_pink_ordering] + # Reorder joints for Pink IK (using controlled joint ordering) + curr_joints = np.array(curr_joints)[ik_controller.isaac_lab_to_pink_controlled_ordering] + target_joints = np.array(target_joints)[ik_controller.isaac_lab_to_pink_controlled_ordering] # Run forward kinematics - kinematics_model.update(curr_joints) - left_curr_pos = kinematics_model.get_transform_frame_to_world( - frame="GR1T2_fourier_hand_6dof_left_hand_pitch_link" - ).translation - right_curr_pos = kinematics_model.get_transform_frame_to_world( - frame="GR1T2_fourier_hand_6dof_right_hand_pitch_link" - ).translation - - kinematics_model.update(target_joints) - left_target_pos = kinematics_model.get_transform_frame_to_world( - frame="GR1T2_fourier_hand_6dof_left_hand_pitch_link" - ).translation - right_target_pos = kinematics_model.get_transform_frame_to_world( - frame="GR1T2_fourier_hand_6dof_right_hand_pitch_link" - ).translation + test_kinematics_model.update(curr_joints) + left_curr_pos = test_kinematics_model.get_transform_frame_to_world(frame=left_eef_urdf_link_name).translation + right_curr_pos = test_kinematics_model.get_transform_frame_to_world(frame=right_eef_urdf_link_name).translation + + test_kinematics_model.update(target_joints) + left_target_pos = test_kinematics_model.get_transform_frame_to_world(frame=left_eef_urdf_link_name).translation + right_target_pos = test_kinematics_model.get_transform_frame_to_world(frame=right_eef_urdf_link_name).translation # Calculate PD errors left_pd_error = ( diff --git a/source/isaaclab/test/controllers/test_pink_ik_components.py b/source/isaaclab/test/controllers/test_pink_ik_components.py new file mode 100644 index 000000000000..08a292d8b8e8 --- /dev/null +++ b/source/isaaclab/test/controllers/test_pink_ik_components.py @@ -0,0 +1,296 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for PinkKinematicsConfiguration class.""" + +import numpy as np +from pathlib import Path + +import pinocchio as pin +import pytest +from pink.exceptions import FrameNotFound + +from isaaclab.controllers.pink_ik.pink_kinematics_configuration import PinkKinematicsConfiguration + + +class TestPinkKinematicsConfiguration: + """Test suite for PinkKinematicsConfiguration class.""" + + @pytest.fixture + def urdf_path(self): + """Path to test URDF file.""" + return Path(__file__).parent / "urdfs/test_urdf_two_link_robot.urdf" + + @pytest.fixture + def mesh_path(self): + """Path to mesh directory (empty for simple test).""" + return "" + + @pytest.fixture + def controlled_joint_names(self): + """List of controlled joint names for testing.""" + return ["joint_1", "joint_2"] + + @pytest.fixture + def pink_config(self, urdf_path, mesh_path, controlled_joint_names): + """Create a PinkKinematicsConfiguration instance for testing.""" + return PinkKinematicsConfiguration( + urdf_path=str(urdf_path), + mesh_path=mesh_path, + controlled_joint_names=controlled_joint_names, + copy_data=True, + forward_kinematics=True, + ) + + def test_initialization(self, pink_config, controlled_joint_names): + """Test proper initialization of PinkKinematicsConfiguration.""" + # Check that controlled joint names are stored correctly + assert pink_config._controlled_joint_names == controlled_joint_names + + # Check that both full and controlled models are created + assert pink_config.full_model is not None + assert pink_config.controlled_model is not None + assert pink_config.full_data is not None + assert pink_config.controlled_data is not None + + # Check that configuration vectors are initialized + assert pink_config.full_q is not None + assert pink_config.controlled_q is not None + + # Check that the controlled model has the same number or fewer joints than the full model + assert pink_config.controlled_model.nq == pink_config.full_model.nq + + def test_joint_names_properties(self, pink_config): + """Test joint name properties.""" + # Test controlled joint names in pinocchio order + controlled_names = pink_config.controlled_joint_names_pinocchio_order + assert isinstance(controlled_names, list) + assert len(controlled_names) == len(pink_config._controlled_joint_names) + assert "joint_1" in controlled_names + assert "joint_2" in controlled_names + + # Test all joint names in pinocchio order + all_names = pink_config.all_joint_names_pinocchio_order + assert isinstance(all_names, list) + assert len(all_names) == len(controlled_names) + assert "joint_1" in all_names + assert "joint_2" in all_names + + def test_update_with_valid_configuration(self, pink_config): + """Test updating configuration with valid joint values.""" + # Get initial configuration + initial_q = pink_config.full_q.copy() + + # Create a new configuration with different joint values + new_q = initial_q.copy() + new_q[1] = 0.5 # Change first revolute joint value (index 1, since 0 is fixed joint) + + # Update configuration + pink_config.update(new_q) + + # Check that the configuration was updated + print(pink_config.full_q) + assert not np.allclose(pink_config.full_q, initial_q) + assert np.allclose(pink_config.full_q, new_q) + + def test_update_with_none(self, pink_config): + """Test updating configuration with None (should use current configuration).""" + # Get initial configuration + initial_q = pink_config.full_q.copy() + + # Update with None + pink_config.update(None) + + # Configuration should remain the same + assert np.allclose(pink_config.full_q, initial_q) + + def test_update_with_wrong_dimensions(self, pink_config): + """Test that update raises ValueError with wrong configuration dimensions.""" + # Create configuration with wrong number of joints + wrong_q = np.array([0.1, 0.2, 0.3]) # Wrong number of joints + + with pytest.raises(ValueError, match="q must have the same length as the number of joints"): + pink_config.update(wrong_q) + + def test_get_frame_jacobian_existing_frame(self, pink_config): + """Test getting Jacobian for an existing frame.""" + # Get Jacobian for link_1 frame + jacobian = pink_config.get_frame_jacobian("link_1") + + # Check that Jacobian has correct shape + # Should be 6 rows (linear + angular velocity) and columns equal to controlled joints + expected_rows = 6 + expected_cols = len(pink_config._controlled_joint_names) + assert jacobian.shape == (expected_rows, expected_cols) + + # Check that Jacobian is not all zeros (should have some non-zero values) + assert not np.allclose(jacobian, 0.0) + + def test_get_frame_jacobian_nonexistent_frame(self, pink_config): + """Test that get_frame_jacobian raises FrameNotFound for non-existent frame.""" + with pytest.raises(FrameNotFound): + pink_config.get_frame_jacobian("nonexistent_frame") + + def test_get_transform_frame_to_world_existing_frame(self, pink_config): + """Test getting transform for an existing frame.""" + # Get transform for link_1 frame + transform = pink_config.get_transform_frame_to_world("link_1") + + # Check that transform is a pinocchio SE3 object + assert isinstance(transform, pin.SE3) + + # Check that transform has reasonable values (not identity for non-zero joint angles) + assert not np.allclose(transform.homogeneous, np.eye(4)) + + def test_get_transform_frame_to_world_nonexistent_frame(self, pink_config): + """Test that get_transform_frame_to_world raises FrameNotFound for non-existent frame.""" + with pytest.raises(FrameNotFound): + pink_config.get_transform_frame_to_world("nonexistent_frame") + + def test_multiple_controlled_joints(self, urdf_path, mesh_path): + """Test configuration with multiple controlled joints.""" + # Create configuration with all available joints as controlled + controlled_joint_names = ["joint_1", "joint_2"] # Both revolute joints + + pink_config = PinkKinematicsConfiguration( + urdf_path=str(urdf_path), + mesh_path=mesh_path, + controlled_joint_names=controlled_joint_names, + ) + + # Check that controlled model has correct number of joints + assert pink_config.controlled_model.nq == len(controlled_joint_names) + + def test_no_controlled_joints(self, urdf_path, mesh_path): + """Test configuration with no controlled joints.""" + controlled_joint_names = [] + + pink_config = PinkKinematicsConfiguration( + urdf_path=str(urdf_path), + mesh_path=mesh_path, + controlled_joint_names=controlled_joint_names, + ) + + # Check that controlled model has 0 joints + assert pink_config.controlled_model.nq == 0 + assert len(pink_config.controlled_q) == 0 + + def test_jacobian_consistency(self, pink_config): + """Test that Jacobian computation is consistent across updates.""" + # Get Jacobian at initial configuration + jacobian_1 = pink_config.get_frame_jacobian("link_2") + + # Update configuration + new_q = pink_config.full_q.copy() + new_q[1] = 0.3 # Change first revolute joint (index 1, since 0 is fixed joint) + pink_config.update(new_q) + + # Get Jacobian at new configuration + jacobian_2 = pink_config.get_frame_jacobian("link_2") + + # Jacobians should be different (not all close) + assert not np.allclose(jacobian_1, jacobian_2) + + def test_transform_consistency(self, pink_config): + """Test that transform computation is consistent across updates.""" + # Get transform at initial configuration + transform_1 = pink_config.get_transform_frame_to_world("link_2") + + # Update configuration + new_q = pink_config.full_q.copy() + new_q[1] = 0.5 # Change first revolute joint (index 1, since 0 is fixed joint) + pink_config.update(new_q) + + # Get transform at new configuration + transform_2 = pink_config.get_transform_frame_to_world("link_2") + + # Transforms should be different + assert not np.allclose(transform_1.homogeneous, transform_2.homogeneous) + + def test_inheritance_from_configuration(self, pink_config): + """Test that PinkKinematicsConfiguration properly inherits from Pink Configuration.""" + from pink.configuration import Configuration + + # Check inheritance + assert isinstance(pink_config, Configuration) + + # Check that we can call parent class methods + assert hasattr(pink_config, "update") + assert hasattr(pink_config, "get_transform_frame_to_world") + + def test_controlled_joint_indices_calculation(self, pink_config): + """Test that controlled joint indices are calculated correctly.""" + # Check that controlled joint indices are valid + assert len(pink_config._controlled_joint_indices) == len(pink_config._controlled_joint_names) + + # Check that all indices are within bounds + for idx in pink_config._controlled_joint_indices: + assert 0 <= idx < len(pink_config._all_joint_names) + + # Check that indices correspond to controlled joint names + for i, idx in enumerate(pink_config._controlled_joint_indices): + joint_name = pink_config._all_joint_names[idx] + assert joint_name in pink_config._controlled_joint_names + + def test_full_model_integrity(self, pink_config): + """Test that the full model maintains integrity.""" + # Check that full model has all joints + assert pink_config.full_model.nq > 0 + assert len(pink_config.full_model.names) > 1 # More than just "universe" + + def test_controlled_model_integrity(self, pink_config): + """Test that the controlled model maintains integrity.""" + # Check that controlled model has correct number of joints + assert pink_config.controlled_model.nq == len(pink_config._controlled_joint_names) + + def test_configuration_vector_consistency(self, pink_config): + """Test that configuration vectors are consistent between full and controlled models.""" + # Check that controlled_q is a subset of full_q + controlled_indices = pink_config._controlled_joint_indices + for i, idx in enumerate(controlled_indices): + assert np.isclose(pink_config.controlled_q[i], pink_config.full_q[idx]) + + def test_error_handling_invalid_urdf(self, mesh_path, controlled_joint_names): + """Test error handling with invalid URDF path.""" + with pytest.raises(Exception): # Should raise some exception for invalid URDF + PinkKinematicsConfiguration( + urdf_path="nonexistent.urdf", + mesh_path=mesh_path, + controlled_joint_names=controlled_joint_names, + ) + + def test_error_handling_invalid_joint_names(self, urdf_path, mesh_path): + """Test error handling with invalid joint names.""" + invalid_joint_names = ["nonexistent_joint"] + + # This should not raise an error, but the controlled model should have 0 joints + pink_config = PinkKinematicsConfiguration( + urdf_path=str(urdf_path), + mesh_path=mesh_path, + controlled_joint_names=invalid_joint_names, + ) + + assert pink_config.controlled_model.nq == 0 + assert len(pink_config.controlled_q) == 0 + + def test_undercontrolled_kinematics_model(self, urdf_path, mesh_path): + """Test that the fixed joint to world is properly handled.""" + + test_model = PinkKinematicsConfiguration( + urdf_path=str(urdf_path), + mesh_path=mesh_path, + controlled_joint_names=["joint_1"], + copy_data=True, + forward_kinematics=True, + ) + # Check that the controlled model only includes the revolute joints + assert "joint_1" in test_model.controlled_joint_names_pinocchio_order + assert "joint_2" not in test_model.controlled_joint_names_pinocchio_order + assert len(test_model.controlled_joint_names_pinocchio_order) == 1 # Only the two revolute joints + + # Check that the full configuration has more elements than controlled + assert len(test_model.full_q) > len(test_model.controlled_q) + assert len(test_model.full_q) == len(test_model.all_joint_names_pinocchio_order) + assert len(test_model.controlled_q) == len(test_model.controlled_joint_names_pinocchio_order) diff --git a/source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf b/source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf new file mode 100644 index 000000000000..07b80b9bac98 --- /dev/null +++ b/source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/isaaclab_assets/config/extension.toml b/source/isaaclab_assets/config/extension.toml index ccde51a7166d..dac5494087e0 100644 --- a/source/isaaclab_assets/config/extension.toml +++ b/source/isaaclab_assets/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.2.2" +version = "0.2.3" # Description title = "Isaac Lab Assets" diff --git a/source/isaaclab_assets/docs/CHANGELOG.rst b/source/isaaclab_assets/docs/CHANGELOG.rst index 85f70e7e8c33..b6582e77e8a2 100644 --- a/source/isaaclab_assets/docs/CHANGELOG.rst +++ b/source/isaaclab_assets/docs/CHANGELOG.rst @@ -1,6 +1,14 @@ Changelog --------- +0.2.3 (2025-08-11) +~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Configuration for G1 robot used for locomanipulation tasks. + 0.2.2 (2025-03-10) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_assets/isaaclab_assets/robots/unitree.py b/source/isaaclab_assets/isaaclab_assets/robots/unitree.py index ab963aafff56..573e1aedbf8b 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/unitree.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/unitree.py @@ -14,6 +14,7 @@ * :obj:`H1_MINIMAL_CFG`: H1 humanoid robot with minimal collision bodies * :obj:`G1_CFG`: G1 humanoid robot * :obj:`G1_MINIMAL_CFG`: G1 humanoid robot with minimal collision bodies +* :obj:`G1_29DOF_CFG`: G1 humanoid robot configured for locomanipulation tasks Reference: https://github.com/unitreerobotics/unitree_ros """ @@ -381,3 +382,174 @@ This configuration removes most collision meshes to speed up simulation. """ + + +G1_29DOF_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path="omniverse://isaac-dev.ov.nvidia.com/Isaac/Robots/Unitree/G1/g1.usd", + activate_contact_sensors=False, + rigid_props=sim_utils.RigidBodyPropertiesCfg( + disable_gravity=False, + retain_accelerations=False, + linear_damping=0.0, + angular_damping=0.0, + max_linear_velocity=1000.0, + max_angular_velocity=1000.0, + max_depenetration_velocity=1.0, + ), + articulation_props=sim_utils.ArticulationRootPropertiesCfg( + enabled_self_collisions=False, + fix_root_link=False, # Configurable - can be set to True for fixed base + solver_position_iteration_count=8, + solver_velocity_iteration_count=4, + ), + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.75), + rot=(0.7071, 0, 0, 0.7071), + joint_pos={ + ".*_hip_pitch_joint": -0.10, + ".*_knee_joint": 0.30, + ".*_ankle_pitch_joint": -0.20, + }, + joint_vel={".*": 0.0}, + ), + soft_joint_pos_limit_factor=0.9, + actuators={ + "legs": DCMotorCfg( + joint_names_expr=[ + ".*_hip_yaw_joint", + ".*_hip_roll_joint", + ".*_hip_pitch_joint", + ".*_knee_joint", + ], + effort_limit={ + ".*_hip_yaw_joint": 88.0, + ".*_hip_roll_joint": 88.0, + ".*_hip_pitch_joint": 88.0, + ".*_knee_joint": 139.0, + }, + velocity_limit={ + ".*_hip_yaw_joint": 32.0, + ".*_hip_roll_joint": 32.0, + ".*_hip_pitch_joint": 32.0, + ".*_knee_joint": 20.0, + }, + stiffness={ + ".*_hip_yaw_joint": 100.0, + ".*_hip_roll_joint": 100.0, + ".*_hip_pitch_joint": 100.0, + ".*_knee_joint": 200.0, + }, + damping={ + ".*_hip_yaw_joint": 2.5, + ".*_hip_roll_joint": 2.5, + ".*_hip_pitch_joint": 2.5, + ".*_knee_joint": 5.0, + }, + armature={ + ".*_hip_.*": 0.03, + ".*_knee_joint": 0.03, + }, + saturation_effort=180.0, + ), + "feet": DCMotorCfg( + joint_names_expr=[".*_ankle_pitch_joint", ".*_ankle_roll_joint"], + stiffness={ + ".*_ankle_pitch_joint": 20.0, + ".*_ankle_roll_joint": 20.0, + }, + damping={ + ".*_ankle_pitch_joint": 0.2, + ".*_ankle_roll_joint": 0.1, + }, + effort_limit={ + ".*_ankle_pitch_joint": 50.0, + ".*_ankle_roll_joint": 50.0, + }, + velocity_limit={ + ".*_ankle_pitch_joint": 37.0, + ".*_ankle_roll_joint": 37.0, + }, + armature=0.03, + saturation_effort=80.0, + ), + "waist": ImplicitActuatorCfg( + joint_names_expr=[ + "waist_.*_joint", + ], + effort_limit={ + "waist_yaw_joint": 88.0, + "waist_roll_joint": 50.0, + "waist_pitch_joint": 50.0, + }, + velocity_limit={ + "waist_yaw_joint": 32.0, + "waist_roll_joint": 37.0, + "waist_pitch_joint": 37.0, + }, + stiffness={ + "waist_yaw_joint": 5000.0, + "waist_roll_joint": 5000.0, + "waist_pitch_joint": 5000.0, + }, + damping={ + "waist_yaw_joint": 5.0, + "waist_roll_joint": 5.0, + "waist_pitch_joint": 5.0, + }, + armature=0.001, + ), + "arms": ImplicitActuatorCfg( + joint_names_expr=[ + ".*_shoulder_pitch_joint", + ".*_shoulder_roll_joint", + ".*_shoulder_yaw_joint", + ".*_elbow_joint", + ".*_wrist_.*_joint", + ], + effort_limit=300, + velocity_limit=100, + stiffness=3000.0, + damping=10.0, + armature={ + ".*_shoulder_.*": 0.001, + ".*_elbow_.*": 0.001, + ".*_wrist_.*_joint": 0.001, + }, + ), + "hands": ImplicitActuatorCfg( + joint_names_expr=[ + ".*_index_.*", + ".*_middle_.*", + ".*_thumb_.*", + ], + effort_limit=300, + velocity_limit=100, + stiffness=4000, + damping=50, + armature=0.001, + ), + }, + prim_path="/World/envs/env_.*/Robot", +) +"""Configuration for the Unitree G1 Humanoid robot for locomanipulation tasks. + +This configuration sets up the G1 humanoid robot for locomanipulation tasks, +allowing both locomotion and manipulation capabilities. The robot can be configured +for either fixed base or mobile scenarios by modifying the fix_root_link parameter. + +Key features: +- Configurable base (fixed or mobile) via fix_root_link parameter +- Optimized actuator parameters for locomanipulation tasks +- Enhanced hand and arm configurations for manipulation + +Usage examples: + # For fixed base scenarios (upper body manipulation only) + fixed_base_cfg = G1_29DOF_CFG.copy() + fixed_base_cfg.spawn.articulation_props.fix_root_link = True + + # For mobile scenarios (locomotion + manipulation) + mobile_cfg = G1_29DOF_CFG.copy() + mobile_cfg.spawn.articulation_props.fix_root_link = False +""" diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py index c782576c3630..7b6e491b6c6a 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/__init__.py @@ -8,6 +8,8 @@ import gymnasium as gym from .exhaustpipe_gr1t2_mimic_env_cfg import ExhaustPipeGR1T2MimicEnvCfg +from .locomanipulation_g1_mimic_env import LocomanipulationG1MimicEnv +from .locomanipulation_g1_mimic_env_cfg import LocomanipulationG1MimicEnvCfg from .nutpour_gr1t2_mimic_env_cfg import NutPourGR1T2MimicEnvCfg from .pickplace_gr1t2_mimic_env import PickPlaceGR1T2MimicEnv from .pickplace_gr1t2_mimic_env_cfg import PickPlaceGR1T2MimicEnvCfg @@ -44,3 +46,10 @@ kwargs={"env_cfg_entry_point": exhaustpipe_gr1t2_mimic_env_cfg.ExhaustPipeGR1T2MimicEnvCfg}, disable_env_checker=True, ) + +gym.register( + id="Isaac-Locomanipulation-G1-Abs-Mimic-v0", + entry_point="isaaclab_mimic.envs.pinocchio_envs:LocomanipulationG1MimicEnv", + kwargs={"env_cfg_entry_point": locomanipulation_g1_mimic_env_cfg.LocomanipulationG1MimicEnvCfg}, + disable_env_checker=True, +) diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env.py new file mode 100644 index 000000000000..ad612c61b0a6 --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env.py @@ -0,0 +1,129 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +import torch +from collections.abc import Sequence + +import isaaclab.utils.math as PoseUtils +from isaaclab.envs import ManagerBasedRLMimicEnv + + +class LocomanipulationG1MimicEnv(ManagerBasedRLMimicEnv): + + def get_robot_eef_pose(self, eef_name: str, env_ids: Sequence[int] | None = None) -> torch.Tensor: + """ + Get current robot end effector pose. Should be the same frame as used by the robot end-effector controller. + + Args: + eef_name: Name of the end effector. + env_ids: Environment indices to get the pose for. If None, all envs are considered. + + Returns: + A torch.Tensor eef pose matrix. Shape is (len(env_ids), 4, 4) + """ + if env_ids is None: + env_ids = slice(None) + + eef_pos_name = f"{eef_name}_eef_pos" + eef_quat_name = f"{eef_name}_eef_quat" + + target_wrist_position = self.obs_buf["policy"][eef_pos_name][env_ids] + target_rot_mat = PoseUtils.matrix_from_quat(self.obs_buf["policy"][eef_quat_name][env_ids]) + + return PoseUtils.make_pose(target_wrist_position, target_rot_mat) + + def target_eef_pose_to_action( + self, + target_eef_pose_dict: dict, + gripper_action_dict: dict, + action_noise_dict: dict | None = None, + env_id: int = 0, + ) -> torch.Tensor: + """ + Takes a target pose and gripper action for the end effector controller and returns an action + (usually a normalized delta pose action) to try and achieve that target pose. + Noise is added to the target pose action if specified. + + Args: + target_eef_pose_dict: Dictionary of 4x4 target eef pose for each end-effector. + gripper_action_dict: Dictionary of gripper actions for each end-effector. + action_noise_dict: Noise to add to the action. If None, no noise is added. + env_id: Environment index to get the action for. + + Returns: + An action torch.Tensor that's compatible with env.step(). + """ + + # target position and rotation + target_left_eef_pos, left_target_rot = PoseUtils.unmake_pose(target_eef_pose_dict["left"]) + target_right_eef_pos, right_target_rot = PoseUtils.unmake_pose(target_eef_pose_dict["right"]) + + target_left_eef_rot_quat = PoseUtils.quat_from_matrix(left_target_rot) + target_right_eef_rot_quat = PoseUtils.quat_from_matrix(right_target_rot) + + # gripper actions + left_gripper_action = gripper_action_dict["left"] + right_gripper_action = gripper_action_dict["right"] + + if action_noise_dict is not None: + pos_noise_left = action_noise_dict["left"] * torch.randn_like(target_left_eef_pos) + pos_noise_right = action_noise_dict["right"] * torch.randn_like(target_right_eef_pos) + quat_noise_left = action_noise_dict["left"] * torch.randn_like(target_left_eef_rot_quat) + quat_noise_right = action_noise_dict["right"] * torch.randn_like(target_right_eef_rot_quat) + + target_left_eef_pos += pos_noise_left + target_right_eef_pos += pos_noise_right + target_left_eef_rot_quat += quat_noise_left + target_right_eef_rot_quat += quat_noise_right + + return torch.cat( + ( + target_left_eef_pos, + target_left_eef_rot_quat, + target_right_eef_pos, + target_right_eef_rot_quat, + left_gripper_action, + right_gripper_action, + ), + dim=0, + ) + + def action_to_target_eef_pose(self, action: torch.Tensor) -> dict[str, torch.Tensor]: + """ + Converts action (compatible with env.step) to a target pose for the end effector controller. + Inverse of @target_eef_pose_to_action. Usually used to infer a sequence of target controller poses + from a demonstration trajectory using the recorded actions. + + Args: + action: Environment action. Shape is (num_envs, action_dim). + + Returns: + A dictionary of eef pose torch.Tensor that @action corresponds to. + """ + target_poses = {} + + target_left_wrist_position = action[:, 0:3] + target_left_rot_mat = PoseUtils.matrix_from_quat(action[:, 3:7]) + target_pose_left = PoseUtils.make_pose(target_left_wrist_position, target_left_rot_mat) + target_poses["left"] = target_pose_left + + target_right_wrist_position = action[:, 7:10] + target_right_rot_mat = PoseUtils.matrix_from_quat(action[:, 10:14]) + target_pose_right = PoseUtils.make_pose(target_right_wrist_position, target_right_rot_mat) + target_poses["right"] = target_pose_right + + return target_poses + + def actions_to_gripper_actions(self, actions: torch.Tensor) -> dict[str, torch.Tensor]: + """ + Extracts the gripper actuation part from a sequence of env actions (compatible with env.step). + + Args: + actions: environment actions. The shape is (num_envs, num steps in a demo, action_dim). + + Returns: + A dictionary of torch.Tensor gripper actions. Key to each dict is an eef_name. + """ + return {"left": actions[:, 14:21], "right": actions[:, 21:]} diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env_cfg.py new file mode 100644 index 000000000000..39bc02653b1f --- /dev/null +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pinocchio_envs/locomanipulation_g1_mimic_env_cfg.py @@ -0,0 +1,112 @@ +# Copyright (c) 2024-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +from isaaclab.envs.mimic_env_cfg import MimicEnvCfg, SubTaskConfig +from isaaclab.utils import configclass + +from isaaclab_tasks.manager_based.locomanipulation.pick_place.locomanipulation_g1_env_cfg import ( + LocomanipulationG1EnvCfg, +) + + +@configclass +class LocomanipulationG1MimicEnvCfg(LocomanipulationG1EnvCfg, MimicEnvCfg): + + def __post_init__(self): + # Call parent post-init + super().__post_init__() + + # Override datagen config values for demonstration generation + self.datagen_config.name = "demo_src_g1_locomanip_demo_task_D0" + self.datagen_config.generation_guarantee = True + self.datagen_config.generation_keep_failed = False + self.datagen_config.generation_num_trials = 1000 + self.datagen_config.generation_select_src_per_subtask = False + self.datagen_config.generation_select_src_per_arm = False + self.datagen_config.generation_relative = False + self.datagen_config.generation_joint_pos = False + self.datagen_config.generation_transform_first_robot_pose = False + self.datagen_config.generation_interpolate_from_last_target_pose = True + self.datagen_config.max_num_failures = 25 + self.datagen_config.num_demo_to_render = 10 + self.datagen_config.num_fail_demo_to_render = 25 + self.datagen_config.seed = 1 + + # Subtask configs for right arm + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="object", + # This key corresponds to the binary indicator in "datagen_info" that signals + # when this subtask is finished (e.g., on a 0 to 1 edge). + subtask_term_signal="idle_right", + # Randomization range for starting index of the first subtask + first_subtask_start_offset_range=(0, 0), + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(0, 0), + # Selection strategy for the source subtask segment during data generation + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.002, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="object", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal=None, + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.002, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=3, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["right"] = subtask_configs + + # Subtask configs for left arm + subtask_configs = [] + subtask_configs.append( + SubTaskConfig( + # Each subtask involves manipulation with respect to a single object frame. + object_ref="object", + # Corresponding key for the binary indicator in "datagen_info" for completion + subtask_term_signal=None, + # Time offsets for data generation when splitting a trajectory + subtask_term_offset_range=(0, 0), + # Selection strategy for source subtask segment + selection_strategy="nearest_neighbor_object", + # Optional parameters for the selection strategy function + selection_strategy_kwargs={"nn_k": 3}, + # Amount of action noise to apply during this subtask + action_noise=0.002, + # Number of interpolation steps to bridge to this subtask segment + num_interpolation_steps=0, + # Additional fixed steps for the robot to reach the necessary pose + num_fixed_steps=0, + # If True, apply action noise during the interpolation phase and execution + apply_noise_during_interpolation=False, + ) + ) + self.subtask_configs["left"] = subtask_configs diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/__init__.py similarity index 62% rename from source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/__init__.py rename to source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/__init__.py index cb907a3f0c8b..a6f661090332 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/__init__.py @@ -3,6 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Locomotion environments for legged robots.""" +"""This sub-module contains the functions that are specific to the locomanipulation environments.""" + +from .pick_place import * # noqa from .tracking import * # noqa diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/__init__.py new file mode 100644 index 000000000000..a3b30988b7fc --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/__init__.py @@ -0,0 +1,31 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +"""This sub-module contains the functions that are specific to the locomanipulation environments.""" + +import gymnasium as gym +import os + +from . import agents, fixed_base_upper_body_ik_g1_env_cfg, locomanipulation_g1_env_cfg + +gym.register( + id="Isaac-PickPlace-Locomanipulation-G1-Abs-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": locomanipulation_g1_env_cfg.LocomanipulationG1EnvCfg, + "robomimic_bc_cfg_entry_point": os.path.join(agents.__path__[0], "robomimic/bc_rnn_low_dim.json"), + }, + disable_env_checker=True, +) + +gym.register( + id="Isaac-PickPlace-FixedBaseUpperBodyIK-G1-Abs-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": fixed_base_upper_body_ik_g1_env_cfg.FixedBaseUpperBodyIKG1EnvCfg, + }, + disable_env_checker=True, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/agents/robomimic/bc_rnn_low_dim.json b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/agents/robomimic/bc_rnn_low_dim.json new file mode 100644 index 000000000000..c1dce5f832c8 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/agents/robomimic/bc_rnn_low_dim.json @@ -0,0 +1,117 @@ +{ + "algo_name": "bc", + "experiment": { + "name": "bc_rnn_low_dim_g1", + "validate": false, + "logging": { + "terminal_output_to_txt": true, + "log_tb": true + }, + "save": { + "enabled": true, + "every_n_seconds": null, + "every_n_epochs": 100, + "epochs": [], + "on_best_validation": false, + "on_best_rollout_return": false, + "on_best_rollout_success_rate": true + }, + "epoch_every_n_steps": 100, + "env": null, + "additional_envs": null, + "render": false, + "render_video": false, + "rollout": { + "enabled": false + } + }, + "train": { + "data": null, + "num_data_workers": 4, + "hdf5_cache_mode": "all", + "hdf5_use_swmr": true, + "hdf5_normalize_obs": false, + "hdf5_filter_key": null, + "hdf5_validation_filter_key": null, + "seq_length": 10, + "dataset_keys": [ + "actions" + ], + "goal_mode": null, + "cuda": true, + "batch_size": 100, + "num_epochs": 2000, + "seed": 101 + }, + "algo": { + "optim_params": { + "policy": { + "optimizer_type": "adam", + "learning_rate": { + "initial": 0.001, + "decay_factor": 0.1, + "epoch_schedule": [], + "scheduler_type": "multistep" + }, + "regularization": { + "L2": 0.0 + } + } + }, + "loss": { + "l2_weight": 1.0, + "l1_weight": 0.0, + "cos_weight": 0.0 + }, + "actor_layer_dims": [], + "gmm": { + "enabled": false, + "num_modes": 5, + "min_std": 0.0001, + "std_activation": "softplus", + "low_noise_eval": true + }, + "rnn": { + "enabled": true, + "horizon": 10, + "hidden_dim": 400, + "rnn_type": "LSTM", + "num_layers": 2, + "open_loop": false, + "kwargs": { + "bidirectional": false + } + }, + "transformer": { + "enabled": false, + "context_length": 10, + "embed_dim": 512, + "num_layers": 6, + "num_heads": 8, + "emb_dropout": 0.1, + "attn_dropout": 0.1, + "block_output_dropout": 0.1, + "sinusoidal_embedding": false, + "activation": "gelu", + "supervise_all_steps": false, + "nn_parameter_for_timesteps": true + } + }, + "observation": { + "modalities": { + "obs": { + "low_dim": [ + "left_eef_pos", + "left_eef_quat", + "right_eef_pos", + "right_eef_quat", + "hand_joint_state", + "object" + ], + "rgb": [], + "depth": [], + "scan": [] + } + } + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py new file mode 100644 index 000000000000..4d8db0b0c150 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/action_cfg.py @@ -0,0 +1,34 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from dataclasses import MISSING + +from isaaclab.managers.action_manager import ActionTerm, ActionTermCfg +from isaaclab.utils import configclass + +from ..mdp.actions import AgileBasedLowerBodyAction + + +@configclass +class AgileBasedLowerBodyActionCfg(ActionTermCfg): + """Configuration for the lower body action term that is based on Agile lower body RL policy.""" + + class_type: type[ActionTerm] = AgileBasedLowerBodyAction + """The class type for the lower body action term.""" + + joint_names: list[str] = MISSING + """The names of the joints to control.""" + + obs_group_name: str = MISSING + """The name of the observation group to use.""" + + policy_path: str = MISSING + """The path to the policy model.""" + + policy_output_offset: float = 0.0 + """Offsets the output of the policy.""" + + policy_output_scale: float = 1.0 + """Scales the output of the policy.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py new file mode 100644 index 000000000000..e4e22987442a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/agile_locomotion_observation_cfg.py @@ -0,0 +1,84 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.envs import mdp +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils import configclass + + +@configclass +class AgileTeacherPolicyObservationsCfg(ObsGroup): + """Observation specifications for the Agile lower body policy. + + Note: This configuration defines only part of the observation input to the Agile lower body policy. + The lower body command portion is appended to the observation tensor in the action term, as that + is where the environment has access to those commands. + """ + + base_lin_vel = ObsTerm( + func=mdp.base_lin_vel, + params={"asset_cfg": SceneEntityCfg("robot")}, + ) + + base_ang_vel = ObsTerm( + func=mdp.base_ang_vel, + params={"asset_cfg": SceneEntityCfg("robot")}, + ) + + projected_gravity = ObsTerm( + func=mdp.projected_gravity, + scale=1.0, + ) + + joint_pos = ObsTerm( + func=mdp.joint_pos_rel, + params={ + "asset_cfg": SceneEntityCfg( + "robot", + joint_names=[ + ".*_shoulder_.*_joint", + ".*_elbow_joint", + ".*_wrist_.*_joint", + ".*_hip_.*_joint", + ".*_knee_joint", + ".*_ankle_.*_joint", + "waist_.*_joint", + ], + ), + }, + ) + + joint_vel = ObsTerm( + func=mdp.joint_vel_rel, + scale=0.1, + params={ + "asset_cfg": SceneEntityCfg( + "robot", + joint_names=[ + ".*_shoulder_.*_joint", + ".*_elbow_joint", + ".*_wrist_.*_joint", + ".*_hip_.*_joint", + ".*_knee_joint", + ".*_ankle_.*_joint", + "waist_.*_joint", + ], + ), + }, + ) + + actions = ObsTerm( + func=mdp.last_action, + scale=1.0, + params={ + "action_name": "lower_body_joint_pos", + }, + ) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = True diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/pink_controller_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/pink_controller_cfg.py new file mode 100644 index 000000000000..1c80674e3831 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/configs/pink_controller_cfg.py @@ -0,0 +1,126 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Configuration for pink controller. + +This module provides configurations for humanoid robot pink IK controllers, +including both fixed base and mobile configurations for upper body manipulation. +""" + +from isaaclab.controllers.pink_ik.local_frame_task import LocalFrameTask +from isaaclab.controllers.pink_ik.null_space_posture_task import NullSpacePostureTask +from isaaclab.controllers.pink_ik.pink_ik_cfg import PinkIKControllerCfg +from isaaclab.envs.mdp.actions.pink_actions_cfg import PinkInverseKinematicsActionCfg + +## +# Pink IK Controller Configuration for G1 +## + +G1_UPPER_BODY_IK_CONTROLLER_CFG = PinkIKControllerCfg( + articulation_name="robot", + base_link_name="pelvis", + num_hand_joints=14, + show_ik_warnings=True, + fail_on_joint_limit_violation=False, + variable_input_tasks=[ + LocalFrameTask( + "g1_29dof_with_hand_rev_1_0_left_wrist_yaw_link", + base_link_frame_name="g1_29dof_with_hand_rev_1_0_pelvis", + position_cost=8.0, # [cost] / [m] + orientation_cost=2.0, # [cost] / [rad] + lm_damping=10, # dampening for solver for step jumps + gain=0.5, + ), + LocalFrameTask( + "g1_29dof_with_hand_rev_1_0_right_wrist_yaw_link", + base_link_frame_name="g1_29dof_with_hand_rev_1_0_pelvis", + position_cost=8.0, # [cost] / [m] + orientation_cost=2.0, # [cost] / [rad] + lm_damping=10, # dampening for solver for step jumps + gain=0.5, + ), + NullSpacePostureTask( + cost=0.5, + lm_damping=1, + controlled_frames=[ + "g1_29dof_with_hand_rev_1_0_left_wrist_yaw_link", + "g1_29dof_with_hand_rev_1_0_right_wrist_yaw_link", + ], + controlled_joints=[ + "left_shoulder_pitch_joint", + "left_shoulder_roll_joint", + "left_shoulder_yaw_joint", + "right_shoulder_pitch_joint", + "right_shoulder_roll_joint", + "right_shoulder_yaw_joint", + "waist_yaw_joint", + "waist_pitch_joint", + "waist_roll_joint", + ], + gain=0.3, + ), + ], + fixed_input_tasks=[], +) +"""Base configuration for the G1 pink IK controller. + +This configuration sets up the pink IK controller for the G1 humanoid robot with +left and right wrist control tasks. The controller is designed for upper body +manipulation tasks. +""" + + +## +# Pink IK Action Configuration for G1 +## + +G1_UPPER_BODY_IK_ACTION_CFG = PinkInverseKinematicsActionCfg( + pink_controlled_joint_names=[ + ".*_shoulder_pitch_joint", + ".*_shoulder_roll_joint", + ".*_shoulder_yaw_joint", + ".*_elbow_joint", + ".*_wrist_pitch_joint", + ".*_wrist_roll_joint", + ".*_wrist_yaw_joint", + "waist_.*_joint", + ], + hand_joint_names=[ + "left_hand_index_0_joint", # Index finger proximal + "left_hand_middle_0_joint", # Middle finger proximal + "left_hand_thumb_0_joint", # Thumb base (yaw axis) + "right_hand_index_0_joint", # Index finger proximal + "right_hand_middle_0_joint", # Middle finger proximal + "right_hand_thumb_0_joint", # Thumb base (yaw axis) + "left_hand_index_1_joint", # Index finger distal + "left_hand_middle_1_joint", # Middle finger distal + "left_hand_thumb_1_joint", # Thumb middle (pitch axis) + "right_hand_index_1_joint", # Index finger distal + "right_hand_middle_1_joint", # Middle finger distal + "right_hand_thumb_1_joint", # Thumb middle (pitch axis) + "left_hand_thumb_2_joint", # Thumb tip + "right_hand_thumb_2_joint", # Thumb tip + ], + target_eef_link_names={ + "left_wrist": "left_wrist_yaw_link", + "right_wrist": "right_wrist_yaw_link", + }, + # the robot in the sim scene we are controlling + asset_name="robot", + # Configuration for the IK controller + # The frames names are the ones present in the URDF file + # The urdf has to be generated from the USD that is being used in the scene + controller=G1_UPPER_BODY_IK_CONTROLLER_CFG, +) +"""Base configuration for the G1 pink IK action. + +This configuration sets up the pink IK action for the G1 humanoid robot, +defining which joints are controlled by the IK solver and which are fixed. +The configuration includes: +- Upper body joints controlled by IK (shoulders, elbows, wrists) +- Fixed joints (pelvis, legs, hands) +- Hand joint names for additional control +- Reference to the pink IK controller configuration +""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py new file mode 100644 index 000000000000..6d22da2e1fa1 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py @@ -0,0 +1,213 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +from isaaclab_assets.robots.unitree import G1_29DOF_CFG + +import isaaclab.envs.mdp as base_mdp +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg +from isaaclab.devices.device_base import DevicesCfg +from isaaclab.devices.openxr import OpenXRDeviceCfg, XrCfg +from isaaclab.devices.openxr.retargeters.humanoid.unitree.g1_upper_body_retargeter import G1UpperBodyRetargeterCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +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.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path + +from isaaclab_tasks.manager_based.locomanipulation.pick_place import mdp as locomanip_mdp +from isaaclab_tasks.manager_based.manipulation.pick_place import mdp as manip_mdp + +from isaaclab_tasks.manager_based.locomanipulation.pick_place.configs.pink_controller_cfg import ( # isort: skip + G1_UPPER_BODY_IK_ACTION_CFG, +) + + +## +# Scene definition +## +@configclass +class FixedBaseUpperBodyIKG1SceneCfg(InteractiveSceneCfg): + """Scene configuration for fixed base upper body IK environment with G1 robot. + + This configuration sets up the G1 humanoid robot with fixed pelvis and legs, + allowing only arm manipulation while the base remains stationary. The robot is + controlled using upper body IK. + """ + + # Table + packing_table = AssetBaseCfg( + prim_path="/World/envs/env_.*/PackingTable", + init_state=AssetBaseCfg.InitialStateCfg(pos=[0.0, 0.55, -0.3], rot=[1.0, 0.0, 0.0, 0.0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/PackingTable/packing_table.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg(kinematic_enabled=True), + ), + ) + + object = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Object", + init_state=RigidObjectCfg.InitialStateCfg(pos=[-0.35, 0.45, 0.6996], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Mimic/pick_place_task/pick_place_assets/steering_wheel.usd", + scale=(0.75, 0.75, 0.75), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + ), + ) + + # Unitree G1 Humanoid robot - fixed base configuration + robot: ArticulationCfg = G1_29DOF_CFG + + # Ground plane + ground = AssetBaseCfg( + prim_path="/World/GroundPlane", + spawn=GroundPlaneCfg(), + ) + + # Lights + light = AssetBaseCfg( + prim_path="/World/light", + spawn=sim_utils.DomeLightCfg(color=(0.75, 0.75, 0.75), intensity=3000.0), + ) + + def __post_init__(self): + """Post initialization.""" + # Set the robot to fixed base + self.robot.spawn.articulation_props.fix_root_link = True + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + upper_body_ik = G1_UPPER_BODY_IK_ACTION_CFG + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP. + This class is required by the environment configuration but not used in this implementation + """ + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group with state values.""" + + actions = ObsTerm(func=manip_mdp.last_action) + robot_joint_pos = ObsTerm( + func=base_mdp.joint_pos, + params={"asset_cfg": SceneEntityCfg("robot")}, + ) + robot_root_pos = ObsTerm(func=base_mdp.root_pos_w, params={"asset_cfg": SceneEntityCfg("robot")}) + robot_root_rot = ObsTerm(func=base_mdp.root_quat_w, params={"asset_cfg": SceneEntityCfg("robot")}) + object_pos = ObsTerm(func=base_mdp.root_pos_w, params={"asset_cfg": SceneEntityCfg("object")}) + object_rot = ObsTerm(func=base_mdp.root_quat_w, params={"asset_cfg": SceneEntityCfg("object")}) + robot_links_state = ObsTerm(func=manip_mdp.get_all_robot_link_state) + + left_eef_pos = ObsTerm(func=manip_mdp.get_eef_pos, params={"link_name": "left_wrist_yaw_link"}) + left_eef_quat = ObsTerm(func=manip_mdp.get_eef_quat, params={"link_name": "left_wrist_yaw_link"}) + right_eef_pos = ObsTerm(func=manip_mdp.get_eef_pos, params={"link_name": "right_wrist_yaw_link"}) + right_eef_quat = ObsTerm(func=manip_mdp.get_eef_quat, params={"link_name": "right_wrist_yaw_link"}) + + hand_joint_state = ObsTerm(func=manip_mdp.get_robot_joint_state, params={"joint_names": [".*_hand.*"]}) + head_joint_state = ObsTerm(func=manip_mdp.get_robot_joint_state, params={"joint_names": []}) + + object = ObsTerm( + func=manip_mdp.object_obs, + params={"left_eef_link_name": "left_wrist_yaw_link", "right_eef_link_name": "right_wrist_yaw_link"}, + ) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = False + + # observation groups + policy: PolicyCfg = PolicyCfg() + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + time_out = DoneTerm(func=locomanip_mdp.time_out, time_out=True) + + object_dropping = DoneTerm( + func=base_mdp.root_height_below_minimum, params={"minimum_height": 0.5, "asset_cfg": SceneEntityCfg("object")} + ) + + success = DoneTerm(func=manip_mdp.task_done_pick_place, params={"task_link_name": "right_wrist_yaw_link"}) + + +## +# MDP settings +## + + +@configclass +class FixedBaseUpperBodyIKG1EnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the G1 fixed base upper body IK environment. + + This environment is designed for manipulation tasks where the G1 humanoid robot + has a fixed pelvis and legs, allowing only arm and hand movements for manipulation. The robot is + controlled using upper body IK. + """ + + # Scene settings + scene: FixedBaseUpperBodyIKG1SceneCfg = FixedBaseUpperBodyIKG1SceneCfg( + num_envs=1, env_spacing=2.5, replicate_physics=True + ) + # MDP settings + terminations: TerminationsCfg = TerminationsCfg() + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + + # Unused managers + commands = None + rewards = None + curriculum = None + + # Position of the XR anchor in the world frame + xr: XrCfg = XrCfg( + anchor_pos=(0.0, 0.0, 0.2), + anchor_rot=(1.0, 0.0, 0.0, 0.0), + ) + + def __post_init__(self): + """Post initialization.""" + # general settings + self.decimation = 4 + self.episode_length_s = 20.0 + # simulation settings + self.sim.dt = 1 / 200 # 200Hz + self.sim.render_interval = 2 + + # Set the URDF and mesh paths for the IK controller + urdf_omniverse_path = f"{ISAACLAB_NUCLEUS_DIR}/Controllers/LocomanipulationAssets/unitree_g1_kinematics_asset/g1_29dof_with_hand_only_kinematics.urdf" + + # Retrieve local paths for the URDF and mesh files. Will be cached for call after the first time. + self.actions.upper_body_ik.controller.urdf_path = retrieve_file_path(urdf_omniverse_path) + + self.teleop_devices = DevicesCfg( + devices={ + "handtracking": OpenXRDeviceCfg( + retargeters=[ + G1UpperBodyRetargeterCfg( + enable_visualization=True, + # OpenXR hand tracking has 26 joints per hand + num_open_xr_hand_joints=2 * 26, + sim_device=self.sim.device, + hand_joint_names=self.actions.upper_body_ik.hand_joint_names, + ), + ], + sim_device=self.sim.device, + xr_cfg=self.xr, + ), + } + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py new file mode 100644 index 000000000000..98f7432d23fa --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py @@ -0,0 +1,227 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +from isaaclab_assets.robots.unitree import G1_29DOF_CFG + +import isaaclab.envs.mdp as base_mdp +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg +from isaaclab.devices.device_base import DevicesCfg +from isaaclab.devices.openxr import OpenXRDeviceCfg, XrCfg +from isaaclab.devices.openxr.retargeters.humanoid.unitree.g1_lower_body_standing import G1LowerBodyStandingRetargeterCfg +from isaaclab.devices.openxr.retargeters.humanoid.unitree.g1_upper_body_retargeter import G1UpperBodyRetargeterCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +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.sim.spawners.from_files.from_files_cfg import GroundPlaneCfg, UsdFileCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR, retrieve_file_path + +from isaaclab_tasks.manager_based.locomanipulation.pick_place import mdp as locomanip_mdp +from isaaclab_tasks.manager_based.locomanipulation.pick_place.configs.action_cfg import AgileBasedLowerBodyActionCfg +from isaaclab_tasks.manager_based.locomanipulation.pick_place.configs.agile_locomotion_observation_cfg import ( + AgileTeacherPolicyObservationsCfg, +) +from isaaclab_tasks.manager_based.manipulation.pick_place import mdp as manip_mdp + +from isaaclab_tasks.manager_based.locomanipulation.pick_place.configs.pink_controller_cfg import ( # isort: skip + G1_UPPER_BODY_IK_ACTION_CFG, +) + + +## +# Scene definition +## +@configclass +class LocomanipulationG1SceneCfg(InteractiveSceneCfg): + """Scene configuration for locomanipulation environment with G1 robot. + + This configuration sets up the G1 humanoid robot for locomanipulation tasks, + allowing both locomotion and manipulation capabilities. The robot can move its + base and use its arms for manipulation tasks. + """ + + # Table + packing_table = AssetBaseCfg( + prim_path="/World/envs/env_.*/PackingTable", + init_state=AssetBaseCfg.InitialStateCfg(pos=[0.0, 0.55, -0.3], rot=[1.0, 0.0, 0.0, 0.0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/PackingTable/packing_table.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg(kinematic_enabled=True), + ), + ) + + object = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Object", + init_state=RigidObjectCfg.InitialStateCfg(pos=[-0.35, 0.45, 0.6996], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Mimic/pick_place_task/pick_place_assets/steering_wheel.usd", + scale=(0.75, 0.75, 0.75), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + ), + ) + + # Humanoid robot w/ arms higher + robot: ArticulationCfg = G1_29DOF_CFG + + # Ground plane + ground = AssetBaseCfg( + prim_path="/World/GroundPlane", + spawn=GroundPlaneCfg(), + ) + + # Lights + light = AssetBaseCfg( + prim_path="/World/light", + spawn=sim_utils.DomeLightCfg(color=(0.75, 0.75, 0.75), intensity=3000.0), + ) + + +@configclass +class ActionsCfg: + """Action specifications for the MDP.""" + + upper_body_ik = G1_UPPER_BODY_IK_ACTION_CFG + + lower_body_joint_pos = AgileBasedLowerBodyActionCfg( + asset_name="robot", + joint_names=[ + ".*_hip_.*_joint", + ".*_knee_joint", + ".*_ankle_.*_joint", + ], + policy_output_scale=0.25, + obs_group_name="lower_body_policy", # need to be the same name as the on in ObservationCfg + policy_path="omniverse://isaac-dev.ov.nvidia.com/Projects/agile/policy_checkpoints/agile_locomotion.pt", + ) + + +@configclass +class ObservationsCfg: + """Observation specifications for the MDP. + This class is required by the environment configuration but not used in this implementation + """ + + @configclass + class PolicyCfg(ObsGroup): + """Observations for policy group with state values.""" + + actions = ObsTerm(func=manip_mdp.last_action) + robot_joint_pos = ObsTerm( + func=base_mdp.joint_pos, + params={"asset_cfg": SceneEntityCfg("robot")}, + ) + robot_root_pos = ObsTerm(func=base_mdp.root_pos_w, params={"asset_cfg": SceneEntityCfg("robot")}) + robot_root_rot = ObsTerm(func=base_mdp.root_quat_w, params={"asset_cfg": SceneEntityCfg("robot")}) + object_pos = ObsTerm(func=base_mdp.root_pos_w, params={"asset_cfg": SceneEntityCfg("object")}) + object_rot = ObsTerm(func=base_mdp.root_quat_w, params={"asset_cfg": SceneEntityCfg("object")}) + robot_links_state = ObsTerm(func=manip_mdp.get_all_robot_link_state) + + left_eef_pos = ObsTerm(func=manip_mdp.get_eef_pos, params={"link_name": "left_wrist_yaw_link"}) + left_eef_quat = ObsTerm(func=manip_mdp.get_eef_quat, params={"link_name": "left_wrist_yaw_link"}) + right_eef_pos = ObsTerm(func=manip_mdp.get_eef_pos, params={"link_name": "right_wrist_yaw_link"}) + right_eef_quat = ObsTerm(func=manip_mdp.get_eef_quat, params={"link_name": "right_wrist_yaw_link"}) + + hand_joint_state = ObsTerm(func=manip_mdp.get_robot_joint_state, params={"joint_names": [".*_hand.*"]}) + + object = ObsTerm( + func=manip_mdp.object_obs, + params={"left_eef_link_name": "left_wrist_yaw_link", "right_eef_link_name": "right_wrist_yaw_link"}, + ) + + def __post_init__(self): + self.enable_corruption = False + self.concatenate_terms = False + + # observation groups + policy: PolicyCfg = PolicyCfg() + lower_body_policy: AgileTeacherPolicyObservationsCfg = AgileTeacherPolicyObservationsCfg() + + +@configclass +class TerminationsCfg: + """Termination terms for the MDP.""" + + time_out = DoneTerm(func=locomanip_mdp.time_out, time_out=True) + + object_dropping = DoneTerm( + func=base_mdp.root_height_below_minimum, params={"minimum_height": 0.5, "asset_cfg": SceneEntityCfg("object")} + ) + + success = DoneTerm(func=manip_mdp.task_done_pick_place, params={"task_link_name": "right_wrist_yaw_link"}) + + +## +# MDP settings +## + + +@configclass +class LocomanipulationG1EnvCfg(ManagerBasedRLEnvCfg): + """Configuration for the G1 locomanipulation environment. + + This environment is designed for locomanipulation tasks where the G1 humanoid robot + can perform both locomotion and manipulation simultaneously. The robot can move its + base and use its arms for manipulation tasks, enabling complex mobile manipulation + behaviors. + """ + + # Scene settings + scene: LocomanipulationG1SceneCfg = LocomanipulationG1SceneCfg(num_envs=1, env_spacing=2.5, replicate_physics=True) + # MDP settings + observations: ObservationsCfg = ObservationsCfg() + actions: ActionsCfg = ActionsCfg() + commands = None + terminations: TerminationsCfg = TerminationsCfg() + + # Unused managers + rewards = None + curriculum = None + + # Position of the XR anchor in the world frame + xr: XrCfg = XrCfg( + anchor_pos=(0.0, 0.0, 0.3), + anchor_rot=(1.0, 0.0, 0.0, 0.0), + ) + + def __post_init__(self): + """Post initialization.""" + # general settings + self.decimation = 4 + self.episode_length_s = 20.0 + # simulation settings + self.sim.dt = 1 / 200 # 200Hz + self.sim.render_interval = 2 + + # Set the URDF and mesh paths for the IK controller + urdf_omniverse_path = f"{ISAACLAB_NUCLEUS_DIR}/Controllers/LocomanipulationAssets/unitree_g1_kinematics_asset/g1_29dof_with_hand_only_kinematics.urdf" + + # Retrieve local paths for the URDF and mesh files. Will be cached for call after the first time. + self.actions.upper_body_ik.controller.urdf_path = retrieve_file_path(urdf_omniverse_path) + + self.teleop_devices = DevicesCfg( + devices={ + "handtracking": OpenXRDeviceCfg( + retargeters=[ + G1UpperBodyRetargeterCfg( + enable_visualization=True, + # OpenXR hand tracking has 26 joints per hand + num_open_xr_hand_joints=2 * 26, + sim_device=self.sim.device, + hand_joint_names=self.actions.upper_body_ik.hand_joint_names, + ), + G1LowerBodyStandingRetargeterCfg( + sim_device=self.sim.device, + ), + ], + sim_device=self.sim.device, + xr_cfg=self.xr, + ), + } + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/__init__.py new file mode 100644 index 000000000000..18ec38070d59 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +"""This sub-module contains the functions that are specific to the locomanipulation environments.""" + +from isaaclab.envs.mdp import * # noqa: F401, F403 + +from .actions import * # noqa: F401, F403 +from .observations import * # noqa: F401, F403 diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py new file mode 100644 index 000000000000..5e7ffebde7e6 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py @@ -0,0 +1,125 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import torch +from typing import TYPE_CHECKING + +from isaaclab.assets.articulation import Articulation +from isaaclab.controllers.utils import load_torchscript_model +from isaaclab.managers.action_manager import ActionTerm +from isaaclab.utils.assets import retrieve_file_path + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv + + from .configs.action_cfg import AgileBasedLowerBodyActionCfg + + +class AgileBasedLowerBodyAction(ActionTerm): + """Action term that is based on Agile lower body RL policy.""" + + cfg: AgileBasedLowerBodyActionCfg + """The configuration of the action term.""" + + _asset: Articulation + """The articulation asset to which the action term is applied.""" + + def __init__(self, cfg: AgileBasedLowerBodyActionCfg, env: ManagerBasedEnv): + super().__init__(cfg, env) + + # Save the observation config from cfg + self._observation_cfg = env.cfg.observations + self._obs_group_name = cfg.obs_group_name + + # Load policy here if needed + _temp_policy_path = retrieve_file_path(cfg.policy_path) + self._policy = load_torchscript_model(_temp_policy_path, device=env.device) + self._env = env + + # Find joint ids for the lower body joints + self._joint_ids, self._joint_names = self._asset.find_joints(self.cfg.joint_names) + + # Get the scale and offset from the configuration + self._policy_output_scale = torch.tensor(cfg.policy_output_scale, device=env.device) + self._policy_output_offset = self._asset.data.default_joint_pos[:, self._joint_ids].clone() + + # Create tensors to store raw and processed actions + self._raw_actions = torch.zeros(self.num_envs, len(self._joint_ids), device=self.device) + self._processed_actions = torch.zeros(self.num_envs, len(self._joint_ids), device=self.device) + + """ + Properties. + """ + + @property + def action_dim(self) -> int: + """Lower Body Action: [vx, vy, wz, hip_height]""" + return 4 + + @property + def raw_actions(self) -> torch.Tensor: + return self._raw_actions + + @property + def processed_actions(self) -> torch.Tensor: + return self._processed_actions + + def _compose_policy_input(self, base_command: torch.Tensor, obs_tensor: torch.Tensor) -> torch.Tensor: + """Compose the policy input by concatenating repeated commands with observations. + + Args: + base_command: The base command tensor [vx, vy, wz, hip_height]. + obs_tensor: The observation tensor from the environment. + + Returns: + The composed policy input tensor with repeated commands concatenated to observations. + """ + # Get history length from observation configuration + history_length = getattr(self._observation_cfg, self._obs_group_name).history_length + # Default to 1 if history_length is None (no history, just current observation) + if history_length is None: + history_length = 1 + + # Repeat commands based on history length and concatenate with observations + repeated_commands = base_command.unsqueeze(1).repeat(1, history_length, 1).reshape(base_command.shape[0], -1) + policy_input = torch.cat([repeated_commands, obs_tensor], dim=-1) + + return policy_input + + def process_actions(self, actions: torch.Tensor): + """Process the input actions using the locomotion policy. + + Args: + actions: The lower body commands. + """ + + # Extract base command from the action tensor + # Assuming the base command [vx, vy, wz, hip_height] + base_command = actions + + obs_tensor = self._env.obs_buf["lower_body_policy"] + + # Compose policy input using helper function + policy_input = self._compose_policy_input(base_command, obs_tensor) + + joint_actions = self._policy.forward(policy_input) + + self._raw_actions[:] = joint_actions + + # Apply scaling and offset to the raw actions from the policy + self._processed_actions = joint_actions * self._policy_output_scale + self._policy_output_offset + + # Clip actions if configured + if self.cfg.clip is not None: + self._processed_actions = torch.clamp( + self._processed_actions, min=self._clip[:, :, 0], max=self._clip[:, :, 1] + ) + + def apply_actions(self): + """Apply the actions to the environment.""" + # Store the raw actions + self._asset.set_joint_position_target(self._processed_actions, joint_ids=self._joint_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/observations.py new file mode 100644 index 000000000000..ab027ce0bf13 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/observations.py @@ -0,0 +1,32 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import torch + +from isaaclab.envs import ManagerBasedRLEnv +from isaaclab.managers import SceneEntityCfg + + +def upper_body_last_action( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"), +) -> torch.Tensor: + """Extract the last action of the upper body.""" + asset = env.scene[asset_cfg.name] + joint_pos_target = asset.data.joint_pos_target + + # Use joint_names from asset_cfg to find indices + joint_names = asset_cfg.joint_names if hasattr(asset_cfg, "joint_names") else None + if joint_names is None: + raise ValueError("asset_cfg must have 'joint_names' attribute for upper_body_last_action.") + + # Find joint indices matching the provided joint_names (supports regex) + joint_indices, _ = asset.find_joints(joint_names) + joint_indices = torch.tensor(joint_indices, dtype=torch.long) + + # Get upper body joint positions for all environments + upper_body_joint_pos_target = joint_pos_target[:, joint_indices] + + return upper_body_joint_pos_target diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/__init__.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/__init__.py rename to source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/__init__.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/__init__.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/__init__.py rename to source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/__init__.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/__init__.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/__init__.py rename to source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/__init__.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/__init__.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/__init__.py rename to source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/__init__.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py rename to source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/agents/rsl_rl_ppo_cfg.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/loco_manip_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/loco_manip_env_cfg.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/manager_based/loco_manipulation/tracking/config/digit/loco_manip_env_cfg.py rename to source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/loco_manip_env_cfg.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py index ed1f0f06130c..488db92d0e7a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_base_env_cfg.py @@ -184,13 +184,16 @@ class PolicyCfg(ObsGroup): params={"asset_cfg": SceneEntityCfg("robot")}, ) - left_eef_pos = ObsTerm(func=mdp.get_left_eef_pos) - left_eef_quat = ObsTerm(func=mdp.get_left_eef_quat) - right_eef_pos = ObsTerm(func=mdp.get_right_eef_pos) - right_eef_quat = ObsTerm(func=mdp.get_right_eef_quat) - - hand_joint_state = ObsTerm(func=mdp.get_hand_state) - head_joint_state = ObsTerm(func=mdp.get_head_state) + left_eef_pos = ObsTerm(func=mdp.get_eef_pos, params={"link_name": "left_hand_roll_link"}) + left_eef_quat = ObsTerm(func=mdp.get_eef_quat, params={"link_name": "left_hand_roll_link"}) + right_eef_pos = ObsTerm(func=mdp.get_eef_pos, params={"link_name": "right_hand_roll_link"}) + right_eef_quat = ObsTerm(func=mdp.get_eef_quat, params={"link_name": "right_hand_roll_link"}) + + hand_joint_state = ObsTerm(func=mdp.get_robot_joint_state, params={"joint_names": ["R_.*", "L_.*"]}) + head_joint_state = ObsTerm( + func=mdp.get_robot_joint_state, + params={"joint_names": ["head_pitch_joint", "head_roll_joint", "head_yaw_joint"]}, + ) robot_pov_cam = ObsTerm( func=mdp.image, @@ -216,7 +219,7 @@ class TerminationsCfg: params={"minimum_height": 0.5, "asset_cfg": SceneEntityCfg("blue_exhaust_pipe")}, ) - success = DoneTerm(func=mdp.task_done_exhaust_pipe) + success = DoneTerm(func=mdp.task_done_exhaust_pipe, params={"relevant_link_name": "right_hand_roll_link"}) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py index 0a3cb26b4d3e..01feeab1cc2e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/exhaustpipe_gr1t2_pink_ik_env_cfg.py @@ -42,49 +42,6 @@ def __post_init__(self): "right_wrist_roll_joint", "right_wrist_pitch_joint", ], - # Joints to be locked in URDF - ik_urdf_fixed_joint_names=[ - "left_hip_roll_joint", - "right_hip_roll_joint", - "left_hip_yaw_joint", - "right_hip_yaw_joint", - "left_hip_pitch_joint", - "right_hip_pitch_joint", - "left_knee_pitch_joint", - "right_knee_pitch_joint", - "left_ankle_pitch_joint", - "right_ankle_pitch_joint", - "left_ankle_roll_joint", - "right_ankle_roll_joint", - "L_index_proximal_joint", - "L_middle_proximal_joint", - "L_pinky_proximal_joint", - "L_ring_proximal_joint", - "L_thumb_proximal_yaw_joint", - "R_index_proximal_joint", - "R_middle_proximal_joint", - "R_pinky_proximal_joint", - "R_ring_proximal_joint", - "R_thumb_proximal_yaw_joint", - "L_index_intermediate_joint", - "L_middle_intermediate_joint", - "L_pinky_intermediate_joint", - "L_ring_intermediate_joint", - "L_thumb_proximal_pitch_joint", - "R_index_intermediate_joint", - "R_middle_intermediate_joint", - "R_pinky_intermediate_joint", - "R_ring_intermediate_joint", - "R_thumb_proximal_pitch_joint", - "L_thumb_distal_joint", - "R_thumb_distal_joint", - "head_roll_joint", - "head_pitch_joint", - "head_yaw_joint", - "waist_yaw_joint", - "waist_pitch_joint", - "waist_roll_joint", - ], hand_joint_names=[ "L_index_proximal_joint", "L_middle_proximal_joint", @@ -164,14 +121,7 @@ def __post_init__(self): ], ), ], - fixed_input_tasks=[ - # COMMENT OUT IF LOCKING WAIST/HEAD - # FrameTask( - # "GR1T2_fourier_hand_6dof_head_yaw_link", - # position_cost=1.0, # [cost] / [m] - # orientation_cost=0.05, # [cost] / [rad] - # ), - ], + fixed_input_tasks=[], xr_enabled=bool(carb.settings.get_settings().get("/app/xr/enabled")), ), ) @@ -179,9 +129,6 @@ def __post_init__(self): temp_urdf_output_path, temp_urdf_meshes_output_path = ControllerUtils.convert_usd_to_urdf( self.scene.robot.spawn.usd_path, self.temp_urdf_dir, force_conversion=True ) - ControllerUtils.change_revolute_to_fixed( - temp_urdf_output_path, self.actions.gr1_action.ik_urdf_fixed_joint_names - ) # Set the URDF and mesh paths for the IK controller self.actions.gr1_action.controller.urdf_path = temp_urdf_output_path diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py index efc8d9f7b1e1..b4dfcb6829f1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/observations.py @@ -14,6 +14,8 @@ def object_obs( env: ManagerBasedRLEnv, + left_eef_link_name: str, + right_eef_link_name: str, ) -> torch.Tensor: """ Object observations (in world frame): @@ -24,8 +26,8 @@ def object_obs( """ body_pos_w = env.scene["robot"].data.body_pos_w - left_eef_idx = env.scene["robot"].data.body_names.index("left_hand_roll_link") - right_eef_idx = env.scene["robot"].data.body_names.index("right_hand_roll_link") + left_eef_idx = env.scene["robot"].data.body_names.index(left_eef_link_name) + right_eef_idx = env.scene["robot"].data.body_names.index(right_eef_link_name) left_eef_pos = body_pos_w[:, left_eef_idx] - env.scene.env_origins right_eef_pos = body_pos_w[:, right_eef_idx] - env.scene.env_origins @@ -46,63 +48,32 @@ def object_obs( ) -def get_left_eef_pos( - env: ManagerBasedRLEnv, -) -> torch.Tensor: +def get_eef_pos(env: ManagerBasedRLEnv, link_name: str) -> torch.Tensor: body_pos_w = env.scene["robot"].data.body_pos_w - left_eef_idx = env.scene["robot"].data.body_names.index("left_hand_roll_link") + left_eef_idx = env.scene["robot"].data.body_names.index(link_name) left_eef_pos = body_pos_w[:, left_eef_idx] - env.scene.env_origins return left_eef_pos -def get_left_eef_quat( - env: ManagerBasedRLEnv, -) -> torch.Tensor: +def get_eef_quat(env: ManagerBasedRLEnv, link_name: str) -> torch.Tensor: body_quat_w = env.scene["robot"].data.body_quat_w - left_eef_idx = env.scene["robot"].data.body_names.index("left_hand_roll_link") + left_eef_idx = env.scene["robot"].data.body_names.index(link_name) left_eef_quat = body_quat_w[:, left_eef_idx] return left_eef_quat -def get_right_eef_pos( - env: ManagerBasedRLEnv, -) -> torch.Tensor: - body_pos_w = env.scene["robot"].data.body_pos_w - right_eef_idx = env.scene["robot"].data.body_names.index("right_hand_roll_link") - right_eef_pos = body_pos_w[:, right_eef_idx] - env.scene.env_origins - - return right_eef_pos - - -def get_right_eef_quat( - env: ManagerBasedRLEnv, -) -> torch.Tensor: - body_quat_w = env.scene["robot"].data.body_quat_w - right_eef_idx = env.scene["robot"].data.body_names.index("right_hand_roll_link") - right_eef_quat = body_quat_w[:, right_eef_idx] - - return right_eef_quat - - -def get_hand_state( - env: ManagerBasedRLEnv, -) -> torch.Tensor: - hand_joint_states = env.scene["robot"].data.joint_pos[:, -22:] # Hand joints are last 22 entries of joint state - - return hand_joint_states - - -def get_head_state( +def get_robot_joint_state( env: ManagerBasedRLEnv, + joint_names: list[str], ) -> torch.Tensor: - robot_joint_names = env.scene["robot"].data.joint_names - head_joint_names = ["head_pitch_joint", "head_roll_joint", "head_yaw_joint"] - indexes = torch.tensor([robot_joint_names.index(name) for name in head_joint_names], dtype=torch.long) - head_joint_states = env.scene["robot"].data.joint_pos[:, indexes] + # hand_joint_names is a list of regex, use find_joints + indexes, _ = env.scene["robot"].find_joints(joint_names) + indexes = torch.tensor(indexes, dtype=torch.long) + robot_joint_states = env.scene["robot"].data.joint_pos[:, indexes] - return head_joint_states + return robot_joint_states def get_all_robot_link_state( diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/terminations.py index ee6dbd685268..477552bbdbae 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/mdp/terminations.py @@ -23,6 +23,7 @@ def task_done_pick_place( env: ManagerBasedRLEnv, + task_link_name: str = "", object_cfg: SceneEntityCfg = SceneEntityCfg("object"), right_wrist_max_x: float = 0.26, min_x: float = 0.40, @@ -54,6 +55,9 @@ def task_done_pick_place( Returns: Boolean tensor indicating which environments have completed the task. """ + if task_link_name == "": + raise ValueError("task_link_name must be provided to task_done_pick_place") + # Get object entity from the scene object: RigidObject = env.scene[object_cfg.name] @@ -65,7 +69,7 @@ def task_done_pick_place( # Get right wrist position relative to environment origin robot_body_pos_w = env.scene["robot"].data.body_pos_w - right_eef_idx = env.scene["robot"].data.body_names.index("right_hand_roll_link") + right_eef_idx = env.scene["robot"].data.body_names.index(task_link_name) right_wrist_x = robot_body_pos_w[:, right_eef_idx, 0] - env.scene.env_origins[:, 0] # Check all success conditions and combine with logical AND diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py index ffa7929c9539..8467b67f39b6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_base_env_cfg.py @@ -205,13 +205,16 @@ class PolicyCfg(ObsGroup): params={"asset_cfg": SceneEntityCfg("robot")}, ) - left_eef_pos = ObsTerm(func=mdp.get_left_eef_pos) - left_eef_quat = ObsTerm(func=mdp.get_left_eef_quat) - right_eef_pos = ObsTerm(func=mdp.get_right_eef_pos) - right_eef_quat = ObsTerm(func=mdp.get_right_eef_quat) - - hand_joint_state = ObsTerm(func=mdp.get_hand_state) - head_joint_state = ObsTerm(func=mdp.get_head_state) + left_eef_pos = ObsTerm(func=mdp.get_eef_pos, params={"link_name": "left_hand_roll_link"}) + left_eef_quat = ObsTerm(func=mdp.get_eef_quat, params={"link_name": "left_hand_roll_link"}) + right_eef_pos = ObsTerm(func=mdp.get_eef_pos, params={"link_name": "right_hand_roll_link"}) + right_eef_quat = ObsTerm(func=mdp.get_eef_quat, params={"link_name": "right_hand_roll_link"}) + + hand_joint_state = ObsTerm(func=mdp.get_robot_joint_state, params={"joint_names": ["R_.*", "L_.*"]}) + head_joint_state = ObsTerm( + func=mdp.get_robot_joint_state, + params={"joint_names": ["head_pitch_joint", "head_roll_joint", "head_yaw_joint"]}, + ) robot_pov_cam = ObsTerm( func=mdp.image, @@ -243,7 +246,7 @@ class TerminationsCfg: func=mdp.root_height_below_minimum, params={"minimum_height": 0.5, "asset_cfg": SceneEntityCfg("factory_nut")} ) - success = DoneTerm(func=mdp.task_done_nut_pour) + success = DoneTerm(func=mdp.task_done_nut_pour, params={"relevant_link_name": "right_hand_roll_link"}) @configclass diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py index b7e1ff3ddecf..6dcdd9a1e8fc 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/nutpour_gr1t2_pink_ik_env_cfg.py @@ -40,49 +40,6 @@ def __post_init__(self): "right_wrist_roll_joint", "right_wrist_pitch_joint", ], - # Joints to be locked in URDF - ik_urdf_fixed_joint_names=[ - "left_hip_roll_joint", - "right_hip_roll_joint", - "left_hip_yaw_joint", - "right_hip_yaw_joint", - "left_hip_pitch_joint", - "right_hip_pitch_joint", - "left_knee_pitch_joint", - "right_knee_pitch_joint", - "left_ankle_pitch_joint", - "right_ankle_pitch_joint", - "left_ankle_roll_joint", - "right_ankle_roll_joint", - "L_index_proximal_joint", - "L_middle_proximal_joint", - "L_pinky_proximal_joint", - "L_ring_proximal_joint", - "L_thumb_proximal_yaw_joint", - "R_index_proximal_joint", - "R_middle_proximal_joint", - "R_pinky_proximal_joint", - "R_ring_proximal_joint", - "R_thumb_proximal_yaw_joint", - "L_index_intermediate_joint", - "L_middle_intermediate_joint", - "L_pinky_intermediate_joint", - "L_ring_intermediate_joint", - "L_thumb_proximal_pitch_joint", - "R_index_intermediate_joint", - "R_middle_intermediate_joint", - "R_pinky_intermediate_joint", - "R_ring_intermediate_joint", - "R_thumb_proximal_pitch_joint", - "L_thumb_distal_joint", - "R_thumb_distal_joint", - "head_roll_joint", - "head_pitch_joint", - "head_yaw_joint", - "waist_yaw_joint", - "waist_pitch_joint", - "waist_roll_joint", - ], hand_joint_names=[ "L_index_proximal_joint", "L_middle_proximal_joint", @@ -162,14 +119,7 @@ def __post_init__(self): ], ), ], - fixed_input_tasks=[ - # COMMENT OUT IF LOCKING WAIST/HEAD - # FrameTask( - # "GR1T2_fourier_hand_6dof_head_yaw_link", - # position_cost=1.0, # [cost] / [m] - # orientation_cost=0.05, # [cost] / [rad] - # ), - ], + fixed_input_tasks=[], xr_enabled=bool(carb.settings.get_settings().get("/app/xr/enabled")), ), ) @@ -177,9 +127,6 @@ def __post_init__(self): temp_urdf_output_path, temp_urdf_meshes_output_path = ControllerUtils.convert_usd_to_urdf( self.scene.robot.spawn.usd_path, self.temp_urdf_dir, force_conversion=True ) - ControllerUtils.change_revolute_to_fixed( - temp_urdf_output_path, self.actions.gr1_action.ik_urdf_fixed_joint_names - ) # Set the URDF and mesh paths for the IK controller self.actions.gr1_action.controller.urdf_path = temp_urdf_output_path diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index 9343db5ffc58..156e38c4785a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -116,7 +116,7 @@ class ObjectTableSceneCfg(InteractiveSceneCfg): class ActionsCfg: """Action specifications for the MDP.""" - pink_ik_cfg = PinkInverseKinematicsActionCfg( + upper_body_ik = PinkInverseKinematicsActionCfg( pink_controlled_joint_names=[ "left_shoulder_pitch_joint", "left_shoulder_roll_joint", @@ -133,49 +133,6 @@ class ActionsCfg: "right_wrist_roll_joint", "right_wrist_pitch_joint", ], - # Joints to be locked in URDF - ik_urdf_fixed_joint_names=[ - "left_hip_roll_joint", - "right_hip_roll_joint", - "left_hip_yaw_joint", - "right_hip_yaw_joint", - "left_hip_pitch_joint", - "right_hip_pitch_joint", - "left_knee_pitch_joint", - "right_knee_pitch_joint", - "left_ankle_pitch_joint", - "right_ankle_pitch_joint", - "left_ankle_roll_joint", - "right_ankle_roll_joint", - "L_index_proximal_joint", - "L_middle_proximal_joint", - "L_pinky_proximal_joint", - "L_ring_proximal_joint", - "L_thumb_proximal_yaw_joint", - "R_index_proximal_joint", - "R_middle_proximal_joint", - "R_pinky_proximal_joint", - "R_ring_proximal_joint", - "R_thumb_proximal_yaw_joint", - "L_index_intermediate_joint", - "L_middle_intermediate_joint", - "L_pinky_intermediate_joint", - "L_ring_intermediate_joint", - "L_thumb_proximal_pitch_joint", - "R_index_intermediate_joint", - "R_middle_intermediate_joint", - "R_pinky_intermediate_joint", - "R_ring_intermediate_joint", - "R_thumb_proximal_pitch_joint", - "L_thumb_distal_joint", - "R_thumb_distal_joint", - "head_roll_joint", - "head_pitch_joint", - "head_yaw_joint", - "waist_yaw_joint", - "waist_pitch_joint", - "waist_roll_joint", - ], hand_joint_names=[ "L_index_proximal_joint", "L_middle_proximal_joint", @@ -280,15 +237,21 @@ class PolicyCfg(ObsGroup): object_rot = ObsTerm(func=base_mdp.root_quat_w, params={"asset_cfg": SceneEntityCfg("object")}) robot_links_state = ObsTerm(func=mdp.get_all_robot_link_state) - left_eef_pos = ObsTerm(func=mdp.get_left_eef_pos) - left_eef_quat = ObsTerm(func=mdp.get_left_eef_quat) - right_eef_pos = ObsTerm(func=mdp.get_right_eef_pos) - right_eef_quat = ObsTerm(func=mdp.get_right_eef_quat) + left_eef_pos = ObsTerm(func=mdp.get_eef_pos, params={"link_name": "left_hand_roll_link"}) + left_eef_quat = ObsTerm(func=mdp.get_eef_quat, params={"link_name": "left_hand_roll_link"}) + right_eef_pos = ObsTerm(func=mdp.get_eef_pos, params={"link_name": "right_hand_roll_link"}) + right_eef_quat = ObsTerm(func=mdp.get_eef_quat, params={"link_name": "right_hand_roll_link"}) - hand_joint_state = ObsTerm(func=mdp.get_hand_state) - head_joint_state = ObsTerm(func=mdp.get_head_state) + hand_joint_state = ObsTerm(func=mdp.get_robot_joint_state, params={"joint_names": ["R_.*", "L_.*"]}) + head_joint_state = ObsTerm( + func=mdp.get_robot_joint_state, + params={"joint_names": ["head_pitch_joint", "head_roll_joint", "head_yaw_joint"]}, + ) - object = ObsTerm(func=mdp.object_obs) + object = ObsTerm( + func=mdp.object_obs, + params={"left_eef_link_name": "left_hand_roll_link", "right_eef_link_name": "right_hand_roll_link"}, + ) def __post_init__(self): self.enable_corruption = False @@ -308,7 +271,7 @@ class TerminationsCfg: func=mdp.root_height_below_minimum, params={"minimum_height": 0.5, "asset_cfg": SceneEntityCfg("object")} ) - success = DoneTerm(func=mdp.task_done_pick_place) + success = DoneTerm(func=mdp.task_done_pick_place, params={"task_link_name": "right_hand_roll_link"}) @configclass @@ -416,13 +379,10 @@ def __post_init__(self): temp_urdf_output_path, temp_urdf_meshes_output_path = ControllerUtils.convert_usd_to_urdf( self.scene.robot.spawn.usd_path, self.temp_urdf_dir, force_conversion=True ) - ControllerUtils.change_revolute_to_fixed( - temp_urdf_output_path, self.actions.pink_ik_cfg.ik_urdf_fixed_joint_names - ) # Set the URDF and mesh paths for the IK controller - self.actions.pink_ik_cfg.controller.urdf_path = temp_urdf_output_path - self.actions.pink_ik_cfg.controller.mesh_path = temp_urdf_meshes_output_path + self.actions.upper_body_ik.controller.urdf_path = temp_urdf_output_path + self.actions.upper_body_ik.controller.mesh_path = temp_urdf_meshes_output_path self.teleop_devices = DevicesCfg( devices={ @@ -433,7 +393,7 @@ def __post_init__(self): # number of joints in both hands num_open_xr_hand_joints=2 * self.NUM_OPENXR_HAND_JOINTS, sim_device=self.sim.device, - hand_joint_names=self.actions.pink_ik_cfg.hand_joint_names, + hand_joint_names=self.actions.upper_body_ik.hand_joint_names, ), ], sim_device=self.sim.device, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py index 636f347109f4..30b17e89493a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py @@ -57,20 +57,16 @@ def __post_init__(self): # Add waist joint to pink_ik_cfg waist_joint_names = ["waist_yaw_joint", "waist_pitch_joint", "waist_roll_joint"] for joint_name in waist_joint_names: - self.actions.pink_ik_cfg.pink_controlled_joint_names.append(joint_name) - self.actions.pink_ik_cfg.ik_urdf_fixed_joint_names.remove(joint_name) + self.actions.upper_body_ik.pink_controlled_joint_names.append(joint_name) # Convert USD to URDF and change revolute joints to fixed temp_urdf_output_path, temp_urdf_meshes_output_path = ControllerUtils.convert_usd_to_urdf( self.scene.robot.spawn.usd_path, self.temp_urdf_dir, force_conversion=True ) - ControllerUtils.change_revolute_to_fixed( - temp_urdf_output_path, self.actions.pink_ik_cfg.ik_urdf_fixed_joint_names - ) # Set the URDF and mesh paths for the IK controller - self.actions.pink_ik_cfg.controller.urdf_path = temp_urdf_output_path - self.actions.pink_ik_cfg.controller.mesh_path = temp_urdf_meshes_output_path + self.actions.upper_body_ik.controller.urdf_path = temp_urdf_output_path + self.actions.upper_body_ik.controller.mesh_path = temp_urdf_meshes_output_path self.teleop_devices = DevicesCfg( devices={ @@ -81,7 +77,7 @@ def __post_init__(self): # number of joints in both hands num_open_xr_hand_joints=2 * self.NUM_OPENXR_HAND_JOINTS, sim_device=self.sim.device, - hand_joint_names=self.actions.pink_ik_cfg.hand_joint_names, + hand_joint_names=self.actions.upper_body_ik.hand_joint_names, ), ], sim_device=self.sim.device, From 19074b551a74e64a191c771511c8b704178aafd8 Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Fri, 5 Sep 2025 09:20:20 -0700 Subject: [PATCH 38/47] documention update --- .../overview/imitation-learning/teleop_imitation.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/overview/imitation-learning/teleop_imitation.rst b/docs/source/overview/imitation-learning/teleop_imitation.rst index 2d415bbca44c..3b2f016017d9 100644 --- a/docs/source/overview/imitation-learning/teleop_imitation.rst +++ b/docs/source/overview/imitation-learning/teleop_imitation.rst @@ -140,7 +140,7 @@ Pre-recorded demonstrations ^^^^^^^^^^^^^^^^^^^^^^^^^^^ We provide a pre-recorded ``dataset.hdf5`` containing 10 human demonstrations for ``Isaac-Stack-Cube-Franka-IK-Rel-v0`` -`here `__. +here: `[Franka Dataset] `__. This dataset may be downloaded and used in the remaining tutorial steps if you do not wish to collect your own demonstrations. .. note:: @@ -451,7 +451,7 @@ Generate the dataset ^^^^^^^^^^^^^^^^^^^^ If you skipped the prior collection and annotation step, download the pre-recorded annotated dataset ``dataset_annotated_gr1.hdf5`` from -`here `__. +here: `[Annotated GR1 Dataset] `_. Place the file under ``IsaacLab/datasets`` and run the following command to generate a new dataset with 1000 demonstrations. .. code:: bash @@ -573,7 +573,7 @@ Follow the same data collection, annotation, and generation process as demonstra If you skipped the prior collection and annotation step, download the pre-recorded annotated dataset ``dataset_annotated_g1_locomanip.hdf5`` from -`here `_. +here: `[Annotated G1 Dataset] `_. Place the file under ``IsaacLab/datasets`` and run the following command to generate a new dataset with 1000 demonstrations. .. code:: bash @@ -633,7 +633,7 @@ Demo 3: Visuomotor Policy for a Humanoid Robot Download the Dataset ^^^^^^^^^^^^^^^^^^^^ -Download the pre-generated dataset from `here `__ and place it under ``IsaacLab/datasets/generated_dataset_gr1_nut_pouring.hdf5``. +Download the pre-generated dataset from here: `[Generated GR1 Dataset] `__ and place it under ``IsaacLab/datasets/generated_dataset_gr1_nut_pouring.hdf5``. The dataset contains 1000 demonstrations of a humanoid robot performing a pouring/placing task that was generated using Isaac Lab Mimic for the ``Isaac-NutPour-GR1T2-Pink-IK-Abs-Mimic-v0`` task. From 17f5e5606a767fbf261e7f22b9744d2c588e384c Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Mon, 8 Sep 2025 10:42:22 -0700 Subject: [PATCH 39/47] fixing pinnochio import for ik unit tests --- .../pink_ik/pink_kinematics_configuration.py | 7 +- .../test/controllers/test_controller_utils.py | 4 +- .../test/controllers/test_local_frame_task.py | 861 +++++++++--------- .../controllers/test_pink_ik_components.py | 13 +- .../urdfs/test_urdf_two_link_robot.urdf | 72 +- 5 files changed, 473 insertions(+), 484 deletions(-) diff --git a/source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py b/source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py index b2cae80c1058..5225a962268a 100644 --- a/source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py +++ b/source/isaaclab/isaaclab/controllers/pink_ik/pink_kinematics_configuration.py @@ -28,9 +28,9 @@ class PinkKinematicsConfiguration(Configuration): def __init__( self, - urdf_path: str, - mesh_path: str, controlled_joint_names: list[str], + urdf_path: str, + mesh_path: str = None, copy_data: bool = True, forward_kinematics: bool = True, ): @@ -51,7 +51,7 @@ def __init__( self._controlled_joint_names = controlled_joint_names # Build robot model with all joints - if mesh_path is not None: + if mesh_path: self.robot_wrapper = RobotWrapper.BuildFromURDF(urdf_path, mesh_path) else: self.robot_wrapper = RobotWrapper.BuildFromURDF(urdf_path) @@ -59,6 +59,7 @@ def __init__( self.full_data = self.robot_wrapper.data self.full_q = self.robot_wrapper.q0 + # import pdb; pdb.set_trace() self._all_joint_names = self.full_model.names.tolist()[1:] # controlled_joint_indices: indices in all_joint_names for joints that are in controlled_joint_names, preserving all_joint_names order self._controlled_joint_indices = [ diff --git a/source/isaaclab/test/controllers/test_controller_utils.py b/source/isaaclab/test/controllers/test_controller_utils.py index dd31e7929bb4..b96fcdc41a99 100644 --- a/source/isaaclab/test/controllers/test_controller_utils.py +++ b/source/isaaclab/test/controllers/test_controller_utils.py @@ -21,6 +21,7 @@ import pytest from isaaclab.controllers.utils import change_revolute_to_fixed, change_revolute_to_fixed_regex, load_torchscript_model +from isaaclab.utils.assets import retrieve_file_path @pytest.fixture @@ -501,7 +502,8 @@ def test_regex_special_characters(test_urdf_file, mock_urdf_content): @pytest.fixture def policy_model_path(): """Path to the test TorchScript model.""" - return "source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/policy/locomotion/agile_locomotion.pt" + _policy_path = "omniverse://isaac-dev.ov.nvidia.com/Projects/agile/policy_checkpoints/agile_locomotion.pt" + return retrieve_file_path(_policy_path) def test_load_torchscript_model_success(policy_model_path): diff --git a/source/isaaclab/test/controllers/test_local_frame_task.py b/source/isaaclab/test/controllers/test_local_frame_task.py index c8c76dcf8be2..48c86eec0826 100644 --- a/source/isaaclab/test/controllers/test_local_frame_task.py +++ b/source/isaaclab/test/controllers/test_local_frame_task.py @@ -3,448 +3,479 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for LocalFrameTask class.""" +"""Test cases for LocalFrameTask class.""" +# Import pinocchio in the main script to force the use of the dependencies installed by IsaacLab and not the one installed by Isaac Sim +# pinocchio is required by the Pink IK controller +import sys + +if sys.platform != "win32": + import pinocchio # noqa: F401 + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app import numpy as np from pathlib import Path import pinocchio as pin import pytest -from pink.exceptions import TargetNotSet from isaaclab.controllers.pink_ik.local_frame_task import LocalFrameTask from isaaclab.controllers.pink_ik.pink_kinematics_configuration import PinkKinematicsConfiguration +# class TestLocalFrameTask: +# """Test suite for LocalFrameTask class.""" + + +@pytest.fixture +def urdf_path(): + """Path to test URDF file.""" + return Path(__file__).parent / "urdfs" / "test_urdf_two_link_robot.urdf" -class TestLocalFrameTask: - """Test suite for LocalFrameTask class.""" - - @pytest.fixture - def urdf_path(self): - """Path to test URDF file.""" - return Path(__file__).parent / "urdfs" / "test_urdf_two_link_robot.urdf" - - @pytest.fixture - def mesh_path(self): - """Path to mesh directory (empty for simple test).""" - return "" - - @pytest.fixture - def controlled_joint_names(self): - """List of controlled joint names for testing.""" - return ["joint_1", "joint_2"] - - @pytest.fixture - def pink_config(self, urdf_path, mesh_path, controlled_joint_names): - """Create a PinkKinematicsConfiguration instance for testing.""" - return PinkKinematicsConfiguration( - urdf_path=str(urdf_path), - mesh_path=mesh_path, - controlled_joint_names=controlled_joint_names, - copy_data=True, - forward_kinematics=True, - ) - - @pytest.fixture - def local_frame_task(self): - """Create a LocalFrameTask instance for testing.""" - return LocalFrameTask( - frame="link_2", - base_link_frame_name="base_link", - position_cost=1.0, - orientation_cost=1.0, - lm_damping=0.0, - gain=1.0, - ) - - def test_initialization(self, local_frame_task): - """Test proper initialization of LocalFrameTask.""" - # Check that the task is properly initialized - assert local_frame_task.frame == "link_2" - assert local_frame_task.base_link_frame_name == "base_link" - assert np.allclose(local_frame_task.cost[:3], [1.0, 1.0, 1.0]) - assert np.allclose(local_frame_task.cost[3:], [1.0, 1.0, 1.0]) - assert local_frame_task.lm_damping == 0.0 - assert local_frame_task.gain == 1.0 - - # Check that target is initially None - assert local_frame_task.transform_target_to_base is None - - def test_initialization_with_sequence_costs(self): - """Test initialization with sequence costs.""" - task = LocalFrameTask( - frame="link_1", - base_link_frame_name="base_link", - position_cost=[1.0, 1.0, 1.0], - orientation_cost=[1.0, 1.0, 1.0], - lm_damping=0.1, - gain=2.0, - ) - - assert task.frame == "link_1" - assert task.base_link_frame_name == "base_link" - assert np.allclose(task.cost[:3], [1.0, 1.0, 1.0]) - assert np.allclose(task.cost[3:], [1.0, 1.0, 1.0]) - assert task.lm_damping == 0.1 - assert task.gain == 2.0 - - def test_inheritance_from_frame_task(self, local_frame_task): - """Test that LocalFrameTask properly inherits from FrameTask.""" - from pink.tasks.frame_task import FrameTask - - # Check inheritance - assert isinstance(local_frame_task, FrameTask) - - # Check that we can call parent class methods - assert hasattr(local_frame_task, "compute_error") - assert hasattr(local_frame_task, "compute_jacobian") - - def test_set_target(self, local_frame_task): - """Test setting target with a transform.""" - # Create a test transform - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - target_transform.rotation = pin.exp3(np.array([0.1, 0.0, 0.0])) - - # Set the target - local_frame_task.set_target(target_transform) - - # Check that target was set correctly - assert local_frame_task.transform_target_to_base is not None - assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) - - # Check that it's a copy (not the same object) - assert local_frame_task.transform_target_to_base is not target_transform - - # Check that values match - assert np.allclose(local_frame_task.transform_target_to_base.translation, target_transform.translation) - assert np.allclose(local_frame_task.transform_target_to_base.rotation, target_transform.rotation) - - def test_set_target_from_configuration(self, local_frame_task, pink_config): - """Test setting target from a robot configuration.""" - # Set target from configuration - local_frame_task.set_target_from_configuration(pink_config) - - # Check that target was set - assert local_frame_task.transform_target_to_base is not None - assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) - - def test_set_target_from_configuration_wrong_type(self, local_frame_task): - """Test that set_target_from_configuration raises error with wrong type.""" - with pytest.raises(ValueError, match="configuration must be a PinkKinematicsConfiguration"): - local_frame_task.set_target_from_configuration("not_a_configuration") - - def test_compute_error_with_target_set(self, local_frame_task, pink_config): - """Test computing error when target is set.""" - # Set a target - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - local_frame_task.set_target(target_transform) - - # Compute error - error = local_frame_task.compute_error(pink_config) - - # Check that error is computed correctly - assert isinstance(error, np.ndarray) - assert error.shape == (6,) # 6D error (3 position + 3 orientation) - - # Error should not be all zeros (unless target exactly matches current pose) - # This is a reasonable assumption for a random target - - def test_compute_error_without_target(self, local_frame_task, pink_config): - """Test that compute_error raises error when no target is set.""" - with pytest.raises(ValueError, match="no target set for frame 'link_2'"): - local_frame_task.compute_error(pink_config) - - def test_compute_error_wrong_configuration_type(self, local_frame_task): - """Test that compute_error raises error with wrong configuration type.""" - # Set a target first - target_transform = pin.SE3.Identity() - local_frame_task.set_target(target_transform) - - with pytest.raises(ValueError, match="configuration must be a PinkKinematicsConfiguration"): - local_frame_task.compute_error("not_a_configuration") - - def test_compute_jacobian_with_target_set(self, local_frame_task, pink_config): - """Test computing Jacobian when target is set.""" - # Set a target - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - local_frame_task.set_target(target_transform) - # Compute Jacobian - jacobian = local_frame_task.compute_jacobian(pink_config) +@pytest.fixture +def controlled_joint_names(): + """List of controlled joint names for testing.""" + return ["joint_1", "joint_2"] + - # Check that Jacobian is computed correctly - assert isinstance(jacobian, np.ndarray) - assert jacobian.shape == (6, 2) # 6 rows (error), 2 columns (controlled joints) +@pytest.fixture +def pink_config(urdf_path, controlled_joint_names): + """Create a PinkKinematicsConfiguration instance for testing.""" + return PinkKinematicsConfiguration( + urdf_path=str(urdf_path), + controlled_joint_names=controlled_joint_names, + # copy_data=True, + # forward_kinematics=True, + ) - # Jacobian should not be all zeros - assert not np.allclose(jacobian, 0.0) - def test_compute_jacobian_without_target(self, local_frame_task, pink_config): - """Test that compute_jacobian raises error when no target is set.""" - with pytest.raises(TargetNotSet, match="no target set for frame 'link_2'"): - local_frame_task.compute_jacobian(pink_config) +@pytest.fixture +def local_frame_task(): + """Create a LocalFrameTask instance for testing.""" + return LocalFrameTask( + frame="link_2", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + lm_damping=0.0, + gain=1.0, + ) - def test_error_consistency_across_configurations(self, local_frame_task, pink_config): - """Test that error computation is consistent across different configurations.""" - # Set a target - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - local_frame_task.set_target(target_transform) - # Compute error at initial configuration - error_1 = local_frame_task.compute_error(pink_config) +def test_initialization(local_frame_task): + """Test proper initialization of LocalFrameTask.""" + # Check that the task is properly initialized + assert local_frame_task.frame == "link_2" + assert local_frame_task.base_link_frame_name == "base_link" + assert np.allclose(local_frame_task.cost[:3], [1.0, 1.0, 1.0]) + assert np.allclose(local_frame_task.cost[3:], [1.0, 1.0, 1.0]) + assert local_frame_task.lm_damping == 0.0 + assert local_frame_task.gain == 1.0 - # Update configuration - new_q = pink_config.full_q.copy() - new_q[1] = 0.5 # Change first revolute joint - pink_config.update(new_q) + # Check that target is initially None + assert local_frame_task.transform_target_to_base is None + + +def test_initialization_with_sequence_costs(): + """Test initialization with sequence costs.""" + task = LocalFrameTask( + frame="link_1", + base_link_frame_name="base_link", + position_cost=[1.0, 1.0, 1.0], + orientation_cost=[1.0, 1.0, 1.0], + lm_damping=0.1, + gain=2.0, + ) + + assert task.frame == "link_1" + assert task.base_link_frame_name == "base_link" + assert np.allclose(task.cost[:3], [1.0, 1.0, 1.0]) + assert np.allclose(task.cost[3:], [1.0, 1.0, 1.0]) + assert task.lm_damping == 0.1 + assert task.gain == 2.0 + + +def test_inheritance_from_frame_task(local_frame_task): + """Test that LocalFrameTask properly inherits from FrameTask.""" + from pink.tasks.frame_task import FrameTask + + # Check inheritance + assert isinstance(local_frame_task, FrameTask) + + # Check that we can call parent class methods + assert hasattr(local_frame_task, "compute_error") + assert hasattr(local_frame_task, "compute_jacobian") + + +def test_set_target(local_frame_task): + """Test setting target with a transform.""" + # Create a test transform + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + target_transform.rotation = pin.exp3(np.array([0.1, 0.0, 0.0])) + + # Set the target + local_frame_task.set_target(target_transform) + + # Check that target was set correctly + assert local_frame_task.transform_target_to_base is not None + assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) + + # Check that it's a copy (not the same object) + assert local_frame_task.transform_target_to_base is not target_transform + + # Check that values match + assert np.allclose(local_frame_task.transform_target_to_base.translation, target_transform.translation) + assert np.allclose(local_frame_task.transform_target_to_base.rotation, target_transform.rotation) + + +def test_set_target_from_configuration(local_frame_task, pink_config): + """Test setting target from a robot configuration.""" + # Set target from configuration + local_frame_task.set_target_from_configuration(pink_config) + + # Check that target was set + assert local_frame_task.transform_target_to_base is not None + assert isinstance(local_frame_task.transform_target_to_base, pin.SE3) + + +def test_set_target_from_configuration_wrong_type(local_frame_task): + """Test that set_target_from_configuration raises error with wrong type.""" + with pytest.raises(ValueError, match="configuration must be a PinkKinematicsConfiguration"): + local_frame_task.set_target_from_configuration("not_a_configuration") + + +def test_compute_error_with_target_set(local_frame_task, pink_config): + """Test computing error when target is set.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute error + error = local_frame_task.compute_error(pink_config) + + # Check that error is computed correctly + assert isinstance(error, np.ndarray) + assert error.shape == (6,) # 6D error (3 position + 3 orientation) + + # Error should not be all zeros (unless target exactly matches current pose) + # This is a reasonable assumption for a random target + + +def test_compute_error_without_target(local_frame_task, pink_config): + """Test that compute_error raises error when no target is set.""" + with pytest.raises(ValueError, match="no target set for frame 'link_2'"): + local_frame_task.compute_error(pink_config) + + +def test_compute_error_wrong_configuration_type(local_frame_task): + """Test that compute_error raises error with wrong configuration type.""" + # Set a target first + target_transform = pin.SE3.Identity() + local_frame_task.set_target(target_transform) + + with pytest.raises(ValueError, match="configuration must be a PinkKinematicsConfiguration"): + local_frame_task.compute_error("not_a_configuration") + + +def test_compute_jacobian_with_target_set(local_frame_task, pink_config): + """Test computing Jacobian when target is set.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute Jacobian + jacobian = local_frame_task.compute_jacobian(pink_config) - # Compute error at new configuration - error_2 = local_frame_task.compute_error(pink_config) + # Check that Jacobian is computed correctly + assert isinstance(jacobian, np.ndarray) + assert jacobian.shape == (6, 2) # 6 rows (error), 2 columns (controlled joints) - # Errors should be different (not all close) - assert not np.allclose(error_1, error_2) + # Jacobian should not be all zeros + assert not np.allclose(jacobian, 0.0) - def test_jacobian_consistency_across_configurations(self, local_frame_task, pink_config): - """Test that Jacobian computation is consistent across different configurations.""" - # Set a target - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - local_frame_task.set_target(target_transform) - # Compute Jacobian at initial configuration - jacobian_1 = local_frame_task.compute_jacobian(pink_config) +def test_compute_jacobian_without_target(local_frame_task, pink_config): + """Test that compute_jacobian raises error when no target is set.""" + with pytest.raises(Exception, match="no target set for frame 'link_2'"): + local_frame_task.compute_jacobian(pink_config) + +def test_error_consistency_across_configurations(local_frame_task, pink_config): + """Test that error computation is consistent across different configurations.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute error at initial configuration + error_1 = local_frame_task.compute_error(pink_config) + + # Update configuration + new_q = pink_config.full_q.copy() + new_q[1] = 0.5 # Change first revolute joint + pink_config.update(new_q) + + # Compute error at new configuration + error_2 = local_frame_task.compute_error(pink_config) + + # Errors should be different (not all close) + assert not np.allclose(error_1, error_2) + + +def test_jacobian_consistency_across_configurations(local_frame_task, pink_config): + """Test that Jacobian computation is consistent across different configurations.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute Jacobian at initial configuration + jacobian_1 = local_frame_task.compute_jacobian(pink_config) + + # Update configuration + new_q = pink_config.full_q.copy() + new_q[1] = 0.3 # Change first revolute joint + pink_config.update(new_q) + + # Compute Jacobian at new configuration + jacobian_2 = local_frame_task.compute_jacobian(pink_config) + + # Jacobians should be different (not all close) + assert not np.allclose(jacobian_1, jacobian_2) + + +def test_error_zero_at_target_pose(local_frame_task, pink_config): + """Test that error is zero when current pose matches target pose.""" + # Get current transform of the frame + current_transform = pink_config.get_transform_frame_to_world("link_2") + + # Set target to current pose + local_frame_task.set_target(current_transform) + + # Compute error + error = local_frame_task.compute_error(pink_config) + + # Error should be very close to zero + assert np.allclose(error, 0.0, atol=1e-10) + + +def test_different_frames(pink_config): + """Test LocalFrameTask with different frame names.""" + # Test with link_1 frame + task_link1 = LocalFrameTask( + frame="link_1", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + ) + + # Set target and compute error + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.0, 0.0]) + task_link1.set_target(target_transform) + + error_link1 = task_link1.compute_error(pink_config) + assert error_link1.shape == (6,) + + # Test with base_link frame + task_base = LocalFrameTask( + frame="base_link", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + ) + + task_base.set_target(target_transform) + error_base = task_base.compute_error(pink_config) + assert error_base.shape == (6,) + + +def test_different_base_frames(pink_config): + """Test LocalFrameTask with different base frame names.""" + # Test with base_link as base frame + task_base_base = LocalFrameTask( + frame="link_2", + base_link_frame_name="base_link", + position_cost=1.0, + orientation_cost=1.0, + ) + + target_transform = pin.SE3.Identity() + task_base_base.set_target(target_transform) + error_base_base = task_base_base.compute_error(pink_config) + assert error_base_base.shape == (6,) + + # Test with link_1 as base frame + task_link1_base = LocalFrameTask( + frame="link_2", + base_link_frame_name="link_1", + position_cost=1.0, + orientation_cost=1.0, + ) + + task_link1_base.set_target(target_transform) + error_link1_base = task_link1_base.compute_error(pink_config) + assert error_link1_base.shape == (6,) + + +def test_sequence_cost_parameters(): + """Test LocalFrameTask with sequence cost parameters.""" + task = LocalFrameTask( + frame="link_2", + base_link_frame_name="base_link", + position_cost=[1.0, 2.0, 3.0], + orientation_cost=[0.5, 1.0, 1.5], + lm_damping=0.1, + gain=2.0, + ) + + assert np.allclose(task.cost[:3], [1.0, 2.0, 3.0]) # Position costs + assert np.allclose(task.cost[3:], [0.5, 1.0, 1.5]) # Orientation costs + assert task.lm_damping == 0.1 + assert task.gain == 2.0 + + +def test_error_magnitude_consistency(local_frame_task, pink_config): + """Test that error computation produces reasonable results.""" + # Set a small target offset + small_target = pin.SE3.Identity() + small_target.translation = np.array([0.01, 0.01, 0.01]) + local_frame_task.set_target(small_target) + + error_small = local_frame_task.compute_error(pink_config) + + # Set a large target offset + large_target = pin.SE3.Identity() + large_target.translation = np.array([0.5, 0.5, 0.5]) + local_frame_task.set_target(large_target) + + error_large = local_frame_task.compute_error(pink_config) + + # Both errors should be finite and reasonable + assert np.all(np.isfinite(error_small)) + assert np.all(np.isfinite(error_large)) + assert not np.allclose(error_small, error_large) # Different targets should produce different errors + + +def test_jacobian_structure(local_frame_task, pink_config): + """Test that Jacobian has the correct structure.""" + # Set a target + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.1, 0.2, 0.3]) + local_frame_task.set_target(target_transform) + + # Compute Jacobian + jacobian = local_frame_task.compute_jacobian(pink_config) + + # Check structure + assert jacobian.shape == (6, 2) # 6 error dimensions, 2 controlled joints + + # Check that Jacobian is not all zeros (basic functionality check) + assert not np.allclose(jacobian, 0.0) + + +def test_multiple_target_updates(local_frame_task, pink_config): + """Test that multiple target updates work correctly.""" + # Set first target + target1 = pin.SE3.Identity() + target1.translation = np.array([0.1, 0.0, 0.0]) + local_frame_task.set_target(target1) + + error1 = local_frame_task.compute_error(pink_config) + + # Set second target + target2 = pin.SE3.Identity() + target2.translation = np.array([0.0, 0.1, 0.0]) + local_frame_task.set_target(target2) + + error2 = local_frame_task.compute_error(pink_config) + + # Errors should be different + assert not np.allclose(error1, error2) + + +def test_inheritance_behavior(local_frame_task): + """Test that LocalFrameTask properly overrides parent class methods.""" + # Check that the class has the expected methods + assert hasattr(local_frame_task, "set_target") + assert hasattr(local_frame_task, "set_target_from_configuration") + assert hasattr(local_frame_task, "compute_error") + assert hasattr(local_frame_task, "compute_jacobian") + + # Check that these are the overridden methods, not the parent ones + assert local_frame_task.set_target.__qualname__ == "LocalFrameTask.set_target" + assert local_frame_task.compute_error.__qualname__ == "LocalFrameTask.compute_error" + assert local_frame_task.compute_jacobian.__qualname__ == "LocalFrameTask.compute_jacobian" + + +def test_target_copying_behavior(local_frame_task): + """Test that target transforms are properly copied.""" + # Create a target transform + original_target = pin.SE3.Identity() + original_target.translation = np.array([0.1, 0.2, 0.3]) + original_rotation = original_target.rotation.copy() + + # Set the target + local_frame_task.set_target(original_target) + + # Modify the original target + original_target.translation = np.array([0.5, 0.5, 0.5]) + original_target.rotation = pin.exp3(np.array([0.5, 0.0, 0.0])) + + # Check that the stored target is unchanged + assert np.allclose(local_frame_task.transform_target_to_base.translation, np.array([0.1, 0.2, 0.3])) + assert np.allclose(local_frame_task.transform_target_to_base.rotation, original_rotation) + + +def test_error_computation_with_orientation_difference(local_frame_task, pink_config): + """Test error computation when there's an orientation difference.""" + # Set a target with orientation difference + target_transform = pin.SE3.Identity() + target_transform.rotation = pin.exp3(np.array([0.2, 0.0, 0.0])) # Rotation around X-axis + local_frame_task.set_target(target_transform) + + # Compute error + error = local_frame_task.compute_error(pink_config) + + # Check that error is computed correctly + assert isinstance(error, np.ndarray) + assert error.shape == (6,) + + # Error should not be all zeros + assert not np.allclose(error, 0.0) + + +def test_jacobian_rank_consistency(local_frame_task, pink_config): + """Test that Jacobian maintains consistent shape across configurations.""" + # Set a target that we know can be reached by the test robot. + target_transform = pin.SE3.Identity() + target_transform.translation = np.array([0.0, 0.0, 0.45]) + # 90 degrees around x axis = pi/2 radians + target_transform.rotation = pin.exp3(np.array([np.pi / 2, 0.0, 0.0])) + local_frame_task.set_target(target_transform) + + # Compute Jacobian at multiple configurations + jacobians = [] + for i in range(5): # Update configuration new_q = pink_config.full_q.copy() - new_q[1] = 0.3 # Change first revolute joint + new_q[1] = 0.1 * i # Vary first joint pink_config.update(new_q) - # Compute Jacobian at new configuration - jacobian_2 = local_frame_task.compute_jacobian(pink_config) - - # Jacobians should be different (not all close) - assert not np.allclose(jacobian_1, jacobian_2) - - def test_error_zero_at_target_pose(self, local_frame_task, pink_config): - """Test that error is zero when current pose matches target pose.""" - # Get current transform of the frame - current_transform = pink_config.get_transform_frame_to_world("link_2") - - # Set target to current pose - local_frame_task.set_target(current_transform) - - # Compute error - error = local_frame_task.compute_error(pink_config) - - # Error should be very close to zero - assert np.allclose(error, 0.0, atol=1e-10) - - def test_different_frames(self, pink_config): - """Test LocalFrameTask with different frame names.""" - # Test with link_1 frame - task_link1 = LocalFrameTask( - frame="link_1", - base_link_frame_name="base_link", - position_cost=1.0, - orientation_cost=1.0, - ) - - # Set target and compute error - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.0, 0.0]) - task_link1.set_target(target_transform) - - error_link1 = task_link1.compute_error(pink_config) - assert error_link1.shape == (6,) - - # Test with base_link frame - task_base = LocalFrameTask( - frame="base_link", - base_link_frame_name="base_link", - position_cost=1.0, - orientation_cost=1.0, - ) - - task_base.set_target(target_transform) - error_base = task_base.compute_error(pink_config) - assert error_base.shape == (6,) - - def test_different_base_frames(self, pink_config): - """Test LocalFrameTask with different base frame names.""" - # Test with base_link as base frame - task_base_base = LocalFrameTask( - frame="link_2", - base_link_frame_name="base_link", - position_cost=1.0, - orientation_cost=1.0, - ) - - target_transform = pin.SE3.Identity() - task_base_base.set_target(target_transform) - error_base_base = task_base_base.compute_error(pink_config) - assert error_base_base.shape == (6,) - - # Test with link_1 as base frame - task_link1_base = LocalFrameTask( - frame="link_2", - base_link_frame_name="link_1", - position_cost=1.0, - orientation_cost=1.0, - ) - - task_link1_base.set_target(target_transform) - error_link1_base = task_link1_base.compute_error(pink_config) - assert error_link1_base.shape == (6,) - - def test_sequence_cost_parameters(self): - """Test LocalFrameTask with sequence cost parameters.""" - task = LocalFrameTask( - frame="link_2", - base_link_frame_name="base_link", - position_cost=[1.0, 2.0, 3.0], - orientation_cost=[0.5, 1.0, 1.5], - lm_damping=0.1, - gain=2.0, - ) - - assert np.allclose(task.cost[:3], [1.0, 2.0, 3.0]) # Position costs - assert np.allclose(task.cost[3:], [0.5, 1.0, 1.5]) # Orientation costs - assert task.lm_damping == 0.1 - assert task.gain == 2.0 - - def test_error_magnitude_consistency(self, local_frame_task, pink_config): - """Test that error computation produces reasonable results.""" - # Set a small target offset - small_target = pin.SE3.Identity() - small_target.translation = np.array([0.01, 0.01, 0.01]) - local_frame_task.set_target(small_target) - - error_small = local_frame_task.compute_error(pink_config) - - # Set a large target offset - large_target = pin.SE3.Identity() - large_target.translation = np.array([0.5, 0.5, 0.5]) - local_frame_task.set_target(large_target) - - error_large = local_frame_task.compute_error(pink_config) - - # Both errors should be finite and reasonable - assert np.all(np.isfinite(error_small)) - assert np.all(np.isfinite(error_large)) - assert not np.allclose(error_small, error_large) # Different targets should produce different errors - - def test_jacobian_structure(self, local_frame_task, pink_config): - """Test that Jacobian has the correct structure.""" - # Set a target - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.1, 0.2, 0.3]) - local_frame_task.set_target(target_transform) - # Compute Jacobian jacobian = local_frame_task.compute_jacobian(pink_config) + jacobians.append(jacobian) + + # All Jacobians should have the same shape + for jacobian in jacobians: + assert jacobian.shape == (6, 2) - # Check structure - assert jacobian.shape == (6, 2) # 6 error dimensions, 2 controlled joints - - # Check that Jacobian is not all zeros (basic functionality check) - assert not np.allclose(jacobian, 0.0) - - def test_multiple_target_updates(self, local_frame_task, pink_config): - """Test that multiple target updates work correctly.""" - # Set first target - target1 = pin.SE3.Identity() - target1.translation = np.array([0.1, 0.0, 0.0]) - local_frame_task.set_target(target1) - - error1 = local_frame_task.compute_error(pink_config) - - # Set second target - target2 = pin.SE3.Identity() - target2.translation = np.array([0.0, 0.1, 0.0]) - local_frame_task.set_target(target2) - - error2 = local_frame_task.compute_error(pink_config) - - # Errors should be different - assert not np.allclose(error1, error2) - - def test_inheritance_behavior(self, local_frame_task): - """Test that LocalFrameTask properly overrides parent class methods.""" - # Check that the class has the expected methods - assert hasattr(local_frame_task, "set_target") - assert hasattr(local_frame_task, "set_target_from_configuration") - assert hasattr(local_frame_task, "compute_error") - assert hasattr(local_frame_task, "compute_jacobian") - - # Check that these are the overridden methods, not the parent ones - assert local_frame_task.set_target.__qualname__ == "LocalFrameTask.set_target" - assert local_frame_task.compute_error.__qualname__ == "LocalFrameTask.compute_error" - assert local_frame_task.compute_jacobian.__qualname__ == "LocalFrameTask.compute_jacobian" - - def test_target_copying_behavior(self, local_frame_task): - """Test that target transforms are properly copied.""" - # Create a target transform - original_target = pin.SE3.Identity() - original_target.translation = np.array([0.1, 0.2, 0.3]) - original_rotation = original_target.rotation.copy() - - # Set the target - local_frame_task.set_target(original_target) - - # Modify the original target - original_target.translation = np.array([0.5, 0.5, 0.5]) - original_target.rotation = pin.exp3(np.array([0.5, 0.0, 0.0])) - - # Check that the stored target is unchanged - assert np.allclose(local_frame_task.transform_target_to_base.translation, np.array([0.1, 0.2, 0.3])) - assert np.allclose(local_frame_task.transform_target_to_base.rotation, original_rotation) - - def test_error_computation_with_orientation_difference(self, local_frame_task, pink_config): - """Test error computation when there's an orientation difference.""" - # Set a target with orientation difference - target_transform = pin.SE3.Identity() - target_transform.rotation = pin.exp3(np.array([0.2, 0.0, 0.0])) # Rotation around X-axis - local_frame_task.set_target(target_transform) - - # Compute error - error = local_frame_task.compute_error(pink_config) - - # Check that error is computed correctly - assert isinstance(error, np.ndarray) - assert error.shape == (6,) - - # Error should not be all zeros - assert not np.allclose(error, 0.0) - - def test_jacobian_rank_consistency(self, local_frame_task, pink_config): - """Test that Jacobian maintains consistent shape across configurations.""" - # Set a target that we know can be reached by the test robot. - target_transform = pin.SE3.Identity() - target_transform.translation = np.array([0.0, 0.0, 0.45]) - # 90 degrees around x axis = pi/2 radians - target_transform.rotation = pin.exp3(np.array([np.pi / 2, 0.0, 0.0])) - local_frame_task.set_target(target_transform) - - # Compute Jacobian at multiple configurations - jacobians = [] - for i in range(5): - # Update configuration - new_q = pink_config.full_q.copy() - new_q[1] = 0.1 * i # Vary first joint - pink_config.update(new_q) - - # Compute Jacobian - jacobian = local_frame_task.compute_jacobian(pink_config) - jacobians.append(jacobian) - - # All Jacobians should have the same shape - for jacobian in jacobians: - assert jacobian.shape == (6, 2) - - # All Jacobians should have rank 2 (full rank for 2-DOF planar arm) - for jacobian in jacobians: - assert np.linalg.matrix_rank(jacobian) == 2 + # All Jacobians should have rank 2 (full rank for 2-DOF planar arm) + for jacobian in jacobians: + assert np.linalg.matrix_rank(jacobian) == 2 diff --git a/source/isaaclab/test/controllers/test_pink_ik_components.py b/source/isaaclab/test/controllers/test_pink_ik_components.py index 08a292d8b8e8..6a691c353b2d 100644 --- a/source/isaaclab/test/controllers/test_pink_ik_components.py +++ b/source/isaaclab/test/controllers/test_pink_ik_components.py @@ -3,7 +3,18 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for PinkKinematicsConfiguration class.""" +"""Test cases for PinkKinematicsConfiguration class.""" +# Import pinocchio in the main script to force the use of the dependencies installed by IsaacLab and not the one installed by Isaac Sim +# pinocchio is required by the Pink IK controller +import sys + +if sys.platform != "win32": + import pinocchio # noqa: F401 + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app import numpy as np from pathlib import Path diff --git a/source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf b/source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf index 07b80b9bac98..cb1a305c50da 100644 --- a/source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf +++ b/source/isaaclab/test/controllers/urdfs/test_urdf_two_link_robot.urdf @@ -1,73 +1,11 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -76,7 +14,6 @@ - @@ -85,4 +22,11 @@ + + + + + + + From ccb019c513c92afb52835e47d32b542d8ade70f2 Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Mon, 8 Sep 2025 11:15:13 -0700 Subject: [PATCH 40/47] updated unitree G1 asset address to public server --- source/isaaclab_assets/isaaclab_assets/robots/unitree.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_assets/isaaclab_assets/robots/unitree.py b/source/isaaclab_assets/isaaclab_assets/robots/unitree.py index 573e1aedbf8b..a8674c069a91 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/unitree.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/unitree.py @@ -22,7 +22,7 @@ import isaaclab.sim as sim_utils from isaaclab.actuators import ActuatorNetMLPCfg, DCMotorCfg, ImplicitActuatorCfg from isaaclab.assets.articulation import ArticulationCfg -from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR ## # Configuration - Actuators. @@ -386,7 +386,7 @@ G1_29DOF_CFG = ArticulationCfg( spawn=sim_utils.UsdFileCfg( - usd_path="omniverse://isaac-dev.ov.nvidia.com/Isaac/Robots/Unitree/G1/g1.usd", + usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/Unitree/G1/g1.usd", activate_contact_sensors=False, rigid_props=sim_utils.RigidBodyPropertiesCfg( disable_gravity=False, From 8f9620148563f01a58d3d6d97154014069c19b68 Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Mon, 8 Sep 2025 13:43:22 -0700 Subject: [PATCH 41/47] removing import of pick place for locomanipulation to avoid pinocchio import error. Also increasing pink_ik test timeout --- scripts/environments/teleoperation/teleop_se3_agent.py | 1 + scripts/imitation_learning/robomimic/play.py | 1 + scripts/imitation_learning/robomimic/train.py | 1 + scripts/tools/record_demos.py | 1 + scripts/tools/replay_demos.py | 1 + source/isaaclab/test/controllers/test_pink_ik.py | 1 + .../isaaclab_tasks/manager_based/locomanipulation/__init__.py | 1 - tools/test_settings.py | 1 + 8 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index 021ee5ff80ff..80ec4431554c 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -67,6 +67,7 @@ if args_cli.enable_pinocchio: import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 + import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 def main() -> None: diff --git a/scripts/imitation_learning/robomimic/play.py b/scripts/imitation_learning/robomimic/play.py index 4b1476f6bea1..4cc327941d0d 100644 --- a/scripts/imitation_learning/robomimic/play.py +++ b/scripts/imitation_learning/robomimic/play.py @@ -70,6 +70,7 @@ if args_cli.enable_pinocchio: import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 + import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 from isaaclab_tasks.utils import parse_env_cfg diff --git a/scripts/imitation_learning/robomimic/train.py b/scripts/imitation_learning/robomimic/train.py index 945c1f40f980..0b63e69b26e1 100644 --- a/scripts/imitation_learning/robomimic/train.py +++ b/scripts/imitation_learning/robomimic/train.py @@ -85,6 +85,7 @@ # Isaac Lab imports (needed so that environment is registered) import isaaclab_tasks # noqa: F401 import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 +import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 def normalize_hdf5_actions(config: Config, log_dir: str) -> str: diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index ec01ffaaf8db..5c1f776573cf 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -98,6 +98,7 @@ if args_cli.enable_pinocchio: import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 + import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 from collections.abc import Callable diff --git a/scripts/tools/replay_demos.py b/scripts/tools/replay_demos.py index 951220959b6f..c23e3a10d87c 100644 --- a/scripts/tools/replay_demos.py +++ b/scripts/tools/replay_demos.py @@ -66,6 +66,7 @@ if args_cli.enable_pinocchio: import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 + import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 import isaaclab_tasks # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index 9cd989e6f826..003170fd13cf 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -35,6 +35,7 @@ import isaaclab_tasks # noqa: F401 import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 +import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/__init__.py index a6f661090332..739fdf113e69 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/__init__.py @@ -6,5 +6,4 @@ """This sub-module contains the functions that are specific to the locomanipulation environments.""" -from .pick_place import * # noqa from .tracking import * # noqa diff --git a/tools/test_settings.py b/tools/test_settings.py index 936d38c49584..d15658757f2b 100644 --- a/tools/test_settings.py +++ b/tools/test_settings.py @@ -26,6 +26,7 @@ "test_factory_environments.py": 1000, # This test runs through Factory environments for 100 steps each "test_multi_agent_environments.py": 800, # This test runs through multi-agent environments for 100 steps each "test_generate_dataset.py": 500, # This test runs annotation for 10 demos and generation until one succeeds + "test_pink_ik.py": 1000, # This test runs through all the pink IK environments through various motions "test_environments_training.py": 6000, "test_simulation_render_config.py": 500, "test_operational_space.py": 500, From 39b30901a1453fa11bdeea211341c203912e00b8 Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Mon, 8 Sep 2025 13:58:39 -0700 Subject: [PATCH 42/47] moved load_torchscript into isaaclab.io/ --- .../teleoperation/teleop_se3_agent.py | 2 +- scripts/imitation_learning/robomimic/train.py | 2 +- source/isaaclab/isaaclab/controllers/utils.py | 30 -------------- source/isaaclab/isaaclab/utils/io/__init__.py | 1 + .../isaaclab/isaaclab/utils/io/torchscript.py | 41 +++++++++++++++++++ .../test/controllers/test_controller_utils.py | 3 +- .../isaaclab/test/controllers/test_pink_ik.py | 2 +- .../pick_place/mdp/actions.py | 2 +- 8 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 source/isaaclab/isaaclab/utils/io/torchscript.py diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index 80ec4431554c..e7615c1f6c8d 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -66,8 +66,8 @@ from isaaclab_tasks.utils import parse_env_cfg if args_cli.enable_pinocchio: - import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 + import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 def main() -> None: diff --git a/scripts/imitation_learning/robomimic/train.py b/scripts/imitation_learning/robomimic/train.py index 0b63e69b26e1..718a18bcbca1 100644 --- a/scripts/imitation_learning/robomimic/train.py +++ b/scripts/imitation_learning/robomimic/train.py @@ -84,8 +84,8 @@ # Isaac Lab imports (needed so that environment is registered) import isaaclab_tasks # noqa: F401 -import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 +import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 def normalize_hdf5_actions(config: Config, log_dir: str) -> str: diff --git a/source/isaaclab/isaaclab/controllers/utils.py b/source/isaaclab/isaaclab/controllers/utils.py index f9624e29d196..b674b267acbb 100644 --- a/source/isaaclab/isaaclab/controllers/utils.py +++ b/source/isaaclab/isaaclab/controllers/utils.py @@ -10,7 +10,6 @@ import os import re -import torch from isaacsim.core.utils.extensions import enable_extension @@ -135,32 +134,3 @@ def change_revolute_to_fixed_regex(urdf_path: str, fixed_joints: list[str], verb with open(urdf_path, "w") as file: file.write(content) - - -def load_torchscript_model(model_path: str, device: str = "cpu") -> torch.nn.Module: - """Load a TorchScript model from the specified path. - - This function only loads TorchScript models (.pt or .pth files created with torch.jit.save). - It will not work with raw PyTorch checkpoints (.pth files created with torch.save). - - Args: - model_path (str): Path to the TorchScript model file (.pt or .pth) - device (str, optional): Device to load the model on. Defaults to 'cpu'. - - Returns: - torch.nn.Module: The loaded TorchScript model in evaluation mode - - Raises: - FileNotFoundError: If the model file does not exist - """ - if not os.path.exists(model_path): - raise FileNotFoundError(f"TorchScript model file not found: {model_path}") - - try: - model = torch.jit.load(model_path, map_location=device) - model.eval() - print(f"Successfully loaded TorchScript model from {model_path}") - return model - except Exception as e: - print(f"Error loading TorchScript model: {e}") - return None diff --git a/source/isaaclab/isaaclab/utils/io/__init__.py b/source/isaaclab/isaaclab/utils/io/__init__.py index 1808eb1df7bb..87a14819a613 100644 --- a/source/isaaclab/isaaclab/utils/io/__init__.py +++ b/source/isaaclab/isaaclab/utils/io/__init__.py @@ -8,4 +8,5 @@ """ from .pkl import dump_pickle, load_pickle +from .torchscript import load_torchscript_model from .yaml import dump_yaml, load_yaml diff --git a/source/isaaclab/isaaclab/utils/io/torchscript.py b/source/isaaclab/isaaclab/utils/io/torchscript.py new file mode 100644 index 000000000000..f48c35d39629 --- /dev/null +++ b/source/isaaclab/isaaclab/utils/io/torchscript.py @@ -0,0 +1,41 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +"""TorchScript I/O utilities.""" + +import os +import torch + + +def load_torchscript_model(model_path: str, device: str = "cpu") -> torch.nn.Module: + """Load a TorchScript model from the specified path. + + This function only loads TorchScript models (.pt or .pth files created with torch.jit.save). + It will not work with raw PyTorch checkpoints (.pth files created with torch.save). + + Args: + model_path (str): Path to the TorchScript model file (.pt or .pth) + device (str, optional): Device to load the model on. Defaults to 'cpu'. + + Returns: + torch.nn.Module: The loaded TorchScript model in evaluation mode + + Raises: + FileNotFoundError: If the model file does not exist + """ + if not os.path.exists(model_path): + raise FileNotFoundError(f"TorchScript model file not found: {model_path}") + + try: + model = torch.jit.load(model_path, map_location=device) + model.eval() + print(f"Successfully loaded TorchScript model from {model_path}") + return model + except Exception as e: + print(f"Error loading TorchScript model: {e}") + return None + + diff --git a/source/isaaclab/test/controllers/test_controller_utils.py b/source/isaaclab/test/controllers/test_controller_utils.py index b96fcdc41a99..f1f249dacf9c 100644 --- a/source/isaaclab/test/controllers/test_controller_utils.py +++ b/source/isaaclab/test/controllers/test_controller_utils.py @@ -20,8 +20,9 @@ import pytest -from isaaclab.controllers.utils import change_revolute_to_fixed, change_revolute_to_fixed_regex, load_torchscript_model +from isaaclab.controllers.utils import change_revolute_to_fixed, change_revolute_to_fixed_regex from isaaclab.utils.assets import retrieve_file_path +from isaaclab.utils.io.torchscript import load_torchscript_model @pytest.fixture diff --git a/source/isaaclab/test/controllers/test_pink_ik.py b/source/isaaclab/test/controllers/test_pink_ik.py index 003170fd13cf..46f610c42f51 100644 --- a/source/isaaclab/test/controllers/test_pink_ik.py +++ b/source/isaaclab/test/controllers/test_pink_ik.py @@ -34,8 +34,8 @@ from isaaclab.utils.math import axis_angle_from_quat, matrix_from_quat, quat_from_matrix, quat_inv import isaaclab_tasks # noqa: F401 -import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 import isaaclab_tasks.manager_based.locomanipulation.pick_place # noqa: F401 +import isaaclab_tasks.manager_based.manipulation.pick_place # noqa: F401 from isaaclab_tasks.utils.parse_cfg import parse_env_cfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py index 5e7ffebde7e6..ad0384a5b821 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/actions.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING from isaaclab.assets.articulation import Articulation -from isaaclab.controllers.utils import load_torchscript_model from isaaclab.managers.action_manager import ActionTerm from isaaclab.utils.assets import retrieve_file_path +from isaaclab.utils.io.torchscript import load_torchscript_model if TYPE_CHECKING: from isaaclab.envs import ManagerBasedEnv From eefd3a450cde46a6d6ae9cc6e57ab2d739836d5b Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Mon, 8 Sep 2025 19:28:49 -0700 Subject: [PATCH 43/47] fixing linting --- source/isaaclab/isaaclab/utils/io/torchscript.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/isaaclab/isaaclab/utils/io/torchscript.py b/source/isaaclab/isaaclab/utils/io/torchscript.py index f48c35d39629..df5fe454bf32 100644 --- a/source/isaaclab/isaaclab/utils/io/torchscript.py +++ b/source/isaaclab/isaaclab/utils/io/torchscript.py @@ -37,5 +37,3 @@ def load_torchscript_model(model_path: str, device: str = "cpu") -> torch.nn.Mod except Exception as e: print(f"Error loading TorchScript model: {e}") return None - - From a9afae2e33d7a91f6c05531067ce87e3b05c3121 Mon Sep 17 00:00:00 2001 From: Michael Lin Date: Mon, 8 Sep 2025 21:41:52 -0700 Subject: [PATCH 44/47] fixed existing bug on main --- .../manipulation/pick_place/pickplace_gr1t2_env_cfg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index 156e38c4785a..b2c1c2d83a3e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -405,7 +405,7 @@ def __post_init__(self): enable_visualization=True, num_open_xr_hand_joints=2 * 26, sim_device=self.sim.device, - hand_joint_names=self.actions.pink_ik_cfg.hand_joint_names, + hand_joint_names=self.actions.upper_body_ik.hand_joint_names, ), ], sim_device=self.sim.device, From 69f341213dc2d5a4c5e3e1ac9440bd8132985c62 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Mon, 8 Sep 2025 22:11:54 -0700 Subject: [PATCH 45/47] Adds a unit tests for catching non-headless app file launch (#3392) # Description Recent isaac sim update introduced a new bug for non-headless scripts where some scripts were hanging at simulation startup. This change introduces a new unit test that aims to capture issues like this by forcing the use of the non-headless app file. Additionally, the isaac sim CI system has very unstable results for perf testing, so we are disabling the performance-related tests for the sim CI. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../test/app/test_non_headless_launch.py | 65 +++++++++++++++++++ .../test_kit_startup_performance.py | 3 - .../test_robot_load_performance.py | 1 - 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 source/isaaclab/test/app/test_non_headless_launch.py diff --git a/source/isaaclab/test/app/test_non_headless_launch.py b/source/isaaclab/test/app/test_non_headless_launch.py new file mode 100644 index 000000000000..52c35a109167 --- /dev/null +++ b/source/isaaclab/test/app/test_non_headless_launch.py @@ -0,0 +1,65 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +""" +This script checks if the app can be launched with non-headless app and start the simulation. +""" + +"""Launch Isaac Sim Simulator first.""" + + +import pytest + +from isaaclab.app import AppLauncher + +# launch omniverse app +app_launcher = AppLauncher(experience="isaaclab.python.kit", headless=True) +simulation_app = app_launcher.app + +"""Rest everything follows.""" + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.utils import configclass + + +@configclass +class SensorsSceneCfg(InteractiveSceneCfg): + """Design the scene with sensors on the robot.""" + + # ground plane + ground = AssetBaseCfg(prim_path="/World/defaultGroundPlane", spawn=sim_utils.GroundPlaneCfg()) + + +def run_simulator( + sim: sim_utils.SimulationContext, +): + """Run the simulator.""" + + count = 0 + + # Simulate physics + while simulation_app.is_running() and count < 100: + # perform step + sim.step() + count += 1 + + +@pytest.mark.isaacsim_ci +def test_non_headless_launch(): + # Initialize the simulation context + sim_cfg = sim_utils.SimulationCfg(dt=0.005) + sim = sim_utils.SimulationContext(sim_cfg) + # design scene + scene_cfg = SensorsSceneCfg(num_envs=1, env_spacing=2.0) + scene = InteractiveScene(scene_cfg) + print(scene) + # Play the simulator + sim.reset() + # Now we are ready! + print("[INFO]: Setup complete...") + # Run the simulator + run_simulator(sim) diff --git a/source/isaaclab/test/performance/test_kit_startup_performance.py b/source/isaaclab/test/performance/test_kit_startup_performance.py index 056b2e6293b1..dfa716cd0b23 100644 --- a/source/isaaclab/test/performance/test_kit_startup_performance.py +++ b/source/isaaclab/test/performance/test_kit_startup_performance.py @@ -10,12 +10,9 @@ import time -import pytest - from isaaclab.app import AppLauncher -@pytest.mark.isaacsim_ci def test_kit_start_up_time(): """Test kit start-up time.""" start_time = time.time() diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index 4acf8ad63314..bca8c36d9d5d 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -33,7 +33,6 @@ ({"name": "Anymal_D", "robot_cfg": ANYMAL_D_CFG, "expected_load_time": 40.0}, "cpu"), ], ) -@pytest.mark.isaacsim_ci def test_robot_load_performance(test_config, device): """Test robot load time.""" with build_simulation_context(device=device) as sim: From 7ee6d2a7b7d3fb41e5c3d635dacafc3eb037d20c Mon Sep 17 00:00:00 2001 From: Philipp Reist <66367163+preist-nvidia@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:58:55 +0200 Subject: [PATCH 46/47] Clarifies asset classes' default_inertia tensor coordinate frame (#3405) # Description The default_inertia attributes of the Articulation, RigidObjectCollection, and RigidObject data asset classes did not specify in what coordinate frame the tensors should be provided. This PR addresses this, and addresses some minor inconsistencies across the default_inertia docstrings. ## Type of change - This change requires a documentation update ## Screenshots ArticulationData | Before | After | | ------ | ----- | | image| image| RigidObjectCollectionData | Before | After | | ------ | ----- | | image | image | RigidObjectData | Before | After | | ------ | ----- | | image | image | ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- CONTRIBUTORS.md | 1 + .../isaaclab/assets/articulation/articulation_data.py | 5 +++-- .../isaaclab/assets/rigid_object/rigid_object_data.py | 7 +++++-- .../rigid_object_collection_data.py | 7 +++++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ee6200de8694..47335ecb0fbc 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -111,6 +111,7 @@ Guidelines for modifications: * Özhan Özen * Patrick Yin * Peter Du +* Philipp Reist * Pulkit Goyal * Qian Wan * Qinxi Yu diff --git a/source/isaaclab/isaaclab/assets/articulation/articulation_data.py b/source/isaaclab/isaaclab/assets/articulation/articulation_data.py index 145a69dfc85f..99b2f76abfa2 100644 --- a/source/isaaclab/isaaclab/assets/articulation/articulation_data.py +++ b/source/isaaclab/isaaclab/assets/articulation/articulation_data.py @@ -151,8 +151,9 @@ def update(self, dt: float): default_inertia: torch.Tensor = None """Default inertia for all the bodies in the articulation. Shape is (num_instances, num_bodies, 9). - The inertia is the inertia tensor relative to the center of mass frame. The values are stored in - the order :math:`[I_{xx}, I_{xy}, I_{xz}, I_{yx}, I_{yy}, I_{yz}, I_{zx}, I_{zy}, I_{zz}]`. + The inertia tensor should be given with respect to the center of mass, expressed in the articulation links' actor frame. + The values are stored in the order :math:`[I_{xx}, I_{yx}, I_{zx}, I_{xy}, I_{yy}, I_{zy}, I_{xz}, I_{yz}, I_{zz}]`. + However, due to the symmetry of inertia tensors, row- and column-major orders are equivalent. This quantity is parsed from the USD schema at the time of initialization. """ diff --git a/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_data.py b/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_data.py index 3aac87d324f0..ee83900376f6 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_data.py +++ b/source/isaaclab/isaaclab/assets/rigid_object/rigid_object_data.py @@ -112,8 +112,11 @@ def update(self, dt: float): default_inertia: torch.Tensor = None """Default inertia tensor read from the simulation. Shape is (num_instances, 9). - The inertia is the inertia tensor relative to the center of mass frame. The values are stored in - the order :math:`[I_{xx}, I_{xy}, I_{xz}, I_{yx}, I_{yy}, I_{yz}, I_{zx}, I_{zy}, I_{zz}]`. + The inertia tensor should be given with respect to the center of mass, expressed in the rigid body's actor frame. + The values are stored in the order :math:`[I_{xx}, I_{yx}, I_{zx}, I_{xy}, I_{yy}, I_{zy}, I_{xz}, I_{yz}, I_{zz}]`. + However, due to the symmetry of inertia tensors, row- and column-major orders are equivalent. + + This quantity is parsed from the USD schema at the time of initialization. """ ## diff --git a/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_data.py b/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_data.py index 897679f75aa5..328010bb14f6 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_data.py +++ b/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection_data.py @@ -118,8 +118,11 @@ def update(self, dt: float): default_inertia: torch.Tensor = None """Default object inertia tensor read from the simulation. Shape is (num_instances, num_objects, 9). - The inertia is the inertia tensor relative to the center of mass frame. The values are stored in - the order :math:`[I_{xx}, I_{xy}, I_{xz}, I_{yx}, I_{yy}, I_{yz}, I_{zx}, I_{zy}, I_{zz}]`. + The inertia tensor should be given with respect to the center of mass, expressed in the rigid body's actor frame. + The values are stored in the order :math:`[I_{xx}, I_{yx}, I_{zx}, I_{xy}, I_{yy}, I_{zy}, I_{xz}, I_{yz}, I_{zz}]`. + However, due to the symmetry of inertia tensors, row- and column-major orders are equivalent. + + This quantity is parsed from the USD schema at the time of initialization. """ ## From e4f1ef9b953e6fcfa317d377f415584efb195ec1 Mon Sep 17 00:00:00 2001 From: Rafael Wiltz Date: Tue, 9 Sep 2025 10:57:51 -0400 Subject: [PATCH 47/47] Moving G1 retageting files under trihand --- .../isaaclab/devices/openxr/retargeters/__init__.py | 5 ++++- .../configs/dex-retargeting/g1_hand_left_dexpilot.yml | 0 .../configs/dex-retargeting/g1_hand_right_dexpilot.yml | 0 .../unitree/{ => trihand}/g1_dex_retargeting_utils.py | 2 +- .../unitree/{ => trihand}/g1_upper_body_retargeter.py | 10 +++++----- .../isaaclab/isaaclab/devices/teleop_device_factory.py | 6 +++--- .../pick_place/fixed_base_upper_body_ik_g1_env_cfg.py | 6 ++++-- .../pick_place/locomanipulation_g1_env_cfg.py | 6 ++++-- 8 files changed, 21 insertions(+), 14 deletions(-) rename source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/{ => trihand}/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml (100%) rename source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/{ => trihand}/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml (100%) rename source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/{ => trihand}/g1_dex_retargeting_utils.py (99%) rename source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/{ => trihand}/g1_upper_body_retargeter.py (95%) diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py b/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py index b1cbaaccb075..c687d287d258 100644 --- a/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/__init__.py @@ -6,7 +6,10 @@ from .humanoid.fourier.gr1t2_retargeter import GR1T2Retargeter, GR1T2RetargeterCfg from .humanoid.unitree.g1_lower_body_standing import G1LowerBodyStandingRetargeter, G1LowerBodyStandingRetargeterCfg -from .humanoid.unitree.g1_upper_body_retargeter import G1UpperBodyRetargeter, G1UpperBodyRetargeterCfg +from .humanoid.unitree.trihand.g1_upper_body_retargeter import ( + G1TriHandUpperBodyRetargeter, + G1TriHandUpperBodyRetargeterCfg, +) from .manipulator.gripper_retargeter import GripperRetargeter, GripperRetargeterCfg from .manipulator.se3_abs_retargeter import Se3AbsRetargeter, Se3AbsRetargeterCfg from .manipulator.se3_rel_retargeter import Se3RelRetargeter, Se3RelRetargeterCfg diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml similarity index 100% rename from source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml rename to source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/data/configs/dex-retargeting/g1_hand_left_dexpilot.yml diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml similarity index 100% rename from source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml rename to source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/data/configs/dex-retargeting/g1_hand_right_dexpilot.yml diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_dex_retargeting_utils.py b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/g1_dex_retargeting_utils.py similarity index 99% rename from source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_dex_retargeting_utils.py rename to source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/g1_dex_retargeting_utils.py index 114f183be7ae..4d8f58886b2f 100644 --- a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_dex_retargeting_utils.py +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/g1_dex_retargeting_utils.py @@ -53,7 +53,7 @@ ] -class G1DexRetargeting: +class G1TriHandDexRetargeting: """A class for hand retargeting with G1. Handles retargeting of OpenXRhand tracking data to G1 robot hand joint angles. diff --git a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_upper_body_retargeter.py b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/g1_upper_body_retargeter.py similarity index 95% rename from source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_upper_body_retargeter.py rename to source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/g1_upper_body_retargeter.py index 5b6592908732..41f7f49fd9f8 100644 --- a/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/g1_upper_body_retargeter.py +++ b/source/isaaclab/isaaclab/devices/openxr/retargeters/humanoid/unitree/trihand/g1_upper_body_retargeter.py @@ -18,11 +18,11 @@ # This import exception is suppressed because g1_dex_retargeting_utils depends on pinocchio which is not available on windows with contextlib.suppress(Exception): - from .g1_dex_retargeting_utils import G1DexRetargeting + from .g1_dex_retargeting_utils import G1TriHandDexRetargeting @dataclass -class G1UpperBodyRetargeterCfg(RetargeterCfg): +class G1TriHandUpperBodyRetargeterCfg(RetargeterCfg): """Configuration for the G1UpperBody retargeter.""" enable_visualization: bool = False @@ -30,7 +30,7 @@ class G1UpperBodyRetargeterCfg(RetargeterCfg): hand_joint_names: list[str] | None = None # List of robot hand joint names -class G1UpperBodyRetargeter(RetargeterBase): +class G1TriHandUpperBodyRetargeter(RetargeterBase): """Retargets OpenXR data to G1 upper body commands. This retargeter maps hand tracking data from OpenXR to wrist and hand joint commands for the G1 robot. @@ -40,7 +40,7 @@ class G1UpperBodyRetargeter(RetargeterBase): def __init__( self, - cfg: G1UpperBodyRetargeterCfg, + cfg: G1TriHandUpperBodyRetargeterCfg, ): """Initialize the G1 upper body retargeter. @@ -54,7 +54,7 @@ def __init__( # Initialize the hands controller if cfg.hand_joint_names is not None: - self._hands_controller = G1DexRetargeting(cfg.hand_joint_names) + self._hands_controller = G1TriHandDexRetargeting(cfg.hand_joint_names) else: raise ValueError("hand_joint_names must be provided in configuration") diff --git a/source/isaaclab/isaaclab/devices/teleop_device_factory.py b/source/isaaclab/isaaclab/devices/teleop_device_factory.py index 9c92a2489832..6f63a1a8d72c 100644 --- a/source/isaaclab/isaaclab/devices/teleop_device_factory.py +++ b/source/isaaclab/isaaclab/devices/teleop_device_factory.py @@ -17,8 +17,8 @@ from isaaclab.devices.openxr.retargeters import ( G1LowerBodyStandingRetargeter, G1LowerBodyStandingRetargeterCfg, - G1UpperBodyRetargeter, - G1UpperBodyRetargeterCfg, + G1TriHandUpperBodyRetargeter, + G1TriHandUpperBodyRetargeterCfg, GR1T2Retargeter, GR1T2RetargeterCfg, GripperRetargeter, @@ -54,7 +54,7 @@ Se3RelRetargeterCfg: Se3RelRetargeter, GripperRetargeterCfg: GripperRetargeter, GR1T2RetargeterCfg: GR1T2Retargeter, - G1UpperBodyRetargeterCfg: G1UpperBodyRetargeter, + G1TriHandUpperBodyRetargeterCfg: G1TriHandUpperBodyRetargeter, G1LowerBodyStandingRetargeterCfg: G1LowerBodyStandingRetargeter, } diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py index 6d22da2e1fa1..1ba7b9ab5a67 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py @@ -11,7 +11,9 @@ from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg from isaaclab.devices.device_base import DevicesCfg from isaaclab.devices.openxr import OpenXRDeviceCfg, XrCfg -from isaaclab.devices.openxr.retargeters.humanoid.unitree.g1_upper_body_retargeter import G1UpperBodyRetargeterCfg +from isaaclab.devices.openxr.retargeters.humanoid.unitree.trihand.g1_upper_body_retargeter import ( + G1TriHandUpperBodyRetargeterCfg, +) from isaaclab.envs import ManagerBasedRLEnvCfg from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm @@ -198,7 +200,7 @@ def __post_init__(self): devices={ "handtracking": OpenXRDeviceCfg( retargeters=[ - G1UpperBodyRetargeterCfg( + G1TriHandUpperBodyRetargeterCfg( enable_visualization=True, # OpenXR hand tracking has 26 joints per hand num_open_xr_hand_joints=2 * 26, diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py index 98f7432d23fa..e807f100c419 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py @@ -12,7 +12,9 @@ from isaaclab.devices.device_base import DevicesCfg from isaaclab.devices.openxr import OpenXRDeviceCfg, XrCfg from isaaclab.devices.openxr.retargeters.humanoid.unitree.g1_lower_body_standing import G1LowerBodyStandingRetargeterCfg -from isaaclab.devices.openxr.retargeters.humanoid.unitree.g1_upper_body_retargeter import G1UpperBodyRetargeterCfg +from isaaclab.devices.openxr.retargeters.humanoid.unitree.trihand.g1_upper_body_retargeter import ( + G1TriHandUpperBodyRetargeterCfg, +) from isaaclab.envs import ManagerBasedRLEnvCfg from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm @@ -209,7 +211,7 @@ def __post_init__(self): devices={ "handtracking": OpenXRDeviceCfg( retargeters=[ - G1UpperBodyRetargeterCfg( + G1TriHandUpperBodyRetargeterCfg( enable_visualization=True, # OpenXR hand tracking has 26 joints per hand num_open_xr_hand_joints=2 * 26,

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 @@ -163,6 +168,8 @@ for the lift-cube environment: .. |stack-cube-link| replace:: `Isaac-Stack-Cube-Franka-v0 `__ .. |stack-cube-bp-link| replace:: `Isaac-Stack-Cube-Franka-IK-Rel-Blueprint-v0 `__ .. |gr1_pick_place-link| replace:: `Isaac-PickPlace-GR1T2-Abs-v0 `__ +.. |long-suction-link| replace:: `Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 `__ +.. |short-suction-link| replace:: `Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 `__ .. |gr1_pp_waist-link| replace:: `Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0 `__ .. |cube-shadow-link| replace:: `Isaac-Repose-Cube-Shadow-Direct-v0 `__ @@ -939,6 +946,14 @@ inferencing, including reading from an already trained checkpoint and disabling - - Manager Based - + * - Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 + - + - Manager Based + - + * - Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 + - + - Manager Based + - * - Isaac-Velocity-Flat-Anymal-B-v0 - Isaac-Velocity-Flat-Anymal-B-Play-v0 - Manager Based diff --git a/scripts/demos/pick_and_place.py b/scripts/demos/pick_and_place.py index 2b3a14aaff23..cc14dcb0a72c 100644 --- a/scripts/demos/pick_and_place.py +++ b/scripts/demos/pick_and_place.py @@ -87,7 +87,7 @@ class PickAndPlaceEnvCfg(DirectRLEnvCfg): # Surface Gripper, the prim_expr need to point to a unique surface gripper per environment. gripper = SurfaceGripperCfg( - prim_expr="/World/envs/env_.*/Robot/picker_head/SurfaceGripper", + prim_path="/World/envs/env_.*/Robot/picker_head/SurfaceGripper", max_grip_distance=0.1, shear_force_limit=500.0, coaxial_force_limit=500.0, diff --git a/scripts/tutorials/01_assets/run_surface_gripper.py b/scripts/tutorials/01_assets/run_surface_gripper.py index e9a300221f5f..6b8e32d2127a 100644 --- a/scripts/tutorials/01_assets/run_surface_gripper.py +++ b/scripts/tutorials/01_assets/run_surface_gripper.py @@ -72,7 +72,7 @@ def design_scene(): # Surface Gripper: Next we define the surface gripper config surface_gripper_cfg = SurfaceGripperCfg() # We need to tell the View which prim to use for the surface gripper - surface_gripper_cfg.prim_expr = "/World/Origin.*/Robot/picker_head/SurfaceGripper" + surface_gripper_cfg.prim_path = "/World/Origin.*/Robot/picker_head/SurfaceGripper" # We can then set different parameters for the surface gripper, note that if these parameters are not set, # the View will try to read them from the prim. surface_gripper_cfg.max_grip_distance = 0.1 # [m] (Maximum distance at which the gripper can grasp an object) diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 08c7cf2f1589..792634e10528 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.11" +version = "0.45.12" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 341cc1840729..72ac0a813266 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +0.45.12 (2025-09-05) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab.envs.mdp.actions.SurfaceGripperBinaryAction` for supporting surface grippers in Manager-Based workflows. + +Changed +^^^^^^^ + +* Added AssetBase inheritance for :class:`~isaaclab.assets.surface_gripper.SurfaceGripper` + + 0.45.11 (2025-09-04) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper.py b/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper.py index f809b645c015..1702dbf90e26 100644 --- a/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper.py +++ b/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper.py @@ -9,6 +9,7 @@ import warnings from typing import TYPE_CHECKING +import omni.log from isaacsim.core.utils.extensions import enable_extension from isaacsim.core.version import get_version @@ -18,7 +19,7 @@ if TYPE_CHECKING: from isaacsim.robot.surface_gripper import GripperView -from .surface_gripper_cfg import SurfaceGripperCfg + from .surface_gripper_cfg import SurfaceGripperCfg class SurfaceGripper(AssetBase): @@ -246,7 +247,7 @@ def _initialize_impl(self) -> None: Raises: ValueError: If the simulation backend is not CPU. - RuntimeError: If the Simulation Context is not initialized. + RuntimeError: If the Simulation Context is not initialized or if gripper prims are not found. Note: The SurfaceGripper is only supported on CPU for now. Please set the simulation backend to run on CPU. @@ -262,8 +263,35 @@ def _initialize_impl(self) -> None: "SurfaceGripper is only supported on CPU for now. Please set the simulation backend to run on CPU. Use" " `--device cpu` to run the simulation on CPU." ) + + # obtain the first prim in the regex expression (all others are assumed to be a copy of this) + template_prim = sim_utils.find_first_matching_prim(self._cfg.prim_path) + if template_prim is None: + raise RuntimeError(f"Failed to find prim for expression: '{self._cfg.prim_path}'.") + template_prim_path = template_prim.GetPath().pathString + + # find surface gripper prims + gripper_prims = sim_utils.get_all_matching_child_prims( + template_prim_path, predicate=lambda prim: prim.GetTypeName() == "IsaacSurfaceGripper" + ) + if len(gripper_prims) == 0: + raise RuntimeError( + f"Failed to find a surface gripper when resolving '{self._cfg.prim_path}'." + " Please ensure that the prim has type 'IsaacSurfaceGripper'." + ) + if len(gripper_prims) > 1: + raise RuntimeError( + f"Failed to find a single surface gripper when resolving '{self._cfg.prim_path}'." + f" Found multiple '{gripper_prims}' under '{template_prim_path}'." + " Please ensure that there is only one surface gripper in the prim path tree." + ) + + # resolve gripper prim back into regex expression + gripper_prim_path = gripper_prims[0].GetPath().pathString + gripper_prim_path_expr = self._cfg.prim_path + gripper_prim_path[len(template_prim_path) :] + # Count number of environments - self._prim_expr = self._cfg.prim_expr + self._prim_expr = gripper_prim_path_expr env_prim_path_expr = self._prim_expr.rsplit("/", 1)[0] self._parent_prims = sim_utils.find_matching_prims(env_prim_path_expr) self._num_envs = len(self._parent_prims) @@ -287,6 +315,10 @@ def _initialize_impl(self) -> None: retry_interval=self._retry_interval.clone(), ) + # log information about the surface gripper + omni.log.info(f"Surface gripper initialized at: {self._cfg.prim_path} with root '{gripper_prim_path_expr}'.") + omni.log.info(f"Number of instances: {self._num_envs}") + # Reset grippers self.reset() diff --git a/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper_cfg.py b/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper_cfg.py index d7b1872edace..4a1f07738cc9 100644 --- a/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper_cfg.py +++ b/source/isaaclab/isaaclab/assets/surface_gripper/surface_gripper_cfg.py @@ -7,12 +7,15 @@ from isaaclab.utils import configclass +from ..asset_base_cfg import AssetBaseCfg +from .surface_gripper import SurfaceGripper + @configclass -class SurfaceGripperCfg: +class SurfaceGripperCfg(AssetBaseCfg): """Configuration parameters for a surface gripper actuator.""" - prim_expr: str = MISSING + prim_path: str = MISSING """The expression to find the grippers in the stage.""" max_grip_distance: float | None = None @@ -26,3 +29,5 @@ class SurfaceGripperCfg: retry_interval: float | None = None """The amount of time the gripper will spend trying to grasp an object.""" + + class_type: type = SurfaceGripper diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py b/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py index 9c932d227b0b..3698f2511262 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/actions_cfg.py @@ -9,7 +9,14 @@ from isaaclab.managers.action_manager import ActionTerm, ActionTermCfg from isaaclab.utils import configclass -from . import binary_joint_actions, joint_actions, joint_actions_to_limits, non_holonomic_actions, task_space_actions +from . import ( + binary_joint_actions, + joint_actions, + joint_actions_to_limits, + non_holonomic_actions, + surface_gripper_actions, + task_space_actions, +) ## # Joint actions. @@ -310,3 +317,25 @@ class OffsetCfg: Note: Functional only when ``nullspace_control`` is set to ``"position"`` within the ``OperationalSpaceControllerCfg``. """ + + +## +# Surface Gripper actions. +## + + +@configclass +class SurfaceGripperBinaryActionCfg(ActionTermCfg): + """Configuration for the binary surface gripper action term. + + See :class:`SurfaceGripperBinaryAction` for more details. + """ + + asset_name: str = MISSING + """Name of the surface gripper asset in the scene.""" + open_command: float = -1.0 + """The command value to open the gripper. Defaults to -1.0.""" + close_command: float = 1.0 + """The command value to close the gripper. Defaults to 1.0.""" + + class_type: type[ActionTerm] = surface_gripper_actions.SurfaceGripperBinaryAction diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/surface_gripper_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/surface_gripper_actions.py new file mode 100644 index 000000000000..4313fbeacd2a --- /dev/null +++ b/source/isaaclab/isaaclab/envs/mdp/actions/surface_gripper_actions.py @@ -0,0 +1,106 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import torch +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import omni.log + +from isaaclab.assets.surface_gripper import SurfaceGripper +from isaaclab.managers.action_manager import ActionTerm + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv + + from . import actions_cfg + + +class SurfaceGripperBinaryAction(ActionTerm): + """Surface gripper binary action. + + This action term maps a binary action to the *open* or *close* surface gripper configurations. + The surface gripper behavior is as follows: + - [-1, -0.3] --> Gripper is Opening + - [-0.3, 0.3] --> Gripper is Idle (do nothing) + - [0.3, 1] --> Gripper is Closing + + Based on above, we follow the following convention for the binary action: + + 1. Open action: 1 (bool) or positive values (float). + 2. Close action: 0 (bool) or negative values (float). + + The action term is specifically designed for surface grippers, which use a different + interface than joint-based grippers. + """ + + cfg: actions_cfg.SurfaceGripperBinaryActionCfg + """The configuration of the action term.""" + _asset: SurfaceGripper + """The surface gripper asset on which the action term is applied.""" + + def __init__(self, cfg: actions_cfg.SurfaceGripperBinaryActionCfg, env: ManagerBasedEnv) -> None: + # initialize the action term + super().__init__(cfg, env) + + # log the resolved asset name for debugging + omni.log.info( + f"Resolved surface gripper asset for the action term {self.__class__.__name__}: {self.cfg.asset_name}" + ) + + # create tensors for raw and processed actions + self._raw_actions = torch.zeros(self.num_envs, 1, device=self.device) + self._processed_actions = torch.zeros(self.num_envs, 1, device=self.device) + + # parse open command + self._open_command = torch.tensor(self.cfg.open_command, device=self.device) + # parse close command + self._close_command = torch.tensor(self.cfg.close_command, device=self.device) + + """ + Properties. + """ + + @property + def action_dim(self) -> int: + return 1 + + @property + def raw_actions(self) -> torch.Tensor: + return self._raw_actions + + @property + def processed_actions(self) -> torch.Tensor: + return self._processed_actions + + """ + Operations. + """ + + def process_actions(self, actions: torch.Tensor): + # store the raw actions + self._raw_actions[:] = actions + # compute the binary mask + if actions.dtype == torch.bool: + # true: close, false: open + binary_mask = actions == 0 + else: + # true: close, false: open + binary_mask = actions < 0 + # compute the command + self._processed_actions = torch.where(binary_mask, self._close_command, self._open_command) + + def apply_actions(self): + """Apply the processed actions to the surface gripper.""" + self._asset.set_grippers_command(self._processed_actions.view(-1)) + self._asset.write_data_to_sim() + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + if env_ids is None: + self._raw_actions[:] = 0.0 + else: + self._raw_actions[env_ids] = 0.0 diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 141024ecb765..e12118a36d04 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -554,9 +554,9 @@ def reset_to( rigid_object.write_root_pose_to_sim(root_pose, env_ids=env_ids) rigid_object.write_root_velocity_to_sim(root_velocity, env_ids=env_ids) # surface grippers - for asset_name, gripper in self._surface_grippers.items(): + for asset_name, surface_gripper in self._surface_grippers.items(): asset_state = state["gripper"][asset_name] - gripper.write_gripper_state_to_sim(asset_state, env_ids=env_ids) + surface_gripper.set_grippers_command(asset_state) # write data to simulation to make sure initial state is set # this propagates the joint targets to the simulation @@ -643,6 +643,10 @@ def get_state(self, is_relative: bool = False) -> dict[str, dict[str, dict[str, asset_state["root_pose"][:, :3] -= self.env_origins asset_state["root_velocity"] = rigid_object.data.root_vel_w.clone() state["rigid_object"][asset_name] = asset_state + # surface grippers + state["gripper"] = dict() + for asset_name, gripper in self._surface_grippers.items(): + state["gripper"][asset_name] = gripper.state.clone() return state """ @@ -749,7 +753,8 @@ def _add_entities_from_cfg(self): asset_paths = sim_utils.find_matching_prim_paths(rigid_object_cfg.prim_path) self._global_prim_paths += asset_paths elif isinstance(asset_cfg, SurfaceGripperCfg): - pass + # add surface grippers to scene + self._surface_grippers[asset_name] = asset_cfg.class_type(asset_cfg) elif isinstance(asset_cfg, SensorBaseCfg): # Update target frame path(s)' regex name space for FrameTransformer if isinstance(asset_cfg, FrameTransformerCfg): diff --git a/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py b/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py index 02b19cd20031..183d0c3779ce 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause + """Configuration for the Universal Robots. The following configuration parameters are available: @@ -21,7 +22,6 @@ # Configuration ## - UR10_CFG = ArticulationCfg( spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Robots/UniversalRobots/UR10/ur10_instanceable.usd", @@ -102,3 +102,23 @@ ) """Configuration of UR-10 arm using implicit actuator models.""" + +UR10_LONG_SUCTION_CFG = UR10_CFG.copy() +UR10_LONG_SUCTION_CFG.spawn.usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/UniversalRobots/ur10/ur10.usd" +UR10_LONG_SUCTION_CFG.spawn.variants = {"Gripper": "Long_Suction"} +UR10_LONG_SUCTION_CFG.spawn.rigid_props.disable_gravity = True +UR10_LONG_SUCTION_CFG.init_state.joint_pos = { + "shoulder_pan_joint": 0.0, + "shoulder_lift_joint": -1.5707, + "elbow_joint": 1.5707, + "wrist_1_joint": -1.5707, + "wrist_2_joint": 1.5707, + "wrist_3_joint": 0.0, +} + +"""Configuration of UR10 arm with long suction gripper.""" + +UR10_SHORT_SUCTION_CFG = UR10_LONG_SUCTION_CFG.copy() +UR10_SHORT_SUCTION_CFG.spawn.variants = {"Gripper": "Short_Suction"} + +"""Configuration of UR10 arm with short suction gripper.""" diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 95a1930a30f4..88ba2eda5fcd 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.10.48" +version = "0.10.49" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 41732b6f8319..c0909d246577 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,14 @@ Changelog --------- +0.10.49 (2025-09-05) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added suction gripper stacking environments with UR10 that can be used with teleoperation. + 0.10.48 (2025-09-03) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/__init__.py new file mode 100644 index 000000000000..d051b5fc5486 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/__init__.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import gymnasium as gym + +from . import stack_ik_rel_env_cfg + +## +# Register Gym environments. +## + + +## +# Inverse Kinematics - Relative Pose Control +## + +gym.register( + id="Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_ik_rel_env_cfg.UR10LongSuctionCubeStackEnvCfg, + }, + disable_env_checker=True, +) + +gym.register( + id="Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + kwargs={ + "env_cfg_entry_point": stack_ik_rel_env_cfg.UR10ShortSuctionCubeStackEnvCfg, + }, + disable_env_checker=True, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/agents/__init__.py new file mode 100644 index 000000000000..2e924fbf1b13 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/agents/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py new file mode 100644 index 000000000000..00c379ef19fa --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py @@ -0,0 +1,80 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + + +from isaaclab.controllers.differential_ik_cfg import DifferentialIKControllerCfg +from isaaclab.devices.device_base import DevicesCfg +from isaaclab.devices.keyboard import Se3KeyboardCfg +from isaaclab.devices.spacemouse import Se3SpaceMouseCfg +from isaaclab.envs.mdp.actions.actions_cfg import DifferentialInverseKinematicsActionCfg +from isaaclab.utils import configclass + +from . import stack_joint_pos_env_cfg + + +@configclass +class UR10LongSuctionCubeStackEnvCfg(stack_joint_pos_env_cfg.UR10LongSuctionCubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set actions for the specific robot type (UR10 LONG SUCTION) + self.actions.arm_action = DifferentialInverseKinematicsActionCfg( + asset_name="robot", + joint_names=[".*_joint"], + body_name="ee_link", + controller=DifferentialIKControllerCfg(command_type="pose", use_relative_mode=True, ik_method="dls"), + scale=1.0, + body_offset=DifferentialInverseKinematicsActionCfg.OffsetCfg(pos=[0.0, 0.0, -0.22]), + ) + + self.teleop_devices = DevicesCfg( + devices={ + "keyboard": Se3KeyboardCfg( + pos_sensitivity=0.02, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + "spacemouse": Se3SpaceMouseCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + } + ) + + +@configclass +class UR10ShortSuctionCubeStackEnvCfg(stack_joint_pos_env_cfg.UR10ShortSuctionCubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set actions for the specific robot type (UR10 SHORT SUCTION) + self.actions.arm_action = DifferentialInverseKinematicsActionCfg( + asset_name="robot", + joint_names=[".*_joint"], + body_name="ee_link", + controller=DifferentialIKControllerCfg(command_type="pose", use_relative_mode=True, ik_method="dls"), + scale=1.0, + body_offset=DifferentialInverseKinematicsActionCfg.OffsetCfg(pos=[0.0, 0.0, -0.159]), + ) + + self.teleop_devices = DevicesCfg( + devices={ + "keyboard": Se3KeyboardCfg( + pos_sensitivity=0.02, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + "spacemouse": Se3SpaceMouseCfg( + pos_sensitivity=0.05, + rot_sensitivity=0.05, + sim_device=self.sim.device, + ), + } + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py new file mode 100644 index 000000000000..86ee8665b6ef --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py @@ -0,0 +1,206 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from isaaclab.assets import RigidObjectCfg, SurfaceGripperCfg +from isaaclab.envs.mdp.actions.actions_cfg import SurfaceGripperBinaryActionCfg +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.sensors import FrameTransformerCfg +from isaaclab.sensors.frame_transformer.frame_transformer_cfg import OffsetCfg +from isaaclab.sim.schemas.schemas_cfg import RigidBodyPropertiesCfg +from isaaclab.sim.spawners.from_files.from_files_cfg import UsdFileCfg +from isaaclab.utils import configclass +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + +from isaaclab_tasks.manager_based.manipulation.stack import mdp +from isaaclab_tasks.manager_based.manipulation.stack.mdp import franka_stack_events +from isaaclab_tasks.manager_based.manipulation.stack.stack_env_cfg import StackEnvCfg + +from isaaclab_assets.robots.universal_robots import ( # isort: skip + UR10_LONG_SUCTION_CFG, + UR10_SHORT_SUCTION_CFG, +) + +## +# Pre-defined configs +## +from isaaclab.markers.config import FRAME_MARKER_CFG # isort: skip + + +@configclass +class EventCfgLongSuction: + """Configuration for events.""" + + init_franka_arm_pose = EventTerm( + func=franka_stack_events.set_default_joint_pose, + mode="reset", + params={ + "default_pose": [0.0, -1.5707, 1.5707, -1.5707, -1.5707, 0.0], + }, + ) + + randomize_franka_joint_state = EventTerm( + func=franka_stack_events.randomize_joint_by_gaussian_offset, + mode="reset", + params={ + "mean": 0.0, + "std": 0.02, + "asset_cfg": SceneEntityCfg("robot"), + }, + ) + + randomize_cube_positions = EventTerm( + func=franka_stack_events.randomize_object_pose, + mode="reset", + params={ + "pose_range": {"x": (0.4, 0.6), "y": (-0.10, 0.10), "z": (0.0203, 0.0203), "yaw": (-1.0, 1.0, 0)}, + "min_separation": 0.1, + "asset_cfgs": [SceneEntityCfg("cube_1"), SceneEntityCfg("cube_2"), SceneEntityCfg("cube_3")], + }, + ) + + +@configclass +class UR10CubeStackEnvCfg(StackEnvCfg): + # Rigid body properties of each cube + cube_properties = RigidBodyPropertiesCfg( + solver_position_iteration_count=16, + solver_velocity_iteration_count=1, + max_angular_velocity=1000.0, + max_linear_velocity=1000.0, + max_depenetration_velocity=5.0, + disable_gravity=False, + ) + cube_scale = (1.0, 1.0, 1.0) + # Listens to the required transforms + marker_cfg = FRAME_MARKER_CFG.copy() + marker_cfg.markers["frame"].scale = (0.1, 0.1, 0.1) + marker_cfg.prim_path = "/Visuals/FrameTransformer" + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set events + self.events = EventCfgLongSuction() + + # Set actions for the specific robot type (ur10) + self.actions.arm_action = mdp.JointPositionActionCfg( + asset_name="robot", joint_names=[".*_joint"], scale=0.5, use_default_offset=True + ) + # Set surface gripper action + self.actions.gripper_action = SurfaceGripperBinaryActionCfg( + asset_name="surface_gripper", + open_command=-1.0, + close_command=1.0, + ) + + # Set each stacking cube deterministically + self.scene.cube_1 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_1", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.4, 0.0, 0.0203], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/blue_block.usd", + scale=self.cube_scale, + rigid_props=self.cube_properties, + ), + ) + self.scene.cube_2 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_2", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.55, 0.05, 0.0203], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/red_block.usd", + scale=self.cube_scale, + rigid_props=self.cube_properties, + ), + ) + self.scene.cube_3 = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube_3", + init_state=RigidObjectCfg.InitialStateCfg(pos=[0.60, -0.1, 0.0203], rot=[1, 0, 0, 0]), + spawn=UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/green_block.usd", + scale=self.cube_scale, + rigid_props=self.cube_properties, + ), + ) + + self.decimation = 5 + self.episode_length_s = 30.0 + # simulation settings + self.sim.dt = 0.01 # 100Hz + self.sim.render_interval = 5 + + +@configclass +class UR10LongSuctionCubeStackEnvCfg(UR10CubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set events + self.events = EventCfgLongSuction() + + # Set UR10 as robot + self.scene.robot = UR10_LONG_SUCTION_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # Set surface gripper: Ensure the SurfaceGripper prim has the required attributes + self.scene.surface_gripper = SurfaceGripperCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link/SurfaceGripper", + max_grip_distance=0.05, + shear_force_limit=5000.0, + coaxial_force_limit=5000.0, + retry_interval=0.05, + ) + + self.scene.ee_frame = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base_link", + debug_vis=True, + visualizer_cfg=self.marker_cfg, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link", + name="end_effector", + offset=OffsetCfg( + pos=[0.22, 0.0, 0.0], + ), + ), + ], + ) + + +@configclass +class UR10ShortSuctionCubeStackEnvCfg(UR10CubeStackEnvCfg): + + def __post_init__(self): + # post init of parent + super().__post_init__() + + # Set UR10 as robot + self.scene.robot = UR10_SHORT_SUCTION_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + + # Set surface gripper: Ensure the SurfaceGripper prim has the required attributes + self.scene.surface_gripper = SurfaceGripperCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link/SurfaceGripper", + max_grip_distance=0.05, + shear_force_limit=5000.0, + coaxial_force_limit=5000.0, + retry_interval=0.05, + ) + + self.scene.ee_frame = FrameTransformerCfg( + prim_path="{ENV_REGEX_NS}/Robot/base_link", + debug_vis=True, + visualizer_cfg=self.marker_cfg, + target_frames=[ + FrameTransformerCfg.FrameCfg( + prim_path="{ENV_REGEX_NS}/Robot/ee_link", + name="end_effector", + offset=OffsetCfg( + pos=[0.1585, 0.0, 0.0], + ), + ), + ], + ) From 1a71e24a98bc99765b7beb208010baeb26d83e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zhan=20=C3=96zen?= <41010165+ozhanozen@users.noreply.github.com> Date: Fri, 5 Sep 2025 21:06:07 +0200 Subject: [PATCH 26/47] Fixes the missing Ray initialization (#3350) # Description Recent changes introduced a minor bug: now, Ray is not initialized when `tuner.py` is called. This PR fixes this by adding back the removed initialization. Fixes #3349 ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots pr_changes ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- scripts/reinforcement_learning/ray/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/reinforcement_learning/ray/util.py b/scripts/reinforcement_learning/ray/util.py index ebc67dc568ed..427c887cdcc3 100644 --- a/scripts/reinforcement_learning/ray/util.py +++ b/scripts/reinforcement_learning/ray/util.py @@ -343,7 +343,7 @@ def get_gpu_node_resources( or simply the resource for a single node if requested. """ if not ray.is_initialized(): - raise Exception("Ray is not initialized. Please initialize Ray before getting node resources.") + ray_init() nodes = ray.nodes() node_resources = [] total_cpus = 0 From cc7685b139c69d528b754ce88445953387a688cb Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Sat, 6 Sep 2025 19:54:25 -0700 Subject: [PATCH 27/47] Disables GPU testing for suction gripper environments (#3373) # Description Since suction gripper requires CPU simulation currently, we disable GPU environment testing for them for now and explicitly sets the device for these environments to CPU. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab/test/assets/test_surface_gripper.py | 2 +- .../stack/config/ur10_gripper/stack_joint_pos_env_cfg.py | 6 ++++++ source/isaaclab_tasks/test/env_test_utils.py | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/source/isaaclab/test/assets/test_surface_gripper.py b/source/isaaclab/test/assets/test_surface_gripper.py index 1c415e62c610..c2f81143f598 100644 --- a/source/isaaclab/test/assets/test_surface_gripper.py +++ b/source/isaaclab/test/assets/test_surface_gripper.py @@ -114,7 +114,7 @@ def generate_surface_gripper( for i in range(num_surface_grippers): prim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=translations[i][:3]) articulation = Articulation(articulation_cfg.replace(prim_path="/World/Env_.*/Robot")) - surface_gripper_cfg = surface_gripper_cfg.replace(prim_expr="/World/Env_.*/Robot/Gripper/SurfaceGripper") + surface_gripper_cfg = surface_gripper_cfg.replace(prim_path="/World/Env_.*/Robot/Gripper/SurfaceGripper") surface_gripper = SurfaceGripper(surface_gripper_cfg) return surface_gripper, articulation, translations diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py index 86ee8665b6ef..467df1d4410f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_joint_pos_env_cfg.py @@ -140,6 +140,9 @@ def __post_init__(self): # post init of parent super().__post_init__() + # Suction grippers currently require CPU simulation + self.device = "cpu" + # Set events self.events = EventCfgLongSuction() @@ -178,6 +181,9 @@ def __post_init__(self): # post init of parent super().__post_init__() + # Suction grippers currently require CPU simulation + self.device = "cpu" + # Set UR10 as robot self.scene.robot = UR10_SHORT_SUCTION_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") diff --git a/source/isaaclab_tasks/test/env_test_utils.py b/source/isaaclab_tasks/test/env_test_utils.py index e946f1bb597b..23a92bab9c12 100644 --- a/source/isaaclab_tasks/test/env_test_utils.py +++ b/source/isaaclab_tasks/test/env_test_utils.py @@ -114,6 +114,10 @@ def _run_environments( if isaac_sim_version < 5 and create_stage_in_memory: pytest.skip("Stage in memory is not supported in this version of Isaac Sim") + # skip suction gripper environments as they require CPU simulation and cannot be run with GPU simulation + if "Suction" in task_name and device != "cpu": + return + # skip these environments as they cannot be run with 32 environments within reasonable VRAM if num_envs == 32 and task_name in [ "Isaac-Stack-Cube-Franka-IK-Rel-Blueprint-v0", From e0a8df23ccd382572ac5b5b3faac0511ef2fc929 Mon Sep 17 00:00:00 2001 From: Mayank Mittal <12863862+Mayankm96@users.noreply.github.com> Date: Mon, 8 Sep 2025 08:31:09 +0200 Subject: [PATCH 28/47] Fixes errors while building the docs (#3370) # Description Another time of manually fixing errors seen in the docs. We should have CI strictly enforce doc build warnings so they get removed before MR is merged. ## Type of change - This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/conf.py | 19 ++-- docs/source/api/lab/isaaclab.controllers.rst | 28 +++-- .../api/lab/isaaclab.sim.converters.rst | 16 +++ .../api/lab_tasks/isaaclab_tasks.utils.rst | 2 - .../source/how-to/optimize_stage_creation.rst | 9 +- docs/source/overview/environments.rst | 2 +- .../imitation-learning/teleop_imitation.rst | 6 +- .../01_io_descriptors/io_descriptors_101.rst | 6 +- .../setup/walkthrough/api_env_design.rst | 2 +- .../tutorials/05_controllers/run_diff_ik.rst | 2 +- source/isaaclab/docs/CHANGELOG.rst | 16 +-- .../rigid_object_collection.py | 1 + source/isaaclab/isaaclab/envs/mdp/events.py | 6 +- .../isaaclab/scene/interactive_scene_cfg.py | 18 +-- .../contact_sensor/contact_sensor_data.py | 13 ++- .../isaaclab/isaaclab/sim/simulation_cfg.py | 104 +++++++++++------- source/isaaclab/isaaclab/utils/math.py | 2 + source/isaaclab_rl/isaaclab_rl/rl_games.py | 1 + .../isaaclab_tasks/isaaclab_tasks/__init__.py | 10 +- .../isaaclab_tasks/utils/importer.py | 8 +- .../isaaclab_tasks/utils/parse_cfg.py | 2 + 21 files changed, 178 insertions(+), 95 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 926d0b402071..3bdf99666ed9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -87,6 +87,15 @@ # TODO: Enable this by default once we have fixed all the warnings # nitpicky = True +nitpick_ignore = [ + ("py:obj", "slice(None)"), +] + +nitpick_ignore_regex = [ + (r"py:.*", r"pxr.*"), # we don't have intersphinx mapping for pxr + (r"py:.*", r"trimesh.*"), # we don't have intersphinx mapping for trimesh +] + # put type hints inside the signature instead of the description (easier to maintain) autodoc_typehints = "signature" # autodoc_typehints_format = "fully-qualified" @@ -112,8 +121,9 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), + "trimesh": ("https://trimesh.org/", None), "torch": ("https://pytorch.org/docs/stable/", None), - "isaac": ("https://docs.omniverse.nvidia.com/py/isaacsim", None), + "isaacsim": ("https://docs.isaacsim.omniverse.nvidia.com/5.0.0/py/", None), "gymnasium": ("https://gymnasium.farama.org/", None), "warp": ("https://nvidia.github.io/warp/", None), "dev-guide": ("https://docs.omniverse.nvidia.com/dev-guide/latest", None), @@ -148,13 +158,6 @@ "pxr.PhysxSchema", "pxr.PhysicsSchemaTools", "omni.replicator", - "omni.isaac.core", - "omni.isaac.kit", - "omni.isaac.cloner", - "omni.isaac.urdf", - "omni.isaac.version", - "omni.isaac.motion_generation", - "omni.isaac.ui", "isaacsim", "isaacsim.core.api", "isaacsim.core.cloner", diff --git a/docs/source/api/lab/isaaclab.controllers.rst b/docs/source/api/lab/isaaclab.controllers.rst index 24bfa0b7f836..1ef31448ab86 100644 --- a/docs/source/api/lab/isaaclab.controllers.rst +++ b/docs/source/api/lab/isaaclab.controllers.rst @@ -11,8 +11,8 @@ DifferentialIKControllerCfg OperationalSpaceController OperationalSpaceControllerCfg - PinkIKController - PinkIKControllerCfg + pink_ik.PinkIKController + pink_ik.PinkIKControllerCfg pink_ik.NullSpacePostureTask Differential Inverse Kinematics @@ -43,12 +43,24 @@ Operational Space controllers :show-inheritance: :exclude-members: __init__, class_type -Differential Inverse Kinematics Controllers (Based on Pink) ------------------------------------------------------------ -For detailed documentation of Pink IK controllers and tasks, see: +Pink IK Controller +------------------ -.. toctree:: - :maxdepth: 1 +.. automodule:: isaaclab.controllers.pink_ik - isaaclab.controllers.pink_ik +.. autoclass:: PinkIKController + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: PinkIKControllerCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__, class_type + +Available Pink IK Tasks +^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: NullSpacePostureTask diff --git a/docs/source/api/lab/isaaclab.sim.converters.rst b/docs/source/api/lab/isaaclab.sim.converters.rst index da8dd49c427e..6fd5155c4e53 100644 --- a/docs/source/api/lab/isaaclab.sim.converters.rst +++ b/docs/source/api/lab/isaaclab.sim.converters.rst @@ -13,6 +13,8 @@ MeshConverterCfg UrdfConverter UrdfConverterCfg + MjcfConverter + MjcfConverterCfg Asset Converter Base -------------------- @@ -52,3 +54,17 @@ URDF Converter :inherited-members: :show-inheritance: :exclude-members: __init__ + +MJCF Converter +-------------- + +.. autoclass:: MjcfConverter + :members: + :inherited-members: + :show-inheritance: + +.. autoclass:: MjcfConverterCfg + :members: + :inherited-members: + :show-inheritance: + :exclude-members: __init__ diff --git a/docs/source/api/lab_tasks/isaaclab_tasks.utils.rst b/docs/source/api/lab_tasks/isaaclab_tasks.utils.rst index b653c14e48ef..3ffd8f075bc5 100644 --- a/docs/source/api/lab_tasks/isaaclab_tasks.utils.rst +++ b/docs/source/api/lab_tasks/isaaclab_tasks.utils.rst @@ -4,5 +4,3 @@ .. automodule:: isaaclab_tasks.utils :members: :imported-members: - - .. rubric:: Submodules diff --git a/docs/source/how-to/optimize_stage_creation.rst b/docs/source/how-to/optimize_stage_creation.rst index a64b6bc7eaaf..b262878d6671 100644 --- a/docs/source/how-to/optimize_stage_creation.rst +++ b/docs/source/how-to/optimize_stage_creation.rst @@ -21,7 +21,7 @@ What These Features Do Usage Examples -------------- -Fabric cloning can be toggled by setting the ``clone_in_fabric`` flag in the ``InteractiveSceneCfg`` configuration. +Fabric cloning can be toggled by setting the :attr:`isaaclab.scene.InteractiveSceneCfg.clone_in_fabric` flag. **Using Fabric Cloning with a RL environment** @@ -34,7 +34,7 @@ Fabric cloning can be toggled by setting the ``clone_in_fabric`` flag in the ``I env = ManagerBasedRLEnv(cfg=env_cfg) -Stage in memory can be toggled by setting the ``create_stage_in_memory`` in the ``SimulationCfg`` configuration. +Stage in memory can be toggled by setting the :attr:`isaaclab.sim.SimulationCfg.create_stage_in_memory` flag. **Using Stage in Memory with a RL environment** @@ -48,8 +48,9 @@ Stage in memory can be toggled by setting the ``create_stage_in_memory`` in the env = ManagerBasedRLEnv(cfg=cfg) Note, if stage in memory is enabled without using an existing RL environment class, a few more steps are need. -The stage creation steps should be wrapped in a ``with`` statement to set the stage context. -If the stage needs to be attached, the ``attach_stage_to_usd_context`` function should be called after the stage is created. +The stage creation steps should be wrapped in a :py:keyword:`with` statement to set the stage context. +If the stage needs to be attached, the :meth:`~isaaclab.sim.utils.attach_stage_to_usd_context` function should +be called after the stage is created. **Using Stage in Memory with a manual scene setup** diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 3a5a2cb74e2f..43994eca5476 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -798,7 +798,7 @@ inferencing, including reading from an already trained checkpoint and disabling - - Direct - - * - Isaac-Forge-GearMesh-Direct-v0 + * - Isaac-Forge-GearMesh-Direct-v0 - - Direct - **rl_games** (PPO) diff --git a/docs/source/overview/imitation-learning/teleop_imitation.rst b/docs/source/overview/imitation-learning/teleop_imitation.rst index ac9ff229a865..859287560a84 100644 --- a/docs/source/overview/imitation-learning/teleop_imitation.rst +++ b/docs/source/overview/imitation-learning/teleop_imitation.rst @@ -140,7 +140,7 @@ Pre-recorded demonstrations ^^^^^^^^^^^^^^^^^^^^^^^^^^^ We provide a pre-recorded ``dataset.hdf5`` containing 10 human demonstrations for ``Isaac-Stack-Cube-Franka-IK-Rel-v0`` -`here `_. +`here `__. This dataset may be downloaded and used in the remaining tutorial steps if you do not wish to collect your own demonstrations. .. note:: @@ -451,7 +451,7 @@ Generate the dataset ^^^^^^^^^^^^^^^^^^^^ If you skipped the prior collection and annotation step, download the pre-recorded annotated dataset ``dataset_annotated_gr1.hdf5`` from -`here `_. +`here `__. Place the file under ``IsaacLab/datasets`` and run the following command to generate a new dataset with 1000 demonstrations. .. code:: bash @@ -514,7 +514,7 @@ Demo 2: Visuomotor Policy for a Humanoid Robot Download the Dataset ^^^^^^^^^^^^^^^^^^^^ -Download the pre-generated dataset from `here `_ and place it under ``IsaacLab/datasets/generated_dataset_gr1_nut_pouring.hdf5``. +Download the pre-generated dataset from `here `__ and place it under ``IsaacLab/datasets/generated_dataset_gr1_nut_pouring.hdf5``. The dataset contains 1000 demonstrations of a humanoid robot performing a pouring/placing task that was generated using Isaac Lab Mimic for the ``Isaac-NutPour-GR1T2-Pink-IK-Abs-Mimic-v0`` task. diff --git a/docs/source/policy_deployment/01_io_descriptors/io_descriptors_101.rst b/docs/source/policy_deployment/01_io_descriptors/io_descriptors_101.rst index bcf83fee1f79..d31de818399a 100644 --- a/docs/source/policy_deployment/01_io_descriptors/io_descriptors_101.rst +++ b/docs/source/policy_deployment/01_io_descriptors/io_descriptors_101.rst @@ -219,9 +219,9 @@ Attaching IO Descriptors to Custom Action Terms In this section, we will cover how to attach IO descriptors to custom action terms. Action terms are classes that inherit from the :class:`managers.ActionTerm` class. To add an IO descriptor to an action term, we need to expand -upon its :meth:`ActionTerm.IO_descriptor` property. +upon its :meth:`~managers.ActionTerm.IO_descriptor` property. -By default, the :meth:`ActionTerm.IO_descriptor` property returns the base descriptor and fills the following fields: +By default, the :meth:`~managers.ActionTerm.IO_descriptor` property returns the base descriptor and fills the following fields: - ``name``: The name of the action term. - ``full_path``: The full path of the action term. - ``description``: The description of the action term. @@ -238,7 +238,7 @@ By default, the :meth:`ActionTerm.IO_descriptor` property returns the base descr self._IO_descriptor.export = self.export_IO_descriptor return self._IO_descriptor -To add more information to the descriptor, we need to override the :meth:`ActionTerm.IO_descriptor` property. +To add more information to the descriptor, we need to override the :meth:`~managers.ActionTerm.IO_descriptor` property. Let's take a look at an example on how to add the joint names, scale, offset, and clip to the descriptor. .. code-block:: python diff --git a/docs/source/setup/walkthrough/api_env_design.rst b/docs/source/setup/walkthrough/api_env_design.rst index fecd3c60889e..07471ec2ea5a 100644 --- a/docs/source/setup/walkthrough/api_env_design.rst +++ b/docs/source/setup/walkthrough/api_env_design.rst @@ -58,7 +58,7 @@ are compositional in this way as a solution for cloning arbitrarily complex envi The **sim** is an instance of :class:`SimulationCfg`, and this is the config that controls the nature of the simulated reality we are building. This field is a member of the base class, ``DirecRLEnvCfg``, but has a default sim configuration, so it's *technically* optional. The ``SimulationCfg`` dictates how finely to step through time (dt), the direction of gravity, and even how physics should be simulated. In this case we only specify the time step and the render interval, with the -former indicating that each step through time should simulate :math:`1/120`th of a second, and the latter being how many steps we should take before we render a frame (a value of 2 means +former indicating that each step through time should simulate :math:`1/120` th of a second, and the latter being how many steps we should take before we render a frame (a value of 2 means render every other frame). .. currentmodule:: isaaclab.scene diff --git a/docs/source/tutorials/05_controllers/run_diff_ik.rst b/docs/source/tutorials/05_controllers/run_diff_ik.rst index 2de81057cf6e..dda5568c0f41 100644 --- a/docs/source/tutorials/05_controllers/run_diff_ik.rst +++ b/docs/source/tutorials/05_controllers/run_diff_ik.rst @@ -79,7 +79,7 @@ joint positions, current end-effector pose, and the Jacobian matrix. While the attribute :attr:`assets.ArticulationData.joint_pos` provides the joint positions, we only want the joint positions of the robot's arm, and not the gripper. Similarly, while -the attribute :attr:`assets.Articulationdata.body_state_w` provides the state of all the +the attribute :attr:`assets.ArticulationData.body_state_w` provides the state of all the robot's bodies, we only want the state of the robot's end-effector. Thus, we need to index into these arrays to obtain the desired quantities. diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 72ac0a813266..1578f89be9d7 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -12,7 +12,7 @@ Added Changed ^^^^^^^ -* Added AssetBase inheritance for :class:`~isaaclab.assets.surface_gripper.SurfaceGripper` +* Added AssetBase inheritance for :class:`~isaaclab.assets.surface_gripper.SurfaceGripper`. 0.45.11 (2025-09-04) @@ -36,7 +36,7 @@ Added 0.45.10 (2025-09-02) -~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ @@ -92,7 +92,7 @@ Added 0.45.6 (2025-08-22) -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ @@ -101,7 +101,7 @@ Fixed 0.45.5 (2025-08-21) -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ Fixed ^^^^^ @@ -113,7 +113,7 @@ Fixed 0.45.4 (2025-08-21) -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ Added ^^^^^ @@ -198,9 +198,9 @@ Fixed Fixed ^^^^^ -* Fixed the old termination manager in :class:`~isaaclab.managers.TerminationManager` term_done logging that logs the -instantaneous term done count at reset. This let to inaccurate aggregation of termination count, obscuring the what really -happeningduring the traing. Instead we log the episodic term done. +* Fixed the old termination manager in :class:`~isaaclab.managers.TerminationManager` term_done logging that + logs the instantaneous term done count at reset. This let to inaccurate aggregation of termination count, + obscuring the what really happening during the training. Instead we log the episodic term done. 0.44.9 (2025-07-30) diff --git a/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection.py index 89ff404a4905..363dca41b959 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab/isaaclab/assets/rigid_object_collection/rigid_object_collection.py @@ -505,6 +505,7 @@ def set_external_force_and_torque( all the external wrenches will be applied in the frame specified by the last call. .. code-block:: python + # example of setting external wrench in the global frame asset.set_external_force_and_torque(forces=torch.ones(1, 1, 3), env_ids=[0], is_global=True) # example of setting external wrench in the link frame diff --git a/source/isaaclab/isaaclab/envs/mdp/events.py b/source/isaaclab/isaaclab/envs/mdp/events.py index 637baa9d725d..17c5f582d1e4 100644 --- a/source/isaaclab/isaaclab/envs/mdp/events.py +++ b/source/isaaclab/isaaclab/envs/mdp/events.py @@ -55,9 +55,9 @@ def randomize_rigid_body_scale( If the dictionary does not contain a key, the range is set to one for that axis. Relative child path can be used to randomize the scale of a specific child prim of the asset. - For example, if the asset at prim path expression "/World/envs/env_.*/Object" has a child - with the path "/World/envs/env_.*/Object/mesh", then the relative child path should be "mesh" or - "/mesh". + For example, if the asset at prim path expression ``/World/envs/env_.*/Object`` has a child + with the path ``/World/envs/env_.*/Object/mesh``, then the relative child path should be ``mesh`` or + ``/mesh``. .. attention:: Since this function modifies USD properties that are parsed by the physics engine once the simulation diff --git a/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py b/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py index 6013e7a965d4..2cc472ca074f 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py @@ -107,16 +107,20 @@ class MySceneCfg(InteractiveSceneCfg): .. note:: Collisions can only be filtered automatically in direct workflows when physics replication is enabled. - If ``replicated_physics=False`` and collision filtering is desired, make sure to call ``scene.filter_collisions()``. + If :attr:`replicated_physics` is ``False`` and collision filtering is desired, make sure to call + ``scene.filter_collisions()``. """ clone_in_fabric: bool = False """Enable/disable cloning in fabric. Default is False. - If True, cloning happens through Omniverse fabric, which is a more optimized method for performing cloning in - scene creation. However, this limits flexibility in accessing the stage through USD APIs and instead, the stage - must be accessed through USDRT. - If False, cloning will happen through regular USD APIs. + + Omniverse Fabric is a more optimized method for performing cloning in scene creation. This reduces the time + taken to create the scene. However, it limits flexibility in accessing the stage through USD APIs and instead, + the stage must be accessed through USDRT. + .. note:: - Cloning in fabric can only be enabled if physics replication is also enabled. - If ``replicated_physics=False``, we will automatically default cloning in fabric to be False. + Cloning in fabric can only be enabled if :attr:`replicated_physics` is also enabled. + If :attr:`replicated_physics` is ``False``, cloning in Fabric will automatically + default to ``False``. + """ diff --git a/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_data.py b/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_data.py index 63edc8b127a2..5d08f6058ce8 100644 --- a/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_data.py +++ b/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_data.py @@ -21,6 +21,7 @@ class ContactSensorData: Note: If the :attr:`ContactSensorCfg.track_pose` is False, then this quantity is None. + """ contact_pos_w: torch.Tensor | None = None @@ -29,13 +30,15 @@ class ContactSensorData: Shape is (N, B, M, 3), where N is the number of sensors, B is number of bodies in each sensor and M is the number of filtered bodies. - Collision pairs not in contact will result in nan. + Collision pairs not in contact will result in NaN. Note: - If the :attr:`ContactSensorCfg.track_contact_points` is False, then this quantity is None. - If the :attr:`ContactSensorCfg.filter_prim_paths_expr` is empty, then this quantity is an empty tensor. - If the :attr:`ContactSensorCfg.max_contact_data_per_prim` is not specified or less than 1, then this quantity - will not be calculated. + + * If the :attr:`ContactSensorCfg.track_contact_points` is False, then this quantity is None. + * If the :attr:`ContactSensorCfg.filter_prim_paths_expr` is empty, then this quantity is an empty tensor. + * If the :attr:`ContactSensorCfg.max_contact_data_per_prim` is not specified or less than 1, then this quantity + will not be calculated. + """ quat_w: torch.Tensor | None = None diff --git a/source/isaaclab/isaaclab/sim/simulation_cfg.py b/source/isaaclab/isaaclab/sim/simulation_cfg.py index 2366bc5b654b..205129484a33 100644 --- a/source/isaaclab/isaaclab/sim/simulation_cfg.py +++ b/source/isaaclab/isaaclab/sim/simulation_cfg.py @@ -9,7 +9,7 @@ configuring the environment instances, viewer settings, and simulation parameters. """ -from typing import Literal +from typing import Any, Literal from isaaclab.utils import configclass @@ -165,9 +165,14 @@ class PhysxCfg: class RenderCfg: """Configuration for Omniverse RTX Renderer. - These parameters are used to configure the Omniverse RTX Renderer. The defaults for IsaacLab are set in the - experience files: `apps/isaaclab.python.rendering.kit` and `apps/isaaclab.python.headless.rendering.kit`. Setting any - value here will override the defaults of the experience files. + These parameters are used to configure the Omniverse RTX Renderer. + + The defaults for IsaacLab are set in the experience files: + + * ``apps/isaaclab.python.rendering.kit``: Setting used when running the simulation with the GUI enabled. + * ``apps/isaaclab.python.headless.rendering.kit``: Setting used when running the simulation in headless mode. + + Setting any value here will override the defaults of the experience files. For more information, see the `Omniverse RTX Renderer documentation`_. @@ -177,88 +182,109 @@ class RenderCfg: enable_translucency: bool | None = None """Enables translucency for specular transmissive surfaces such as glass at the cost of some performance. Default is False. - Set variable: /rtx/translucency/enabled + This is set by the variable: ``/rtx/translucency/enabled``. """ enable_reflections: bool | None = None """Enables reflections at the cost of some performance. Default is False. - Set variable: /rtx/reflections/enabled + This is set by the variable: ``/rtx/reflections/enabled``. """ enable_global_illumination: bool | None = None """Enables Diffused Global Illumination at the cost of some performance. Default is False. - Set variable: /rtx/indirectDiffuse/enabled + This is set by the variable: ``/rtx/indirectDiffuse/enabled``. """ antialiasing_mode: Literal["Off", "FXAA", "DLSS", "TAA", "DLAA"] | None = None """Selects the anti-aliasing mode to use. Defaults to DLSS. - - DLSS: Boosts performance by using AI to output higher resolution frames from a lower resolution input. DLSS samples multiple lower resolution images and uses motion data and feedback from prior frames to reconstruct native quality images. - - DLAA: Provides higher image quality with an AI-based anti-aliasing technique. DLAA uses the same Super Resolution technology developed for DLSS, reconstructing a native resolution image to maximize image quality. - Set variable: /rtx/post/dlss/execMode + - **DLSS**: Boosts performance by using AI to output higher resolution frames from a lower resolution input. + DLSS samples multiple lower resolution images and uses motion data and feedback from prior frames to reconstruct + native quality images. + - **DLAA**: Provides higher image quality with an AI-based anti-aliasing technique. DLAA uses the same Super Resolution + technology developed for DLSS, reconstructing a native resolution image to maximize image quality. + + This is set by the variable: ``/rtx/post/dlss/execMode``. """ enable_dlssg: bool | None = None - """"Enables the use of DLSS-G. - DLSS Frame Generation boosts performance by using AI to generate more frames. - DLSS analyzes sequential frames and motion data to create additional high quality frames. - This feature requires an Ada Lovelace architecture GPU. - Enabling this feature also enables additional thread-related activities, which can hurt performance. - Default is False. - - Set variable: /rtx-transient/dlssg/enabled + """"Enables the use of DLSS-G. Default is False. + + DLSS Frame Generation boosts performance by using AI to generate more frames. DLSS analyzes sequential frames + and motion data to create additional high quality frames. + + .. note:: + + This feature requires an Ada Lovelace architecture GPU. Enabling this feature also enables additional + thread-related activities, which can hurt performance. + + This is set by the variable: ``/rtx-transient/dlssg/enabled``. """ enable_dl_denoiser: bool | None = None """Enables the use of a DL denoiser. - The DL denoiser can help improve the quality of renders, but comes at a cost of performance. - Set variable: /rtx-transient/dldenoiser/enabled + The DL denoiser can help improve the quality of renders, but comes at a cost of performance. + + This is set by the variable: ``/rtx-transient/dldenoiser/enabled``. """ dlss_mode: Literal[0, 1, 2, 3] | None = None - """For DLSS anti-aliasing, selects the performance/quality tradeoff mode. - Valid values are 0 (Performance), 1 (Balanced), 2 (Quality), or 3 (Auto). Default is 0. + """For DLSS anti-aliasing, selects the performance/quality tradeoff mode. Default is 0. - Set variable: /rtx/post/dlss/execMode + Valid values are: + + * 0 (Performance) + * 1 (Balanced) + * 2 (Quality) + * 3 (Auto) + + This is set by the variable: ``/rtx/post/dlss/execMode``. """ enable_direct_lighting: bool | None = None - """Enable direct light contributions from lights. + """Enable direct light contributions from lights. Default is False. - Set variable: /rtx/directLighting/enabled + This is set by the variable: ``/rtx/directLighting/enabled``. """ samples_per_pixel: int | None = None - """Defines the Direct Lighting samples per pixel. - Higher values increase the direct lighting quality at the cost of performance. Default is 1. + """Defines the Direct Lighting samples per pixel. Default is 1. - Set variable: /rtx/directLighting/sampledLighting/samplesPerPixel""" + A higher value increases the direct lighting quality at the cost of performance. + + This is set by the variable: ``/rtx/directLighting/sampledLighting/samplesPerPixel``. + """ enable_shadows: bool | None = None - """Enables shadows at the cost of performance. When disabled, lights will not cast shadows. Defaults to True. + """Enables shadows at the cost of performance. Defaults to True. + + When disabled, lights will not cast shadows. - Set variable: /rtx/shadows/enabled + This is set by the variable: ``/rtx/shadows/enabled``. """ enable_ambient_occlusion: bool | None = None """Enables ambient occlusion at the cost of some performance. Default is False. - Set variable: /rtx/ambientOcclusion/enabled + This is set by the variable: ``/rtx/ambientOcclusion/enabled``. """ - carb_settings: dict | None = None - """Provides a general dictionary for users to supply all carb rendering settings with native names. - - Name strings can be formatted like a carb setting, .kit file setting, or python variable. - - For instance, a key value pair can be - /rtx/translucency/enabled: False # carb - rtx.translucency.enabled: False # .kit - rtx_translucency_enabled: False # python""" + carb_settings: dict[str, Any] | None = None + """A general dictionary for users to supply all carb rendering settings with native names. + + The keys of the dictionary can be formatted like a carb setting, .kit file setting, or python variable. + For instance, a key value pair can be ``/rtx/translucency/enabled: False`` (carb), ``rtx.translucency.enabled: False`` (.kit), + or ``rtx_translucency_enabled: False`` (python). + """ rendering_mode: Literal["performance", "balanced", "quality"] | None = None - """Sets the rendering mode. Behaves the same as the CLI arg '--rendering_mode'""" + """The rendering mode. + + This behaves the same as the passing the CLI arg ``--rendering_mode`` to an executable script. + """ @configclass diff --git a/source/isaaclab/isaaclab/utils/math.py b/source/isaaclab/isaaclab/utils/math.py index 61524790feeb..f8ad612a9164 100644 --- a/source/isaaclab/isaaclab/utils/math.py +++ b/source/isaaclab/isaaclab/utils/math.py @@ -682,6 +682,7 @@ def quat_apply_yaw(quat: torch.Tensor, vec: torch.Tensor) -> torch.Tensor: def quat_rotate(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: """Rotate a vector by a quaternion along the last dimension of q and v. + .. deprecated v2.1.0: This function will be removed in a future release in favor of the faster implementation :meth:`quat_apply`. @@ -705,6 +706,7 @@ def quat_rotate_inverse(q: torch.Tensor, v: torch.Tensor) -> torch.Tensor: .. deprecated v2.1.0: This function will be removed in a future release in favor of the faster implementation :meth:`quat_apply_inverse`. + Args: q: The quaternion in (w, x, y, z). Shape is (..., 4). v: The vector in (x, y, z). Shape is (..., 3). diff --git a/source/isaaclab_rl/isaaclab_rl/rl_games.py b/source/isaaclab_rl/isaaclab_rl/rl_games.py index d24dc9d0d846..8c448c172ac4 100644 --- a/source/isaaclab_rl/isaaclab_rl/rl_games.py +++ b/source/isaaclab_rl/isaaclab_rl/rl_games.py @@ -66,6 +66,7 @@ class RlGamesVecEnvWrapper(IVecEnv): The wrapper supports **either** concatenated tensors (default) **or** Dict inputs: when wrapper is concate mode, rl-games sees {"obs": Tensor, (optional)"states": Tensor} when wrapper is not concate mode, rl-games sees {"obs": dict[str, Tensor], (optional)"states": dict[str, Tensor]} + - Concatenated mode (``concate_obs_group=True``): ``observation_space``/``state_space`` are ``gym.spaces.Box``. - Dict mode (``concate_obs_group=False``): ``observation_space``/``state_space`` are ``gym.spaces.Dict`` keyed by the requested groups. When no ``"states"`` groups are provided, the states Dict is omitted at runtime. diff --git a/source/isaaclab_tasks/isaaclab_tasks/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/__init__.py index 5d75948a5204..16871efcb911 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/__init__.py @@ -3,7 +3,15 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Package containing task implementations for various robotic environments.""" +"""Package containing task implementations for various robotic environments. + +The package is structured as follows: + +- ``direct``: These include single-file implementations of tasks. +- ``manager_based``: These include task implementations that use the manager-based API. +- ``utils``: These include utility functions for the tasks. + +""" import os import toml diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/importer.py b/source/isaaclab_tasks/isaaclab_tasks/utils/importer.py index 3bbf151a3063..075feb3c5279 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/importer.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/importer.py @@ -41,6 +41,11 @@ def import_packages(package_name: str, blacklist_pkgs: list[str] | None = None): pass +""" +Internal helpers. +""" + + def _walk_packages( path: str | None = None, prefix: str = "", @@ -51,8 +56,9 @@ def _walk_packages( Note: This function is a modified version of the original ``pkgutil.walk_packages`` function. It adds - the `blacklist_pkgs` argument to skip blacklisted packages. Please refer to the original + the ``blacklist_pkgs`` argument to skip blacklisted packages. Please refer to the original ``pkgutil.walk_packages`` function for more details. + """ if blacklist_pkgs is None: blacklist_pkgs = [] diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py index d56c8721cefd..b4f788a9bcbd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/parse_cfg.py @@ -32,6 +32,7 @@ def load_cfg_from_registry(task_name: str, entry_point_key: str) -> dict | objec kwargs={"env_entry_point_cfg": "path.to.config:ConfigClass"}, ) + The parsed configuration object for above example can be obtained as: .. code-block:: python @@ -40,6 +41,7 @@ def load_cfg_from_registry(task_name: str, entry_point_key: str) -> dict | objec cfg = load_cfg_from_registry("My-Awesome-Task-v0", "env_entry_point_cfg") + Args: task_name: The name of the environment. entry_point_key: The entry point key to resolve the configuration file. From 314e5158647d527866c7d260dd87647444b7cb40 Mon Sep 17 00:00:00 2001 From: Mayank Mittal <12863862+Mayankm96@users.noreply.github.com> Date: Mon, 8 Sep 2025 09:41:50 +0200 Subject: [PATCH 29/47] Moves location of serve file check to the correct module (#3368) # Description Earlier, the function `check_usd_path_with_timeout` was in `sim/utils.py` while all file related operations live in `utils/asset.py`. This MR moves the function to the right location. Fixes # (issue) ## Type of change - Breaking change (fix or feature that would cause existing functionality to not work as expected) - This change requires a documentation update ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- .../sim/spawners/from_files/from_files.py | 2 +- source/isaaclab/isaaclab/sim/utils.py | 235 +++++++----------- source/isaaclab/isaaclab/utils/assets.py | 77 ++++++ source/isaaclab/test/utils/test_assets.py | 13 + 4 files changed, 174 insertions(+), 153 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py index 639fada48b88..a3c8a44015a2 100644 --- a/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py +++ b/source/isaaclab/isaaclab/sim/spawners/from_files/from_files.py @@ -24,11 +24,11 @@ from isaaclab.sim.utils import ( bind_physics_material, bind_visual_material, - check_usd_path_with_timeout, clone, is_current_stage_in_memory, select_usd_variants, ) +from isaaclab.utils.assets import check_usd_path_with_timeout if TYPE_CHECKING: from . import from_files_cfg diff --git a/source/isaaclab/isaaclab/sim/utils.py b/source/isaaclab/isaaclab/sim/utils.py index fa38bc186580..debda3ec807f 100644 --- a/source/isaaclab/isaaclab/sim/utils.py +++ b/source/isaaclab/isaaclab/sim/utils.py @@ -7,13 +7,11 @@ from __future__ import annotations -import asyncio import contextlib import functools import inspect import re -import time -from collections.abc import Callable +from collections.abc import Callable, Generator from typing import TYPE_CHECKING, Any import carb @@ -829,97 +827,6 @@ def find_global_fixed_joint_prim( return fixed_joint_prim -""" -Stage management. -""" - - -def attach_stage_to_usd_context(attaching_early: bool = False): - """Attaches stage in memory to usd context. - - This function should be called during or after scene is created and before stage is simulated or rendered. - - Note: - If the stage is not in memory or rendering is not enabled, this function will return without attaching. - - Args: - attaching_early: Whether to attach the stage to the usd context before stage is created. Defaults to False. - """ - - from isaacsim.core.simulation_manager import SimulationManager - - from isaaclab.sim.simulation_context import SimulationContext - - # if Isaac Sim version is less than 5.0, stage in memory is not supported - isaac_sim_version = float(".".join(get_version()[2])) - if isaac_sim_version < 5: - return - - # if stage is not in memory, we can return early - if not is_current_stage_in_memory(): - return - - # attach stage to physx - stage_id = get_current_stage_id() - physx_sim_interface = omni.physx.get_physx_simulation_interface() - physx_sim_interface.attach_stage(stage_id) - - # this carb flag is equivalent to if rendering is enabled - carb_setting = carb.settings.get_settings() - is_rendering_enabled = get_carb_setting(carb_setting, "/physics/fabricUpdateTransformations") - - # if rendering is not enabled, we don't need to attach it - if not is_rendering_enabled: - return - - # early attach warning msg - if attaching_early: - omni.log.warn( - "Attaching stage in memory to USD context early to support an operation which doesn't support stage in" - " memory." - ) - - # skip this callback to avoid wiping the stage after attachment - SimulationContext.instance().skip_next_stage_open_callback() - - # disable stage open callback to avoid clearing callbacks - SimulationManager.enable_stage_open_callback(False) - - # enable physics fabric - SimulationContext.instance()._physics_context.enable_fabric(True) - - # attach stage to usd context - omni.usd.get_context().attach_stage_with_callback(stage_id) - - # attach stage to physx - physx_sim_interface = omni.physx.get_physx_simulation_interface() - physx_sim_interface.attach_stage(stage_id) - - # re-enable stage open callback - SimulationManager.enable_stage_open_callback(True) - - -def is_current_stage_in_memory() -> bool: - """This function checks if the current stage is in memory. - - Compares the stage id of the current stage with the stage id of the context stage. - - Returns: - If the current stage is in memory. - """ - - # grab current stage id - stage_id = get_current_stage_id() - - # grab context stage id - context_stage = omni.usd.get_context().get_stage() - with use_stage(context_stage): - context_stage_id = get_current_stage_id() - - # check if stage ids are the same - return stage_id != context_stage_id - - """ USD Variants. """ @@ -1001,84 +908,107 @@ class TableVariants: """ -Nucleus Connection +Stage management. """ -async def _is_usd_path_available(usd_path: str, timeout: float) -> bool: - """ - Asynchronously checks whether the given USD path is available on the server. +def attach_stage_to_usd_context(attaching_early: bool = False): + """Attaches the current USD stage in memory to the USD context. - Args: - usd_path: The remote or local USD file path to check. - timeout: Timeout in seconds for the async stat call. + This function should be called during or after scene is created and before stage is simulated or rendered. - Returns: - True if the server responds with OK, False otherwise. + Note: + If the stage is not in memory or rendering is not enabled, this function will return without attaching. + + Args: + attaching_early: Whether to attach the stage to the usd context before stage is created. Defaults to False. """ - try: - result, _ = await asyncio.wait_for(omni.client.stat_async(usd_path), timeout=timeout) - return result == omni.client.Result.OK - except asyncio.TimeoutError: - omni.log.warn(f"Timed out after {timeout}s while checking for USD: {usd_path}") - return False - except Exception as ex: - omni.log.warn(f"Exception during USD file check: {type(ex).__name__}: {ex}") - return False + from isaacsim.core.simulation_manager import SimulationManager -def check_usd_path_with_timeout(usd_path: str, timeout: float = 300, log_interval: float = 30) -> bool: - """ - Synchronously runs an asynchronous USD path availability check, - logging progress periodically until it completes. + from isaaclab.sim.simulation_context import SimulationContext - This is useful for checking server responsiveness before attempting to load a remote asset. - It will block execution until the check completes or times out. + # if Isaac Sim version is less than 5.0, stage in memory is not supported + isaac_sim_version = float(".".join(get_version()[2])) + if isaac_sim_version < 5: + return - Args: - usd_path: The remote USD file path to check. - timeout: Maximum time (in seconds) to wait for the server check. - log_interval: Interval (in seconds) at which progress is logged. + # if stage is not in memory, we can return early + if not is_current_stage_in_memory(): + return - Returns: - True if the file is available (HTTP 200 / OK), False otherwise. - """ - start_time = time.time() - loop = asyncio.get_event_loop() + # attach stage to physx + stage_id = get_current_stage_id() + physx_sim_interface = omni.physx.get_physx_simulation_interface() + physx_sim_interface.attach_stage(stage_id) - coroutine = _is_usd_path_available(usd_path, timeout) - task = asyncio.ensure_future(coroutine) + # this carb flag is equivalent to if rendering is enabled + carb_setting = carb.settings.get_settings() + is_rendering_enabled = get_carb_setting(carb_setting, "/physics/fabricUpdateTransformations") - next_log_time = start_time + log_interval + # if rendering is not enabled, we don't need to attach it + if not is_rendering_enabled: + return - first_log = True - while not task.done(): - now = time.time() - if now >= next_log_time: - elapsed = int(now - start_time) - if first_log: - omni.log.warn(f"Checking server availability for USD path: {usd_path} (timeout: {timeout}s)") - first_log = False - omni.log.warn(f"Waiting for server response... ({elapsed}s elapsed)") - next_log_time += log_interval - loop.run_until_complete(asyncio.sleep(0.1)) # Yield to allow async work + # early attach warning msg + if attaching_early: + omni.log.warn( + "Attaching stage in memory to USD context early to support an operation which doesn't support stage in" + " memory." + ) - return task.result() + # skip this callback to avoid wiping the stage after attachment + SimulationContext.instance().skip_next_stage_open_callback() + # disable stage open callback to avoid clearing callbacks + SimulationManager.enable_stage_open_callback(False) -""" -Isaac Sim stage utils wrappers to enable backwards compatibility to Isaac Sim 4.5 -""" + # enable physics fabric + SimulationContext.instance()._physics_context.enable_fabric(True) + + # attach stage to usd context + omni.usd.get_context().attach_stage_with_callback(stage_id) + + # attach stage to physx + physx_sim_interface = omni.physx.get_physx_simulation_interface() + physx_sim_interface.attach_stage(stage_id) + + # re-enable stage open callback + SimulationManager.enable_stage_open_callback(True) + + +def is_current_stage_in_memory() -> bool: + """Checks if the current stage is in memory. + + This function compares the stage id of the current USD stage with the stage id of the USD context stage. + + Returns: + Whether the current stage is in memory. + """ + + # grab current stage id + stage_id = get_current_stage_id() + + # grab context stage id + context_stage = omni.usd.get_context().get_stage() + with use_stage(context_stage): + context_stage_id = get_current_stage_id() + + # check if stage ids are the same + return stage_id != context_stage_id @contextlib.contextmanager -def use_stage(stage: Usd.Stage) -> None: +def use_stage(stage: Usd.Stage) -> Generator[None, None, None]: """Context manager that sets a thread-local stage, if supported. In Isaac Sim < 5.0, this is a no-op to maintain compatibility. Args: - stage (Usd.Stage): The stage to set temporarily. + stage: The stage to set temporarily. + + Yields: + None """ isaac_sim_version = float(".".join(get_version()[2])) if isaac_sim_version < 5: @@ -1090,10 +1020,10 @@ def use_stage(stage: Usd.Stage) -> None: def create_new_stage_in_memory() -> Usd.Stage: - """Create a new stage in memory, if supported. + """Creates a new stage in memory, if supported. Returns: - The new stage. + The new stage in memory. """ isaac_sim_version = float(".".join(get_version()[2])) if isaac_sim_version < 5: @@ -1107,12 +1037,13 @@ def create_new_stage_in_memory() -> Usd.Stage: def get_current_stage_id() -> int: - """Get the current open stage id. + """Gets the current open stage id. - Reimplementation of stage_utils.get_current_stage_id() for Isaac Sim < 5.0. + This function is a reimplementation of :meth:`isaacsim.core.utils.stage.get_current_stage_id` for + backwards compatibility to Isaac Sim < 5.0. Returns: - int: The stage id. + The current open stage id. """ stage = get_current_stage() stage_cache = UsdUtils.StageCache.Get() diff --git a/source/isaaclab/isaaclab/utils/assets.py b/source/isaaclab/isaaclab/utils/assets.py index 2318a9be55c4..ef61e9f89afd 100644 --- a/source/isaaclab/isaaclab/utils/assets.py +++ b/source/isaaclab/isaaclab/utils/assets.py @@ -13,9 +13,11 @@ .. _Omniverse Nucleus: https://docs.omniverse.nvidia.com/nucleus/latest/overview/overview.html """ +import asyncio import io import os import tempfile +import time from typing import Literal import carb @@ -127,3 +129,78 @@ def read_file(path: str) -> io.BytesIO: return io.BytesIO(memoryview(file_content).tobytes()) else: raise FileNotFoundError(f"Unable to find the file: {path}") + + +""" +Nucleus Connection. +""" + + +def check_usd_path_with_timeout(usd_path: str, timeout: float = 300, log_interval: float = 30) -> bool: + """Checks whether the given USD file path is available on the NVIDIA Nucleus server. + + This function synchronously runs an asynchronous USD path availability check, + logging progress periodically until it completes. The file is available on the server + if the HTTP status code is 200. Otherwise, the file is not available on the server. + + This is useful for checking server responsiveness before attempting to load a remote + asset. It will block execution until the check completes or times out. + + Args: + usd_path: The remote USD file path to check. + timeout: Maximum time (in seconds) to wait for the server check. + log_interval: Interval (in seconds) at which progress is logged. + + Returns: + Whether the given USD path is available on the server. + """ + start_time = time.time() + loop = asyncio.get_event_loop() + + coroutine = _is_usd_path_available(usd_path, timeout) + task = asyncio.ensure_future(coroutine) + + next_log_time = start_time + log_interval + + first_log = True + while not task.done(): + now = time.time() + if now >= next_log_time: + elapsed = int(now - start_time) + if first_log: + omni.log.warn(f"Checking server availability for USD path: {usd_path} (timeout: {timeout}s)") + first_log = False + omni.log.warn(f"Waiting for server response... ({elapsed}s elapsed)") + next_log_time += log_interval + loop.run_until_complete(asyncio.sleep(0.1)) # Yield to allow async work + + return task.result() + + +""" +Helper functions. +""" + + +async def _is_usd_path_available(usd_path: str, timeout: float) -> bool: + """Checks whether the given USD path is available on the Omniverse Nucleus server. + + This function is a asynchronous routine to check the availability of the given USD path on the Omniverse Nucleus server. + It will return True if the USD path is available on the server, False otherwise. + + Args: + usd_path: The remote or local USD file path to check. + timeout: Timeout in seconds for the async stat call. + + Returns: + Whether the given USD path is available on the server. + """ + try: + result, _ = await asyncio.wait_for(omni.client.stat_async(usd_path), timeout=timeout) + return result == omni.client.Result.OK + except asyncio.TimeoutError: + omni.log.warn(f"Timed out after {timeout}s while checking for USD: {usd_path}") + return False + except Exception as ex: + omni.log.warn(f"Exception during USD file check: {type(ex).__name__}: {ex}") + return False diff --git a/source/isaaclab/test/utils/test_assets.py b/source/isaaclab/test/utils/test_assets.py index 71a769ef20aa..fefb44f46c94 100644 --- a/source/isaaclab/test/utils/test_assets.py +++ b/source/isaaclab/test/utils/test_assets.py @@ -37,3 +37,16 @@ def test_check_file_path_invalid(): usd_path = f"{assets_utils.ISAACLAB_NUCLEUS_DIR}/Robots/FrankaEmika/panda_xyz.usd" # check file path assert assets_utils.check_file_path(usd_path) == 0 + + +def test_check_usd_path_with_timeout(): + """Test checking a USD path with timeout.""" + # robot file path + usd_path = f"{assets_utils.ISAACLAB_NUCLEUS_DIR}/Robots/FrankaEmika/panda_instanceable.usd" + # check file path + assert assets_utils.check_usd_path_with_timeout(usd_path) is True + + # invalid file path + usd_path = f"{assets_utils.ISAACLAB_NUCLEUS_DIR}/Robots/FrankaEmika/panda_xyz.usd" + # check file path + assert assets_utils.check_usd_path_with_timeout(usd_path) is False From 2e2c57c0aca88f7426fc7add76b649d234d30deb Mon Sep 17 00:00:00 2001 From: Ziqi Fan Date: Mon, 8 Sep 2025 22:24:04 +0800 Subject: [PATCH 30/47] Deletes unused asset.py in isaaclab (#3389) # Description Deleted the `utils` folder which should not exist ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab/utils/assets.py | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 source/isaaclab/utils/assets.py diff --git a/source/isaaclab/utils/assets.py b/source/isaaclab/utils/assets.py deleted file mode 100644 index 2e924fbf1b13..000000000000 --- a/source/isaaclab/utils/assets.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause From 152eeb2546bd56bd5cbad9e7a0c82f2debb11b39 Mon Sep 17 00:00:00 2001 From: Cathy Li <40371641+cathyliyuanchen@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:18:27 -0700 Subject: [PATCH 31/47] Adds support for manus and vive (#3357) # Description Support getting hand tracking data from manus gloves (joint poses relative to wrists) and vive trackers (wrist poses, calibrated with AVP wrist poses). ## Type of change - New feature (non-breaking change which adds functionality) ## Checklist - [ x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ x] I have made corresponding changes to the documentation - [ x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- CONTRIBUTORS.md | 1 + docs/source/api/lab/isaaclab.devices.rst | 9 + docs/source/how-to/cloudxr_teleoperation.rst | 32 ++ .../teleoperation/teleop_se3_agent.py | 3 +- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 9 + source/isaaclab/isaaclab/devices/__init__.py | 2 +- .../isaaclab/devices/openxr/__init__.py | 1 + .../isaaclab/devices/openxr/manus_vive.py | 248 +++++++++ .../devices/openxr/manus_vive_utils.py | 498 ++++++++++++++++++ .../isaaclab/devices/openxr/openxr_device.py | 3 + .../isaaclab/devices/teleop_device_factory.py | 3 +- .../pick_place/pickplace_gr1t2_env_cfg.py | 14 +- 13 files changed, 819 insertions(+), 6 deletions(-) create mode 100644 source/isaaclab/isaaclab/devices/openxr/manus_vive.py create mode 100644 source/isaaclab/isaaclab/devices/openxr/manus_vive_utils.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index aaef502a2e82..73893d8217a6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -53,6 +53,7 @@ Guidelines for modifications: * Brian Bingham * Cameron Upright * Calvin Yu +* Cathy Y. Li * Cheng-Rong Lai * Chenyu Yang * Connor Smith diff --git a/docs/source/api/lab/isaaclab.devices.rst b/docs/source/api/lab/isaaclab.devices.rst index 3d0eb1da801d..1a2ed776d3b0 100644 --- a/docs/source/api/lab/isaaclab.devices.rst +++ b/docs/source/api/lab/isaaclab.devices.rst @@ -16,6 +16,7 @@ Se2SpaceMouse Se3SpaceMouse OpenXRDevice + ManusVive isaaclab.devices.openxr.retargeters.GripperRetargeter isaaclab.devices.openxr.retargeters.Se3AbsRetargeter isaaclab.devices.openxr.retargeters.Se3RelRetargeter @@ -86,6 +87,14 @@ OpenXR :inherited-members: :show-inheritance: +Manus + Vive +------------ + +.. autoclass:: ManusVive + :members: + :inherited-members: + :show-inheritance: + Retargeters ----------- diff --git a/docs/source/how-to/cloudxr_teleoperation.rst b/docs/source/how-to/cloudxr_teleoperation.rst index c4523acbeb6a..0b7c8c9c017c 100644 --- a/docs/source/how-to/cloudxr_teleoperation.rst +++ b/docs/source/how-to/cloudxr_teleoperation.rst @@ -390,6 +390,38 @@ Back on your Apple Vision Pro: and build teleoperation and imitation learning workflows with Isaac Lab. +.. _manus-vive-handtracking: + +Manus + Vive Hand Tracking +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Manus gloves and HTC Vive trackers can provide hand tracking when optical hand tracking from a headset is occluded. +This setup expects Manus gloves with a Manus SDK license and Vive trackers attached to the gloves. +Requires Isaac Sim >=5.1. + +Run the teleoperation example with Manus + Vive tracking: + +.. code-block:: bash + + ./isaaclab.sh -p scripts/environments/teleoperation/teleop_se3_agent.py \ + --task Isaac-PickPlace-GR1T2-Abs-v0 \ + --teleop_device manusvive \ + --xr \ + --enable_pinocchio + +Begin the session with your palms facing up. +This is necessary for calibrating Vive tracker poses using Apple Vision Pro wrist poses from a few initial frames, +as the Vive trackers attached to the back of the hands occlude the optical hand tracking. + +.. note:: + + To avoid resource contention and crashes, ensure Manus and Vive devices are connected to different USB controllers/buses. + Use ``lsusb -t`` to identify different buses and connect devices accordingly. + + Vive trackers are automatically calculated to map to the left and right wrist joints. + This auto-mapping calculation supports up to 2 Vive trackers; + if more than 2 Vive trackers are detected, it uses the first two trackers detected for calibration, which may not be correct. + .. _develop-xr-isaac-lab: Develop for XR in Isaac Lab diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index cb0f151380ba..021ee5ff80ff 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -19,8 +19,7 @@ "--teleop_device", type=str, default="keyboard", - choices=["keyboard", "spacemouse", "gamepad", "handtracking"], - help="Device for interacting with environment", + help="Device for interacting with environment. Examples: keyboard, spacemouse, gamepad, handtracking, manusvive", ) parser.add_argument("--task", type=str, default=None, help="Name of the task.") parser.add_argument("--sensitivity", type=float, default=1.0, help="Sensitivity factor.") diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 792634e10528..8b426e2d302b 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.45.12" +version = "0.45.13" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 1578f89be9d7..6789061e9143 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +0.45.13 (2025-09-08) +~~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab.devices.openxr.manus_vive.ManusVive` to support teleoperation with Manus gloves and Vive trackers. + + 0.45.12 (2025-09-05) ~~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/devices/__init__.py b/source/isaaclab/isaaclab/devices/__init__.py index 41dd348d53fd..718695e3503a 100644 --- a/source/isaaclab/isaaclab/devices/__init__.py +++ b/source/isaaclab/isaaclab/devices/__init__.py @@ -22,7 +22,7 @@ from .device_base import DeviceBase, DeviceCfg, DevicesCfg from .gamepad import Se2Gamepad, Se2GamepadCfg, Se3Gamepad, Se3GamepadCfg from .keyboard import Se2Keyboard, Se2KeyboardCfg, Se3Keyboard, Se3KeyboardCfg -from .openxr import OpenXRDevice, OpenXRDeviceCfg +from .openxr import ManusVive, ManusViveCfg, OpenXRDevice, OpenXRDeviceCfg from .retargeter_base import RetargeterBase, RetargeterCfg from .spacemouse import Se2SpaceMouse, Se2SpaceMouseCfg, Se3SpaceMouse, Se3SpaceMouseCfg from .teleop_device_factory import create_teleop_device diff --git a/source/isaaclab/isaaclab/devices/openxr/__init__.py b/source/isaaclab/isaaclab/devices/openxr/__init__.py index eaa2ccc42f04..e7bc0cfda038 100644 --- a/source/isaaclab/isaaclab/devices/openxr/__init__.py +++ b/source/isaaclab/isaaclab/devices/openxr/__init__.py @@ -5,5 +5,6 @@ """Keyboard device for SE(2) and SE(3) control.""" +from .manus_vive import ManusVive, ManusViveCfg from .openxr_device import OpenXRDevice, OpenXRDeviceCfg from .xr_cfg import XrCfg, remove_camera_configs diff --git a/source/isaaclab/isaaclab/devices/openxr/manus_vive.py b/source/isaaclab/isaaclab/devices/openxr/manus_vive.py new file mode 100644 index 000000000000..4dda777843f2 --- /dev/null +++ b/source/isaaclab/isaaclab/devices/openxr/manus_vive.py @@ -0,0 +1,248 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +""" +Manus and Vive for teleoperation and interaction. +""" + +import contextlib +import numpy as np +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum + +import carb +from isaacsim.core.version import get_version + +from isaaclab.devices.openxr.common import HAND_JOINT_NAMES +from isaaclab.devices.retargeter_base import RetargeterBase + +from ..device_base import DeviceBase, DeviceCfg +from .openxr_device import OpenXRDevice +from .xr_cfg import XrCfg + +# For testing purposes, we need to mock the XRCore +XRCore = None + +with contextlib.suppress(ModuleNotFoundError): + from omni.kit.xr.core import XRCore + +from isaacsim.core.prims import SingleXFormPrim + +from .manus_vive_utils import HAND_JOINT_MAP, ManusViveIntegration + + +@dataclass +class ManusViveCfg(DeviceCfg): + """Configuration for Manus and Vive.""" + + xr_cfg: XrCfg | None = None + + +class ManusVive(DeviceBase): + """Manus gloves and Vive trackers for teleoperation and interaction. + + This device tracks hand joints using Manus gloves and Vive trackers and makes them available as: + + 1. A dictionary of tracking data (when used without retargeters) + 2. Retargeted commands for robot control (when retargeters are provided) + + The user needs to install the Manus SDK and add `{path_to_manus_sdk}/manus_sdk/lib` to `LD_LIBRARY_PATH`. + Data are acquired by `ManusViveIntegration` from `isaaclab.devices.openxr.manus_vive_utils`, including + + * Vive tracker poses in scene frame, calibrated from AVP wrist poses. + * Hand joints calculated from Vive wrist joints and Manus hand joints (relative to wrist). + * Vive trackers are automatically mapped to the left and right wrist joints. + + Raw data format (_get_raw_data output): consistent with :class:`OpenXRDevice`. + Joint names are defined in `HAND_JOINT_MAP` from `isaaclab.devices.openxr.manus_vive_utils`. + + Teleop commands: consistent with :class:`OpenXRDevice`. + + The device tracks the left hand, right hand, head position, or any combination of these + based on the TrackingTarget enum values. When retargeters are provided, the raw tracking + data is transformed into robot control commands suitable for teleoperation. + """ + + class TrackingTarget(Enum): + """Enum class specifying what to track with Manus+Vive. Consistent with :class:`OpenXRDevice.TrackingTarget`.""" + + HAND_LEFT = 0 + HAND_RIGHT = 1 + HEAD = 2 + + TELEOP_COMMAND_EVENT_TYPE = "teleop_command" + + def __init__(self, cfg: ManusViveCfg, retargeters: list[RetargeterBase] | None = None): + """Initialize the Manus+Vive device. + + Args: + cfg: Configuration object for Manus+Vive settings. + retargeters: List of retargeter instances to use for transforming raw tracking data. + """ + super().__init__(retargeters) + # Enforce minimum Isaac Sim version (>= 5.1) + version_info = get_version() + major, minor = int(version_info[2]), int(version_info[3]) + if (major < 5) or (major == 5 and minor < 1): + raise RuntimeError(f"ManusVive requires Isaac Sim >= 5.1. Detected version {major}.{minor}. ") + self._xr_cfg = cfg.xr_cfg or XrCfg() + self._additional_callbacks = dict() + self._vc_subscription = ( + XRCore.get_singleton() + .get_message_bus() + .create_subscription_to_pop_by_type( + carb.events.type_from_string(self.TELEOP_COMMAND_EVENT_TYPE), self._on_teleop_command + ) + ) + self._manus_vive = ManusViveIntegration() + + # Initialize dictionaries instead of arrays + default_pose = np.array([0, 0, 0, 1, 0, 0, 0], dtype=np.float32) + self._previous_joint_poses_left = {name: default_pose.copy() for name in HAND_JOINT_NAMES} + self._previous_joint_poses_right = {name: default_pose.copy() for name in HAND_JOINT_NAMES} + self._previous_headpose = default_pose.copy() + + xr_anchor = SingleXFormPrim("/XRAnchor", position=self._xr_cfg.anchor_pos, orientation=self._xr_cfg.anchor_rot) + carb.settings.get_settings().set_float("/persistent/xr/profile/ar/render/nearPlane", self._xr_cfg.near_plane) + carb.settings.get_settings().set_string("/persistent/xr/profile/ar/anchorMode", "custom anchor") + carb.settings.get_settings().set_string("/xrstage/profile/ar/customAnchor", xr_anchor.prim_path) + + def __del__(self): + """Clean up resources when the object is destroyed. + Properly unsubscribes from the XR message bus to prevent memory leaks + and resource issues when the device is no longer needed. + """ + if hasattr(self, "_vc_subscription") and self._vc_subscription is not None: + self._vc_subscription = None + + # No need to explicitly clean up OpenXR instance as it's managed by NVIDIA Isaac Sim + + def __str__(self) -> str: + """Provide details about the device configuration, tracking settings, + and available gesture commands. + + Returns: + Formatted string with device information. + """ + + msg = f"Manus+Vive Hand Tracking Device: {self.__class__.__name__}\n" + msg += f"\tAnchor Position: {self._xr_cfg.anchor_pos}\n" + msg += f"\tAnchor Rotation: {self._xr_cfg.anchor_rot}\n" + + # Add retargeter information + if self._retargeters: + msg += "\tRetargeters:\n" + for i, retargeter in enumerate(self._retargeters): + msg += f"\t\t{i + 1}. {retargeter.__class__.__name__}\n" + else: + msg += "\tRetargeters: None (raw joint data output)\n" + + # Add available gesture commands + msg += "\t----------------------------------------------\n" + msg += "\tAvailable Gesture Commands:\n" + + # Check which callbacks are registered + start_avail = "START" in self._additional_callbacks + stop_avail = "STOP" in self._additional_callbacks + reset_avail = "RESET" in self._additional_callbacks + + msg += f"\t\tStart Teleoperation: {'✓' if start_avail else '✗'}\n" + msg += f"\t\tStop Teleoperation: {'✓' if stop_avail else '✗'}\n" + msg += f"\t\tReset Environment: {'✓' if reset_avail else '✗'}\n" + + # Add joint tracking information + msg += "\t----------------------------------------------\n" + msg += "\tTracked Joints: 26 XR hand joints including:\n" + msg += "\t\t- Wrist, palm\n" + msg += "\t\t- Thumb (tip, intermediate joints)\n" + msg += "\t\t- Fingers (tip, distal, intermediate, proximal)\n" + + return msg + + def reset(self): + """Reset cached joint and head poses.""" + default_pose = np.array([0, 0, 0, 1, 0, 0, 0], dtype=np.float32) + self._previous_joint_poses_left = {name: default_pose.copy() for name in HAND_JOINT_NAMES} + self._previous_joint_poses_right = {name: default_pose.copy() for name in HAND_JOINT_NAMES} + self._previous_headpose = default_pose.copy() + + def add_callback(self, key: str, func: Callable): + """Register a callback for a given key. + + Args: + key: The message key to bind ('START', 'STOP', 'RESET'). + func: The function to invoke when the message key is received. + """ + self._additional_callbacks[key] = func + + def _get_raw_data(self) -> dict: + """Get the latest tracking data from Manus and Vive. + + Returns: + Dictionary with TrackingTarget enum keys (HAND_LEFT, HAND_RIGHT, HEAD) containing: + - Left hand joint poses: Dictionary of 26 joints with position and orientation + - Right hand joint poses: Dictionary of 26 joints with position and orientation + - Head pose: Single 7-element array with position and orientation + + Each pose is represented as a 7-element array: [x, y, z, qw, qx, qy, qz] + where the first 3 elements are position and the last 4 are quaternion orientation. + """ + hand_tracking_data = self._manus_vive.get_all_device_data()["manus_gloves"] + result = {"left": self._previous_joint_poses_left, "right": self._previous_joint_poses_right} + for joint, pose in hand_tracking_data.items(): + hand, index = joint.split("_") + joint_name = HAND_JOINT_MAP[int(index)] + result[hand][joint_name] = np.array(pose["position"] + pose["orientation"], dtype=np.float32) + return { + OpenXRDevice.TrackingTarget.HAND_LEFT: result["left"], + OpenXRDevice.TrackingTarget.HAND_RIGHT: result["right"], + OpenXRDevice.TrackingTarget.HEAD: self._calculate_headpose(), + } + + def _calculate_headpose(self) -> np.ndarray: + """Calculate the head pose from OpenXR. + + Returns: + 7-element numpy.ndarray [x, y, z, qw, qx, qy, qz]. + """ + head_device = XRCore.get_singleton().get_input_device("/user/head") + if head_device: + hmd = head_device.get_virtual_world_pose("") + position = hmd.ExtractTranslation() + quat = hmd.ExtractRotationQuat() + quati = quat.GetImaginary() + quatw = quat.GetReal() + + # Store in w, x, y, z order to match our convention + self._previous_headpose = np.array([ + position[0], + position[1], + position[2], + quatw, + quati[0], + quati[1], + quati[2], + ]) + + return self._previous_headpose + + def _on_teleop_command(self, event: carb.events.IEvent): + """Handle teleoperation command events. + + Args: + event: The XR message-bus event containing a 'message' payload. + """ + msg = event.payload["message"] + + if "start" in msg: + if "START" in self._additional_callbacks: + self._additional_callbacks["START"]() + elif "stop" in msg: + if "STOP" in self._additional_callbacks: + self._additional_callbacks["STOP"]() + elif "reset" in msg: + if "RESET" in self._additional_callbacks: + self._additional_callbacks["RESET"]() diff --git a/source/isaaclab/isaaclab/devices/openxr/manus_vive_utils.py b/source/isaaclab/isaaclab/devices/openxr/manus_vive_utils.py new file mode 100644 index 000000000000..c58e32fa0d23 --- /dev/null +++ b/source/isaaclab/isaaclab/devices/openxr/manus_vive_utils.py @@ -0,0 +1,498 @@ +# Copyright (c) 2022-2025, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import contextlib +import numpy as np +from time import time + +import carb +from isaacsim.core.utils.extensions import enable_extension + +# For testing purposes, we need to mock the XRCore +XRCore, XRPoseValidityFlags = None, None + +with contextlib.suppress(ModuleNotFoundError): + from omni.kit.xr.core import XRCore, XRPoseValidityFlags + +from pxr import Gf + +# Mapping from Manus joint index (0-24) to joint name. Palm (25) is calculated from middle metacarpal and proximal. +HAND_JOINT_MAP = { + # Palm + 25: "palm", + # Wrist + 0: "wrist", + # Thumb + 21: "thumb_metacarpal", + 22: "thumb_proximal", + 23: "thumb_distal", + 24: "thumb_tip", + # Index + 1: "index_metacarpal", + 2: "index_proximal", + 3: "index_intermediate", + 4: "index_distal", + 5: "index_tip", + # Middle + 6: "middle_metacarpal", + 7: "middle_proximal", + 8: "middle_intermediate", + 9: "middle_distal", + 10: "middle_tip", + # Ring + 11: "ring_metacarpal", + 12: "ring_proximal", + 13: "ring_intermediate", + 14: "ring_distal", + 15: "ring_tip", + # Little + 16: "little_metacarpal", + 17: "little_proximal", + 18: "little_intermediate", + 19: "little_distal", + 20: "little_tip", +} + + +class ManusViveIntegration: + def __init__(self): + enable_extension("isaacsim.xr.input_devices") + from isaacsim.xr.input_devices.impl.manus_vive_integration import get_manus_vive_integration + + _manus_vive_integration = get_manus_vive_integration() + self.manus = _manus_vive_integration.manus_tracker + self.vive_tracker = _manus_vive_integration.vive_tracker + self.device_status = _manus_vive_integration.device_status + self.default_pose = {"position": [0, 0, 0], "orientation": [1, 0, 0, 0]} + # 90-degree ccw rotation on Y-axis and 90-degree ccw rotation on Z-axis + self.rot_adjust = Gf.Matrix3d().SetRotate(Gf.Quatd(0.5, Gf.Vec3d(-0.5, 0.5, 0.5))) + self.scene_T_lighthouse_static = None + self._vive_left_id = None + self._vive_right_id = None + self._pairA_candidates = [] # Pair A: WM0->Left, WM1->Right + self._pairB_candidates = [] # Pair B: WM1->Left, WM0->Right + self._pairA_trans_errs = [] + self._pairA_rot_errs = [] + self._pairB_trans_errs = [] + self._pairB_rot_errs = [] + + def get_all_device_data(self) -> dict: + """Get all tracked device data in scene coordinates. + + Returns: + Manus glove joint data and Vive tracker data. + { + 'manus_gloves': { + '{left/right}_{joint_index}': { + 'position': [x, y, z], + 'orientation': [w, x, y, z] + }, + ... + }, + 'vive_trackers': { + '{vive_tracker_id}': { + 'position': [x, y, z], + 'orientation': [w, x, y, z] + }, + ... + } + } + """ + self.update_manus() + self.update_vive() + # Get raw data from trackers + manus_data = self.manus.get_data() + vive_data = self.vive_tracker.get_data() + vive_transformed = self._transform_vive_data(vive_data) + scene_T_wrist = self._get_scene_T_wrist_matrix(vive_transformed) + + return { + "manus_gloves": self._transform_manus_data(manus_data, scene_T_wrist), + "vive_trackers": vive_transformed, + } + + def get_device_status(self) -> dict: + """Get connection and data freshness status for Manus gloves and Vive trackers. + + Returns: + Dictionary containing connection flags and last-data timestamps. + Format: { + 'manus_gloves': {'connected': bool, 'last_data_time': float}, + 'vive_trackers': {'connected': bool, 'last_data_time': float}, + 'left_hand_connected': bool, + 'right_hand_connected': bool + } + """ + return self.device_status + + def update_manus(self): + """Update raw Manus glove data and status flags.""" + self.manus.update() + self.device_status["manus_gloves"]["last_data_time"] = time() + manus_data = self.manus.get_data() + self.device_status["left_hand_connected"] = "left_0" in manus_data + self.device_status["right_hand_connected"] = "right_0" in manus_data + + def update_vive(self): + """Update raw Vive tracker data, and initialize coordinate transformation if it is the first data update.""" + self.vive_tracker.update() + self.device_status["vive_trackers"]["last_data_time"] = time() + try: + # Initialize coordinate transformation from first Vive wrist position + if self.scene_T_lighthouse_static is None: + self._initialize_coordinate_transformation() + except Exception as e: + carb.log_error(f"Vive tracker update failed: {e}") + + def _initialize_coordinate_transformation(self): + """ + Initialize the scene to lighthouse coordinate transformation. + The coordinate transformation is used to transform the wrist pose from lighthouse coordinate system to isaac sim scene coordinate. + It is computed from multiple frames of AVP/OpenXR wrist pose and Vive wrist pose samples at the beginning of the session. + """ + min_frames = 6 + tolerance = 3.0 + vive_data = self.vive_tracker.get_data() + wm0_id, wm1_id = get_vive_wrist_ids(vive_data) + if wm0_id is None and wm1_id is None: + return + + try: + # Fetch OpenXR wrists + L, R, gloves = None, None, [] + if self.device_status["left_hand_connected"]: + gloves.append("left") + L = get_openxr_wrist_matrix("left") + if self.device_status["right_hand_connected"]: + gloves.append("right") + R = get_openxr_wrist_matrix("right") + + M0, M1, vives = None, None, [] + if wm0_id is not None: + vives.append(wm0_id) + M0 = pose_to_matrix(vive_data[wm0_id]) + if wm1_id is not None: + vives.append(wm1_id) + M1 = pose_to_matrix(vive_data[wm1_id]) + + TL0, TL1, TR0, TR1 = None, None, None, None + # Compute transforms for available pairs + if wm0_id is not None and L is not None: + TL0 = M0.GetInverse() * L + self._pairA_candidates.append(TL0) + if wm1_id is not None and L is not None: + TL1 = M1.GetInverse() * L + self._pairB_candidates.append(TL1) + if wm1_id is not None and R is not None: + TR1 = M1.GetInverse() * R + self._pairA_candidates.append(TR1) + if wm0_id is not None and R is not None: + TR0 = M0.GetInverse() * R + self._pairB_candidates.append(TR0) + + # Per-frame pairing error if both candidates present + if TL0 is not None and TR1 is not None and TL1 is not None and TR0 is not None: + eT, eR = compute_delta_errors(TL0, TR1) + self._pairA_trans_errs.append(eT) + self._pairA_rot_errs.append(eR) + eT, eR = compute_delta_errors(TL1, TR0) + self._pairB_trans_errs.append(eT) + self._pairB_rot_errs.append(eR) + + # Choose a mapping + choose_A = None + if len(self._pairA_candidates) == 0 and len(self._pairB_candidates) >= min_frames: + choose_A = False + elif len(self._pairB_candidates) == 0 and len(self._pairA_candidates) >= min_frames: + choose_A = True + elif len(self._pairA_trans_errs) >= min_frames and len(self._pairB_trans_errs) >= min_frames: + errA = get_pairing_error(self._pairA_trans_errs, self._pairA_rot_errs) + errB = get_pairing_error(self._pairB_trans_errs, self._pairB_rot_errs) + if errA < errB and errA < tolerance: + choose_A = True + elif errB < errA and errB < tolerance: + choose_A = False + if choose_A is None: + carb.log_info(f"error A: {errA}, error B: {errB}") + return + + if choose_A: + chosen_list = self._pairA_candidates + self._vive_left_id, self._vive_right_id = wm0_id, wm1_id + else: + chosen_list = self._pairB_candidates + self._vive_left_id, self._vive_right_id = wm1_id, wm0_id + + if len(chosen_list) >= min_frames: + cluster = select_mode_cluster(chosen_list) + carb.log_info(f"Wrist calibration: formed size {len(cluster)} cluster from {len(chosen_list)} samples") + if len(cluster) >= min_frames // 2: + averaged = average_transforms(cluster) + self.scene_T_lighthouse_static = averaged + carb.log_info(f"Resolved mapping: {self._vive_left_id}->Left, {self._vive_right_id}->Right") + + except Exception as e: + carb.log_error(f"Failed to initialize coordinate transformation: {e}") + + def _transform_vive_data(self, device_data: dict) -> dict: + """Transform Vive tracker poses to scene coordinates. + + Args: + device_data: raw vive tracker poses, with device id as keys. + + Returns: + Vive tracker poses in scene coordinates, with device id as keys. + """ + transformed_data = {} + for joint_name, joint_data in device_data.items(): + transformed_pose = self.default_pose + if self.scene_T_lighthouse_static is not None: + transformed_matrix = pose_to_matrix(joint_data) * self.scene_T_lighthouse_static + transformed_pose = matrix_to_pose(transformed_matrix) + transformed_data[joint_name] = transformed_pose + return transformed_data + + def _get_scene_T_wrist_matrix(self, vive_data: dict) -> dict: + """Compute scene-frame wrist transforms for left and right hands. + + Args: + vive_data: Vive tracker poses expressed in scene coordinates. + + Returns: + Dictionary with 'left' and 'right' keys mapping to 4x4 transforms. + """ + scene_T_wrist = {"left": Gf.Matrix4d().SetIdentity(), "right": Gf.Matrix4d().SetIdentity()} + # 10 cm offset on Y-axis for change in vive tracker position after flipping the palm + Rcorr = Gf.Matrix4d(self.rot_adjust, Gf.Vec3d(0, -0.1, 0)) + if self._vive_left_id is not None: + scene_T_wrist["left"] = Rcorr * pose_to_matrix(vive_data[self._vive_left_id]) + if self._vive_right_id is not None: + scene_T_wrist["right"] = Rcorr * pose_to_matrix(vive_data[self._vive_right_id]) + return scene_T_wrist + + def _transform_manus_data(self, manus_data: dict, scene_T_wrist: dict) -> dict: + """Transform Manus glove joints from wrist-relative to scene coordinates. + + Args: + manus_data: Raw Manus joint pose dictionary, wrist-relative. + scene_T_wrist: Dictionary of scene transforms for left and right wrists. + + Returns: + Dictionary of Manus joint poses in scene coordinates. + """ + Rcorr = Gf.Matrix4d(self.rot_adjust, Gf.Vec3d(0, 0, 0)).GetInverse() + transformed_data = {} + for joint_name, joint_data in manus_data.items(): + hand, _ = joint_name.split("_") + joint_mat = Rcorr * pose_to_matrix(joint_data) * scene_T_wrist[hand] + transformed_data[joint_name] = matrix_to_pose(joint_mat) + # Calculate palm with middle metacarpal and proximal + transformed_data["left_25"] = self._get_palm(transformed_data, "left") + transformed_data["right_25"] = self._get_palm(transformed_data, "right") + return transformed_data + + def _get_palm(self, transformed_data: dict, hand: str) -> dict: + """Compute palm pose from middle metacarpal and proximal joints. + + Args: + transformed_data: Manus joint poses in scene coordinates. + hand: The hand side, either 'left' or 'right'. + + Returns: + Pose dictionary with 'position' and 'orientation'. + """ + if f"{hand}_6" not in transformed_data or f"{hand}_7" not in transformed_data: + carb.log_error(f"Joint data not found for {hand}") + return self.default_pose + metacarpal = transformed_data[f"{hand}_6"] + proximal = transformed_data[f"{hand}_7"] + pos = (np.array(metacarpal["position"]) + np.array(proximal["position"])) / 2.0 + return {"position": [pos[0], pos[1], pos[2]], "orientation": metacarpal["orientation"]} + + +def compute_delta_errors(a: Gf.Matrix4d, b: Gf.Matrix4d) -> tuple[float, float]: + """Compute translation and rotation error between two transforms. + + Args: + a: The first transform. + b: The second transform. + + Returns: + Tuple containing (translation_error_m, rotation_error_deg). + """ + try: + delta = a * b.GetInverse() + t = delta.ExtractTranslation() + trans_err = float(np.linalg.norm([t[0], t[1], t[2]])) + q = delta.ExtractRotation().GetQuat() + w = float(max(min(q.GetReal(), 1.0), -1.0)) + ang = 2.0 * float(np.arccos(w)) + ang_deg = float(np.degrees(ang)) + if ang_deg > 180.0: + ang_deg = 360.0 - ang_deg + return trans_err, ang_deg + except Exception: + return float("inf"), float("inf") + + +def average_transforms(mats: list[Gf.Matrix4d]) -> Gf.Matrix4d: + """Average rigid transforms across translations and quaternions. + + Args: + mats: The list of 4x4 transforms to average. + + Returns: + Averaged 4x4 transform, or None if the list is empty. + """ + if not mats: + return None + ref_quat = mats[0].ExtractRotation().GetQuat() + ref = np.array([ref_quat.GetReal(), *ref_quat.GetImaginary()]) + acc_q = np.zeros(4, dtype=np.float64) + acc_t = np.zeros(3, dtype=np.float64) + for m in mats: + t = m.ExtractTranslation() + acc_t += np.array([t[0], t[1], t[2]], dtype=np.float64) + q = m.ExtractRotation().GetQuat() + qi = np.array([q.GetReal(), *q.GetImaginary()], dtype=np.float64) + if np.dot(qi, ref) < 0.0: + qi = -qi + acc_q += qi + mean_t = acc_t / float(len(mats)) + norm = np.linalg.norm(acc_q) + if norm <= 1e-12: + quat_avg = Gf.Quatd(1.0, Gf.Vec3d(0.0, 0.0, 0.0)) + else: + qn = acc_q / norm + quat_avg = Gf.Quatd(float(qn[0]), Gf.Vec3d(float(qn[1]), float(qn[2]), float(qn[3]))) + rot3 = Gf.Matrix3d().SetRotate(quat_avg) + trans = Gf.Vec3d(float(mean_t[0]), float(mean_t[1]), float(mean_t[2])) + return Gf.Matrix4d(rot3, trans) + + +def select_mode_cluster( + mats: list[Gf.Matrix4d], trans_thresh_m: float = 0.03, rot_thresh_deg: float = 10.0 +) -> list[Gf.Matrix4d]: + """Select the largest cluster of transforms under proximity thresholds. + + Args: + mats: The list of 4x4 transforms to cluster. + trans_thresh_m: The translation threshold in meters. + rot_thresh_deg: The rotation threshold in degrees. + + Returns: + The largest cluster (mode) of transforms. + """ + if not mats: + return [] + best_cluster: list[Gf.Matrix4d] = [] + for center in mats: + cluster: list[Gf.Matrix4d] = [] + for m in mats: + trans_err, rot_err = compute_delta_errors(m, center) + if trans_err <= trans_thresh_m and rot_err <= rot_thresh_deg: + cluster.append(m) + if len(cluster) > len(best_cluster): + best_cluster = cluster + return best_cluster + + +def get_openxr_wrist_matrix(hand: str) -> Gf.Matrix4d: + """Get the OpenXR wrist matrix if valid. + + Args: + hand: The hand side ('left' or 'right'). + + Returns: + 4x4 transform for the wrist if valid, otherwise None. + """ + hand = hand.lower() + try: + hand_device = XRCore.get_singleton().get_input_device(f"/user/hand/{hand}") + if hand_device is None: + return None + joints = hand_device.get_all_virtual_world_poses() + if "wrist" not in joints: + return None + joint = joints["wrist"] + required = XRPoseValidityFlags.POSITION_VALID | XRPoseValidityFlags.ORIENTATION_VALID + if (joint.validity_flags & required) != required: + return None + return joint.pose_matrix + except Exception as e: + carb.log_warn(f"OpenXR {hand} wrist fetch failed: {e}") + return None + + +def get_vive_wrist_ids(vive_data: dict) -> tuple[str, str]: + """Get the Vive wrist tracker IDs if available. + + Args: + vive_data: The raw Vive data dictionary. + + Returns: + (wm0_id, wm1_id) if available, otherwise None values. + """ + wm_ids = [k for k in vive_data.keys() if len(k) >= 2 and k[:2] == "WM"] + wm_ids.sort() + if len(wm_ids) >= 2: # Assumes the first two vive trackers are the wrist trackers + return wm_ids[0], wm_ids[1] + if len(wm_ids) == 1: + return wm_ids[0], None + return None, None + + +def pose_to_matrix(pose: dict) -> Gf.Matrix4d: + """Convert a pose dictionary to a 4x4 transform matrix. + + Args: + pose: The pose with 'position' and 'orientation' fields. + + Returns: + A 4x4 transform representing the pose. + """ + pos, ori = pose["position"], pose["orientation"] + quat = Gf.Quatd(ori[0], Gf.Vec3d(ori[1], ori[2], ori[3])) + rot = Gf.Matrix3d().SetRotate(quat) + trans = Gf.Vec3d(pos[0], pos[1], pos[2]) + return Gf.Matrix4d(rot, trans) + + +def matrix_to_pose(matrix: Gf.Matrix4d) -> dict: + """Convert a 4x4 transform matrix to a pose dictionary. + + Args: + matrix: The 4x4 transform matrix to convert. + + Returns: + Pose dictionary with 'position' and 'orientation'. + """ + pos = matrix.ExtractTranslation() + rot = matrix.ExtractRotation() + quat = rot.GetQuat() + return { + "position": [pos[0], pos[1], pos[2]], + "orientation": [quat.GetReal(), quat.GetImaginary()[0], quat.GetImaginary()[1], quat.GetImaginary()[2]], + } + + +def get_pairing_error(trans_errs: list, rot_errs: list) -> float: + """Compute a scalar pairing error from translation and rotation errors. + + Args: + trans_errs: The list of translation errors across samples. + rot_errs: The list of rotation errors across samples. + + Returns: + The weighted sum of medians of translation and rotation errors. + """ + + def _median(values: list) -> float: + try: + return float(np.median(np.asarray(values, dtype=np.float64))) + except Exception: + return float("inf") + + return _median(trans_errs) + 0.01 * _median(rot_errs) diff --git a/source/isaaclab/isaaclab/devices/openxr/openxr_device.py b/source/isaaclab/isaaclab/devices/openxr/openxr_device.py index 34cd4bb2cfe6..4e5e08249803 100644 --- a/source/isaaclab/isaaclab/devices/openxr/openxr_device.py +++ b/source/isaaclab/isaaclab/devices/openxr/openxr_device.py @@ -40,10 +40,12 @@ class OpenXRDevice(DeviceBase): """An OpenXR-powered device for teleoperation and interaction. This device tracks hand joints using OpenXR and makes them available as: + 1. A dictionary of tracking data (when used without retargeters) 2. Retargeted commands for robot control (when retargeters are provided) Raw data format (_get_raw_data output): + * A dictionary with keys matching TrackingTarget enum values (HAND_LEFT, HAND_RIGHT, HEAD) * Each hand tracking entry contains a dictionary of joint poses * Each joint pose is a 7D vector (x, y, z, qw, qx, qy, qz) in meters and quaternion units @@ -52,6 +54,7 @@ class OpenXRDevice(DeviceBase): Teleop commands: The device responds to several teleop commands that can be subscribed to via add_callback(): + * "START": Resume hand tracking data flow * "STOP": Pause hand tracking data flow * "RESET": Reset the tracking and signal simulation reset diff --git a/source/isaaclab/isaaclab/devices/teleop_device_factory.py b/source/isaaclab/isaaclab/devices/teleop_device_factory.py index 89787b86674f..f2a7eed32c6d 100644 --- a/source/isaaclab/isaaclab/devices/teleop_device_factory.py +++ b/source/isaaclab/isaaclab/devices/teleop_device_factory.py @@ -29,7 +29,7 @@ with contextlib.suppress(ModuleNotFoundError): # May fail if xr is not in use - from isaaclab.devices.openxr import OpenXRDevice, OpenXRDeviceCfg + from isaaclab.devices.openxr import ManusVive, ManusViveCfg, OpenXRDevice, OpenXRDeviceCfg # Map device types to their constructor and expected config type DEVICE_MAP: dict[type[DeviceCfg], type[DeviceBase]] = { @@ -40,6 +40,7 @@ Se2GamepadCfg: Se2Gamepad, Se2SpaceMouseCfg: Se2SpaceMouse, OpenXRDeviceCfg: OpenXRDevice, + ManusViveCfg: ManusVive, } diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py index 4d0871fcb8a0..6192f3e58836 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py @@ -14,7 +14,7 @@ from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg from isaaclab.controllers.pink_ik import NullSpacePostureTask, PinkIKControllerCfg from isaaclab.devices.device_base import DevicesCfg -from isaaclab.devices.openxr import OpenXRDeviceCfg, XrCfg +from isaaclab.devices.openxr import ManusViveCfg, OpenXRDeviceCfg, XrCfg from isaaclab.devices.openxr.retargeters.humanoid.fourier.gr1t2_retargeter import GR1T2RetargeterCfg from isaaclab.envs import ManagerBasedRLEnvCfg from isaaclab.envs.mdp.actions.pink_actions_cfg import PinkInverseKinematicsActionCfg @@ -437,5 +437,17 @@ def __post_init__(self): sim_device=self.sim.device, xr_cfg=self.xr, ), + "manusvive": ManusViveCfg( + retargeters=[ + GR1T2RetargeterCfg( + enable_visualization=True, + num_open_xr_hand_joints=2 * 26, + sim_device=self.sim.device, + hand_joint_names=self.actions.pink_ik_cfg.hand_joint_names, + ), + ], + sim_device=self.sim.device, + xr_cfg=self.xr, + ), } ) From 20c3dfa359908cf382eacb75ef9fe9f2cfe3b9f5 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Mon, 8 Sep 2025 17:59:01 -0700 Subject: [PATCH 32/47] Updates Isaac Sim license (#3393) # Description Updates Isaac Sim license to the latest Apache 2.0 license and fixes broken link in the docs. ## Type of change - This change requires a documentation update --- README.md | 2 + .../dependencies/isaacsim-license.txt | 193 +++++++++++++++++- docs/source/refs/license.rst | 7 +- 3 files changed, 189 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d031c7bfe2fe..521ed3356eaf 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,8 @@ The Isaac Lab framework is released under [BSD-3 License](LICENSE). The `isaacla corresponding standalone scripts are released under [Apache 2.0](LICENSE-mimic). The license files of its dependencies and assets are present in the [`docs/licenses`](docs/licenses) directory. +Note that Isaac Lab requires Isaac Sim, which includes components under proprietary licensing terms. Please see the [Isaac Sim license](docs/licenses/dependencies/isaacsim-license.txt) for information on Isaac Sim licensing. + ## Acknowledgement Isaac Lab development initiated from the [Orbit](https://isaac-orbit.github.io/) framework. We would appreciate if diff --git a/docs/licenses/dependencies/isaacsim-license.txt b/docs/licenses/dependencies/isaacsim-license.txt index 80cff4a45145..0454ece1bed6 100644 --- a/docs/licenses/dependencies/isaacsim-license.txt +++ b/docs/licenses/dependencies/isaacsim-license.txt @@ -1,13 +1,188 @@ -Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved. +The Isaac Sim software in this repository is covered under the Apache 2.0 +License terms below. -NVIDIA CORPORATION and its licensors retain all intellectual property -and proprietary rights in and to this software, related documentation -and any modifications thereto. Any use, reproduction, disclosure or -distribution of this software and related documentation without an express -license agreement from NVIDIA CORPORATION is strictly prohibited. +Building or using the software requires additional components licenced +under other terms. These additional components include dependencies such +as the Omniverse Kit SDK, as well as 3D models and textures. -Note: Licenses for assets such as Robots and Props used within these environments can be found inside their respective folders on the Nucleus server where they are hosted. +License terms for these additional NVIDIA owned and licensed components +can be found here: + https://docs.nvidia.com/NVIDIA-IsaacSim-Additional-Software-and-Materials-License.pdf -For more information: https://docs.omniverse.nvidia.com/app_isaacsim/common/NVIDIA_Omniverse_License_Agreement.html +Any open source dependencies downloaded during the build process are owned +by their respective owners and licensed under their respective terms. -For sub-dependencies of Isaac Sim: https://docs.omniverse.nvidia.com/app_isaacsim/common/licenses.html + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/docs/source/refs/license.rst b/docs/source/refs/license.rst index 4d907efa15e2..4e1a29658733 100644 --- a/docs/source/refs/license.rst +++ b/docs/source/refs/license.rst @@ -3,15 +3,14 @@ License ======== -NVIDIA Isaac Sim is available freely under `individual license -`_. For more information -about its license terms, please check `here `_. +NVIDIA Isaac Sim is licensed under Apache 2.0. For more information +about its license terms, please check `here `_. The license files for all its dependencies and included assets are available in its `documentation `_. The Isaac Lab framework is open-sourced under the -`BSD-3-Clause license `_. +`BSD-3-Clause license `_, with some dependencies licensed under other terms. .. code-block:: text From 9d194dc5c98de4e7051159a3dbca52bfd3768535 Mon Sep 17 00:00:00 2001 From: rebeccazhang0707 <168459200+rebeccazhang0707@users.noreply.github.com> Date: Tue, 9 Sep 2025 09:29:32 +0800 Subject: [PATCH 33/47] Adds two new robots with grippers (#3229) # Description Adds two new robots with grippers: - franka + robotiq_2f_85 gripper, with a new usd asset - ur10e + robotiq_2f_140 gripper, with IsaacSim UR10E asset with variants Test the two new arms with gripper with this script: [test_new_arms.py](https://github.com/user-attachments/files/22200295/test_new_arms.py) `./isaaclab.sh -p test_new_arms.py` ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots ![ur10e_franka_robotiq_grippers](https://github.com/user-attachments/assets/8f904de2-90ad-4534-a274-d0d9a220ccf4) ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: rebeccazhang0707 <168459200+rebeccazhang0707@users.noreply.github.com> Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- .../isaaclab_assets/robots/franka.py | 66 ++++++++++++++++++- .../robots/universal_robots.py | 41 ++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/source/isaaclab_assets/isaaclab_assets/robots/franka.py b/source/isaaclab_assets/isaaclab_assets/robots/franka.py index c3581fa61308..36d07253425b 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/franka.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/franka.py @@ -9,14 +9,16 @@ * :obj:`FRANKA_PANDA_CFG`: Franka Emika Panda robot with Panda hand * :obj:`FRANKA_PANDA_HIGH_PD_CFG`: Franka Emika Panda robot with Panda hand with stiffer PD control +* :obj:`FRANKA_ROBOTIQ_GRIPPER_CFG`: Franka robot with Robotiq_2f_85 gripper Reference: https://github.com/frankaemika/franka_ros """ + import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets.articulation import ArticulationCfg -from isaaclab.utils.assets import ISAACLAB_NUCLEUS_DIR +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, ISAACLAB_NUCLEUS_DIR ## # Configuration @@ -82,3 +84,65 @@ This configuration is useful for task-space control using differential IK. """ + + +FRANKA_ROBOTIQ_GRIPPER_CFG = FRANKA_PANDA_CFG.copy() +FRANKA_ROBOTIQ_GRIPPER_CFG.spawn.usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/FrankaRobotics/FrankaPanda/franka.usd" +FRANKA_ROBOTIQ_GRIPPER_CFG.spawn.variants = {"Gripper": "Robotiq_2F_85"} +FRANKA_ROBOTIQ_GRIPPER_CFG.spawn.rigid_props.disable_gravity = True +FRANKA_ROBOTIQ_GRIPPER_CFG.init_state.joint_pos = { + "panda_joint1": 0.0, + "panda_joint2": -0.569, + "panda_joint3": 0.0, + "panda_joint4": -2.810, + "panda_joint5": 0.0, + "panda_joint6": 3.037, + "panda_joint7": 0.741, + "finger_joint": 0.0, + ".*_inner_finger_joint": 0.0, + ".*_inner_finger_knuckle_joint": 0.0, + ".*_outer_.*_joint": 0.0, +} +FRANKA_ROBOTIQ_GRIPPER_CFG.init_state.pos = (-0.85, 0, 0.76) +FRANKA_ROBOTIQ_GRIPPER_CFG.actuators = { + "panda_shoulder": ImplicitActuatorCfg( + joint_names_expr=["panda_joint[1-4]"], + effort_limit_sim=5200.0, + velocity_limit_sim=2.175, + stiffness=1100.0, + damping=80.0, + ), + "panda_forearm": ImplicitActuatorCfg( + joint_names_expr=["panda_joint[5-7]"], + effort_limit_sim=720.0, + velocity_limit_sim=2.61, + stiffness=1000.0, + damping=80.0, + ), + "gripper_drive": ImplicitActuatorCfg( + joint_names_expr=["finger_joint"], # "right_outer_knuckle_joint" is its mimic joint + effort_limit_sim=1650, + velocity_limit_sim=10.0, + stiffness=17, + damping=0.02, + ), + # enable the gripper to grasp in a parallel manner + "gripper_finger": ImplicitActuatorCfg( + joint_names_expr=[".*_inner_finger_joint"], + effort_limit_sim=50, + velocity_limit_sim=10.0, + stiffness=0.2, + damping=0.001, + ), + # set PD to zero for passive joints in close-loop gripper + "gripper_passive": ImplicitActuatorCfg( + joint_names_expr=[".*_inner_finger_knuckle_joint", "right_outer_knuckle_joint"], + effort_limit_sim=1.0, + velocity_limit_sim=10.0, + stiffness=0.0, + damping=0.0, + ), +} + + +"""Configuration of Franka Emika Panda robot with Robotiq_2f_85 gripper.""" diff --git a/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py b/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py index 183d0c3779ce..4433b8242352 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/universal_robots.py @@ -9,6 +9,7 @@ The following configuration parameters are available: * :obj:`UR10_CFG`: The UR10 arm without a gripper. +* :obj:`UR10E_ROBOTIQ_GRIPPER_CFG`: The UR10E arm with Robotiq_2f_140 gripper. Reference: https://github.com/ros-industrial/universal_robot """ @@ -122,3 +123,43 @@ UR10_SHORT_SUCTION_CFG.spawn.variants = {"Gripper": "Short_Suction"} """Configuration of UR10 arm with short suction gripper.""" + +UR10e_ROBOTIQ_GRIPPER_CFG = UR10e_CFG.copy() +UR10e_ROBOTIQ_GRIPPER_CFG.spawn.variants = {"Gripper": "Robotiq_2f_140"} +UR10e_ROBOTIQ_GRIPPER_CFG.spawn.rigid_props.disable_gravity = True +UR10e_ROBOTIQ_GRIPPER_CFG.init_state.joint_pos["finger_joint"] = 0.0 +UR10e_ROBOTIQ_GRIPPER_CFG.init_state.joint_pos[".*_inner_finger_joint"] = 0.0 +UR10e_ROBOTIQ_GRIPPER_CFG.init_state.joint_pos[".*_inner_finger_pad_joint"] = 0.0 +UR10e_ROBOTIQ_GRIPPER_CFG.init_state.joint_pos[".*_outer_.*_joint"] = 0.0 +# the major actuator joint for gripper +UR10e_ROBOTIQ_GRIPPER_CFG.actuators["gripper_drive"] = ImplicitActuatorCfg( + joint_names_expr=["finger_joint"], + effort_limit_sim=10.0, + velocity_limit_sim=1.0, + stiffness=11.25, + damping=0.1, + friction=0.0, + armature=0.0, +) +# the auxiliary actuator joint for gripper +UR10e_ROBOTIQ_GRIPPER_CFG.actuators["gripper_finger"] = ImplicitActuatorCfg( + joint_names_expr=[".*_inner_finger_joint"], + effort_limit_sim=1.0, + velocity_limit_sim=1.0, + stiffness=0.2, + damping=0.001, + friction=0.0, + armature=0.0, +) +# the passive joints for gripper +UR10e_ROBOTIQ_GRIPPER_CFG.actuators["gripper_passive"] = ImplicitActuatorCfg( + joint_names_expr=[".*_inner_finger_pad_joint", ".*_outer_finger_joint", "right_outer_knuckle_joint"], + effort_limit_sim=1.0, + velocity_limit_sim=1.0, + stiffness=0.0, + damping=0.0, + friction=0.0, + armature=0.0, +) + +"""Configuration of UR-10E arm with Robotiq_2f_140 gripper.""" From e4b5681edc5207f691c2098ac69b8c9adb3fce84 Mon Sep 17 00:00:00 2001 From: lotusl-code Date: Mon, 8 Sep 2025 20:35:17 -0700 Subject: [PATCH 34/47] Adds notification widgets at IK error status and Teleop task completion (#3356) # Description 1. Add a notification widget when ik error happens 2. At the end of Teleop data collection, display a notification before the application termination Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (fix or feature that would cause existing functionality to not work as expected) - This change requires a documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Kelly Guo --- CONTRIBUTORS.md | 1 + .../_static/setup/cloudxr_avp_ik_error.jpg | Bin 0 -> 301133 bytes docs/source/how-to/cloudxr_teleoperation.rst | 12 + scripts/tools/record_demos.py | 22 +- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 9 + .../isaaclab/controllers/pink_ik/pink_ik.py | 5 + .../controllers/pink_ik/pink_ik_cfg.py | 3 + .../isaaclab/ui/xr_widgets/__init__.py | 4 +- .../ui/xr_widgets/instruction_widget.py | 163 ++++- .../ui/xr_widgets/scene_visualization.py | 609 ++++++++++++++++++ .../teleop_visualization_manager.py | 67 ++ .../check_scene_xr_visualization.py | 257 ++++++++ .../exhaustpipe_gr1t2_pink_ik_env_cfg.py | 2 + .../nutpour_gr1t2_pink_ik_env_cfg.py | 2 + .../pick_place/pickplace_gr1t2_env_cfg.py | 2 + 16 files changed, 1119 insertions(+), 41 deletions(-) create mode 100644 docs/source/_static/setup/cloudxr_avp_ik_error.jpg create mode 100644 source/isaaclab/isaaclab/ui/xr_widgets/scene_visualization.py create mode 100644 source/isaaclab/isaaclab/ui/xr_widgets/teleop_visualization_manager.py create mode 100644 source/isaaclab/test/visualization/check_scene_xr_visualization.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 73893d8217a6..d93e0ddf2718 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -88,6 +88,7 @@ Guidelines for modifications: * Kourosh Darvish * Kousheek Chakraborty * Lionel Gulich +* Lotus Li * Louis Le Lay * Lorenz Wellhausen * Lukas Fröhlich diff --git a/docs/source/_static/setup/cloudxr_avp_ik_error.jpg b/docs/source/_static/setup/cloudxr_avp_ik_error.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3f0d430182e7e4540a18f76a15726882f9cd35fa GIT binary patch literal 301133 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