Skip to content

Commit 1124d99

Browse files
committed
fix(retargeters): guard against invalid controller grip poses in Se3 retargeters
A connected controller with pose tracking lost (e.g. resting on a table) produces a present ControllerInput group with grip_is_valid=False and a zero-norm grip orientation. Se3AbsRetargeter and Se3RelRetargeter fed that quaternion straight to Rotation.from_quat, raising 'ValueError: Found zero norm quaternions' and killing the retargeting pipeline (in pipelined mode the async retarget worker dies permanently, forcing consumers into session teardown/restart loops). Guard the controller branch with GRIP_IS_VALID, mirroring the hand branch's joint_valid handling: Abs holds the last pose (same as the is_none path); Rel emits a zero delta and re-arms the first-frame baseline so the first valid frame after recovery does not jump. Signed-off-by: Hougant Chen <hougantc@nvidia.com>
1 parent 5da61a9 commit 1124d99

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""
5+
Tests for controller pose validity handling in the Se3 retargeters.
6+
7+
A connected controller can report an invalid grip pose (e.g. resting on a
8+
table with pose tracking lost): the ControllerInput group is present (not
9+
``is_none``) but ``grip_is_valid`` is False and the orientation is a
10+
zero-norm quaternion. Regression: Se3AbsRetargeter/Se3RelRetargeter used to
11+
feed that quaternion to ``Rotation.from_quat``, raising
12+
``ValueError: Found zero norm quaternions in `quat`.`` and killing the
13+
retargeting pipeline.
14+
"""
15+
16+
import numpy as np
17+
import numpy.testing as npt
18+
import pytest
19+
20+
from isaacteleop.retargeting_engine.interface import (
21+
ComputeContext,
22+
ExecutionEvents,
23+
ExecutionState,
24+
OptionalTensorGroup,
25+
TensorGroup,
26+
)
27+
from isaacteleop.retargeting_engine.interface.retargeter_core_types import GraphTime
28+
from isaacteleop.retargeting_engine.interface.tensor_group_type import (
29+
OptionalTensorGroupType,
30+
)
31+
from isaacteleop.retargeting_engine.tensor_types import ControllerInputIndex
32+
33+
from isaacteleop.retargeters import (
34+
Se3AbsRetargeter,
35+
Se3RelRetargeter,
36+
Se3RetargeterConfig,
37+
)
38+
39+
_DEVICE = "controller_right"
40+
41+
42+
def _make_context() -> ComputeContext:
43+
return ComputeContext(
44+
graph_time=GraphTime(sim_time_ns=0, real_time_ns=0),
45+
execution_events=ExecutionEvents(
46+
reset=False, execution_state=ExecutionState.RUNNING
47+
),
48+
)
49+
50+
51+
def _build_io(retargeter):
52+
"""Build inputs/outputs for a retargeter, using OptionalTensorGroup for optional specs."""
53+
inputs = {}
54+
for k, v in retargeter.input_spec().items():
55+
if isinstance(v, OptionalTensorGroupType):
56+
inputs[k] = OptionalTensorGroup(v)
57+
else:
58+
inputs[k] = TensorGroup(v)
59+
outputs = {}
60+
for k, v in retargeter.output_spec().items():
61+
if isinstance(v, OptionalTensorGroupType):
62+
outputs[k] = OptionalTensorGroup(v)
63+
else:
64+
outputs[k] = TensorGroup(v)
65+
return inputs, outputs
66+
67+
68+
def _fill_controller(group, *, grip_valid: bool, position=(0.0, 0.0, 0.0)) -> None:
69+
"""Populate a present ControllerInput group.
70+
71+
When *grip_valid* is False the orientation is the zero-norm quaternion a
72+
real ControllersSource emits for a connected controller whose pose
73+
tracking is lost.
74+
"""
75+
orientation = (
76+
np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
77+
if grip_valid
78+
else np.zeros(4, dtype=np.float32)
79+
)
80+
group[ControllerInputIndex.GRIP_POSITION] = np.asarray(position, dtype=np.float32)
81+
group[ControllerInputIndex.GRIP_ORIENTATION] = orientation
82+
group[ControllerInputIndex.GRIP_IS_VALID] = grip_valid
83+
84+
85+
class TestSe3AbsRetargeterPoseValidity:
86+
@pytest.fixture()
87+
def retargeter(self):
88+
cfg = Se3RetargeterConfig(input_device=_DEVICE)
89+
return Se3AbsRetargeter(cfg, name="se3abs")
90+
91+
def test_invalid_grip_holds_last_pose(self, retargeter):
92+
"""Present-but-invalid grip must not raise and must hold the last pose."""
93+
inputs, outputs = _build_io(retargeter)
94+
stale = np.array([1.0, 2.0, 3.0, 0.5, 0.5, 0.5, 0.5], dtype=np.float32)
95+
retargeter._last_pose = stale.copy()
96+
97+
_fill_controller(inputs[_DEVICE], grip_valid=False)
98+
retargeter.compute(inputs, outputs, _make_context())
99+
100+
pose = np.from_dlpack(outputs["ee_pose"][0])
101+
npt.assert_array_almost_equal(pose, stale)
102+
103+
def test_valid_grip_after_invalid_resumes(self, retargeter):
104+
"""Retargeting must resume normally once the grip pose is valid again."""
105+
inputs, outputs = _build_io(retargeter)
106+
107+
_fill_controller(inputs[_DEVICE], grip_valid=False)
108+
retargeter.compute(inputs, outputs, _make_context())
109+
110+
_fill_controller(inputs[_DEVICE], grip_valid=True, position=(0.1, 0.2, 0.3))
111+
retargeter.compute(inputs, outputs, _make_context())
112+
113+
pose = np.from_dlpack(outputs["ee_pose"][0])
114+
npt.assert_array_almost_equal(pose[:3], [0.1, 0.2, 0.3])
115+
116+
117+
class TestSe3RelRetargeterPoseValidity:
118+
@pytest.fixture()
119+
def retargeter(self):
120+
cfg = Se3RetargeterConfig(input_device=_DEVICE)
121+
return Se3RelRetargeter(cfg, name="se3rel")
122+
123+
def test_invalid_grip_emits_zero_delta(self, retargeter):
124+
"""Present-but-invalid grip must not raise and must emit a zero delta."""
125+
inputs, outputs = _build_io(retargeter)
126+
127+
# Establish a baseline with a valid frame first.
128+
_fill_controller(inputs[_DEVICE], grip_valid=True)
129+
retargeter.compute(inputs, outputs, _make_context())
130+
131+
_fill_controller(inputs[_DEVICE], grip_valid=False)
132+
retargeter.compute(inputs, outputs, _make_context())
133+
134+
delta = np.from_dlpack(outputs["ee_delta"][0])
135+
npt.assert_array_almost_equal(delta, np.zeros(6))
136+
137+
def test_recovery_rebaselines_without_jump(self, retargeter):
138+
"""The first valid frame after an invalid stretch must not emit a jump."""
139+
inputs, outputs = _build_io(retargeter)
140+
141+
_fill_controller(inputs[_DEVICE], grip_valid=True)
142+
retargeter.compute(inputs, outputs, _make_context())
143+
144+
_fill_controller(inputs[_DEVICE], grip_valid=False)
145+
retargeter.compute(inputs, outputs, _make_context())
146+
147+
# Controller re-tracks far from the pre-loss baseline.
148+
_fill_controller(inputs[_DEVICE], grip_valid=True, position=(1.0, 1.0, 1.0))
149+
retargeter.compute(inputs, outputs, _make_context())
150+
151+
delta = np.from_dlpack(outputs["ee_delta"][0])
152+
npt.assert_array_almost_equal(delta, np.zeros(6))

src/retargeters/se3_retargeter.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,13 @@ def _compute_fn(self, inputs: RetargeterIO, outputs: RetargeterIO, context) -> N
249249
]
250250
)
251251
else:
252+
# A connected controller can report an invalid grip pose (e.g. at
253+
# rest with pose tracking lost) whose zero-norm orientation
254+
# Rotation.from_quat rejects. Hold the last pose, matching the
255+
# is_none path and the hand branch's joint_valid handling.
256+
if not bool(inp[ControllerInputIndex.GRIP_IS_VALID]):
257+
ee_pose[0] = self._last_pose
258+
return
252259
grip_pos = np.from_dlpack(inp[ControllerInputIndex.GRIP_POSITION])
253260
grip_ori = np.from_dlpack(
254261
inp[ControllerInputIndex.GRIP_ORIENTATION]
@@ -384,6 +391,14 @@ def _compute_fn(self, inputs: RetargeterIO, outputs: RetargeterIO, context) -> N
384391
]
385392
)
386393
else:
394+
# A connected controller can report an invalid grip pose (e.g. at
395+
# rest with pose tracking lost) whose zero-norm orientation
396+
# Rotation.from_quat rejects. Emit a zero delta and re-arm the
397+
# first-frame baseline so the next valid frame does not jump.
398+
if not bool(inp[ControllerInputIndex.GRIP_IS_VALID]):
399+
self._first_frame = True
400+
ee_delta[0] = np.zeros(6, dtype=np.float32)
401+
return
387402
grip_pos = np.from_dlpack(inp[ControllerInputIndex.GRIP_POSITION])
388403
grip_ori = np.from_dlpack(inp[ControllerInputIndex.GRIP_ORIENTATION])
389404
wrist = np.concatenate([grip_pos, grip_ori])

0 commit comments

Comments
 (0)