做机器人视觉引导的同行应该都有过这种经历:项目还没开始跑,先被“手眼标定”四个字卡了好几天。我之前接过一个视觉抓取项目,相机固定在工作站上方,标定做了三遍,精度始终在±3mm左右徘徊,后来发现不是标定算法的问题,而是这个场景本身就不适合眼在手外模式——机器人末端刚好有一截会挡住工件,每次拍照都得绕路。后来我把相机改到机械臂法兰上,重新标定,精度一次就到了±0.5mm以内。从那以后我就意识到,搞懂眼在手上和眼在手外的区别,不是一道选择题,而是决定项目成败的第一步。
这篇文章我把这几年做手眼标定的经验完整拆开:两种模式各自的空间变换逻辑、AX=XB到底在解什么、UR机器人位姿数据怎么正确转成矩阵、完整标定流程里那些容易被忽略的细节,以及几个我在实际项目里踩过的坑。无论你是刚入门的机器人调试工程师,还是被标定精度折磨了很久的老手,这篇都能给你一些可落地的参考。
1. 相机装哪儿是战略选择:眼在手上与眼在手外的本质差异
1.1 两种模式最直白的区别
眼在手上(Eye-in-Hand)就是把相机通过法兰支架固定在机器人末端,相机跟着机械臂一起动。拍照的时候,机器人会先做一个“摆姿势”的动作,让相机对准目标工件。这套方案在3C装配、螺丝锁附、精密抓取里最常见。
眼在手外(Eye-to-Hand)则把相机固定在工作空间外面的某个位置,最常见的是倒装在机架上方,或者侧装在工作站边上,也有直接用三脚架支在防护网外面的。相机全程不动,机器人是“被看着干活的”。
这两种模式不只是物理安装位置的差别,它们的标定目标、误差模型、标定维护周期、对相机视野的要求全部不同。可以说,这个选择题从你在SolidWorks里画第一个支架的时候就定了。
1.2 眼在手上的优势和麻烦
眼在手上的优势非常明显。
第一,视野跟着末端走。你可以用相对小的视野拍到工件的局部细节,相机的分辨率利用率很高,小目标的识别精度自然好。同样的500万像素相机,眼在手上拍一个指甲盖大小的零件,可能占满整个画面,而眼在手外拍同一个零件可能只占几十个像素。
第二,基本不存在遮挡问题。相机跟着末端走,总能找到一个不挡光、不挡运动的角度。作为调试工程师,这点真的能救命。
第三,适合大工作空间。相机不需要覆盖整个机器人工作范围,只看当前要抓的区域就行。
麻烦也不少。
换工具、换相机、机器人撞过一次,标定关系基本就要重新来一遍。说实话,眼在手上的标定频率比我预想的高得多,产线上换治具是常态,每换一次就得多花半小时做标定。
另一个麻烦的是,眼在手上标定和实际运行都依赖机器人正运动学精度。机器人某个轴的零点漂了,你的视觉抓取精度会跟着崩。这个是系统性误差,标定的时候可能看不出来,跑一段时间后越来越明显。
1.3 眼在手外的优势和麻烦
眼在手外的优势,第一是稳定。相机不动,标定关系基本不会漂,标定一次可以稳定用很久。适合那种“机器人去找料”的场景,比如传送带上料、料筐抓取、装配工作台定位。
第二,可以监控整个工作空间。工件位姿直接映射到机器人基座坐标系,路径规划和防碰撞都好做很多。
第三,机器人运动学误差对视觉定位结果的影响相对小。因为相机固定,物体位姿直接转换到基座坐标系,不经过末端位姿链的累计误差。
麻烦也同样明显。
视野固定,离相机远的区域精度会明显下降。如果工件尺寸大或者工作空间大,你得配更高分辨率的相机,或者做多相机拼接,成本一下就上去了。
遮挡问题也要重点考虑。机器人本体、防护栏、线缆、飞溅的焊渣都可能进入视野。我见过一个项目,机械臂一运行就把标定板挡了一小半,标定板检测不到,整个标定流程直接卡死。
还有一个很多人忽略的:环境光对固定相机的影响是持续的。车间里的灯光、窗外的阳光、设备的指示灯,都会在一天不同时段改变成像效果,稳定性不如眼在手上那种“贴脸拍”。
1.4 一张表抓住选型核心
| 判断维度 | 眼在手上(Eye-in-Hand) | 眼在手外(Eye-to-Hand) |
|---|---|---|
| 小工件识别精度 | 高,视野可贴近工件 | 较低,受视野覆盖限制 |
| 大工作空间 | 适合,视野跟随末端 | 不推荐,需要高分辨率扩展 |
| 遮挡问题 | 基本不受影响 | 容易因机器人动作或设备遮挡 |
| 标定维护 | 换工具、碰撞后需要重标 | 一次标定长期使用 |
| 对机器人绝对精度依赖 | 高 | 相对较低 |
| 视觉伺服/动态跟踪 | 适合 | 不太适合 |
| 多相机/多工位协同 | 复杂 | 灵活 |
我的个人建议是:如果任务是“机器人去找工件”,比如料筐抓取、随动跟踪,首选眼在手外;如果任务是“工件在相对固定的位置,机器人去精准插拔、锁附、抓取”,而且目标不大,眼在手上更容易把精度做上去。当然,也有不少项目是两种模式混合用的,这个后面细说。
2. 标定的数学内核:AX=XB为什么能解出相机位姿
2.1 为什么所有手眼标定最终都在解AX=XB
先别被数学符号吓到,手眼标定的原理用一个比喻就能说清楚。
想象你把相机和机器人比作两根尺子。眼在手上时,机器人基座到机器人末端是一根尺子(可以从机器人系统读出来),末端到相机又是一根尺子(这是我们要标定的未知量),相机到标定板是第三根尺子(可以从图像算出)。三根尺子首尾相接,构成了一个闭环。
问题在于,第三根尺子每次拍出来的“数值”会变,第一根尺子也会随着机器人运动变,但中间那根“末端到相机”的尺子始终不变。我们做的就是通过多次变换,把这个固定不变的关系解出来。
数学上,把这个闭环写成齐次变换矩阵,就是经典的AX=XB形式,或者更准确地说是一系列等价方程的组合。A是机器人给出的位姿变换,B是视觉系统给出的位姿变换,X就是我们要求的相机与机器人之间的联系矩阵。
2.2 两种模式下X的含义完全不同
这是很多人容易搞混的点。
眼在手上模式下,机器人基座到末端的变换是随着运动变化的,这个叫A;相机拍到标定板,标定板在相机坐标系下的位姿也是变的,这个叫B。我们要求的X,是工具坐标系(末端法兰坐标系)与相机坐标系之间的固定变换关系。
眼在手外模式下,A同样是机器人基座到末端的变换,B是标定板在相机坐标系下的位姿,但X的含义变成了机器人基座坐标系与相机坐标系之间的固定变换关系。
同一个符号X,在这两种模式下代表的东西完全不同。如果你用错了,写出来的标定代码结果全是乱的,旋转矩阵动辄差十几度甚至几十度。
2.3 最少需要多少个位姿,推荐取多少个
理论上来讲,手眼标定最少需要3组数据才能解出全部6个自由度,因为每多一组数据就在旋转分量上增加两个约束方程。
但实际工作中,我强烈建议采集10到15组位姿。为什么?因为有噪声。
机器人位姿本身有重复定位误差,标定板角点提取也有亚像素级别的微小偏差,数据量太少时,Ax=Bx这个超定方程组的解会受到单组噪声的支配,结果很随机。数据量上来之后,噪声会被统计平均,解才稳定。
还有一个关键原则:采集的位姿一定要“花”一点。别让标定板每次都出现在图像的同一个位置,也别让机器人末端始终是一个姿态。要覆盖不同的高度、不同的偏转角度,甚至可以让标定板在视野里转着圈出现。这样做的目的是让旋转分量充分激励,否则矩阵求解时会退化,结果看起来能用但实际是错的。
3. UR机器人位姿数据的转换:旋转向量不是欧拉角
3.1 UR的位姿表示方法,先把这个坑填平
UR机器人示教器上看到的Pose格式是 [X, Y, Z, RX, RY, RZ],六个数字。
很多人一看RX、RY、RZ,下意识当成欧拉角处理,然后按XYZ欧拉角或者ZYX欧拉角去转矩阵,结果转出来怎么都对不上。这个坑我见过太多人在里面爬不出来。
UR的RX、RY、RZ不是欧拉角,而是一个旋转向量,也叫轴角表示法。它的方向代表旋转轴方向,模长代表旋转角度。举个例子,如果UR输出RX=1.57,RY=0,RZ=0,意思不是绕X轴转90度,而是绕一个方向为(1,0,0)、模长为1.57的旋转轴转了90度。在这个特殊例子里恰好等价,但只要RX、RY、RZ同时不为零,按欧拉角去解算就会出问题。
3.2 从UR位姿到齐次变换矩阵:罗德里格斯公式落地
从旋转向量转旋转矩阵,标准做法是罗德里格斯公式。
我先给一个Python的实现代码,这段代码我直接用在线视觉项目里,稳定跑了两三年。
import numpy as np import math def ur_pose_to_matrix(pose): """UR位姿 [x, y, z, rx, ry, rz] -> 4x4齐次变换矩阵 rx/ry/rz 是旋转向量,不是欧拉角 """ x, y, z, rx, ry, rz = pose theta = math.sqrt(rx * rx + ry * ry + rz * rz) if theta < 1e-9: R = np.eye(3) else: kx = rx / theta ky = ry / theta kz = rz / theta K = np.array([ [0.0, -kz, ky], [kz, 0.0, -kx], [-ky, kx, 0.0] ]) R = np.eye(3) + math.sin(theta) * K + (1.0 - math.cos(theta)) * (K @ K) T = np.eye(4) T[:3, :3] = R T[:3, 3] = [x, y, z] return T代码里有两个细节值得说明。
一是theta等于0的判据。旋转向量模长为0时,旋转矩阵是单位阵,直接返回单位阵,避免后面除以sin(0)产生NaN。
二是反对称矩阵K的构造方向。这个方向和UR内部使用的旋转方向约定必须一致,实际项目中如果发现转换出来的矩阵在某个方向上的旋转是反的,把K的上三角和下三角符号对调一下就能解决。
3.3 从变换矩阵转回UR位姿的逆变换
标定之后,很多时候我们得到的是齐次变换矩阵,但UR示教器需要的是六个数字,所以还得从矩阵转回UR位姿。
def matrix_to_ur_pose(T): """4x4齐次变换矩阵 -> UR位姿 [x, y, z, rx, ry, rz] 当旋转角度接近180度时注意退化问题 """ R = T[:3, :3] x, y, z = T[0, 3], T[1, 3], T[2, 3] cos_theta = (np.trace(R) - 1.0) / 2.0 cos_theta = max(-1.0, min(1.0, cos_theta)) theta = math.acos(cos_theta) if theta < 1e-6: return [x, y, z, 0.0, 0.0, 0.0] rx = theta * (R[2, 1] - R[1, 2]) / (2.0 * math.sin(theta)) ry = theta * (R[0, 2] - R[2, 0]) / (2.0 * math.sin(theta)) rz = theta * (R[1, 0] - R[0, 1]) / (2.0 * math.sin(theta)) return [x, y, z, rx, ry, rz]这个逆变换有几个边界条件。
当theta接近180度时,sin(theta)趋于0,用这个公式提取旋转向量会出现数值不稳定。如果你的机器人姿态经常处于180度翻转状态,建议改用等效四元数提取旋转轴更稳妥。
另外,theta接近0时,acos函数输出的值也是0,但浮点误差可能让cos_theta略大于1或略小于-1,所以需要clamp一下。
在实际调试中,你大概率会在命令行里做这样的往返校验:
ori = [0.3, 0.5, -0.8, 0.4, 0.2, 1.1] T = ur_pose_to_matrix(ori) back = matrix_to_ur_pose(T) print(back) # 理论上应该和 ori 几乎一致如果六个数字都能对到小数点后4位以上,你的转换代码就没有问题。
3.4 UR控制器内部的坐标变换函数
如果不想在外部做矩阵运算,UR控制器内部其实提供了现成的函数。
URScript里有一个pose_trans(PoseA, PoseB),用于计算PoseA叠加PoseB后的结果。这个函数的语义是:PoseB是相对PoseA坐标系的位姿,结果是PoseB在基坐标系下的位姿。正好相当于两个齐次变换矩阵的乘法。
还有一个pose_inv(PoseA),用于求逆,相当于矩阵求逆。
用这些脚本函数直接做位姿链的拼接,复杂度会低很多。但要注意UR的Pose格式依然是旋转向量表示,你把外部标定得到的矩阵转回ROTATION VECTOR格式时,必须保证转换代码是正确的。
4. 完整的标定实操流程:以UR5e配合Halcon为例
4.1 标定板的选择与固定方式
标定板我建议优先选陶瓷基板的圆点阵列板,因为圆点中心提取精度比棋盘格的角点更稳定,而且Halcon对手眼标定的支持很成熟。
尺寸选择上有一个经验原则:标定板在图像里占的像素面积至少在2万像素以上,太小了角点或圆点提取精度会大幅下降。对500万像素相机,标定板边长为视野对角线长度的一半到三分之二比较合适。
固定方式有个小技巧:标定板不要用夹子,要用强力双面胶或磁吸方式贴在一个绝对平整的金属平板上。我遇到过用夹子固定标定板,在机器人运动过程中标定板发生几毫米的轻微位移,导致后面所有数据全部报废的情况。
4.2 数据采集的位姿要求
先用UR示教器把机器人手动移到标定板的正面方,然后把相机对准标定板中心。这时候记录第一组数据。
接着,让标定板在相机视野内移动位置、改变姿态。我一般会做下面几个动作:
- 标定板出现在视野左上角、中心、右上角、左下角、右下角,各拍一组
- 让标定板相对于相机平面倾斜约15度到30度,拍几组
- 让标定板在视野中旋转不同角度,拍几组
- 把机器人末端调整到不同高度,再拍几组
在这个过程里,有一个非常重要的原则:标定板必须在相机视野内完整可见,不能只露出一半。同时,每次记录机器人位姿时,要等机器人完全静止后再读取,不要边运动边读。
UR读取当前位姿的方式有几种。如果是通过socket通信,可以用Dashboard或者URX库;最简单的是直接在示教器屏幕上手动记录,但对15组数据来说效率太低。我建议用Python通过UR的实时通信接口读取实际位姿。
关于读取的是实际位姿还是指令位姿,有一点要说明:UR默认读取的是控制器内部计算的实际运动学解算结果。如果你需要的是TCP位姿,要配置好工具参数后再记录,否则记录的是法兰坐标系位姿,后面做变换时会对不上。
顺带提醒一下,记录机器人位姿时,如果你换过工具、换过TCP配置,一定要先确认示教器上的TCP配置是最新的。我的做法是在程序开头加一段工具检测逻辑,用已知高度的针尖做一次快速TCP校验,确认无误后再开始标定。
4.3 Halcon手眼标定算子的使用细节
Halcon中执行手眼标定的是calibrate_hand_eye算子,调用它之前需要准备两组输入:
- CalibPlatePoses:标定板在相机坐标系下的位姿,这个由
find_calib_object自动提取 - ToolPoses:对应每张图像记录的机器人工具位姿
有一个非常关键的方向问题,必须在写代码前确认清楚:Halcon的calibrate_hand_eye输出的 HandEyePose,在eye_in_hand模式下是“工具坐标系在相机坐标系中的位姿”,在eye_to_hand模式下是“机器人基坐标系在相机坐标系中的位姿”。
也就是说,Halcon输出的是一个“坐标系A在坐标系B中”的表示。如果你后续计算需要的是相反方向的变换,必须用pose_invert取逆,否则你标定出来的矩阵方向就反了。
我之前有次标定完成后,抓取坐标的Z方向始终差一个翻转,排查了半天,最后发现就是忘了对HandEyePose取逆。
Halcon代码的基本结构如下:
* 创建标定数据模型 create_calib_data('calibration_object', 'halcon', CalibDataID) set_calib_data_cam_param(CalibDataID, 0, 'area_scan_division', CameraParam) set_calib_data_calib_object(CalibDataID, 0, 'calplate_60mm', CalibObjParam) * 循环读图并检测标定板 for I := 0 to NumImages - 1 by 1 read_image (Image, ImageFiles[I]) find_calib_object (Image, CalibDataID, 0, 0, I, [], []) get_calib_data (CalibDataID, 'calib_obj_pose', [0, I], 'pose', CalibPlatePose) * 这里把机器人位姿手动拼进来 tuple_concat (CalibPlatePoses, CalibPlatePose, CalibPlatePoses) tuple_concat (ToolPoses, ToolPose, ToolPoses) endfor * 标定 calibrate_hand_eye (CalibDataID, 'eye_in_hand', CalibPlatePoses, ToolPoses, CameraPose, HandEyePose, Errors)标定做完后,Halcon会返回Errors数组,对应每个标定板的检测误差。这个值一般会在亚像素级别,如果某个图像的误差明显偏大,比如超过0.5像素,建议把对应图像剔除重标。
4.4 标定完成后怎么验证
标定完成不能立刻上产线,先做三个验证步骤。
第一步是“视觉重投影验证”。把标定板放回工作空间内的某个位置,机器人移动到一个已知位姿,通过标定结果计算标定板在机器人基座下的理论坐标,再把机器人末端移到这个点上,看末端工具中心点和标定板中心实际重合多少。这个误差在±0.5mm以内基本就算合格。
第二步是“判向验证”。用标定板做一个已知方向的运动,比如沿基座X方向移动100mm,视觉识别得到的位移也应该是100mm,方向不能偏。
第三步是“静态重复性验证”。同一个位姿拍三张图,标定计算的结果应该高度一致。如果同一个位姿下两次计算结果的姿态差超过0.5度,说明标定数据里可能有某些位姿离群,需要重新采集。
5. 实操中绕不开的坑:数据转换与手眼标定背后的细节
5.1 机器人位姿记录错误:最隐蔽的数据源问题
手眼标定结果差,很多时候不是算法不好,而是喂给算法的数据根本就是错的。
UR机器人通过URX或者socket读取的位姿,和示教器上显示的位姿在绝大多数情况下是一致的。但还是要注意数据有效性问题。UR的实时通信接口有控制频率,如果你从独立线程里读取位姿,可能读到的是几毫秒前的旧值,而图像是另一个时刻拍的。严格来说,图像和位姿必须严格对应。
我的做法是:让机器人在每个拍照点完全停稳,停稳之后读取位姿、触发相机拍照,确保软件逻辑上的一一对应。
这个顺序非常重要。用URScript的话,可以在脚本里用get_actual_tcp_pose()读取当前实际TCP位姿,返回的就是一个位姿列表,然后通过socket发出来。这样读到的一定是当前时刻的位姿。
5.2 Halcon和UR的坐标系方向容易搞混
Halcon的3D坐标系是右手系,X向右,Y向下,Z指向相机前方。而UR机器人基座坐标系是右手系,X向前,Z向上,Y按右手定则确定。这两个坐标系定义不同,标定之后所有数据都要统一到机器人坐标系下,才能做抓取计算。
实操中经常出现的现象是:标定结果矩阵看起来没有任何问题,但机器人走过去的点总是差一个Z轴反射的关系。这种情况,十有八九是你在某个环节把矩阵取了逆,或者坐标系的轴方向理解反了。
我的排查技巧是:先用一个简单的逆向验证。给标定板一个明确的已知位姿,用工控机算出它在机器人基座下的坐标,然后让机器人末端TCP点对准它。如果X方向反了,就检查矩阵里的X列符号;如果Z方向反了,重点检查坐标系轴定义。
5.3 眼在手外时标定板不能放地上
这看起来是个很傻的提醒,但真的有人这么干过。
眼在手外标定的时候,标定板需要在相机视野内清晰成像。很多人图省事,直接放在工作台上,但工作台表面如果有反光、纹理、或者本身就是金属网格板,会干扰标定板检测。更严重的,如果工作台高度和机器人抓取平面高度差很大,标定板放在工作台上得出的手眼关系只对那个高度有效,换到抓取平面时误差很大。
正确做法是,把标定板固定在一个可调高度的支架上,让标定板尽量处于实际工件所在的平面附近,并且保证标定板平面和相机光轴之间有一个明显的夹角,不要去追求完全的正面平行。这样做的原因是,标定需要标定板在不同深度、不同角度上都有效,如果所有数据都在同一个平面上,求解出来的Z方向分量会退化。
5.4 工具坐标系改变后必须重做标定
这条同时适用于眼在手上和眼在手外,但很多人只在眼在手上模式下面会想起来,眼在手外模式常常被忽略。
眼在手外模式下,相机是固定的,理论上工具坐标系变了不影响相机到基座的标定结果。但如果你后续的抓取计算链路里使用了工具坐标系下的偏移关系,实际运行效果就会不对。比如你的吸盘换了长度,末端TCP没有更新,视觉引导的抓取位置就会整体偏移。
所以每次换工具要做两件事:一是更新UR控制器里的TCP参数,二是确认抓取计算链路里所有涉及工具坐标系的矩阵全部是最新的。
6. 精度不够时怎么排查:从数据质量到矩阵运算的完整链路
6.1 先分清误差来源再动手
标定完成后精度不够,不要急着重新采集数据。先按优先级排查。
首先看图像质量。标定板在图像里是否清晰,有没有过曝或欠曝。如果是线阵相机或者大靶面相机,还要确认畸变标定是否正确,畸变大的情况下角点定位会有几十像素的误差,标定结果自然不准。
其次看机器人位姿精度。UR的重复精度很高,但绝对精度受减速机间隙、零点漂移影响。如果你发现同一个位姿下相机算出的标定板位姿有波动,多半是机器人本身的问题。
排查顺序是:先验证图像检测重复性,再验证机器人位姿重复性,最后才怀疑标定算法。
6.2 对误差的容忍度怎么定
手眼标定本身引入的误差通常不是最大的误差源。视觉识别本身的误差、机器人定位误差、夹具的机械误差都会叠加进来。
以抓取场景为例,我给你一个参考值:
- 标定重投影误差:小于0.3像素
- 视觉识别工件中心重复性:小于0.2像素
- 机器人绝对定位误差:±0.5mm到±1mm之间
- 标定后验证误差:小于±1mm
如果你的系统总误差在±1mm以内,对于大部分抓取场景已经够用。如果要求±0.2mm以内的重复精度,光靠手眼标定是不够的,需要加上伺服定位、力反馈或者机械对中结构。
6.3 复杂场景下的扩展方案
项目里还有一种常见情况:眼在手外但机器人工作区域跨度很大,一个相机覆盖不了所有区域。通常的做法是多个相机分别标定,各自建立从相机到基座的固定变换关系,然后在程序中根据工件所在区域选择对应的相机和变换矩阵。
也有一种做法是眼在手上配合一个固定的“工位相机”,形成一个混合系统。机器人末端的相机负责精定位,固定相机负责粗定位和料筐识别。两个相机各自标定,互不干扰,系统做成两级定位。
这种混合架构比较考验代码组织能力,但实际效果很好。粗定位给机器人一个“大概位置”,末端相机快到目标区域时才拍照精定位,精度可以做得很高。
最后说一个我个人很依赖的操作习惯:不管标定做得多好,上产线前一定做一次“空跑验证”,让机器人带一个不吸料的吸盘或者不带电的夹爪,按视觉引导路径完整走一遍,确认所有变换关系在真实节拍下都正常。这个步骤看似多花半小时,但能帮你省下后面至少两小时的排查时间。
我在实际项目里还有一个习惯:每次标定完,把机器人位姿、图像、标定结果、验证结果全部归档到项目文件夹里,命名带上日期和工具编号。这样以后系统异常了,我可以快速回滚到“上一次确认可用的标定结果”,不用从零开始重新标。这个习惯帮我避免了很多次产线停线的尴尬。