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 世界坐标系 → 像素坐标系:完整的转换链路

这是整个章节的「灵魂」所在。从真实世界的一个点,到图像上的一个像素,经历了四步:

  1. 世界坐标系 → 相机坐标系(外参:R, t)
  2. 相机坐标系 → 归一化平面(除以 Z)
  3. 归一化平面 → 畸变平面(加上畸变)
  4. 畸变平面 → 像素坐标系(内参: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 感知、多传感器融合,都会顺畅很多。

下一章,我们会聊图像预处理——如何把原始图像变成模型喜欢的样子。嗯,那里面也有不少坑,到时候再细说。