做机械臂视觉抓取的时候,最磨人的往往不是算法本身,而是坐标系。相机兴致勃勃地报了一个坐标(523, 411),机械臂一脸茫然——这是像素坐标?还是某个平面上的物理坐标?这个坐标是相对于谁的?我第一次做视觉抓取项目时,就是在这个环节卡了将近一周,后来把原理彻底理清了才发现,所谓"手眼协同"的真正门槛,不在识别算法,而在坐标变换。
这篇是机械臂视觉系列的第一篇,核心任务就两个:把摄像机真正接入机械臂系统,以及打通世界坐标与像素坐标之间的双向转换通道。我会把这套东西从相机模型、坐标系定义、标定实操到换算代码一步步拆开讲,适合正在入门机械臂视觉、或者已经跑通了图像识别但卡在坐标换算上的开发者参考。
1. 项目背景与整体架构:视觉系统在机械臂项目里到底扮演什么角色
1.1 先搞清楚这套视觉系统解决什么问题
机械臂本身是"盲"的。它只知道自己各个关节的角度,通过运动学可以算出末端执行器在机器人基坐标系下的位置,但完全不知道目标物体在哪。传统方案是示教或者离线编程,目标位置固定不变,手臂按预设轨迹走就行。一旦目标位置会变——流水线上来料位置有偏差、抓取对象是传送带上随机到达的工件——纯靠示教就不成立了。
这时候需要给机械臂配上视觉,让它"看见"目标在哪里。
但视觉给出来的信息,本质上是一张二维图像上某个目标的像素位置。机械臂需要的是一个三维或至少是某个已知平面上的物理坐标。这个"翻译"过程,就是本篇要解决的坐标互转问题。
一个典型的视觉引导抓取流程是这样的:
- 相机采集图像
- 算法识别目标,得到目标在像素坐标系下的位置(u, v)
- 通过相机标定(内参)和手眼标定(外参),把像素坐标换算成机械臂基坐标系下的世界坐标
- 机械臂运动学逆解,规划路径,抓取
可以看到,相机标定解决"像素坐标和相机坐标"的关系,手眼标定解决"相机坐标和机械臂世界坐标"的关系,两者叠加就是完整的坐标链路。本篇先解决第 1、3 步中的坐标基础部分,手眼标定后续单独开篇。
1.2 整条坐标链路:从像素到世界的五次变换
在做任何视觉抓取之前,脑子里必须有一张坐标变换的全景图。机械臂视觉涉及的坐标系,从上到下依次是:
| 坐标系 | 含义 | 典型单位 |
|---|---|---|
| 像素坐标系 | 图像数组的行列位置,原点在图像左上角 | pixel |
| 图像坐标系 | 以光轴与图像平面交点为原点的物理坐标 | mm |
| 相机坐标系 | 以相机光心为原点,Z 轴沿光轴向外 | mm |
| 机械臂基坐标系(世界坐标系) | 机械臂底座固定原点 | mm |
如果你用的是眼在手上(相机装在机械臂末端),中间还会多一个机械臂末端坐标系。本篇以眼在手外(相机固定安装)为主,链路是:
像素坐标 →(内参逆变换)→ 相机坐标 →(外参变换)→ 世界坐标
这个链路里最迷惑人的一点是:像素坐标是二维的,世界坐标是三维的,从二维反推三维,数学上信息不足。所以在做像素到世界的换算时,必须给一个约束条件——最常见的做法是"目标放在已知高度的平面上",也就是 Z=0 平面假设,或者用测距传感器给出深度。
2. 摄像机选型与安装方式:不要一上来就买最贵的工业相机
2.1 相机参数怎么选:分辨率、帧率、镜头、接口
很多新手一上来就纠结"用海康还是用巴斯勒",结果连自己需要的分辨率都没算清楚。相机选型的核心依据是:你要在多远的距离上看多大的视野,而这个视野里又需要看清多小的目标。
选分辨率的时候,可以用一个简单公式估算:
- 假设工作距离是 500mm,视野宽度是 400mm,目标最小特征尺寸是 0.5mm
- 那么需要的像素至少是 400 / 0.5 = 800 pixel,加上检测算法的稳定性考虑,留 3~5 倍余量,选 500 万像素级别的相机比较稳妥
帧率这块,如果目标是静止的(比如定位一个放在固定位置的工件),30fps 足够。如果目标在传送带上运动,至少 60fps 起步,还需要考虑曝光会不会拖影。
镜头方面,固定视野的应用选定焦镜头就行,根据工作距离和视野宽度算焦距:
( f = \frac{\text{传感器宽度} \times \text{工作距离}}{\text{视野宽度}} )
比如传感器宽度 6.4mm(1/2 英寸级别),工作距离 500mm,视野宽度 400mm,那么焦距大约是 8mm。这个公式很实用,可以避免买了镜头发现视野不对的尴尬。
接口趋势上,USB3.0 接口相机性价比高、免采集卡,是入门首选。GigE 接口适合需要长距离布线、多相机同步的场景。我自己做实验选的就是 USB3.0 工业相机,驱动稳定,OpenCV 里直接 VideoCapture 就能读取,省掉一堆 SDK 对接的麻烦。
2.2 眼在手外 vs 眼在手上:安装方式直接决定你的标定方案
相机装在机械臂外面固定不动,叫眼在手外(固定安装);相机装在机械臂末端跟随手臂移动,叫眼在手上。两种方案各有适用场景:
眼在手外的优势是标定一次后长期有效,只要相机没有被碰撞移动,坐标变换关系基本不变。缺点是视野固定,机械臂活动范围不能超出相机视野,而且存在目标被机械臂本体遮挡的可能。
眼在手上的优势是灵活性大,机械臂走到哪看到哪,适合大范围作业、目标分布在多个区域的情况。代价是每次机械臂姿态变化,相机坐标系和世界坐标系的变换关系都在变,标定复杂得多,对运动学精度要求也高。
对于新手入门,我的建议是从眼在手外开始。一是标定流程直观,二是调试时人能看清相机视野,三是出了问题排查链路短。本篇代码也是按眼在手外的场景写的。
安装的时候有几个细节:相机支架要足够刚性,不能用那种细长的柔性支架,轻微的振动就会造成坐标漂移;相机平面尽量垂直于工作台平面;用螺丝固定好后,在支架上做个标记,万一被碰到能及时发现。
2.3 标定板的选择与打印技巧
本文的标定方案用棋盘格标定板。如果你手头没有现成的标定板,可以自己打印,但有几个注意点:
- 棋盘格要打印在高精度打印机上,普通喷墨打印出来的格子边缘模糊,角点检测精度会很差
- 打印后用卡尺量一下每个格子实际的边长,不要直接信打印软件里填的尺寸值
- 标定板贴在平整的刚性背板上,不能贴在软板上,否则拍摄时板面弯曲会严重影响标定精度
- 格子数量选择:9x6(内部角点 8x5)以上都可以,格子数量太少会导致畸变估计不稳定
我在项目中常用的是 9x7 内角点、格边长 25mm 的棋盘格,工作距离 400~600mm 的情况下,标定残差能稳定在 0.1~0.2 像素左右。
3. 针孔成像模型与五个坐标系:像素坐标到底是怎么来的
3.1 针孔模型:用一个"暗箱"理解相机成像
相机的成像原理可以简化为一个针孔模型。想象一个不透光的盒子,一侧开一个小孔,另一侧是感光平面。外面的物体通过小孔在感光平面上成一个倒像。焦距就是小孔到感光平面的距离。
这个模型的数学形式非常简单:从相机坐标系下的一点 ( (X_c, Y_c, Z_c) ),投影到图像平面上的坐标是:
( x = f \cdot \frac{X_c}{Z_c}, \quad y = f \cdot \frac{Y_c}{Z_c} )
注意这里有一个重要推论:同一个物体,离相机越远,成像越小(Z_c 越大,x 越小),但它在图像中被观测到的方向是不变的。这个"方向不变"的特性,就是后面做坐标换算的数学基础。
3.2 内参矩阵:像素坐标和相机坐标之间的桥梁
上面公式给的是图像物理坐标,要变成像素坐标,还要考虑两个问题:像素在感光元件上的物理尺寸(dx, dy),以及像素坐标系原点和光轴投影点之间的偏移(cx, cy)。
综合起来,相机坐标系到像素坐标系的齐次坐标变换是:
[ s \begin{bmatrix} u \ v \ 1 \end{bmatrix} = \begin{bmatrix} f_x & 0 & c_x \ 0 & f_y & c_y \ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} X_c \ Y_c \ Z_c \end{bmatrix} ]
其中 ( f_x = f/dx ),( f_y = f/dy )。这个 3x3 矩阵就是内参矩阵。
这里的几个参数理解起来不要死记:fx、fy 是焦距换算成像素单位后的值,如果感光元件的像素是正方形(dx=dy),那 fx 和 fy 应该很接近;cx、cy 理论上等于图像中心,但实际上由于镜头安装误差,会和图像中心有几十像素的偏差。标定完看到 cx 不是正好在图像中心,不要太惊讶,这是正常现象。
内参矩阵描述了相机自身的性质,不随相机移动而改变。它刻画的是"相机坐标系中的一个点,在图像上会落在哪个像素"这个映射关系。标定相机,主要就是标定这几个参数。
3.3 畸变:为什么直线在图像里会变弯
真实的镜头不是完美的针孔,光线在通过镜头时会发生折射,导致图像出现畸变。两种主要畸变:
径向畸变:离图像中心越远,畸变越明显,直线会变成向内或向外的曲线。桶形畸变是向外膨胀,枕形畸变是向内收缩。用三个参数 k1、k2、k3 来描述,其中 k3 在鱼眼镜头、大广角镜头里必须估计,普通镜头可以置 0。
切向畸变:由于镜头和感光元件不完全平行导致,用两个参数 p1、p2 描述。对普通工业镜头,切向畸变通常很小,但还是建议标出来。
畸变模型在 OpenCV 里是一个 5 参数向量:( (k_1, k_2, p_1, p_2, k_3) )。标定完成后,可以调用 cv2.undistort 对图像做畸变校正,也可以在校正后的图像上做目标检测,这两种做法在精度要求高的场景里差异不小。我的一般做法是先畸变校正再检测,因为后续坐标换算公式是基于无畸变针孔模型推导的,直接用在畸变图像上会有误差。
3.4 外参:相机坐标系与世界坐标系的刚性变换
内参定义了相机坐标系内部的事,但相机放在空间哪个位置、朝哪个方向,需要用外参来描述。外参是一个旋转矩阵 R 和一个平移向量 t:
[ P_c = R P_w + t ]
这里 P_w 是某个点在世界坐标系(通常约定为机械臂基坐标系或工作台平面坐标系)下的齐次坐标,P_c 是同一个点在相机坐标系下的坐标。R 是 3x3 的旋转矩阵,t 是 3x1 的平移向量。合起来是 4x4 的齐次变换矩阵:
[ T = \begin{bmatrix} R & t \ 0 & 1 \end{bmatrix} ]
这个矩阵就是世界坐标和相机坐标之间的桥梁。机械臂视觉里,如果目标在机械臂的基坐标系下定位,世界坐标系就直接取机械臂基坐标系;如果只关心目标在某个平面上的位置,也可以把世界坐标系定义在平面角点上。但为了让抓取指令可以直接下发,我建议世界坐标系直接和机械臂基坐标系重合,后续换算出来就是臂能直接用的坐标。
4. 摄像机标定实操:棋盘格拍摄与 OpenCV 标定
4.1 标定原理和拍摄规范
棋盘格标定的思路是:我们已知棋盘格每个角点在标定板坐标系下的物理坐标,又可以在图像里检测到这些角点的像素坐标,然后通过一组对应点,求解内参和外参。
关键是如何让求解结果精确。这里必须理解一个前提:单个角点的检测精度受限于图像质量,但多个角点的大规模对应关系可以把误差平均掉。所以拍摄时需要注意:
- 拍 15~20 张不同角度的棋盘格图像,包括正面、俯仰角、左右偏转、旋转
- 每张图中棋盘格占画面面积建议超过 1/3,太小了角点检测不稳定
- 光照均匀,避免棋盘格上出现高光反射或阴影
- 拍摄过程中不要移动相机,移动棋盘格
- 棋盘格平面和相机光轴的夹角要有变化,全拍正面无法估计畸变和焦距的耦合
4.2 标定代码:从图像采集到内参输出
下面是基于 OpenCV 的标定核心代码,采集图像和标定可以在一个脚本里完成:
import cv2 import numpy as np import glob # 棋盘格内角点数(行,列) CHECKERBOARD = (8, 6) # 每个格子的实际边长,单位 mm SQUARE_SIZE = 25.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 objpoints = [] # 物理坐标 imgpoints = [] # 像素坐标 images = glob.glob('calib_images/*.jpg') for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, None) if ret: # 亚像素角点精化 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners2) cv2.drawChessboardCorners(img, CHECKERBOARD, corners2, ret) cv2.imshow('corners', img) cv2.waitKey(200) cv2.destroyAllWindows() ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None) print("内参矩阵:\n", mtx) print("畸变系数:\n", dist) print("重投影误差:", ret)标定完成后,把内参矩阵和畸变系数保存成 npz 文件,后续每次启动系统直接加载,不用重新标定。
4.3 重投影误差:标定质量怎么判断
OpenCV 返回的 ret 就是重投影误差,单位是像素。这个值的意思是:用标定得到的内外参,把已知物理角点投影回图像平面,和实际检测到的像素坐标之间平均差了多远。
经验值:
- 小于 0.1 像素:非常优秀
- 0.1~0.3 像素:正常水平
- 大于 0.5 像素:需要检查标定图片质量、角点检测是否错位、是否用了弯曲的打印板
如果误差偏大,先排查数据而不是改代码。我遇到过一次误差突然从 0.15 跳到 0.6,排查了半天发现是标定板没贴平整,中间有一道弯曲的折痕。打印板的平整度对结果的影响,远大于算法参数调整的影响。
另外补充一个细节:内参矩阵的 fx、fy 理论上和相机出厂标称焦距焦距一致,可以拿这个去验证标定结果是否合理。如果标定出的焦距和镜头规格书上差了 20% 以上,大概率是拍摄数据有问题。
5. 世界坐标与像素坐标的互转:公式推导与 OpenCV 实现
5.1 世界坐标到像素坐标:正向投影
正向投影是从 3D 世界到 2D 图像,信息是完整的。流程分两步:
第一步,外参变换:世界坐标点 ( P_w ) 左乘外参矩阵,得到相机坐标 ( P_c )。
[ \begin{bmatrix} X_c \ Y_c \ Z_c \ 1 \end{bmatrix} = \begin{bmatrix} R & t \ 0 & 1 \end{bmatrix} \begin{bmatrix} X_w \ Y_w \ Z_w \ 1 \end{bmatrix} ]
第二步,内参投影:相机坐标除以 Z_c 后左乘内参矩阵,得到像素坐标(注意还要代入畸变模型做修正,或者直接用校正后的像素坐标计算)。
OpenCV 提供了 cv2.projectPoints 函数,可以直接完成这一步:
import numpy as np import cv2 # 世界坐标点(齐次形式) world_point = np.array([[100.0, 50.0, 0.0]], dtype=np.float64) # 用 solvePnP 或标定的结果得到 rvec, tvec(外参) rvec = np.array([[0.02], [0.1], [-0.05]], dtype=np.float64) tvec = np.array([[100.0], [-50.0], [600.0]], dtype=np.float64) # 正投影:世界坐标 -> 像素坐标 img_points, _ = cv2.projectPoints(world_point, rvec, tvec, mtx, dist) print("像素坐标:", img_points.reshape(-1, 2))注意 rvec 是旋转向量,使用 Rodrigues 变换可以转成旋转矩阵,二者等价,OpenCV 内部会自动处理。
5.2 像素坐标到世界坐标:反投影的深度约束问题
这才是整个系列里最容易翻车的地方。像素坐标只有两个值,而世界坐标有三个自由度(X, Y, Z),从 2D 反推 3D 在数学上就是一个欠定问题。
它的物理含义是:图像上的一个像素,对应相机光心到该像素连线上的一大串空间点,你不知道目标到底在这条线的哪个深度位置上。
实际工程里通常用两种方式补上缺失的深度信息:
- 已知目标在某个平面上。比如目标放在工作台面上,我们约定世界坐标系的 Z_w = 0 平面就是工作台面。此时有数学约束,可以唯一求解 X_w 和 Y_w。
- 用深度相机(结构光、双目、ToF)直接测出深度。有真实深度后再反投影。
机械臂视觉抓取里,最常用的是第一种——目标放在平面上。求解方法有两种:用 solvePnP 求解单应变换,或者直接构造平面单应矩阵。
5.3 平面假设下的像素到世界坐标求解
当目标在世界坐标系的 Z_w = 0 平面上时,正向投影公式可以简化为一个 3x3 的单应矩阵 H:
[ s \begin{bmatrix} u \ v \ 1 \end{bmatrix} = H \begin{bmatrix} X_w \ Y_w \ 1 \end{bmatrix} ]
H 是内参矩阵、外参旋转矩阵的第一列、第二列、平移向量组合而成的。有了 H,就可以直接从像素坐标算出 (X_w, Y_w),非常高效。
求解 H 最简单的方式是利用棋盘格角点对应关系,调用 cv2.findHomography:
# 用棋盘格角点建立像素到世界平面对应关系 # src_points: 图像上的角点像素坐标 # dst_points: 棋盘格在平面上的物理坐标(注意去掉 Z 轴) H, status = cv2.findHomography(src_points, dst_points) # 从像素坐标反解世界坐标 pixel_point = np.array([[u, v]], dtype=np.float32).reshape(-1, 1, 2) world_point = cv2.perspectiveTransform(pixel_point, H) print("世界坐标 (Xw, Yw):", world_point.reshape(-1, 2))用 findHomography 的好处是:它直接拟合了像素坐标系到平面世界坐标系的完整变换,不需要单独拆解内外参。缺点是 H 只在固定平面上有效,一旦目标不在这个平面上,结果就会偏移。所以使用场景要明确:目标必须贴着平面或至少距离可控。
如果你的目标需要在空间里定位(不限于平面),那么就要完整使用相机标定 + 手眼标定的链路,后续系列文章会专门讲。
5.4 完整坐标变换的罗德里格斯转换和代码封装
实际项目中建议把坐标互转封装成工具函数,避免每次都要重新拼公式。这里给一个封装思路:
class CoordinateTransformer: def __init__(self, mtx, dist, rvec, tvec): self.mtx = mtx self.dist = dist self.rvec = rvec self.tvec = tvec self.R, _ = cv2.Rodrigues(rvec) # 旋转向量转旋转矩阵 def world_to_pixel(self, world_points): img_pts, _ = cv2.projectPoints( world_points, self.rvec, self.tvec, self.mtx, self.dist) return img_pts.reshape(-1, 2) def pixel_to_world_on_plane(self, pixel_points, z_w=0.0): # 用逆投影射线和平面的交点求解 # 第一步:像素坐标 -> 归一化相机坐标 pts = np.array(pixel_points, dtype=np.float64).reshape(-1, 1, 2) undist_pts = cv2.undistortPoints(pts, self.mtx, self.dist) # 归一化相机坐标 norm_pts = undist_pts.reshape(-1, 2) # 第二步:射线方向在相机坐标系下为 (x_n, y_n, 1) # 第三步:用外参把相机坐标系下的射线变换到世界坐标系 # 再与世界坐标系的 z=z_w 平面求交点 ray_cam = np.hstack([norm_pts, np.ones((len(norm_pts), 1))]) # R 的逆变换到世界坐标系 ray_world = (self.R.T @ ray_cam.T).T # 方向向量,忽略平移 tvec_world = -self.R.T @ self.tvec.reshape(3) # 相机原点在世界坐标下的位置 results = [] for ray, origin in zip(ray_world, [tvec_world] * len(ray_world)): if abs(ray[2]) < 1e-8: results.append([np.nan, np.nan]) continue s = (z_w - origin[2]) / ray[2] x = origin[0] + s * ray[0] y = origin[1] + s * ray[1] results.append([x, y]) return np.array(results)这里我用了射线求交的方法来反投影:先把像素点反畸变得到归一化相机坐标,构成相机坐标系下的一条射线方向,再把射线变换到世界坐标系,最后和已知高度平面求交点。这个方法比直接拟合单应矩阵更一般化,即使外参有变化(比如你真的做了手眼标定),也能正确工作。
写代码时容易出错的一个点:dist 参数在 cv2.undistortPoints 里是可以传入的,它会在反投影前先去除畸变影响。如果你之前对图像做的是全图校正(undistort),那这里传 dist 就是多余的。两种方式别混用,否则会有二次校正问题。
6. 实测验证与常见问题排查:精度上不去别急着改算法
6.1 坐标转换精度验证流程
整套系统搭好之后,最重要的就是量化验证。不要只在仿真里跑,必须做实物验证。我的做法是这样:
- 在工作台上放一个棋盘格标定板,标定板平面的高度就是 Z=0 平面
- 在标定板上选几个已知物理坐标的角点(比如两个角点之间距离是精确的 25mm 倍数)
- 用相机识别某个角点的像素坐标,通过坐标转换得到世界坐标
- 用游标卡尺或者机械臂末端示教的方式实测该角点的真实世界坐标
- 计算两者之间的误差
验证结果应该包含误差的分布情况,而不只是一个平均值。通常我会分别记录 X 方向和 Y 方向的误差,以及不同图像位置的误差。如果误差在图像边缘明显大于中心,多半是畸变校正不充分或内参标定精度不够;如果整体都有一个固定偏移,多半是外参或者手眼标定有问题。
好的系统,在 500mm 左右工作距离下,平面定位误差应该能控制到 1~2mm 以内。如果超过这个范围,先按下面的排查清单走一遍,通常能找到原因。
6.2 精度问题排查清单
我踩过的坑不少,整理一个排查顺序,按照从高概率到低概率排列:
| 表现 | 排查方向 | 典型原因 |
|---|---|---|
| 整体固定偏移 | 外参、手眼标定 | 标定板平面和实际工作台面有高度差 |
| 边缘误差大 | 畸变标定 | 畸变图拍摄角度覆盖不够,k1/k2 估计不准 |
| 误差随机波动 | 机械臂运动学 | 机械臂零位不准,末端实际位置和理论位置偏差大 |
| 图像检测坐标抖动 | 光照与图像质量 | 曝光过长、目标反光、特征检测不稳定 |
| 误差随目标位置变化 | 单应矩阵平面假设失效 | 目标不在标定平面上,或台面本身不平 |
一个特别容易被忽视的问题:标定板如果是在木桌面上拍的,换了一块平板玻璃或钢板后,Z=0 平面的位置其实变了,但外参没变——结果就是整体坐标都偏了一个固定量。所以标定之后,工作台面、相机、标定板的相对关系就必须固定下来,任何改动都要重新标定。
6.3 相机和机械臂的坐标系对齐技巧
很多项目里,相机的视野和工作区域不在同一个位置,坐标转换完还要做一步平移变换。但更方便的做法是:直接把标定板放在机械臂的工作平面上,把标定板坐标系作为世界坐标系。这样标定的世界坐标可以直接对应到工作台坐标,不用再额外定义。
如果你用的是机械臂自带的示教器,可以用"三点示教法"定义一个用户坐标系:在台面上示教原点、X 方向点、Y 方向点,然后把这个用户坐标系输出给视觉系统,让视觉输出的坐标直接就是这个坐标系下的值。这样后期调试省很多事。
6.4 一个典型问题的完整排查过程
举一个我实际遇到的案例。某次做抓取测试,视觉报出来的目标位置,机械臂始终抓偏,误差大约 15mm 左右,而且是固定方向。第一反应是外参标定错了,于是重新做了手眼标定,但误差还在。
后来把标定板放在台面上,用机械臂末端去碰标定板的某一个角点,读出机械臂示教的坐标,再和视觉计算的坐标对比,发现差异主要出在 X 方向。进一步检查发现,我用来做外参标定的标定板是放在台上的,但实际抓取时目标放在一个透明亚克力板上,亚克力板有 3mm 厚度,等于 Z=0 平面整体抬高 3mm,而视觉只按原来的平面计算,误差就这样来了。
这个案例想说明的是:坐标转换的精度问题,很多时候不能只盯着算法,要回到物理世界里去找原因。桌面不平、垫高了一块板、目标本身有厚度,都会带来系统误差。
7. 手眼标定的衔接思路:眼在手外和眼在手上的下一步扩展
7.1 手眼标定解决的问题
前面讲的坐标互转,前提是外参已知。但实际工程项目里,相机装到支架上之后,没有人能手动测出相机相对于机械臂基座的精确位置和姿态,必须通过标定来求解。这个求解过程就是手眼标定。
手眼标定的输入是:机械臂末端在基坐标系下的位姿,以及相机观测到的某个固定标定板在相机坐标系下的位姿。通过对多组数据的计算,求解出手眼变换矩阵。常见的解法是 AX = XB 方程,OpenCV 提供了 cv2.calibrateHandEye 函数。
7.2 眼在手外和眼在手上标定的区别
眼在手外时,标定目标是求解"相机相对于机械臂基座"的变换关系,也就是 camera-to-base。这个矩阵相对固定,只要相机不动、机械臂底座不动,它就是常量。
眼在手上时,需要求解的是"相机相对于机械臂末端"的变换关系,也就是 camera-to-tool。虽然机械臂运动时相机位置变化,但这个变换本身是固定的。求解时一般让机械臂带着相机移动到多个不同姿态,观测固定标定板,利用多组运动学数据和观测数据解方程。
两种模式虽然方程形式类似,但数据采集策略不同。眼在手外采集数据时,要保证标定板充分覆盖相机的视野范围;眼在手上时,要保证机械臂的多个姿态变化足够大,不能只在一个狭窄范围内取数据。
7.3 手眼标定后的坐标链路
完成手眼标定后,像素到世界坐标的完整链路就闭合了:
- 像素坐标 → 相机坐标(内参逆变换)
- 相机坐标 → 机械臂基坐标(手眼标定的外参)
- 机械臂基坐标 → 目标抓取位置
这套链路可以在任意三维位置定位目标,不再局限于平面假设。不过平面假设在绝大多数桌面级抓取场景里已经够用了,所以本篇先掌握单应变换的思路,后续再接手眼标定。
7.4 给新手的学习路径建议
如果你正在入门机械臂视觉,我建议的学习顺序是:
- 先在本篇基础上,搭一个"相机 + 棋盘格 + 固定工作台"的最小系统,真正跑通一次像素坐标到世界坐标的互转
- 用标定板做一个精度验证实验,记录不同位置的误差,理解误差来源
- 再去看手眼标定,把外参从手动测量换成算法求解
- 最后加入目标识别,用一个简单的色块检测或轮廓检测代替人工选点,完成一个完整的视觉抓取 Demo
每一步都建立在前一步的物理直觉上,比直接啃一堆公式要快得多。我见过太多人一上来就想做"全自动视觉抓取",结果坐标链路没打通,识别再准也是白搭——手臂永远抓不到东西。
8. 几个实操中的经验总结(这次是真的踩过坑)
最后再分享几个我在实际调试中积累的细节经验,这些不一定写在文档里,但非常影响落地效果。
第一个经验:标定完成后立刻用数据固化现场。把内参矩阵、畸变系数、外参、单应矩阵全部保存下来,并且在工程目录里记录标定日期、相机位置照片、工作台高度。这样下次系统异常时,能快速判断是硬件被碰了还是代码改了。
第二个经验:检测算法尽量在无畸变图上做。虽然畸变校正会引入一点点插值误差,但坐标换算公式是基于针孔模型的,无畸变图能让整个链路保持自洽。我曾经试过在原始畸变图上检测目标,然后强行用畸变内参做坐标换算,结果边缘位置的误差非常不稳定,后来统一改成先校正再检测,稳定多了。
第三个经验:实测时别只看单点误差。至少测 9 个点(3x3 网格分布),才能暴露系统误差还是随机误差。如果 9 个点的误差方向一致、大小接近,那是系统误差,可以靠补偿修正;如果误差方向忽左忽右,那是随机误差,要回到机械臂精度或检测稳定性上找原因。
第四个经验:坐标系命名清楚。机械臂视觉的链路很长,代码里很容易把"相机坐标系下的点"和"机械臂坐标系下的点"搞混。我的习惯是变量名里带上坐标系后缀,比如 world_pt、cam_pt、pixel_pt,函数签名里也写清楚输入输出坐标系。这个习惯帮我在复杂项目中避免了很多低级错误。
第五个经验:如果你用的机械臂有控制器可以直接读取末端坐标,做精度验证时别用尺子量——直接用机械臂示教方式让末端去碰目标点,读出系统记录的坐标,作为真值。尺子量的误差比你想象的大得多,会掩盖系统本身的真实精度。
这篇先到这里,坐标互转这一关过了,下一篇就可以开始讲目标识别和抓取规划了。