
做机械臂抓取、视觉引导上料这类项目最绕不开的一步就是手眼标定。OpenCV在这条链路里承担的角色几乎贯穿全程——从相机内参标定、标定板角点提取到机械臂与相机之间的位姿求解一套流程走下来你才能真正把“像素坐标”翻译成机械臂能懂的基座坐标。这篇文章会从坐标变换的原理讲起结合OpenCV的完整实操流程把眼在手上、眼在手外两种常见装法都梳理清楚顺便把我踩过的坑和排查思路一起写出来适合正在用UR、JAKA、Piper这类机械臂做视觉抓取或者刚开始接触OpenCV手眼标定的朋友直接参考。1. 手眼标定到底在解决什么问题1.1 两种装法眼在手上还是眼在手外手眼标定里的“手”指的是机械臂“眼”指的是相机。根据相机的安装位置工程上分成两种场景。第一种是相机固定在机械臂末端法兰上跟着机械臂一起动这叫眼在手上eye-in-hand做上下料、装配、移动抓取时很常见。第二种是相机固定在工作台或者支架上机械臂在相机视野里来回运动这叫眼在手外eye-to-hand适合固定工位视觉引导、上料定位这类需求。这两种装法的标定目标完全不同。眼在手上时我们要找的是相机坐标系和机械臂末端坐标系之间的固定位姿关系因为相机装在法兰上不管机械臂怎么动这个相对关系都不变。眼在手外时相机和机械臂基座都是固定的我们要找的是相机坐标系和机械臂基座坐标系之间的固定关系。很多初学者一上来就把两种场景的代码混着用结果求出来的矩阵看起来合理实际一抓就偏。选哪一种装法取决于现场工艺。如果目标物体位置不固定机械臂需要在移动过程中持续跟踪视觉信息通常选眼在手上因为视觉随动误差小。如果只是在固定工位拍照定位机械臂再过去抓眼在手外更简单因为相机不跟着机械臂抖动标定一次能用很久也不会因为机械臂运动导致图像模糊。没有绝对好坏只有适不适合现场。1.2 标定的本质是一条坐标系变换链不管是哪一种装法本质都是在求解一条坐标系变换链。眼在手上的完整链路是像素坐标通过相机内参转到相机坐标系再通过手眼矩阵转到末端坐标系再通过机械臂的正运动学转到基座坐标系最后才变成机械臂能用的目标坐标。眼在手外的链路相对短一些像素坐标转到相机坐标系后直接通过手眼矩阵转到基座坐标系。OpenCV 能帮我们做的是中间这几段相机内参解决像素到相机坐标系的投影关系solvePnP 解决标定板坐标系到相机坐标系的位姿关系calibrateHandEye 解决相机坐标系到机械臂坐标系的手眼关系。但这里有个关键点容易被忽略手眼标定结果是否准确严重依赖机械臂本身的正运动学精度和示教器读数精度。你标定前如果没确认机械臂的 TCP、工具坐标系、安装法兰有没有偏差后面齐活都是白搭。另外标定板棋盘格上的 3D 点坐标单位要和机械臂一致。我见过有人内参标定时棋盘格边长用的是毫米手眼标定时机械臂读数是米最后解出来的平移向量数值差了好几个数量级抓取偏差大得离谱。这个细节会在第三节实操里再强调。2. OpenCV在标定链路里的三个关键任务2.1 相机内参标定先用棋盘格把镜头“校准”手眼标定的第一步是先把相机内参和畸变系数搞准。这一步用 OpenCV 的calibrateCamera就能完成。你需要打印一张棋盘格标定板最好贴在平整的硬板上从不同角度、不同距离采集 15 到 20 张图像。注意避免反光材质棋盘格如果贴在亚克力板下面反光会导致角点检测不稳定。内参标定的原理本质是把空间中的三维点通过针孔模型投影到图像平面通过多张图像上棋盘格角点的位置优化出焦距、主点坐标和畸变系数。OpenCV 里的流程是先用findChessboardCorners找出棋盘格角点再用cornerSubPix做亚像素细化最后调用calibrateCamera得到内参矩阵和畸变向量。import cv2 import numpy as np import glob CHESS_COLS 9 CHESS_ROWS 6 CHESS_SIZE_MM 25.0 # 棋盘格单格边长单位mm按实际标定板尺寸填 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) objp np.zeros((CHESS_COLS * CHESS_ROWS, 3), np.float32) objp[:, :2] np.mgrid[0:CHESS_COLS, 0:CHESS_ROWS].T.reshape(-1, 2) objp * CHESS_SIZE_MM objpoints [] imgpoints [] for fname in sorted(glob.glob(calib/*.jpg)): img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, (CHESS_COLS, CHESS_ROWS), None) if ret: objpoints.append(objp) corners_sub cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) imgpoints.append(corners_sub) ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None ) np.savez(camera_params.npz, mtxmtx, distdist) print(重投影误差:, ret) print(内参矩阵:\n, mtx) print(畸变系数:\n, dist)calibrateCamera返回的ret是重投影误差单位是像素。实测下来正常的内参标定误差应该在 0.1 到 0.2 像素以内。如果超过 0.3优先检查标定板是否平整、图像是否清晰不要急着往手眼标定阶段走。畸变系数不正常后面所有和图像相关的位姿计算都会跟着出错而且这种误差很难靠手眼标定补偿回来。2.2 位姿求解solvePnP把像素坐标变成相机坐标内参标定完之后每拍一张标定板图像你都可以用solvePnP求出标定板坐标系相对于相机坐标系的旋转和平移。做法是把棋盘格的物理 3D 点z 轴为 0和当前图像上检测到的 2D 角点对应起来并结合内参矩阵和畸变系数求解。这一步输出的旋转向量和平移向量就是手眼标定方程里的 target2cam。旋转向量需要用cv2.Rodrigues转成旋转矩阵平移向量要 reshape 成 3x1 的列向量。很多新手在这里踩坑把旋转矩阵直接当 3x3 用没问题但把旋转向量当旋转矩阵用或者把平移向量当成行向量最后组数据时维度全乱。ret_pnp, rvec, tvec cv2.solvePnP(objp, corners_sub, mtx, dist) R_target2cam cv2.Rodrigues(rvec)[0] # 3x3 t_target2cam tvec.reshape(3, 1) # 3x1这里有个经验如果标定板图像边缘有畸变角点位置可能偏几个像素solvePnP的旋转平移会有可见误差。建议检测角点时把原图先做一次畸变校正再检测或者直接用cornerSubPix做亚像素提取两者结合起来能把误差压到很小。solvePnP默认用的是迭代法对平面标定板的效果已经足够不需要刻意换solvePnPRansac除非你的场景里存在大量遮挡或误匹配。2.3 手眼方程求解calibrateHandEye怎么用OpenCV 4.x 提供了cv2.calibrateHandEye来求解手眼标定矩阵。调用前需要准备两组数据一组是机械臂末端的位姿也就是 gripper2base另一组是标定板相对相机的位姿也就是 target2cam每组都要十几份。R_cam2gripper, t_cam2gripper cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, methodcv2.CALIB_HAND_EYE_TSAI )眼在手上的场景直接传入即可输出R_cam2gripper和t_cam2gripper就是点从相机坐标系变换到机械臂末端坐标系的旋转和平移。眼在手外的场景工程上通常把两组输入都取逆之后再传入输出对应的是点从相机坐标系变换到机械臂基坐标系的旋转和平移。取逆的代码如下R_gripper2base_inv [] t_gripper2base_inv [] for R, t in zip(R_gripper2base, t_gripper2base): Rt R.T R_gripper2base_inv.append(Rt) t_gripper2base_inv.append(-Rt t) R_target2cam_inv [] t_target2cam_inv [] for R, t in zip(R_target2cam, t_target2cam): Rt R.T R_target2cam_inv.append(Rt) t_target2cam_inv.append(-Rt t) R_base2cam, t_base2cam cv2.calibrateHandEye( R_gripper2base_inv, t_gripper2base_inv, R_target2cam_inv, t_target2cam_inv, methodcv2.CALIB_HAND_EYE_TSAI )calibrateHandEye提供了多种求解方法常用的有 Tsai、Park、Horaud。实测下来Tsai 在大多数噪声可控的场景下表现稳定Park 在旋转变化较大时更稳。如果是刚开始做先用 Tsai 跑通流程再换 Park 对比结果两个结果如果差异很大说明输入数据里有问题而不是算法的问题。另外需要注意OpenCV 的 Python 接口要 4.5.4 以上才有calibrateHandEye装版本太老的库会直接报没有这个函数。3. 实操流程从数据采集到AXXB求解3.1 数据采集规范角度、数量、顺序一个都不能少手眼标定最耗时间的不是代码是数据采集。采集质量直接决定标定精度这里我把经验整理成几条硬规范。第一数据量至少 12 到 16 组。不要用三五组数据去跑calibrateHandEye那样求出来的矩阵可能数值上完全正确实际用起来一塌糊涂。第二机械臂的姿态要有明显差异。很多新手在采集时只改变机械臂的位置姿态几乎不变这会导致方程退化。你要让末端分别做俯仰、偏航、滚转方向的旋转姿态变化幅度越大越好但前提是标定板始终在相机视野内且清晰可辨。第三每组数据的图像要清晰不能有运动模糊。手眼标定要的数据不是越多越好而是覆盖越广越好一组模糊的坏数据比缺失一组数据更致命。眼在手上的采集方式是把标定板固定在外部某个稳定位置机械臂带着相机移动到不同角度去拍同一个标定板。眼在手外的采集方式是把标定板固定在机械臂末端机械臂运动让标定板在固定相机视野里出现在不同位置和角度。两种方式的图像和机械臂位姿都要一一对应建议在采集代码里直接按序号保存图片同时把示教器的位姿读数记录到同一个 JSON 或文本文件里。采集前还要确认图像坐标系和机器人坐标系的单位。示教器上显示的位置通常是毫米角度是度或弧度不同品牌不一样。UR 的位姿里旋转部分默认是轴角弧度JAKA 的位姿里旋转部分通常是欧拉角角度制Piper 这类新出的臂也有自己的旋转顺序定义。把这些信息统一转换成旋转矩阵时务必搞清楚旋转顺序和单位否则后面标定结果全错。3.2 完整代码实现内参标定到外参输出这里给出一套可以直接跑通主流程的代码骨架。假设你已经完成了内参标定camera_params.npz中存在内参。现在要做的是读取每组图像和对应的机械臂位姿组装 hand-eye 求解输入。import cv2 import numpy as np import json import glob camera_data np.load(camera_params.npz) mtx camera_data[mtx] dist camera_data[dist] CHESS_COLS 9 CHESS_ROWS 6 CHESS_SIZE_MM 25.0 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) objp np.zeros((CHESS_COLS * CHESS_ROWS, 3), np.float32) objp[:, :2] np.mgrid[0:CHESS_COLS, 0:CHESS_ROWS].T.reshape(-1, 2) objp * CHESS_SIZE_MM R_gripper2base [] t_gripper2base [] R_target2cam [] t_target2cam [] with open(pose_log.json) as f: poses json.load(f) for i, fname in enumerate(sorted(glob.glob(handeye/*.jpg))): img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, (CHESS_COLS, CHESS_ROWS), None) if not ret: print(f{fname} 未检测到棋盘格跳过) continue corners_sub cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) ret_pnp, rvec, tvec cv2.solvePnP(objp, corners_sub, mtx, dist) R_target2cam.append(cv2.Rodrigues(rvec)[0]) t_target2cam.append(tvec.reshape(3, 1)) pose poses[i] # 注意这里的转换函数需要按你的机械臂手册实现 R_gripper2base.append(euler_to_rotation_matrix(pose[3:], orderZYX)) t_gripper2base.append(np.array(pose[:3], dtypenp.float64).reshape(3, 1)) R_cam2gripper, t_cam2gripper cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, methodcv2.CALIB_HAND_EYE_TSAI ) print(R_cam2gripper:\n, R_cam2gripper) print(t_cam2gripper:\n, t_cam2gripper)euler_to_rotation_matrix这个函数要根据机械臂厂商定义的旋转顺序来写。比如常见的 ZYX 顺序角度制输入时如下def euler_to_rotation_matrix(rpy, degreesTrue, orderZYX): rx, ry, rz np.deg2rad(rpy) if degrees else rpy Rx np.array([[1, 0, 0], [0, np.cos(rx), -np.sin(rx)], [0, np.sin(rx), np.cos(rx)]]) Ry np.array([[np.cos(ry), 0, np.sin(ry)], [0, 1, 0], [-np.sin(ry), 0, np.cos(ry)]]) Rz np.array([[np.cos(rz), -np.sin(rz), 0], [np.sin(rz), np.cos(rz), 0], [0, 0, 1]]) if order ZYX: R Rz Ry Rx elif order XYZ: R Rx Ry Rz else: raise ValueError(unsupported order) return R如果你的机械臂 SDK 直接给四元数建议用scipy.spatial.transform.Rotation转成旋转矩阵比自己手工算欧拉角更不容易出错。关于欧拉角转旋转矩阵的公式不同库的乘法和顺序定义可能有细微差异拿不准时用一组已知数值验证一下再继续。3.3 结果验证把标定误差量化出来手眼标定完成后先别急着去抓取。先用数学方式验证一下标定结果是否自洽。眼在手上的场景下理论上每一组数据的baseTgripper * gripperTcam * camTtarget都应该是同一个常数矩阵。你可以在 Python 里算一遍统计这些矩阵之间的差异。def build_homogeneous(R, t): T np.eye(4) T[:3, :3] R T[:3, 3] t.reshape(3) return T err [] C_ref None for i in range(len(R_gripper2base)): A build_homogeneous(R_gripper2base[i], t_gripper2base[i]) X build_homogeneous(R_cam2gripper, t_cam2gripper) B build_homogeneous(R_target2cam[i], t_target2cam[i]) C_est A X B if C_ref is None: C_ref C_est else: err.append(np.linalg.norm(C_est - C_ref)) print(AXB一致性误差:, np.mean(err))这个误差只能反映手眼标定结果在自身上是否自洽不能完全代表实际抓取精度。更可靠的验证方法是实拍验证在机械臂工作空间里放一个固定尖点或工件相机识别出它的坐标后用手眼矩阵转换到机械臂基座坐标系然后控制机械臂末端移动到该点看看实际偏差。我个人的习惯是先用手动移动模式慢速对准一次记录偏差。如果平移误差在 2 到 3 毫米以内可以继续微调如果差到几厘米先不要反复重新标定而是回头检查数据单位、旋转顺序、TCP 设置这几个源头。4. 常见问题与排查技巧实录4.1 为什么标定结果看起来精度很高抓取却偏得离谱这是所有人都会遇到的最诡异问题。标定过程中所有数值都正常内部一致性误差也很小但实际抓取就是偏。根据我的排查经验最常见的原因是示教器读数和代码里用的坐标系定义不一致。UR 的位姿是法兰坐标系在基座坐标系下的位姿JAKA 的位姿默认可能带了工具坐标系的补偿如果代码里没注意这些区别手眼矩阵就会整体偏差。第二个常见原因是欧拉角转旋转矩阵的顺序和实际不符。同样的(rx, ry, rz)ZYX 顺序和 XYZ 顺序算出来的矩阵完全不同。你从示教器抄了一个姿态角度不清楚内部旋转顺序随便用了一个常见公式结果就是标定程序跑得顺顺当当实际用起来全废。第三个常见原因是机械臂的 TCP 或工具坐标系设置有误。手眼标定要求你读到的末端位姿必须是真实的坐标系位姿如果机器人控制器的工具参数没校准读数本身就偏手眼矩阵自然跟着偏。这个问题在换过法兰、装过夹爪后尤其容易犯千万不要以为重新跑一遍标定就能补偿一切机械偏差。第四个原因是图像畸变校正和实际使用环境不一致。比如标定时用的是广角镜头畸变很大内参标定做好了但实际抓取时换了镜头焦距或者加了滤镜内参失效视觉坐标整体漂移。遇到这种情况不是手眼标定的问题是相机内外参已经变化需要重新标定整个链路。4.2 示教器位姿的旋转顺序和单位是一个大坑我在项目里被这个坑耽误过整整一天。那一版手眼标定程序跑完矩阵看着很正常内部一致性误差也很小但实际验证时机械臂在 X 方向偏了 5 毫米Y 方向偏了 8 毫米而且随着机械臂姿态变化偏差方向还在变。后来我把示教器里的姿态读数和代码里转出来的旋转矩阵拆开逐个对比才发现是角度制和弧度制混用了。具体来说UR 示教器上显示的位姿旋转部分是轴角单位是弧度而且它的轴角表达方式是标准化后的旋转向量。JAKA 示教器显示的是欧拉角默认角度制但具体旋转顺序需要查手册。Piper 这类比较新的臂各家 SDK 有不同的实现有的直接给四元数。这些信息代码里必须先统一成旋转矩阵才能送进手眼标定。我建议在代码里加一个自检步骤读取一组示教器位姿后手动用机器人的正运动学算一遍末端在基座下的坐标和示教器显示的位置对比一下。如果一致说明单位没问题如果不一致多半是单位或旋转顺序写错了。这个自检看起来多此一举但能帮你省下后面大量的排查时间。4.3 手眼标定精度差的排查路径如果你的手眼标定结果反复不如预期我建议按下面这张表逐项排查而不是盲目重新采集数据。排查项典型表现处理方式相机内参不准重投影误差大于0.3像素重拍内参标定板控制在0.2以内棋盘格不平或反光角点检测抖动位姿跳变换磨砂材质标定板贴在硬质平板上机械臂姿态变化太小AXB一致性误差小但不准增加大幅旋转和不同高度至少16组位姿单位/旋转顺序错误验证抓取偏差随姿态变化核对示教器手册统一转旋转矩阵TCP/工具坐标系错误读取位姿与实际末端偏差大先做工具坐标系标定数据分组没对齐图像和位姿错位保存时按序号同步记录交叉核对相机松动或镜头变焦标定结果突然变化重新锁紧固定相机重标内参和手眼我自己最常犯的是数据没对齐。用 20 组数据跑完总觉得哪里不对后来发现中间有 3 组图片检测棋盘格失败被跳过了但位姿列表没有同步跳过导致后续所有数据错位。后来我改成在采集阶段就保证每一张图和每一组位姿必须成对少一组就直接中止采集而不是事后再去筛选。另外旋转矩阵和平移向量在保存时建议连同当时的内参、畸变、机械臂型号、采集日期一起存成 JSON 或 npz。手眼标定这种工作往往隔几天就要重做一次有完整的记录能帮你快速对比不同批次结果的差异也方便出问题时回溯。最后再分享一个实操技巧标定完成后保存结果前把R_cam2gripper和t_cam2gripper打印出来用当前某一组数据反推一遍相机识别到的某个角点坐标投影到图像上看看重投影误差。如果重投影误差在几个像素以内说明整个链路的数值闭环是正常的可以放心进入抓取测试如果重投影误差很大哪怕 AXB 一致性误差再小也说明某个环节的数据定义有问题先回去查清楚再谈精度。