自从把UR3和RealSense L515这对组合搬上工作台,手眼标定就成了绕不开的第一道门槛。说实话,这套东西硬件不难接,难的是把相机坐标系和机械臂末端坐标系之间的关系搞清楚,否则后续视觉抓取全是空中楼阁。我前前后后折腾了两周,踩了无数坑,把一套相对靠谱的标定流程沉淀了下来,今天完整拆给你看。
这套方案适合谁?如果你手上正好有UR3(或其他UR系列机械臂)加一个RealSense系列的深度相机,想做视觉引导抓取、工件定位或者位姿估计,那这篇文章基本能帮你少走三天的弯路。整篇内容涵盖从方案选型、数学原理、数据采集,到代码实现和精度验证的完整闭环,不管是刚接触手眼标定的新手,还是已经标过但精度一直不理想的老手,都能从中找到需要的东西。
1. 项目背景与方案选型
1.1 这个项目到底在解决什么问题
先说说为什么需要手眼标定。UR3是一个6自由度的协作机械臂,它的控制器只知道自己末端执行器在机器人基座坐标系下的位姿,也就是T_base_to_tcp。作为“眼睛”的L515相机能告诉你的,是棋盘格、工件或者其他目标在相机坐标系下的位置,也就是T_cam_to_target。问题是这两个坐标系根本没有对上。
你要让机械臂去抓相机看到的那个杯子,就得知道“相机看到的点”怎么映射到“机械臂能动的空间”里。这个映射关系,就是相机坐标系到机械臂末端坐标系的变换矩阵,叫手眼矩阵。手眼标定的本质,就是求解这个变换矩阵。
影响范围可不只是抓取这一件事。后续如果你要做相机的3D点云拼接、机械臂引导焊接、装配对位等,基础都是这个标定结果。标定误差1毫米,最后末端执行器可能偏出好几毫米甚至一两厘米,因为误差会在坐标转换链路上放大。所以花时间把标定做扎实,完全是值得的。
1.2 为什么选UR3配L515
UR系列协作机器人在实验室和轻量级自动化场景里非常常见,UR3的负载是3公斤,工作半径500毫米,正好适合桌面级应用。L515则是Intel RealSense里的一个比较特殊的型号,用的是固态LiDAR技术,不像普通结构光或者双目那样依赖环境纹理,近距离点云质量很干净。
这两个搭配起来有个天然优势:UR3尺寸小、末端法兰承重够,L515本身非常轻(大约100克),装在UR3末端完全不影响动态性能。而且L515的近距测量精度在0.25米到1.5米范围内表现不错,正好适配UR3的小工作半径。
还有一个加分项:L515内置了IMU,单说标定这个环节用不上,但后续做视觉伺服或者移动抓取时候可以多一个姿态参考。当然,L515的坑也不少,后面我专门开一节聊。
1.3 手眼模式选型:眼在手上还是眼在手外
手眼标定分两种经典模式:eye-in-hand(眼在手上)和eye-to-hand(眼在手外)。L515装在UR3的末端法兰上,就是比较典型的眼在手上模式。这种模式下,相机跟随机械臂运动,每次换个角度拍同一块固定的标定板,通过多组“机械臂末端位姿+标定板在相机坐标系下的位姿”来求解相机和末端的固定变换关系。
另一种眼在手外是相机固定在外部支架上,机械臂末端装标定板,机械臂运动时相机静止。我之前也在另一套项目里做过眼在手外,说实话两种模式标定原理相通,但数据采集的姿势差别很大。本文以UR3末端挂L515、标定板固定的眼在手上方案为主线,因为这是视觉抓取最常见的布局。
眼在手上的好处是相机可以靠近目标,把工件拍得更大更清楚,识别精度高;缺点是机械臂姿态变化可能导致相机视野被遮挡,规划运动轨迹时候要小心。眼在手外视野固定,后期手眼标定完就不需要再做眼动补偿,但相机离目标远,识别精度通常会差一些。选哪种,要看你的工位布局和工艺需求,不存在绝对哪个好。
2. 手眼标定的核心原理与数学基础
2.1 AX=XB 到底在说什么
网上聊手眼标定,几乎每个人都会甩出一个公式:AX=XB。很多教程一笔带过,但真正想标好,这个式子背后的含义必须吃透。
先约定记号。X代表待求的手眼矩阵,也就是从相机坐标系到机械臂末端坐标系的变换T_cam_to_tcp。这个X是固定不变的,不管机械臂怎么动,相机和末端的相对关系都不变。
A怎么来?机械臂每次运动,末端在基座坐标系下有一个位姿变化。比如从第1帧运动到第2帧,末端从T_base_to_tcp1变成T_base_to_tcp2,那么A就是这两次末端位姿之间的相对运动:A = T_base_to_tcp2 * inv(T_base_to_tcp1)。
B又怎么来?机械臂动了之后,固定在外部世界里的标定板在相机坐标系下的位姿也变了。第1帧标定板在相机坐标系下是T_cam_to_board1,第2帧是T_cam_to_board2,那么B就是这两次标定板位姿之间的相对运动:B = T_cam_to_board2 * inv(T_cam_to_board1)。
AX=XB的意思就是:末端运动了A这个变换,经过手眼矩阵X传导到相机上,等价于相机坐标系下观察到标定板发生了B这个变换。这个等式和机械臂的基座坐标系无关,只和两次运动的相对差有关,所以它能把未知的基座到标定板的关系消掉。每两组数据就能构造一个这样的方程,数据组数越多,方程越多,解出来的X越稳定。
2.2 UR的位姿输出到底该怎么用
这一节非常重要,我见过太多人在这里翻车。UR3的示教器或者API里返回的TCP位姿是[x, y, z, rx, ry, rz],这个形式看着像欧拉角,其实不是,它用的是旋转矢量表示法,也就是旋转轴方向乘以旋转角度。OpenCV里也有一套cv2.Rodrigues函数,专门做旋转矢量和旋转矩阵之间的转换,刚好能和UR的数据对上。
如果你把这个rx、ry、rz直接当欧拉角去转旋转矩阵,标定出来的结果一定是乱七八糟的,而且你很难排查出来。正确做法是先做一次Rodrigues转换得到旋转矩阵,凑成4x4的齐次变换矩阵。
还有一个更大的坑:UR返回的是T_base_to_tcp,也就是末端在基座坐标系下的表示。而OpenCV的calibrateHandEye函数要求输入的是R_gripper2base和t_gripper2base,也就是基座在末端坐标系下的表示,语义正好相反。所以需要在代码里对UR返回的位姿做一次矩阵求逆,再传给标定函数。很多网上的教程贴的代码没提这茬,直接把UR的位姿塞进去,结果标定出来的X自然不对。
2.3 标定板的选择与图像外参求解
标定板我建议优先用棋盘格,别用二维码或者圆点阵。棋盘格角点检测在OpenCV里有成熟的函数,稳定性和精度在室内光照条件下都很好。圆点阵在倾斜角度大的时候检测容易出问题,二维码标定板单帧包含的信息不够,对AX=XB这种多帧联合求解的优化反而麻烦。
棋盘格角点数量建议内角点9x6或者11x8,每个格子边长20毫米到30毫米。UR3的工作范围小,相机离标定板通常只有0.3米到0.5米,L515的RGB分辨率是1280x720,用30毫米格子的9x6棋盘格是比较稳妥的组合。格子太密太小,远了角点糊成一团;格子太疏,一帧图像里角点数量太少,外参解算不稳定。
标定板本身要贴在一个绝对平整的刚性底板上,铝合金板或者亚克力板都行,千万别用普通A4纸直接打印。纸会微弯,角点坐标带几像素的误差,看似不起眼,传到手眼矩阵上就是好几毫米的偏差。我第一次就是手拿着打印纸拍的,标定结果重投影误差一直在2到3个像素徘徊,怎么都降不下去,后来换成铝板贴片,误差一下降到0.3像素以下。
求解每帧标定板外参,其实就是cv2.calibrateCamera,先用findChessboardCorners提取角点,再配合棋盘格的物理尺寸,算出一个T_board_to_cam。这个变换就是上面说的T_cam_to_board的逆矩阵,用的时候注意方向别搞混了。
3. 标定实操:从数据采集到离线求解
3.1 硬件连接与环境配置
正式开始之前,先花点时间把环境搭好,后面能省很多心。
硬件上,L515用USB 3.0线接到工控机上。这个相机对USB口的供电能力要求不低,最好插在主板的原生USB口上,不要经过杂牌HUB。我用过一个便宜的USB拓展坞,结果相机频繁掉线、深度流时不时丢帧,后来换直连主板立刻稳定了。UR3的控制柜和工控机之间用网线直连,UR的默认IP是192.168.1.10,给工控机网口配置一个同网段的静态IP。
软件方面,L515安装Intel出的RealSense SDK,直接在Release页面下载Windows或者Linux的安装包就行。UR通讯我用的是ur_rtde这个Python库,pip install ur-rtde装完就能用,它提供的RTDEReceive.getActualTCPPose()方法可以稳定地拿到机械臂当前实际TCP位姿,比用UR脚本回调方便得多。
3.2 数据采集到底要采什么、采多少
这是手眼标定最容易被低估的部分。我这里直接给一套经过实际验证的采集规范:
- 组数不能太少。我的最低建议是15组,实际经验是20到25组效果最好。少于10组,AX=XB方程组的约束不够,求解结果随方法不同波动很大。
- 位姿差异要拉开。不要只让机械臂平着移动,要用不同的高度、不同的俯仰角、不同的偏航角去拍。理想情况下,相机姿态的变化覆盖一个比较大的姿态范围,这样方程之间的独立性才够。
- 标定板必须完整出现在画面里,而且最好只占画面面积的四分之一到一半,不要怼得太满,否则角点容易出视野。
- 标定板固定不动。如果你用手拿着,手再稳也有微晃,每帧之间的物理坐标不一致,整个AX=XB推导的前提就崩了。
- 每组数据必须是“同一时刻”的机械臂位姿和图像。正确流程是:机械臂运动到目标位姿,完全停稳,等待0.5秒,然后先记录TCP位姿,再触发拍照。不要让机械臂边运动边拍,运动模糊和位姿超前滞后都会污染数据。
关于UR3的运动控制,我建议用示教器的Move手动跑点位,慢慢把机械臂摆到各个角度,到了位之后直接在Python端采集。用自动运行URScript批量跑也可以,但要注意速度调低一点,最后加一段停顿时间,确保实际位姿收敛到位。
3.3 离线标定完整代码与参数解析
直接看代码。下面的脚本假设你已经采集了20组“图片+机械臂TCP位姿”,图片存放在images/目录下,位姿记录在一个numpy文件或者文本文件里。
import cv2 import numpy as np import glob # 棋盘格参数:内角点数(列, 行),格子边长(米) pattern_size = (9, 6) square_size = 0.03 # 标定板物理坐标 objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) objp *= square_size # 1. 提取所有图片的棋盘格角点 image_files = sorted(glob.glob('images/*.png')) obj_points = [] img_points = [] for fname in image_files: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners( gray, pattern_size, cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE ) if not ret: print(f'角点检测失败: {fname}') continue criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_refined = cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners_refined) # 2. 求解每帧标定板到相机的位姿 ret, K, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) # 3. 加载UR3位姿并转换 # rtde_poses: (N, 6),每行是 [x, y, z, rx, ry, rz] # 注意:UR的rx/ry/rz是旋转矢量,不是欧拉角 rtde_poses = np.load('ur_poses.npy') R_gripper2base = [] t_gripper2base = [] for pose in rtde_poses: R_base2tcp, _ = cv2.Rodrigues(np.array(pose[3:6], dtype=np.float64)) T_base2tcp = np.eye(4) T_base2tcp[:3, :3] = R_base2tcp T_base2tcp[:3, 3] = pose[0:3] # 关键步骤:求逆,得到 T_tcp2base,也就是 gripper2base T_gripper2base = np.linalg.inv(T_base2tcp) R_gripper2base.append(T_gripper2base[:3, :3]) t_gripper2base.append(T_gripper2base[:3, 3]) # 4. 整理 target2cam:calibrateCamera 返回的 rvec/tvec 已经是 T_board2cam R_target2cam = [] t_target2cam = [] for rvec, tvec in zip(rvecs, tvecs): R, _ = cv2.Rodrigues(rvec) R_target2cam.append(R.astype(np.float64)) t_target2cam.append(tvec.reshape(3).astype(np.float64)) # 5. 手眼标定求解 X = T_cam2tcp R_cam2tcp, t_cam2tcp = cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, method=cv2.CALIB_HAND_EYE_DANIILIDIS ) X = np.eye(4) X[:3, :3] = R_cam2tcp X[:3, 3] = t_cam2tcp.flatten() print('手眼矩阵: \n', X)这里有几个参数值得单独说。
method参数有人会忽略,直接默认TSAI。我实际测试下来,在UR3+L515这种中等噪声场景里,CALIB_HAND_EYE_DANIILIDIS相对稳健,CALIB_HAND_EYE_PARK也不错。建议脚本里把几种方法都跑一遍,对比后面的重投影误差,取最小的那个结果。
calibrateCamera这一步,本质上是单独对每一帧图像求一次外参。这里有个细节:不要用calibrateCamera返回的整体重投影误差来评价手眼标定,那是评价相机内参和外参的,手眼标定需要单独计算。
3.4 精度评价:重投影误差到底怎么算
标定完不能直接拿去用,先做精度验证。我用的方法是对已经标定的结果做一次交叉验证:把20组数据分成两组,16组用来求解X,4组用来做检验。这是标定类任务的老规矩,训练和测试必须分开,否则你不能判断是过拟合还是真的标对了。
检验方式是这样的:有了手眼矩阵X之后,每一帧都可以把标定板从相机坐标系投影回机器人基座坐标系,得到标定板在基座坐标系下的位姿T_base_to_board:
def compute_board_in_base(pose, X, rvec, tvec): # pose: 1x6 UR位姿 # X: 4x4 手眼矩阵 # rvec, tvec: 当前帧标定板在相机坐标系下的外参 R_base2tcp, _ = cv2.Rodrigues(np.array(pose[3:6], dtype=np.float64)) T_base2tcp = np.eye(4) T_base2tcp[:3, :3] = R_base2tcp T_base2tcp[:3, 3] = pose[0:3] T_cam2board = np.eye(4) R_cam2board, _ = cv2.Rodrigues(rvec) T_cam2board[:3, :3] = R_cam2board T_cam2board[:3, 3] = tvec.flatten() T_base2board = T_base2tcp @ X @ T_cam2board return T_base2board对于理想标定,每组算出的T_base_to_board应该完全一致,因为标定板从头到尾没有动过。所以你可以计算这些矩阵之间的平移标准差和旋转角标准差,标准差越小说明标定越稳定。
更直观的另一个指标是反投影像素误差:用求出的X和标定板在基座下的平均位姿,把棋盘格角点重新投影回图像坐标系,计算和实际检测角点之间的像素距离,求RMS。这个误差小于0.5像素说明标定质量很好,1到2像素也能接受,超过5像素基本就是某一步出问题了。
4. 常见问题与排错实录
4.1 标定结果看着正常,但抓取偏得离谱
这是最恼人的情况:手眼矩阵求出来了,重投影误差也不大,但真去抓东西就是偏。我排查了很久,最后发现问题出在L515的RGB和深度坐标系不一致上。
L515默认输出的RGB图像是1920x1080,深度图像是1280x720,两者在一个相机内部其实对应着两个略有偏移的坐标系。你在标定时如果用彩色图提取棋盘格角点,算出来的手眼矩阵是相对于RGB相机光心的;后续如果直接把深度图的点云拿来用,点云坐标却是相对深度相机光心的。这两个光心之间有大概几毫米到十几毫米的偏移,反映在末端执行器上就是稳定偏一个方向。
解决办法是开启RealSense SDK里的深度对齐功能,把深度图对齐到彩色相机坐标系。在pyrealsense2里只需要一行:
align = rs.align(rs.stream.color) frames = align.process(frames)对齐之后,深度图和RGB图逐像素对应,点云坐标就和标定用到的坐标系一致了。
4.2 角点检测时好时坏,重投影误差下不来
如果你发现findChessboardCorners在有的角度下总是不识别,先别怀疑算法,多半是标定板反射光太强或者格子对比度不够。L515的RGB在室内的成像素质没有户外相机那么强,暗光环境下很容易出现角点检测不稳定。
对策有几个:一是尽量用无反射哑光材料做标定板,别用热转印那种带反光的覆膜;二是采集数据时保证均匀照明,不要有强光直射;三是把CALIB_CB_ADAPTIVE_THRESH和CALIB_CB_NORMALIZE_IMAGE都打开,这两个标志位对光照不均非常管用。
还有一种情况是L515的自动曝光在某个角度下把图像调得太亮或太暗,导致对比度崩溃。可以在SDK里把RGB相机的自动曝光关掉,手动固定曝光参数。我一般设成曝光时间100到200毫秒、增益1到2,基本全场景都能覆盖。
4.3 UR3的TCP位姿总有几个点明显偏了
这种问题往往来自机械臂没有真正停稳。UR3位姿有一个读取频率问题,getActualTCPPose()读的是当前实际位置,但如果你在机械臂减速还没结束的时刻去读,读到的值和最终停止点之间会有几毫米甚至更多的偏差。
稳妥做法是采集线程里先判断机械臂是否停止。最简单的方法是连续读两次TCP位姿,如果两次变化小于0.1毫米和0.01度,才认为机械臂稳定了,这时再拍照和记录。另外,手动示教的时候,点位到位后不要急着采集,默数几拍再触发。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 重投影误差大于2像素 | 标定板不是刚性平面 | 换铝板/亚克力板,固定好 |
| 手眼矩阵平移分量异常大 | UR的rx/ry/rz被当成了欧拉角 | 用cv2.Rodrigues转旋转矢量 |
| 标定结果和方法强相关 | 数据组数太少或姿态重复 | 至少15组,姿态拉开 |
| 实际抓取稳定偏移 | RGB和深度坐标系未对齐 | 用rs.align把深度对齐到彩色 |
| 角点检测漏检频繁 | 光照问题/标定板反光 | 固定曝光,换哑光板子 |
| TCP位姿读到的是规划值 | 读取时机太早 | 等机械臂停稳后再读取 |
| 同一组数据多次求解结果跳动 | 双目/深度噪声大,角点亚像素不稳 | 用cornerSubPix细化角点 |
4.5 一个容易忽略的细节:TCP到底设在哪
UR3在示教器里可以设置TCP坐标系,默认是法兰盘中心。如果你的系统里之前定义过工具坐标系(比如装了夹爪之后设了工具TCP),那么getActualTCPPose()返回的是工具末端的位姿,不是法兰的位姿。
手眼标定必须明确是在标定“相机到法兰”还是“相机到工具”。我建议标定时把TCP设为默认的法兰盘坐标系,也就是不设任何工具偏移,这样手眼矩阵X就直接是相机到法兰的变换。后面如果你装了夹爪,只需在UR控制器里另设工具TCP,坐标转换链路依然是清晰的。如果带着工具TCP去做标定,工具中心偏移会被混进手眼矩阵里,换一个工具整个标定就得重来。
5. 一些值得后续扩展的方向
标定完只是第一步,接下来可以考虑把这个流程做成自动化的。
比如可以写一个URScript脚本,让机械臂自动按预设的一系列位姿依次运动,每到一个点就通过socket通知工控机拍照和记录位姿,最后自动跑离线标定脚本并输出精度报告。这样每次换相机、换工装之后,整个标定流程可以压缩到十分钟以内,不用再手动示教几十个点。
另外,手眼标定结果也可以接到机器人视觉系统里做闭环。比如在抓取之前,先用相机识别目标,通过手眼矩阵把目标坐标换算到机械臂基座坐标系,再通过逆运动学生成抓取轨迹。如果有兴趣,还可以加入视觉伺服,让机械臂根据实时视觉反馈闭环修正位姿,这时候手眼矩阵的精度直接决定伺服收敛速度和稳定性。
还有一个小技巧:如果你的应用场景里相机会频繁拆装,建议每次装完之后都做一次快速验证,不需要完整标定,只要用四五个位姿算一下手眼矩阵和上一次结果的偏差,偏差超过阈值再触发完整标定流程。这个思路能帮你提前发现机械松动带来的坐标漂移,比等到抓取失败再排查要高效得多。
对我来说,手眼标定最深的体会就是:它本质上不是算法问题,而是数据质量和坐标系约定问题。算法公式是公开的,代码也不复杂,难点全在于你把每一个坐标变换想清楚、把每一组数据采干净。UR3加L515这套组合,只要坐标系捋顺了、数据采够了,标的精度完全可以满足绝大多数桌面级抓取任务。希望这篇文章能帮你少踩几个坑,把时间花在真正有趣的事情上。