Skip to content

Commit 0a68332

Browse files
committed
Add tendon actuation to the Shadow Hand tasks
1 parent 958cb25 commit 0a68332

25 files changed

Lines changed: 910 additions & 262 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
Added
2+
^^^^^
3+
4+
* Added fixed-tendon actuation to the Shadow Hand tasks. The hand's twenty motors drive sixteen
5+
joints and four tendons, so each manager-based task pairs a joint action term with a fixed-tendon
6+
action term -- one pair per hand, so handover carries two -- and the direct tasks apply the joint
7+
and tendon halves of the action in turn. Without the tendon term the eight joints coupled by a
8+
tendon took no command at all. Each hand also observes the tendon half of its previous command,
9+
which takes the manager-based handover policy input from 282 to 290 values.
10+
11+
Fixed
12+
^^^^^
13+
14+
* Fixed the direct Shadow Hand reorientation and handover tasks rescaling their whole twenty-motor
15+
action against their sixteen actuated joints, which raised a shape error on the first step.
16+
17+
* Fixed the Shadow Hand reorientation task spawning the hand in an orientation that left the palm
18+
facing sideways on the current asset, so the object could not be held.
19+
20+
* Fixed the Shadow Hand reorientation and handover tasks diverging on PhysX. Twenty-four joints
21+
under finger-object contact need more solver iterations than the default budget provides, and
22+
training ended with non-finite observations. The hand's configuration sets them again for both
23+
engines; Newton ignores them.
24+
25+
Changed
26+
^^^^^^^
27+
28+
* Changed ``Metrics/success_rate`` for the Shadow Hand handover task to report whether the object is
29+
at the goal when the episode ends. It previously latched as soon as the object first came within
30+
the success distance, so an object swung through the goal scored the same as one left resting
31+
there. Both the manager-based and direct environments were updated together. Reported success
32+
rates are lower than before for the same policy, and are not comparable with values recorded
33+
under the previous definition; re-evaluate any checkpoint whose success rate is being compared
34+
across this change.
35+
36+
* Reduced the default RSL-RL training length for the Shadow Hand tasks: reorientation from 10000 to
37+
3000 iterations and handover from 5000 to 3500. Success rate flattens well before the previous
38+
budgets, so a default run reaches the same success rate in roughly a third of the wall time. Pass
39+
``agent.max_iterations=<n>`` to train longer.

source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_common.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,7 @@
1515
import isaaclab.sim as sim_utils
1616
from isaaclab.markers import VisualizationMarkersCfg
1717

