Skip to content

Commit 869b07a

Browse files
committed
[FEATURE] #2332 Implement Local Offset in Inverse Kinematics
1 parent 1b5aadf commit 869b07a

File tree

3 files changed

+182
-3
lines changed

3 files changed

+182
-3
lines changed

genesis/engine/entities/rigid_entity/rigid_entity.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,7 @@ def inverse_kinematics(
14221422
link,
14231423
pos=None,
14241424
quat=None,
1425+
local_point=None,
14251426
init_qpos=None,
14261427
respect_joint_limit=True,
14271428
max_samples=50,
@@ -1447,6 +1448,10 @@ def inverse_kinematics(
14471448
The target position. If None, position error will not be considered. Defaults to None.
14481449
quat : None | array_like, shape (4,), optional
14491450
The target orientation. If None, orientation error will not be considered. Defaults to None.
1451+
local_point : None | array_like, shape (3,), optional
1452+
A point in the link's local frame to be positioned at `pos`. If None, the link origin is used.
1453+
This is useful for positioning a tool center point (TCP) or fingertip that is offset from the link origin.
1454+
Defaults to None (equivalent to [0, 0, 0]).
14501455
init_qpos : None | array_like, shape (n_dofs,), optional
14511456
Initial qpos used for solving IK. If None, the current qpos will be used. Defaults to None.
14521457
respect_joint_limit : bool, optional
@@ -1495,6 +1500,7 @@ def inverse_kinematics(
14951500
links=[link],
14961501
poss=[pos] if pos is not None else [],
14971502
quats=[quat] if quat is not None else [],
1503+
local_points=[local_point] if local_point is not None else [],
14981504
init_qpos=init_qpos,
14991505
respect_joint_limit=respect_joint_limit,
15001506
max_samples=max_samples,
@@ -1521,6 +1527,7 @@ def inverse_kinematics_multilink(
15211527
links,
15221528
poss=None,
15231529
quats=None,
1530+
local_points=None,
15241531
init_qpos=None,
15251532
respect_joint_limit=True,
15261533
max_samples=50,
@@ -1546,6 +1553,11 @@ def inverse_kinematics_multilink(
15461553
List of target positions. If empty, position error will not be considered. Defaults to None.
15471554
quats : list, optional
15481555
List of target orientations. If empty, orientation error will not be considered. Defaults to None.
1556+
local_points : list, optional
1557+
List of local points (one per link) in each link's local frame to be positioned at the corresponding target position.
1558+
If empty or None, link origins are used. Each element should be array_like of shape (3,) or None.
1559+
This is useful for positioning tool center points (TCP) or fingertips that are offset from the link origin.
1560+
Defaults to None.
15491561
init_qpos : array_like, shape (n_dofs,), optional
15501562
Initial qpos used for solving IK. If None, the current qpos will be used. Defaults to None.
15511563
respect_joint_limit : bool, optional
@@ -1608,6 +1620,18 @@ def inverse_kinematics_multilink(
16081620
elif len(quats) != n_links:
16091621
gs.raise_exception("Accepting only `quats` with length equal to `links` or empty list.")
16101622

1623+
# Process local_points - default to origin [0, 0, 0] for each link
1624+
local_points = list(local_points) if local_points is not None else []
1625+
if not local_points:
1626+
local_points = [None for _ in range(n_links)]
1627+
elif len(local_points) != n_links:
1628+
gs.raise_exception("Accepting only `local_points` with length equal to `links` or empty list.")
1629+
for i, lp in enumerate(local_points):
1630+
if lp is None:
1631+
lp = [0.0, 0.0, 0.0]
1632+
local_points[i] = broadcast_tensor(lp, gs.tc_float, (3,), ("",)).contiguous()
1633+
local_points = torch.stack(local_points, dim=0) # (n_links, 3)
1634+
16111635
link_pos_mask, link_rot_mask = [], []
16121636
for i, (pos, quat) in enumerate(zip(poss, quats)):
16131637
if pos is None and quat is None:
@@ -1658,6 +1682,7 @@ def inverse_kinematics_multilink(
16581682
links_idx,
16591683
poss,
16601684
quats,
1685+
local_points,
16611686
n_links,
16621687
dofs_idx,
16631688
n_dofs,

genesis/engine/solvers/rigid/abd/inverse_kinematics.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def kernel_rigid_entity_inverse_kinematics(
2020
links_idx: ti.types.ndarray(),
2121
poss: ti.types.ndarray(),
2222
quats: ti.types.ndarray(),
23+
local_points: ti.types.ndarray(),
2324
n_links: ti.i32,
2425
dofs_idx: ti.types.ndarray(),
2526
n_dofs: ti.i32,
@@ -94,7 +95,11 @@ def kernel_rigid_entity_inverse_kinematics(
9495
i_l_ee = links_idx[i_ee]
9596

9697
tgt_pos_i = ti.Vector([poss[i_ee, i_b_, 0], poss[i_ee, i_b_, 1], poss[i_ee, i_b_, 2]])
97-
err_pos_i = tgt_pos_i - links_state.pos[i_l_ee, i_b]
98+
local_point_i = ti.Vector([local_points[i_ee, 0], local_points[i_ee, 1], local_points[i_ee, 2]])
99+
pos_curr_i = links_state.pos[i_l_ee, i_b] + gu.ti_transform_by_quat(
100+
local_point_i, links_state.quat[i_l_ee, i_b]
101+
)
102+
err_pos_i = tgt_pos_i - pos_curr_i
98103
for k in range(3):
99104
err_pos_i[k] *= pos_mask[k] * link_pos_mask[i_ee]
100105
if err_pos_i.norm() > pos_tol:
@@ -123,10 +128,11 @@ def kernel_rigid_entity_inverse_kinematics(
123128
for i_ee in range(n_links):
124129
# update jacobian for ee link
125130
i_l_ee = links_idx[i_ee]
131+
local_point_i = ti.Vector([local_points[i_ee, 0], local_points[i_ee, 1], local_points[i_ee, 2]])
126132
rigid_entity._func_get_jacobian(
127133
tgt_link_idx=i_l_ee,
128134
i_b=i_b,
129-
p_local=ti.Vector.zero(gs.ti_float, 3),
135+
p_local=local_point_i,
130136
pos_mask=pos_mask,
131137
rot_mask=rot_mask,
132138
dofs_info=dofs_info,
@@ -222,7 +228,11 @@ def kernel_rigid_entity_inverse_kinematics(
222228
i_l_ee = links_idx[i_ee]
223229

224230
tgt_pos_i = ti.Vector([poss[i_ee, i_b_, 0], poss[i_ee, i_b_, 1], poss[i_ee, i_b_, 2]])
225-
err_pos_i = tgt_pos_i - links_state.pos[i_l_ee, i_b]
231+
local_point_i = ti.Vector([local_points[i_ee, 0], local_points[i_ee, 1], local_points[i_ee, 2]])
232+
pos_curr_i = links_state.pos[i_l_ee, i_b] + gu.ti_transform_by_quat(
233+
local_point_i, links_state.quat[i_l_ee, i_b]
234+
)
235+
err_pos_i = tgt_pos_i - pos_curr_i
226236
for k in range(3):
227237
err_pos_i[k] *= pos_mask[k] * link_pos_mask[i_ee]
228238
if err_pos_i.norm() > pos_tol:

tests/test_rigid_physics.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,6 +1575,150 @@ def test_multilink_inverse_kinematics(show_viewer):
15751575
assert_allclose(wrist.get_pos(envs_idx=(1,)), wrist_pos, tol=TOL)
15761576

15771577

1578+
@pytest.mark.required
1579+
@pytest.mark.parametrize("n_envs", [0, 2])
1580+
@pytest.mark.parametrize("backend", [gs.cpu, gs.gpu])
1581+
def test_inverse_kinematics_local_point(n_envs, show_viewer):
1582+
"""Test IK with local_point parameter - positions an offset point at the target instead of link origin."""
1583+
1584+
TOL = 2e-3 # 2mm tolerance for final position check
1585+
1586+
scene = gs.Scene(
1587+
viewer_options=gs.options.ViewerOptions(
1588+
camera_pos=(2.5, 0.0, 1.5),
1589+
camera_lookat=(0.0, 0.0, 0.5),
1590+
),
1591+
show_viewer=show_viewer,
1592+
)
1593+
robot = scene.add_entity(
1594+
morph=gs.morphs.MJCF(file="xml/franka_emika_panda/panda.xml"),
1595+
)
1596+
scene.build(n_envs=n_envs)
1597+
1598+
end_effector = robot.get_link("hand")
1599+
1600+
# Define a local offset point in the end-effector frame (e.g., 10cm along Z-axis)
1601+
local_offset = torch.tensor([0.0, 0.0, 0.1], dtype=gs.tc_float, device=gs.device)
1602+
1603+
# Create different target positions and quaternions for each environment
1604+
num_envs = max(n_envs, 1)
1605+
target_pos_base = torch.tensor(
1606+
[[0.5, 0.2, 0.4], [0.45, 0.15, 0.35], [0.55, 0.25, 0.45]], dtype=gs.tc_float, device=gs.device
1607+
)[:num_envs]
1608+
target_quat_base = torch.tensor(
1609+
[[0.0, 1.0, 0.0, 0.0], [0.0, 0.9239, 0.3827, 0.0], [0.0, 0.9239, -0.3827, 0.0]],
1610+
dtype=gs.tc_float,
1611+
device=gs.device,
1612+
)[:num_envs]
1613+
1614+
# Handle different shapes based on n_envs
1615+
if n_envs > 0:
1616+
target_pos = target_pos_base
1617+
target_quat = target_quat_base
1618+
else:
1619+
target_pos = target_pos_base[0]
1620+
target_quat = target_quat_base[0]
1621+
1622+
# Solve IK with local_point (local_offset stays 1D - it gets broadcast internally)
1623+
qpos, err = robot.inverse_kinematics(
1624+
link=end_effector,
1625+
pos=target_pos,
1626+
quat=target_quat,
1627+
local_point=local_offset,
1628+
return_error=True,
1629+
)
1630+
1631+
# Apply the solution
1632+
robot.set_qpos(qpos)
1633+
scene.step()
1634+
1635+
# Verify the offset point is at the target position
1636+
link_pos = end_effector.get_pos()
1637+
link_quat = end_effector.get_quat()
1638+
1639+
# Transform local offset to world frame
1640+
world_offset = gu.transform_by_quat(local_offset, link_quat)
1641+
actual_point_pos = link_pos + world_offset
1642+
1643+
# Check that the offset point reached the target
1644+
assert_allclose(actual_point_pos, target_pos, tol=TOL)
1645+
1646+
# Also verify via forward kinematics
1647+
links_pos, links_quat = robot.forward_kinematics(qpos)
1648+
1649+
# Handle indexing based on n_envs
1650+
if n_envs > 0:
1651+
fk_link_pos = links_pos[:, end_effector.idx_local]
1652+
fk_link_quat = links_quat[:, end_effector.idx_local]
1653+
else:
1654+
fk_link_pos = links_pos[end_effector.idx_local]
1655+
fk_link_quat = links_quat[end_effector.idx_local]
1656+
1657+
fk_world_offset = gu.transform_by_quat(local_offset, fk_link_quat)
1658+
fk_actual_point_pos = fk_link_pos + fk_world_offset
1659+
assert_allclose(fk_actual_point_pos, target_pos, tol=TOL)
1660+
1661+
if show_viewer:
1662+
scene.visualizer.update()
1663+
1664+
1665+
@pytest.mark.required
1666+
@pytest.mark.parametrize("backend", [gs.cpu, gs.gpu])
1667+
def test_inverse_kinematics_multilink_local_points(show_viewer):
1668+
"""Test multi-link IK with local_points parameter."""
1669+
1670+
TOL = 2e-3 # 2mm tolerance for final position check
1671+
1672+
scene = gs.Scene(
1673+
viewer_options=gs.options.ViewerOptions(
1674+
camera_pos=(2.5, 0.0, 1.5),
1675+
camera_lookat=(0.0, 0.0, 0.5),
1676+
),
1677+
show_viewer=show_viewer,
1678+
)
1679+
robot = scene.add_entity(
1680+
morph=gs.morphs.URDF(file="urdf/shadow_hand/shadow_hand.urdf"),
1681+
)
1682+
scene.build()
1683+
1684+
index_finger = robot.get_link("index_finger_distal")
1685+
middle_finger = robot.get_link("middle_finger_distal")
1686+
1687+
# Different local offsets for each finger (e.g., fingertip positions)
1688+
index_local_offset = torch.tensor([0.0, 0.0, 0.02], dtype=gs.tc_float, device=gs.device)
1689+
middle_local_offset = torch.tensor([0.0, 0.0, 0.02], dtype=gs.tc_float, device=gs.device)
1690+
1691+
# Target positions for the fingertips
1692+
index_target = torch.tensor([0.6, 0.5, 0.2], dtype=gs.tc_float, device=gs.device)
1693+
middle_target = torch.tensor([0.63, 0.5, 0.2], dtype=gs.tc_float, device=gs.device)
1694+
1695+
# Solve multi-link IK with local_points
1696+
qpos, err = robot.inverse_kinematics_multilink(
1697+
links=[index_finger, middle_finger],
1698+
poss=[index_target, middle_target],
1699+
local_points=[index_local_offset, middle_local_offset],
1700+
return_error=True,
1701+
)
1702+
1703+
# Apply solution
1704+
robot.set_qpos(qpos)
1705+
scene.step()
1706+
1707+
# Verify each offset point is at its target
1708+
for link, local_offset, target in [
1709+
(index_finger, index_local_offset, index_target),
1710+
(middle_finger, middle_local_offset, middle_target),
1711+
]:
1712+
link_pos = link.get_pos()
1713+
link_quat = link.get_quat()
1714+
world_offset = gu.transform_by_quat(local_offset, link_quat)
1715+
actual_point_pos = link_pos + world_offset
1716+
assert_allclose(actual_point_pos, target, tol=TOL)
1717+
1718+
if show_viewer:
1719+
scene.visualizer.update()
1720+
1721+
15781722
@pytest.mark.slow # ~180s
15791723
@pytest.mark.required
15801724
@pytest.mark.parametrize("n_envs", [0, 2])

0 commit comments

Comments
 (0)