相机成像与标定原理
相机标定,说白了就是搞清楚相机到底是怎么「看」世界的。我刚开始做机器视觉时,总觉得标定是个可有可无的步骤——直到有一次,一个看似简单的定位项目,死活对不准,折腾了两天才发现是相机内参没标好。嗯,从那以后,我再也不敢跳过这一步了。
这一章,我们就把相机成像的底牌翻个底朝天。从针孔模型到张正友标定法,再到OpenCV实战,一条线走通。
针孔相机模型:最朴素的成像原理
你想想看,相机成像的本质是什么?就是把三维空间里的点,映射到二维图像上。针孔模型就是描述这个映射的最简方式。
它的核心假设是:光线沿直线传播,穿过一个小孔(光心),在成像平面上形成倒立的像。虽然真实相机有透镜、有复杂的光路,但针孔模型作为基础框架,已经足够我们理解大部分标定原理了。
核心公式(投影方程):
s · [u, v, 1]^T = K · [R|t] · [X, Y, Z, 1]^T
其中:s为尺度因子,K为内参矩阵,[R|t]为外参矩阵,[X,Y,Z]为世界坐标点,[u,v]为像素坐标。
这个公式,就是整个相机标定的灵魂。后面所有操作,都是在求解K和[R|t]。
内参矩阵:相机的「身份证」
内参矩阵K,描述了相机内部的几何特性。它长这样:
K = [fx, 0, cx;
0, fy, cy;
0, 0, 1]
参数含义很直观:
- fx, fy:焦距在x、y方向上的像素度量。说白了,就是镜头中心到成像平面的距离,换算成像素单位。
- cx, cy:主点坐标,也就是光轴与成像平面的交点。理想情况下在图像正中心,但实际总有偏移。
我在项目中遇到过一台工业相机,标定后发现cx偏了将近20个像素。一开始以为是标定板没放正,反复标了三次,结果都一样。后来查手册才发现,那款相机的传感器在封装时就有装配公差。嗯,这就是为什么每台相机都要单独标定,不能拿出厂参数直接用的原因。
畸变模型:现实总是不完美的
针孔模型是理想情况。真实相机有透镜,透镜就会带来畸变。最常见的两种:
| 畸变类型 | 产生原因 | 视觉效果 |
|---|---|---|
| 径向畸变 | 透镜形状不完美,边缘折射率与中心不同 | 直线变弯,像鱼眼效果 |
| 切向畸变 | 透镜与成像平面不平行 | 图像倾斜,像被拧了一下 |
畸变模型通常用多项式来描述:
径向畸变:x_distorted = x * (1 + k1*r^2 + k2*r^4 + k3*r^6)
切向畸变:x_distorted = x + [2*p1*x*y + p2*(r^2 + 2*x^2)]
其中k1、k2、k3是径向畸变系数,p1、p2是切向畸变系数。标定的任务之一,就是求出这5个参数。
避坑提醒:我曾经在一个项目中只标定了k1、k2两个径向参数,结果图像边缘的畸变校正后还是有明显弯曲。后来加上k3,效果才满意。对于广角镜头或高精度测量场景,建议标定全部5个畸变参数。
张正友标定法:从理论到实践的桥梁
张正友标定法,是目前最主流的相机标定方法。它的巧妙之处在于:只需要拍摄不同角度的棋盘格照片,就能同时解出内参和外参。
核心思路分三步:
- 提取角点:从每张棋盘格照片中,提取出所有内角点的像素坐标。
- 求解单应矩阵:利用已知的棋盘格物理尺寸(比如格子边长30mm),建立世界坐标与像素坐标的对应关系,求解单应矩阵H。
- 分解内参外参:利用多张照片的单应矩阵,通过最小二乘法求解内参矩阵K和畸变系数。
为什么这个方法好用?因为它不需要知道相机的位置,也不需要精密的标定设备。一张打印的棋盘格,加上几行代码,就能搞定。
个人经验:我建议拍摄15-20张不同角度的棋盘格照片。角度变化要大——俯仰、偏航、旋转都要覆盖。但注意,棋盘格不要超出图像边缘太多,否则角点提取会失败。
OpenCV相机标定实战
理论说完了,直接上代码。OpenCV的标定流程非常成熟,核心就几个函数。
第一步:准备标定板
我用的是标准的9×6棋盘格,格子边长30mm。打印出来贴在平板上就行。
注意:棋盘格必须平整!我曾经用一张贴在纸箱上的棋盘格做标定,结果因为纸箱表面不平,标定出来的内参误差很大。后来换了亚克力板,效果立竿见影。
第二步:提取角点
import cv2
import numpy as np
# 棋盘格参数
pattern_size = (9, 6) # 内角点数量
square_size = 30.0 # 格子边长(mm)
# 准备世界坐标
objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2)
objp *= square_size
# 存储所有图片的角点
obj_points = [] # 世界坐标
img_points = [] # 像素坐标
# 遍历图片
for fname in image_files:
img = cv2.imread(fname)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 提取角点
ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)
if ret:
# 亚像素精确化
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
obj_points.append(objp)
img_points.append(corners)
# 可视化
cv2.drawChessboardCorners(img, pattern_size, corners, ret)
cv2.imshow('Corners', img)
cv2.waitKey(100)
第三步:执行标定
# 相机标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
obj_points, img_points, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
输出结果示例:
内参矩阵:
[[534.21, 0. , 320.15],
[ 0. , 533.89, 240.72],
[ 0. , 0. , 1. ]]
畸变系数:
[[-0.284, 0.087, -0.002, 0.001, -0.021]]
第四步:评估标定结果
# 计算重投影误差
total_error = 0
for i in range(len(obj_points)):
img_points_proj, _ = cv2.projectPoints(
obj_points[i], rvecs[i], tvecs[i], mtx, dist
)
error = cv2.norm(img_points[i], img_points_proj, cv2.NORM_L2) / len(img_points_proj)
total_error += error
print("平均重投影误差:", total_error / len(obj_points))
经验阈值:重投影误差小于0.5像素,说明标定质量良好。如果超过1个像素,建议检查角点提取是否准确,或者增加标定图片数量。
本章知识体系
下面这张图,把相机成像与标定的核心逻辑串起来了:
从针孔模型出发,到内参矩阵和畸变模型,再到张正友标定法的数学原理,最后落地到OpenCV代码——这条路径,就是相机标定的完整闭环。我个人习惯把标定结果保存成YAML文件,每次换相机或调整镜头后重新标定一次,确保数据可靠。
标定完成后,下一步就是用它来校正图像、测量尺寸,或者——为手眼标定做准备。嗯,那是后面章节的事了。