18-
from isaaclab_assets.robots.shadow_hand import (
19-
SHADOW_ACTUATED_JOINT_NAMES as ACTUATED_JOINT_NAMES,
20-
)
21-
from isaaclab_assets.robots.shadow_hand import (
22-
SHADOW_FINGERTIP_BODY_NAMES as FINGERTIP_BODY_NAMES,
23-
)
24-
2518
__all__ = [
26-
"ACTUATED_JOINT_NAMES",
27-
"FINGERTIP_BODY_NAMES",
2819
"GOAL_MARKER_CFG",
2920
"GOAL_POSITION_OFFSET",
3021
"OBJECT_RADIUS",

source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@
2121
from isaaclab_tasks.core.handover.handover_common import GOAL_POSITION_OFFSET
2222
from isaaclab_tasks.core.handover.handover_env_cfg import HandoverEnvCfg
2323
from isaaclab_tasks.core.handover.mdp.rewards import evaluate_handover_success, handover_reward
24-
from isaaclab_tasks.core.utils import (
24+
from isaaclab_tasks.core.reorient.utils import (
2525
EpisodeErrorRecorder,
2626
randomize_rotation,
27+
resolve_actuated_tendons,
2728
sample_joint_positions_within_limits,
2829
)
2930

@@ -57,6 +58,18 @@ def __init__(self, cfg: HandoverEnvCfg, render_mode: str | None = None, **kwargs
5758
f"Expected {len(cfg.actuated_joint_names)} actuated joints, found {len(self.actuated_dof_indices)}."
5859
)
5960

61+
# Motors that pull a tendon rather than drive a joint. Both hands are the same model, so
62+
# one index set serves both.
63+
self.actuated_tendon_indices: list[int] = []
64+
if cfg.actuated_tendon_names:
65+
self.actuated_tendon_indices, self.tendon_lower_limits, self.tendon_upper_limits = resolve_actuated_tendons(
66+
self.right_hand,
67+
cfg.actuated_tendon_names,
68+
self.num_envs,
69+
self.device,
70+
cfg.actuated_tendon_position_limits,
71+
)
72+
6073
# finger bodies
6174
self.finger_bodies, _ = self.right_hand.find_bodies(self.cfg.fingertip_body_names)
6275
if len(self.finger_bodies) != len(self.cfg.fingertip_body_names):
@@ -83,14 +96,15 @@ def __init__(self, cfg: HandoverEnvCfg, render_mode: str | None = None, **kwargs
8396

8497
# Sticky per-env flag: True once the object reached the goal within threshold.
8598
self._episode_succeeded = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device)
99+
# Goal distance from the most recent reward step, read at reset as the episode's final value.
100+
self._last_goal_dist = torch.full((self.num_envs,), float("inf"), device=self.device)
86101
self._goal_distance = EpisodeErrorRecorder(self.num_envs, self.device)
87102

88103
# unit tensors for sampling goal/object rotations about the x and y axes
89104
self.x_unit_tensor = torch.tensor([1, 0, 0], dtype=torch.float, device=self.device).repeat((self.num_envs, 1))
90105
self.y_unit_tensor = torch.tensor([0, 1, 0], dtype=torch.float, device=self.device).repeat((self.num_envs, 1))
91106

92107
def _setup_scene(self):
93-
# add hand, in-hand object, and goal object
94108
self.right_hand = Articulation(self.cfg.right_robot_cfg)
95109
self.left_hand = Articulation(self.cfg.left_robot_cfg)
96110
self.object = RigidObject(self.cfg.object_cfg)
@@ -128,23 +142,35 @@ def _apply_hand_action(
128142
curr_targets: torch.Tensor,
129143
prev_targets: torch.Tensor,
130144
) -> None:
131-
"""Map one agent's actions to joint position targets and write them to its hand.
145+
"""Map one agent's actions to position targets and write them to its hand.
132146
133-
The raw ``[-1, 1]`` action is rescaled to the joint limits, blended with the previous
134-
target via the exponential moving average, clamped to the limits, and set on the hand.
147+
Actions are ordered joints first, then tendons, matching the manager task's action term.
148+
Each raw ``[-1, 1]`` joint action is rescaled to the joint limits, blended with the
149+
previous target via the exponential moving average, clamped to the limits, and set on the
150+
hand. Tendon actions are rescaled to the tendon's commandable range and written directly.
135151
"""
136152
idx = self.actuated_dof_indices
137153
lower = self.hand_dof_lower_limits[:, idx]
138154
upper = self.hand_dof_upper_limits[:, idx]
139155

140-
targets = unscale_transform(self.actions[agent], lower, upper)
156+
targets = unscale_transform(self.actions[agent][:, : len(idx)], lower, upper)
141157
targets = self.cfg.act_moving_average * targets + (1.0 - self.cfg.act_moving_average) * prev_targets[:, idx]
142158
targets = saturate(targets, lower, upper)
143159

144160
curr_targets[:, idx] = targets
145161
prev_targets[:, idx] = targets
146162
hand.set_joint_position_target_index(target=targets, joint_ids=idx)
147163

164+
if self.actuated_tendon_indices:
165+
# No moving average on the tendon target: the manager task's action term applies none,
166+
# and the two task variants have to stay comparable.
167+
hand.set_fixed_tendon_position_target_index(
168+
target=unscale_transform(
169+
self.actions[agent][:, len(idx) :], self.tendon_lower_limits, self.tendon_upper_limits
170+
),
171+
fixed_tendon_ids=self.actuated_tendon_indices,
172+
)
173+
148174
def _hand_proprio_obs(self, agent: str) -> torch.Tensor:
149175
"""Per-hand proprioceptive observation block for ``agent`` (133 dims).
150176
@@ -213,8 +239,10 @@ def _get_rewards(self) -> dict[str, torch.Tensor]:
213239
self.extras["log"]["dist_reward"] = rew_dist.mean()
214240
self.extras["log"]["dist_goal"] = goal_dist_mean
215241
self.extras["log"]["Metrics/goal_distance"] = goal_dist_mean
216-
# Sticky per-env success: True once the object reached the goal within threshold.
242+
# Reaching the goal is necessary but not sufficient; the object must still be there when
243+
# the episode ends. ``_reset_idx`` combines this with the final distance.
217244
self._episode_succeeded |= succeeded
245+
self._last_goal_dist = goal_dist
218246

219247
return {"right_hand": rew_dist, "left_hand": rew_dist}
220248

@@ -233,9 +261,12 @@ def _get_dones(self) -> tuple[dict[str, torch.Tensor], dict[str, torch.Tensor]]:
233261
def _reset_idx(self, env_ids: Sequence[int] | torch.Tensor | None):
234262
if env_ids is None:
235263
env_ids = self.right_hand._ALL_INDICES
236-
# Flush per-episode success (sticky binary: object ever reached the goal within threshold).
237-
# 0-dim device tensor, for the same reason
238-
self.extras.setdefault("log", {})["Metrics/success_rate"] = self._episode_succeeded[env_ids].float().mean()
264+
# Flush per-episode success: the object is AT the goal as the episode ends, not merely
265+
# that it passed through. 0-dim device tensor, for the same reason.
266+
succeeded = (self._last_goal_dist[env_ids] < self.cfg.success_distance_threshold) & self._episode_succeeded[
267+
env_ids
268+
]
269+
self.extras.setdefault("log", {})["Metrics/success_rate"] = succeeded.float().mean()
239270
for statistic, value in self._goal_distance.reset(env_ids).items():
240271
self.extras["log"][f"Diagnostics/episode_min_goal_distance_{statistic}"] = value
241272
self._episode_succeeded[env_ids] = False

source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env_cfg.py

Lines changed: 69 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,89 +9,95 @@
99
from isaaclab_physx.physics import PhysxCfg
1010

1111
import isaaclab.sim as sim_utils
12-
import isaaclab.utils.math as math_utils
13-
from isaaclab.assets import ArticulationCfg, RigidObjectCfg
12+
from isaaclab.assets import RigidObjectCfg
13+
from isaaclab.assets.articulation import ArticulationCfg
1414
from isaaclab.envs import DirectMARLEnvCfg
1515
from isaaclab.markers import VisualizationMarkersCfg
1616
from isaaclab.physics import PhysxAutoCfg
1717
from isaaclab.scene import InteractiveSceneCfg
1818
from isaaclab.sim import SimulationCfg
1919
from isaaclab.sim.spawners.materials import RigidBodyMaterialBaseCfg
20+
from isaaclab.utils import math as math_utils
2021
from isaaclab.utils.configclass import configclass
21-
22-
from isaaclab_tasks.core.handover.handover_common import (
23-
ACTUATED_JOINT_NAMES,
24-
FINGERTIP_BODY_NAMES,
25-
GOAL_MARKER_CFG,
26-
OBJECT_RADIUS,
22+
from isaaclab.visualizers import VisualizerCfg
23+
24+
from isaaclab_tasks.core.handover.handover_common import GOAL_MARKER_CFG, OBJECT_RADIUS
25+
from isaaclab_tasks.utils import PresetCfg
26+
27+
from isaaclab_assets.robots.shadow_hand import (
28+
FINGERTIP_NAMES,
29+
JOINT_NAMES,
30+
SHADOW_HAND_NEWTON_CFG,
31+
SHADOW_HAND_PHYSX_CFG,
32+
TENDON_NAMES,
33+
TENDON_POSITION_LIMITS,
2734
)
28-
from isaaclab_tasks.utils import PresetCfg, preset
29-
30-
from isaaclab_assets.robots.shadow_hand import SHADOW_HAND_CFG, SHADOW_HAND_NEWTON_CFG
3135

3236

33-
def _shadow_hand_cfg(
37+
def _hand_cfg(
38+
base: ArticulationCfg,
3439
prim_path: str,
3540
init_pos: tuple[float, float, float],
3641
init_rot: tuple[float, float, float, float],
37-
) -> PresetCfg:
38-
"""Build the per-hand Shadow Hand preset for each supported backend.
42+
) -> ArticulationCfg:
43+
"""Place one engine's Shadow Hand at this task's pose for one hand.
44+
45+
The catch needs more joint authority than reorientation, but the hand's gains belong to the
46+
hand, so both tasks take them as the asset configuration supplies them. This task used to raise
47+
every actuator to stiffness 20 / damping 2, which also drove the tendon-coupled joints -- they
48+
take no position command, and MEASURED, giving them one costs the tendon most of its travel:
49+
11.1 rad falls to 1.0 rad.
3950
4051
Args:
52+
base: The hand on the engine's asset variant.
4153
prim_path: Scene path the hand spawns at.
4254
init_pos: Spawn position [m].
4355
init_rot: Spawn orientation as ``(w, x, y, z)``.
4456
4557
Returns:
46-
A preset carrying the PhysX, Newton MJWarp and OvPhysX variants, each at
47-
*prim_path* with the given pose. The two hands differ only in these arguments.
58+
That configuration at *prim_path* with the given pose.
4859
"""
49-
physx_cfg = SHADOW_HAND_CFG.replace(prim_path=prim_path).replace(
50-
init_state=ArticulationCfg.InitialStateCfg(pos=init_pos, rot=init_rot, joint_pos={".*": 0.0})
51-
)
52-
# Newton's importer bakes the asset's root orientation into the root joint (see the note on
53-
# SHADOW_HAND_NEWTON_CFG.init_state), so the task rotation must compose with it rather than
54-
# replace it — replacing leaves both palms rotated 90 degrees.
55-
newton_rot = tuple(
60+
# The asset's own spawn rotation is shared by both engines, so the per-hand rotation COMPOSES
61+
# with it rather than replacing it -- replacing leaves both palms turned 90 degrees. See
62+
# SHADOW_HAND_PHYSX_CFG's init_state for why the asset carries that rotation.
63+
hand_rot = tuple(
5664
math_utils.quat_mul(
5765
torch.tensor(init_rot, dtype=torch.float64),
58-
torch.tensor(SHADOW_HAND_NEWTON_CFG.init_state.rot, dtype=torch.float64),
66+
torch.tensor(base.init_state.rot, dtype=torch.float64),
5967
).tolist()
6068
)
61-
newton_mjwarp_cfg = SHADOW_HAND_NEWTON_CFG.replace(
62-
prim_path=prim_path,
63-
init_state=SHADOW_HAND_NEWTON_CFG.init_state.replace(pos=init_pos, rot=newton_rot),
64-
actuators={
65-
**SHADOW_HAND_NEWTON_CFG.actuators,
66-
"fingers": SHADOW_HAND_NEWTON_CFG.actuators["fingers"].replace(stiffness=20.0, damping=2.0),
67-
},
68-
)
69-
ovphysx_cfg = SHADOW_HAND_CFG.replace(
69+
return base.replace(
7070
prim_path=prim_path,
71-
# OVPhysX does not expose the fixed-tendon runtime API, so spawn without tendon overrides.
72-
spawn=SHADOW_HAND_CFG.spawn.replace(fixed_tendons_props=None),
73-
init_state=SHADOW_HAND_CFG.init_state.replace(pos=init_pos, rot=init_rot),
74-
)
75-
return preset(
76-
default=newton_mjwarp_cfg,
77-
physx=physx_cfg,
78-
isaacsim_physx=physx_cfg,
79-
newton_mjwarp=newton_mjwarp_cfg,
80-
ovphysx=ovphysx_cfg,
71+
init_state=base.init_state.replace(pos=init_pos, rot=hand_rot),
8172
)
8273

8374

84-
# Per-hand presets shared by the Direct environment and the manager scene.
85-
RIGHT_HAND_CFG = _shadow_hand_cfg(
86-
prim_path="{ENV_REGEX_NS}/RightRobot",
87-
init_pos=(0.0, 0.0, 0.5),
88-
init_rot=(0.0, 0.0, 0.0, 1.0),
89-
)
90-
LEFT_HAND_CFG = _shadow_hand_cfg(
91-
prim_path="{ENV_REGEX_NS}/LeftRobot",
92-
init_pos=(0.0, -1.0, 0.5),
93-
init_rot=(0.0, 0.0, 1.0, 0.0),
94-
)
75+
# Per-hand poses. The rotations are composed with the asset's own; they are unchanged from the
76+
# previous Newton asset, which the two assets being identical geometry makes valid.
77+
_RIGHT_POSE = ("{ENV_REGEX_NS}/RightRobot", (0.0, 0.0, 0.5), (0.0, 0.0, 0.0, 1.0))
78+
_LEFT_POSE = ("{ENV_REGEX_NS}/LeftRobot", (0.0, -1.0, 0.5), (0.0, 0.0, 1.0, 0.0))
79+
80+
81+
@configclass
82+
class RightHandCfg(PresetCfg):
83+
"""The right hand on every engine; only the asset's physics variant differs."""
84+
85+
newton_mjwarp = _hand_cfg(SHADOW_HAND_NEWTON_CFG, *_RIGHT_POSE)
86+
isaacsim_physx = _hand_cfg(SHADOW_HAND_PHYSX_CFG, *_RIGHT_POSE)
87+
physx = isaacsim_physx
88+
ovphysx = isaacsim_physx
89+
default = newton_mjwarp
90+
91+
92+
@configclass
93+
class LeftHandCfg(PresetCfg):
94+
"""The left hand on every engine; only the asset's physics variant differs."""
95+
96+
newton_mjwarp = _hand_cfg(SHADOW_HAND_NEWTON_CFG, *_LEFT_POSE)
97+
isaacsim_physx = _hand_cfg(SHADOW_HAND_PHYSX_CFG, *_LEFT_POSE)
98+
physx = isaacsim_physx
99+
ovphysx = isaacsim_physx
100+
default = newton_mjwarp
95101

96102

97103
BALL_CFG = RigidObjectCfg(
@@ -169,13 +175,18 @@ class HandoverEnvCfg(DirectMARLEnvCfg):
169175
render_interval=decimation,
170176
physics_material=RigidBodyMaterialBaseCfg(static_friction=1.0, dynamic_friction=1.0),
171177
physics=PhysicsCfg(),
178+
# Frame both hands and the object between them. Without this the visualizer looks at the
179+
# origin from its default 4 m away, which renders the pair a few pixels wide.
180+
default_visualizer_cfg=VisualizerCfg(eye=(1.15, -1.65, 1.15), lookat=(0.0, -0.5, 0.55), focal_length=35.0),
172181
)
173182

174183
# robot
175-
right_robot_cfg: PresetCfg = RIGHT_HAND_CFG
176-
left_robot_cfg: PresetCfg = LEFT_HAND_CFG
177-
actuated_joint_names = ACTUATED_JOINT_NAMES
178-
fingertip_body_names = FINGERTIP_BODY_NAMES
184+
right_robot_cfg: RightHandCfg = RightHandCfg()
185+
left_robot_cfg: LeftHandCfg = LeftHandCfg()
186+
actuated_joint_names = JOINT_NAMES
187+
actuated_tendon_names = TENDON_NAMES
188+
actuated_tendon_position_limits = TENDON_POSITION_LIMITS
189+
fingertip_body_names = FINGERTIP_NAMES
179190

180191
# in-hand object
181192
object_cfg: RigidObjectCfg = BALL_CFG

0 commit comments

Comments
 (0)