ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

机械臂手眼标定实战:从相机坐标到基座坐标的完整流程与避坑指南

机械臂手眼标定实战:从相机坐标到基座坐标的完整流程与避坑指南

简介:面向机器人视觉与自动化控制开发者的Python-OpenCV手眼标定代码包,采用九点定位法实现相机像素坐标到机械臂坐标的仿射变换,可用于自动化生产线、质量检测、三维重建等视觉引导场景,帮助工程师快速打通视觉系统与机械臂控制之间的坐标转换链路。压缩包共5个文件,涵盖Python核心脚本、文本说明、HTML文档、可视化图片及辅助配置,整体仅12KB,结构紧凑、内容精炼,便于直接阅读和复用。已有158人学习该资源,适合具备一定OpenCV基础并希望上手实测的视觉工程师或机器人方向学生。示例代码完整演示了九个圆心像素坐标与机械臂坐标集的采集方式,以及基于estimateAffine2D计算仿射矩阵的关键步骤;随附文档还交代了运行环境配置、依赖安装顺序与常见问题排查思路,可有效降低手眼标定的调试门槛,使读者能将这套方法快速迁移到自己的项目中。

1. 手眼标定解决什么问题:机械臂抓不准,先别急着调 PID

你兴致勃勃地装好相机,让机械臂去抓一个固定位置的螺丝,结果每次抓取都偏出去三毫米。手动补偿一两次后换个角度又偏了,这时候问题基本不在机械臂重复精度,而在于“相机看到的坐标”和“机械臂运动的坐标”根本不在同一个坐标系里。手眼标定做的就是这件事:算出相机坐标系和机械臂基座坐标系之间的旋转和平移关系,把相机里的像素位置换算成机械臂能听懂的三维坐标。这篇文章用 Python + OpenCV 完整走一遍手眼标定流程,从相机内参标定、标定板位姿求解,到 calibrateHandEye 的调用和踩坑排查,适合正在做视觉抓取、机器人引导或双目定位的从业者。

2. 手眼标定的数学本质:坐标系变换与 AX=XB 问题的来龙去脉

2.1 两种安装方式决定标定公式:Eye-in-Hand 与 Eye-to-Hand 怎么选

机械臂视觉系统里,相机和机械臂的相对关系只有两种。第一种是相机固定在机械臂末端,跟着机械臂一起动,叫 Eye-in-Hand,也就是“眼在手上”。第二种是相机固定在工作空间外部,机械臂在相机视野里运动,叫 Eye-to-Hand,也就是“眼在手外”。这两种安装方式下,要标定的变换矩阵不一样:Eye-in-Hand 求的是相机坐标系到机械臂末端坐标系的变换,记为 end_T_cam;Eye-to-Hand 求的是相机坐标系到机械臂基座坐标系的变换,记为 base_T_cam。

选哪种安装方式取决于实际场景。Eye-in-Hand 的好处是相机离目标近,能看细节,而且机械臂换个站位就能避开遮挡,但它每次移动后相机视野会变,需要实时知道相机在基座里的位置。Eye-to-Hand 的好处是相机固定不动,视野稳定,适合大范围定位,但容易因为机械臂自身遮挡目标。对于初学或小批量验证,Eye-in-Hand 更常见,因为数据采集时标定板可以固定在桌上不动,只要机械臂带着相机走不同位姿就行。

从公式角度看,两种安装方式的标准方程都是 AX=XB,只是 A、B、X 的具体含义不同。理解这一点很重要,因为网上很多代码把两种方式混在一起写,直接套用经常会算出看起来合理但实际错误的结果。

2.2 用齐次变换写出约束关系:为什么 AX=XB 而不是直接测距离

先引入齐次变换矩阵的概念。一个 4×4 的齐次矩阵 T 包含了旋转矩阵 R(3×3)和平移向量 t(3×1),表达的是“把一个坐标系里的点变换到另一个坐标系里”。比如 base_T_end 表示把末端坐标系里的点变换到基座坐标系,也就是机械臂控制器通常直接给的那组位姿数据。

