针孔相机模型:内参矩阵、外参矩阵、畸变校正
各位同学,今天我们聊一个BEV感知绕不开的基础——针孔相机模型。说实话,很多做BEV的同学一开始就扑到Transformer和注意力机制上去了,结果标定没搞明白,投影出来全是歪的。我当年在量产项目里就吃过这个亏,嗯,今天咱们把这个坑填上。
1. 针孔相机模型到底在说啥?
说白了,针孔相机模型就是描述「三维世界里的一个点,是怎么跑到二维图像上的」。你想想看,相机镜头再复杂,本质上跟小时候玩的小孔成像没区别。光线穿过一个小孔,在后面的感光元件上投出一个倒像。
数学上,这个过程可以拆成三步:
- 世界坐标系 → 相机坐标系(靠外参)
- 相机坐标系 → 图像坐标系(靠内参中的焦距)
- 图像坐标系 → 像素坐标系(靠内参中的主点偏移)
我习惯把这三步记成「世界到相机,相机到图像,图像到像素」。每一步对应一个矩阵变换,合起来就是完整的投影方程。
核心公式:
s * [u, v, 1]^T = K * [R | t] * [X, Y, Z, 1]^T
其中:s是尺度因子,K是内参矩阵,[R|t]是外参矩阵。
2. 内参矩阵:相机的「出厂设置」
内参矩阵K长这样:
K = [fx, 0, cx]
[ 0, fy, cy]
[ 0, 0, 1]
fx、fy是焦距(单位是像素),cx、cy是主点坐标(光轴与成像平面的交点)。
我在项目中遇到过一件事:某次用公开数据集训练BEV模型,效果一直差口气。后来一查,发现数据集的cx、cy标错了,差了5个像素。你想想看,5个像素在图像边缘能造成几十厘米的投影误差。所以啊,内参一定要自己标定,别信网上的「默认值」。
个人经验:量产项目中,我建议每3个月重新标定一次内参。车载相机受振动和温度影响,内参会缓慢漂移。尤其是fx、fy,温度变化10度能漂移0.3%左右。
3. 外参矩阵:相机装在车上的「位置和朝向」
外参矩阵[R|t]描述的是相机坐标系相对于世界坐标系(或车身坐标系)的旋转和平移。
- R:3x3旋转矩阵,表示相机的朝向(俯仰角、偏航角、翻滚角)
- t:3x1平移向量,表示相机的位置
做BEV时,外参的重要性怎么强调都不过分。我记得有一次,一个同事把外参的旋转矩阵写反了(R用了转置),结果BEV俯视图里的车道线全往反方向歪。查了两天才找到原因。
避坑指南:我曾经在多个项目里发现,外参的坐标系定义不统一是最大的坑。有的用「前-左-上」,有的用「右-下-前」。一定要在项目一开始就统一坐标系约定,否则后面融合多传感器数据时会疯掉。
4. 畸变校正:让直线变回直线
现实中的镜头不是完美的小孔。广角镜头尤其明显,画面边缘的直线会变成曲线。这就是畸变。
畸变主要分两种:
| 畸变类型 | 表现 | 数学模型 |
|---|---|---|
| 径向畸变 | 直线向外或向内弯曲 | k1, k2, k3 三个参数 |
| 切向畸变 | 镜头与成像平面不平行 | p1, p2 两个参数 |
校正流程其实很简单:先对每个像素计算畸变后的坐标,然后查表映射。但要注意,畸变校正会损失边缘视野,大概损失5%-10%。做BEV时,我一般会保留原始图像做校正,而不是校正后再裁剪。
5. 代码实现:从理论到OpenCV
下面给出一段完整的代码,演示内参、外参、畸变校正的实战用法。这段代码我实际在项目里跑过,可以直接复用。
import cv2
import numpy as np
# 1. 内参矩阵(假设已标定)
K = np.array([[718.856, 0, 607.193],
[0, 718.856, 185.216],
[0, 0, 1]], dtype=np.float32)
# 2. 畸变系数(k1, k2, p1, p2, k3)
dist_coeffs = np.array([-0.054, 0.098, -0.001, 0.002, -0.032], dtype=np.float32)
# 3. 畸变校正
def undistort_image(img):
h, w = img.shape[:2]
# 获取最优的新内参矩阵(可保留更多视野)
new_K, roi = cv2.getOptimalNewCameraMatrix(K, dist_coeffs, (w, h), 1, (w, h))
# 校正
undistorted = cv2.undistort(img, K, dist_coeffs, None, new_K)
return undistorted, new_K
# 4. 外参:世界到相机(假设相机装在车顶,俯仰角5度)
def build_extrinsic(yaw=0, pitch=5, roll=0, tx=0, ty=1.5, tz=1.2):
# 角度转弧度
yaw_rad = np.deg2rad(yaw)
pitch_rad = np.deg2rad(pitch)
roll_rad = np.deg2rad(roll)
# 旋转矩阵(按Z-Y-X顺序)
R_yaw = np.array([[np.cos(yaw_rad), -np.sin(yaw_rad), 0],
[np.sin(yaw_rad), np.cos(yaw_rad), 0],
[0, 0, 1]])
R_pitch = np.array([[np.cos(pitch_rad), 0, np.sin(pitch_rad)],
[0, 1, 0],
[-np.sin(pitch_rad), 0, np.cos(pitch_rad)]])
R_roll = np.array([[1, 0, 0],
[0, np.cos(roll_rad), -np.sin(roll_rad)],
[0, np.sin(roll_rad), np.cos(roll_rad)]])
R = R_yaw @ R_pitch @ R_roll
t = np.array([tx, ty, tz], dtype=np.float32).reshape(3, 1)
return R, t
# 5. 世界坐标点投影到像素
def project_world_to_pixel(world_pts, K, R, t):
# world_pts: Nx3
pts_cam = (R @ world_pts.T + t).T # 转到相机坐标系
# 投影到像素
pts_pixel = K @ pts_cam.T
pts_pixel = pts_pixel[:2] / pts_pixel[2] # 归一化
return pts_pixel.T
# 示例:投影一个3D点
world_point = np.array([[5.0, 0.0, 0.0]]) # 前方5米,地面高度
R, t = build_extrinsic()
pixel = project_world_to_pixel(world_point, K, R, t)
print(f"世界点 (5,0,0) 投影到像素坐标: {pixel[0]}")
我的一点建议:实际项目中,畸变校正最好用OpenCV的initUndistortRectifyMap + remap组合,这样只需要计算一次映射表,后面每帧直接查表,速度能快3-5倍。
6. 总结与思考
针孔相机模型是BEV感知的基石。内参告诉你「相机本身长什么样」,外参告诉你「相机装在车的哪里」,畸变校正则是「让图像变正常」的预处理。
我个人觉得,这三个概念里最容易出问题的是外参。因为内参和畸变可以用标定板一次性搞定,但外参会随着车辆振动、时间推移而慢慢变化。量产项目中,我见过太多因为外参不准导致BEV拼接错位的案例。
下一章我们会讲如何用这些知识做BEV视角的IPM变换,到时候你会更深刻地理解「内参+外参」组合的威力。
好了,今天就到这里。有问题欢迎在评论区交流。