4. 手眼标定实战

手眼标定,说白了就是让机器人知道“眼睛”长在哪。

你想想看,相机看到了一个零件,坐标是 (x, y, z)。但机器人要去抓,它得知道这个点相对于自己基座在哪。中间差的就是相机和机器人之间的那个变换关系。这个关系,就是手眼标定要解的。

我个人习惯把标定分成两步:先搞清楚用哪种构型,再选对方法解方程。别一上来就套代码,构型选错了,后面全白搭。

4.1 两种构型:Eye-in-Hand 与 Eye-to-Hand

先看一张图,把两种构型的核心逻辑理清楚。

手眼标定两种构型对比 Eye-in-Hand(眼在手上) 机器人末端 相机 标定板 (固定) 相机观测 Eye-to-Hand(眼在手外) 机器人基座 相机 标定板 (装在末端) 相机观测

两种构型的区别,我用一个表格给你讲清楚:

对比项 Eye-in-Hand Eye-to-Hand
相机安装位置 固定在机器人末端(法兰盘) 固定在工作空间外(如天花板、支架)
标定板位置 固定在工作空间内 装在机器人末端
核心求解量 相机 → 末端(camTee) 相机 → 基座(camTbase)
视野特点 随末端移动,视野变化大 视野固定,覆盖整个工作区
典型应用 小零件抓取、装配 大范围定位、码垛
我的经验:做高精度抓取(比如电子元件),我倾向用 Eye-in-Hand。相机离目标近,误差小。但要注意,末端移动时标定板不能出视野,数据采集要覆盖整个工作空间。

4.2 数学本质:解 AX = XB

不管哪种构型,手眼标定的数学本质都一样——解 AX = XB。

什么意思?

  • A:机器人末端两次位姿之间的变换(从机器人控制器读)
  • B:相机两次观测标定板之间的变换(从图像算)
  • X:我们要找的手眼关系矩阵

你想想看,机器人动一下,末端位姿变了,相机看到的标定板位姿也变了。这两个变化之间,就藏着 X。

方程很简单,但解起来有讲究。因为 A 和 B 都有噪声,直接求逆会放大误差。

4.3 Tsai 法与 Navy 法

业界最常用的两种解法:Tsai 法和 Navy 法。我当年刚入行时也纠结过选哪个,后来踩过坑才明白。

对比项 Tsai 法 Navy 法
全称 Tsai & Lenz 两步法 Navy 手眼标定法
核心思路 先解旋转,再解平移 旋转和平移同时优化
数据要求 至少 2 组位姿,建议 5+ 至少 3 组位姿,建议 8+
鲁棒性 对旋转噪声敏感 整体更稳定
实现难度 简单,有现成代码 稍复杂,需 SVD 分解
我的推荐 快速验证、精度要求不高 正式部署、高精度场景
避坑指南:Tsai 法在旋转角很小时(比如末端只平移没怎么转),解出来的旋转矩阵可能不满足正交性。我曾经在一个项目里用 Tsai 法标定,结果抓取偏差 5mm,换成 Navy 法后降到 1mm 以内。

Navy 法的核心代码片段,我贴出来给你参考:

import numpy as np

def navy_calibration(A_list, B_list):
    """
    Navy 法手眼标定
    A_list: 机器人末端位姿变换列表 (4x4)
    B_list: 相机观测变换列表 (4x4)
    返回: X (4x4)
    """
    n = len(A_list)
    M = np.zeros((3*n, 4))
    y = np.zeros((3*n, 1))
    
    for i in range(n):
        Ra = A_list[i][:3, :3]
        Rb = B_list[i][:3, :3]
        ta = A_list[i][:3, 3]
        tb = B_list[i][:3, 3]
        
        # 构造线性方程组
        M[3*i:3*i+3, :] = np.hstack((Ra - np.eye(3), np.zeros((3,1))))
        y[3*i:3*i+3] = ta.reshape(3,1)
    
    # 最小二乘求解
    x, _, _, _ = np.linalg.lstsq(M, y, rcond=None)
    
    # 构造 X 矩阵
    X = np.eye(4)
    X[:3, :3] = ...  # 旋转部分需正交化
    X[:3, 3] = x[:3].flatten()
    return X