在 Eye-in-Hand 场景下,标定板固定在外部,相机固定在末端。对任意一个拍照位姿 i,从机器人控制器读到 base_T_end_i,从图像计算得到 cam_T_target_i,这两个变换之间夹着我们要求的 end_T_cam,而且三者乘起来等于一个常量——标定板在基座坐标系下的位姿 base_T_target:

base_T_target = base_T_end_i · end_T_cam · cam_T_target_i

对两个不同的位姿 i 和 j,这个常量相等,于是消去 base_T_target 后得到:

base_T_end_j⁻¹ · base_T_end_i · end_T_cam = end_T_cam · cam_T_target_j · cam_T_target_i⁻¹

这就是 AX=XB 的标准形式。左边 A 由机器人位姿相对运动构成,右边 B 由相机观测到的标定板相对运动构成,X 就是要求的 end_T_cam。OpenCV 的 cv2.calibrateHandEye 内部就是按这个思路,通过多组数据联合估计 X。

所以手眼标定不能靠拿尺子量相机装在哪,因为相机光学中心和机械臂法兰盘中心都不是可以直接量到的物理点。AX=XB 的好处是只需要相对运动关系,不需要知道任何绝对尺寸,这也就解释了为什么同一个公式既能用于 Eye-in-Hand,也能通过角色互换用于 Eye-to-Hand。

2.3 标定需要哪些已知量:机器人位姿、标定板位姿和相机内参的分工

一次完整的手眼标定需要三类数据配合。第一类是相机内参,包括焦距、主点坐标和畸变系数,内参不准直接影响标定板位姿的精度。第二类是标定板相对于相机的位姿,靠相机拍标定板图像、检测角点后用 solvePnP 求解,这一步依赖内参。第三类是机械臂末端相对于基座的位姿,直接从机器人控制器读取。

这三类数据缺一不可,而且必须保证它们描述的是同一时刻的状态。手眼标定本质上是把机器人运动学和视觉几何联立起来解方程,任何一方的数据有系统性偏差,都会直接折进 X 里。很多人标定结果不稳定,回过头排查,往往就是这三个数据源里某一个出了问题,而不是算法本身的问题。

另外要注意单位统一。机械臂控制器输出的平移量通常是毫米,而棋盘格角点的三维坐标通常用米或者毫米定义,solvePnP 的输入和输出单位必须一致,否则 t 向量会差一千倍,手眼矩阵算出来自然是错的。

3. 相机内参标定与标定板位姿求解:手眼标定的两个数据源头

3.1 先标相机内参:畸变不修,手眼矩阵就是玄学

手眼标定的第二步依赖相机拍摄标定板并求解位姿,如果相机内参不对,solvePnP 求出来的旋转和平移会带着系统性误差,后面 AX=XB 解出来的 X 也会跟着错。所以我的习惯是先单独完成相机内参标定,把重投影误差压到 0.1 像素以下再开始采集手眼数据。

内参标定用棋盘格是最稳的。打印一张 9×6 内角点的棋盘格,贴在平整硬板上,用相机从不同角度拍 15~20 张照片。OpenCV 里的 findChessboardCorners 加 calibrateCamera 一套走完,核心代码如下:

import cv2 import numpy as np import glob CHECKERBOARD = (9, 6) # 内角点数:9列 x 6行 SQUARE_SIZE = 0.025 # 每个格子的物理边长,单位:米 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) # 构造棋盘格的物理三维点(标定板坐标系下,z=0) objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objp *= SQUARE_SIZE obj_points = [] # 所有图像的三维点 img_points = [] # 所有图像的二维角点 # 遍历标定图片 for fname in sorted(glob.glob("calib_images/*.jpg")): img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 检测棋盘格内角点 ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, None) if not ret: print(f"{fname} 检测失败,跳过") continue # 亚像素精化,把角点定位精度提到 0.1 像素级别 corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners2) # 相机标定 ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) # 保存内参和畸变系数,后续手眼标定直接加载 np.save("camera_mtx.npy", mtx) np.save("camera_dist.npy", dist) print("重投影误差:", ret)

