ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

ROS手眼标定程序包详解:从AX=XB到JAKA/AUBO实战

ROS手眼标定程序包详解:从AX=XB到JAKA/AUBO实战

简介:基于ROS的手眼标定程序包,面向计算机、自动化、电子信息等专业学生,适用于毕设、课程设计或初期项目立项演示,主要解决JAKA、AUBO机械臂与相机之间手眼标定的坐标变换问题。包内通过launch文件快速启动标定流程,以Python脚本实现多种标定算法,并附带C++接口代码,从数据获取、标定解算到结果输出形成完整链路。资源共78个文件,整体约5.22MB,除主要运行脚本和启动配置外,还包含8个txt说明文档、5个log运行日志、4个csv数据文件及若干头文件和依赖库,方便核对中间结果与排查问题。压缩包内按不同机械臂型号分模块组织,配套使用说明文档,目录结构清晰,便于快速定位。已有274人在该页面学习浏览,对于搭建ROS机械臂标定环境、需要可运行项目作为参考的用户来说,是一份结构紧凑且便于二次修改的完整代码包。

1. 手眼标定程序包:让机械臂和相机不再各说各话

做机械臂抓取或者视觉引导定位的朋友,大概率都卡在过同一个环节:相机告诉你的坐标,和机械臂实际抓取的位置,总是差那么几毫米到一两厘米。这个偏差不是相机精度不够,而是相机坐标系和机械臂基坐标系之间的变换关系没解出来。这个变换关系,就是手眼标定要解决的问题。

我正在用的这套基于 ROS 的程序包,做的就是这件事——它把 eye-in-hand(眼在手上)和 eye-to-hand(眼在手外)两种模式都覆盖了,内置多种求解算法,并且直接适配了 JAKA 和 AUBO 两家机械臂的 ROS 驱动话题。也就是说,你不用自己去翻论文重写 AX = XB 的求解器,也不用满世界找驱动适配代码,装好依赖、配好话题、跑一组采集脚本,标定结果就出来了。这篇文章适合正在做 ROS 机械臂开发、被标定精度搞得头疼、或者刚入手 JAKA / AUBO 想快速把视觉跟机械臂对齐的工程师。

2. 手眼标定的数学原理和程序包设计:为什么一个包要装多种算法

2.1 AX = XB 到底在解什么

手眼标定的核心方程就是 AX = XB。这里的 A 是机械臂末端在基坐标系下的位姿变换矩阵,B 是相机光学中心在标定板坐标系下的位姿变换矩阵,X 就是我们要找的手眼矩阵——相机坐标系到机械臂末端坐标系的变换。说起来很绕,但拆开就很直白:

  • 眼在手上(eye-in-hand):相机装在机械臂末端,跟着机械臂一起动。标定板放在世界里不动。A 是机械臂末端位姿(从控制器读),B 是相机检测到的标定板位姿(从图像算)。X 是相机到机械臂末端的变换。
  • 眼在手外(eye-to-hand):相机固定在外界,看着机械臂动。标定板装在机械臂末端。A 还是机械臂末端位姿,B 是相机检测到的标定板位姿,X 是相机到机械臂基座的变换。

不管哪种模式,方程形式都一样。但这里的难点在于:A 和 B 都是带噪声的测量值,而且如果你采集的姿态不够多样,方程会退化或者陷入局部最优。这就是为什么一个成熟的手眼标定程序包需要内置多种算法——不同算法对噪声的鲁棒性、对标定姿态分布的要求、对初值的敏感程度都不一样,你需要对比着看结果。

2.2 程序包的典型模块划分和算法选型

我一般会把这类标定包划分为四个模块:数据采集模块、特征提取模块、求解器模块、结果验证模块。数据采集模块负责订阅机械臂位姿话题和相机图像话题,特征提取模块负责检测标定板角点并计算 B 矩阵,求解器模块就是 AX = XB 的解算,结果验证模块负责把解出来的 X 代回原方程算残差。

