人手到机器人手映射详解¶
从视觉捕捉的 21 点坐标到机器人关节角的完整 pipeline,包括坐标系转换、左右手镜像、关节限位等工程细节。
MediaPipe 21 点模型¶
MediaPipe Hands 输出人手的 21 个 3D landmarks,编号如下:
手腕 (0)
│
├─ 拇指: CMC(1) → MCP(2) → IP(3) → TIP(4)
├─ 食指: MCP(5) → PIP(6) → DIP(7) → TIP(8)
├─ 中指: MCP(9) → PIP(10) → DIP(11) → TIP(12)
├─ 无名指: MCP(13) → PIP(14) → DIP(15) → TIP(16)
└─ 小指: MCP(17) → PIP(18) → DIP(19) → TIP(20)
关键点分组¶
FINGER_INDICES = {
"thumb": [1, 2, 3, 4],
"index": [5, 6, 7, 8],
"middle": [9, 10, 11, 12],
"ring": [13, 14, 15, 16],
"pinky": [17, 18, 19, 20],
}
WRIST_IDX = 0
MCP_INDICES = [2, 5, 9, 13, 17] # 拇指 MCP 是 2,其他手指 MCP 是 5,9,13,17
坐标系转换¶
步骤 1:转换到手腕局部坐标系¶
视觉捕捉的 landmarks 通常在相机坐标系下,需要转换到以手腕为原点的局部坐标系。
def to_local_coordinates(landmarks):
"""
将 landmarks 从相机坐标系转换到手腕局部坐标系
Args:
landmarks: [21, 3] 3D 坐标
Returns:
local_landmarks: [21, 3] 以手腕为原点的坐标
"""
wrist = landmarks[0]
local = landmarks - wrist # 平移:手腕 → 原点
return local
步骤 2:尺度归一化¶
以手腕到中指 MCP 的距离作为单位长度,消除不同人手尺寸的影响。
def normalize_scale(local_landmarks):
"""
尺度归一化
"""
wrist = local_landmarks[0]
middle_mcp = local_landmarks[9]
scale = np.linalg.norm(middle_mcp - wrist)
if scale < 1e-6:
return local_landmarks # 避免除零
normalized = local_landmarks / scale
return normalized
步骤 3:左右手镜像¶
双手系统中,左手需要进行 Y 轴镜像,确保左右手在局部坐标系中对称。
左手: index @ -Y, pinky @ +Y → Y 取反后: index @ +Y, pinky @ -Y
右手: index @ +Y, pinky @ -Y → 无需处理
def mirror_left_hand(landmarks, is_left):
"""
左手镜像处理
Args:
landmarks: [21, 3] 局部坐标系下的坐标
is_left: 是否为左手
"""
if is_left:
landmarks = landmarks.copy()
landmarks[:, 1] *= -1 # Y 轴取反
return landmarks
完整坐标转换 Pipeline¶
def preprocess_landmarks(landmarks, is_left):
"""
完整的 landmarks 预处理
"""
# 1. 局部坐标系
local = to_local_coordinates(landmarks)
# 2. 尺度归一化
normalized = normalize_scale(local)
# 3. 左右手镜像
mirrored = mirror_left_hand(normalized, is_left)
return mirrored
从 Landmarks 计算角度特征¶
弯曲角(Flexion Angle)¶
相邻关键点向量之间的夹角,反映手指卷曲程度。
def compute_flexion_angle(landmarks, joint_indices):
"""
计算手指关节的弯曲角
Args:
landmarks: [21, 3]
joint_indices: [i, j, k] 三个连续关键点的索引
Returns:
angle: 弯曲角(弧度)
"""
p1 = landmarks[joint_indices[0]]
p2 = landmarks[joint_indices[1]]
p3 = landmarks[joint_indices[2]]
v1 = p1 - p2
v2 = p3 - p2
cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8)
cos_angle = np.clip(cos_angle, -1.0, 1.0)
angle = np.arccos(cos_angle)
return angle
# 示例:计算食指 PIP 弯曲角
index_pip_angle = compute_flexion_angle(landmarks, [5, 6, 7])
外展角(Abduction Angle)¶
相邻手指根部方向向量之间的夹角,反映手指张开程度。
def compute_abduction_angle(landmarks, finger1_base, finger2_base, wrist_idx=0):
"""
计算两根手指的外展角
Args:
finger1_base: 第一根手指的 MCP 索引
finger2_base: 第二根手指的 MCP 索引
"""
wrist = landmarks[wrist_idx]
mcp1 = landmarks[finger1_base]
mcp2 = landmarks[finger2_base]
v1 = mcp1 - wrist
v2 = mcp2 - wrist
cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8)
cos_angle = np.clip(cos_angle, -1.0, 1.0)
angle = np.arccos(cos_angle)
return angle
人手到 O10 灵巧手的映射¶
O10 灵巧手有 10 个主动关节(每根手指 2 个:MCP 弯曲 + PIP 弯曲)。
Rule-based 映射¶
def map_to_o10(human_landmarks, is_left=False):
"""
将人手 21 点坐标映射到 O10 关节角
Returns:
joint_angles: [10] 关节角(弧度)
"""
landmarks = preprocess_landmarks(human_landmarks, is_left)
joints = []
# 拇指: MCP(2), IP(3)
thumb_mcp = compute_flexion_angle(landmarks, [1, 2, 3])
thumb_ip = compute_flexion_angle(landmarks, [2, 3, 4])
joints.extend([thumb_mcp, thumb_ip])
# 食指: MCP(5), PIP(6)
index_mcp = compute_flexion_angle(landmarks, [0, 5, 6])
index_pip = compute_flexion_angle(landmarks, [5, 6, 7])
joints.extend([index_mcp, index_pip])
# 中指: MCP(9), PIP(10)
middle_mcp = compute_flexion_angle(landmarks, [0, 9, 10])
middle_pip = compute_flexion_angle(landmarks, [9, 10, 11])
joints.extend([middle_mcp, middle_pip])
# 无名指: MCP(13), PIP(14)
ring_mcp = compute_flexion_angle(landmarks, [0, 13, 14])
ring_pip = compute_flexion_angle(landmarks, [13, 14, 15])
joints.extend([ring_mcp, ring_pip])
# 小指: MCP(17), PIP(18)
pinky_mcp = compute_flexion_angle(landmarks, [0, 17, 18])
pinky_pip = compute_flexion_angle(landmarks, [17, 18, 19])
joints.extend([pinky_mcp, pinky_pip])
# 转换为 numpy 并应用缩放系数
joints = np.array(joints)
# 经验:调整归一化分母和缩放系数以确保手势到位
# 21点 → finger_curl 往返转换存在衰减,需要补偿
scale_factor = 1.60 # 经验值
joints = joints * scale_factor
# 裁剪到 O10 关节范围
o10_limits = np.array([
[0.0, 1.2], [0.0, 1.2], # 拇指
[0.0, 1.2], [0.0, 1.2], # 食指
[0.0, 1.2], [0.0, 1.2], # 中指
[0.0, 1.2], [0.0, 1.2], # 无名指
[0.0, 1.2], [0.0, 1.2], # 小指
])
joints = np.clip(joints, o10_limits[:, 0], o10_limits[:, 1])
return joints
关键工程细节¶
- 归一化分母调整:从 1.45 调到 0.95,补偿 landmark 到 curl 的衰减
- Actuator 缩放系数:从 1.25 调到 1.60,确保手势到位
- 关节限位:O10 每个关节都有严格的角度范围,必须裁剪
- 左右手镜像:左手 Y 轴取反后再计算角度
常见工程问题与解决方案¶
问题 1:关节振荡¶
症状:机器人手指快速来回抖动
原因: - 视觉捕捉噪声导致 landmarks 抖动 - 控制频率过高,微小变化被放大
解决:
# 时域滤波(指数移动平均)
alpha = 0.3 # 平滑系数
smoothed_joints = alpha * current_joints + (1 - alpha) * prev_joints
问题 2:关节空间插值失真¶
症状:在关节空间做三次样条插值后,任务空间(指尖位置)轨迹不自然
原因:关节空间非线性,插值后 FK 结果与预期不符
解决:
# 正确做法:先对 landmarks 插值,再通过 IK 生成关节角
interpolated_landmarks = spline_interpolate(landmarks_sequence)
for lm in interpolated_landmarks:
joints = ik_solver.solve(lm)
robot.set_joints(joints)
问题 3:拇指校准不准¶
症状:拇指 MCP 角度与其他手指不协调
原因:Vector Optimization 方法中拇指自由度复杂,容易陷入局部最优
解决: - 对拇指使用 Rule-based 映射,其他手指使用 Vector Optimization - 增加拇指的优化权重 - 使用多初始点优化
问题 4:手掌漂移¶
症状:停止运动后,机器人手掌位置缓慢漂移
原因:freejoint 的速度未清零
解决:
# 重置位置和速度
data.qpos[dof_adr:dof_adr+3] = target_position
data.qvel[dof_adr:dof_adr+6] = 0.0 # 关键:速度清零
双手系统注意事项¶
UDP 数据格式¶
左右手 landmarks 在同一 UDP 包中发送:
packet = {
"left_landmarks": [[x, y, z], ...], # 21 点
"right_landmarks": [[x, y, z], ...], # 21 点
}
端口管理¶
- 网页控制器:端口 8782
- UDP 数据:端口 9000
- 避免冲突
双手镜像对称¶
# 左手
left_joints = map_to_o10(packet["left_landmarks"], is_left=True)
# 右手
right_joints = map_to_o10(packet["right_landmarks"], is_left=False)