99import time
1010from typing import Any , Protocol , Sequence
1111
12+ import numpy as np
13+
1214from teleopit .inputs .pico4_provider import (
1315 PicoControllerSnapshot ,
1416 PicoControllerState ,
2830HAND_MODES = ("off" , "gripper" , "vr_hand_pose" )
2931DEFAULT_SOMEHAND_CONFIG_PATH = "third_party/somehand/configs/retargeting/bihand/linkerhand_l6_bihand.yaml"
3032DEFAULT_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
3345class 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+
107168def 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+
635710def _positive_float (value : object , field_name : str ) -> float :
636711 parsed = float (value )
637712 if parsed <= 0.0 :
0 commit comments