3. 相机标定进阶

相机标定进阶 学习卡片

任务定义

在激光焊接视觉定位系统中,相机安装于机械臂末端(眼在手上),需建立像素坐标到机器人基座坐标的完整映射。本案例完成手眼标定中的核心矩阵求解:给定一组机械臂末端位姿与对应的标定板角点像素坐标,求解手眼变换矩阵 X(相机到末端),使系统能够通过当前末端位姿将图像坐标转换为基座坐标。

手眼标定原理与数据约定

眼在手上标定满足方程 AX = XB,其中 A 为两次拍摄间机械臂末端的相对运动(4×4齐次矩阵),B 为对应两次拍摄间标定板在相机坐标系下的相对运动。求解 X 后,像素坐标经相机内参转为相机坐标,再经 X 变换到末端坐标系,最后通过当前末端位姿变换到基座坐标系。

本案例提供6组已预处理数据(机械臂末端位姿 T_ee 与标定板角点在相机坐标系下的位姿 T_obj_cam),每组均为4×4齐次矩阵。代码实现Tsai-Lenz两步法:先由旋转方程 RA·RX = RX·RB 求解旋转部分,再通过平移方程求解平移部分。

眼在手上手眼标定数据流 机械臂基座 坐标系 {B} 机械臂末端 坐标系 {E} 相机 坐标系 {C} 标定板 坐标系 {O} T_ee (已知) X (待求) T_obj_cam (已知) AX = XB A = T_ee_i⁻¹ · T_ee_j B = T_obj_cam_i · T_obj_cam_j⁻¹

Python实现(Tsai-Lenz法)


import numpy as np
from scipy.linalg import logm, expm

