从3D到2D:相机投影的完整解析

前言

在计算机视觉、摄影测量、AR/VR等领域,我们经常需要解决一个问题:如何把三维空间中的点,正确地投影到二维图像上?

这个问题看似简单,实际上涉及了完整的相机成像模型。今天我们就来深入解析这个过程的每一个步骤。


一、为什么要理解相机投影?

现实场景

想象你在做一个无人机测绘项目:

  1. 无人机拍摄了100张照片
  2. 通过SfM(Structure from Motion)算法重建了三维场景
  3. 现在你想在每张照片上圈出一个多边形区域(比如建筑物的轮廓)

问题来了:三维空间中的多边形,在每张照片上的位置都不一样,怎么计算?

答案就是:相机投影模型

应用领域

  • 摄影测量:三维重建、地图绘制
  • 自动驾驶:将激光雷达点云投影到摄像头图像上
  • AR/VR:在真实场景中叠加虚拟物体
  • 工业检测:从不同角度测量物体的三维特征
  • 摄影测量:正射影像生成、三维建模

二、相机投影的核心流程

从3D点到2D像素,要经历以下步骤:

复制代码
世界坐标系 → 相机坐标系 → 归一化平面 → 畸变校正 → 像素坐标系
   (外参)      (位姿变换)    (透视投影)    (畸变模型)    (内参变换)

让我们一步步拆解。


三、步骤1:世界坐标系 → 相机坐标系

问题:相机在哪?朝向哪里?

三维空间中的点,需要转换到相机视角才能正确成像。这需要两个信息:

  • 位置(translation):相机在三维空间中的位置
  • 朝向(rotation):相机看的是哪个方向

数学表达

假设三维空间中有一个点 P(X, Y, Z),相机在世界坐标系中的位置为 T,朝向为旋转矩阵 R。

转换公式:

python 复制代码
P_camera = R @ P_world + T

用Python代码表示:

python 复制代码
# 旋转矩阵
R = camera_pose['rotation']

# 平移向量
t = camera_pose['translation']

# 转换到相机坐标系
points_cam = (R @ points_3d.T).T + t

直观理解

想象你在房间里:

  • 世界坐标系:房间的某个角落为原点
  • 相机坐标系:以相机光心为原点,光轴为Z轴
  • 外参就是告诉你:相机在房间的哪个位置,朝着哪个方向

四、步骤2:相机坐标系 → 归一化平面

问题:如何从三维变成二维?

这是透视投影 的核心思想:近大远小

数学原理

在相机坐标系中,Z轴就是光轴方向(相机看的方向)。一个三维点 (X, Y, Z) 在成像平面上的位置,可以通过简单的相似三角形得到:

复制代码
x = X / Z
y = Y / Z

这就是归一化坐标。为什么叫"归一化"?

  • 当Z=1时,x和y就是实际坐标
  • 相当于把所有点都投影到Z=1的平面上
  • 消除了距离(深度)的影响

Python代码

python 复制代码
# 提取深度Z
z = np.abs(points_cam[:, 2])

# 避免除零(点在相机后方的处理)
z[z < 1e-6] = 1e-6

# 透视投影
x = points_cam[:, 0] / z
y = points_cam[:, 1] / z

直观理解

想象一个针孔相机:

  • 光心后面有一张感光平面
  • 三维场景中的光线穿过针孔,投射到感光平面上
  • 离得近的物体在图像上大,离得远的小
  • 这就是透视效果的本质

五、步骤3:归一化平面 → 畸变校正

问题:为什么需要畸变校正?

理想情况下,相机镜头应该把直线拍成直线。但现实中的镜头不是完美的:

  • 径向畸变:直线变成曲线

    • 桶形畸变:图像边缘向外鼓
    • 枕形畸变:图像边缘向内凹
  • 切向畸变:镜头光轴和感光平面不完全垂直

如果不校正,投影的精度会大打折扣。

径向畸变模型

最常用的是 Brown-Conrady 模型

python 复制代码
# 计算点到中心的距离平方
r2 = x**2 + y**2
r4 = r2**2
r6 = r4 * r2

# 径向畸变因子
radial_distortion = 1 + k1 * r2 + k2 * r4 + k3 * r6

