Skip to content

Commit 4592efd

Browse files
committed
Fix L6 hand pose mapping
1 parent 0be0fa0 commit 4592efd

2 files changed

Lines changed: 113 additions & 39 deletions

File tree

teleopit/sim2real/dexterous_hand.py

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import time
1010
from typing import Any, Protocol, Sequence
1111

12+
import numpy as np
13+
1214
from teleopit.inputs.pico4_provider import (
1315
PicoControllerSnapshot,
1416
PicoControllerState,
@@ -28,6 +30,16 @@
2830
HAND_MODES = ("off", "gripper", "vr_hand_pose")
2931
DEFAULT_SOMEHAND_CONFIG_PATH = "third_party/somehand/configs/retargeting/bihand/linkerhand_l6_bihand.yaml"
3032
DEFAULT_LINKERHAND_SDK_ROOT = "third_party/linkerhand-python-sdk"
33+
L6_QPOS_CHANNELS = (
34+
"thumb_cmc_pitch",
35+
"thumb_cmc_yaw",
36+
"index_mcp_pitch",
37+
"middle_mcp_pitch",
38+
"ring_mcp_pitch",
39+
"pinky_mcp_pitch",
40+
)
41+
L6_QPOS_MIN = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float64)
42+
L6_QPOS_MAX = np.array([0.99, 1.39, 1.26, 1.26, 1.26, 1.26], dtype=np.float64)
3143

3244

3345
class ControllerSnapshotProvider(Protocol):
@@ -104,6 +116,55 @@ def trigger_to_pose(
104116
return pose
105117

106118

119+
class L6RetargetPoseMapper:
120+
"""Map somehand-retargeted L6 qpos into Teleopit's six-channel L6 SDK pose."""
121+
122+
def __init__(self, hand_model: Any | None, config: LinkerHandConfig, *, hand_type: str):
123+
self._config = config
124+
self._hand_type = hand_type
125+
self._indices = self._resolve_indices(hand_model, hand_type=hand_type)
126+
127+
def qpos_to_pose(self, qpos: Any) -> list[int]:
128+
values = np.asarray(qpos, dtype=np.float64).reshape(-1)
129+
if self._indices is None:
130+
if values.shape[0] < len(L6_QPOS_CHANNELS):
131+
raise ValueError(
132+
"somehand L6 retarget qpos is too short: "
133+
f"got {values.shape[0]}, need at least {len(L6_QPOS_CHANNELS)}"
134+
)
135+
channel_values = values[:len(L6_QPOS_CHANNELS)]
136+
else:
137+
channel_values = values[self._indices]
138+
139+
normalized = np.clip((channel_values - L6_QPOS_MIN) / (L6_QPOS_MAX - L6_QPOS_MIN), 0.0, 1.0)
140+
pose = [
141+
int(round(float(open_value) + float(alpha) * (float(close_value) - float(open_value))))
142+
for open_value, close_value, alpha in zip(
143+
self._config.open_pose,
144+
self._config.close_pose,
145+
normalized,
146+
)
147+
]
148+
pose[1] = int(self._config.thumb_yaw_center)
149+
return [_uint8(value, f"somehand.{self._hand_type}.pose") for value in pose]
150+
151+
@staticmethod
152+
def _resolve_indices(hand_model: Any | None, *, hand_type: str) -> np.ndarray | None:
153+
if hand_model is None:
154+
return None
155+
get_index = getattr(hand_model, "get_joint_name_to_qpos_index", None)
156+
if not callable(get_index):
157+
return None
158+
joint_index = get_index()
159+
indices: list[int] = []
160+
for channel in L6_QPOS_CHANNELS:
161+
resolved = _resolve_l6_joint_name(joint_index, channel, hand_type=hand_type)
162+
if resolved is None:
163+
return None
164+
indices.append(int(joint_index[resolved]))
165+
return np.asarray(indices, dtype=np.int64)
166+
167+
107168
def parse_linkerhand_config(cfg: Any) -> LinkerHandConfig:
108169
hand_cfg = cfg_get(cfg, "dexterous_hand", {}) or {}
109170
raw_mode = cfg_get(hand_cfg, "mode", None)
@@ -472,7 +533,7 @@ def __init__(self, config: LinkerHandConfig, provider: HandSnapshotProvider):
472533
self._hand_frame_cls: Any | None = None
473534
self._bihand_frame_cls: Any | None = None
474535
self._pico_hand_to_landmarks: Any | None = None
475-
self._adapters: dict[str, Any] = {}
536+
self._pose_mappers: dict[str, L6RetargetPoseMapper] = {}
476537

477538
@property
478539
def enabled(self) -> bool:
@@ -526,7 +587,7 @@ def _tick_snapshot(self, snapshot: PicoHandSnapshot) -> None:
526587
):
527588
if hand_type not in self.config.selected_hand_types or not detected:
528589
continue
529-
pose = self._adapters[hand_type].qpos_to_sdk_range(step.qpos)
590+
pose = self._pose_mappers[hand_type].qpos_to_pose(step.qpos)
530591
self._sender.send(hand_type, pose, reason="vr-hand-pose")
531592

