3. 坐标系与投影:从3D世界到2D图像

做BEV感知,说白了就是要把多个摄像头看到的2D图像,统一映射到车顶俯瞰的3D空间里。这中间最核心的一步,就是搞清楚坐标系怎么转、投影怎么做。我刚开始接触这块时,也被各种矩阵绕得头晕。今天咱们就把这事彻底捋清楚。

3.1 四大坐标系,一个都不能少

在自动驾驶感知里,我们主要跟四个坐标系打交道。你想想看,一辆车在路上跑,摄像头装在车上,图像存在像素里——每个环节都有自己的“语言”。

  • 车辆坐标系:以车为中心。通常原点在后轴中心,X轴朝前,Y轴朝左,Z轴朝上。这是BEV感知的“世界原点”。
  • 相机坐标系:以摄像头为中心。原点在相机光心,Z轴沿光轴朝前,X轴朝右,Y轴朝下。嗯,这里要注意,相机坐标系的Y轴是向下的,跟车辆坐标系不一样。
  • 图像坐标系:在成像平面上。原点在图像中心,x轴朝右,y轴朝下。单位是毫米或米。
  • 像素坐标系:就是我们看到的图片。原点在左上角,u轴朝右,v轴朝下。单位是像素。

核心关系:车辆→相机→图像→像素。每一步都是一个变换矩阵。

3.2 内外参矩阵:相机的“身份证”

每个摄像头都有两套参数:内参和外参。我习惯把它们理解为“相机自己的脾气”和“相机在车上的位置”。

3.2.1 内参矩阵(K)

内参描述的是相机本身的特性。说白了,就是光线怎么通过镜头落到传感器上。

K = [fx,  0, cx,
      0, fy, cy,
      0,  0,  1]
  • fx, fy:焦距,单位是像素。我遇到过一些同学把焦距单位搞混,其实fx = 物理焦距 / 像素尺寸。
  • cx, cy:主点偏移,通常是图像中心。但实际生产中,镜头安装会有偏差,所以需要标定。

我的经验:内参一般不会变,除非你换了镜头或者动了CMOS。所以标定一次,存成文件,每次启动时加载就行。

3.2.2 外参矩阵([R|t])

外参描述的是相机在车辆坐标系下的位姿。旋转矩阵R和平移向量t,共同决定了“相机坐标系”到“车辆坐标系”的变换。

P_vehicle = R * P_camera + t

这里有个坑:外参是随时间变化的。车在颠簸,摄像头支架可能松动,甚至碰撞后位置会偏移。我曾经在一个项目中,因为车辆过了一个大坑,右侧摄像头外参变了2度,结果BEV拼接出现明显错位。从那以后,我坚持每次上线前做一次外参校验。

3.3 从3D到2D:投影的完整链路

现在我们把所有步骤串起来。假设车辆前方有一个3D点,它最终是怎么变成图像上一个像素的?

  1. 世界点 → 车辆坐标系:如果点是用GPS/IMU给的,需要先转到车辆坐标系。
  2. 车辆坐标系 → 相机坐标系:用外参矩阵[R|t]变换。
  3. 相机坐标系 → 图像坐标系:用内参矩阵K做透视投影。
  4. 图像坐标系 → 像素坐标系:加上主点偏移和缩放。

完整公式长这样:

z * [u, v, 1]^T = K * [R|t] * [X, Y, Z, 1]^T

其中z是相机坐标系下的深度值。为什么要有z?因为透视投影会丢失深度信息,z就是那个被“除”掉的分量。

避坑指南:我曾经在写代码时,忘了把齐次坐标的最后一维归一化,结果投影出来的点全跑到图像外面去了。检查了半天才发现是忘了除以z。记住:投影后一定要做归一化!

3.4 知识体系总览

下面这张图,是我自己总结的坐标系与投影的完整链路。你可以把它当作一个“地图”,每次做投影时对照着看。

车辆坐标系 相机坐标系 图像坐标系 像素坐标系 外参[R|t] 内参K 缩放+偏移 关键公式与说明 1. 车辆→相机:P_cam = R * P_veh + t 2. 相机→图像:x = fx * X_cam / Z_cam + cx, y = fy * Y_cam / Z_cam + cy 3. 图像→像素:u = x / dx + u0, v = y / dy + v0 ⚠ 常见错误 • 忘记除以Z(深度归一化) • 外参矩阵顺序搞反(R * P 写成 P * R) • 像素坐标系原点在左上角,不是左下角

3.5 实战:写一个投影函数

理论说完了,咱们直接上代码。这是我项目中常用的一个投影函数,把车辆坐标系下的3D点投影到像素坐标。

import numpy as np

def project_vehicle_to_pixel(points_3d, K, R, t):
    """
    将车辆坐标系下的3D点投影到像素坐标系
    
    参数:
        points_3d: (N, 3) 车辆坐标系下的点
        K: (3, 3) 内参矩阵
        R: (3, 3) 旋转矩阵
        t: (3, 1) 平移向量
    
    返回:
        pixels: (N, 2) 像素坐标
    """
    # 1. 车辆 → 相机
    points_cam = (R @ points_3d.T + t).T  # (N, 3)
    
    # 2. 透视投影
    x = points_cam[:, 0] / points_cam[:, 2]
    y = points_cam[:, 1] / points_cam[:, 2]
    
    # 3. 内参映射到像素
    u = K[0, 0] * x + K[0, 2]
    v = K[1, 1] * y + K[1, 2]
    
    pixels = np.stack([u, v], axis=1)
    return pixels

个人习惯:我一般会在投影前先过滤掉Z <= 0的点,因为这些点在相机后面,投影出来也没意义。另外,如果做BEV,我习惯把投影后的像素坐标再转成BEV网格坐标,这一步其实就是逆透视映射(IPM)。

3.6 避坑总结

常见问题 原因 解决办法
投影点跑到图像外面 深度Z为负或太小 检查外参方向,过滤Z<=0的点
BEV拼接错位 外参不准或松动 定期标定,加装防松支架
图像扭曲变形 内参标定不准确 用棋盘格重新标定,至少20张图
坐标轴方向搞反 不同坐标系定义不一致 统一用右手系,写注释标注方向

嗯,坐标系与投影这部分,说白了就是“矩阵乘法+归一化”。但真正做工程时,细节决定成败。我见过太多同学在仿真里跑得飞起,一上车就崩——多半是坐标系没对齐。记住:先验证,再集成。拿一个已知3D点,手动算一遍像素坐标,跟代码输出对比,这一步能省你半天调试时间。

可可精品资料库