ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ROS手眼标定实战:从坐标变换原理到避坑指南

ROS手眼标定实战:从坐标变换原理到避坑指南

简介:基于ROS的手眼标定实现项目,面向机器人视觉、机械臂控制领域的开发者和研究人员,旨在解决相机与机械臂之间相对位姿的精确标定问题,适用于视觉伺服、抓取定位等典型场景。压缩包共78个文件,以19个Python脚本、11个launch启动文件、C++源文件及头文件为主体,辅以txt配置说明、CSV数据、日志文件等,整体大小仅5.25MB,目录结构清晰,便于按模块学习和调用。目前已有460人学习下载。除完整源码外,资源还配套流程教程,从实验环境搭建、标定图像采集、特征提取与参数求解,到结果验证与误差分析均有详细讲解,并针对Tsai-Lenz与张正友等标定方法给出ROS实现思路。源码关键部分附有注释,便于理解与二次开发,既适合ROS初学者快速上手,也能为已有经验的研究者提供可直接使用的标定参考。

1. 基于ROS的手眼标定:为什么这个包值得亲手跑一遍

机械臂视觉抓取的第一步往往不是训练模型,而是让机械臂搞清楚“相机看到的东西,在机械臂坐标系里到底在哪”。手眼标定解决的就是这个坐标转换问题:它求出一个4x4的齐次变换矩阵,把相机坐标系下的坐标换算到机械臂基座或工具坐标系。基于ROS的手眼标定实现已经是相当成熟的套路,采集标定板位姿、记录TF、用OpenCV求解、发布成TF做验证,一整套流程半天就能跑通。这篇笔记面向想往视觉抓取方向走、但又不想只调黑匣子的开发者,我会把原理、命令、参数和血泪踩坑一次说清楚。

2. 手眼标定先搞清楚两件事:eye-in-hand与eye-to-hand的数学差异和选型

手眼标定最容易被新手忽略的是:你手里的机械臂和相机组合,究竟是“眼在手上”还是“眼在手外”。这两者的数学模型不一样,待求矩阵的含义也不一样,选错了,后面所有数据都是白采集。

2.1 eye-in-hand(眼在手上):相机随机械臂移动,标定的是相机到工具末端的变换

眼在手上是目前视觉抓取最常用的构型。相机固定在机械臂末端法兰上,标定板摆在机械臂工作空间内的固定位置,机械臂带着相机从不同角度去看这块板。每次移动机械臂,我们可以记录两个位姿:一是相机观察到的标定板相对相机的位姿,二是机械臂末端相对基座的位姿。连续两组数据之间做差,得到两个变换增量:机械臂末端从位姿A移动到位姿A'的增量,以及相机看到的标定板从位姿B到B'的增量。由于相机和末端都是刚性地固定在一起,这两个增量之间满足AX = XB的关系,其中X就是相机坐标系到机械臂末端坐标系的变换矩阵。

为什么用增量而不直接用绝对位姿?因为绝对位姿里混着相机安装位置、机械臂零位误差等未知量,而增量只关心相对运动,误差能在一定程度上相互抵消。AX = XB这个方程其实就是旋转矩阵约束加上平移向量约束的联立方程组,需要至少三组非平行的运动才能有唯一解,实际中我们往往采15到30组,让姿态差异尽量大。这个矩阵X一旦求出来,之后相机看到任何一个目标点,我们都能通过X把它换算到末端坐标系下,再结合TF树里末端到基座的变换,最终得到目标在机械臂基座坐标系下的坐标,直接交给规划器去抓取。

眼在手上的优点是相机跟着末端走,能够近距离观察目标,精度高;缺点是工作范围受机械臂臂展限制,而且标定误差会随机械臂姿态变化被放大。它特别适合桌面抓取、装配引导这一类场景。

2.2 eye-to-hand(眼在手外):相机固定,标定的是相机到机械臂基座的变换

眼在手外的构型是相机固定在天花板或者支架上,机械臂在相机视野里运动。此时待求矩阵变成相机坐标系到机械臂基座坐标系的变换。方程形式上也可以写成AX = XB,但A和B的含义对调了:A是机械臂末端两次运动的增量,B是相机看到的机械臂末端或附着在末端上的标定板两次观测的增量。由于相机固定,标定板通常绑在机械臂末端,跟着末端一起动。

