4. 相机标定与坐标系:内参、外参、畸变校正、世界坐标系到像素坐标系的转换
各位同学,欢迎来到第四讲。
今天聊的话题,是自动驾驶感知里最基础、也最绕不开的一环——相机标定与坐标系转换。
说实话,我见过不少刚入行的同学,模型跑得飞起,但一问他「你相机内参是多少?畸变校正做了没?」就卡壳了。嗯,这其实很危险。因为你的感知算法再牛,如果输入图像是变形的、坐标是乱的,那后面的检测、跟踪、BEV 融合全都会崩。
我自己在早期做多传感器融合项目时,就吃过这个亏。当时一辆车的激光雷达和相机对不上,排查了三天,最后发现是相机外参标定的时候,旋转矩阵写反了一个符号。从那以后,我对标定这件事就再也不敢马虎了。
4.1 为什么要做相机标定?
说白了,相机标定就干两件事:
- 把镜头畸变去掉,让图像变「正常」
- 建立从真实世界到图像像素的映射关系
你想想看,摄像头拍出来的照片,其实是有「失真」的。尤其是广角镜头,边缘的直线会变弯。如果不校正,你在图像上看到的车道线是弯的,那下游的规划控制就全乱套了。
核心结论:没有标定的相机,就像没有校准的尺子——量出来的数据,你敢信吗?
4.2 内参:相机自己的「基因」
内参矩阵,描述的是相机内部的几何特性。它包含:
- 焦距(fx, fy)—— 单位是像素
- 光心(cx, cy)—— 图像中心点的像素坐标
- 畸变系数(k1, k2, p1, p2, k3)—— 径向畸变和切向畸变
内参矩阵长这样:
K = [[fx, 0, cx],
[ 0, fy, cy],
[ 0, 0, 1]]
我习惯把内参理解为「相机的出厂设置」。同一款相机,内参基本固定。但注意,如果你换了镜头、或者相机摔过,内参可能会变。所以,量产车上每台相机最好单独标定。
我的经验:标定内参时,棋盘格要拍至少15-20张不同角度。别偷懒,我见过有人只拍5张,结果畸变校正后边缘还是弯的。
4.3 外参:相机在「哪里」、朝「哪看」
外参描述的是相机在世界坐标系中的位置和朝向。它包含:
- 旋转矩阵 R(3x3)—— 相机朝向
- 平移向量 t(3x1)—— 相机位置
外参矩阵通常写成:
[R | t] (3x4 矩阵)
举个例子:如果相机装在车顶,外参就告诉你「相机相对于车体坐标系,偏了30度,高了1.5米」。这个数据一旦错了,你看到的障碍物位置就会整体偏移。
避坑指南:我曾经在标定外参时,把旋转矩阵的欧拉角顺序搞反了(应该是 Z-Y-X,我写成了 X-Y-Z)。结果所有障碍物的朝向都偏了90度。嗯,那天的 debug 经历,至今难忘。
4.4 畸变校正:让图像「直」回来
畸变主要分两种:
- 径向畸变:镜头边缘的「桶形」或「枕形」变形
- 切向畸变:镜头和成像平面不平行导致的变形
校正公式其实不复杂:
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
看着有点晕?没关系。你只需要知道:OpenCV 的 cv2.undistort() 函数能帮你搞定。
但要注意,畸变校正会损失图像边缘的一些像素。所以,我建议在标定后,检查一下校正后的图像边缘是否还有明显的弯曲。如果有,说明标定参数不够准。
4.5 世界坐标系 → 像素坐标系:完整的转换链路
这是整个章节的「灵魂」所在。从真实世界的一个点,到图像上的一个像素,经历了四步:
- 世界坐标系 → 相机坐标系(外参:R, t)
- 相机坐标系 → 归一化平面(除以 Z)
- 归一化平面 → 畸变平面(加上畸变)
- 畸变平面 → 像素坐标系(内参:fx, fy, cx, cy)
用公式表达就是:
1. P_cam = R * P_world + t
2. x_norm = P_cam.x / P_cam.z, y_norm = P_cam.y / P_cam.z
3. 应用畸变模型得到 x_dist, y_dist
4. u = fx * x_dist + cx, v = fy * y_dist + cy
你想想看,这个链路里任何一个环节出错,最终像素坐标就全错了。所以,每一步都要验证。
验证方法:找一个已知世界坐标的点(比如棋盘格的角点),投影到图像上,看看是否和实际像素位置重合。误差在1-2个像素以内,算合格。
4.6 实战:用 OpenCV 做标定
下面给一个完整的标定流程代码(核心部分):
import cv2
import numpy as np
# 准备棋盘格角点的世界坐标
objp = np.zeros((6*9, 3), np.float32)
objp[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2)
objpoints = [] # 世界坐标
imgpoints = [] # 像素坐标
# 遍历标定图片
for fname in images:
img = cv2.imread(fname)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, corners = cv2.findChessboardCorners(gray, (9, 6), None)
if ret:
objpoints.append(objp)
imgpoints.append(corners)
# 标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None)
# 畸变校正
img_undistorted = cv2.undistort(img, mtx, dist, None, mtx)
这段代码我用了很多年。唯一要提醒的是:棋盘格的尺寸要量准。我见过有人用 30mm 的格子,代码里写成了 25mm,结果标定出来的焦距全偏了。
4.7 坐标系转换的常见坑
最后,分享几个我踩过的坑:
- 坐标系方向:自动驾驶里常用「右-前-上」坐标系,但相机坐标系是「右-下-前」。转换时别忘了。
- 单位统一:世界坐标用米,内参用像素,别混用。
- 畸变参数顺序:OpenCV 的畸变系数顺序是 (k1, k2, p1, p2, k3),别搞反。
- 外参标定后要验证:把激光雷达点云投影到图像上,看看是否对齐。这是最直观的验证方式。
我的习惯:每次标定完,我都会在车上放一个已知尺寸的标定板,拍一张照片,然后手动计算投影误差。虽然麻烦,但能避免很多线上问题。
小结
这一章的内容,说白了就是「让相机知道自己在哪、怎么看世界」。内参、外参、畸变校正,这三者缺一不可。你只要把坐标系转换链路理清楚,后面的 BEV 感知、多传感器融合,都会顺畅很多。
下一章,我们会聊图像预处理——如何把原始图像变成模型喜欢的样子。嗯,那里面也有不少坑,到时候再细说。