2. 手眼标定(眼在手外)

任务目标

本案例实现眼在手外(Eye-to-Hand)手眼标定:标定板固定在机械臂末端,相机固定于外部,通过采集多组机械臂末端位姿与对应标定板图像,求解相机坐标系到机器人基座坐标系的变换矩阵 camTbase。

核心原理

眼在手外标定求解方程为:baseTtool · toolTboard = baseTcam · camTboard,其中 baseTtool 由机械臂正运动学给出,camTboard 由标定板图像(PnP)解算。对多组数据构建形如 AX = ZB 的方程组,分离求解旋转与平移,最终得到 baseTcam(即 camTbase 的逆)。

环境依赖

  • Python 3.8+,OpenCV(opencv-contrib-python 含 aruco 模块)、NumPy
  • 机械臂需能输出末端位姿(本案例使用仿真数据模拟,真实场景替换为实际读取值)

完整实现

import cv2
import numpy as np

# ---------- 1. 生成仿真数据(模拟真实采集) ----------
def generate_synthetic_data(n_poses=15, cam_intrinsics=None):
    """生成 n_poses 组 (tool_in_base, board_in_cam) 真值,用于验证标定算法"""
    if cam_intrinsics is None:
        cam_intrinsics = np.array([[800, 0, 320],
                                   [0, 800, 240],
                                   [0, 0, 1]], dtype=np.float64)
    dist_coeffs = np.zeros(5)

    # 固定手眼变换真值:baseTcam(相机在基座坐标系中的位姿)
    true_baseTcam = np.eye(4)
    true_baseTcam[:3, :3] = cv2.Rodrigues(np.array([0.3, -0.2, 0.1]))[0]
    true_baseTcam[:3, 3] = [0.5, -0.3, 0.8]

    # 标定板坐标系到相机坐标系的真值(用于生成图像点)
    true_boardTcam = np.eye(4)
    true_boardTcam[:3, :3] = cv2.Rodrigues(np.array([0.1, 0.2, -0.15]))[0]
    true_boardTcam[:3, 3] = [0.2, 0.1, 0.5]

    # 标定板角点(7x5 棋盘格,格宽 0.03m)
    board_size = (7, 5)
    square_size = 0.03
    objp = np.zeros((board_size[0]*board_size[1], 3), np.float32)
    objp[:, :2] = np.mgrid[0:board_size[0], 0:board_size[1]].T.reshape(-1, 2) * square_size

    data = []
    for _ in range(n_poses):
        # 随机生成 tool_in_base(机械臂末端位姿)
        tool_in_base = np.eye(4)
        tool_in_base[:3, :3] = cv2.Rodrigues(np.random.uniform(-0.5, 0.5, 3))[0]
        tool_in_base[:3, 3] = np.random.uniform([0.3, -0.3, 0.2], [0.7, 0.3, 0.6])

        # 计算 board_in_cam:boardTcam 固定,但加入微小扰动模拟噪声
        noise = np.random.normal(0, 0.002, 3)
        board_in_cam = true_boardTcam.copy()
        board_in_cam[:3, 3] += noise

        # 投影生成图像点(模拟相机观测)
        rvec, _ = cv2.Rodrigues(board_in_cam[:3, :3])
        tvec = board_in_cam[:3, 3]
        imgpts, _ = cv2.projectPoints(objp, rvec, tvec, cam_intrinsics, dist_coeffs)

        data.append({
            'tool_in_base': tool_in_base,
            'obj_points': objp,
            'img_points': imgpts.reshape(-1, 2),
            'cam_intrinsics': cam_intrinsics,
            'dist_coeffs': dist_coeffs
        })
    return data, true_baseTcam

# ---------- 2. 从图像解算 board_in_cam ----------
def solve_board_in_cam(sample):
    """使用 PnP 从标定板图像点解算 board_in_cam"""
    _, rvec, tvec = cv2.solvePnP(sample['obj_points'], sample['img_points'],
                                 sample['cam_intrinsics'], sample['dist_coeffs'])
    board_in_cam = np.eye(4)
    board_in_cam[:3, :3] = cv2.Rodrigues(rvec)[0]
    board_in_cam[:3, 3] = tvec.flatten()
    return board_in_cam