眼在手外的好处是整个工作空间都在视野内,适合对全局进行观察和避障;缺点是相机离目标往往较远,定位精度可能不如眼在手上。选型上,如果你的目标物体尺寸小、机械臂工作范围不大,优先考虑眼在手上;如果目标是多工位切换,或者机械臂移动范围很大,眼在手外更省心。还有一点,眼在手外的标定结果不随机械臂姿态变化,使用起来更稳定,但安装时需要保证相机支架本身不发生松动,否则标定矩阵要重新做。

2.3 旋转与平移分离:为什么标定结果是4x4齐次矩阵而不是3x3旋转矩阵

很多入门教程直接给一个四元数结果就完事,但实际标定出来的是一个4x4齐次矩阵,它由3x3旋转矩阵R和3x1平移向量t组合而成。旋转矩阵负责把方向拨正,平移向量负责把坐标系原点挪到位。3x3矩阵只能表达旋转,无法表达两个坐标系原点的偏移,而手眼标定的本质就是同时求旋转和平移,所以必须是齐次矩阵。

理解这个结构对后续验证很重要。一个合法的手眼矩阵,它的3x3子矩阵必须是正交矩阵,行列式严格等于1,平移向量量级应该和相机到末端的实际距离吻合。你可以把标定板、相机、机械臂末端的物理尺寸画出来,粗略估算一下平移向量是否合理。如果R的行列式偏离1超过0.01,或者t向量比实际物理距离大了一个数量级,那一定不是最优解,而是数据采集或坐标系定义出了问题。把旋转和平移分开看,定位问题会快很多。

构型相机位置待求矩阵适用场景
eye-in-hand固定在机械臂末端相机到末端(camera to tool)桌面抓取、近距离装配
eye-to-hand固定在外部支架相机到基座(camera to base)全局视野、多工位切换

3. 在ROS里跑通手眼标定的最小闭环:采集、记录、求解一条龙

原理讲完,接下来就是动手。这一章我按最常见的eye-in-hand流程来写,标定板固定,相机随机械臂动。整套流程分为四步:准备环境、采集数据、离线求解、验证结果。每一步的命令和代码都会给出,并说明关键参数怎么改。

3.1 准备:相机内参、标定板、TF树与消息流

开始之前,先确认三样东西是齐的。第一,相机驱动要正常发布图像话题和camera_info话题,camera_info里必须带有相机内参矩阵K和畸变系数D;如果没有,可以先通过ROS的camera_calibration功能包或离线OpenCV棋盘格标定把内参标出来,再写进相机驱动。第二,标定板用ArUco比棋盘格更方便,因为单张图片就能得到唯一的位姿,而且鲁棒性更好。我一般买边长5到8厘米的ArUco板,打印后一定要用卡尺量实际尺寸,不要相信打印设置里的尺寸。第三,机械臂的TF树里要有从base_link到tool0的完整变换,在RViz里能看到才算正常。

消息流连接起来是这样:相机发布/camera/color/image_raw和/camera/color/camera_info,机械臂驱动发布TF。采集脚本订阅图像话题,检测ArUco标定板,得到标定板在相机坐标系下的位姿;同时通过TF监听器拿到机械臂末端在基座坐标系下的位姿。两条数据配对保存,构成一组手眼标定样本。这里有一个容易混淆的点:标定板位姿是“标定板坐标系在相机坐标系下”的表示,末端位姿是“末端坐标系在基座坐标系下”的表示,两者的参考系不同,所以不能直接比较,必须等求解阶段通过AX=XB把它们关联起来。

3.2 采集端:用ArUco检测得到标定板在相机坐标系的位姿,同时记录机械臂末端位姿

下面是采集脚本的核心代码。这个脚本会实时显示图像,检测到ArUco ID为0的标定板后,绘制坐标轴;按s键保存一组数据,按q键退出并保存全部样本。注意要先标定好相机内参,否则estimatePoseSingleMarkers会直接报错或者结果偏移。