代码里最关键的是 object points 的构造。mgrid 生成的坐标从 (0,0,0) 到 (8,5,0),乘上格子边长后单位是米。角点检测失败时直接跳过而不是中断,因为总会有几张光线不好或者棋盘出界的图。cornerSubPix 的窗口大小选 11×11,搜索半径(-1,-1)表示自动按窗口大小估计,迭代准则 30 次和 1e-6 精度足够常用场景。

标定完成后把 mtx 和 dist 存成 npy 文件,手眼标定前先检查重投影误差 ret。如果 ret 大于 0.5,说明图像质量或棋盘格平整度有问题,建议重拍而不是直接往下走。

3.2 棋盘格角点检测与 solvePnP:从一张图得到标定板到相机的变换

有了内参之后,每一张手眼数据图需要计算一个 cam_T_target,也就是标定板坐标系到相机坐标系的变换。这个变换用 solvePnP 来做:已知棋盘格角点的三维坐标和对应的二维像素坐标,求解相机外参。下面的函数封装了从图像到位姿的完整过程:

import cv2 import numpy as np def detect_target_pose(image, mtx, dist, checkerboard=(9, 6), square_size=0.025): """ 检测棋盘格并计算标定板坐标系到相机坐标系的变换。 返回 R_target2cam (3,3), t_target2cam (3,1);检测失败返回 None。 """ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, checkerboard, None) if not ret: return None criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) # 构造标定板坐标系下三维点 objp = np.zeros((checkerboard[0] * checkerboard[1], 3), np.float32) objp[:, :2] = np.mgrid[0:checkerboard[0], 0:checkerboard[1]].T.reshape(-1, 2) objp *= square_size # solvePnP 求解外参:把标定板坐标系变换到相机坐标系 retval, rvec, tvec = cv2.solvePnP( objp, corners2, mtx, dist, flags=cv2.SOLVEPNP_ITERATIVE ) if not retval: return None R_target2cam, _ = cv2.Rodrigues(rvec) return R_target2cam, tvec.reshape(3, 1)

solvePnP 输入的三维点顺序必须和 findChessboardCorners 输出的角点顺序一一对应。棋盘格的 mgrid 顺序是从左上角开始按行排列的,findChessboardCorners 默认也是这个顺序,只要不手动打乱就没有问题。SOLVEPNP_ITERATIVE 适合已知内参的场景,对畸变不大的镜头稳定性很好;如果棋盘格在图像里占比较小,可以换 SOLVEPNP_AP3P 或 SOLVEPNP_IPPE,但实际中 ITERATIVE 足够。

这个函数返回的 R_target2cam 和 t_target2cam 就是手眼标定公式里的 B。注意 tvec 的单位跟 square_size 一致,square_size 用米,tvec 就是米。

3.3 数据采集方法:17 组位姿怎么摆,图像和机器人位姿怎么同步

手眼标定数据不是随便拍几张就行的。我一般控制在 12~20 组之间,太少解不稳定,太多数据冗余对精度提升有限,反而增加采集工作量。关键不是数量,而是位姿的多样性。

实际操作时,机械臂带着相机走到一个位姿,先停下来,等运动完全稳定后同时记录两样东西:一是机器人控制器输出的末端位姿,二是相机拍下的棋盘格图像。我用的常见做法是写一个采集脚本,机械臂每到位一个点,先延时 0.5 秒等待震动消失,再触发相机拍照,同时从控制器读取当前位姿。这样能保证图像和位姿对应的是同一个时刻。

