简介:面向机器人开发与计算机相关专业学生,这套基于ROS的手眼标定程序包可解决“眼在手上”的标定问题:输入机械臂末端位姿与相机识别到的标定板位姿,即可计算末端与相机间的坐标变换矩阵,支持JAKA、AUBO机械臂,已在ROS Kinetic/Melodic平台验证通过。压缩包共158个文件,约10.44MB,以Python源码、launch启动文件、CSV/TXT位姿数据配置及CSV结果文件为主,并含AUBO机械臂相关动态库和编译配置,便于直接编译运行与二次开发。包内提供详细程序使用说明,包含基础标定流程、launch文件参数修改方法和多组测试数据,运行后还能输出不同算法下的计算结果及标准差、方差,方便对比与参考。作为毕业设计、课程设计或入门项目均有较高借鉴价值。目前已有413人学习下载,适合ROS与机器人领域学习者使用。
1. 手眼标定的本质:先接受一个反直觉结论,标定质量八成在数据不在算法
机械臂加视觉的第一步,几乎都是手眼标定。不管你是在做视觉引导抓取、装配对位还是移动底盘上的机械臂作业,都要先解决同一个问题:相机坐标系里的点在机械臂坐标系里到底在哪。这个变换矩阵求不准,后面所有视觉定位、轨迹规划全是空中楼阁。很多人拿到 ROS 手眼标定程序包,以为跑一遍拿到一个矩阵就完事了,实际上数据采集、中间验证和误差排查才是真正花时间的环节。
这里先说一个反直觉的结论:手眼标定用的算法早就成熟了,Tsai-Lenz 和 Park-Martin 在 OpenCV 里一个函数就能调,真正让标定结果翻车的,十有八九是数据采集姿势不对。标定板只在一个姿态附近活动、采样帧数太少、TF 链路没接对,任何一个都能让最终矩阵偏得离谱。这篇文章按我实际做过的流程来讲:标定的数学本质是什么、ROS 程序包怎么跑通、数据怎么采才有效,以及几个能让你少熬夜的避坑经验。适合正在做机械臂视觉抓取、ROS 机器人开发,或者刚接触手眼标定但被结果稳定性折磨过的从业者。
2. 眼在手上还是眼在手外:AX=XB 方程的两种配置与解法选型
2.1 眼在手外配置:相机固定、标定板贴在机械臂末端
眼在手外(eye-to-hand)是工位自动化里最常见的布局:相机装在支架上俯拍工作区,机械臂在相机视野里干活。这时候求的是相机坐标系到机械臂基座坐标系的固定变换。标定时把标定板固定在机械臂末端法兰上,让机械臂带着标定板在相机视野里变换姿态,程序同时记录机械臂末端位姿和相机观测到的标定板位姿。
方程用 AX=XB 来写。设 A 是机械臂末端在基座坐标系下的位姿变换,B 是标定板在相机坐标系下的位姿变换,X 就是待求的手眼矩阵。同一个标定板在空间中有一个不变的真实位置,两次采样之间能消掉这个不变量,最后整理成 A₁X = XB₁ 的形式。这个方程的意思是:通过两次不同姿态的观测,把手眼矩阵从等式两边夹逼出来。
眼在手外的标定板装夹很关键,标定板平面要和末端法兰轴线基本垂直,而且装上去之后整个标定过程中不能再动。我之前见过有人用胶带临时贴标定板,标到一半板子歪了,结果解出来的矩阵看起来合理,实际抓取偏了 3 厘米,排查了两天才发现是机械端的问题。
2.2 眼在手上配置:相机装在末端、标定板钉在桌面上
眼在手上(eye-in-hand)是移动机械臂和复合机器人更常用的方案:相机直接装在机械臂末端,跟着机械臂一起动,标定板固定在工作台或地面上。这种配置的好处是视野随末端移动,近距离作业精度更高,缺点是相机跟着运动,图像容易模糊,对曝光时间要求高。
方程形式上仍然是 AX=XB,但 X 的含义变成了相机坐标系到机械臂末端坐标系的变换。这里 A 是机械臂末端位姿,B 是标定板在相机坐标系下的位姿,两个变量都存在 tf 树里。注意一个最常踩的坑:眼在手外求的是相机到基座的变换,眼在手上求的是相机到末端的变换,两者物理意义完全不同,你要是把配置选错了,标定程序不会报错,但结果在验证阶段一定会露馅。
判断该用哪种配置没有绝对标准,我的习惯是:工作区固定、机械臂底座不动,优先眼在手外,标定一次管很久;机械臂要移动、或者目标物体在多个工位之间流转,用眼在手上,标定结果跟随末端,换底座位置不用重标。
2.3 求解算法选型:Tsai-Lenz、Park-Martin 与 OpenCV 封装
AX=XB 的解法主要分两类。Tsai-Lenz 是两步法,先求旋转再求平移,计算效率高,对旋转噪声相对鲁棒,适合数据质量一般、有抖动的工程现场。Park-Martin 基于李群和李代数,把旋转和平移放在一个框架里同时求解,理论上更精确,但数据里如果有离群点,结果反而容易飘。
实际工程里我一般直接用 OpenCV 的cv2.calibrateHandEye(),它内部实现了 Tsai-Lenz、Park-Martin、Horaud 等多种方法,通过method参数切换。如果你采集的数据姿态差异大、质量稳定,用 Park-Martin 效果更细;如果标定板检测有抖动、末端姿态精度一般,Tsai-Lenz 更稳。calibrateHandEye的输入是两组 4x4 齐次变换矩阵:机械臂末端位姿和标定板位姿,输出就是手眼矩阵 X。
用 OpenCV 封装还有个好处,它能顺便给出重投影误差的中间结果,方便你在数据采集阶段就筛掉坏帧,而不必等到最后验证才发现问题。下一步就是把它接到 ROS 程序包里跑通整个流程。
3. ROS 程序包跑通全流程:目录结构、编译命令与 launch 配置
3.1 程序包目录结构:先看懂三个目录再动手
拿到一个基于 ROS 的手眼标定程序包,解压之后先不要急着编译,花五分钟把目录结构看明白,后面能省很多排查时间。常见布局是src/下放功能包,功能包里分launch/、nodes/(或scripts/)、config/、data/几个目录。launch/放标定主流程的启动文件,config/放相机参数、标定板尺寸、检测器参数,data/存采集到的位姿数据。
标题里提到的"详细程序使用说明"一般会单独放在doc/或 README 里,我拿到包之后的习惯是先把使用说明扫一遍,重点看它写的是 ROS1 还是 ROS2、依赖了哪些包、标定板是什么规格。这三件事没搞清楚就编译,大概率会在中途卡住。
hand_eye_calib_ws/ ├── src/ │ └── hand_eye_calib/ │ ├── launch/ # 标定主流程 launch 文件 │ ├── nodes/ # Python/C++ 标定节点 │ ├── config/ # 相机内参、标定板参数 │ ├── data/ # 采集的位姿数据落盘目录 │ └── doc/ # 程序使用说明这个结构很典型:data/目录存在感不高,但如果你标定完发现结果不理想想复盘,它能帮你保留现场。我一般会在每轮标定前清空data/,避免上一轮的数据混进来污染结果。
3.2 编译与依赖检查:catkin_make 之前先确认这四样
编译之前先确认环境。手眼标定程序包依赖四样东西:ROS 核心(以 ROS1 Noetic 为例,ROS2 同理)、OpenCV、tf2 相关库、以及你相机型号对应的驱动。如果环境还没装好,用鱼香ROS的一键安装脚本是最省时间的路径,安装完之后记得source /opt/ros/noetic/setup.bash。检查顺序有一套固化的命令:
# 1. 检查 ROS 环境是否正常,输出包含 version 才算就绪 roscore --version # 2. 检查 OpenCV 版本,低于 3.4 建议升级 pkg-config --modversion opencv4 # 3. 检查依赖包是否装上 rospack find tf2_ros rospack find cv_bridge # 4. 创建并编译工作空间 mkdir -p ~/hand_eye_calib_ws/src cd ~/hand_eye_calib_ws catkin_make source devel/setup.bash逻辑说明:前三条命令分别验证 ROS 主框架、视觉库和 TF 通信库,rospack find找不到包说明依赖没装全,这时候编译会报 "package not found"。catkin_make编译整个工作空间,如果源码里有编译错误,通常集中在 OpenCV 头文件路径和 Eigen 库冲突这两类,后面避坑章节再细说。
参数说明:catkin_make -j2可以限制编译并行数,机器内存小的时候能避免 OOM。另外如果你装的是 ROS2,对应的命令是colcon build,程序包若只写了 ROS1 接口,需要先用ros1_bridge或者改源码适配,不建议硬编。
3.3 第一轮标定怎么跑:launch 文件逐行拆解
编译通过之后,运行标定主流程靠 launch 文件。一个标准的手眼标定 launch 要启动三部分:相机驱动、标定板检测节点、标定主节点。以眼在手上的配置为例,launch 文件的核心结构如下:
<launch> <!-- 相机驱动:按你的相机型号替换,这里以 USB 相机为例 --> <node name="camera_driver" pkg="usb_cam" type="usb_cam_node" output="screen"> <param name="video_device" value="/dev/video0" /> <param name="camera_frame" value="camera_link" /> </node> <!-- 标定板检测:aruco 检测节点,发布 marker 的 tf --> <node name="aruco_detector" pkg="aruco_ros" type="single"> <param name="marker_size" value="0.04" /> <param name="camera_frame" value="camera_link" /> <param name="marker_frame" value="board_frame" /> </node> <!-- 手眼标定主节点 --> <node name="hand_eye_calib" pkg="hand_eye_calib" type="calib_node" output="screen"> <param name="samples_max" value="30" /> <param name="min_rotation_deg" value="15" /> <param name="output_file" value="$(find hand_eye_calib)/data/hand_eye.yaml" /> </node> </launch>逻辑说明:launch 文件里camera_frame和marker_frame两个参数决定了 tf 树的结构。相机驱动发布camera_link到图像帧的变换,aruco 节点发布board_frame相对于相机的变换,标定主节点从 tf 树里读取这两组数据。如果你用的是 RealSense 或海康相机,驱动节点名和参数不同,但原理一样。
参数说明:marker_size是标定板格子实际物理边长,单位米,这个值必须精确测量,差 1mm 最终平移误差可能放大 10 倍;samples_max是最多采样多少组数据;min_rotation_deg是相邻两次采样之间末端姿态至少旋转多少度才入队,低于这个值的数据高度相关,对求解是负贡献。首轮标定建议把这些参数调保守,先跑通再谈精度。
4. 采样姿势决定标定精度:数据采集脚本、数量策略与筛选逻辑
4.1 采样数量与姿态分布:15 组是底线,30 组才稳
手眼标定最反直觉的地方在这里:不是采集几百组数据就能更准,真正有效的是姿态的多样性。假设你的机械臂末端只在同一个位置附近前后移动,姿态固定,那么 100 组数据等价于 1 组数据,因为 AX=XB 方程在这些数据下是线性相关的,解不唯一。理论上至少需要 3 组非平行的位姿才能求解,工程上我一般按下面这个经验表来采:
| 配置 | 最低样本数 | 推荐样本数 | 姿态要求 |
|---|---|---|---|
| 眼在手外 | 10 | 20-30 | 绕末端 X/Y/Z 轴都有 >30° 的旋转 |
| 眼在手上 | 10 | 25-30 | 标定板在视野中心、边缘、角落都有分布 |
采样策略分三点。第一,机械臂末端要同时做旋转和平移,不能只平移不旋转,min_rotation_deg至少设 15 度,30 度更稳。第二,采样点要分散在相机视野的不同区域,不要集中在视野中心,边缘畸变区域能提供额外的约束信息。第三,每两个采样点之间让机械臂走一个大回环,打破数据的相关性,我见过有人用连续低速轨迹采样,数据一个挨一个,最后解出来的矩阵验证精度很差。
另外强调一点:采样时机。必须在机械臂完全停止、振动消除之后再记录数据,运动过程中采的帧位姿是模糊的,检测到的标定板角点位置会偏移。
4.2 数据采集脚本:订阅 tf、把位姿落盘
程序包一般会自带采样节点,但自己写一个采集脚本能完全控制采样时机。下面这段 Python 脚本做了三件事:监听 tf、在机械臂静止时采样、把位姿矩阵保存成文件。
#!/usr/bin/env python3 import rospy import tf2_ros import yaml class HandEyeSampler: def __init__(self): self.tf_buffer = tf2_ros.Buffer() self.listener = tf2_ros.TransformListener(self.tf_buffer) self.samples = [] def collect(self, base_frame, tool_frame, cam_frame, board_frame): # 用 tf 时间戳对齐,保证同一时刻的位姿配对 try: tool = self.tf_buffer.lookup_transform( base_frame, tool_frame, rospy.Time(0), rospy.Duration(1.0)) board = self.tf_buffer.lookup_transform( cam_frame, board_frame, rospy.Time(0), rospy.Duration(1.0)) except (tf2_ros.LookupException, tf2_ros.ExtrapolationException) as e: rospy.logwarn("tf 获取失败,跳过当前采样: %s", e) return False # 转成 4x4 齐次矩阵后入队 tool_mat = self._to_matrix(tool.transform) board_mat = self._to_matrix(board.transform) self.samples.append({"tool": tool_mat, "board": board_mat}) return True def save(self, path): with open(path, "w") as f: yaml.dump(self.samples, f) rospy.loginfo("已保存 %d 组采样到 %s", len(self.samples), path) @staticmethod def _to_matrix(t): import tf.transformations as tf_t return tf_t.translation_matrix([ t.translation.x, t.translation.y, t.translation.z ]).dot(tf_t.quaternion_matrix([ t.rotation.x, t.rotation.y, t.rotation.z, t.rotation.w ]))逻辑说明:lookup_transform用rospy.Time(0)拿到当前最新的 tf,并用Duration(1.0)做超时保护,防止 tf 延迟导致位姿配对出错。保存成 YAML 格式是为了后续直接喂给cv2.calibrateHandEye,这也是程序包数据目录最常见的落盘格式。
参数说明:base_frame、tool_frame要从你的机械臂 URDF 里确认,常见的写法是base_link和tool0/ee_link。如果你用的机械臂有多个工具坐标系,务必确认标定板装在哪个tool下,采样的tool_frame要和实际装夹一致,这是后面避坑章节还会提到的重点。
4.3 数据筛选逻辑:重投影误差与一致性检查
采集完之后不要急着丢给求解器,先做一轮筛选。手眼标定的筛选逻辑和视觉里程计类似,核心是看重投影误差。把机械臂末端位姿和手眼矩阵的乘积,投影到图像平面上,和实际检测到的标定板角点做比较,偏差超过阈值的帧直接剔除。
import cv2 import numpy as np def compute_reprojection_error(hand_eye, tool_mats, board_mats, camera_matrix, dist_coeffs): errors = [] for tool, board in zip(tool_mats, board_mats): # 标定板中心从相机坐标系变换到机械臂基座坐标系 board_in_base = tool.dot(hand_eye).dot(board) # 把 3D 点投影到二维图像,计算和检测角点的像素偏差 projected, _ = cv2.projectPoints( board_in_base[:3, 3], np.zeros(3), np.zeros(3), camera_matrix, dist_coeffs) detected = extract_corner_pixels(board) # 实际检测到的角点像素 errors.append(np.linalg.norm(projected - detected)) return np.array(errors) # 筛选:剔除误差大于 2 像素的采样帧,重新求解 valid = errors < 2.0 hand_eye_refined = cv2.calibrateHandEye( tool_mats[valid], board_mats[valid], method=cv2.CALIB_HAND_EYE_PARK)逻辑说明:这段代码先把标定板中心投影到图像平面,和实际检测的角点像素坐标做差。误差大于 2 像素的帧说明检测或者 tf 有异常,剔除后重跑calibrateHandEye,结果通常会更稳定。注意extract_corner_pixels需要从你的检测节点里取,不同检测器接口不同,但思路一致。
参数说明:2 像素这个阈值不是死的,视野大、相机分辨率低的场景可以放宽到 3 像素,高分辨率近距作业可以收紧到 1 像素。筛完如果发现剔除了超过三分之一的帧,说明采集阶段有问题,不要靠筛选硬撑,重采更划算。
5. 手眼标定避坑指南:实测翻车现场与排查路径
5.1 视觉端翻车现场:内参不准、TF 链路缺失、标定板检测失败
现象一:标定结果在验证时抓取偏差 2 厘米以上,且偏差方向不固定。原因:相机内参用的出厂默认值,没有针对当前分辨率和对焦距离重新标定。内参不准,标定板位姿 B 本身就是错的,AX=XB 解出来的手眼矩阵当然跟着错。解决:先用棋盘格跑一遍相机内参标定,得到camera_matrix和dist_coeffs,把结果写进config/camera.yaml,再回来做手眼标定。这一步别省,内参影响重投影误差是全局性的。
现象二:标定节点启动后一直在等 tf,控制台刷 LookupException。原因:TF 树链路不完整。最常见的是camera_link到camera_color_optical_frame之间缺少静态变换,或者机械臂的base_link到tool0的 URDF 没加载。解决:用rosrun tf2_ros tf2_echo camera_link board_frame检查链路,缺哪段补哪段静态变换。特别是用 RealSense 相机时,camera_link和光学帧之间经常需要手动加static_transform_publisher。
现象三:aruco 标定板偶尔检测不到,角点跳变导致标定结果方差大。原因:光照不均匀、标定板反光、曝光时间过长导致运动模糊。解决:换成哑光材质的标定板,用外部光源补光,把相机曝光时间调低(手眼标定是静态采集,不需要长时间曝光)。min_rotation_deg调高也能减少抖动帧入队。
5.2 机械臂端翻车现场:数据退化、tool 关系错误、launch 顺序问题
现象四:每次标定结果都不一样,同一组数据解出来的 X 矩阵差异很大。原因:数据退化,采样姿态太集中。机械臂末端只在一个姿态附近微调,AX=XB 方程组的奇异性高,解不稳定。解决:检查采样数据里旋转矩阵的分布,绕 X/Y/Z 三个轴都要有 30 度以上的变化。另一个隐藏原因是机械臂关节角在奇异点附近,末端位姿噪声被放大,采样时尽量避开腕部奇异区域。
现象五:标定程序能跑,结果也稳定,但抓取目标时固定偏移 5 毫米。原因:标定板相对末端法兰的变换(tool-to-board)没设准,或者标定板装夹位置和程序里写的不一致。眼在手外配置下,标定板是装在机械臂末端的,这个变换被当作已知量代入方程,错了就是固定偏移。解决:用示教器把标定板中心在工具坐标系下的坐标精确测出来,写进配置。如果是眼在手上配置,反过来要确认标定板在工作台上的固定位置没有在标定过程中被挪动。
现象六:roslaunch 启动顺序不对,标定节点比相机驱动先启动导致反复报错。原因:ROS 节点之间没有启动顺序依赖,标定节点启动时 tf 还没开始广播。解决:在 launch 文件里用respawn="true"让检测节点崩溃后自动重启,或者把标定主节点做成收到第一帧有效 tf 再开始计时,而非启动即开始采集。
6. 标定完必须做的闭环验证:一个棋盘格检验全部成果
很多程序包标定完直接输出一个hand_eye.yaml,你以为结束了,实际上验证才是真正检验成果的时刻。我的验证方法很简单:拿一块 A4 大小的棋盘格放在机械臂工作范围内任意位置,先用相机识别它中心角点在相机坐标系下的坐标,再用手眼矩阵变换到机械臂基座坐标系,然后让机械臂末端移动到那个坐标,看末端夹爪和棋盘格中心的重合偏差。
# 验证:把相机检测到的点变换到机械臂基座坐标系 import tf.transformations as tf_t # p_cam 是相机识别到的目标点齐次坐标 [x, y, z, 1] p_cam = np.array([0.12, -0.05, 0.30, 1.0]) # 眼在手外:p_base = camera_to_base * p_cam p_base = hand_eye.dot(p_cam) # 眼在手上:p_base = tool_to_base * hand_eye * p_cam # 需要额外取当前末端位姿 tool_to_base p_base = current_tool_to_base.dot(hand_eye).dot(p_cam) # 机械臂运动到 p_base,用示教器或激光测距确认偏差 move_to(p_base[:3])逻辑说明:变换链把相机坐标系逐步映射到机械臂基座,任何一环错了,最终位置都会偏移。误差在 5 毫米内说明标定可用,10 毫米以上需要回到数据采集阶段排查。
验证流程要走三遍,分别放在工作区左上角、中心、右下角,偏差一致才算标定真正完成。如果三个点偏差方向一致,怀疑 tool-to-board 设置有固定误差;如果偏差随机,优先怀疑数据退化或内参不准。另外我养成一个习惯:每轮标定的数据先备份到带时间戳的目录里,标定板挪过位置就强制重标一次,不做任何假设。这个习惯帮我避掉过很多次返工,希望也能帮到你。
本文还有配套的精品资源,点击获取