#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy import numpy as np import cv2 import tf2_ros import tf.transformations as tf from cv_bridge import CvBridge from sensor_msgs.msg import Image, CameraInfo MARKER_SIZE = 0.05 # ArUco 实际边长,单位米,必须用卡尺量 ARUCO_DICT = cv2.aruco.Dictionary_get(cv2.aruco.DICT_ARUCO_ORIGINAL) DET_PARAMS = cv2.aruco.DetectorParameters_create() class HandEyeSampler: def __init__(self): self.bridge = CvBridge() self.tf_buffer = tf2_ros.Buffer() self.tf_listener = tf2_ros.TransformListener(self.tf_buffer) self.K = None self.D = None self.samples = [] self.latest_T_marker_cam = None rospy.Subscriber("/camera/color/camera_info", CameraInfo, self.info_cb) rospy.Subscriber("/camera/color/image_raw", Image, self.image_cb) def info_cb(self, msg): if self.K is None: self.K = np.array(msg.K).reshape(3, 3) self.D = np.array(msg.D) def image_cb(self, msg): if self.K is None: return img = self.bridge.imgmsg_to_cv2(msg, "bgr8") corners, ids, _ = cv2.aruco.detectMarkers(img, ARUCO_DICT, parameters=DET_PARAMS) if ids is None or 0 not in ids.flatten(): return i = list(ids.flatten()).index(0) rvec, tvec, _ = cv2.aruco.estimatePoseSingleMarkers( corners[i], MARKER_SIZE, self.K, self.D) R, _ = cv2.Rodrigues(rvec[0]) T_marker_cam = np.eye(4) # 标定板坐标系在相机坐标系下的位姿 T_marker_cam[:3, :3] = R T_marker_cam[:3, 3] = tvec[0].ravel() self.latest_T_marker_cam = T_marker_cam cv2.drawFrameAxes(img, self.K, self.D, rvec[0], tvec[0], MARKER_SIZE) cv2.imshow("calib", img) cv2.waitKey(1) def grab(self): if self.latest_T_marker_cam is None: rospy.logwarn("没有检测到标定板,不保存") return False try: trans = self.tf_buffer.lookup_transform( "base_link", "tool0", rospy.Time(0)) except Exception as e: rospy.logwarn("TF获取失败: %s", e) return False q = trans.transform.rotation T_tool_base = tf.quaternion_matrix([q.x, q.y, q.z, q.w]) T_tool_base[:3, 3] = [ trans.transform.translation.x, trans.transform.translation.y, trans.transform.translation.z ] self.samples.append((self.latest_T_marker_cam.copy(), T_tool_base)) rospy.loginfo("第 %d 组已保存", len(self.samples)) return True def main(): rospy.init_node("handeye_sampler") sampler = HandEyeSampler() rate = rospy.Rate(10) while not rospy.is_shutdown(): key = cv2.waitKey(10) & 0xFF if key == ord('s'): sampler.grab() elif key == ord('q'): break rate.sleep() if len(sampler.samples) >= 3: T_cam_marker = np.array([s[0] for s in sampler.samples]) T_tool_base = np.array([s[1] for s in sampler.samples]) np.savez("handeye_samples.npz", T_cam_marker=T_cam_marker, T_tool_base=T_tool_base) rospy.loginfo("已保存 %d 组数据到 handeye_samples.npz", len(sampler.samples)) if __name__ == "__main__": main()

这段代码里,MARKER_SIZE是最关键的参数,它直接决定平移向量的尺度。如果你把5厘米的板写成5米,求出来的t向量会大一百倍,整个结果毫无意义。lookup_transform("base_link", "tool0", ...)返回的是tool0坐标系在base_link坐标系下的位姿,也就是我们在AX=XB中需要的gripper2base变换。如果你用的是UR机械臂,可能还需要确认TCP坐标系到底是tool0还是tool0_controller,两者差了一个工具中心点的偏移,实际标定前最好在RViz里点开TF树看清楚。保存格式是npz,里面存了两个4x4矩阵数组,第一维是样本序号。

3.3 求解端:用OpenCV calibrateHandEye计算X矩阵

采集完数据,进入离线求解。OpenCV内置了calibrateHandEye函数,支持TSAI、PARK、HORAUD等多种算法。我们只需要把采集到的两组矩阵拆成旋转和平移,按函数要求的顺序传入即可。

