相机内参标定:张正友标定法原理
说到相机内参标定,我敢说这是每个做感知的人都会遇到的第一道坎。你想想看,相机拍出来的图像是二维的,但我们要从里面恢复三维信息,这中间缺的就是一个「映射关系」。这个关系,就是内参矩阵和畸变系数。
张正友标定法,说白了就是用一张棋盘格照片,通过数学推导把相机内部参数算出来。我当年刚入行时,觉得这玩意儿特别玄乎,后来亲手跑了一遍流程才发现——其实核心思想就四个字:几何约束。
核心思想:利用棋盘格上已知的角点坐标(世界坐标系)与图像中检测到的角点坐标(像素坐标系)之间的对应关系,建立方程组,求解内参矩阵和畸变系数。
棋盘格角点检测
这一步是标定的基础。棋盘格上的黑白格子交界处,就是我们要找的角点。为什么选棋盘格?因为它的角点特征非常明显——两个方向上的梯度变化都很大,容易检测。
我记得第一次做标定时,用的是一张打印出来的A4纸棋盘格,贴在硬纸板上。当时没注意光照,结果角点检测死活不对。后来才发现,棋盘格表面有反光,角点被误判了。嗯,这里要注意:棋盘格一定要平整,光照要均匀。
OpenCV里提供了现成的角点检测函数:
import cv2
import numpy as np
# 棋盘格尺寸(内角点数量)
pattern_size = (9, 6) # 9列6行内角点
# 读取图像
img = cv2.imread('chessboard.jpg')
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_sub = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)
# 绘制角点
cv2.drawChessboardCorners(img, pattern_size, corners_sub, ret)
我的经验:亚像素精确化这一步千万别省。我见过有人直接用粗检测的角点去标定,结果重投影误差大得离谱。多花几毫秒,精度能提升一个数量级。
内参矩阵的求解
内参矩阵长这样:
[fx 0 cx]
K = [ 0 fy cy]
[ 0 0 1]
其中fx、fy是焦距(以像素为单位),cx、cy是主点坐标(通常接近图像中心)。
张正友标定法的核心,是利用了棋盘格平面上的点满足「单应性矩阵」这个约束。说白了,就是世界坐标系中的平面点,到图像坐标系中的点,存在一个3x3的变换矩阵H。通过多张不同角度的棋盘格照片,我们可以解出H,再从H中分解出内参。
我个人习惯至少拍15-20张不同角度的棋盘格照片。为什么?因为角度越多,约束越强,解出来的内参越稳定。我曾经试过只用5张照片,结果标定出来的fx和fy差了3%——这在自动驾驶里是不能接受的。
畸变系数的求解
相机镜头不是完美的,会产生畸变。主要有两种:
- 径向畸变:镜头形状导致的,图像边缘的直线会变弯。用k1、k2、k3三个参数描述。
- 切向畸变:镜头与成像平面不平行导致的。用p1、p2两个参数描述。
畸变模型可以写成:
x_distorted = x * (1 + k1*r^2 + k2*r^4 + k3*r^6) + 2*p1*x*y + p2*(r^2 + 2*x^2)
y_distorted = y * (1 + k1*r^2 + k2*r^4 + k3*r^6) + p1*(r^2 + 2*y^2) + 2*p2*x*y
其中r是点到主点的距离。你看,畸变校正本质上就是把这个过程反过来——从畸变图像反推无畸变图像。
避坑指南:我曾经在标定一个广角相机时,发现畸变系数k3特别大。后来排查发现,是因为棋盘格只覆盖了图像中心区域,边缘的畸变没有被充分约束。解决方案:让棋盘格出现在图像的各个角落,尤其是边缘和四角。
完整的标定流程
把上面这些串起来,完整的标定流程是这样的:
import cv2
import numpy as np
import glob
# 准备世界坐标系中的点
pattern_size = (9, 6)
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)
# 存储所有图像的点
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, pattern_size, None)
if ret:
objpoints.append(objp)
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
corners_sub = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)
imgpoints.append(corners_sub)
# 标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
知识体系结构
下面这张图,是我自己总结的相机内参标定知识体系。你看,整个流程从数据采集开始,到角点检测,再到参数求解,最后是精度验证,环环相扣。
标定结果验证
标定完不是就完事了。我每次都会做一件事:用标定结果去校正一张棋盘格照片,看看直线是不是真的变直了。
# 畸变校正
img_undistorted = cv2.undistort(img, mtx, dist, None, mtx)
# 计算重投影误差
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("平均重投影误差: ", total_error / len(objpoints))
我的判断标准:重投影误差小于0.5像素,这个标定结果就算合格。如果超过1个像素,我建议重新采集数据。误差来源通常是:棋盘格不平整、照片数量不够、或者角点检测精度不够。
好了,相机内参标定这块,核心就是这些。说白了,就是通过多张棋盘格照片,用数学方法把相机的「内在参数」算出来。这个结果,是后续激光雷达与相机融合的基础——没有准确的内参,点云投影到图像上就会错位,融合效果也就无从谈起。
可可精品资料库