532593
def _make_hand_frame(self, hand_type: str, state: PicoHandState) -> Any | None:
@@ -548,7 +609,6 @@ def _deactivate(self, *, reason: str) -> None:
548609
def _load_somehand(self) -> None:
549610
try:
550611
from somehand.api import BiHandFrame, BiHandRetargetingEngine, HandFrame
551-
from somehand.infrastructure import HandModel, LinkerHandModelAdapter, infer_linkerhand_model_family
552612
from somehand.pico_input import pico_hand_to_landmarks
553613
except ImportError as exc:
554614
raise ImportError(
@@ -563,22 +623,20 @@ def _load_somehand(self) -> None:
563623
f"{config_path}. Initialize the submodule and download assets with "
564624
"scripts/setup/download_somehand_l6_assets.sh"
565625
)
566-
sdk_root = _resolve_project_path(self.config.somehand_sdk_root)
567626
self._engine = BiHandRetargetingEngine.from_config_path(str(config_path))
568627
self._hand_frame_cls = HandFrame
569628
self._bihand_frame_cls = BiHandFrame
570629
self._pico_hand_to_landmarks = pico_hand_to_landmarks
571630

572-
self._adapters = {}
631+
# somehand owns hand-pose retargeting; Teleopit owns the LinkerHand L6 command mapping.
632+
self._pose_mappers = {}
573633
for hand_type, engine in (("left", self._engine.left_engine), ("right", self._engine.right_engine)):
574634
if hand_type not in self.config.selected_hand_types:
575635
continue
576-
family = infer_linkerhand_model_family(engine.config.hand.name)
577-
self._adapters[hand_type] = LinkerHandModelAdapter(
578-
HandModel(engine.config.hand.mjcf_path),
579-
family=family,
580-
hand_side=hand_type,
581-
sdk_root=str(sdk_root),
636+
self._pose_mappers[hand_type] = L6RetargetPoseMapper(
637+
getattr(engine, "hand_model", None),
638+
self.config,
639+
hand_type=hand_type,
582640
)
583641
logger.info("somehand LinkerHand L6 runtime started | hands=%s", ",".join(self.config.selected_hand_types))
584642

@@ -632,6 +690,23 @@ def _resolve_project_path(path_value: str) -> Path:
632690
return (PROJECT_ROOT / path).resolve()
633691

634692

693+
def _resolve_l6_joint_name(joint_index: dict[str, int], semantic_name: str, *, hand_type: str) -> str | None:
694+
candidates = (
695+
semantic_name,
696+
f"{hand_type}_{semantic_name}",
697+
f"{hand_type[0]}_{semantic_name}",
698+
f"{'lh' if hand_type == 'left' else 'rh'}_{semantic_name}",
699+
)
700+
for candidate in candidates:
701+
if candidate in joint_index:
702+
return candidate
703+
suffix = f"_{semantic_name}"
704+
for name in joint_index:
705+
if name.endswith(suffix):
706+
return name
707+
return None
708+
709+
635710
def _positive_float(value: object, field_name: str) -> float:
636711
parsed = float(value)
637712
if parsed <= 0.0:

tests/test_dexterous_hand.py

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def close_can(self) -> None:
276276
assert created_hands[0].hand.close_calls == 1
277277

278278

279-
def _install_fake_somehand(monkeypatch, *, left_pose: list[int], right_pose: list[int]) -> None:
279+
def _install_fake_somehand(monkeypatch, *, left_qpos: list[float], right_qpos: list[float]) -> None:
280280
class FakeHandFrame:
281281
def __init__(self, *, landmarks_3d, landmarks_2d, hand_side):
282282
self.landmarks_3d = landmarks_3d
@@ -288,13 +288,26 @@ def __init__(self, *, left=None, right=None):
288288
self.left = left
289289
self.right = right
290290

291+
class FakeHandModel:
292+
def get_joint_name_to_qpos_index(self):
293+
return {
294+
"thumb_cmc_pitch": 0,
295+
"thumb_cmc_yaw": 1,
296+
"index_mcp_pitch": 2,
297+
"middle_mcp_pitch": 3,
298+
"ring_mcp_pitch": 4,
299+
"pinky_mcp_pitch": 5,
300+
}
301+
291302
class FakeEngine:
292303
def __init__(self):
293304
self.left_engine = SimpleNamespace(
294-
config=SimpleNamespace(hand=SimpleNamespace(name="linkerhand_l6_left", mjcf_path="left.xml"))
305+
config=SimpleNamespace(hand=SimpleNamespace(name="linkerhand_l6_left", mjcf_path="left.xml")),
306+
hand_model=FakeHandModel(),
295307
)
296308
self.right_engine = SimpleNamespace(
297-
config=SimpleNamespace(hand=SimpleNamespace(name="linkerhand_l6_right", mjcf_path="right.xml"))
309+
config=SimpleNamespace(hand=SimpleNamespace(name="linkerhand_l6_right", mjcf_path="right.xml")),
310+
hand_model=FakeHandModel(),
298311
)
299312

300313
@classmethod
@@ -305,40 +318,26 @@ def process(self, frame):
305318
return SimpleNamespace(
306319
left_detected=frame.left is not None,
307320
right_detected=frame.right is not None,
308-
left=SimpleNamespace(qpos=np.array([1.0], dtype=np.float64)),
309-
right=SimpleNamespace(qpos=np.array([2.0], dtype=np.float64)),
321+
left=SimpleNamespace(qpos=np.asarray(left_qpos, dtype=np.float64)),
322+
right=SimpleNamespace(qpos=np.asarray(right_qpos, dtype=np.float64)),
310323
)
311324

312-
class FakeHandModel:
313-
def __init__(self, mjcf_path: str):
314-
self.mjcf_path = mjcf_path
315-
316-
class FakeAdapter:
317-
def __init__(self, _hand_model, *, family: str, hand_side: str, sdk_root: str):
318-
del family, sdk_root
319-
self.hand_side = hand_side
320-
321-
def qpos_to_sdk_range(self, _qpos):
322-
return left_pose if self.hand_side == "left" else right_pose
323-
324325
fake_api = SimpleNamespace(
325326
BiHandFrame=FakeBiHandFrame,
326327
BiHandRetargetingEngine=FakeEngine,
327328
HandFrame=FakeHandFrame,
328329
)
329-
fake_infra = SimpleNamespace(
330-
HandModel=FakeHandModel,
331-
LinkerHandModelAdapter=FakeAdapter,
332-
infer_linkerhand_model_family=lambda _name: "L6",
333-
)
334330
fake_pico = SimpleNamespace(pico_hand_to_landmarks=lambda joints: np.asarray(joints, dtype=np.float64)[:21, :3])
335331
monkeypatch.setitem(sys.modules, "somehand.api", fake_api)
336-
monkeypatch.setitem(sys.modules, "somehand.infrastructure", fake_infra)
337332
monkeypatch.setitem(sys.modules, "somehand.pico_input", fake_pico)
338333

339334

340335
def test_vr_hand_pose_runtime_holds_last_pose_when_hand_pose_disappears(monkeypatch, tmp_path) -> None:
341-
_install_fake_somehand(monkeypatch, left_pose=[1, 2, 3, 4, 5, 6], right_pose=[6, 5, 4, 3, 2, 1])
336+
_install_fake_somehand(
337+
monkeypatch,
338+
left_qpos=[0.99, 0.0, 1.26, 1.26, 1.26, 1.26],
339+
right_qpos=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
340+
)
342341
config_path = tmp_path / "linkerhand_l6_bihand.yaml"
343342
config_path.write_text("left: {}\nright: {}\n", encoding="utf-8")
344343
provider = HandSnapshotProvider()
@@ -363,8 +362,8 @@ def test_vr_hand_pose_runtime_holds_last_pose_when_hand_pose_disappears(monkeypa
363362
runtime.tick(active=True, now_s=10.0)
364363
assert runtime._sender.wait_idle(timeout_s=1.0)
365364

366-
assert runtime._sender._last_pose["left"] == [1, 2, 3, 4, 5, 6]
367-
assert runtime._sender._last_pose["right"] == [6, 5, 4, 3, 2, 1]
365+
assert runtime._sender._last_pose["left"] == list(runtime.config.close_pose)
366+
assert runtime._sender._last_pose["right"] == list(runtime.config.open_pose)
368367

369368
provider.snapshot = _hand_snapshot(
370369
left=_hand_state(active=False, value=9.0),
@@ -375,8 +374,8 @@ def test_vr_hand_pose_runtime_holds_last_pose_when_hand_pose_disappears(monkeypa
375374
runtime.tick(active=True, now_s=10.1)
376375
assert runtime._sender.wait_idle(timeout_s=1.0)
377376

378-
assert runtime._sender._last_pose["left"] == [1, 2, 3, 4, 5, 6]
379-
assert runtime._sender._last_pose["right"] == [6, 5, 4, 3, 2, 1]
377+
assert runtime._sender._last_pose["left"] == list(runtime.config.close_pose)
378+
assert runtime._sender._last_pose["right"] == list(runtime.config.open_pose)
380379

381380
runtime.tick(active=False, now_s=10.2)
382381
assert runtime._sender.wait_idle(timeout_s=1.0)

0 commit comments

Comments
 (0)