def hand_eye_calibration(T_ee_list, T_obj_cam_list):
    """
    眼在手上手眼标定:求解 AX = XB
    参数:
        T_ee_list: 机械臂末端位姿列表 (N, 4, 4)
        T_obj_cam_list: 标定板在相机坐标系下的位姿列表 (N, 4, 4)
    返回:
        X: 手眼变换矩阵 (4, 4),相机到末端的变换
    """
    n = len(T_ee_list)
    assert n >= 3, "至少需要3组数据"
    
    # 构造旋转方程的最小二乘矩阵
    M = np.zeros((3 * n * (n-1) // 2, 9))
    row = 0
    for i in range(n):
        for j in range(i+1, n):
            # A = T_ee_i^-1 * T_ee_j
            A = np.linalg.inv(T_ee_list[i]) @ T_ee_list[j]
            # B = T_obj_cam_i * T_obj_cam_j^-1
            B = T_obj_cam_list[i] @ np.linalg.inv(T_obj_cam_list[j])
            
            RA = A[:3, :3]
            RB = B[:3, :3]
            
            # 旋转轴-角度表示
            logA = logm(RA)
            logB = logm(RB)
            pa = np.array([logA[2,1], logA[0,2], logA[1,0]])
            pb = np.array([logB[2,1], logB[0,2], logB[1,0]])
            
            # 叉积矩阵形式: [pa]_x * rx = pb  =>  (I - [pb]_x) * rx = pa
            # 使用 Kronecker 积构造: (pa^T ⊗ I - I ⊗ pb^T) vec(RX) = 0
            K = np.kron(pa.reshape(1,3), np.eye(3)) - np.kron(np.eye(3), pb.reshape(1,3))
            M[row:row+3, :] = K
            row += 3
    
    # SVD求解旋转部分
    _, _, Vt = np.linalg.svd(M)
    rx = Vt[-1, :]
    RX = rx.reshape(3, 3)
    
    # 投影到SO(3)
    U, _, Vt = np.linalg.svd(RX)
    RX = U @ Vt
    if np.linalg.det(RX) < 0:
        Vt[-1, :] *= -1
        RX = U @ Vt
    
    # 求解平移部分
    C = []
    d = []
    for i in range(n):
        for j in range(i+1, n):
            A = np.linalg.inv(T_ee_list[i]) @ T_ee_list[j]
            B = T_obj_cam_list[i] @ np.linalg.inv(T_obj_cam_list[j])
            RA = A[:3, :3]
            tA = A[:3, 3]
            tB = B[:3, 3]
            C.append(RA - np.eye(3))
            d.append(RX @ tB - tA)
    
    C = np.vstack(C)
    d = np.hstack(d)
    tx, _, _, _ = np.linalg.lstsq(C, d, rcond=None)
    
    X = np.eye(4)
    X[:3, :3] = RX
    X[:3, 3] = tx
    return X

# ========== 样例数据(6组实测预处理位姿) ==========
# 每组: 4x4齐次矩阵 [R t; 0 1]
T_ee_data = [
    np.array([[0.998, -0.052, 0.031, 0.512],
              [0.053, 0.998, -0.021, -0.134],
              [-0.030, 0.023, 0.999, 0.876],
              [0, 0, 0, 1]]),
    np.array([[0.985, -0.168, 0.041, 0.498],
              [0.169, 0.985, -0.028, -0.121],
              [-0.036, 0.035, 0.999, 0.892],
              [0, 0, 0, 1]]),
    np.array([[0.972, 0.234, -0.022, 0.523],
              [-0.233, 0.972, 0.031, -0.148],
              [0.029, -0.025, 0.999, 0.861],
              [0, 0, 0, 1]]),
    np.array([[0.991, 0.132, 0.018, 0.505],
              [-0.131, 0.991, 0.035, -0.139],
              [-0.023, -0.032, 0.999, 0.884],
              [0, 0, 0, 1]]),
    np.array([[0.979, -0.205, -0.015, 0.517],
              [0.204, 0.979, 0.026, -0.127],
              [0.020, -0.022, 0.999, 0.869],
              [0, 0, 0, 1]]),
    np.array([[0.995, 0.098, -0.012, 0.508],
              [-0.097, 0.995, 0.029, -0.142],
              [0.015, -0.028, 0.999, 0.881],
              [0, 0, 0, 1]])
]

T_obj_cam_data = [
    np.array([[0.999, 0.012, -0.038, 0.082],
              [-0.011, 0.999, 0.031, -0.045],
              [0.038, -0.031, 0.999, 0.612],
              [0, 0, 0, 1]]),
    np.array([[0.982, 0.185, -0.042, 0.095],
              [-0.184, 0.982, 0.035, -0.052],
              [0.048, -0.027, 0.999, 0.598],
              [0, 0, 0, 1]]),
    np.array([[0.965, -0.258, 0.033, 0.071],
              [0.259, 0.965, -0.028, -0.038],
              [-0.025, 0.036, 0.999, 0.625],
              [0, 0, 0, 1]]),
    np.array([[0.988, -0.152, 0.021, 0.088],
              [0.153, 0.988, -0.030, -0.041],
              [-0.016, 0.033, 0.999, 0.605],
              [0, 0, 0, 1]]),
    np.array([[0.976, 0.218, -0.018, 0.079],
              [-0.217, 0.976, 0.034, -0.049],
              [0.025, -0.029, 0.999, 0.618],
              [0, 0, 0, 1]]),
    np.array([[0.993, -0.118, 0.015, 0.086],
              [0.119, 0.993, -0.026, -0.043],
              [-0.012, 0.028, 0.999, 0.609],
              [0, 0, 0, 1]])
]

# 执行标定
X_result = hand_eye_calibration(T_ee_data, T_obj_cam_data)
print("手眼变换矩阵 X (相机→末端):")
print(np.round(X_result, 4))

验证方法

运行上述代码,观察输出的4×4矩阵。判定标准:

  • 程序正常执行无异常,输出矩阵的旋转部分行列式接近1(误差<1e-3),满足SO(3)约束;
  • 将任意两组数据代入 A @ X_result 与 X_result @ B,两者差值范数应小于0.05,验证方程 AX = XB 成立;
  • 平移部分数值在0.05~0.15量级,与相机安装偏移的物理尺寸相符。

标定完成后,实际使用时通过 P_base = T_ee_current @ X_result @ P_cam 将相机坐标系下的点变换到基座坐标系,其中 P_cam 由像素坐标经相机内参反投影得到。