
去年搭了一套机械臂视觉抓取系统相机用的就是 Intel RealSense D435i算法侧全部走 OpenCV 加 Python。系统刚跑起来那阵子视觉识别出来的目标位置和机械臂实际抓取的位置总有偏差大概一厘米上下。分拣一些大件工件时勉强能用一换到精密装配或者小零件抓取就明显不行了——夹爪总是擦着零件边过去偶尔还会把零件碰倒。后来我花了大概两周时间把整条链路从头到尾排查了一遍最后把手眼标定的误差从 1cm 压到了 1mm 以内。这个过程里踩了不少坑也总结了一套可以复用的方法和代码今天一并分享出来。这篇文章适合正在做机械臂视觉引导、机器人抓取、自动化上料这类项目的朋友尤其是刚接触手眼标定、被精度问题折磨过的工程师。1. 先把坐标系掰扯清楚手眼标定到底在算什么1.1 一个公式看懂 AXXB手眼标定的核心是要算清楚相机坐标系和机械臂坐标系之间的变换关系。你可以把它理解成一个“翻译器”相机看到的每个点都是以相机为原点来描述坐标的但机械臂执行动作时需要的是以机械臂基座为原点、或者以末端执行器为原点的坐标。没有这层变换关系相机告诉你“目标在这里”机械臂根本不知道“这里”到底对应自己坐标系下的哪个位置。这个关系在数学上就是一个 4x4 的齐次变换矩阵 X。怎么求 X经典方法是让机械臂带着相机运动到多个不同的位置每个位置下同时记录机械臂的位姿和相机看到的标定板位姿。相邻两个位置之间机械臂末端的相对运动记为 A标定板在相机视野里的相对运动记为 B理论上满足A * X X * B这个式子就是手眼标定的核心方程求解 X 的过程就是解 AXXB。X 一旦求出来相机图像里的任意一个目标点都能通过 X 变换到机械臂坐标系机械臂就能照着坐标去抓了。这里的关键在于理解A 是从机械臂自身读取出来的位姿变化B 是从图像中提取出来的标定板位姿变化而 X 是未知的、固定不变的相机到机械臂的变换。未知量 X 夹在中间通过多组 A、B 数据把它“夹”出来这就是整个标定的数学本质。1.2 眼在手上 vs 眼在手外手眼标定分两种典型配置。一种是“眼在手上”Eye-in-Hand相机装在机械臂末端法兰上跟着机械臂一起动另一种是“眼在手外”Eye-to-Hand相机固定在工作区域上方或者侧方机械臂单独运动。我这套项目用的是眼在手上的配置。原因是 D435i 本身很轻巧装在末端不会给机械臂增加太多负载而且相机跟着机械臂运动可以从不同角度观察目标视野更灵活尤其适合工件位置不固定、需要机械臂找角度的场景。两种配置的标定流程几乎一样但代码里有细微差别。OpenCV 的calibrateHandEye函数天生适合眼在手上的场景直接传入机械臂末端到基座的旋转平移、标定板到相机的旋转平移返回的就是相机到机械臂末端的变换矩阵。眼在手外时需要把机械臂的位姿矩阵求逆之后再传入返回的才是相机到基座的变换。这个细节我在后面的代码里会专门标注照着抄不会错。项目里我选的眼在手上还有一层考虑标定板可以固定在工作台面上不需要额外做支架采集数据时机械臂只需要带着相机在标定板周围变换姿态即可。这样整个标定过程非常快一台机械臂、一个相机、一块标定板就搞定。2. 精度从 1cm 到 1mm误差链路与优化思路2.1 手眼标定的误差从哪里来刚开始排查精度问题时我以为手眼标定算出来就完事了误差大可能是视觉识别算法的问题。后来把识别算法单独测了一遍发现单目标定精度其实很好误差基本在 2-3 个像素以内说明问题不在识别而在坐标系变换这层。手眼标定的精度受整条链路多个环节影响我把它们称为误差链路相机内参误差包括焦距、主点、畸变系数。内参不准标定板在相机坐标系下的位姿估计就会引入偏差。角点提取误差棋盘格角点提取得不够准直接导致 solvePnP 求出来的 rvec/tvec 有误差。机械臂位姿误差机械臂返回的末端到基座的位姿本身有误差这个误差会直接进入 A 矩阵。采样姿态质量姿态数量不足、姿态太相似、标定板太小、距离太远都会让 AXXB 方程病态。算法选择OpenCV 提供了多种求解算法不同算法对不同噪声分布和初始条件的鲁棒性不同。这条链路上任何一个环节掉链子最终都会反映到手眼变换矩阵的误差上。1cm 的定位误差不是你视觉算法不够好更可能是链路中间某几环精度太差。2.2 影响最大的三个“隐形杀手”在排查过程中我总结出三个最容易被忽视、但影响极大的因素。第一个是直接用相机的出厂内参。D435i 出厂时会给一组内参但实测下来直接用出厂内参做重投影图像边缘区域的误差能达到好几个像素。对于手眼标定这种对精度极度敏感的任务这是不可接受的。我后来用棋盘格对 D435i 的 RGB 相机重新做了内参标定边缘误差降到了 0.1 像素级别手眼标定的结果立刻改善了一个量级。第二个是采样姿态的数量和分布。我第一次标定只采了 8 组数据而且机械臂的姿态几乎都是同一个朝向只是位置稍微平移。结果就是 AXXB 方程严重病态不同算法求出来的 X 差异巨大。后来我把采样数量增加到 20 组以上并且让机械臂末端在空间中做明显的旋转变化——俯仰、偏航、翻滚都要覆盖到标定结果才稳定下来。第三个是机械臂运动过程中采集数据。如果机械臂还在运动时就触发相机拍照机械臂返回的位姿和图像实际拍摄时刻的位姿有时间差A 矩阵和 B 矩阵就不是严格对应的。正确做法是让机械臂运动到目标姿态后先停稳最少 300 到 500 毫秒再触发相机采集确保位姿和图像严格同步。2.3 为什么从 1cm 到 1mm 是一个分水岭1cm 的定位精度在机器人项目里意味着什么分拣大纸箱、搬运托盘这种粗活1cm 误差问题不大。但一旦涉及插拔、装配、螺丝锁付、小零件抓取1cm 误差就是灾难级别的。以典型的电机齿轮装配为例齿轮配合间隙往往在 0.5mm 以内1cm 的误差连对准都做不到。我这次优化的目标就是把定位精度压到 1mm 以内因为对于 0.3 到 0.5 米工作距离下的 D435i 视觉引导1mm 是一个合理的精度上限。低于这个值基本就是机械臂自身绝对定位精度的极限了——绝大多数工业机械臂的重复定位精度能到 ±0.1mm但绝对定位精度通常只有 0.5 到 2mm。换句话说手眼标定做到 1mm 以内之后继续提升的空间已经不取决于标定本身而取决于机械臂自身的精度。所以从 1cm 到 1mm不是一个简单的“再调调参数”的过程而是把整条误差链路的每一环都压到极限。下面我详细拆解完整流程和代码。3. 实操完整的手眼标定流程与 Python 代码3.1 环境准备RealSense SDK OpenCV先说环境。我用的系统是 Ubuntu 20.04Python 3.8OpenCV 用的 4.5.5RealSense 驱动用的是官方 SDK 的 Python 接口 pyrealsense2。安装命令很简单两条 pip 就搞定pip install opencv-python opencv-contrib-python pip install pyrealsense2注意 pyrealsense2 要装官方 PyPI 上的版本不要用某些第三方编译的包兼容性容易出问题。如果用的是 ROS 环境也可以直接用ros-noetic-realsense2-camera这个驱动包但我在验证阶段为了减少 ROS 层的不确定性直接用 pyrealsense2 采集图像ROS 留给后续工程化。除了这两个核心库还需要 numpy做矩阵运算用基础操作不展开。3.2 标定板选择精度就是从这里抠出来的标定板的选择直接决定角点提取精度进而决定整个标定的上限。第一梯队是工业级的陶瓷或玻璃基板标定板精度最高但价格也高。第二梯队是自己打印棋盘格贴在刚性背板上精度取决于打印机的精度和背板的平整度。第三梯队是随便打印一张纸贴在墙上这种精度最差建议直接放弃。我这次用的是自己打印的棋盘格格子尺寸 25mm规格 9x6 内角点。关键步骤是把它贴到一块 5mm 厚的铝板上确保标定板绝对平整。如果标定板本身有弯曲角点位置就会产生系统性的偏移这个误差会直接进到手眼标定的结果里。这里有个细节要注意棋盘格的黑白格子表面最好做哑光处理避免反光。普通打印纸在强光下会有镜面反射D435i 的 RGB 相机一旦拍出反光角点检测就会不稳。我后来在标定板表面贴了一层哑光膜角点提取的成功率明显提升。如果条件允许用 Charuco 板替代传统棋盘格也是不错的选择。Charuco 板在部分遮挡的情况下仍然能稳定提取角点而且角点数量多solvePnP 的稳定性更好。我的经验是精度要求高、实验环境复杂优先考虑 Charuco。3.3 数据采集姿态覆盖比数量更重要数据采集是整个标定流程里最考验耐心的一步。我用了 20 组数据但这 20 组不是随便拍的我在规划姿态时遵循了几个原则。第一个原则是旋转变化要足够丰富。机械臂带着相机从正视标定板开始然后逐步增加俯仰角、偏航角和翻滚角每一组数据之间旋转角度最好相差 15 度以上。因为 AXXB 方程求解依赖旋转信息如果所有姿态的旋转都很相似方程就是病态的。第二个原则是标定板在画面中的占比要适中。标定板太小角点间距过小像素量化误差占比高标定板太大超出画面边缘角点截断。我一般控制标定板在画面中占三分之一到二分之一并且尽量让标定板位于画面中心区域。第三个原则是稳停后采集。我把每组的停顿时间设在 500ms确保机械臂完全静止后再拍照彻底避免时间同步误差。采集完每组数据后我都会立即做一次角点检测的验证确认这组数据的角点都被完整提取出来了再让机械臂运动到下一个姿态。这样可以避免事后来回补采效率高很多。3.4 核心代码一步到位的 Python 实现下面给出完整的核心代码各位可以直接参考。先说明一下数据组织方式每组数据包含一个机械臂末端的 4x4 位姿矩阵和一张彩色图像图像按编号保存机械臂位姿单独存成一个 JSON 文件。第一步是相机内参标定。D435i 的 RGB 内参我用标准棋盘格重新标定具体代码是 OpenCV 的calibrateCamera这段属于通用流程这里略过只给出标定结果的使用方式。第二步是角点检测与标定板位姿求解import cv2 import numpy as np import json import pyrealsense2 as rs pattern (9, 6) square_size 0.025 # 25mm camera_matrix np.array([[...]]) # 替换为自己的内参标定结果 dist_coeffs np.array([...]) # 替换为自己的畸变系数 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 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) def detect_board_pose(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern, None) if not ret: return None corners_sub cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) # solvePnP 求解标定板在相机坐标系下的位姿 ret, rvec, tvec cv2.solvePnP(objp, corners_sub, camera_matrix, dist_coeffs) if not ret: return None T_target2cam np.eye(4) T_target2cam[:3, :3] cv2.Rodrigues(rvec)[0] T_target2cam[:3, 3] tvec.flatten() return T_target2cam注意这里用了cornerSubPix做亚像素细化这一步非常关键。如果只用findChessboardCorners的原始角点精度会停留在像素级对 1mm 级别的目标来说是不够的。亚像素细化就是把角点定位到浮点像素精度能显著提升 solvePnP 的稳定性。第三步是机械臂位姿读取。这里我用了一个抽象函数实际项目中要根据机械臂品牌替换def get_gripper_to_base(): # 从机械臂控制接口读取当前末端位姿返回 4x4 齐次矩阵 # 以 UR 机械臂为例可以通过 RTDE 接口获取 TCP 位姿 # 其他品牌替换为对应的 SDK 调用即可 pass第四步就是手眼标定的核心调用。这里我一次性跑 OpenCV 提供的全部五种算法方便对比R_gripper2base [] t_gripper2base [] R_target2cam [] t_target2cam [] for sample in samples: Tg2b sample[gripper_to_base] # 机械臂末端到基座 Tt2c sample[target_to_cam] # 标定板到相机 R_gripper2base.append(Tg2b[:3, :3]) t_gripper2base.append(Tg2b[:3, 3]) R_target2cam.append(Tt2c[:3, :3]) t_target2cam.append(Tt2c[:3, 3]) methods { Tsai: cv2.CALIB_HAND_EYE_TSAI, Park: cv2.CALIB_HAND_EYE_PARK, Horaud: cv2.CALIB_HAND_EYE_HORAUD, Andreff: cv2.CALIB_HAND_EYE_ANDREFF, Daniilidis: cv2.CALIB_HAND_EYE_DANIILIDIS, } for name, method in methods.items(): R_cam2gripper, t_cam2gripper cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, methodmethod ) # 保存每个算法的结果后续用验证集打分 print(f{name}: t_cam2gripper {t_cam2gripper.flatten()})眼在手外的场景代码只需要改一处把Tg2b替换为np.linalg.inv(Tg2b)也就是把机械臂末端到基座的矩阵取逆变成基座到末端的矩阵然后正常调用calibrateHandEye返回结果就是相机到基座的变换。3.5 结果验证标定完必须做的事标定完直接拿去用是很多新手会犯的致命错误。一块手眼标定结果到底准不准必须用独立的数据验证。我的验证方法是“固定标定板重投影一致性验证”方法如下。因为标定板固定在工作台面上眼在手上的配置理论上不管机械臂带着相机怎么动标定板在机械臂基座坐标系下的位姿应该是恒定不变的。于是我额外采集一组验证数据不参与手眼标定求解专门用来评估def validate(hand_eye_result, test_samples): T_cam2gripper hand_eye_result T_gripper2cam np.linalg.inv(T_cam2gripper) ref_pose None max_err 0.0 for i, sample in enumerate(test_samples): Tg2b sample[gripper_to_base] Tt2c sample[target_to_cam] # 预测标定板在基座坐标系下的位姿 T_base2target Tg2b T_gripper2cam Tt2c if i 0: ref_pose T_base2target continue # 位置误差单位米 pos_err np.linalg.norm(T_base2target[:3, 3] - ref_pose[:3, 3]) # 姿态误差单位度 rot_err np.degrees( np.arccos(np.clip((np.trace(T_base2target[:3, :3].T ref_pose[:3, :3]) - 1) / 2, -1, 1)) ) max_err max(max_err, pos_err) print(fsample {i}: pos_err{pos_err*1000:.3f}mm, rot_err{rot_err:.4f}deg) return max_err这就是我用来给五种算法打分的方法。实测下来Tsai 算法在我这组数据上表现一般Park 和 Horaud 的效果最好位置误差稳定在 1mm 以内。Andreff 计算最快但抗噪能力弱误差偏大。Daniilidis 表现也不错仅次于 Park 和 Horaud。验证通过之后把选定的T_cam2gripper保存成文件后续工程代码加载这个矩阵就能把相机坐标变换到机械臂坐标了。4. 实战踩坑记录从失败到成功的调试过程4.1 内参不准结果看似合理实际定位偏第一次标定完我用验证集算了一下位置误差大约 7mm感觉还有优化空间。当时没怀疑内参因为 D435i 出厂参数看起来精度很高。后来我用 OpenCV 单独做了内参标定发现出厂内参和实测内参在主点坐标上差了约 5 个像素焦距差了约 0.3%。这个量级的内参误差直接导致 solvePnP 求解出的标定板位姿有了系统性偏差进而污染了手眼标定结果。重新标定内参、并修改代码里的camera_matrix和dist_coeffs之后验证误差从 7mm 降到了 3mm 左右。所以我的建议是任何相机做手眼标定之前先花一个小时把内参标定做了。这个时间花得非常值。4.2 姿态太少不同算法结果打架一开始我只采了 8 组数据而且姿态基本都是机械臂平移、旋转角度很小。跑五种算法时发现Tsai 和 Horaud 求出的平移向量在 z 轴方向差了 15mm。这个现象非常典型说明 AXXB 方程的条件数太差解不稳定。旋转信息不足时方程在某个方向上几乎是“平的”微小的噪声就会被放大成大误差。解决办法就是增加采样姿态的旋转多样性。我把机械臂在标定板前做了俯仰 30 度、偏航 45 度、翻滚 20 度等一系列变化最终凑了 20 组数据。重新标定后五种算法的结果非常接近验证误差也降到了接近 2mm。4.3 机械臂工具坐标系设置错误偏差集中在某个轴有一次标定完验证误差在 x、y 方向都很好唯独 z 方向一直偏 4mm 左右。排查了各种环节最后发现是机械臂的工具坐标系设置的问题。我用的是末端法兰坐标系读取位姿但机械臂控制器的工具坐标系里还残留着之前调试时设置的偏移量。这个偏移量导致读取的末端位姿和实际相机安装位置之间有一个固定偏差最后全部映射到了手眼标定结果里。解决办法是在机械臂控制面板里把工具坐标系清零或者把标定用的相机坐标系设置在末端法兰中心保证读取的位姿就是末端法兰的真实位姿。这个坑很隐蔽如果你的标定结果在其他维度都正常、唯独某个轴有固定偏差优先检查工具坐标系。4.4 自动曝光导致图像不稳定D435i 的 RGB 相机默认开启自动曝光在标定过程中机械臂运动时遮住光源、或者标定板移动到不同光照区域都会导致自动曝光剧烈调整画面亮度忽明忽暗角点检测的稳定性就会下降。我的处理方式是关闭自动曝光固定一个合适的曝光值。具体数值根据现场光照条件实验确定我在室内日光灯环境下测试曝光值设在 200 左右效果比较稳定。sensor profile.get_device().first_color_sensor() sensor.set_option(rs.option.enable_auto_exposure, 0) sensor.set_option(rs.option.exposure, 200)手动固定曝光之后20 组数据的角点检测成功率从 80% 提升到了 100%标定稳定性明显改善。5. 常见问题与排查技巧实录这部分整理我标定过程中遇到的典型问题做成速查表方便大家排查。问题现象可能原因解决方案五种算法结果差异巨大采样姿态旋转不足或数量太少增加到 20 组以上覆盖俯仰偏航翻滚验证误差稳定在 5mm 以上相机内参不准用棋盘格重新标定内参不用出厂参数某个轴方向固定偏移机械臂工具坐标系设置有误清零工具坐标系确认末端位姿读取位置角点检测时好时坏自动曝光导致图像亮度波动关闭自动曝光手动固定曝光值标定结果随数据顺序变化数据里混入了不合格样本每组数据都做角点检测验证不合格就重采标定板边缘角点偏差大标定板不够平整换铝板或玻璃板保证板面绝对平整验证误差小但实际抓取偏验证集与标定集分布相似过拟合验证集单独规划姿态与标定集显著不同还有一个独家技巧数据采集时可以写一个简单的脚本实时检测角点数量并把检测失败的样本标红提示。这个脚本能帮你在现场立刻发现问题而不是等采完 20 组数据回办公室跑标定才发现其中几组已经废了。另外关于算法选择我的建议是不要迷信某一个算法的官方文档实际项目里把五种算法全跑一遍用验证集打分选最优这才是最稳妥的做法。不同项目的噪声特性不同最优算法也不一样。我这次是 Park 和 Horaud 胜出其他项目可能 Tsai 表现更好。还有个容易忽略的问题是数据精度。机械臂位姿如果是从示教器上人工读出来再录入的精度会很差务必用控制接口直接读取浮点型数据。我这边通过 RTDE 接口读取的时候位姿数据都是带 6 位小数的浮点数这样进入手眼标定的 A 矩阵才有足够的精度。最近我还在尝试把标定采集流程做成半自动化的脚本机械臂按预设路径自动运动到各个采样姿态每个姿态停稳后自动触发相机拍照程序自动做角点检测并把可用的样本数据保存下来。整个采集过程从手动 40 分钟缩减到 10 分钟而且不容易漏采姿态。如果你也需要频繁做标定强烈建议把这个流程自动化。最后说一句我在实际项目里的体会手眼标定是一项“上限由硬件决定、下限由方法决定”的工作。当你把所有方法层面的坑都填平之后最终精度大概率就停在机械臂自身绝对定位精度的量级上。1mm 对多数协作臂来说是合理的终点不要为了追求 0.1mm 而投入过多精力除非你用的是绝对定位精度很高的工业机器人并且标定板、环境、相机都达到实验室级别。认清这一点能帮你把精力花在真正有回报的地方。