3、眼在手外(Eye-to-Hand)标定:原理、数学模型、标定流程、数据采集方法

眼在手外,说白了就是相机固定不动,机器人带着标定板满世界跑。这种构型在工业现场非常常见——比如流水线上方挂一个相机,机器人去抓取传送带上的工件。我个人觉得,Eye-to-Hand 比 Eye-in-Hand 更容易理解,但坑也不少。

3.1 基本原理

先想清楚一个问题:相机不动,机器人动,我们要找的是谁和谁的关系?

答案是:相机坐标系 {C} 到 机器人基坐标系 {B} 的变换矩阵 ^B_C T。

为什么?因为相机看到了标定板(固定在机器人末端),但机器人只知道自己的末端在哪儿。我们需要把相机看到的物体位置,换算到机器人基坐标系下,才能指导机器人去抓。

核心思想: 相机坐标系 → 机器人基坐标系,就是 Eye-to-Hand 标定要解的方程。

3.2 数学模型

我们一步步推导。先定义几个坐标系:

  • {B}:机器人基坐标系
  • {E}:机器人末端法兰坐标系
  • {C}:相机坐标系
  • {P}:标定板坐标系

标定板固定在末端上,所以 ^E_P T 是已知的(或者说,我们假设它不变)。相机拍到标定板,得到 ^C_P T。机器人控制器告诉我们 ^B_E T。

那么,从标定板到机器人基坐标系的变换,可以走两条路:

  1. 直接走:^B_P T = ^B_C T · ^C_P T
  2. 绕一圈:^B_P T = ^B_E T · ^E_P T

两条路结果相等,所以:

^B_C T · ^C_P T = ^B_E T · ^E_P T

整理一下,把未知量 ^B_C T 放到左边:

^B_C T = ^B_E T · ^E_P T · (^C_P T)^{-1}

嗯,这里要注意:^C_P T 是相机测出来的,有噪声。直接求逆会放大误差。所以实际中我们不会只拍一张,而是拍多张,然后优化求解。

我的经验: 至少拍 10-15 个不同位姿。我曾经只拍了 6 张,结果标定出来的旋转矩阵误差大得离谱,后来加了 10 张才稳定下来。

3.3 标定流程

整个流程分五步,我习惯按这个顺序走:

步骤 操作 注意事项
1 固定相机,安装标定板 标定板要牢固,不能松动
2 机器人走多个位姿 覆盖视野各个区域,角度要多样
3 记录每组数据 同时保存机器人位姿和相机图像
4 提取角点,计算 ^C_P T 用 OpenCV 的 solvePnP
5 求解 ^B_C T 用最小二乘或非线性优化

你想想看,这五步里最容易出问题的是哪一步?我个人觉得是第二步——机器人位姿的选择。如果所有位姿都集中在视野中央,标定结果会非常脆弱。

避坑指南: 我曾经让机器人只在小范围内移动,结果标定出来的平移向量误差有 5mm。后来把位姿分布到视野的四个角落和中心,误差降到了 0.5mm 以内。

3.4 数据采集方法

数据采集听起来简单,但做起来有很多细节。我总结了一个 checklist:

3.4.1 机器人位姿规划

  • 数量: 12-20 组,太少容易欠约束
  • 分布: 在相机视野内均匀分布,包括 X、Y、Z 三个方向
  • 姿态: 让标定板相对于相机有不同角度,避免所有位姿都平行于像平面
  • 距离: 改变标定板到相机的距离,覆盖工作深度范围

3.4.2 图像采集要求

  • 清晰度: 标定板角点必须清晰可见,不能模糊
  • 光照: 避免反光和阴影,我遇到过标定板一半亮一半暗,角点提取失败
  • 遮挡: 标定板不能部分超出视野,也不能被机器人本体遮挡

3.4.3 数据同步

这是个大坑。机器人位姿和相机图像必须严格同步。我见过有人用手动记录,结果时间戳对不上,标定结果完全不对。

我的做法: 写一个脚本,让机器人每到一个位姿就自动触发相机拍照,同时记录当前机器人位姿。这样能保证毫秒级同步。