其中 k1, k2, k3 是相机标定时得到的畸变系数。

切向畸变模型

python 复制代码
tangential_x = 2 * p1 * x * y + p2 * (r2 + 2 * x**2)
tangential_y = p1 * (r2 + 2 * y**2) + 2 * p2 * x * y

其中 p1, p2 是切向畸变系数。

畸变校正

python 复制代码
# 应用畸变
x_distorted = x * radial_distortion + tangential_x
y_distorted = y * radial_distortion + tangential_y

直观理解

想象一个哈哈镜:

  • 正常的镜子会把你照得很真实
  • 哈哈镜会让你的脸变形(这就是畸变)
  • 畸变校正就是"反哈镜"的过程,把变形还原

六、步骤4:归一化坐标 → 像素坐标

问题:如何把数学坐标变成图像上的像素?

现在我们有的是畸变后的归一化坐标 (x, y),需要转换成图像上的像素坐标 (u, v)。

相机内参

这需要相机的内参

  1. 焦距(focal_x, focal_y):决定视野大小

    • 焦距越大,视野越窄(长焦镜头)
    • 焦距越小,视野越广(广角镜头)
  2. 主点(c_x, c_y):图像中心位置

    • 理想情况下,主点在图像正中央
    • 实际中可能略有偏移

标准公式(OpenCV模型)

python 复制代码
u = focal_x * x_distorted + c_x
v = focal_y * y_distorted + c_y

这就是最常用的相机投影公式!

像素中心校正

还有一个细节:像素的中心在哪里?

  • OpenCV中,像素(0,0)的左上角是(0,0)
  • 但实际像素中心应该是(0.5, 0.5)
  • 所以需要减去0.5
python 复制代码
u = focal_x * x_distorted + c_x - 0.5
v = focal_y * y_distorted + c_y - 0.5

直观理解

想象你在画布上画画:

  • 归一化坐标就像"画布比例"(0到1之间)
  • 焦距就像"放大倍数"
  • 主点就像"画布中心位置"
  • 最终结果就是像素在图像上的具体位置

七、完整代码示例

python 复制代码
import numpy as np

def project_3d_to_pixels(points_3d, camera_intrinsics, camera_pose):
    """
    将3D点投影到像素坐标(完整版)

    Args:
        points_3d: (N, 3) 3D点坐标
        camera_intrinsics: 相机内参
        camera_pose: 相机位姿

    Returns:
        pixel_coords: (N, 2) 像素坐标
    """
    # 1. 世界坐标系 → 相机坐标系
    R = camera_pose['rotation']
    t = camera_pose['translation']
    points_cam = (R @ points_3d.T).T + t

    # 2. 相机坐标系 → 归一化平面(透视投影)
    z = np.abs(points_cam[:, 2])
    z[z < 1e-6] = 1e-6  # 避免除零
    x = points_cam[:, 0] / z
    y = points_cam[:, 1] / z

    # 3. 畸变校正
    k1, k2, k3 = camera_intrinsics['k1'], camera_intrinsics['k2'], camera_intrinsics['k3']
    p1, p2 = camera_intrinsics['p1'], camera_intrinsics['p2']

    r2 = x**2 + y**2
    r4 = r2**2
    r6 = r4 * r2
    radial = 1 + k1 * r2 + k2 * r4 + k3 * r6

    tangential_x = 2 * p1 * x * y + p2 * (r2 + 2 * x**2)
    tangential_y = p1 * (r2 + 2 * y**2) + 2 * p2 * x * y

    x_distorted = x * radial + tangential_x
    y_distorted = y * radial + tangential_y

    # 4. 归一化坐标 → 像素坐标
    focal_x = camera_intrinsics['focal_x']
    focal_y = camera_intrinsics['focal_y']
    c_x = camera_intrinsics['c_x']
    c_y = camera_intrinsics['c_y']

    pixel_u = focal_x * x_distorted + c_x - 0.5
    pixel_v = focal_y * y_distorted + c_y - 0.5

    return np.column_stack([pixel_u, pixel_v])

八、实际工程中的变体

OpenSFM的特殊处理

