2. 相机成像模型与内参标定
相机标定,说白了就是搞清楚相机到底是怎么「看」世界的。你想想看,一个三维空间里的点,怎么就能映射到一张二维照片上?中间经历了哪些变换?又引入了哪些误差?
我刚开始做视觉项目时,总觉得标定就是跑个OpenCV脚本,出几个参数就完事了。直到有一次,一个机械臂抓取项目怎么都对不准——标定板拍了20组,重投影误差0.3个像素,看着挺漂亮,但实际抓取偏差却有5毫米。后来排查才发现,畸变参数没给对,边缘区域的像素偏差被平均掉了。嗯,从那以后我再也不敢小看这一步了。
2.1 针孔相机模型
针孔相机模型是最基础的成像模型。它假设光线沿直线传播,穿过一个小孔后在成像平面上形成倒像。虽然真实相机用的是透镜组,但数学上我们把它等效成针孔模型来处理。
整个成像过程涉及四个坐标系的转换:
| 坐标系 | 符号 | 说明 |
|---|---|---|
| 世界坐标系 | Pw (Xw, Yw, Zw) | 物体在真实世界中的位置 |
| 相机坐标系 | Pc (Xc, Yc, Zc) | 以相机光心为原点 |
| 图像物理坐标系 | p (x, y) | 成像平面上的物理坐标(毫米) |
| 图像像素坐标系 | p (u, v) | 最终图像上的像素坐标 |
从世界坐标到像素坐标,核心公式就一个:
s * [u, v, 1]^T = K * [R | t] * [Xw, Yw, Zw, 1]^T
其中 K 是内参矩阵,[R | t] 是外参矩阵,s 是尺度因子。内参矩阵长这样:
K = [fx, 0, cx;
0, fy, cy;
0, 0, 1]
fx、fy 是焦距在像素单位下的值,cx、cy 是主点坐标。这四个参数,就是我们标定的核心目标。
2.2 畸变模型
真实相机不是针孔,它用的是透镜。透镜就会带来畸变。最常见的两种:
- 径向畸变:光线在透镜边缘弯曲更厉害,导致图像边缘向内凹(桶形畸变)或向外凸(枕形畸变)。用
k1, k2, k3三个参数描述。 - 切向畸变:透镜和成像平面不平行,导致图像倾斜。用
p1, p2两个参数描述。
畸变矫正的数学过程是这样的:
// 径向畸变
x_distorted = x * (1 + k1*r^2 + k2*r^4 + k3*r^6)
y_distorted = y * (1 + k1*r^2 + k2*r^4 + k3*r^6)
// 切向畸变
x_distorted = x + [2*p1*x*y + p2*(r^2 + 2*x^2)]
y_distorted = y + [p1*(r^2 + 2*y^2) + 2*p2*x*y]
其中 r^2 = x^2 + y^2,(x, y) 是归一化平面上的坐标。
2.3 张正友标定法原理
张正友标定法是目前最主流的相机内参标定方法。它的核心思想是:用一张平面棋盘格,从不同角度拍多张照片,然后通过数学推导解出内参。
为什么这个方法好用?因为它不需要你知道标定板在空间中的精确位置,只需要标定板是平面就行。这在实际操作中太方便了。
算法的核心步骤:
- 提取角点:在每张图像中检测棋盘格的角点坐标。
- 计算单应矩阵:每张图像都可以计算一个从标定板平面到图像平面的单应矩阵 H。
- 求解内参:利用单应矩阵的约束条件,构建线性方程组,解出内参的初始值。
- 估计畸变:在得到内参后,用最小二乘法估计畸变参数。
- 全局优化:用Levenberg-Marquardt算法对所有参数进行非线性优化,最小化重投影误差。
2.4 使用OpenCV进行内参标定实战
理论说完了,咱们直接上手。OpenCV提供了完整的标定工具链,核心函数就几个:
import cv2
import numpy as np
import glob
# 1. 准备标定板的角点坐标(假设棋盘格内角点数为9x6)
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
objp = np.zeros((6*9, 3), np.float32)
objp[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2)
# 2. 存储所有图像的世界坐标和像素坐标
objpoints = [] # 3D点
imgpoints = [] # 2D点
images = glob.glob('calib_images/*.jpg')
for fname in images:
img = cv2.imread(fname)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 3. 查找棋盘格角点
ret, corners = cv2.findChessboardCorners(gray, (9, 6), None)
if ret:
objpoints.append(objp)
corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
imgpoints.append(corners2)
# 可视化角点
cv2.drawChessboardCorners(img, (9, 6), corners2, ret)
cv2.imshow('img', img)
cv2.waitKey(500)
cv2.destroyAllWindows()
# 4. 执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
# 5. 评估标定精度
mean_error = 0
for i in range(len(objpoints)):
imgpoints2, _ = cv2.projectPoints(
objpoints[i], rvecs[i], tvecs[i], mtx, dist
)
error = cv2.norm(imgpoints[i], imgpoints2, cv2.NORM_L2) / len(imgpoints2)
mean_error += error
print("平均重投影误差: ", mean_error / len(objpoints))
这段代码跑完,你会得到内参矩阵和畸变系数。但别急着高兴,有几个细节要注意:
- 标定板尺寸:代码里
objp[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2)用的是格子索引,不是实际物理尺寸。如果你要得到以毫米为单位的外参,需要乘以实际格子边长。 - 角点检测失败:如果
findChessboardCorners返回 False,检查一下标定板是否完整、光照是否均匀、图像是否模糊。 - 重投影误差:一般小于0.5像素就算不错了。如果大于1像素,建议重新采集数据。
cv2.undistort() 矫正一张图像看看效果。如果矫正后的图像边缘出现明显的拉伸或扭曲,说明畸变参数可能有问题。这时候我会检查标定板是否覆盖了视野边缘。
下面这张图展示了整个标定流程的结构:
标定完成后,记得保存结果。我一般会用 np.savez 把内参和畸变系数存下来,方便后续调用:
np.savez('camera_calib.npz', mtx=mtx, dist=dist, rvecs=rvecs, tvecs=tvecs)
下次加载时:
data = np.load('camera_calib.npz')
mtx = data['mtx']
dist = data['dist']
最后说一句,标定不是一次性的工作。在实际项目中,我建议每隔一段时间重新标定一次,尤其是相机受到震动或温度变化较大的场景。标定数据就是相机的「身份证」,参数准了,后面的手眼标定和运动规划才有意义。