位姿的摆法要注意几个原则。标定板在图像里的占比不要太小,最好超过画面四分之一,否则角点亚像素定位精度不够。机械臂的姿态要覆盖不同朝向,不能所有点位都只是平移,要在 X、Y、Z 三个旋转方向都有变化,比如让末端绕工具坐标系的 X 轴转 20 度、绕 Y 轴转 30 度这样交叉变化。标定板尽量放在视野的不同区域,不要一直摆在画面正中央,否则外参求解的病态程度会增加。

数据采集完,把机器人位姿保存成文本文件,每行格式统一为x, y, z, rx, ry, rz,其中平移单位是毫米,旋转是旋转向量。图像文件按采集顺序编号,和位姿文件的行一一对应。

4. 用 calibrateHandEye 跑通手眼标定:完整代码与参数选择

4.1 Eye-in-Hand 标定完整代码:从文件到变换矩阵

这一步把前面准备的数据汇合到一起,调用 OpenCV 的 calibrateHandEye 求出 end_T_cam。完整代码可以分成三个模块:读取机器人位姿文件、遍历图像计算标定板位姿、调用求解函数输出结果。

import cv2 import numpy as np import glob def load_robot_poses(pose_file): """ 读取机械臂末端位姿文件。 每行格式: x_mm, y_mm, z_mm, rx, ry, rz 平移单位毫米,旋转为旋转向量。 返回 R_gripper2base (N,3,3) 和 t_gripper2base (N,3),单位转换为米。 """ R_list, t_list = [], [] with open(pose_file, "r") as f: for line in f: vals = [float(v) for v in line.strip().split(",")] x, y, z = vals[0] / 1000.0, vals[1] / 1000.0, vals[2] / 1000.0 rvec = np.array(vals[3:], dtype=np.float64).reshape(3, 1) R, _ = cv2.Rodrigues(rvec) t = np.array([[x], [y], [z]], dtype=np.float64) R_list.append(R) t_list.append(t) return np.array(R_list), np.array(t_list).reshape(-1, 3) # 加载相机内参 mtx = np.load("camera_mtx.npy") dist = np.load("camera_dist.npy") # 读取机械臂位姿和图像列表 R_gripper2base, t_gripper2base = load_robot_poses("robot_poses.txt") image_files = sorted(glob.glob("handeye_images/*.jpg")) assert len(image_files) == len(R_gripper2base), "图像数量和位姿数量必须一致" # 遍历图像,计算标定板到相机的位姿 R_target2cam_list, t_target2cam_list = [], [] valid_idx = [] for i, fname in enumerate(image_files): img = cv2.imread(fname) result = detect_target_pose(img, mtx, dist, checkerboard=(9, 6), square_size=0.025) if result is None: print(f"{fname} 棋盘格检测失败,跳过") continue R_target2cam, t_target2cam = result R_target2cam_list.append(R_target2cam) t_target2cam_list.append(t_target2cam) valid_idx.append(i) # 同步剔除检测失败对应的机器人位姿 R_gripper2base = R_gripper2base[valid_idx] t_gripper2base = t_gripper2base[valid_idx] R_target2cam = np.array(R_target2cam_list) t_target2cam = np.array(t_target2cam_list) # 手眼标定 R_cam2gripper, t_cam2gripper = cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, method=cv2.CALIB_HAND_EYE_TSAI ) # 输出结果 print("end_T_cam 旋转矩阵:") print(R_cam2gripper) print("end_T_cam 平移向量 (米):") print(t_cam2gripper) # 保存结果 np.save("handeye_R_cam2gripper.npy", R_cam2gripper) np.save("handeye_t_cam2gripper.npy", t_cam2gripper)

这段代码里最容易出错的地方是单位换算。机器人位姿文件里的 x、y、z 是毫米,除以 1000 转成米,与棋盘格 square_size 的米制单位保持一致。旋转向量用 cv2.Rodrigues 转成旋转矩阵,注意 rvec 的 shape 必须是 (3,1) 或 (3,),否则某些 OpenCV 版本会报错或得到错误结果。