有些框架(如OpenSFM)会对内参进行归一化处理:

python 复制代码
# OpenSFM的内参是比例值,需要乘以实际尺寸
size = max(camera_intrinsics['width'], camera_intrinsics['height'])

pixel_u = (focal_x * x_distorted + c_x) * size - 0.5 + camera_intrinsics['width'] / 2.0
pixel_v = (focal_y * y_distorted + c_y) * size - 0.5 + camera_intrinsics['height'] / 2.0

为什么要这样设计?

  • 内参归一化后,模型与具体分辨率无关
  • 方便在不同分辨率间切换
  • 但投影时需要额外的缩放步骤

什么时候可以忽略畸变?

在某些场景下,为了性能可以忽略畸变:

python 复制代码
# 低分辨率图像(如缩略图)
if low_resolution:
    k1, k2, k3 = 0, 0, 0  # 忽略畸变
    p1, p2 = 0, 0

九、性能优化技巧

1. 批量处理

不要循环处理每个点,要使用矩阵运算:

python 复制代码
# ❌ 慢:循环处理
for i in range(len(points_3d)):
    x = points_3d[i, 0] / points_3d[i, 2]
    # ...

# ✅ 快:矩阵运算
x = points_3d[:, 0] / points_3d[:, 2]
# ...

2. 避免重复计算

python 复制代码
# 预计算r2,避免重复
r2 = x**2 + y**2  # 只算一次
r4 = r2**2
r6 = r4 * r2

3. 降采样加速

如果对精度要求不高,可以降采样:

python 复制代码
# 在小尺寸上投影,再放大
scale = 0.1
small_w, small_h = int(img_w * scale), int(img_h * scale)

# 在小尺寸上计算
# ...

# 最近邻插值放大
mask = cv2.resize(small_mask, (img_w, img_h), interpolation=cv2.INTER_NEAREST)

十、常见问题

Q1: 投影后的点超出了图像范围怎么办?

这是正常的,说明点不在相机视野内。可以通过边界检查处理:

python 复制代码
if pixel_u < 0 or pixel_u >= width or pixel_v < 0 or pixel_v >= height:
    # 点在图像外
    pass

Q2: 点在相机后方怎么办?

投影时Z值会是负数,需要取绝对值:

python 复制代码
z = np.abs(points_cam[:, 2])  # 取绝对值

或者直接标记为无效:

python 复制代码
valid_mask = points_cam[:, 2] > 0  # Z>0才有效

十一、进阶话题:加畸变 vs 去畸变

问题:归一化平面不是无畸变的吗?为什么要畸变校正?

这是一个非常容易混淆的概念!让我详细解释。

真实世界有两个"成像过程"

过程1:物理小孔成像(无畸变)

在理想的针孔相机模型中:

复制代码
真实物体 → 小孔 → 成像平面(焦距=1)

这个过程没有畸变,是纯粹的光学投影。

为什么? 因为光线是直线传播的,小孔只是一个点,不会改变光线的方向。

过程2:真实镜头成像(有畸变)

但现实中的相机没有小孔 ,而是用镜头

复制代码
真实物体 → 镜头(玻璃片)→ 成像平面

问题就在这个镜头上!镜头不是完美的:

  • 光线在边缘会被"折射"得更厉害(径向畸变)
  • 镜片可能不是完美的球面(切向畸变)

直观理解:

  • 理想针孔:就像平面镜,把真实的你照出来(无畸变)
  • 真实镜头:就像哈哈镜,把你照变形了(有畸变)

那为什么要"先归一化,再畸变"?

这是数学建模的顺序,不是物理顺序!

物理顺序(真实世界)
复制代码
真实物体 → 镜头(产生畸变)→ 成像平面
数学建模顺序(代码中)
复制代码
1. 真实物体 → 针孔投影 → 归一化平面
   (计算理想的、无畸变的位置)

2. 归一化平面 → 畸变模型 → 畸变后的坐标
   (模拟镜头带来的畸变)

为什么要分两步?

直接模拟"光线通过多片玻璃的折射"太复杂,而用"先理想投影,再畸变校正"的方法既准确又高效。