#!/usr/bin/env python3 # -*- coding: utf-8 -*- import numpy as np import cv2 data = np.load("handeye_samples.npz") T_cam_marker = data["T_cam_marker"] # 标定板在相机系下 T_tool_base = data["T_tool_base"] # 末端在基座系下 # calibrateHandEye 要求输入的是 R_gripper2base / t_gripper2base # 以及 R_target2cam / t_target2cam R_gripper2base = T_tool_base[:, :3, :3] t_gripper2base = T_tool_base[:, :3, 3] R_target2cam = T_cam_marker[:, :3, :3] t_target2cam = T_cam_marker[:, :3, 3] # TSAI 是经典方法,数据质量好时推荐;PARK 对噪声更稳 R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, method=cv2.CALIB_HAND_EYE_TSAI ) X = np.eye(4) X[:3, :3] = R_cam2gripper X[:3, 3] = t_cam2gripper.ravel() np.savetxt("handeye_X.txt", X, fmt="%.6f") print("相机到机械臂末端变换矩阵 X:") print(X)

这段代码的输出X,含义是“相机坐标系在机械臂末端坐标系下”的位姿。也就是说,如果你在TF里把camera_link挂在tool0下面,父子关系应该是tool0为父、camera_link为子,他们的变换矩阵就是X。使用calibrateHandEye有一个细节:函数内部会做归一化和去均值,因此对数据量并不苛刻,但输入数据如果全是同一个姿态变化很小的样本,算法会返回不可信的结果。经验上看,TSAI方法在16到25组数据时表现稳定,数据量超过30组后提升有限,反而可能引入更多手眼运动带来的机械振动误差。

matrix求解后,记得检查旋转子矩阵的行列式是否为1。可以在代码里加一行验证:

# 验证旋转矩阵合法性 R = X[:3, :3] print("det(R) =", np.linalg.det(R))

如果行列式在0.999到1.001之外,直接说明数据有问题,不要继续往下用。

3.4 验证端:把标定结果发布成TF,用重投影误差和位姿误差打分

求解出X之后,先不要急着接抓取。最直接的验证方法是把X发布成静态TF,然后在一个固定标定板场景下移动机械臂,看看“标定板在基座坐标系下的位置”是否稳定。因为标定板是固定不动的,理论上不管机械臂怎么动,用X和当前机械臂末端的TF把相机观测到的标定板位姿换算到基座系下,结果应该是一个几乎不动的点。

#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy import numpy as np import tf2_ros import tf.transformations as tf X = np.loadtxt("handeye_X.txt").reshape(4, 4) rospy.init_node("publish_handeye_tf") br = tf2_ros.StaticTransformBroadcaster() msg = geometry_msgs.msg.TransformStamped() msg.header.stamp = rospy.Time.now() msg.header.frame_id = "tool0" # 父坐标系:机械臂末端 msg.child_frame_id = "camera_link" # 子坐标系:相机 quat = tf.quaternion_from_matrix(X) msg.transform.translation.x = X[0, 3] msg.transform.translation.y = X[1, 3] msg.transform.translation.z = X[2, 3] msg.transform.rotation.x = quat[0] msg.transform.rotation.y = quat[1] msg.transform.rotation.z = quat[2] msg.transform.rotation.w = quat[3] br.sendTransform([msg]) rospy.spin()

运行这个发布节点后,在RViz里把TF树展开,你会看到base_link -> tool0 -> camera_link这个链。如果相机图像话题和标定板检测能对上,你还可以对当前检测到的标定板位姿做一个定量验证:把标定板在相机坐标系下的四个角点,通过X变换到tool0,再通过TF变换到base_link,和实际物理位置对比偏差。更简单的做法是用3.2的采集脚本再采几组新数据,离线计算标定板在基座系下的坐标波动,标准差小于5毫米基本就可以用了,大于10毫米需要回头查原因。

4. 手眼标定的四个必调参数:数据量、标定板尺寸、采样策略、内参精度

手眼标定表面上是求解一个矩阵,实际操作中真正影响结果的是几个参数。这一章把四个最容易踩的参数位拆开讲,每个都对应你在终端里能看到、能改的数值。

4.1 数据量:为什么12组数据是玄学,20组以上才稳定

