去年搭了一套机械臂视觉抓取系统,相机用的就是 Intel RealSense D435i,算法侧全部走 OpenCV 加 Python。系统刚跑起来那阵子,视觉识别出来的目标位置和机械臂实际抓取的位置总有偏差,大概一厘米上下。分拣一些大件工件时勉强能用,一换到精密装配或者小零件抓取,就明显不行了——夹爪总是擦着零件边过去,偶尔还会把零件碰倒。
后来我花了大概两周时间,把整条链路从头到尾排查了一遍,最后把手眼标定的误差从 1cm 压到了 1mm 以内。这个过程里踩了不少坑,也总结了一套可以复用的方法和代码,今天一并分享出来。这篇文章适合正在做机械臂视觉引导、机器人抓取、自动化上料这类项目的朋友,尤其是刚接触手眼标定、被精度问题折磨过的工程师。
1. 先把坐标系掰扯清楚:手眼标定到底在算什么
1.1 一个公式看懂 AX=XB
手眼标定的核心,是要算清楚相机坐标系和机械臂坐标系之间的变换关系。你可以把它理解成一个“翻译器”:相机看到的每个点,都是以相机为原点来描述坐标的;但机械臂执行动作时,需要的是以机械臂基座为原点、或者以末端执行器为原点的坐标。没有这层变换关系,相机告诉你“目标在这里”,机械臂根本不知道“这里”到底对应自己坐标系下的哪个位置。
这个关系在数学上就是一个 4x4 的齐次变换矩阵 X。怎么求 X?经典方法是让机械臂带着相机运动到多个不同的位置,每个位置下同时记录机械臂的位姿和相机看到的标定板位姿。相邻两个位置之间,机械臂末端的相对运动记为 A,标定板在相机视野里的相对运动记为 B,理论上满足:
A * X = X * B
这个式子就是手眼标定的核心方程,求解 X 的过程就是解 AX=XB。X 一旦求出来,相机图像里的任意一个目标点,都能通过 X 变换到机械臂坐标系,机械臂就能照着坐标去抓了。
这里的关键在于理解:A 是从机械臂自身读取出来的位姿变化,B 是从图像中提取出来的标定板位姿变化,而 X 是未知的、固定不变的相机到机械臂的变换。未知量 X 夹在中间,通过多组 A、B 数据把它“夹”出来,这就是整个标定的数学本质。
1.2 眼在手上 vs 眼在手外
手眼标定分两种典型配置。一种是“眼在手上”(Eye-in-Hand),相机装在机械臂末端法兰上,跟着机械臂一起动;另一种是“眼在手外”(Eye-to-Hand),相机固定在工作区域上方或者侧方,机械臂单独运动。
我这套项目用的是眼在手上的配置。原因是 D435i 本身很轻巧,装在末端不会给机械臂增加太多负载,而且相机跟着机械臂运动,可以从不同角度观察目标,视野更灵活,尤其适合工件位置不固定、需要机械臂找角度的场景。
两种配置的标定流程几乎一样,但代码里有细微差别。OpenCV 的calibrateHandEye函数天生适合眼在手上的场景,直接传入机械臂末端到基座的旋转平移、标定板到相机的旋转平移,返回的就是相机到机械臂末端的变换矩阵。眼在手外时,需要把机械臂的位姿矩阵求逆之后再传入,返回的才是相机到基座的变换。这个细节我在后面的代码里会专门标注,照着抄不会错。
项目里我选的眼在手上还有一层考虑:标定板可以固定在工作台面上,不需要额外做支架,采集数据时机械臂只需要带着相机在标定板周围变换姿态即可。这样整个标定过程非常快,一台机械臂、一个相机、一块标定板就搞定。
2. 精度从 1cm 到 1mm:误差链路与优化思路
2.1 手眼标定的误差从哪里来
刚开始排查精度问题时,我以为手眼标定算出来就完事了,误差大可能是视觉识别算法的问题。后来把识别算法单独测了一遍,发现单目标定精度其实很好,误差基本在 2-3 个像素以内,说明问题不在识别,而在坐标系变换这层。
手眼标定的精度受整条链路多个环节影响,我把它们称为误差链路:
- 相机内参误差:包括焦距、主点、畸变系数。内参不准,标定板在相机坐标系下的位姿估计就会引入偏差。
- 角点提取误差:棋盘格角点提取得不够准,直接导致 solvePnP 求出来的 rvec/tvec 有误差。
- 机械臂位姿误差:机械臂返回的末端到基座的位姿本身有误差,这个误差会直接进入 A 矩阵。
- 采样姿态质量:姿态数量不足、姿态太相似、标定板太小、距离太远,都会让 AX=XB 方程病态。
- 算法选择:OpenCV 提供了多种求解算法,不同算法对不同噪声分布和初始条件的鲁棒性不同。
这条链路上任何一个环节掉链子,最终都会反映到手眼变换矩阵的误差上。1cm 的定位误差,不是你视觉算法不够好,更可能是链路中间某几环精度太差。
2.2 影响最大的三个“隐形杀手”
在排查过程中,我总结出三个最容易被忽视、但影响极大的因素。
第一个是直接用相机的出厂内参。D435i 出厂时会给一组内参,但实测下来,直接用出厂内参做重投影,图像边缘区域的误差能达到好几个像素。对于手眼标定这种对精度极度敏感的任务,这是不可接受的。我后来用棋盘格对 D435i 的 RGB 相机重新做了内参标定,边缘误差降到了 0.1 像素级别,手眼标定的结果立刻改善了一个量级。
第二个是采样姿态的数量和分布。我第一次标定只采了 8 组数据,而且机械臂的姿态几乎都是同一个朝向,只是位置稍微平移。结果就是 AX=XB 方程严重病态,不同算法求出来的 X 差异巨大。后来我把采样数量增加到 20 组以上,并且让机械臂末端在空间中做明显的旋转变化——俯仰、偏航、翻滚都要覆盖到,标定结果才稳定下来。
第三个是机械臂运动过程中采集数据。如果机械臂还在运动时就触发相机拍照,机械臂返回的位姿和图像实际拍摄时刻的位姿有时间差,A 矩阵和 B 矩阵就不是严格对应的。正确做法是让机械臂运动到目标姿态后,先停稳最少 300 到 500 毫秒,再触发相机采集,确保位姿和图像严格同步。
2.3 为什么从 1cm 到 1mm 是一个分水岭
1cm 的定位精度在机器人项目里意味着什么?分拣大纸箱、搬运托盘这种粗活,1cm 误差问题不大。但一旦涉及插拔、装配、螺丝锁付、小零件抓取,1cm 误差就是灾难级别的。以典型的电机齿轮装配为例,齿轮配合间隙往往在 0.5mm 以内,1cm 的误差连对准都做不到。
我这次优化的目标就是把定位精度压到 1mm 以内,因为对于 0.3 到 0.5 米工作距离下的 D435i 视觉引导,1mm 是一个合理的精度上限。低于这个值,基本就是机械臂自身绝对定位精度的极限了——绝大多数工业机械臂的重复定位精度能到 ±0.1mm,但绝对定位精度通常只有 0.5 到 2mm。换句话说,手眼标定做到 1mm 以内之后,继续提升的空间已经不取决于标定本身,而取决于机械臂自身的精度。
所以从 1cm 到 1mm,不是一个简单的“再调调参数”的过程,而是把整条误差链路的每一环都压到极限。下面我详细拆解完整流程和代码。
3. 实操:完整的手眼标定流程与 Python 代码
3.1 环境准备:RealSense SDK + OpenCV
先说环境。我用的系统是 Ubuntu 20.04,Python 3.8,OpenCV 用的 4.5.5,RealSense 驱动用的是官方 SDK 的 Python 接口 pyrealsense2。
安装命令很简单,两条 pip 就搞定:
pip install opencv-python opencv-contrib-python pip install pyrealsense2注意 pyrealsense2 要装官方 PyPI 上的版本,不要用某些第三方编译的包,兼容性容易出问题。如果用的是 ROS 环境,也可以直接用ros-noetic-realsense2-camera这个驱动包,但我在验证阶段为了减少 ROS 层的不确定性,直接用 pyrealsense2 采集图像,ROS 留给后续工程化。
除了这两个核心库,还需要 numpy,做矩阵运算用,基础操作不展开。
3.2 标定板选择:精度就是从这里抠出来的
标定板的选择直接决定角点提取精度,进而决定整个标定的上限。
第一梯队是工业级的陶瓷或玻璃基板标定板,精度最高,但价格也高。第二梯队是自己打印棋盘格贴在刚性背板上,精度取决于打印机的精度和背板的平整度。第三梯队是随便打印一张纸贴在墙上,这种精度最差,建议直接放弃。
我这次用的是自己打印的棋盘格,格子尺寸 25mm,规格 9x6 内角点。关键步骤是把它贴到一块 5mm 厚的铝板上,确保标定板绝对平整。如果标定板本身有弯曲,角点位置就会产生系统性的偏移,这个误差会直接进到手眼标定的结果里。
这里有个细节要注意:棋盘格的黑白格子表面最好做哑光处理,避免反光。普通打印纸在强光下会有镜面反射,D435i 的 RGB 相机一旦拍出反光,角点检测就会不稳。我后来在标定板表面贴了一层哑光膜,角点提取的成功率明显提升。
如果条件允许,用 Charuco 板替代传统棋盘格也是不错的选择。Charuco 板在部分遮挡的情况下仍然能稳定提取角点,而且角点数量多,solvePnP 的稳定性更好。我的经验是:精度要求高、实验环境复杂,优先考虑 Charuco。
3.3 数据采集:姿态覆盖比数量更重要
数据采集是整个标定流程里最考验耐心的一步。我用了 20 组数据,但这 20 组不是随便拍的,我在规划姿态时遵循了几个原则。
第一个原则是旋转变化要足够丰富。机械臂带着相机从正视标定板开始,然后逐步增加俯仰角、偏航角和翻滚角,每一组数据之间旋转角度最好相差 15 度以上。因为 AX=XB 方程求解依赖旋转信息,如果所有姿态的旋转都很相似,方程就是病态的。
第二个原则是标定板在画面中的占比要适中。标定板太小,角点间距过小,像素量化误差占比高;标定板太大,超出画面边缘,角点截断。我一般控制标定板在画面中占三分之一到二分之一,并且尽量让标定板位于画面中心区域。
第三个原则是稳停后采集。我把每组的停顿时间设在 500ms,确保机械臂完全静止后再拍照,彻底避免时间同步误差。
采集完每组数据后,我都会立即做一次角点检测的验证,确认这组数据的角点都被完整提取出来了,再让机械臂运动到下一个姿态。这样可以避免事后来回补采,效率高很多。
3.4 核心代码:一步到位的 Python 实现
下面给出完整的核心代码,各位可以直接参考。先说明一下数据组织方式:每组数据包含一个机械臂末端的 4x4 位姿矩阵和一张彩色图像,图像按编号保存,机械臂位姿单独存成一个 JSON 文件。
第一步是相机内参标定。D435i 的 RGB 内参我用标准棋盘格重新标定,具体代码是 OpenCV 的calibrateCamera,这段属于通用流程,这里略过,只给出标定结果的使用方式。
第二步是角点检测与标定板位姿求解:
import cv2 import numpy as np import json import pyrealsense2 as rs pattern = (9, 6) square_size = 0.025 # 25mm camera_matrix = np.array([[...]]) # 替换为自己的内参标定结果 dist_coeffs = np.array([...]) # 替换为自己的畸变系数 objp = np.zeros((pattern[0] * pattern[1], 3), np.float32) objp[:, :2] = np.mgrid[0:pattern[0], 0:pattern[1]].T.reshape(-1, 2) * square_size criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) def detect_board_pose(image): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, pattern, None) if not ret: return None corners_sub = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) # solvePnP 求解标定板在相机坐标系下的位姿 ret, rvec, tvec = cv2.solvePnP(objp, corners_sub, camera_matrix, dist_coeffs) if not ret: return None T_target2cam = np.eye(4) T_target2cam[:3, :3] = cv2.Rodrigues(rvec)[0] T_target2cam[:3, 3] = tvec.flatten() return T_target2cam注意这里用了cornerSubPix做亚像素细化,这一步非常关键。如果只用findChessboardCorners的原始角点,精度会停留在像素级,对 1mm 级别的目标来说是不够的。亚像素细化就是把角点定位到浮点像素精度,能显著提升 solvePnP 的稳定性。
第三步是机械臂位姿读取。这里我用了一个抽象函数,实际项目中要根据机械臂品牌替换:
def get_gripper_to_base(): # 从机械臂控制接口读取当前末端位姿,返回 4x4 齐次矩阵 # 以 UR 机械臂为例,可以通过 RTDE 接口获取 TCP 位姿 # 其他品牌替换为对应的 SDK 调用即可 pass第四步就是手眼标定的核心调用。这里我一次性跑 OpenCV 提供的全部五种算法,方便对比:
R_gripper2base = [] t_gripper2base = [] R_target2cam = [] t_target2cam = [] for sample in samples: Tg2b = sample['gripper_to_base'] # 机械臂末端到基座 Tt2c = sample['target_to_cam'] # 标定板到相机 R_gripper2base.append(Tg2b[:3, :3]) t_gripper2base.append(Tg2b[:3, 3]) R_target2cam.append(Tt2c[:3, :3]) t_target2cam.append(Tt2c[:3, 3]) methods = { 'Tsai': cv2.CALIB_HAND_EYE_TSAI, 'Park': cv2.CALIB_HAND_EYE_PARK, 'Horaud': cv2.CALIB_HAND_EYE_HORAUD, 'Andreff': cv2.CALIB_HAND_EYE_ANDREFF, 'Daniilidis': cv2.CALIB_HAND_EYE_DANIILIDIS, } for name, method in methods.items(): R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, method=method ) # 保存每个算法的结果,后续用验证集打分 print(f'{name}: t_cam2gripper = {t_cam2gripper.flatten()}')眼在手外的场景,代码只需要改一处:把Tg2b替换为np.linalg.inv(Tg2b),也就是把机械臂末端到基座的矩阵取逆,变成基座到末端的矩阵,然后正常调用calibrateHandEye,返回结果就是相机到基座的变换。
3.5 结果验证:标定完必须做的事
标定完直接拿去用,是很多新手会犯的致命错误。一块手眼标定结果到底准不准,必须用独立的数据验证。我的验证方法是“固定标定板重投影一致性验证”,方法如下。
因为标定板固定在工作台面上(眼在手上的配置),理论上不管机械臂带着相机怎么动,标定板在机械臂基座坐标系下的位姿应该是恒定不变的。于是我额外采集一组验证数据,不参与手眼标定求解,专门用来评估:
def validate(hand_eye_result, test_samples): T_cam2gripper = hand_eye_result T_gripper2cam = np.linalg.inv(T_cam2gripper) ref_pose = None max_err = 0.0 for i, sample in enumerate(test_samples): Tg2b = sample['gripper_to_base'] Tt2c = sample['target_to_cam'] # 预测标定板在基座坐标系下的位姿 T_base2target = Tg2b @ T_gripper2cam @ Tt2c if i == 0: ref_pose = T_base2target continue # 位置误差(单位:米) pos_err = np.linalg.norm(T_base2target[:3, 3] - ref_pose[:3, 3]) # 姿态误差(单位:度) rot_err = np.degrees( np.arccos(np.clip((np.trace(T_base2target[:3, :3].T @ ref_pose[:3, :3]) - 1) / 2, -1, 1)) ) max_err = max(max_err, pos_err) print(f'sample {i}: pos_err={pos_err*1000:.3f}mm, rot_err={rot_err:.4f}deg') return max_err这就是我用来给五种算法打分的方法。实测下来,Tsai 算法在我这组数据上表现一般,Park 和 Horaud 的效果最好,位置误差稳定在 1mm 以内。Andreff 计算最快,但抗噪能力弱,误差偏大。Daniilidis 表现也不错,仅次于 Park 和 Horaud。
验证通过之后,把选定的T_cam2gripper保存成文件,后续工程代码加载这个矩阵,就能把相机坐标变换到机械臂坐标了。
4. 实战踩坑记录:从失败到成功的调试过程
4.1 内参不准:结果看似合理,实际定位偏
第一次标定完,我用验证集算了一下位置误差,大约 7mm,感觉还有优化空间。当时没怀疑内参,因为 D435i 出厂参数看起来精度很高。后来我用 OpenCV 单独做了内参标定,发现出厂内参和实测内参在主点坐标上差了约 5 个像素,焦距差了约 0.3%。
这个量级的内参误差,直接导致 solvePnP 求解出的标定板位姿有了系统性偏差,进而污染了手眼标定结果。重新标定内参、并修改代码里的camera_matrix和dist_coeffs之后,验证误差从 7mm 降到了 3mm 左右。
所以我的建议是:任何相机做手眼标定之前,先花一个小时把内参标定做了。这个时间花得非常值。
4.2 姿态太少:不同算法结果打架
一开始我只采了 8 组数据,而且姿态基本都是机械臂平移、旋转角度很小。跑五种算法时发现,Tsai 和 Horaud 求出的平移向量在 z 轴方向差了 15mm。
这个现象非常典型,说明 AX=XB 方程的条件数太差,解不稳定。旋转信息不足时,方程在某个方向上几乎是“平的”,微小的噪声就会被放大成大误差。
解决办法就是增加采样姿态的旋转多样性。我把机械臂在标定板前做了俯仰 30 度、偏航 45 度、翻滚 20 度等一系列变化,最终凑了 20 组数据。重新标定后,五种算法的结果非常接近,验证误差也降到了接近 2mm。
4.3 机械臂工具坐标系设置错误:偏差集中在某个轴
有一次标定完,验证误差在 x、y 方向都很好,唯独 z 方向一直偏 4mm 左右。排查了各种环节,最后发现是机械臂的工具坐标系设置的问题。
我用的是末端法兰坐标系读取位姿,但机械臂控制器的工具坐标系里还残留着之前调试时设置的偏移量。这个偏移量导致读取的末端位姿和实际相机安装位置之间有一个固定偏差,最后全部映射到了手眼标定结果里。
解决办法是在机械臂控制面板里把工具坐标系清零,或者把标定用的相机坐标系设置在末端法兰中心,保证读取的位姿就是末端法兰的真实位姿。这个坑很隐蔽,如果你的标定结果在其他维度都正常、唯独某个轴有固定偏差,优先检查工具坐标系。
4.4 自动曝光导致图像不稳定
D435i 的 RGB 相机默认开启自动曝光,在标定过程中,机械臂运动时遮住光源、或者标定板移动到不同光照区域,都会导致自动曝光剧烈调整,画面亮度忽明忽暗,角点检测的稳定性就会下降。
我的处理方式是关闭自动曝光,固定一个合适的曝光值。具体数值根据现场光照条件实验确定,我在室内日光灯环境下测试,曝光值设在 200 左右效果比较稳定。
sensor = profile.get_device().first_color_sensor() sensor.set_option(rs.option.enable_auto_exposure, 0) sensor.set_option(rs.option.exposure, 200)手动固定曝光之后,20 组数据的角点检测成功率从 80% 提升到了 100%,标定稳定性明显改善。
5. 常见问题与排查技巧实录
这部分整理我标定过程中遇到的典型问题,做成速查表,方便大家排查。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 五种算法结果差异巨大 | 采样姿态旋转不足或数量太少 | 增加到 20 组以上,覆盖俯仰偏航翻滚 |
| 验证误差稳定在 5mm 以上 | 相机内参不准 | 用棋盘格重新标定内参,不用出厂参数 |
| 某个轴方向固定偏移 | 机械臂工具坐标系设置有误 | 清零工具坐标系,确认末端位姿读取位置 |
| 角点检测时好时坏 | 自动曝光导致图像亮度波动 | 关闭自动曝光,手动固定曝光值 |
| 标定结果随数据顺序变化 | 数据里混入了不合格样本 | 每组数据都做角点检测验证,不合格就重采 |
| 标定板边缘角点偏差大 | 标定板不够平整 | 换铝板或玻璃板,保证板面绝对平整 |
| 验证误差小但实际抓取偏 | 验证集与标定集分布相似,过拟合 | 验证集单独规划姿态,与标定集显著不同 |
还有一个独家技巧:数据采集时,可以写一个简单的脚本,实时检测角点数量并把检测失败的样本标红提示。这个脚本能帮你在现场立刻发现问题,而不是等采完 20 组数据回办公室跑标定才发现其中几组已经废了。
另外,关于算法选择,我的建议是不要迷信某一个算法的官方文档,实际项目里把五种算法全跑一遍,用验证集打分选最优,这才是最稳妥的做法。不同项目的噪声特性不同,最优算法也不一样。我这次是 Park 和 Horaud 胜出,其他项目可能 Tsai 表现更好。
还有个容易忽略的问题是数据精度。机械臂位姿如果是从示教器上人工读出来再录入的,精度会很差,务必用控制接口直接读取浮点型数据。我这边通过 RTDE 接口读取的时候,位姿数据都是带 6 位小数的浮点数,这样进入手眼标定的 A 矩阵才有足够的精度。
最近我还在尝试把标定采集流程做成半自动化的脚本:机械臂按预设路径自动运动到各个采样姿态,每个姿态停稳后自动触发相机拍照,程序自动做角点检测并把可用的样本数据保存下来。整个采集过程从手动 40 分钟缩减到 10 分钟,而且不容易漏采姿态。如果你也需要频繁做标定,强烈建议把这个流程自动化。
最后说一句我在实际项目里的体会:手眼标定是一项“上限由硬件决定、下限由方法决定”的工作。当你把所有方法层面的坑都填平之后,最终精度大概率就停在机械臂自身绝对定位精度的量级上。1mm 对多数协作臂来说是合理的终点,不要为了追求 0.1mm 而投入过多精力,除非你用的是绝对定位精度很高的工业机器人,并且标定板、环境、相机都达到实验室级别。认清这一点,能帮你把精力花在真正有回报的地方。