4、相机内参标定:张正友标定法、内参矩阵、畸变系数、标定流程与代码实现
相机内参标定,说白了就是搞清楚相机内部到底是怎么成像的。你想想看,一个三维世界里的点,落到二维图像上,中间经历了什么?这就是内参要回答的问题。
我刚开始做双目测距时,觉得标定就是个例行公事。直到有一次,测出来的距离偏差了5%以上,怎么调算法都没用。最后发现,是标定板打印时尺寸缩放了1毫米。嗯,从那以后,我对标定再也不敢马虎了。
4.1 张正友标定法:为什么它成了行业标准?
张正友标定法,说白了就是用一张棋盘格,从不同角度拍几张照片,就能算出相机内参。这个方法最大的好处是——你不需要知道标定板在空间中的精确位置。
传统标定需要高精度的三维标定块,操作起来很麻烦。张正友的方法只需要一个平面棋盘格,拍个10-20张照片就够了。我在项目中用过很多次,只要操作规范,精度完全够用。
它的核心思想是这样的:
- 假设标定板在Z=0平面上
- 通过角点检测,得到图像坐标
- 利用单应性矩阵,建立图像坐标与世界坐标的关系
- 通过多张照片,解算出内参矩阵和畸变系数
核心公式:
s * m = A * [R|t] * M
其中:m是图像坐标,M是世界坐标,A是内参矩阵,[R|t]是外参,s是尺度因子
4.2 内参矩阵:相机的"身份证"
内参矩阵描述了相机内部的几何特性。它长这样:
A = [fx 0 cx]
[ 0 fy cy]
[ 0 0 1]
这里面:
- fx, fy:焦距,单位是像素。我习惯叫它"像素焦距"
- cx, cy:主点坐标,一般是图像中心附近
为什么说它是相机的"身份证"?因为每台相机的内参都不一样。同一型号的两台相机,内参也会有细微差别。我在项目中遇到过,两台标称参数完全一样的工业相机,实际标定出来的fx差了0.3%。这个差异在近距离测距时影响不大,但远距离就会累积误差。
我的经验:标定完成后,一定要检查fx和fy的比值。理想情况下,fx/fy应该接近1。如果偏差超过5%,说明标定可能有问题,或者相机传感器本身存在较大畸变。
4.3 畸变系数:镜头不是完美的
没有镜头是完美的。畸变主要分两种:
- 径向畸变:光线通过透镜边缘时弯曲更厉害,导致图像边缘变形
- 切向畸变:镜头和成像平面不平行,导致图像倾斜
畸变模型通常用多项式表示:
径向畸变:
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]
其中k1、k2、k3是径向畸变系数,p1、p2是切向畸变系数。一般标定会输出5个畸变系数:[k1, k2, p1, p2, k3]。
注意:k3通常只在畸变很严重时才需要。对于大多数工业镜头,标定k1和k2就够了。我曾经为了追求"完整",强行标定k3,结果反而引入了额外的误差。
4.4 标定流程:一步一步来
我总结了一套标定流程,用了很多年,效果稳定:
- 准备标定板:打印一张棋盘格,贴在平整的硬板上。格子数量建议9x6或10x7
- 拍摄图像:从不同角度拍15-20张。注意:
- 标定板要占图像面积的1/4到1/2
- 角度变化要大,包括倾斜、旋转、远近
- 避免运动模糊
- 角点检测:用OpenCV的findChessboardCorners函数
- 标定计算:用calibrateCamera函数
- 评估结果:检查重投影误差,一般小于0.5像素就算合格
重投影误差:把标定板上的角点,用标定结果重新投影到图像上,计算投影点和实际检测点之间的距离。这个值越小,标定越准。
4.5 代码实现:OpenCV实战
下面是我常用的标定代码,加了详细的注释:
import cv2
import numpy as np
import glob
# 棋盘格参数
CHESSBOARD_SIZE = (9, 6) # 内角点数量
SQUARE_SIZE = 25.0 # 格子边长,单位mm
# 准备世界坐标
objp = np.zeros((CHESSBOARD_SIZE[0] * CHESSBOARD_SIZE[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:CHESSBOARD_SIZE[0],
0:CHESSBOARD_SIZE[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, CHESSBOARD_SIZE, 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)
# 可视化
cv2.drawChessboardCorners(img, CHESSBOARD_SIZE, corners2, ret)
cv2.imshow('img', img)
cv2.waitKey(500)
cv2.destroyAllWindows()
# 标定计算
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
print("重投影误差:", ret)
4.6 标定结果评估
标定完不是就完事了。我习惯做两件事:
- 检查重投影误差:小于0.3像素是优秀,0.3-0.5是良好,大于0.5就要重新标定
- 查看畸变校正效果:用标定结果校正一张图像,看看直线是不是变直了
我的习惯:标定完成后,我会把内参矩阵和畸变系数保存到YAML文件里。这样下次用的时候直接加载,不用重新标定。除非相机被拆过或者镜头被换过。
4.7 知识体系总览
下面这张图总结了相机内参标定的核心逻辑:
最后说一句:标定是个细致活。我见过太多人拍几张照片就急着跑代码,结果标定出来的参数根本不能用。多花10分钟拍好照片,能省下后面几小时的调试时间。