网上很多教程写“至少采12组”,这句话被很多人当成铁律。实际上12组只是理论上能求的最小数量,真实数据里每一组都带噪声,12组往往让求解结果落在局部敏感区间。我自己的习惯是至少采20组,保证姿态覆盖充分。数据量不是越多越好,超过40组后,每次移动机械臂都会引入微小的振动,而且时间越长越容易混入标定板被碰移位的风险。合理区间是20到30组。

判断数据量是否够,不需要看绝对值,可以做一个简单实验:用采集到的前12组数据求一次X,再用全部数据求一次X,对比两个结果的平移向量差。如果差超过5毫米,说明数据量不足或姿态多样性不够,需要补采。这个方法比盲目堆数据量更靠谱。

4.2 标定板尺寸与角点间距:毫米单位的细微差别如何影响t向量

ArUco板的MARKER_SIZE参数是标定板物理边长,单位米。这个参数同时影响estimatePoseSingleMarkers的尺度恢复,如果实际边长为49.5毫米,你却在代码里写0.05,那么求出来的相机到标定板距离会偏大约1%,最终手眼矩阵的平移向量也会跟着偏1%。比如实际距离是50厘米,偏差就有5毫米,这在抓取精度要求高的场景下已经很明显。

我一般用电子卡尺量标定板的黑色外边框,多量几次取平均值。不要量内部白色方块,因为不同型号的ArUco字典白色区域占比不一样,只有最外层的黑色边框尺寸才是算法使用的真实尺度。另外打印纸的受潮、弯曲也会导致尺寸变化,建议把标定板贴在硬质平板上,不要用软纸。

4.3 采样策略:不要把机械臂拧成一个姿势连拍,要让末端姿态差异尽量大

采样策略直接决定AX=XB方程是否有解。原理上说,X被旋转约束唯一确定的前提是多组姿态变化之间不共线。实操中,最常见的错误是让机械臂在同一个位置附近小幅平移,姿态几乎不变,这样采集到的数据在旋转维度上高度相关,求解结果会病态。

正确做法是让机械臂末端绕X、Y、Z轴分别做30度以上的姿态变化,同时平移范围覆盖相机视野的不同区域。一个有效的经验是:相邻两组的末端姿态差异,至少要让标定板在图像中的角度变化超过15度。你可以想象让相机从一个“俯视”的视角逐渐变成一个“斜视”视角,这样才能给求解器足够的约束。如果机械臂运动学精度差,这个姿态差异要求还要更高。

4.4 相机内参精度:先标内参再标外参,否则全盘翻车

手眼标定建立在相机内参准确的前提上。ArUco位姿估计需要K矩阵和畸变系数D,如果内参标定时误差超过1个像素,标定板位姿会出现系统性偏差,这个偏差会原封不动地传递到手眼矩阵里。更隐蔽的是,畸变系数不准时,图像边缘的标定板位姿误差明显大于中心区域,即使你后期重新采样也无法消除。

所以我的流程永远是:先用棋盘格离线标定相机内参,检查重投影误差小于0.5像素,再开始手眼标定。不要把ROS机器人驱动里写的默认内参当真,很多模拟器与真实相机驱动出来的camera_info只是占位符。内参不准时,手眼标定结果看起来可能很漂亮,但一旦做抓取实验就会原形毕露,属于典型的“标定一时爽,验证火葬场”。

5. 手眼标定避坑指南:3个真实翻车现场与排查清单

这一章把我实际遇到过的、以及在多个现场帮别人解决的问题整理成三条。每一条都按现象、原因、解决的顺序写,你在复现时如果遇到类似症状,可以直接照着定位。

5.1 坑1:标定板在图像边缘,算出来的X平移向量偏得离谱

现象:求解出的X旋转矩阵看起来正常,但平移向量数值比物理距离大好几倍,或者验证时投影误差达到几厘米。原因:标定板靠近图像边缘时,相机镜头畸变最严重,ArUco角点检测出现亚像素偏差,畸变校正后的位姿误差被放大。另一方面,图像边缘的标定板往往太小或成像角度过于倾斜,导致位姿估计退化。解决:采集时把标定板控制在图像中心60%的区域,并且让标定板在图像中占据至少10%的面积。如果必须使用边缘数据,先确认畸变系数标定可靠,再决定是否保留该样本。