valid_idx 的作用是同步过滤。如果第 5 张图棋盘格检测失败,那么第 5 行机器人位姿必须一并剔除,否则数据对不齐,标定结果的平移部分会直接偏掉。calibrateHandEye 要求输入数组长度一致,而且内部是按数组下标配对的,顺序错位是手眼标定最容易导致“结果看起来合理但实际完全不能用”的原因之一。

4.2 calibrateHandEye 的五种方法:TSAI/PARK/HORAUD/ANDREFF/DANIILIDIS 怎么选

OpenCV 的 calibrateHandEye 支持五种算法,通过 method 参数切换。我实际用下来,最常用的就是 TSAI,它实现简单、速度快、对数据质量要求适中,大多数工业场景都能满足。PARK 和 HORAUD 在理论上是等效的,但对噪声更敏感,适合数据质量比较高的情况。ANDREFF 是基于正交约束的线性解,不需要迭代,但精度一般比不过 Tsai 和 Park。DANIILIDIS 是较新的方法,对数据退化情况的鲁棒性更好,如果你的采集位姿多样性不够,它能比 TSAI 少出一些极端错误。

下面这个表格可以帮你快速选:

method 参数特点适用场景
CALIB_HAND_EYE_TSAI经典两步法,稳定,对标定板噪声不敏感默认首选,大多数场景够用
CALIB_HAND_EYE_PARK李群上求平均,精度高但需要数据干净标定板检测质量好、位姿覆盖充分
CALIB_HAND_EYE_HORAUD与 Park 类似,旋转和平移分离求解同上,看个人偏好
CALIB_HAND_EYE_ANDREFF线性方法,速度最快数据量大、实时性要求高的场景
CALIB_HAND_EYE_DANIILIDIS处理退化情况更鲁棒位姿多样性不足或带噪声数据

我的建议是先全部跑一遍,对比五种方法输出的 X 矩阵的差异。如果差异很小,说明数据质量好,选哪个都行;如果 TSAI 和 DANIILIDIS 的结果差了十万八千里,说明你的数据里存在病态位姿或同步问题,这时候先别纠结算法,回去检查数据才是正路。

4.3 Eye-to-Hand 的差异:把相对变换取逆再传入

Eye-to-Hand 场景下,相机固定、标定板固定在机械臂末端。此时读到的机器人位姿还是 base_T_end,但标定板不再是固定的,它跟着末端一起动。OpenCV 的 calibrateHandEye 依然能用,但传入参数的角色要做调整。

我在项目里常用的做法是:把每组 cam_T_target 取逆得到 target_T_cam,把每组 base_T_end 求逆得到 end_T_base,然后将前者作为 gripper2base 传入,后者作为 target2cam 传入,求解出来的 X 就是 base_T_cam。听起来绕,但代码很简单:

def calibrate_eye_to_hand(R_base2end, t_base2end, R_cam2target, t_cam2target): R_gripper2base_list, t_gripper2base_list = [], [] R_target2cam_list, t_target2cam_list = [], [] for i in range(len(R_base2end)): # 构造 4x4 齐次矩阵 T_be = np.eye(4) T_be[:3, :3] = R_base2end[i] T_be[:3, 3] = t_base2end[i].flatten() T_ct = np.eye(4) T_ct[:3, :3] = R_cam2target[i] T_ct[:3, 3] = t_cam2target[i].flatten() # 取逆 T_eb = np.linalg.inv(T_be) # end_T_base T_tc = np.linalg.inv(T_ct) # target_T_cam R_gripper2base_list.append(T_tc[:3, :3]) t_gripper2base_list.append(T_tc[:3, 3]) R_target2cam_list.append(T_eb[:3, :3]) t_target2cam_list.append(T_eb[:3, 3]) R_base2cam, t_base2cam = cv2.calibrateHandEye( np.array(R_gripper2base_list), np.array(t_gripper2base_list), np.array(R_target2cam_list), np.array(t_target2cam_list), method=cv2.CALIB_HAND_EYE_TSAI ) return R_base2cam, t_base2cam # 最终得到基座到相机的变换