# ---------- 3. 手眼标定核心算法(AX = ZB 分解) ----------
def hand_eye_calibration(data_list):
    """
    输入: data_list 每项含 tool_in_base (4x4), board_in_cam (4x4)
    输出: baseTcam (4x4)
    方法: 对每组数据构造 A = tool_in_base, B = board_in_cam,
          求解 AX = ZB 中的 X = baseTcam
    """
    n = len(data_list)
    assert n >= 3, "至少需要3组数据"

    # 构建旋转方程: R_A * R_X = R_X * R_B  => 使用 Kronecker 积线性求解
    A_rot = []
    B_rot = []
    for d in data_list:
        A_rot.append(d['tool_in_base'][:3, :3])
        B_rot.append(d['board_in_cam'][:3, :3])

    # 使用 OpenCV 的 CalibrateHandEye(Tsai 方法)
    R_gripper2base = [d['tool_in_base'][:3, :3] for d in data_list]
    t_gripper2base = [d['tool_in_base'][:3, 3] for d in data_list]
    R_target2cam = [d['board_in_cam'][:3, :3] for d in data_list]
    t_target2cam = [d['board_in_cam'][:3, 3] for d in data_list]

    # 注意: OpenCV 的 CalibrateHandEye 求解的是 camHtool(眼在手内),
    # 眼在手外需转换: 将 gripper 视为 tool, target 视为 board,
    # 求解 baseTcam 等价于求解 X 满足 A X = Z B,其中 Z 为未知中间量。
    # 直接使用 Tsai 方法求解 AX=ZB 形式:
    R_cam2base, t_cam2base, _ = cv2.calibrateHandEye(
        R_gripper2base, t_gripper2base,
        R_target2cam, t_target2cam,
        method=cv2.CALIB_HAND_EYE_TSAI
    )
    baseTcam = np.eye(4)
    baseTcam[:3, :3] = R_cam2base
    baseTcam[:3, 3] = t_cam2base.flatten()
    return baseTcam

# ---------- 4. 主流程 ----------
def main():
    # 生成仿真数据(真实场景替换为机械臂读取 + 图像检测)
    data_list, true_baseTcam = generate_synthetic_data(n_poses=15)

    # 从图像解算 board_in_cam
    processed = []
    for sample in data_list:
        board_in_cam = solve_board_in_cam(sample)
        processed.append({
            'tool_in_base': sample['tool_in_base'],
            'board_in_cam': board_in_cam
        })

    # 执行手眼标定
    est_baseTcam = hand_eye_calibration(processed)

    # 验证:计算估计值与真值的误差
    error = np.linalg.norm(est_baseTcam[:3, 3] - true_baseTcam[:3, 3])
    # 旋转误差(角度)
    R_err = est_baseTcam[:3, :3] @ true_baseTcam[:3, :3].T
    cos_angle = (np.trace(R_err) - 1) / 2
    rot_err_deg = np.degrees(np.arccos(np.clip(cos_angle, -1, 1)))

    print("估计的 baseTcam 平移:", est_baseTcam[:3, 3])
    print("真值 baseTcam 平移:", true_baseTcam[:3, 3])
    print(f"平移误差: {error:.4f} m")
    print(f"旋转误差: {rot_err_deg:.2f} deg")

    # 保存结果
    np.save("baseTcam.npy", est_baseTcam)
    print("标定结果已保存至 baseTcam.npy")

if __name__ == "__main__":
    main()

运行与验证

  1. 运行:python hand_eye_calibration.py
  2. 预期输出(仿真数据无噪声时):平移误差接近 0.002m 以内,旋转误差小于 1°(因仿真加入了 2mm 平移噪声)
  3. 通过条件:程序正常结束并输出 baseTcam.npy;平移误差 < 5mm,旋转误差 < 2° 即认为标定精度合格

真实场景接入说明

将 generate_synthetic_data() 替换为实际采集流程:读取机械臂末端位姿(tool_in_base),拍摄标定板图像并用 cv2.aruco.detectMarkers 或 cv2.findChessboardCorners 获取角点,再调用 solvePnP 得到 board_in_cam。其余标定与验证代码无需修改。