Skip to content

Commit 28cf077

Browse files
authored
Adds configurable target coordinate frame for teleop output poses (#5050)
# Description Add a target_T_world parameter to IsaacTeleopDevice.advance() and TeleopSessionLifecycle.step() that left-multiplies the anchor matrix to rebase all output poses into an arbitrary target coordinate frame (e.g. robot base link for IK). Add a config-driven alternative via IsaacTeleopCfg.target_frame_prim_path: when set to a USD prim path, the device automatically reads the prim's world transform each frame via USDRT/Fabric and uses its inverse as the rebase matrix, removing the need for callers to compute it manually. Add _to_numpy_4x4 helper that accepts np.ndarray, torch.Tensor, wp.array, or any duck-typed .numpy() object and normalizes to float32 NumPy. Add comprehensive unit tests for the conversion helper, matrix rebase math, and config-vs-explicit auto-selection logic. Fixes # (issue) ## Type of change <!-- As you go through the list, delete the ones that are not applicable. --> - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [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
1 parent 06e4c91 commit 28cf077

File tree

6 files changed

+519
-11
lines changed

6 files changed

+519
-11
lines changed

source/isaaclab_teleop/config/extension.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
# Semantic Versioning is used: https://semver.org/
3-
version = "0.3.3"
3+
version = "0.3.4"
44

55
# Description
66
title = "Isaac Lab Teleop"

source/isaaclab_teleop/docs/CHANGELOG.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
Changelog
22
---------
33

4+
0.3.4 (2026-03-17)
5+
~~~~~~~~~~~~~~~~~~~
6+
7+
Added
8+
^^^^^
9+
10+
* Added :attr:`~isaaclab_teleop.IsaacTeleopCfg.target_frame_prim_path` for
11+
config-driven frame rebasing. When set to a USD prim path, the device
12+
automatically reads the prim's world transform each frame and uses its
13+
inverse as the ``target_T_world`` rebase matrix, so all output poses are
14+
expressed in the target frame (e.g. robot base link for IK).
15+
16+
* Added ``target_T_world`` parameter to
17+
:meth:`~isaaclab_teleop.IsaacTeleopDevice.advance` for rebasing all output
18+
poses into an arbitrary target coordinate frame (e.g. robot base link for
19+
IK). Accepts :class:`numpy.ndarray`, :class:`torch.Tensor`, or
20+
``wp.array``.
21+
22+
423
0.3.3 (2026-03-13)
524
~~~~~~~~~~~~~~~~~~~
625

source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,26 @@ def build_pipeline():
108108
If ``None``, the tuning UI will not be opened.
109109
"""
110110

111+
target_frame_prim_path: str | None = None
112+
"""Optional USD prim path whose world frame becomes the target coordinate
113+
frame for all output poses.
114+
115+
When set, the device automatically reads this prim's world transform each
116+
frame and uses its inverse as the ``target_T_world`` rebase matrix in
117+
:meth:`~isaaclab_teleop.IsaacTeleopDevice.advance`. An explicit
118+
``target_T_world`` argument to :meth:`~isaaclab_teleop.IsaacTeleopDevice.advance`
119+
takes precedence over this config.
120+
121+
Typical usage: set to the robot base link prim path so that an IK
122+
controller receives end-effector poses in the robot's base frame.
123+
124+
Example::
125+
126+
IsaacTeleopCfg(
127+
target_frame_prim_path="/World/envs/env_0/Robot/base_link",
128+
...
129+
)
130+
"""
131+
111132
app_name: str = "IsaacLabTeleop"
112133
"""Application name for the IsaacTeleop session."""

source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@
99

1010
import logging
1111
from collections.abc import Callable
12+
from typing import TYPE_CHECKING
1213

14+
import numpy as np
1315
import torch
1416

1517
from .command_handler import CommandHandler
1618
from .isaac_teleop_cfg import IsaacTeleopCfg
1719
from .session_lifecycle import TeleopSessionLifecycle
1820
from .xr_anchor_manager import XrAnchorManager
1921

22+
if TYPE_CHECKING:
23+
from .session_lifecycle import SupportsDLPack
24+
2025
logger = logging.getLogger(__name__)
2126

2227

@@ -42,6 +47,24 @@ class IsaacTeleopDevice:
4247
The device uses IsaacTeleop's TensorReorderer to flatten pipeline outputs
4348
into a single action tensor matching the environment's action space.
4449
50+
Frame rebasing:
51+
By default, all output poses are expressed in the simulation world
52+
frame. When an application needs poses in a different frame (e.g.
53+
robot base link for IK), there are two options:
54+
55+
* **Config-driven** (recommended): set
56+
:attr:`~IsaacTeleopCfg.target_frame_prim_path` to the USD prim
57+
whose frame the output should be expressed in. The device reads
58+
the prim's world transform each frame and applies the rebase
59+
automatically.
60+
* **Explicit**: pass a ``target_T_world`` matrix directly to
61+
:meth:`advance`.
62+
63+
In both cases the device composes
64+
``target_T_world @ world_T_anchor`` before feeding the matrix into
65+
the retargeting pipeline, so all resulting poses are expressed in the
66+
target frame.
67+
4568
Teleop commands:
4669
The device supports callbacks for START, STOP, and RESET commands
4770
that can be triggered via XR controller buttons or the message bus.
@@ -54,10 +77,25 @@ class IsaacTeleopDevice:
5477
sim_device="cuda:0",
5578
)
5679
80+
# Poses in world frame (default)
81+
with IsaacTeleopDevice(cfg) as device:
82+
while running:
83+
action = device.advance()
84+
env.step(action.repeat(num_envs, 1))
85+
86+
# Config-driven rebase into robot base frame
87+
cfg.target_frame_prim_path = "/World/Robot/base_link"
5788
with IsaacTeleopDevice(cfg) as device:
5889
while running:
5990
action = device.advance()
6091
env.step(action.repeat(num_envs, 1))
92+
93+
# Explicit rebase into robot base frame
94+
with IsaacTeleopDevice(cfg) as device:
95+
while running:
96+
robot_T_world = get_robot_base_transform()
97+
action = device.advance(target_T_world=robot_T_world)
98+
env.step(action.repeat(num_envs, 1))
6199
"""
62100

63101
def __init__(self, cfg: IsaacTeleopCfg):
@@ -147,14 +185,35 @@ def add_callback(self, key: str, func: Callable) -> None:
147185
"""
148186
self._command_handler.add_callback(key, func)
149187

150-
def advance(self) -> torch.Tensor | None:
188+
def advance(self, target_T_world: np.ndarray | torch.Tensor | SupportsDLPack | None = None) -> torch.Tensor | None:
151189
"""Process current device state and return control commands.
152190
153191
If the IsaacTeleop session has not been started yet (because the OpenXR
154192
handles were not available at ``__enter__`` time), this method will
155193
attempt to start it on each call. Once the user clicks "Start AR" and
156194
the handles become available, the session is created transparently.
157195
196+
Args:
197+
target_T_world: Optional 4x4 transform matrix that rebases all
198+
output poses into an arbitrary target coordinate frame. When
199+
provided, the matrix sent to the retargeting pipeline becomes
200+
``target_T_world @ world_T_anchor`` instead of just
201+
``world_T_anchor``, so all resulting poses are expressed in
202+
the target frame rather than the simulation world frame.
203+
204+
Typical use case: pass ``robot_base_T_world`` so that an IK
205+
controller receives end-effector poses in the robot's base
206+
link frame.
207+
208+
Accepts any object supporting the DLPack buffer protocol
209+
(``__dlpack__``), including :class:`numpy.ndarray`,
210+
:class:`torch.Tensor`, and ``wp.array``.
211+
212+
When ``None`` and
213+
:attr:`~IsaacTeleopCfg.target_frame_prim_path` is set, the
214+
transform is computed automatically by reading the prim's
215+
world matrix from Fabric and inverting it.
216+
158217
Returns:
159218
A flattened action :class:`torch.Tensor` ready for the Isaac Lab
160219
environment, or ``None`` if the session has not started yet
@@ -163,9 +222,14 @@ def advance(self) -> torch.Tensor | None:
163222
Raises:
164223
RuntimeError: If called outside of a context manager.
165224
"""
225+
# Auto-compute target_T_world from config if not explicitly provided
226+
if target_T_world is None and self._cfg.target_frame_prim_path is not None:
227+
target_T_world = self._get_target_frame_T_world()
228+
166229
# Step the session (handles lazy start and action extraction)
167230
action = self._session_lifecycle.step(
168231
anchor_world_matrix_fn=self._anchor_manager.get_world_matrix,
232+
target_T_world=target_T_world,
169233
)
170234

171235
if action is not None:
@@ -174,6 +238,75 @@ def advance(self) -> torch.Tensor | None:
174238

175239
return action
176240

241+
# ------------------------------------------------------------------
242+
# Target frame transform (config-driven rebase)
243+
# ------------------------------------------------------------------
244+
245+
def _get_target_frame_T_world(self) -> np.ndarray | None:
246+
"""Read the target-frame prim's world matrix from Fabric and return its inverse.
247+
248+
Uses USDRT to read the prim's hierarchical world matrix, matching the
249+
pattern used by :class:`XrAnchorSynchronizer` for anchor prim reads.
250+
251+
Returns:
252+
A (4, 4) float32 :class:`numpy.ndarray` representing the inverse
253+
of the prim's world transform (i.e. ``target_T_world``), or
254+
``None`` if the prim cannot be read.
255+
"""
256+
try:
257+
import omni.usd
258+
import usdrt
259+
from pxr import UsdUtils
260+
from usdrt import Rt
261+
262+
stage = omni.usd.get_context().get_stage()
263+
stage_cache = UsdUtils.StageCache.Get()
264+
stage_id = stage_cache.GetId(stage).ToLongInt()
265+
if stage_id < 0:
266+
stage_id = stage_cache.Insert(stage).ToLongInt()
267+
rt_stage = usdrt.Usd.Stage.Attach(stage_id)
268+
if rt_stage is None:
269+
return None
270+
271+
rt_prim = rt_stage.GetPrimAtPath(self._cfg.target_frame_prim_path)
272+
if not rt_prim.IsValid():
273+
return None
274+
275+
rt_xformable = Rt.Xformable(rt_prim)
276+
if not rt_xformable.GetPrim().IsValid():
277+
return None
278+
279+
world_matrix_attr = rt_xformable.GetFabricHierarchyWorldMatrixAttr()
280+
if world_matrix_attr is None:
281+
return None
282+
283+
rt_matrix = world_matrix_attr.Get()
284+
if rt_matrix is None:
285+
return None
286+
287+
pos = rt_matrix.ExtractTranslation()
288+
rt_quat = rt_matrix.ExtractRotationQuat()
289+
290+
from scipy.spatial.transform import Rotation
291+
292+
quat_xyzw = [
293+
float(rt_quat.GetImaginary()[0]),
294+
float(rt_quat.GetImaginary()[1]),
295+
float(rt_quat.GetImaginary()[2]),
296+
float(rt_quat.GetReal()),
297+
]
298+
299+
R = Rotation.from_quat(quat_xyzw).as_matrix().astype(np.float32)
300+
t = np.array([float(pos[0]), float(pos[1]), float(pos[2])], dtype=np.float32)
301+
302+
inv_mat = np.eye(4, dtype=np.float32)
303+
inv_mat[:3, :3] = R.T
304+
inv_mat[:3, 3] = -(R.T @ t)
305+
return inv_mat
306+
except Exception as e:
307+
logger.warning(f"Failed to read target frame prim '{self._cfg.target_frame_prim_path}': {e}")
308+
return None
309+
177310
# ------------------------------------------------------------------
178311
# Controller button polling (glue between session and anchor manager)
179312
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)