这段代码没有做单位转换,调用前务必保证所有 t 都已经是米。Eye-to-Hand 的输出习惯上直接给 base_T_cam,也就是把相机坐标系里的点变换到基座坐标系。如果你拿到的是 cam_T_base,记得取逆再用。

4.4 保存标定结果:YAML 和 numpy 两种持久化方式

标定完的结果要存下来给抓取程序用。最常见的两种方式,一种是用 numpy 的 save 直接存二进制 npy,适合自己写的小程序快速加载;另一种是存成 YAML,方便和其他模块或同事对接。OpenCV 自带 FileStorage 可以写 YAML,但格式如果只是自己内部用,npy 更省事。

import cv2 import numpy as np # 方式一:npy 直接保存 np.save("handeye_R_cam2gripper.npy", R_cam2gripper) np.save("handeye_t_cam2gripper.npy", t_cam2gripper) # 方式二:YAML 保存,附带内参,方便后续独立加载 fs = cv2.FileStorage("handeye_result.yaml", cv2.FILE_STORAGE_WRITE) fs.write("R_cam2gripper", R_cam2gripper) fs.write("t_cam2gripper", t_cam2gripper) fs.write("camera_mtx", mtx) fs.write("camera_dist", dist) fs.release() # 读取 YAML fs = cv2.FileStorage("handeye_result.yaml", cv2.FILE_STORAGE_READ) R_load = fs.getNode("R_cam2gripper").mat() t_load = fs.getNode("t_cam2gripper").mat() fs.release()

YAML 方式的好处是同时保存了内参和畸变系数,抓取模块只需要读这一个文件就能完整恢复手眼链路。注意 OpenCV 的 FileStorage 写矩阵时会自动处理数据类型,读取时用 mat() 转回 numpy 数组,平移向量的 shape 默认是 (3,1),使用前可能需要 reshape 成 (3,)。

5. 手眼标定避坑记:五个让你翻车的细节

5.1 标定板在画面里太小,solvePnP 精度崩了

现象:标定板放在视野远处,只占画面十分之一左右,手眼标定结果换一组数据重新标,X 矩阵变化很大。

原因:棋盘格角点数量不变,但每个角点在图像里的亚像素定位误差是固定的,尺度小了之后,同样的像素误差映射到三维空间的角度误差就变大,solvePnP 的旋转分量不稳定,直接传导给 AX=XB 求解。

解决:重新采集数据,保证标定板最长边在图像里占比超过四分之一,最好接近一半。我自己的经验是棋盘格外围至少留一圈空白边,否则边缘角点受镜头畸变影响更明显。

5.2 图像采集和机器人位姿不同步,标定结果像抽风

现象:标定结果每次跑都有小幅度跳动,或者干脆出现明显不可能的平移量,比如 1 米以上。

原因:机械臂还没完全停稳或者控制器位姿缓存还没更新,相机就先拍照了,图像对应的实际末端位姿和读到的数值不一致。高速运动下哪怕差 50 毫秒,末端位置就能差好几毫米。

解决:在采集脚本里加延时,到位后至少等 0.3~0.5 秒再拍照和读数。如果控制器支持外部触发,用同一个硬件信号同时触发相机和位姿记录是最稳的。另一个办法是先记录位姿再触发拍照,因为位姿读取是瞬时的,而拍照有曝光和传输延迟。

5.3 机器人位姿转换成旋转矩阵时,欧拉角顺序搞反了

现象:标定结果重投影验证时,旋转方向完全错误,比如沿着某个轴转了 90 度。

原因:很多机器人控制器返回的位姿是欧拉角,而不是旋转向量。欧拉角的顺序在不同厂商之间可能不一样,常见的 ZYX 和 XYZ 转换出来的旋转矩阵天差地别。我用 cv2.Rodrigues 处理的是旋转向量,直接拿欧拉角喂进去必错。