多种算法这块,常见程序包一般集成了这几种:

  • Tsai-Lenz 方法:经典的两步法,先解旋转再解平移。速度快,对姿态数量要求不高,适合快速验证。
  • Park-Martin 方法:用李群和李代数把问题转成最小二乘问题,对噪声的鲁棒性比 Tsai-Lenz 好一些。
  • Daniilidis 方法:基于对偶四元数,是现在很多开源实现默认选用的方法,精度和稳定性比较均衡。
  • OpenCV 的 calibrateHandEye 函数:里面封装了上面几种方法,直接调用就行。

一个工程化的标定包不会只调其中一种,而是把几种都跑出来,让你对比重投影误差和旋转矩阵的正交性,再决定用哪个结果。这也是我拿到一个手眼标定程序包后第一个会去确认的事——它有没有把多种算法都暴露成可调参数。

2.3 JAKA 和 AUBO 的适配层是怎么处理的

JAKA 和 AUBO 都是国内常见的协作机械臂,它们的 ROS 驱动发布的话题格式不完全一样。JAKA 的驱动包一般发布 /jaka_pose 这样的 geometry_msgs/PoseStamped 话题,AUBO 的驱动则可能发布 /aubo_robot/pose 或者用 action 接口来查询位姿。一个支持两种机械臂的标定包,通常会在配置文件中让你指定机械臂类型和对应的话题名,底层用一个统一的位姿获取接口把数据转成 Eigen 矩阵。

这块是最容易踩坑的地方——不同版本的驱动话题名不一致。比如 JAKA 的驱动有时候发布的是 /jaka_pose,有时候是 /jaka_robot/current_pose;AUBO 的 i5 和 i3 驱动话题也不一样。所以一个设计良好的程序包,一定会在配置项里暴露话题名、机械臂型号这些参数,而不是硬编码。

3. 用 ROS 跑通手眼标定:采集数据的完整流程与核心代码

3.1 环境准备和程序包安装

先从环境说起。这套程序包基于 ROS,所以我默认你已经在 Ubuntu 上装好了 ROS Noetic 或 ROS Melodic。如果你还在为装 ROS 发愁,网上有很多一键安装的脚本可以用,搜“鱼香ros一键安装”就能找到自动配置源和安装依赖的脚本,能省掉不少配置软件源和密钥的麻烦。装完 ROS 之后,还需要确认几个核心依赖是否就位:

# 检查 ROS 版本和已安装的包 roscd && echo $ROS_DISTRO # 安装标定包常见的依赖 sudo apt install ros-$ROS_DISTRO-ar-track-alvar ros-$ROS_DISTRO-opencv-apps ros-$ROS_DISTRO-image-proc ros-$ROS_DISTRO-cv-bridge # 如果要用棋盘格标定板,还需要 OpenCV 的标定模块 python3 -c "import cv2; print(cv2.__version__)"

这里我一般会用 apt 安装系统级的 ROS 依赖,而不是用 pip 去装 OpenCV 的 Python 包。原因很简单:ROS 的 cv_bridge 依赖的是系统 OpenCV 版本,如果你用 pip 装了一个新版的 opencv-python,cv_bridge 编译时会因为 ABI 不一致直接报错。这个坑我踩过不止一次,后面避坑章节还会细说。

装好依赖后,把程序包放到工作空间的 src 目录下,用 catkin_make 或 catkin build 编译。编译后记得 source 一下:

cd ~/catkin_ws catkin_make source devel/setup.bash

编译成功后,用 rospack find 确认包已经被识别到。如果找不到包,多半是没 source 或者包的 CMakeLists.txt 有问题。

3.2 标定板准备和检测参数配置

标定板推荐用棋盘格或圆点板。棋盘格的好处是 OpenCV 的 findChessboardCorners 非常成熟,角点检测稳定,对光照变化的容忍度也高。程序包一般会要求你提供标定板图片、格子尺寸和行列数。这里的格子尺寸指的是实际物理尺寸,单位是毫米,不是像素。

我一般会先用打印好的 A4 棋盘格做粗标定,再用玻璃材质的陶瓷棋盘格做精标定。A4 纸在环境湿度变化时会轻微伸缩,虽然听起来影响不大,但在标定精度要求到 0.5 毫米以内时,纸基板的变形就是实打实的误差源。

检测参数一般有两个:一个是 findChessboardCorners 的 flags,另一个是亚像素优化的终止条件。程序包的配置文件里通常会有类似这样的 YAML 片段:

calib_board: pattern: chessboard cols: 9 rows: 6 square_size: 0.025 # 单位:米 detection_flags: 1 # CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE subpixel_win: 11 subpixel_epsilon: 0.01 subpixel_iterations: 30

这几个参数值得解释一下。detection_flags 建议同时开自适应阈值和归一化,棋盘格在光照不均时两种 flag 能显著提高检出率。subpixel_win 是亚像素角点优化的窗口半径,窗口太小容易受噪声影响,太大又可能跨到邻近角点。对 9x6 的棋盘格,11 是一个比较稳妥的大小。

3.3 机械臂位姿和相机图像的话题订阅与同步

接下来是数据采集节点。程序包的工作方式一般是:你控制机械臂运动到一组预定义的姿态,在每个姿态停留几秒,程序包在这段时间里同步采集机械臂位姿和一张或多张图像。这里的同步有个隐藏的坑:机械臂位姿话题和图像话题的时间戳可能不在同一时区,或者说有传输延迟,你需要用 TimeSynchronizer 做时间同步,或者直接取最近邻的两帧。

关键代码如下:

#!/usr/bin/env python3 import rospy import message_filters from sensor_msgs.msg import Image from geometry_msgs.msg import PoseStamped from cv_bridge import CvBridge class HandEyeAcquisition: def __init__(self): self.bridge = CvBridge() self.latest_image = None self.latest_pose = None self.image_topic = rospy.get_param("~image_topic", "/camera/color/image_raw") self.pose_topic = rospy.get_param("~pose_topic", "/jaka_pose") self.image_sub = message_filters.Subscriber(self.image_topic, Image) self.pose_sub = message_filters.Subscriber(self.pose_topic, PoseStamped) # ApproximateTimeSynchronizer 用于近似时间同步 self.sync = message_filters.ApproximateTimeSynchronizer( [self.image_sub, self.pose_sub], queue_size=10, slop=0.05) self.sync.registerCallback(self.callback) def callback(self, image_msg, pose_msg): self.latest_image = self.bridge.imgmsg_to_cv2(image_msg, "bgr8") self.latest_pose = pose_msg.pose rospy.loginfo("Received synchronized image and pose at time %.3f", image_msg.header.stamp.to_sec()) def save_data(self, index): if self.latest_image is None or self.latest_pose is None: rospy.logwarn("No synchronized data yet") return False # 保存图像和位姿到磁盘,后面离线解算 cv2.imwrite(f"capture_{index:03d}.png", self.latest_image) # 把 PoseStamped 存成 npy return True

这段代码里的 ApproximateTimeSynchronizer 的 slop 参数是时间同步容差,单位秒。0.05 表示两路消息的时间戳相差不超过 50 毫秒才认为是同一帧。实际上机械臂控制器的位姿发布频率一般是 100Hz 到 500Hz,相机是 30Hz,50 毫秒的容差基本够用。如果发现同步率太低,可以考虑把 slop 放宽到 0.1,但要注意这也会引入更大的时间不对齐误差。

3.4 自动采集的机械臂运动规划

采集数据最笨的方法是手动用示教器把机械臂掰到各个位置,然后按一下记录按钮。但手掰的位置往往不够多样,姿态变化范围小,标定结果会退化。更好的做法是写一个自动采集脚本,让机械臂走一组预先定义好的球形轨迹或者网格轨迹,保证机械臂末端在空间中的姿态覆盖足够广。

常见的做法是:在机械臂末端可达空间内,以某个中心点为基准,让末端沿着半径 10-20 厘米的球面均匀取点,同时让末端姿态绕工具中心点旋转。这样得到的 A 矩阵集合在旋转空间里分布比较均匀,有利于 AX = XB 求解的稳定性。