3.5 知识结构图

下面这张图概括了 Eye-to-Hand 标定的核心逻辑,从输入到输出一目了然:

Eye-to-Hand 标定知识结构图 机器人位姿 ^B_E T (12-20组) 相机图像 标定板照片 标定板参数 棋盘格尺寸/内角点数 数据处理与求解 角点提取 → solvePnP → 构建方程 → 最小二乘优化 ^C_P T 计算 | 方程 ^B_C T · ^C_P T = ^B_E T · ^E_P T 输出:^B_C T 相机到机器人基坐标系的变换矩阵 验证与误差分析 重投影误差 < 0.5px | 平移误差 < 1mm 关键参数 位姿数 ≥ 12 覆盖全视野 常见问题 位姿分布不均 数据不同步

3.6 代码示例:数据采集与标定

下面是一个简化版的 Python 代码,展示核心流程。实际项目中我会加上异常处理和日志记录。

import numpy as np
import cv2

def collect_data(robot, camera, num_poses=15):
    """
    采集 Eye-to-Hand 标定数据
    robot: 机器人控制接口
    camera: 相机接口
    """
    robot_poses = []  # 存储 ^B_E T
    board_poses = []  # 存储 ^C_P T
    
    for i in range(num_poses):
        # 1. 移动机器人到随机位姿
        pose = generate_random_pose()
        robot.move_to(pose)
        
        # 2. 记录机器人位姿
        robot_poses.append(robot.get_current_pose())
        
        # 3. 拍照并提取角点
        img = camera.capture()
        ret, corners = cv2.findChessboardCorners(img, (9, 6), None)
        if not ret:
            print(f"位姿 {i}: 角点提取失败,跳过")
            continue
        
        # 4. 计算 ^C_P T
        _, rvec, tvec = cv2.solvePnP(obj_points, corners, camera_matrix, dist_coeffs)
        board_poses.append(compose_transform(rvec, tvec))
        
        print(f"位姿 {i}: 采集成功")
    
    return robot_poses, board_poses

def calibrate_eye_to_hand(robot_poses, board_poses):
    """
    求解 ^B_C T
    使用最小二乘法
    """
    # 构建方程 A * X = B
    # 其中 X = ^B_C T
    A_list = []
    B_list = []
    
    for i in range(len(robot_poses)):
        # ^B_E T * ^E_P T = ^B_C T * ^C_P T
        A = board_poses[i]  # ^C_P T
        B = robot_poses[i]  # ^B_E T (假设 ^E_P T 为单位矩阵)
        A_list.append(A)
        B_list.append(B)
    
    # 使用 SVD 求解
    X = solve_ax_xb(A_list, B_list)
    return X

小技巧: 代码里的 solve_ax_xb 函数,我推荐用 Tsai 方法或者 Park 方法。OpenCV 的 calibrateHandEye 也支持,但要注意参数顺序——Eye-to-Hand 和 Eye-in-Hand 的输入顺序是反的。

3.7 常见问题与避坑

最后,我把自己踩过的坑整理一下:

  • 标定板松动: 有一次标定板没拧紧,结果每次拍照位置都不一样,标定结果自然一塌糊涂。后来我用螺纹胶固定了。
  • 机器人精度不够: 如果机器人本身重复定位精度差(比如 ±0.1mm),标定结果很难优于这个量级。别指望标定能弥补机械误差。
  • 相机畸变未校正: 我见过有人直接用未校正的图像做标定,结果角点位置偏差很大。先做相机内参标定,这是前提。
  • 位姿退化: 如果所有位姿的标定板都平行于像平面,旋转矩阵的某些分量会不可观。一定要让标定板有俯仰和偏转。

重要提醒: 标定完成后,一定要做验证。拿一个已知位置的物体,让机器人去抓,看看误差在不在容忍范围内。我一般要求重投影误差小于 0.5 像素,平移误差小于 1mm。

好了,Eye-to-Hand 标定的核心内容就这些。说白了就是解一个矩阵方程,但工程实现上细节很多。记住:数据质量决定标定质量,别在采集阶段偷懒。