解决:先确认控制器输出的旋转表示形式。如果是欧拉角,要用对应的转换公式先转成旋转矩阵,再喂给标定程序。一个快速自检方法是让机械臂只绕单一轴转 90 度,看转换后的矩阵是否符合预期。这个坑我踩过一次之后,所有位姿读取函数都会先做单轴验证再交给标定流程。

5.4 采集数据全在一个平面,AX=XB 近似退化

现象:标定结果在数学上能收敛,但换一组数据重新标定,结果差异明显,尤其是平移分量。

原因:AX=XB 需要多组非共面的相对旋转来约束解空间。如果机械臂只是把相机平移到不同位置,姿态完全不变,那么所有位姿之间的相对旋转都是单位矩阵,方程退化,X 在某个方向上没有被约束住。

解决:采集时让机械臂在每个位置都做姿态变化,至少绕两个轴各转 20~30 度。我一般会在 5 个不同高度、每个高度变换 3 种姿态,凑 15 组,确保相对运动在三维空间里铺开。

5.5 相机内参和手眼标定用了不同的分辨率

现象:手眼标定结果单独看似乎合理,一旦用真实抓取测试就偏,而且偏的方向没有规律。

原因:相机标定内参时用的图像分辨率是 1280×720,后来采集手眼数据时相机被设置成了 1920×1080,或者反过来。分辨率变化后图像坐标缩放,内参却不匹配,solvePnP 自然出错。

解决:全流程固定相机分辨率,内参标定和手眼数据采集用同一个分辨率、同一个镜头焦距。如果中途动过镜头或换过分辨率,重新做一遍内参标定,不要沿用旧的内参文件。

6. 用抓取测试验证标定精度:一个单点测试流程跑完才算数

标定结果不能只看矩阵打印得漂亮,最终要能用。我的验证流程是做一个单点抓取测试:在机械臂工作空间里放一个已知半径的小圆柱或小球,用相机识别它的中心点,换算成基座坐标后让机械臂去抓或去指,量一下末端实际到达位置和目标位置的偏差。

def pixel_to_base(u, v, depth, mtx, R_cam2gripper, t_cam2gripper, R_gripper2base, t_gripper2base): """ 将像素坐标转换为基座坐标。 depth 为相机坐标系下的深度值,单位米。 """ # 像素坐标转相机坐标(归一化平面) fx = mtx[0, 0] fy = mtx[1, 1] cx = mtx[0, 2] cy = mtx[1, 2] x_cam = (u - cx) * depth / fx y_cam = (v - cy) * depth / fy z_cam = depth p_cam = np.array([[x_cam], [y_cam], [z_cam]], dtype=np.float64) # 相机坐标 -> 末端坐标 p_gripper = R_cam2gripper @ p_cam + t_cam2gripper.reshape(3, 1) # 末端坐标 -> 基座坐标 p_base = R_gripper2base @ p_gripper + t_gripper2base.reshape(3, 1) return p_base.flatten()

测试时注意,目标点的深度值如果是用单目估算的,精度会很差,我通常配合已知目标高度或者用双目/结构光来获取可靠的 depth。如果标定结果正确,机械臂执行点到目标位置的偏差应该在 5 毫米以内,具体取决于机械臂本身的绝对定位精度。

最后说一个我自己的教训。有一回标定结果怎么看怎么合理,重投影误差也不大,但抓取总是偏一个固定角度,排查了三天,最后发现是机器人控制器的底座安装没有校平,基座坐标系的 Z 轴和手眼标定假设的完全垂直差了 1.5 度。这种问题手眼标定本身无法修正,因为标定假设基座坐标系是精确已知的。遇到这种情况,先把机械臂自身的坐标精度校准好,再回头做手眼标定。希望这篇文章能帮你少走几步弯路,标定一次就能稳。

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

RELATED READING

延伸阅读

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