类比:隔哈哈镜画画
  • 真实过程:你站在风景前,隔着一个哈哈镜看风景,在纸上画下来
  • 数学建模
    1. 假设没有哈哈镜,计算理想的画法(归一化)
    2. 加上哈哈镜的变形效果(畸变校正)
    3. 得到最终结果

分两步算更简单,因为我们知道哈哈镜的变形规律(畸变系数)!

总结

  • 归一化平面本身:确实是理想的、无畸变的投影
  • 畸变校正 :不是为了修正归一化平面,而是为了模拟真实镜头的畸变

所以流程是:

复制代码
真实物体 
  → 归一化平面(理想投影)
  → 畸变模型(模拟镜头畸变)
  → 像素坐标

这是数学建模的技巧:把复杂问题分解为简单步骤的组合。


十二、加畸变 vs 去畸变:两个相反的过程

1. 加畸变(正向):3D投影到图像

这是我们现在做的:把无畸变的坐标,加上畸变。

python 复制代码
# 已知:无畸变的归一化坐标 (x, y)
# 求:有畸变的坐标 (x_distorted, y_distorted)

r2 = x**2 + y**2
r4 = r2**2
r6 = r4 * r2
radial = 1 + k1 * r2 + k2 * r4 + k3 * r6

tangential_x = 2 * p1 * x * y + p2 * (r2 + 2 * x**2)
tangential_y = p1 * (r2 + 2 * y**2) + 2 * p2 * x * y

x_distorted = x * radial + tangential_x
y_distorted = y * radial + tangential_y

特点

  • ✅ 有解析解(直接计算)
  • ✅ 复杂度 O(1)
  • ✅ 速度快

用途

  • 将3D点投影到图像上
  • 模拟真实相机的成像过程

2. 去畸变(反向):校正有畸变的图像

把有畸变的图像,恢复成无畸变的。

不能用上面的公式! 需要反向计算,但这个反向计算没有解析解

为什么反向计算这么难?

正向公式:

复制代码
x_distorted = x * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶)

这是一个6次多项式方程!要从 x_distorted 反解出 x,需要解:

复制代码
x * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶) = x_distorted

其中 r² = x² + y²,这是个非线性方程组,无法直接求反函数。

直观理解

想象一个哈哈镜:

  • 正向:看到真实的人 → 通过哈哈镜 → 看到变形的脸(你知道变形规律)
  • 反向:看到变形的脸 → 想知道真实的人长什么样(需要反推)

但问题是:不同的人通过哈哈镜,变形结果不同,你看到变形的脸,不知道是哪个位置的真实人变形来的,需要逐个位置尝试(迭代求解)。

实际如何做去畸变?

方法1:OpenCV的cv2.undistort(推荐)

python 复制代码
import cv2
import numpy as np

# 相机矩阵
camera_matrix = np.array([
    [focal_x, 0, c_x],
    [0, focal_y, c_y],
    [0, 0, 1]
])

# 畸变系数
dist_coeffs = np.array([k1, k2, p1, p2, k3])

# 去畸变
undistorted_img = cv2.undistort(distorted_img, camera_matrix, dist_coeffs)

OpenCV内部使用牛顿迭代法,逐像素求解反向方程。


方法2:自己实现(学习用)

python 复制代码
def undistort_point(x_distorted, y_distorted, k1, k2, k3, p1, p2, max_iter=10):
    """
    反向畸变:从有畸变的坐标,求解无畸变的坐标
    使用牛顿迭代法
    """
    # 初始猜测:假设没有畸变
    x, y = x_distorted, y_distorted
    
    for _ in range(max_iter):
        # 计算当前的r²
        r2 = x**2 + y**2
        
        # 计算畸变因子
        radial = 1 + k1 * r2 + k2 * r2**2 + k3 * r2**3
        tangential_x = 2 * p1 * x * y + p2 * (r2 + 2 * x**2)
        tangential_y = p1 * (r2 + 2 * y**2) + 2 * p2 * x * y
        
        # 计算当前畸变后的坐标
        x_curr = x * radial + tangential_x
        y_curr = y * radial + tangential_y
        
        # 计算误差
        error_x = x_curr - x_distorted
        error_y = y_curr - y_distorted
        
        # 如果误差很小,说明收敛了
        if abs(error_x) < 1e-6 and abs(error_y) < 1e-6:
            break
        
        # 更新猜测(梯度下降)
        x -= error_x * 0.5
        y -= error_y * 0.5
    
    return x, y

