3. 相机标定实战
相机标定,说白了就是给摄像头做「体检」。
你想想看,摄像头拍出来的图像,边缘是不是有点弯?远处的直线是不是有点歪?这就是镜头畸变。我刚开始做感知时,总觉得标定是件小事,直到有一次车道线检测在弯道处频频出错——排查了三天,最后发现是相机内参没标准。嗯,从那以后,我再也不敢跳过这一步了。
3.1 张正友标定法:为什么它成了行业标准?
张正友标定法,核心思路其实很简单:用一张棋盘格,从不同角度拍几张照片,就能算出相机的内参和外参。
为什么大家都用这个方法?因为它不需要精密的3D标定物,只需要一个打印出来的棋盘格。我在项目中用过好几次,说实话,只要打印平整、粘贴在硬板上,精度完全够用。
核心原理一句话:
棋盘格上的角点坐标是已知的(世界坐标系),图像上的角点坐标是检测到的(像素坐标系)。通过这两组对应关系,求解相机投影矩阵,再分解出内参、外参和畸变系数。
数学上,它用到了单应性矩阵。每张棋盘格照片,都能建立一个从世界平面到图像平面的单应性矩阵H。多张照片的H矩阵联立求解,就能解出内参矩阵K。
我的经验:至少拍15-20张不同角度的棋盘格照片。角度变化要大——左右倾斜、上下俯仰、远近移动都要有。我曾经只拍了10张,结果标定出来的焦距偏差了3%,后来补拍了几张才稳定下来。
3.2 棋盘格采集:看似简单,坑却不少
棋盘格采集,听起来就是「拍几张照片嘛」。但实际操作中,我踩过的坑可以写一本小册子了。
3.2.1 棋盘格制作要点
- 尺寸要准:打印出来后,用尺子量一下每个格子的实际边长。打印缩放会导致尺寸偏差,我习惯在打印时关闭「适应页面」选项。
- 格子数要奇偶搭配:比如9×6(内角点数为8×5)。这样棋盘格有明确的方向性,算法能自动识别。
- 粘贴要平整:贴在硬纸板或亚克力板上,不能有褶皱。我见过有人贴在软布上,标定结果一塌糊涂。
3.2.2 采集规范
| 要求 | 说明 | 我的建议 |
|---|---|---|
| 光照均匀 | 避免强光直射或阴影遮挡 | 室内日光灯下拍摄最佳 |
| 棋盘格占画面1/3以上 | 太小了角点检测不准 | 距离相机0.5-1.5米为宜 |
| 角度变化丰富 | X轴、Y轴、Z轴方向都要旋转 | 每个角度拍2-3张 |
| 保持棋盘格清晰 | 不能有运动模糊 | 使用三脚架或提高快门速度 |
避坑指南:我曾经在室外强光下采集棋盘格,结果反光导致部分角点检测失败。后来我改成室内漫反射环境,问题就解决了。另外,棋盘格不要离相机太近——太近了畸变严重,角点检测容易出错。
3.3 使用OpenCV进行标定:代码实战
OpenCV提供了完整的标定工具链。我个人习惯用Python,调试起来方便。下面是一套完整的标定流程。
3.3.1 角点检测
import cv2
import numpy as np
import glob
# 棋盘格内角点数量(列,行)
chessboard_size = (9, 6)
# 每个格子的实际尺寸(单位:mm)
square_size = 30.0
# 准备世界坐标系中的点
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('Corners', img)
cv2.waitKey(100)
cv2.destroyAllWindows()
注意:如果findChessboardCorners返回False,说明角点检测失败。常见原因有:棋盘格太小、光照不均匀、角度过于极端。我建议先拍一张正面的照片测试一下,确认检测成功后再批量采集。
3.3.2 执行标定
# 执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
print("重投影误差:", ret)
这里输出的mtx就是内参矩阵,包含fx、fy、cx、cy。dist是畸变系数,包含k1、k2、p1、p2、k3。
为什么会输出5个畸变系数?因为OpenCV默认使用径向畸变(k1、k2、k3)和切向畸变(p1、p2)。径向畸变是镜头本身造成的,切向畸变是传感器和镜头不平行导致的。
3.4 标定结果评估:别急着用,先看看准不准
标定完不能直接拿去用。我见过有人标定完就部署,结果车道线检测偏差了5个像素——这就是没做评估的后果。
3.4.1 重投影误差
calibrateCamera返回的ret就是重投影误差。它的含义是:将世界坐标系中的角点投影回图像,与检测到的角点之间的平均像素误差。
一般来说,重投影误差小于0.5像素就算合格。如果大于1.0像素,说明标定质量有问题,需要检查采集数据。
我的经验:重投影误差0.3像素以内,标定结果就很好了。如果误差偏大,我通常会做两件事:一是剔除误差最大的几张照片,二是检查棋盘格是否平整。
3.4.2 可视化评估
# 计算每张图像的重投影误差
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("平均重投影误差: {:.3f} 像素".format(mean_error / len(objpoints)))
我习惯把每张图像的误差打印出来,看看有没有异常值。如果某张照片的误差明显偏大,直接删掉重新标定。
3.5 去畸变:让图像恢复「本来面目」
标定完成后,最后一步就是去畸变。说白了,就是把弯曲的直线拉直。
# 读取一张测试图像
img = cv2.imread('test_image.jpg')
h, w = img.shape[:2]
# 获取新的相机矩阵(可选,用于裁剪黑边)
newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h))
# 去畸变
dst = cv2.undistort(img, mtx, dist, None, newcameramtx)
# 裁剪黑边
x, y, w, h = roi
dst = dst[y:y+h, x:x+w]
cv2.imshow('Undistorted', dst)
cv2.waitKey(0)
注意:去畸变后图像边缘会有黑边。使用getOptimalNewCameraMatrix可以自动裁剪掉黑边区域。但如果你需要保留完整的视野,可以把alpha参数设为0,这样会保留所有像素,但边缘会有拉伸变形。
去畸变后的图像,直线就是直线了。这对于车道线检测至关重要——你想想看,如果图像本身是弯的,检测出来的车道线怎么可能准?
3.6 本章知识体系
下面这张图,是我梳理的相机标定全流程。从棋盘格采集到最终的去畸变,每一步都有坑,每一步都有技巧。
标定这件事,说难不难,说简单也不简单。关键是要理解每一步的原理,知道为什么这么做,以及出了问题怎么排查。我在项目中积累的经验就是:多拍几张、多检查几次、多验证一遍。标定做好了,后面的感知算法才能跑得稳。