
第一次做机械臂视觉抓取时我在第一步就卡了快一周相机明明能识别到目标机械臂却怎么都抓不准。后来才反应过来自己一直把相机坐标系下的坐标直接发给机械臂这等于让一个不知道自己站在哪的人去拿桌上的水杯。OpenCV 处理的是像素和相机坐标系机械臂活在它自己的基座坐标系里两个坐标系之间的桥就是手眼标定Hand-Eye Calibration。这篇内容不是教科书式讲原理而是把我实际走过的完整流程、踩过的坑、以及事后总结的验证方法一次性讲清楚。内容覆盖眼在手上和眼在手外两种方案、OpenCV 环境准备、相机内参标定、数据采集设计、calibrateHandEye 的使用方式、还有最容易出错的坐标系统一问题。适合正在用 OpenCV 做机械臂抓取项目、被标定结果搞到头大的工程师和学生也适合想系统理解机械臂视觉坐标变换的入门者。1. 先弄清相机与机械臂的“视线关系”Eye-in-Hand 与 Eye-to-Hand1.1 两种安装方式的本质差异手眼标定这个词里的“手”和“眼”指的就是机械臂末端和相机。安装方式不同标定要解的目标也不同所以上来第一件事不是写代码而是确认你的相机装在哪儿。眼在手上Eye-in-Hand是最常见的一种相机固定在机械臂末端法兰上跟着机械臂一起移动。这种情况下相机和末端之间的相对位姿是固定的标定目标就是求解相机坐标系到机械臂末端坐标系的变换关系。因为相机跟着机械臂动它可以靠近物体观察细节还能绕到遮挡物侧面适合抓取、装配这类需要近距离视觉引导的场景。缺点是视野会随运动变化定位目标时要先把目标坐标换算到机械臂基座系。眼在手外Eye-to-Hand是另一种常见布局相机固定在工作空间上方或侧面不随机械臂运动。标定目标变成相机坐标系到机械臂基座坐标系的变换关系。这种方案适合固定工位、视觉引导上料、质检分拣只要相机视野能覆盖工作区就不存在“相机跟着动导致目标丢失”的问题。两种方案没有绝对优劣但要注意很多人以为眼在手外时机械臂可以完全不动实际上标定过程里机械臂仍然要动只是动的是标定板或末端执行器这一点到数据采集部分会详细说。项目眼在手上 Eye-in-Hand眼在手外 Eye-to-Hand相机安装位置机械臂末端工作空间固定位置标定目标相机到机械臂末端的变换相机到机械臂基座的变换运动时相机视野随机械臂移动固定不变典型应用移动抓取、装配、近距离检测固定工位分拣、上料、质检标定板位置固定在工作空间固定在机械臂末端1.2 标定在解什么AX XB手眼标定的数学本质是求解 AX XB 形式的矩阵方程。别被字母吓到用大白话解释机械臂从位姿 A1 运动到位姿 A2 时末端在基座坐标系下的变化量可以读出来这是 A同一段时间内相机观察固定标定板标定板在相机坐标系下的变化量可以通过图像算出这是 B。因为相机和末端刚性连接它们之间的变换 X 在这段运动前后是不变的所以 A 作用在 X 上必须等于 X 作用在 B 上也就是 AX XB。你可能会有个疑惑为什么一次运动不够非要采几十组数据因为一次运动只能列出一个方程X 里有旋转和平移共 12 个未知数理论上需要足够的约束才能解出来。实际中由于图像噪声和机械臂绝对定位误差的存在数据越多叠加的约束越强解越稳定。还要明确 OpenCV 里 calibrateHandEye 函数的输出到底是什么。它接收机械臂的末端位姿gripper2base和标定板在相机系下的位姿target2cam输出的是相机相对机械臂末端的变换cam2gripper。也就是说这个函数默认按眼在手上的场景设计。如果你做的是眼在手外等于把标定板固定在机械臂末端最后还需要一步坐标变换推导不能直接把输出当成 cam2base 来用。2. 标定前的准备OpenCV 环境、相机内参和标定板2.1 环境依赖与版本选择标定这件事用 Python 最省事一个环境配好后面全流程都能跑。我常用的是 Python 3.9 或 3.10配 OpenCV 4.x。安装时注意aruco 模块在 opencv-contrib-python 里只装 opencv-python 会找不到cv2.aruco。命令很简单pip install opencv-python opencv-contrib-python numpy如果你用的是 Realsense、工业相机还可能要装对应的 SDK但手眼标定核心计算只依赖 OpenCV相机 SDK 只负责取图。OpenCV 版本上4.5 以上都能正常使用 calibrateHandEye4.7 之后部分 aruco 位姿估计接口开始提示过时但短期内不影响使用。我建议尽量用 4.6 或 4.8 这类稳定版本避免太新带来的 API 变动。2.2 相机内参标定与畸变处理手眼标定看起来很酷但很多人忽略了一个前置任务相机内参和畸变系数必须准确。内参就是焦距、主点这些参数畸变系数描述镜头对图像的扭曲。如果内参不准你用图像估计标定板位姿target2cam时就已经带上了误差后面所有计算都会跟着偏。标定内参最经典的方法是张正友标定法OpenCV 里用cv2.calibrateCamera实现。你需要打印一张棋盘格从不同角度、不同距离拍 15 到 25 张照片然后用如下代码处理import cv2 import numpy as np CHECKERBOARD (9, 6) SQUARE_SIZE 0.03 # 每个格子的实际边长单位米 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) 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 img in calibration_images: gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, CHECKERBOARD, None) if ret: corners2 cv2.cornerSubPix(gray, corners, (5, 5), (-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 )这段代码有几个关键点。一是SQUARE_SIZE必须是标定板格子的真实边长不是图像里的像素长度单位建议统一成米否则内参里的焦距会换算错误。二是角点检测后要做亚像素细化cornerSubPix不然角点坐标精度不够内参标定结果会有明显偏差。三是拍照片时一定要有倾斜角度别只在正前方拍否则退化方向上的参数无法被约束。后面手眼标定估计标定板位姿时dist 畸变系数要不要传必须传。cv2.solvePnP这一步如果忽略 dist得到的旋转向量和平移向量会有系统性偏差这种偏差在视野边缘尤其大。2.3 标定目标的选择棋盘格还是 AprilTag手眼标定需要一个在图像里能稳定检测、并且能准确估计位姿的标定目标。常见选择是棋盘格、ArUco 标记、ChArUco 板。棋盘格的优势是角点检测稳定配合cornerSubPix能达到亚像素精度而且不需要担心方向模糊。缺点是必须整块棋盘都出现在视野里一旦有遮挡或大角度拍摄检测就可能失败。手眼标定的数据采集恰恰要求相机和标定板之间有较大相对旋转所以很多时候棋盘格反而不好用。ArUco 标记的优势是单个 marker 也能检测并估计位姿允许遮挡实时性好。缺点是单个 marker 的角点较少位姿估计噪声相对大。为了兼顾精度和灵活性我更推荐 ChArUco 板——它把棋盘格和 ArUco 编码结合在一起角点仍是棋盘格角点但每个角点有唯一 ID即使部分遮挡也能匹配出完整板面。选好标定板后打印和安装同样重要。普通 A4 纸打印容易翘边时间长还会变形建议打印后贴到平整的铝板或玻璃板上。打印时要设置“实际大小”不要在 PDF 打印时选“适应页面”导致尺寸缩放。因为后面cv2.aruco或cv2.solvePnP都需要你传入 marker 的真实边长一旦这个值和实际不符位姿平移量就会按比例缩放手眼标定结果直接崩。3. 手眼标定数据采集数据设计比跑代码更重要3.1 机械臂端读数与单位换算手眼标定需要机械臂末端在基座坐标系下的位姿这个数据通常从机械臂控制器或 SDK 接口里读。UR 机械臂有get_actual_tcp_poseJAKA 有一套自己的接口很多国产机械臂也都有类似函数。无论从哪个接口拿你拿到的可能是平移加欧拉角、四元数或者直接是齐次变换矩阵。拿到数据后第一件事是统一单位。OpenCV 的 solvePnP 估计出的 tvec 单位由你传入的标定板尺寸决定如果你用的标定板边长单位是米那么 tvec 单位就是米。机械臂这边很多 SDK 默认输出毫米比如有些机械臂的 TCP 位置是[x, y, z, rx, ry, rz]其中 xyz 单位是毫米。如果不转换成米calibrateHandEye 算出来的平移向量就会差 1000 倍抓取时目标点直接飞出工作空间。旋转量也要注意。机械臂返回的欧拉角有各种约定有的按 X-Y-Z 顺序旋转有的按 Z-Y-X有的是固定轴欧拉角有的是内在旋转。你看到[rx, ry, rz]就无脑转成旋转矩阵非常容易出问题。我的做法是先让机械臂动到一个已知姿态在示教器上读角度再和自己写的旋转顺序换算结果对比确认一致后再批量处理数据。这一步能省下后面一整天的排查时间。3.2 相机端坐标怎么读相机端的输入是标定板在相机坐标系下的位姿也就是 target2cam。通常做法是拍摄标定板图像检测角点或 ArUco 标记然后调用cv2.solvePnPretval, rvec, tvec cv2.solvePnP(obj_points, image_points, mtx, dist) R_target2cam, _ cv2.Rodrigues(rvec) t_target2cam tvec.reshape(3, 1)如果你用的是 ChArUco 板OpenCV 有cv2.aruco.estimatePoseCharucoBoard可以直接得到板面坐标系到相机坐标系的变换底层也是 solvePnP。需要记住的是tvec表示的是标定板原点在相机坐标系下的位置rvec表示标定板坐标系相对相机坐标系的旋转这个方向关系后面在坐标变换链里会反复用到。每个样本都必须保存成一条对齐记录同一时刻机械臂末端位姿、相机图像、检测出的 target2cam。有些机械臂 SDK 能提供实时位姿流你可以拍一张图的同时去读位姿如果没有实时接口也可以让机械臂走到某个位姿后暂停先记录位姿再拍图然后移动到下一位姿。暂停法的关键是确保机械臂完全停稳再拍运动过程中拍照肯定会引入模糊和位姿时刻不一致的问题。3.3 我常用的数据质量检查方法数据采集不是随便拍几十张就完事。我总结了几条硬性要求缺一条标定结果就可能飘样本数量至少 25 组我一般采 30 到 50 组。数量太少旋转约束不足解出来的 X 在某个方向上特别脆弱。运动幅度要大相邻位姿之间不能只有几毫米几度的差别尽量让机械臂末端做大范围转动和平移把相机视野的各个区域都覆盖到。姿态要多样不能只做平移必须包含充分旋转。纯平移样本对求解 AXXB 几乎是无效的会让方程退化。标定板在图像里的位置和角度要分散。如果标定板永远在画面中央得到的 target2cam 位姿分布太集中外参解会过度拟合中间区域。采集完之后我会先在相机坐标系下把标定板的位姿可视化出来看看 30 个点是不是覆盖了视野的不同深度和角度。如果所有 tvec 都挤在一起那就说明机械臂动得不够重新补数据比继续跑代码有意义得多。另外建议把原始数据落盘保存为 numpy 或 CSV 文件不要每次都重新采集。这样后面用不同算法方法求解、或者发现某个样本异常时能快速重跑节省大量重复劳动。4. 用 OpenCV 求解手眼矩阵calibrateHandEye 的正确打开方式4.1 参数说明与算法选型OpenCV 提供cv2.calibrateHandEye输入是机械臂末端位姿和标定板位姿输出是相机到机械臂末端的变换。Python 接口签名大致是cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, methodcv2.CALIB_HAND_EYE_PARK )其中R_gripper2base是所有样本的旋转矩阵列表t_gripper2base是对应平移向量列表同理R_target2cam和t_target2cam是标定板相对相机的旋转和平移列表。旋转矩阵可以是 3x3 矩阵列表也可以是旋转向量列表OpenCV 内部会自动处理。算法 method 有几种可选Tsai、Park、Horaud、Andreff、Daniilidis。它们的数学思路不太一样Tsai 是经典两步法先求旋转再求平移对噪声敏感Park 通过李代数或四元数求解通常比较稳定Horaud 和 Park 类似Andreff 和 Daniilidis 基于非线性优化理论上更精确但初值敏感。我自己的习惯是先用 Park 算一版再用 Daniilidis 算一版对比两组解的差异。如果两个方法的结果明显不一致说明采集数据有问题而不是算法不行。4.2 完整代码骨架下面这段代码是我项目里常用的骨架省略了具体的相机取流只保留核心标定逻辑import cv2 import numpy as np R_gripper2base [] t_gripper2base [] R_target2cam [] t_target2cam [] # 对已经采集好的每组数据做解析 for sample in dataset: # 机械臂末端相对基座的位姿 R_g2b sample[R_gripper2base] t_g2b sample[t_gripper2base] R_gripper2base.append(R_g2b) t_gripper2base.append(t_g2b) # 标定板相对相机的位姿来自 solvePnP R_t2c sample[R_target2cam] t_t2c sample[t_target2cam] R_target2cam.append(R_t2c) t_target2cam.append(t_t2c) R_cam2gripper, t_cam2gripper cv2.calibrateHandEye( R_gripper2base, t_gripper2base, R_target2cam, t_target2cam, methodcv2.CALIB_HAND_EYE_PARK ) print(R_cam2gripper:\n, R_cam2gripper) print(t_cam2gripper:\n, t_cam2gripper)代码本身不长难的是输入数据的一致性。我再强调一次t_gripper2base和t_target2cam的单位必须保持一致旋转矩阵必须来自同一个坐标系约定否则这个函数再强大也救不了你。另外一个容易踩的点是t_gripper2base如果是从欧拉角转换来的一定要先验证机械臂的 RPY 顺序这部分在前面已经提过。4.3 结果怎么判断好坏验证必须闭环很多人的标定流程到输出R_cam2gripper就结束了这是最危险的做法。标定结果看起来有模有样但到底准不准必须做闭环验证。我常用的验证方法是利用“固定点不动”的性质。以眼在手上为例如果标定板固定在工作空间里不动那么无论机械臂怎么动标定板在机械臂基座坐标系下的位姿应该保持不变。写成变换链就是T_target2base T_gripper2base · T_cam2gripper · T_target2cam对每一组数据都算一次T_target2base理论上这几十个结果应该高度一致。如果它们差异很大说明标定有问题或数据有问题。这个验证方法简单有效比单看重投影误差可靠得多因为它覆盖了机械臂读数、相机位姿估计和手眼矩阵三个环节的综合误差。还可以做一个抓取验证。找一个静止目标通过相机识别得到目标在相机坐标系下的坐标使用标定结果把它换算到机械臂基座坐标系然后让机械臂移动到该点。第一次抓偏很正常但偏差方向和大小能帮你判断问题出在标定还是 TCP。偏差呈固定偏移多半是 TCP 没标偏差随机械臂姿态变化多半是手眼标定外参不对。5. 踩坑记录从标定精度差到抓取偏移的完整排查链路5.1 第一轮排查以为是代码问题结果是坐标系约定不一致有次我在某个六轴机械臂上做眼在手上标定calibrateHandEye 跑出来的旋转矩阵看起来正常但平移向量达到几百毫米明显超出实际安装范围。一开始我以为是采集数据太少补了一轮还是这样又怀疑是 solvePnP 的输入点顺序错了反复检查也没发现毛病。后来把机械臂的末端位姿单独拿出来和一个已知姿态对比才发现问题出在欧拉角转换上。机械臂 SDK 返回的旋转顺序和我的转换函数假设的不一致直接导致旋转矩阵错误。这种错误在 AXXB 方程里不会被立刻暴露但会让结果莫名其貌地飞。排查链路是单样本独立检查别只看标定输出。把某一组数据的旋转矩阵和平移向量拆出来和示教器上读到的姿态比对逐项核对通常能很快锁定问题。5.2 第二轮排查样本“看着很多”其实退化又一次标定结果每次运行都不一样换算法 method 结果也差别很大。我重新看了采集过程发现机械臂虽然移动了 30 多个位姿但基本都是沿某个固定方向平移旋转幅度很小。这种数据放到 AXXB 里旋转部分的约束严重不足方程近似退化解自然不稳定。解决办法是重新设计采样轨迹让机械臂末端在旋转量上做文章。我常用的策略是固定标定板在视野内让机械臂带着相机走立方体顶点每个顶点上保持多种姿态或者沿球面轨迹运动让相机光轴指向不同方向。这样旋转自由度才能被充分激发。采集完顺手算一下所有旋转矩阵序列的条件数如果条件数很大就能预判标定结果不可信。5.3 第三轮排查畸变没校正中心准边缘歪标定结果在视野中心区域抓取精度还行一到视野边缘就偏得离谱。我第一反应是外参不准重新标定了好几次都一样。后来仔细看 solvePnP 调用发现我居然没把相机畸变系数传进去等于让算法继续使用未校正的像素坐标去估计标定板位姿。镜头畸变在中长焦和广角镜头上都很明显尤其是画面边缘。解决方式有两个要么在估计 target2cam 前先对图像去畸变cv2.undistort要么在solvePnP和estimatePoseCharucoBoard里传入 dist 参数。我更推荐后者因为去畸变会插值重采样有时会引入额外误差。传了畸变系数后同一批数据重新标定边缘位置的抓取误差立刻小了很多。5.4 第四轮排查标定板物理尺寸不对误差全部按比例缩放还有一种很隐蔽的情况标定结果在验证时 T_target2base 比较稳定但实际抓取还是偏且偏差方向和大小基本固定。这个组合暗示手眼矩阵的旋转部分可能没问题但平移比例不对。一查发现打印 ArUco 板时软件默认缩放了页面实际打印出来的 marker 边长和代码里填的不一致。我当时用的办法是用卡尺量出标定板实际边长然后重新估计全部 target2cam。你可以直接把 marker 的真实边长作为参数重新跑一轮不用重新采集图像。这个坑尤其容易出现在从网上下载 PDF 标定板的人身上所以我现在都是自己用 OpenCV 生成标定板图片并在代码里记录实际打印尺寸。6. 从标定到抓取坐标变换链才是最终目的6.1 把标定结果串成一条变换链手眼标定不是终点它服务的是最终坐标变换链。眼在手上的完整链路是目标在相机系坐标 → 相机到末端标定结果→ 末端到基座机械臂实时位姿→ 基座下坐标用矩阵表示就是T_target2base T_gripper2base · T_cam2gripper · T_target2cam这里每一步都不要再单独手动转换最好封装成一个函数。实际做抓取时目标还会相对相机移动所以要用当前帧的 target2cam 和当前时刻的 gripper2base再乘上标定得到的 cam2gripper才能得到目标在机械臂基座下的坐标。很多项目里相机安装好、手眼标定做完后又把相机拆下来重新装这会导致原来的 cam2gripper 或 cam2base 立刻失效必须重新标定这个坑我已经踩过不只一次。6.2 关于相机选型和深度相机的特别提醒现在很多人用 Realsense D435i 这类 RGB-D 相机做机械臂抓取。深度相机可以直接给目标的三维点云省去单目位姿估计的一些步骤但手眼标定的逻辑完全相同。使用深度相机时我建议先让深度图对齐到彩色图再用彩色相机内参做手眼标定最后在抓取阶段直接用对齐后的深度值作为目标在相机坐标系下的 z 坐标这样可以少处理一套坐标系。也有人问相机内参和手眼标定每个项目都要重新做吗不一定。相机内参只要镜头和分辨率不变可以长期复用但手眼标定只要相机相对机械臂的安装位置变了就要重做。如果项目里相机和机械臂相对位置很稳定可以把标定结果保存到配置文件里长期复用但只要动过相机支架、换过相机、换过机械臂末端法兰都应该重新标定。我个人的经验是手眼标定这个环节不必追求一次跑出完美结果。先快速采一批数据跑通全流程再做验证根据验证结果判断是数据问题、坐标系问题还是参数问题然后有针对性地重新采集。保存好原始数据、统一好单位、多试几种求解方法做交叉验证比你对着参数调一整天有用得多。最后一个小技巧标定完成后把 R_cam2gripper 和 t_cam2gripper 打印出来做一个简单的随机位姿仿真验证如果机械臂转一个大角度后目标点还能对上那这个标定才真正能上产线。