5.2 坑2:机械臂末端位姿用了错误坐标系,结果R矩阵一直震荡

现象:每次运行求解脚本,得到的X都不一致,旋转矩阵的行列式明显不等于1,甚至出现NaN。原因:TF树上存在多个与末端相关的坐标系,例如tool0、tool0_controller、flange等,选了错误的父坐标系。这些坐标系的原点差异只有几毫米,但旋转方向可能相差很大,导致同一组数据算出的X完全不同。解决:在RViz里显示TF树,找到真正的运动学链末端坐标系。用示教器手动移动机械臂,观察base_link -> tool0的变换是否随运动持续更新。确认后,把采集脚本里的坐标系字符串固定下来,不要随意改。

5.3 坑3:时间戳对不齐,A和B序列错位,求解器直接给NaN

现象:采集时偶尔提示TF lookup失败,求解时某些数据点产生极端异常值,甚至数组维度不匹配。原因:图像回调数据和TF数据来自不同话题,传感器时间戳相差过大。图像需要经过传输和编码解码,到达回调时已经滞后;而TF查询用的是当前时间,两者配对到同一物理运动状态时存在时间偏差。解决:图像话题开启transport_hints,使用tcpNoDelay降低延迟;TF查询时不要用rospy.Time(0),而是用图像消息的header.stamp,这样能保证配对的是同一时刻的位姿。另外对每一组样本,计算图像时间戳和TF时间戳的差值,超过20毫秒的样本直接剔除。

5.4 排错清单:从TF树到矩阵行列式的五步检查

如果你已经按流程走了一遍但结果不理想,按下面顺序检查,基本能定位80%的问题。对每项,有一个典型阈值供参考。

检查项方法合理范围
TF树坐标系RViz查看末端与基座关系父子关系正确,无跳变
旋转矩阵行列式numpy计算det(R)0.999 到 1.001
平移向量量级对比物理安装距离误差不超过20%
重投影误差固定标定板多姿态验证标准差小于5毫米
交叉一致性前12组与全部数据各求一次X平移差小于5毫米

这个清单的最后一项往往最能说明问题。如果你发现前12组数据和全部数据求出来的X差异很大,而其他检查项都正常,那大概率是采样姿态多样性不够,不是算法问题。不要急着换求解方法,先回去补几组大姿态变化的数据,往往效果立竿见影。

6. 标定完别急着抓取:验证重投影误差与动态精度的两个技巧

求出手眼矩阵X只代表数学上收敛了,不代表真实场景里能用。我每次标定完都会先做一个固定标定板稳定性验证:把标定板固定在桌上,机械臂带着相机移动六个不同姿态,每一组都用当前X把检测到的标定板位姿变换到基座坐标系下,计算这六个三维点的离散程度。这个离散度直接反映了手眼矩阵在动态运动中的精度,我的习惯是标准差小于3毫米才敢进入下一步。

第二个技巧是交叉验证。把采集到的样本随机去掉五组,用剩余数据求一次X,再用这五组数据做重投影,记录误差;重复做十次,取平均误差作为X的最终评分。如果某一次验证误差特别大,说明该组数据可能是手抖、标定板移位或时间戳错乱产生的离群值,直接删除并重新标定。我一般会保留误差最小的那次标定结果,而不是每次都重新采集数据。

进阶一点,可以用在线标定思路:在机械臂实际工作时,每抓取成功一次,就把当前观测到的目标点反投影到图像上,计算像素偏差,持续累加。如果偏差逐渐变大,说明手眼矩阵可能因为碰撞或相机松动已经失效。这个技巧虽然简单,却能帮你提前发现“标定突然不准”的问题。我自己的习惯是每次开机先跑一次固定标定板验证,耗时不到两分钟,换来的却是后面抓取实验的稳定复现,非常划算。

手眼标定这个方向,真正难的不是解法,而是对坐标系、参数和数据质量的敬畏。把这一套流程跑通之后,你会发现视觉抓取的后续调试都顺畅了很多。希望这份实战笔记能帮你少走一些弯路,顺利跑出自己的手眼标定结果。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进