3、相机成像模型与标定:针孔相机模型、内参矩阵、畸变模型、张正友标定法原理与实操
相机标定,说白了就是搞清楚相机到底是怎么「看」世界的。我刚开始做机器人抓取项目时,总觉得标定是个纯理论的东西,随便跑个 OpenCV 脚本就完事了。结果呢?抓取精度死活上不去,后来才发现是相机内参根本没标准。嗯,从那以后我再也不敢小看这一步了。
这一节,我们就把相机成像的底牌翻出来看看。从针孔模型到畸变矫正,再到张正友标定法的完整流程,我会结合自己踩过的坑,给你讲透。
3.1 针孔相机模型——相机在模仿谁?
你想想看,最早的相机其实就是个暗箱。光线从一个小孔射进去,在后面的平面上倒着成像。这就是针孔相机模型的核心思想。
数学上,我们把三维世界中的一个点 \(P(X, Y, Z)\) 映射到成像平面上的像素点 \(p(u, v)\)。这个过程分两步:
- 从世界坐标到相机坐标——靠外参矩阵(旋转 + 平移)
- 从相机坐标到像素坐标——靠内参矩阵(焦距、主点)
我个人习惯把内参矩阵写成这样:
K = [fx 0 cx]
[ 0 fy cy]
[ 0 0 1]
其中 \(fx, fy\) 是焦距(单位是像素),\(cx, cy\) 是光心在像素平面上的位置。注意,这里的焦距不是物理毫米,而是「像素当量」——这是新手最容易搞混的地方。
核心理解:内参矩阵描述的是「相机坐标系下的点」到「像素坐标系下的点」的映射关系。它只跟相机本身的硬件有关,跟你把相机放在哪儿无关。
3.2 畸变模型——为什么直线会变弯?
现实中的镜头不是完美的针孔。为了进更多光,镜头用了透镜组,结果就带来了畸变。我在项目中遇到过最典型的情况:用广角镜头拍棋盘格,边缘的格子全弯了,标定出来的内参偏差很大。
畸变主要分两类:
| 畸变类型 | 产生原因 | 视觉效果 | 数学模型 |
|---|---|---|---|
| 径向畸变 | 透镜形状不完美 | 直线向外或向内弯曲 | \(k_1, k_2, k_3\) |
| 切向畸变 | 透镜与成像平面不平行 | 图像倾斜、梯形失真 | \(p_1, p_2\) |
矫正公式其实不复杂:
// 径向畸变矫正
x_corrected = x * (1 + k1*r^2 + k2*r^4 + k3*r^6)
y_corrected = y * (1 + k1*r^2 + k2*r^4 + k3*r^6)
// 切向畸变矫正
x_corrected = x + [2*p1*x*y + p2*(r^2 + 2*x^2)]
y_corrected = y + [p1*(r^2 + 2*y^2) + 2*p2*x*y]
避坑指南:我曾经在标定鱼眼镜头时只用了 \(k_1, k_2\) 两个参数,结果边缘矫正效果很差。后来加上 \(k_3\) 才搞定。对于大畸变镜头,建议至少用 3 个径向畸变参数。
3.3 张正友标定法——为什么它是工业标准?
张正友标定法,说白了就是用一张棋盘格照片,通过多角度拍摄来解算相机内参和畸变系数。它的巧妙之处在于:不需要你知道棋盘格在空间中的精确位置,只需要知道格子是正方形的(尺寸已知)。
整个流程我拆成 5 步:
- 拍摄棋盘格——至少 10-20 张不同角度、不同位置的照片
- 提取角点——用亚像素精度找到每个格子的角点坐标
- 计算单应性矩阵——每张照片都有一个从棋盘格平面到像素平面的映射
- 求解内参初值——利用单应性矩阵的约束条件,闭式解出内参
- 非线性优化——用 LM 算法同时优化内参、外参和畸变系数
我的经验:拍摄时一定要让棋盘格充满画面不同区域。很多人只拍中间区域,结果标定出来的畸变参数在边缘完全不准。我习惯让棋盘格出现在画面的四个角和中心,每个位置拍 2-3 张不同角度的。
3.4 标定流程与核心代码
下面是我在实际项目中用的标定代码片段。注意,这里我用了 OpenCV 的 findChessboardCorners 和 calibrateCamera,但参数设置上有几个关键点:
import cv2
import numpy as np
import glob
# 棋盘格参数
CHECKERBOARD = (9, 6) # 内角点数量
SQUARE_SIZE = 25.0 # 格子边长,单位 mm
# 准备世界坐标系中的点
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 = [] # 3D 点
imgpoints = [] # 2D 点
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:
objpoints.append(objp)
# 亚像素精确化
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
imgpoints.append(corners2)
# 标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
关键参数说明:
CHECKERBOARD是内角点数量,不是格子数量。9×6 的棋盘格,内角点是 8×5。SQUARE_SIZE的单位要和实际物理单位一致。我习惯用毫米。cornerSubPix的窗口大小 (11,11) 是经验值,太小了精度不够,太大了容易受干扰。
3.5 标定结果评估——怎么看标得好不好?
标定完不是就完事了。我每次都会做两件事来验证:
- 重投影误差——把标定板上的 3D 点投影回图像,算一下跟实际角点的像素误差。一般小于 0.5 像素就算合格。
- 畸变矫正可视化——用
cv2.undistort()矫正一张照片,看看边缘的直线是不是变直了。
# 计算重投影误差
total_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)
total_error += error
print("平均重投影误差: {:.3f} 像素".format(total_error / len(objpoints)))
我曾经踩过的坑:有一次标定结果重投影误差只有 0.3 像素,但实际手眼标定后抓取精度很差。后来发现是因为拍摄时棋盘格没有覆盖画面边缘,导致畸变参数在边缘区域完全失效。所以,评估标定质量不能只看重投影误差,一定要看畸变矫正后的图像边缘效果。
3.6 知识体系总览
下面这张图把相机标定的核心逻辑串起来了。你可以把它当作一个检查清单:
这张图把整个标定流程串起来了。从针孔模型出发,到畸变矫正,再到张正友标定法的完整 pipeline。你可以把它当作一个检查清单——每次做标定前,对照着走一遍,基本不会漏东西。
我的习惯:标定完成后,我会把内参矩阵和畸变系数保存成一个 YAML 文件,方便后续手眼标定和 3D 定位时直接加载。文件名格式建议用 camera_serial_number_calib_date.yaml,避免搞混不同相机的参数。