针孔相机模型:内参矩阵、外参矩阵、畸变校正

各位同学,今天我们聊一个BEV感知绕不开的基础——针孔相机模型。说实话,很多做BEV的同学一开始就扑到Transformer和注意力机制上去了,结果标定没搞明白,投影出来全是歪的。我当年在量产项目里就吃过这个亏,嗯,今天咱们把这个坑填上。

1. 针孔相机模型到底在说啥?

说白了,针孔相机模型就是描述「三维世界里的一个点,是怎么跑到二维图像上的」。你想想看,相机镜头再复杂,本质上跟小时候玩的小孔成像没区别。光线穿过一个小孔,在后面的感光元件上投出一个倒像。

数学上,这个过程可以拆成三步:

  1. 世界坐标系 → 相机坐标系(靠外参)
  2. 相机坐标系 → 图像坐标系(靠内参中的焦距)
  3. 图像坐标系 → 像素坐标系(靠内参中的主点偏移)

我习惯把这三步记成「世界到相机,相机到图像,图像到像素」。每一步对应一个矩阵变换,合起来就是完整的投影方程。

核心公式:

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变换,到时候你会更深刻地理解「内参+外参」组合的威力。

好了,今天就到这里。有问题欢迎在评论区交流。