# 生成球面采样点,并下发到机械臂驱动 import numpy as np import rospy from geometry_msgs.msg import PoseStamped def generate_sphere_trajectory(center, radius, num_points): # 用斐波那契球面均匀采样,避免极地区域点过于集中 points = [] golden_ratio = (1 + np.sqrt(5)) / 2 for i in range(num_points): theta = np.arccos(1 - 2 * (i + 0.5) / num_points) phi = 2 * np.pi * i / golden_ratio x = center[0] + radius * np.sin(theta) * np.cos(phi) y = center[1] + radius * np.sin(theta) * np.sin(phi) z = center[2] + radius * np.cos(theta) points.append((x, y, z)) return points def send_pose_to_robot(pose_stamped): # 这里按不同机械臂驱动的话题名分别处理 if rospy.has_param("~robot_type") and rospy.get_param("~robot_type") == "jaka": pub = rospy.Publisher("/jaka_pose_cmd", PoseStamped, queue_size=1) elif rospy.get_param("~robot_type") == "aubo": pub = rospy.Publisher("/aubo_robot/cmd_pose", PoseStamped, queue_size=1) # 注意:真实使用时要等待机械臂到位后再触发相机采集 pub.publish(pose_stamped) rospy.sleep(2.0) # 等待机械臂稳定

这段脚本里最关键的是每个点之间的 sleep 时间。机械臂运动到位后会有残余抖动,如果相机在机械臂还没停稳时就拍照,图像会模糊,角点检测精度直接掉下来。2 秒是我常用的等待时间,如果用的是重负载的 JAKA 或者是长臂展的 AUBO,建议增加到 3 秒,代价是采集时间变长,但精度更有保障。

4. 多种算法计算标定结果:离线求解与精度对比

4.1 用 OpenCV 的 calibrateHandEye 快速出结果

数据采集完成后,就到了核心求解环节。程序包里的求解模块通常会读入一组 A(机械臂位姿)和 B(标定板位姿),然后调用 OpenCV 的 calibrateHandEye 求解 X。下面是一段可以直接跑的离线求解脚本,它会输出四种算法的结果以及对应的重投影误差。

import numpy as np import cv2 def load_poses(image_dir, pose_file): # 从保存的 npy 文件中读取机械臂位姿和标定板位姿 eef_poses = np.load(pose_file + "_eef.npy") # 机械臂末端位姿 board_poses = np.load(pose_file + "_board.npy") # 标定板位姿 return eef_poses, board_poses def solve_handeye(): eef_poses, board_poses = load_poses("captures", "data") # 统一转成 4x4 齐次矩阵 R_gripper2base = [p[:3, :3] for p in eef_poses] t_gripper2base = [p[:3, 3] for p in eef_poses] R_target2cam = [p[:3, :3] for p in board_poses] t_target2cam = [p[:3, 3] for p in board_poses] methods = [ (cv2.CALIB_HAND_EYE_TSAI, "Tsai"), (cv2.CALIB_HAND_EYE_PARK, "Park"), (cv2.CALIB_HAND_EYE_HORAUD, "Horaud"), (cv2.CALIB_HAND_EYE_ANDREFF, "Andreff"), (cv2.CALIB_HAND_EYE_DANIILIDIS, "Daniilidis"), ] for method, name in methods: R_cam2base, t_cam2base = cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, method=method) X = np.eye(4) X[:3, :3] = R_cam2base X[:3, 3] = t_cam2base.reshape(3) # 计算残差:A @ X 和 X @ B 应该相等 residual = compute_residual(eef_poses, board_poses, X) print(f"{name:12s} residual: {residual:.6f}") def compute_residual(eef_poses, board_poses, X): errors = [] for A, B in zip(eef_poses, board_poses): lhs = A @ X rhs = X @ B # 平移残差取毫米级 errors.append(np.linalg.norm(lhs[:3, 3] - rhs[:3, 3])) return np.mean(errors)

这里我多做了一个步骤——compute_residual。很多程序包只输出一个变换矩阵,不告诉你这个结果到底准不准。但手眼标定有个天然的验证方式:把 X 代回 AX = XB 方程,看左边和右边的平移差多少。残差小于 5 毫米,说明标定数据自洽;残差大于 10 毫米,说明数据里有野值或者姿态分布不够多样。这个残差是相对值,不是最终绝对精度,但对筛查坏数据非常有效。

4.2 程序包内置的 ROS 服务接口

如果你不想自己写求解脚本,程序包一般会提供一个 ROS service 来完成求解。常见的服务接口格式是:

rosservice call /handeye/calibrate "{}"

调用后返回一个 transformation 数组和一个状态码。这种交互方式的好处是可以在标定现场马上看结果,不用切到终端跑 Python 脚本。程序包还会把结果发布成 TF 广播,这样你就直接能在 RViz 里看到相机坐标系和机械臂基坐标系的关系,视觉上直观地判断这个标定结果是否合理。

服务接口内部做的事情和上面的 Python 脚本是一样的:读数据、调 calibrateHandEye、算残差、发布 TF。不同之处在于它会把多组标定结果存成文件,并且保留每次标定的参数副本,方便你回看历史标定的变化趋势。这点很重要——机械臂长时间运行后,末端负载变化会导致连杆轻微变形,手眼矩阵会有缓慢漂移,定期重标定并对比历史结果,是保证视觉系统长期稳定运行的标准做法。

4.3 不同算法的精度对比和选择建议

我拿同一组 25 个姿态的采集数据做过对比:Tsai 方法的重投影残差在 3 到 8 毫米之间浮动,Park 和 Daniilidis 方法比较稳定,一般在 2 到 4 毫米,Horaud 方法对初始姿态的分布比较敏感,姿态覆盖不够时会出现明显偏大的结果。当然,这个数值受标定板尺寸、相机分辨率和机械臂绝对定位精度影响很大,不同环境下不能直接套用。

选算法的建议是这样的:第一次标定或者快速验证,用 Tsai 先出个大概结果;正式产线上线,用 Daniilidis 或者 Park 的结果,然后用验证集的残差来拍板。如果几种算法的结果差异超过 1 厘米,别急着选一个,先回去检查数据采集的姿态分布,大概率是采集样本里有些姿态太接近,方程退化严重。

5. 手眼标定避坑:ROS 环境下最常见的 5 个翻车现场

5.1 画面正常但角点检测不到:图像 topic 的像素格式不对

现象:订阅图像话题后,用 cv_bridge 转出来的图像是灰度图或者 16 位深度图,OpenCV 的 findChessboardCorners 在 16 位图上完全检测不到角点。

原因:很多 RGBD 相机(比如 RealSense 或 Kinect)的 color 话题默认是 RGB8 格式,但 infrared 或者深度话题转出来的图像格式可能是 CV_16UC1。cv_bridge 在转换时如果不指定目标编码,默认按源图像的编码转换,OpenCV 的角点检测函数不支持非 8 位图。

解决:在 cv_bridge 转换时显式指定目标编码为 "mono8" 或者 "bgr8",并且打印确认图像的 dtype 是 uint8:

cv_image = self.bridge.imgmsg_to_cv2(image_msg, desired_encoding="bgr8") print(cv_image.shape, cv_image.dtype) # 确认是 (720, 1280, 3) uint8

5.2 标定结果重复性差:每次标定 X 矩阵都不一样

现象:同一台设备、同一天内做两次标定,两次结果的平移分量差了 1 厘米以上。

原因:标定数据不满足 AX = XB 的可观性条件。OpenCV 的文档和论文里多次提到,手眼标定需要至少两组旋转轴不平行的姿态。如果你采集的姿态都是机械臂末端绕同一个轴旋转,方程在数学上退化到了不可解的状态。

解决:检查采集的姿态集合,计算所有旋转矩阵的旋转轴向量,确认至少有 3 个不共面的旋转方向。一个简单的做法是在采集时让机械臂末端画"8"字轨迹,保证姿态变化覆盖多个轴向。

5.3 机械臂位姿话题数值不变:JAKA 驱动话题名搞错了

现象:订阅了 /jaka_pose 话题后,发现机械臂明明在动,但话题数值一直不变。

原因:JAKA 的 ROS 驱动有多个版本,有些版本的驱动默认发布的不是末端实际位姿,而是一个静态的初始位姿,实际位姿要通过 action 服务端去查询。还有的版本发布的话题是 /jaka_robot/current_pose,如果你订阅的是旧的 /jaka_pose,自然拿不到数据。

解决:先用 rostopic list 看一下实际话题列表,再用 rostopic echo 验证数据是否随机械臂运动而变化。AUBO 那边也要做同样的验证,不同固件版本的话题名差异很大。