方法3:快速近似法(牺牲精度换速度)

如果畸变不大,可以用一阶近似:

python 复制代码
def undistort_approx(x_distorted, y_distorted, k1, k2, k3, p1, p2):
    """
    一阶近似:假设畸变较小,直接用反推公式
    注意:这只在畸变很小时才准确
    """
    r2 = x_distorted**2 + y_distorted**2
    radial = 1 + k1 * r2 + k2 * r2**2 + k3 * r2**3
    
    # 一阶近似:直接除以畸变因子
    x = x_distorted / radial
    y = y_distorted / radial
    
    return x, y

两种操作的对比

操作 输入 输出 复杂度 公式 典型场景
加畸变(投影) 无畸变坐标 有畸变坐标 O(1) 直接计算 3D点投影到图像
去畸变(校正) 有畸变坐标 无畸变坐标 O(N*迭代) 迭代求解 图像畸变校正

实际应用建议

对于mask生成项目:

  • ❌ 不需要去畸变!
  • 原因:你是在生成mask,不是处理图像;3D投影时已经考虑了畸变(加畸变);最终mask和原图的畸变是一致的。

如果真要做去畸变图像:

  • ✅ 推荐:用 cv2.undistort()
  • ✅ 学习:用迭代法自己实现
  • ✅ 快速:一阶近似(精度损失)

Q3: 如何测试投影是否正确?

  1. 可视化检查:在原图上绘制投影结果
  2. 重投影误差:3D点投影后,与图像上的2D点对比
  3. 边界测试:测试极端情况(点很远、很近、在视野外)

十一、总结

核心公式

复制代码
1. 世界 → 相机:P_cam = R @ P_world + T
2. 相机 → 归一化:x = X/Z, y = Y/Z
3. 畸变校正:应用径向和切向畸变模型
4. 归一化 → 像素:u = focal_x * x + c_x

关键要点

  1. 外参:相机的位置和朝向
  2. 内参:相机的焦距、主点、畸变系数
  3. 畸变模型:径向畸变 + 切向畸变
  4. 透视投影:Z轴除法,实现近大远小

应用价值

理解相机投影模型,你就能:

  • 将三维场景投影到任意视角
  • 进行三维重建和摄影测量
  • 开发AR/VR应用
  • 做自动驾驶的传感器融合

这就是计算机视觉最基础也最重要的技术之一!


参考资源

  • 《计算机视觉:算法与应用》- Richard Szeliski
  • 《多视角几何》- Hartley & Zisserman
  • OpenCV相机标定文档
  • OpenSFM源码

希望这篇文章对你理解相机投影有帮助!有问题欢迎交流讨论。

相关推荐
新启航光学频率梳2 小时前
航空航天支架孔深光学3D轮廓测量-激光频率梳3D轮廓技术
科技·3d·制造
格林威2 小时前
工业相机彩色图像采集:为什么我的图是绿色的?附海康/Basler/堡盟相机设置
开发语言·人工智能·数码相机·opencv·计算机视觉·c#·工业相机
没学上了2 小时前
Gige多相机高速拍照模式补充
数码相机
七77.3 小时前
【世界模型】UrbanWorld: An Urban World Model for 3D City Generation
3d·世界模型
给算法爸爸上香17 小时前
web网页显示点云
前端·3d·web·点云
V搜xhliang024617 小时前
3D 点云处理(PCL)
人工智能·目标检测·计算机视觉·3d·分类·知识图谱
weixin_5051544617 小时前
博维数孪创新引领,3D作业指导助力制造业升级
大数据·人工智能·3d·数字孪生·数据可视化·产品交互展示
twe775825817 小时前
“揭开3D IC封装的神秘面纱:动画演绎芯片堆叠的艺术“
科技·3d·制造·动画
CHENJIAMIAN PRO18 小时前
3D Tiles 2.0 技术审查整理笔记
笔记·3d