注意:上面代码是简化版,实际使用时需要做旋转矩阵正交化(比如用 SVD 投影到 SO(3) 流形上)。完整实现建议用 OpenCV 的 calibrateHandEye() 函数,它内置了多种方法。

4.4 标定板选择

标定板选不好,数据采了也白采。我见过有人用 A4 纸打印棋盘格,结果反光严重,角点检测全飘了。

我的建议:

  • 棋盘格:最常用,角点检测稳定。推荐 7×9 或 9×11 格,格子大小 20-40mm。
  • 圆点阵:圆心检测精度高,适合高精度标定。但要注意光照均匀,否则边缘检测会偏。
  • CharuCo 板:棋盘格 + ArUco 码的结合体。部分遮挡也能检测,适合 Eye-in-Hand 场景(标定板可能出视野)。
标定板类型 检测方式 精度 抗遮挡 推荐场景
棋盘格 角点检测 ★★★ 差 通用场景
圆点阵 圆心拟合 ★★★★ 中 高精度标定
CharuCo 角点 + 编码 ★★★ 好 Eye-in-Hand
小技巧:标定板要贴在刚性平板上,不要用软磁铁贴。我吃过亏——软磁铁贴在机器人末端,一加速就变形,标定结果全废。

4.5 数据采集实战流程

数据采集是手眼标定最耗时的环节。我总结了一套流程,照着做基本不会翻车。

  1. 固定标定板:确保标定板在工作空间内,且不会被机器人撞到。
  2. 规划采集位姿:让机器人末端走 10-15 个不同位姿。要覆盖:
    • 不同高度(Z 轴变化)
    • 不同旋转角度(绕 X、Y、Z 各转 30°-60°)
    • 不同平移位置(X、Y 方向覆盖工作区)
  3. 同步记录:在每个位姿,同时记录:
    • 机器人末端位姿(从控制器读,通常是 4×4 齐次矩阵)
    • 相机图像(保存为图片或直接检测标定板角点)
  4. 数据检查:检查每张图像中标定板是否完整、角点检测是否准确。有问题的直接删掉。
  5. 运行标定:用 OpenCV 的 calibrateHandEye() 求解。
重要:采集位姿时,相邻两个位姿的旋转角度差不要太小(至少 10°)。否则 A 和 B 矩阵接近奇异,解出来的 X 误差巨大。我见过有人采了 20 组数据,结果位姿几乎没变,标定结果完全不能用。

数据采集的伪代码,我写给你:

# 伪代码:Eye-in-Hand 数据采集
poses = generate_poses(n=15, range_xyz=[-100,100], range_rpy=[-30,30])
data = []

for pose in poses:
    # 1. 移动机器人到目标位姿
    robot.move_to(pose)
    
    # 2. 读取当前末端位姿(从控制器)
    T_ee_base = robot.get_current_pose()
    
    # 3. 拍照并检测标定板
    img = camera.capture()
    ret, corners = detect_chessboard(img, pattern_size=(7,9))
    
    if ret:
        # 4. 计算标定板在相机坐标系下的位姿
        T_board_cam = solve_pnp(corners, board_3d_points, camera_matrix, dist_coeffs)
        
        # 5. 保存数据
        data.append({
            'T_ee_base': T_ee_base,
            'T_board_cam': T_board_cam
        })

# 6. 构造 A 和 B 矩阵
A_list = []
B_list = []
for i in range(len(data)-1):
    A = inv(data[i]['T_ee_base']) @ data[i+1]['T_ee_base']
    B = data[i]['T_board_cam'] @ inv(data[i+1]['T_board_cam'])
    A_list.append(A)
    B_list.append(B)

# 7. 标定
X = cv2.calibrateHandEye(A_list, B_list, method=cv2.CALIB_HAND_EYE_TSAI)
核心要点:标定结果的好坏,70% 取决于数据质量,30% 取决于算法。花时间把数据采好,比调算法参数管用得多。

最后说一句,手眼标定不是一次性的活。换镜头、换相机、机器人拆装后,都要重新标。我一般在项目里留一个标定脚本,现场半小时就能跑完。