简介:面向机器人视觉与自动化控制开发者的手眼标定代码包,基于 Python 与 OpenCV 实现九点定位法,用于确定相机与机械臂坐标系的对应关系,是视觉引导抓取、自动化装配等场景的核心环节。资源共5个文件,压缩包仅12KB,包含 Python 脚本、运行依赖说明、可视化标定结果图及 HTML/inscode 说明文档,结构精简,便于快速学习与二次修改。代码演示了采集九个圆心像素坐标与机械臂坐标集,并调用 estimateAffine2D 计算仿射变换矩阵的完整流程,同时涵盖坐标集获取、矩阵求解及运行环境配置等关键步骤。整套代码注释清晰、流程完整,无需复杂依赖即可在常见 Python 环境中运行,可帮助读者理解手眼标定原理并迁移到实际项目。已有155人学习下载,适合希望掌握视觉与机械臂联合标定的工程师参考,具有较高实用价值。
1. 项目概述:这套 Python + OpenCV 手眼标定代码能解决什么问题
手眼标定这个词,接触机器人视觉的朋友应该都不陌生。前段时间我在做一个机械臂抓取项目,相机装在机械臂末端,典型的“眼在手上”(eye-in-hand)结构。目标很直接:机械臂要根据相机画面里识别到的物体位置,自己去抓。但相机看到的坐标是相机坐标系下的,机械臂运动需要的是基座坐标系下的坐标,中间这段换算关系怎么来?靠的就是手眼标定。
这套基于 Python + OpenCV 的手眼标定方案,核心就是把“相机坐标系到机械臂末端坐标系的固定变换矩阵”算出来。整个流程包含棋盘格角点检测、solvePnP 求解标定板位姿、机械臂位姿读取、calibrateHandEye 手眼标定求解、结果验证这几个步骤。代码可以直接运行,只要你手里有一台机械臂和一个打印出来的棋盘格,按步骤操作就能得到结果。没有真实机械臂的读者,我也在文末补充了用模拟数据验证算法正确性的方法,方便先把整套逻辑跑通。
这篇内容适合三类人:刚入门机器人视觉、被各种坐标系绕晕的新手;已经在做视觉引导抓取、但标定结果始终不理想的老手;以及想快速给项目加视觉定位功能、需要一份能抄作业代码的工程师。我会把坐标系推导、数据采集、代码实现和踩坑经验都讲清楚,尽量让每个人都能复现。
1.1 手眼标定在机器人视觉里的位置
在机器人抓取、分拣、装配这类任务里,流程通常是“相机看到目标 → 计算目标在相机坐标系下的位置 → 转换到机器人基座坐标系 → 机械臂规划轨迹去抓”。如果这个坐标转换关系没搞对,相机测出来的点哪怕再准,机械臂也会抓偏。
手眼标定就是专门解决这个坐标转换问题的。它要算的是一个固定的 4×4 齐次变换矩阵,表示相机坐标系相对于机械臂末端坐标系的位姿关系。因为相机固定在末端法兰上,所以这个关系是固定的,只要标定一次,后续每次视觉检测后都能把目标点从相机坐标换算到机器人坐标。
很多人觉得手眼标定难,其实难点不在代码,而在坐标系理解和数据采集。OpenCV 已经把核心求解封装成了 calibrateHandEye 函数,参数填对、数据采好,结果基本就靠谱。我见过不少项目标定结果一直发散的,一查基本都是坐标系方向搞反了或者机械臂姿态变化太少,而不是算法本身的问题。
1.2 eye-in-hand 与 eye-to-hand 先分清
手眼标定按相机安装方式分两种,动手之前必须先确认自己是哪一种。eye-in-hand(眼在手上):相机固定在机械臂末端,随机械臂一起运动。标定的目标是求相机坐标系与末端坐标系之间的固定变换 X。这是本文重点讲的场景。eye-to-hand(眼在手外):相机固定在支架上,不随机械臂运动。标定的目标是求相机坐标系与机器人基座坐标系之间的固定变换 X。
两种场景的数学形式都是 AX=XB,但输入的位姿数据含义不同。眼在手上时,输入的是“末端相对基座”和“标定板相对相机”;眼在手外时,输入的是“末端相对基座”和“相机相对标定板”,推导更绕一些。你如果做的是 eye-to-hand,先把本文原理看懂,再对照 OpenCV 文档调整输入即可。我建议新手优先从 eye-in-hand 入手,因为直观、数据好采集,也最容易验证。
2. 标定原理:坐标系链条与 AX=XB 是怎么来的
代码写起来很简单,但如果不理解 AX=XB 的来龙去脉,遇到问题根本不知道怎么排查。这一节我把坐标系和数学推导讲透,看完你就明白 calibrateHandEye 到底在干什么。
2.1 四个坐标系的变换链
要理清手眼标定,必须先把四个坐标系放在一起看。base(基座坐标系):机器人底座的固定坐标系,所有视觉引导最终都要换算到这个坐标系下才能驱动机械臂。gripper(末端坐标系):机械臂末端法兰或工具坐标系,机器人 SDK 返回的位姿数据通常就是这个。cam(相机坐标系):图像坐标最终换算到三维空间后的坐标系,相机识别目标得到的就是这个坐标系下的坐标。target(标定板坐标系):固定在空间中不动的棋盘格或 ArUco 标定板坐标系,是整条变换链的“锚点”。
在 eye-in-hand 场景中,gripper 与 cam 相对固定,target 在标定过程中固定不动,base 当然也固定。于是每采集一次数据,就存在一条完整的变换链:
base → gripper → cam → target
用齐次矩阵表示就是:T_base_to_gripper × T_gripper_to_cam × T_cam_to_target = T_base_to_target。因为 target 和 base 都固定不动,所以等号右边是常量。这就是手眼标定能成立的根本前提。
2.2 为什么手眼标定最后变成 AX=XB
既然 T_base_to_target 是常量,那我们取两个不同机械臂姿态 i 和 j,把两个等式放在一起:
T_base_to_gripper_i × X × T_cam_to_target_i = T_base_to_gripper_j × X × T_cam_to_target_j
其中 X 就是 T_gripper_to_cam,也就是要求的固定手眼矩阵。两边整理一下,就能化成 AX=XB 的标准形式。解这个方程,X 就是相机到末端的位姿变换。
OpenCV 的 calibrateHandEye 函数做的事情就是这个。你只需要提供两个列表:机械臂末端相对基座的旋转平移(R_gripper2base, t_gripper2base),以及标定板相对相机的旋转平移(R_target2cam, t_target2cam)。前者的来源是机器人 SDK,后者的来源是 solvePnP 对棋盘格角点的位姿估计。注意,这里名字里的 target2cam 是标定板坐标系到相机坐标系的变换,也就是 T_cam_to_target,千万别被命名绕晕。
2.3 calibrateHandEye 的求解算法怎么选
OpenCV 从 4.1 版本开始提供 calibrateHandEye 函数,一共有 5 种求解方法可选,参数通过 method 指定。我用实际项目对比过,直接给结论:
| 方法 | 原理特点 | 适用建议 |
|---|---|---|
| CALIB_HAND_EYE_TSAI | 经典 Tsai-Lenz 法,先求旋转再求平移 | 机械臂位姿精度高时可用,噪声大时稳定性一般 |
| CALIB_HAND_EYE_PARK | 基于旋转矩阵 SVD,整体稳定性好 | 工程首选,我项目里默认用这个 |
| CALIB_HAND_EYE_HORAUD | 与 PARK 类似,数学形式略有差异 | 可作 PARK 的交叉验证 |
| CALIB_HAND_EYE_ANDREFF | 对初值敏感,数据质量差时容易发散 | 不推荐新手使用 |
| CALIB_HAND_EYE_DANIILIDIS | 对偶四元数法,旋转平移联合求解 | 精度上限高,但要求数据多样性足够 |
我的建议是:一般项目直接 CALIB_HAND_EYE_PARK,精度要求高的场景再用 CALIB_HAND_EYE_DANIILIDIS 做一次交叉验证,两种方法结果如果差异很大,说明数据采集有问题,而不是算法问题。
3. 标定前准备:数据采集决定标定成败
代码写得再漂亮,数据采得烂,标定结果一样是废的。这一节全是实操经验,照着做能少踩很多坑。
3.1 标定板与相机内参的准备
标定板的选择很自由,棋盘格、ArUco、Charuco 都可以。棋盘格最经典,OpenCV 自带 findChessboardCorners 检测函数,代码简单,适合入门。缺点是棋盘格一旦被遮挡一部分或者光照不均匀,检测就会失败;ArUco/Charuco 鲁棒性更好,单个 Marker 丢了也能用其他 Marker 补位,实际项目里更推荐。但注意 ArUco 模块在 opencv-contrib-python 里,后面我会细说。
不管用哪种标定板,手眼标定之前都必须先完成相机内参标定。因为 solvePnP 需要相机内参矩阵 mtx 和畸变系数 dist 才能准确估计标定板的三维位姿。内参标定办法很多,最简单的是用 OpenCV 自带的 calibrateCamera,拿棋盘格在不同角度、不同距离拍 20 张以上,跑一遍流程就能得到内参。内参不准,手眼标定结果也不会准,这一步不能省。
3.2 机械臂位姿的数据怎么取
机械臂位姿的获取完全取决于你用的什么品牌。常见的几种情况:
有些 SDK 直接返回 base 坐标系下的 x、y、z、rx、ry、rz 欧拉角,或者四元数,需要自己转换成 4×4 齐次矩阵。有些 SDK 提供的是 T_base_to_gripper,也就是末端相对基座的变换,这正好对应 calibrateHandEye 需要的 R_gripper2base。还有的 SDK 接口命名混乱,返回的其实是 T_gripper_to_base 或者把欧拉角约定搞反了,旋转顺序不一样,直接转换就会出错。
我的做法是写一个统一封装函数,输入 SDK 返回的原始姿态数据,内部统一转换成 4×4 齐次矩阵的旋转矩阵和平移向量。这样后面传到 calibrateHandEye 的格式永远一致,排查问题也好定位。如果你拿不准 SDK 返回的坐标系方向,可以让机械臂绕末端坐标系的某个轴旋转 10 度,观察相机在机器人坐标下的运动方向与 SDK 输出是否一致,这个验证方法非常管用。
3.3 采集多少组数据、怎么摆姿态
数据数量上,10~20 组就够。标定结果不好时,先不要急着增加数量,先检查姿态多样性。手眼标定最忌讳的是机械臂在同一个位置附近微调几下就拍一组,姿态差异太小,旋转信息不足,AX=XB 方程组退化,结果必然发散。
实际操作时,我习惯这样控制机械臂运动:固定标定板,让机械臂带着相机在标定板前方范围内运动。每组数据之间故意让末端姿态有明显变化,比如绕 x 轴转 30 度、绕 y 轴转 40 度,再换一个位置继续。整个采集过程尽量覆盖不同角度、不同距离,但保证标定板始终完整出现在画面里,并且占画面面积不要太小,至少 1/3 以上。
还有一个很容易忽略的细节:每次记录机械臂位姿前,一定要等机械臂完全停稳。机械臂刹车在停稳前会有微小抖动,臂展长的六轴机械臂这个抖动可能达到几毫米,直接拍进去就是标定误差。我用的是软触发方式,先发指令让机械臂运动到位,延时 200~300ms 等它完全稳定,再同时采集图像和读取位姿。如果图像和机械臂位姿是异步采集的,时间戳对不上,标定结果也会很怪。
4. 完整代码实现:从棋盘格检测到输出手眼矩阵
代码是重头戏。我把整套流程拆成四步,每一步都给你可以直接用的 Python 代码。这里的核心是 OpenCV 的 calibrateHandEye 函数调用,完整流程跑完会输出一个 4×4 的齐次矩阵,这就是手眼矩阵。
4.1 环境依赖与安装避坑
环境要求其实很低:Python 3.8 以上,opencv-python、numpy。装好就能跑。安装命令很简单:
pip install opencv-python numpy如果要用 ArUco/Charuco 标定板,这里有个大坑:cv2.aruco 模块不在普通 opencv-python 包里,而是在 opencv-contrib-python 包里。装完 opencv-python 后调用 cv2.aruco.detectMarkers 会报 AttributeError。解决办法是先卸载再装 contrib 版:
pip uninstall opencv-python pip install opencv-contrib-python这两个包不能共存,装了 contrib 版之后 cv2.findChessboardCorners、calibrateHandEye 也都有,不影响棋盘格流程。用 conda 管理环境的话,conda install opencv 也自带部分 contrib 功能,但版本可能偏旧,建议还是用 pip 指定版本。
4.2 图像侧:棋盘格角点检测与 solvePnP
这一步骤的目标是算出每组数据里“标定板坐标系到相机坐标系”的旋转和平移。先用 findChessboardCorners 检测棋盘格内角点,再用 solvePnP 求解位姿。代码模板如下:
import cv2 import numpy as np import glob # 相机内参,先单独标定好再填进来 mtx = np.array([[1400.0, 0.0, 960.0], [0.0, 1400.0, 540.0], [0.0, 0.0, 1.0]]) dist = np.zeros((1, 5)) # 棋盘格参数:内角点数和格子边长 pattern = (9, 6) square_size = 25.0 # 单位 mm,按实际打印尺寸填 R_target2cam_list = [] t_target2cam_list = [] images = sorted(glob.glob("data/*.jpg")) for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, pattern, None) if not ret: print("检测失败,跳过:", fname) continue # 亚像素精化 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners = cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) # 棋盘格的3D点坐标 objp = np.zeros((pattern[0] * pattern[1], 3), np.float32) objp[:, :2] = np.mgrid[0:pattern[0], 0:pattern[1]].T.reshape(-1, 2) * square_size # 求标定板到相机的位姿 ret_pnp, rvec, tvec = cv2.solvePnP(objp, corners, mtx, dist) if not ret_pnp: continue R_target2cam, _ = cv2.Rodrigues(rvec) t_target2cam = tvec.reshape(3, 1) R_target2cam_list.append(R_target2cam) t_target2cam_list.append(t_target2cam) print("有效图像数量:", len(R_target2cam_list))这里有个很关键的细节:solvePnP 返回的 rvec/tvec 是“标定板坐标系到相机坐标系”的变换,对应 OpenCV 里 calibrateHandEye 的 R_target2cam 命名。如果你习惯把 rvec 理解为“相机在标定板坐标系下的姿态”,那需要取逆再传,否则结果会非常奇怪。我见过无数人踩这个坑。
4.3 机械臂侧:位姿矩阵的封装
机械臂位姿采集因品牌而异,我写成示例函数 get_robot_pose(),实际使用时替换成你自己的 SDK 调用即可。关键是保证返回的两个值分别是末端相对基座的旋转矩阵 3×3 和平移向量 3×1。
def get_robot_pose(): """从机械臂 SDK 读取末端相对基座的位姿。 这里用随机数模拟,实际替换为: pos, rot = robot.get_end_pose() # 不同 SDK 接口不同 R, t = convert_to_matrix(pos, rot) """ # 示例:随机生成一个合法的旋转矩阵和平移 rvec = np.random.randn(3, 1) * 0.5 R, _ = cv2.Rodrigues(rvec) t = np.random.randn(3, 1) * 100 return R, t实际项目中,我建议把 SDK 返回的欧拉角、四元数、或者已经组合好的 4×4 矩阵统一封装到一个 convert_to_matrix 函数里。重点提醒:如果你的 SDK 返回的是 base 相对 gripper 的变换(即 T_gripper_to_base),而不是 gripper 相对 base 的变换(T_base_to_gripper),必须先取逆再传给 calibrateHandEye。取逆不是简单的转置,需要调用 np.linalg.inv 对 4×4 齐次矩阵求逆。
4.4 调用 calibrateHandEye 输出并保存结果
所有数据采集完毕后,调用 calibrateHandEye 一行求解:
# 机械臂位姿列表 R_gripper2base_list = [] t_gripper2base_list = [] for _ in range(len(R_target2cam_list)): R, t = get_robot_pose() R_gripper2base_list.append(R) t_gripper2base_list.append(t) # 手眼标定核心 R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( R_gripper2base_list, t_gripper2base_list, R_target2cam_list, t_target2cam_list, method=cv2.CALIB_HAND_EYE_PARK, ) # 组合成4x4齐次矩阵并保存 T_gripper2cam = np.eye(4) T_gripper2cam[:3, :3] = R_cam2gripper T_gripper2cam[:3, 3] = t_cam2gripper.flatten() np.save("handeye_matrix.npy", T_gripper2cam) print("手眼矩阵 T_gripper2cam:\n", T_gripper2cam)实际项目中,R_gripper2base_list 应该在拍摄图像的同时从机械臂 SDK 读取并保存,而不是像我示例代码里这样在循环里重新采集。正确流程是:机械臂运动到位 → 等待稳定 → 同时保存图像和位姿 → 进入下一组。我把图像采集和位姿采集分开写,只是为了让你看清两边分别要准备什么数据。
拿到手眼矩阵后,视觉引导的计算公式就顺理成章了:
# T_cam2obj 是目标物在相机坐标系下的位姿,由视觉识别得到 T_base2gripper = get_robot_pose_matrix() # base -> gripper,机械臂当前位姿 T_base2obj = T_base2gripper @ T_gripper2cam @ T_cam2obj目标在基座坐标系下的位姿,就是这条变换链一路乘下来。矩阵顺序千万别写反,4×4 齐次矩阵的乘法不满足交换律,写反结果完全不对。
5. 结果验证与踩坑排查实录
标定完成不等于工作完成。这一节分享我在真实项目里遇到过的典型问题和排查方法,每一条都是真金白银换来的经验。
5.1 坐标系方向反了,结果会怎么奇怪
手眼标定最大的坑就是坐标系约定搞反。标定出的结果如果旋转矩阵接近单位阵、平移向量却出现几万毫米的异常值,或者重投影误差高得离谱,多半就是坐标系方向弄反了。
最容易出问题的点在机械臂位姿。前面说过,calibrateHandEye 需要的是 gripper→base,也就是机械臂 SDK 返回的末端在基座坐标系下的姿态。但有些 SDK 的命名和返回方向不一样,我遇到过返回的其实是 base→gripper 但接口文档写得含糊的情况。排查办法很直接:控制机械臂让末端绕基座坐标系 x 轴正方向旋转 10 度,观察相机在画面里的运动方向,再对照 SDK 输出的数值变化,方向对不上就赶紧修正。
另一种情况是 solvePnP 传参方向反了。有资料把 solvePnP 返回的 rvec/tvec 解释为 cam→target,然后习惯性取了逆,这不会报错,但结果始终不对。我用验证脚本检查过,一旦取反,最终标定出的平移向量会差出好几倍。我的建议是:在采集数据时,每次 solvePnP 后把检测到的标定板坐标系原点投影回图像,画出来确认是在棋盘格角点位置,方向问题就能一眼看穿。
5.2 姿态多样性不足,误差偏大怎么办
如果你采集了 20 组数据,标定结果依然不稳定,拿不同算法交叉验证结果差很远,先别怀疑代码,大概率是姿态多样性不够。
我调过的一个项目就是这样:技术员用示教器手动操作机械臂,为了图省事,每组数据就在同一个位置附近前后左右平移 10 毫米,拍了 30 张。手眼标定结果旋转矩阵某几个分量一直在跳。后来我改成预设 12 个目标点,强制让机械臂每到一个点都变换末端姿态,绕不同轴旋转 20~40 度,结果一下子就收敛了。
一个快速判断数据多样性的方法:把每个姿态下的旋转轴方向画出来,如果这些轴方向在空间里覆盖范围很窄,基本都在同一个平面附近,说明旋转信息不足,需要重新采集。条件允许的话,让机械臂在标定板前形成一个半球状的视野覆盖,效果最好。
5.3 标定完成后如何做精度验证
标定完成后一定要验证,验证方法至少做两种,不要只看一个指标。第一种是重投影验证:用标定出的手眼矩阵,把棋盘格角点反投影回图像,计算像素误差。正常来说,平均重投影误差在 1~2 个像素以内才算合格。如果误差达到 5 个像素以上,数据或内参必然有问题。
第二种是实际轴对准测试:在机械臂末端装一根尖针,视觉系统识别工作台上一个固定尖点,通过标定出的手眼矩阵换算到基座坐标,让机械臂带尖针移动到该点,测量实际位置偏差。毫米级内算正常,我见过标定良好的系统能做到 1~2mm 的重复定位误差。如果你的机械臂本身绝对精度就一般,不要强求标定结果达到理想值,手眼标定只会传递误差,不会消除机器人本身的精度上限。
5.4 我常用的一套标定检查清单
最后整理一份我在每次手眼标定项目里都会走一遍的清单,帮你把容易出问题的环节都堵住。标定板打印是否平整、棋盘格尺寸是否填对;机械臂基座固定不动,标定板在整个过程中也不能动;每个采集点机械臂停稳后再读数,图像和位姿时间戳对齐;姿态差异足够大,旋转轴方向尽量分散;solvePnP 和机械臂位姿的坐标系方向都验证过;求出的旋转矩阵满足 R^T R ≈ I,det(R) ≈ +1;用 PARK 和 DANIILIDIS 两种方法交叉验证,结果一致性良好;手眼矩阵保存后,用独立的一组新数据做重投影验证,误差达标。
这套流程跑完,标定结果基本就能放心用了。我在实际项目里体会最深的一点是:手眼标定的代码量真的不大,关键的功夫全在数据和细节上。机械臂停稳再采样是一个好习惯,坐标系方向先验证再标定是铁律。你把这套 Python + OpenCV 流程完整走一遍,踩过的坑越多,后面再上手其他视觉引导项目就越稳。
本文还有配套的精品资源,点击获取