5.4 标定板角点检测出来但位姿跳动:标定板不够平整

现象:同一张图像连续检测 10 次,标定板位姿的平移分量一直在抖动,噪声超过 5 毫米。

原因:如果是 A4 纸打印的标定板贴在纸箱或弯折过的硬纸板上,棋盘格局部不平整,角点的亚像素位置会偏移。特别是边上几个角点,轻微翘起就会导致 solvePnP 解出的姿态出现明显偏差。

解决:把标定板贴在 3 毫米以上的铝板或玻璃板上。实在没有硬质底板的话,采集的时候尽量让标定板处于比较舒展的平面状态,不要悬空或者手扶着。手扶标定板时,手部的微小抖动会直接进到 B 矩阵里。

5.5 cv_bridge 编译报错:Python 的 OpenCV 和系统 OpenCV 冲突

现象:catkin_make 编译程序包时报错,说找不到 cv_bridge 的头文件,或者链接时出现 OpenCV 版本符号冲突。

原因:系统里用 apt 装了 ROS 自带的 OpenCV 4.2,而 Python 环境里用 pip 装了 opencv-python 4.5 以上版本。cv_bridge 在编译时查找的是系统 OpenCV,但 CMake 的 Python 绑定可能找到了 pip 的 OpenCV,两边的头文件版本不一致,符号表冲突。

解决:在编译之前,先确认 Python 里不加载 pip 装的 opencv-python,或者干脆统一换成系统版本:

pip uninstall opencv-python opencv-contrib-python sudo apt install python3-opencv python3 -c "import cv2; print(cv2.getBuildInformation())"

这个坑在 Ubuntu 20.04 + ROS Noetic 环境下出现的频率非常高,基本是每次换新环境都要排查一遍。

6. 标定结果的验证技巧:用 TF 广播和重投影误差做双重确认

拿到手眼矩阵后,最容易犯的错误就是直接用它做抓取,不验证。我现在的习惯是,任何一组手眼标定结果,都要过两道验证关才敢用它去算抓取坐标。

第一道是 TF 可视化验证。把标定结果发布成 TF,然后在 RViz 里加载机械臂模型和相机模型。正常的话,你会看到相机坐标系原点和相机实物模型重合,标定板坐标系和实际标定板的位置重合。如果相机坐标系飘在离相机实物十几厘米开外的地方,那标定结果大概率有问题,不用继续往下走。这一步能过滤掉大约一半的错误标定。

第二道是估算精度验证。找机械臂末端装一个尖针,把针尖对准标定板上的某个角点,记录下此时机械臂末端位姿。然后让相机识别同一个角点,用标定结果把角点的相机坐标转换到机械臂基坐标系,和机械臂实际的针尖位置做差。这个误差是端到端的综合误差,包含了机械臂绝对定位误差、相机内参误差、手眼标定误差,一般做到 2 毫米以内算合格,1 毫米以内算优秀。

还有一个进阶技巧是动态重标定。如果你的机械臂负载经常变,或者相机被撞过、重新拧过固定螺丝,手眼矩阵会变化。程序包如果支持多组标定结果的保存和切换,我会建议把历史标定结果按日期命名归档,每次维护后重新标定一次,并对比新旧 X 矩阵的差异。差异超过 5 毫米,就说明机械臂或相机发生了机械形变,需要检查安装结构。

另外,代码里一定要加一个标定结果质量的自检函数。比如检查 X 矩阵的旋转部分是否满足正交性,行列式是否接近 1,平移部分的模长是否在合理范围内。手眼矩阵的旋转矩阵如果不是正交的,说明求解过程有数值问题,这个结果不能直接用于下游的坐标变换计算。

我个人的做法是:把标定结果的验证脚本直接集成到程序包的 launch 文件里,每次标定完自动跑一遍检查,输出 PASS 或 FAIL。这样不管是自己用还是交给产线同事用,都不会出现拿着一个异常标定结果去算抓取坐标的情况。这套流程走下来,机械臂抓取的坐标偏差基本都能控制在毫米范围内。

希望帮到你。

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

RELATED READING

延伸阅读

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