简介:手眼标定是机器人视觉引导中的基础问题,旨在求解相机与机械臂坐标系之间的齐次变换矩阵。其核心方程可归结为AX=XB,通过机械臂运动与标定板观测构造数据对,从而解算出固定变换关系。基于ROS的工程实现能有效整合相机驱动、标定板检测与tf树,支持眼在手上、眼在手外两种模式,并提供Tsai-Lenz、Daniilidis等经典求解算法。在机械臂抓取、分拣、装配等场景中,精确的手眼标定可显著提升视觉定位的准确性与稳定性。本文围绕一套完整的ROS手眼标定程序包,介绍从数据采集、参数配置到误差分析的工程实践方法,帮助开发者快速落地可靠的视觉引导系统。 装好机械臂和深度相机后,让机器人去抓桌上的积木,视觉识别的位置明明是对的,但机械臂每次都是歪着抓过去——差了那么两三厘米。相信不少做过机器人抓取、视觉引导项目的工程师都遇过这个场景。排查来排查去,相机内参标过了,识别算法也没问题,问题十有八九出在一个环节上:手眼标定没做好。
这篇博文要聊的,就是我基于ROS整理的一套手眼标定程序包,包含完整源码和详细使用说明。这不是一个理论demo,而是可以直接跑在真实机械臂加真实相机上的工程方案。你下载下来,改一下机械臂型号、相机话题名、标定板参数,就能把“相机看到的物体坐标”和“机械臂实际要去抓的坐标”之间的变换矩阵算出来。无论你用的是眼在手上(eye-in-hand)还是眼在手外(eye-to-hand)的安装方式,这套程序包都覆盖了。
适合谁看?主要是三类人:一是做机械臂视觉抓取、分拣、装配、码垛的机器人工程师,二是刚接触ROS想要理解手眼标定完整流程的学生,三是被各种标定demo折腾过、想找一个能稳定复现并评估精度的方案的开发者。下面我按自己的实际操作顺序,把这个程序包从原理到代码到排错经验完整讲一遍。
1. 机械臂抓不准,问题常出在坐标系没打通
1.1 手眼标定到底在标什么
先拆一个最容易被忽略的概念:手眼标定里的“手”和“眼”,在不同的安装方式下指向不同。
眼在手上,就是相机装在机械臂末端法兰上,跟着机械臂一起动。这时候“手”是机械臂末端(tool),目标是求相机坐标系相对末端坐标系的变换,记为camHtool或者toolHcam。因为相机固定在末端,这个变换是固定的。有了它,视觉识别到的目标点才能换算到机械臂末端坐标,再通过机械臂的正运动学换算到基座坐标,机械臂才知道往哪儿走。
眼在手外,就是相机固定在外部的支架上,机械臂动它不动。这时候“手”反而是固定不动的机械臂基座,目标是求相机坐标系相对基座坐标系的变换,记为baseHcam。因为相机固定,这个变换也是固定的。视觉识别到的目标点先换算到相机系,再通过baseHcam换算到基座系。
所以手眼标定本质上是一个坐标系标定问题,求的不是什么高深的物理量,就是一个4x4齐次变换矩阵。真正的难点在两方面:一是数据怎么采集才能让方程可解,二是方程怎么解才能让结果稳定、精度高。
1.2 眼在手上与眼在手外:两种模式的本质区别
两种安装模式的标定思路非常不一样,但很多人一开始搞反了,导致采集了一堆数据却解不出来。
眼在手上的标准做法:机械臂末端带着相机移动到不同位姿,拍摄一个固定不动的标定板。每移动到一个位姿,记录两件事:
- 机械臂末端在基座坐标系下的位姿,也就是正运动学结果,记作baseHtool。
- 相机检测到的标定板在相机坐标系下的位姿,记作camHtarget。
因为标定板固定不动,它相对基座的变换targetHbase始终不变。于是有:
toolHbase * baseHtarget = toolHcam * camHtarget
整理一下,用相邻两帧数据相消,最终会得到一个 AX = XB 形式的矩阵方程。这里的X就是要求解的toolHcam。A由相邻两帧机械臂末端位姿构成,B由相邻两帧相机观测到的标定板位姿构成。
眼在手外的标准做法:机械臂末端带着标定板移动,相机固定在外侧。每移动到一个位姿,记录:
- 标定板在相机坐标系下的位姿,camHtarget。
- 机械臂末端在基座坐标系下的位姿,baseHtool。
因为末端和标定板刚性连接,toolHtarget是固定的。于是有:
baseHcam * camHtarget = baseHtool * toolHtarget
同样整理,也能化成 AX = XB 的形式,不过这里的X是baseHcam。
这两种模式我都封装在程序包里了,通过参数切换。采集数据时脚本会自动判断用的是哪种模式,然后把数据按照对应的格式写入求解器,不需要你去手动改方程代码。
2. 程序包的整体构成:从tf树到核心节点
2.1 代码结构与运行链路
这个程序包不是单文件跑完的那种玩具工程,而是按ROS节点的方式组织的。拿到源码之后,你会看到这样的目录结构:
handeye_calibration/ ├── CMakeLists.txt ├── package.xml ├── launch/ │ ├── eye_in_hand.launch │ └── eye_to_hand.launch ├── config/ │ └── settings.yaml ├── include/ │ └── handeye_calibration/ │ ├── data_recorder.h │ ├── calibration_solver.h │ └── handeye_utils.h └── src/ ├── handeye_calibration_node.cpp ├── data_recorder.cpp ├── calibration_solver.cpp └── tools/ ├── check_axis_angle.py └── plot_result.py运行链路是这样:相机驱动节点和ArUco标定板检测节点先跑起来,持续发布标定板位姿;机械臂驱动节点发布tf树,提供base_link到tool0的变换;然后启动标定主节点,订阅这两个数据源,在机械臂移动过程中按策略采集数据对;采集足够多之后,求解器算出变换矩阵,把结果写入tf参数服务器或者保存成yaml文件。
主节点内部有三个核心模块,分工明确:
- data_recorder:负责数据对采集,做时间戳同步和运动阈值判断。
- calibration_solver:封装线性求解和迭代优化,输出最终矩阵。
- handeye_utils:工具函数库,包括四元数/旋转矩阵互转、向量转反对称矩阵、Ax=B方程组装。
数据和话题的关系,最终是这样的:
标定板检测话题(如 /aruco_board/pose) → data_recorder tf树(base_link → tool0) → data_recorder data_recorder → 数据对缓存 → calibration_solver → 结果2.2 数据从哪来:标定板检测与机械臂正运动学
很多新手以为手眼标定需要专门的高精度测量设备,其实不需要。数据源就两个,一个是视觉检测标定板得到位姿,另一个是从tf树读出机械臂末端位姿。
标定板我用的是ArUco板,不是传统的棋盘格。原因很简单:ArUco板有唯一的ID编码,检测程序可以同时给出角点坐标、板的平面姿态和ID,不需要人工选角点的顺序。对于标定这种要采集几十上百帧的场景,全自动检测太关键了。相机驱动用普通的USB摄像头或者Realsense都可以,只要发布sensor_msgs/Image话题就行。
机械臂末端位姿从tf树拿。主节点内部监听对应的时间戳,随时可以通过tf2的lookupTransform取到base_link到tool0的变换。这里有一个容易踩的坑:一定要确保发布tf的是机械臂的真实正运动学,而不是某个关节角度经过不完整URDF模型推算出来的结果。URDF里哪怕有一个link的长度误差,标定出来的矩阵都会整体偏离。
2.3 AX=XB方程如何对齐到ROS消息
数据对怎么组织,决定了后面求解器的输入格式。我在程序包里定义了一个DataPair结构体:
struct DataPair { Eigen::Matrix4d source; // 机械臂末端位姿(baseHtool)或基座位姿 Eigen::Matrix4d target; // 标定板位姿(camHtarget) ros::Time stamp; };眼在手上时,source存的是baseHtool,target存的是camHtarget。眼在手外时,source存的还是baseHtool,target存的仍然是从视觉话题拿到的camHtarget。区别在于求解器内部怎么组装AX=XB,这个我在calibration_solver里做了分支处理:
if (mode_ == EYE_IN_HAND) { // A = pose_i.inverse() * pose_j // B = board_i * board_j.inverse() } else { // A = board_i.inverse() * board_j // B = pose_i * pose_j.inverse() }这里很多人会绕晕,建议直接对照论文推导一遍。我自己第一次写的时候也是对着公式推了半下午,才把A和B的矩阵乘法顺序完全对齐。程序包里的注释把每一步对应到论文式子的编号都备注了,看源码时多留意。
3. 从零跑通标定:环境准备与全流程实操
3.1 依赖安装与launch文件配置
程序包基于ROS Noetic开发,理论上Melodic和Ubuntu 20.04也能编译。依赖项有:
- ros-noetic-aruco-ros(标定板检测)
- ros-noetic-tf2-eigen
- ros-noetic-cv-bridge
- Eigen3
- OpenCV
安装依赖:
sudo apt install ros-noetic-aruco-ros ros-noetic-tf2-eigen ros-noetic-cv-bridge然后编译工作空间:
cd ~/catkin_ws/src git clone <你的仓库地址>/handeye_calibration.git cd ~/catkin_ws catkin_make source devel/setup.bash编译过程中最容易出问题的点是Eigen3和OpenCV的版本冲突,特别是如果你机器上还装了旧版ROS,可能会链接到旧库。我建议编译前先确认:
pkg-config --modversion eigen3 pkg-config --modversion opencv4确保Eigen3版本是3.3以上,OpenCV是4.x。CMakeLists里我加了版本检查,版本不对会直接报错提示,不会等到链接阶段才一脸懵。
3.2 launch文件里那些必须改的参数
launch文件里参数比较多,但如果理解每个参数背后对应的是什么,就不会改错。以eye_in_hand.launch为例:
<launch> <node name="handeye_calibration" pkg="handeye_calibration" type="handeye_calibration_node" output="screen"> <param name="mode" value="eye_in_hand"/> <param name="camera_topic" value="/camera/color/image_raw"/> <param name="board_topic" value="/aruco_board/pose"/> <param name="base_frame" value="base_link"/> <param name="tool_frame" value="tool0"/> <param name="camera_frame" value="camera_color_optical_frame"/> <param name="marker_size" value="0.031"/> <param name="board_marker_distance" value="0.070"/> <param name="board_markers_x" value="5"/> <param name="board_markers_y" value="7"/> <param name="min_rotation_deg" value="10.0"/> <param name="min_translation_m" value="0.03"/> <param name="max_samples" value="60"/> </node> </launch>这里我特别说明几个关键参数:
marker_size:ArUco板中单个黑色方块的实际边长,单位米。这个必须用卡尺量准,不是你打印的时候设置的那个尺寸,因为打印缩放和贴板过程都可能引入误差。差1毫米,标定出来的平移量可能偏好几毫米。
board_marker_distance:ArUco板中相邻marker中心的距离,单位米。这个直接决定标定板参考坐标系的比例尺,错了会导致相机估计的标定板位姿整体缩放。
min_rotation_deg和min_translation_m:这是采集数据时的运动阈值。只有机械臂移动超过这个阈值,程序才认为这是有效的新数据帧,否则就丢弃。目的是避免连续采集几乎相同的数据对,导致方程病态。
max_samples:最大采集帧数。我一般设60帧,够用且不至于让操作变得冗长。
3.3 标定板ArUco板的参数测量
ArUco板可以从aruco_ros包自带的板子生成工具打印,也可以自己生成。程序包config目录下我放了一个生成脚本:
python3 tools/gen_aruco_board.py -o board.png --markers_x 5 --markers_y 7 --marker_size 0.031 --marker_distance 0.070注意,打印完之后一定要用卡尺重新量marker_size和board_marker_distance,不要直接信脚本里的参数。曾经有一块板子打印机默认缩放了97%,我偷懒没量,结果整批数据解出来的矩阵在Z方向偏了差不多3%。量完小数点后三位为止,量完填回配置里。
标定板最好贴在硬质平面上,厚度均匀的亚克力板或者铝板都行,不要贴纸板。纸板容易弯曲,检测出来的平面法向量会随受力变化,标定精度直接报废。
3.4 数据采集与求解:完整执行流程
启动顺序有讲究,我按下面这个顺序执行:
第一步,在终端1启动相机驱动:
roslaunch realsense2_camera rs_camera.launch如果是其他相机,确保发布Image话题即可。
第二步,在终端2启动ArUco检测:
roslaunch aruco_ros aruco_board.launch注意launch文件里要设置camera_frame和image话题,这两个必须跟相机驱动一致。多花两分钟确认一下RViz里能看到标定板的位置信息,别等到采集完才发现一直在检测空气。
第三步,在终端3启动机械臂驱动。保证tf树里有base_link到tool0的完整变换。
第四步,在终端4启动标定主节点:
roslaunch handeye_calibration eye_in_hand.launch程序启动后会打印当前模式,并开始等待有效数据。控制机械臂以不同姿态移动,每个姿态停留一两秒,让程序完成数据对采集。过程中可以在终端里看到类似这样的输出:
[INFO] Collected sample 1, rotation diff = 12.3 deg, translation diff = 0.035 m [INFO] Collected sample 2, rotation diff = 15.1 deg, translation diff = 0.042 m ... [INFO] Collected 60 samples, solving...采集完成后,程序自动进入求解阶段,打印出标定结果:
[INFO] Calibration result (camera to tool): [INFO] Rotation matrix: 0.9987 -0.0231 0.0452 0.0241 0.9995 -0.0188 -0.0447 0.0199 0.9988 [INFO] Translation vector: [0.034, -0.062, 0.108] [INFO] Reprojection error: 0.0023 m结果会同时保存为yaml文件,可以直接用于其他节点。
4. 标定结果怎么判断好坏:误差分析与常见失败定位
4.1 重投影误差与标准差看什么
拿到矩阵不是终点,判断矩阵准不准才是关键。网上下载很多程序包标定完就结束了,这是不对的。我至少看三个指标。
第一个是重投影误差。把标定板角点在相机坐标系下的检测位置,用标定结果反变换到机械臂末端,再正变换到相机坐标系,计算和原始检测位置的偏差。这个偏差的均方根就是重投影误差。工程上做到5毫米以内算合格,2毫米以内算不错,1毫米以内属于很好了。程序包里的solve_and_evaluate()函数会自动计算并打印。
第二个是旋转矩阵的正交性。手眼标定解出来的旋转矩阵必须满足R^T * R = I。数值求解有时会破坏这个性质,所以求解器里做了正交化处理。如果发现某个求解器版本跑出来矩阵不正交,请先更新代码。
第三个是多次重复标定的一致性。同样一套硬件,连着标三次,每次的旋转矩阵和平移向量应该非常接近。如果三次结果差得远,说明数据采集质量不稳定,不是求解器的问题,是数据本身有问题。
4.2 姿态变化单一导致解算退化
我踩过一个很经典的坑,说出来给大家引以为戒。第一次用这个程序包标定的时候,我控制机械臂做了很多平移运动,每个位置只稍微转动一下,结果解出来的矩阵在旋转部分完全对不上,重投影误差高达2厘米。
问题出在姿态变化太单调。手眼标定方程要解出旋转矩阵,本质上依赖机械臂在不同姿态下相机的观测差异。如果你只在同一个朝向附近小幅度晃动,方程组的条件数会非常大,一个微小的测量噪声就会被放大成巨大的旋转误差。
解决办法很简单:每个采样点之间,姿态变化尽量大。我在程序里强制要求相邻两个采样点的旋转角差至少10度,实际操作中我甚至建议至少15度。而且要覆盖不同的旋转方向,不要只绕一个轴转。
程序包里附带了一个check_axis_angle.py脚本,专门用来分析已采集数据的姿态覆盖度。它会画出所有旋转轴在单位球面上的分布,如果发现都聚在一起,说明数据不够多样性,需要重新采集。
4.3 内参与标定板尺寸错误引发的系统性偏移
还有一类问题,标定结果看起来误差不大,但机械臂实际去抓就是偏。这种情况大概率是相机内参或者标定板尺寸有系统性偏差。
相机内参不准,最常见的原因是标定的时候用的标定板太小,覆盖不了画面边缘。手眼标定要用到的内参是畸变系数和焦距,哪怕稍微偏一点,标定板在图像边缘的位姿估计就会偏。如果条件允许,用大一点的棋盘格重新标一次内参,画面里标定板要反复覆盖各个区域。
标定板尺寸错误我也遇到过。打印的ArUco板看起来尺寸差不多,我用尺子一量,marker的实际边长是30.5毫米而不是设定的31毫米,当时没在意。结果标定出来的平移向量在Z方向系统性偏了约1.5毫米。这个偏差在标定板距离相机越远时越明显。
如果有尺寸误差,最直接的验证方法是标定完以后,放一个已知大小的物体在机械臂工作空间内,让机械臂通过视觉定位去抓,看实际偏差方向是否一致。如果偏差方向固定、大小固定,通常就是标定板尺寸或者内参没有标准。
5. 源码里值得关注的核心实现细节
5.1 求解器的封装、选型与背后的数学
程序包里封装了两种经典手眼标定求解算法:Tsai-Lenz和Daniilidis。前者简洁快速,后者基于旋转矩阵的特殊欧式群性质,数值稳定性更好,对噪声更鲁棒。默认使用Daniilidis,因为实测在真实数据下它的结果更稳定,重投影误差略小一点。
核心求解代码的骨架:
Eigen::Matrix4d CalibrationSolver::solve() { if (method_ == METHOD_TSAI_LENZ) { return solveTsaiLenz(data_pairs_); } else { return solveDaniilidis(data_pairs_); } }solveTsaiLenz的基本思路是:把旋转矩阵方程拆成轴角表示,然后对每个数据对建立一个线性方程,最后用最小二乘求解旋转轴,再根据罗德里格斯公式恢复旋转矩阵。solveDaniilidis的思路是:把变换矩阵嵌入到对偶四元数空间,构造一个二次型约束下的优化问题,通过SVD求最小特征值对应的特征向量来得到旋转和平移。
这两种方法在数学上都是先求旋转、再求平移,原理差异在对旋转的表示不同。工程上不需要每次手工推导,但你需要知道的是:方程可解的前提是数据足够多样,否则无论哪个求解器都会给你一个看似合理的错误结果。
5.2 数据预处理与外点剔除
求解器拿到数据之前,data_recorder已经做了一层筛选。除了运动阈值过滤,还有一个容易被忽视的操作:时间戳同步。
相机话题和tf树的时间戳来自不同的时钟源时,会有一个固定的时间偏移。如果直接拿两个时间戳不完全对应的数据组装数据对,会造成系统性误差。我在data_recorder里用tf2的waitForTransform,配合一定的时间容忍度把数据对齐到相邻时间戳上。如果找不到合适的时间戳对,这一帧就丢弃。
程序里还内置了个简单的RANSAC外点剔除。每次迭代,随机抽取8个数据对求解,算出所有数据对在结果下的残差,保留残差小于阈值的点作为内点,重复500次,取内点数最多的那组作为最终结果。这个机制对偶发的大误差帧非常有效。
在采样数量足够,但某些帧因为反光或者遮挡导致角点检测不对的情况下,这个外点剔除能把污染帧的干扰降到最低。实测下来,即便20%的数据是坏的,最终结果仍然能保持不错的精度。
5.3 参数配置表速查
把launch文件里的核心参数整理成一张表,方便对照检查:
| 参数 | 含义 | 建议值 | 影响 |
|---|---|---|---|
| mode | 标定模式 | eye_in_hand / eye_to_hand | 决定方程组装方式 |
| marker_size | ArUco标记边长 | 0.031 | 直接影响尺度 |
| board_marker_distance | 标记中心距离 | 0.070 | 直接影响标定板位姿估计 |
| markers_x / markers_y | 标定板行列数 | 5 / 7 | 板子规格,必须和实际一致 |
| min_rotation_deg | 触发采集的最小旋转 | 10.0 | 数据多样性 |
| min_translation_m | 触发采集的最小平移 | 0.03 | 数据多样性 |
| max_samples | 最大采样数 | 60 | 求解稳定性 |
| solver_method | 求解算法 | daniilidis / tsai_lenz | 数值稳定性 |
如果采集过程中发现某些帧总是被丢弃,去检查min_rotation_deg和min_translation_m是不是设得太大,或者机械臂实际运动不够远。
6. 实测中总结的手眼标定经验与技巧
6.1 采集轨迹设计:别让方程因病态而失效
手眼标定精度好坏,采集轨迹的设计占一半。我的习惯是让机械臂在相机工作空间内走一个类似“球面采样”的轨迹:从中心位置出发,向上下左右前后六个方向移动,每个方向再叠加不同的末端姿态,让末端绕着相机光轴做几次大幅度旋转。
有一个容易忽略的点:不要只在工作空间一个角落采集。相机视野边缘和中心看到的标定板畸变程度不同,如果全程只在中心附近采,内参的畸变误差就体现不出来,标定结果只在中心附近好用,到边缘就偏。所以采集过程要刻意覆盖相机的各个视野区域。
具体操作上,我建议整个采集过程持续3到5分钟,每移动到一个位置后先停顿半秒再移动下一位置。不要快速连续移动,否则相机图像会模糊,ArUco检测位姿会不稳定。
6.2 环境与相机设置对精度的实际影响
这是一个很多人不重视但效果显著的点。光照变化会导致标定板角点检测出现亚像素级别的偏移,这种偏移虽然小,但60帧数据累加起来,对标定矩阵的影响不容忽视。
我的做法是:标定过程中保持环境光照稳定,不要开自动曝光。如果相机驱动支持设置曝光时间,手动固定一个合理的曝光值。我用Realsense时,会把自动曝光关闭,设为固定值,然后通过数字增益把画面亮度调到标定板黑色区域和白色区域对比明显为止。
标定板要尽量平整、无反光。如果用普通亚克力或纸质标定板,不要在强光直射下使用,反光会让角点检测位置偏移。如果必须用反光材质,可以选择在阴天或室内均匀灯光下标定。
相机镜头如果有自动对焦功能,请手动固定焦距。因为自动对焦会在每次拍摄时微调镜头位置,导致内参和畸变系数发生微小变化。手眼标定过程中如果焦距变了,整个标定就废了。对自动对焦相机,我一般把对焦环用胶带固定住。
6.3 标定完成后如何验证:一把尺子检验一切
标定完成后不要直接上线,做一次完整的验证闭环。我的验证方法是:在机械臂工作空间里放一个已知高度的标准量规或一个固定尺寸的方块,让机械臂末端装上针尖或者激光笔,通过视觉定位方块中心位置,再让机械臂移动到那个位置,看针尖是否准确落在方块中心。
如果发现针尖落在目标点附近但有固定偏差,优先怀疑标定板的marker_size或board_marker_distance测量不准。如果偏差方向随机、大小不稳定,说明数据采集质量不够好,需要重新标定。
更进一步的验证是做一个“视觉引导抓取”测试,连续放置多个不同位置的目标物体,让机械臂逐一抓取。全部成功才说明标定结果可靠。这个方法虽然耗时,但能真实检验整个视觉引导链路的综合精度。
6.4 程序包使用过程中的其他注意点
最后补充几个零散但实用的注意点。
程序包里的plot_result.py脚本可以读取保存的标定结果,画出标定板在相机坐标系下的轨迹和机械臂末端轨迹,有助于直观判断数据覆盖是否充分。每次标定完建议跑一下这个脚本,保存一张图作为记录。
如果标定过程中突然出现“waitForTransform: lookup would require extrapolation into the past”之类的报错,说明相机话题和tf的时间戳不同步。检查一下相机驱动发的是不是sensor_msgs/Image的header stamp,以及机械臂驱动是不是在持续发布tf。这种问题大多不是程序包本身的bug,而是上游驱动的时间戳乱了。
程序包也支持棋盘格检测模式,但一般建议用ArUco板。如果你机器上不方便打印ArUco板,也可以改用棋盘格,但需要额外安装标定板检测节点,且检测代码需要相应调整。压缩包里的源码对两种模式都做了封装,但ArUco是主力路径,棋盘格模式我平时用得少,稳定性验证不如ArUco充分。
我在实际项目里用这套程序包标定过的机械臂,有六轴的协作臂,也有四轴的SCARA,相机用过Realsense D435i、海康工业相机和普通USB摄像头。只要数据采够、采好,标定出来的矩阵精度基本稳定在1到3毫米之间。对于绝大多数视觉抓取场景,这个精度已经完全够用了。如果你之前在别的地方下载过手眼标定程序却总是标不准,可以考虑试试这套,至少源码里每一步都留有日志和验证工具,出了问题能快速定位。
本文还有配套的精品资源,点击获取