前言
在计算机视觉、摄影测量、AR/VR等领域,我们经常需要解决一个问题:如何把三维空间中的点,正确地投影到二维图像上?
这个问题看似简单,实际上涉及了完整的相机成像模型。今天我们就来深入解析这个过程的每一个步骤。
一、为什么要理解相机投影?
现实场景
想象你在做一个无人机测绘项目:
- 无人机拍摄了100张照片
- 通过SfM(Structure from Motion)算法重建了三维场景
- 现在你想在每张照片上圈出一个多边形区域(比如建筑物的轮廓)
问题来了:三维空间中的多边形,在每张照片上的位置都不一样,怎么计算?
答案就是:相机投影模型。
应用领域
- 摄影测量:三维重建、地图绘制
- 自动驾驶:将激光雷达点云投影到摄像头图像上
- 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)。
相机内参
这需要相机的内参:
-
焦距(focal_x, focal_y):决定视野大小
- 焦距越大,视野越窄(长焦镜头)
- 焦距越小,视野越广(广角镜头)
-
主点(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. 归一化平面 → 畸变模型 → 畸变后的坐标
(模拟镜头带来的畸变)
为什么要分两步?
直接模拟"光线通过多片玻璃的折射"太复杂,而用"先理想投影,再畸变校正"的方法既准确又高效。
类比:隔哈哈镜画画
- 真实过程:你站在风景前,隔着一个哈哈镜看风景,在纸上画下来
- 数学建模 :
- 假设没有哈哈镜,计算理想的画法(归一化)
- 加上哈哈镜的变形效果(畸变校正)
- 得到最终结果
分两步算更简单,因为我们知道哈哈镜的变形规律(畸变系数)!
总结
- 归一化平面本身:确实是理想的、无畸变的投影
- 畸变校正 :不是为了修正归一化平面,而是为了模拟真实镜头的畸变
所以流程是:
真实物体
→ 归一化平面(理想投影)
→ 畸变模型(模拟镜头畸变)
→ 像素坐标
这是数学建模的技巧:把复杂问题分解为简单步骤的组合。
十二、加畸变 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: 如何测试投影是否正确?
- 可视化检查:在原图上绘制投影结果
- 重投影误差:3D点投影后,与图像上的2D点对比
- 边界测试:测试极端情况(点很远、很近、在视野外)
十一、总结
核心公式
1. 世界 → 相机:P_cam = R @ P_world + T
2. 相机 → 归一化:x = X/Z, y = Y/Z
3. 畸变校正:应用径向和切向畸变模型
4. 归一化 → 像素:u = focal_x * x + c_x
关键要点
- 外参:相机的位置和朝向
- 内参:相机的焦距、主点、畸变系数
- 畸变模型:径向畸变 + 切向畸变
- 透视投影:Z轴除法,实现近大远小
应用价值
理解相机投影模型,你就能:
- 将三维场景投影到任意视角
- 进行三维重建和摄影测量
- 开发AR/VR应用
- 做自动驾驶的传感器融合
这就是计算机视觉最基础也最重要的技术之一!
参考资源
- 《计算机视觉:算法与应用》- Richard Szeliski
- 《多视角几何》- Hartley & Zisserman
- OpenCV相机标定文档
- OpenSFM源码
希望这篇文章对你理解相机投影有帮助!有问题欢迎交流讨论。