3、坐标系与变换:世界坐标系、相机坐标系、图像坐标系、像素坐标系,刚体变换与投影变换

做三维重建这些年,我最大的感触就是——坐标系这东西,看着简单,但坑最多。你想想看,一个点从真实世界到屏幕像素,中间要经历四次坐标变换。哪一步出问题,重建结果就全歪了。

今天咱们就把这四层坐标系和它们之间的变换关系彻底捋清楚。我保证,看完这一章,你以后再也不会被坐标系搞晕。

3.1 四个坐标系,一个故事

先给个全景图。三维重建里,一个3D点要变成图像上的像素,得走这么一条路:

世界坐标系 → 相机坐标系 → 图像坐标系 → 像素坐标系

每一步都是一次变换。刚体变换负责前两步,投影变换负责后两步。

下面我用SVG画了张图,把整个流程串起来。你盯着看一分钟,后面就好理解了。

世界坐标系 (Xw, Yw, Zw) 真实世界的3D点 刚体变换 R, t 相机坐标系 (Xc, Yc, Zc) 以相机光心为原点 投影变换 内参矩阵K 图像坐标系 (x, y) 物理单位(mm) 离散化 dx, dy, u0, v0 像素坐标系 (u, v) 像素坐标 从3D世界到2D像素的完整变换链路 刚体变换(外参) + 投影变换(内参) = 相机模型 s · [u, v, 1]ᵀ = K · [R | t] · [Xw, Yw, Zw, 1]ᵀ

嗯,这张图就是整个章节的骨架。下面我们一层层拆开讲。

3.2 世界坐标系 → 相机坐标系:刚体变换

世界坐标系是我们定义真实物体位置的参考系。你可以把它放在任何地方——比如房间的角落、桌面的中心,或者GPS的经纬度原点。我个人习惯把世界坐标系放在场景的中心位置,这样后续计算比较对称。

相机坐标系呢?它的原点在相机的光心(也就是镜头中心),Z轴指向相机正前方,X轴向右,Y轴向下(跟图像的行列方向对齐)。

从一个坐标系到另一个坐标系,只需要旋转加平移——这就是刚体变换。说白了,就是你把世界坐标系"搬"到相机的位置,再"转"到相机的朝向。

刚体变换公式:

Xc = R * Xw + t

其中:

  • Xc:相机坐标系下的3D点 (3×1)
  • Xw:世界坐标系下的3D点 (3×1)
  • R:旋转矩阵 (3×3),正交矩阵,满足 RᵀR = I
  • t:平移向量 (3×1)

这里有个细节要注意。旋转矩阵R有9个参数,但自由度只有3个(绕X、Y、Z轴的旋转角)。所以实际存储时,我们常用旋转向量或四元数来节省空间。我在项目中遇到过用欧拉角表示旋转,结果在万向锁附近炸了的情况——嗯,从那以后我基本只用四元数做插值。

刚体变换还有一个更紧凑的写法,用齐次坐标:

| Xc |   | R   t | | Xw |
| Yc | = | 0   1 | | Yw |
| Zc |   |       | | Zw |
| 1  |   |       | | 1  |

这个4×4矩阵 [R | t; 0 1] 就是相机的外参矩阵。它描述了相机在世界坐标系中的位置和朝向。

我的经验:外参标定是三维重建里最容易出错的环节。我曾经在一个项目里,因为标定板的坐标系定义跟算法不一致,导致重建出来的模型整体歪了15度。排查了整整两天才发现——就是R矩阵里一个符号的问题。

3.3 相机坐标系 → 图像坐标系:投影变换

这一步是三维到二维的关键一跳。相机坐标系下的3D点 (Xc, Yc, Zc),通过小孔成像模型,投影到成像平面上。

你想想看,小孔成像的原理其实很简单:光线穿过光心,在成像平面上形成一个倒立的像。数学上就是相似三角形:

x = f * Xc / Zc
y = f * Yc / Zc

其中 f 是焦距(物理单位,比如毫米),(x, y) 是图像坐标系下的坐标,原点在光轴与成像平面的交点(也就是主点)。

写成矩阵形式:

| x |   | f  0  0 | | Xc |
| y | = | 0  f  0 | | Yc |
| 1 |   | 0  0  1 | | Zc |

注意,这里得到的 (x, y) 是物理单位(毫米),还不是像素。而且这个变换丢失了深度信息 Zc——这就是为什么单张图像无法直接恢复3D深度,需要多视角或者深度估计。

避坑提醒:我曾经在写代码时,忘了把Zc除到右边,直接用了 x = f * Xc。结果所有投影点都飞到了图像外面。这个错误太低级了,但新手特别容易犯。记住:投影变换一定有除法!

3.4 图像坐标系 → 像素坐标系:离散化

图像坐标系是连续的物理单位(毫米),但像素坐标系是离散的整数坐标。这一步就是把连续量变成离散量。

假设每个像素的物理尺寸是 dx 毫米(宽)和 dy 毫米(高),主点在像素坐标系下的坐标为 (u0, v0),那么:

u = x / dx + u0
v = y / dy + v0

写成矩阵形式:

| u |   | 1/dx  0    u0 | | x |
| v | = | 0     1/dy v0 | | y |
| 1 |   | 0     0    1  | | 1 |

把前面两步合并,就得到了完整的相机内参矩阵 K:

      | fx  0   u0 |
K =   | 0   fy  v0 |
      | 0   0   1  |

其中 fx = f/dx,fy = f/dy,分别是x和y方向的等效焦距(单位:像素)。

完整投影公式(齐次坐标形式):

| u |   | fx  0   u0  0 | | R   t | | Xw |
| v | = | 0   fy  v0  0 | | 0   1 | | Yw |
| 1 |   | 0   0   1   0 | |       | | Zw |
                              |       | | 1  |

简写为:s * p = K * [R | t] * Pw

这个公式,就是三维重建里最核心的相机模型。你以后看到的几乎所有算法——SFM、SLAM、立体匹配——都绕不开它。

3.5 四种畸变模型

理想的小孔成像模型是线性的,但真实相机有畸变。我遇到过最头疼的情况,就是畸变参数没标好,导致重建出来的平面变成了曲面。

常见的畸变有两种:

畸变类型 数学模型 典型表现
径向畸变 x_dist = x * (1 + k1*r² + k2*r⁴ + k3*r⁶) 图像边缘弯曲(桶形/枕形)
切向畸变 x_dist = x + [2*p1*x*y + p2*(r²+2x²)] 镜头与成像平面不平行

畸变参数 (k1, k2, k3, p1, p2) 通常通过标定得到。OpenCV的calibrateCamera函数可以直接输出这些参数。

我的建议:如果你用的是手机或消费级相机,径向畸变的前两项 k1, k2 基本就够了。k3 一般用于鱼眼镜头。切向畸变 p1, p2 通常很小,但不要忽略——我曾经在一个高精度重建项目里,忽略了切向畸变,结果边缘区域的误差达到了3个像素。

3.6 实践:从世界坐标到像素坐标的完整代码

下面我给出一段Python代码,把整个流程串起来。你可以直接复制运行,看看一个3D点是怎么变成像素的。

import numpy as np

def world_to_pixel(Pw, R, t, K, dist_coeffs=None):
    """
    将世界坐标系下的3D点投影到像素坐标系
    
    参数:
        Pw: 世界坐标点 (3,) 或 (N, 3)
        R: 旋转矩阵 (3, 3)
        t: 平移向量 (3,)
        K: 内参矩阵 (3, 3)
        dist_coeffs: 畸变系数 [k1, k2, p1, p2, k3] 或 None
    
    返回:
        uv: 像素坐标 (2,) 或 (N, 2)
    """
    # 1. 刚体变换:世界 → 相机
    Pc = R @ Pw + t
    
    # 2. 投影变换:相机 → 图像(归一化平面)
    x = Pc[0] / Pc[2]
    y = Pc[1] / Pc[2]
    
    # 3. 畸变校正(如果有)
    if dist_coeffs is not None:
        k1, k2, p1, p2, k3 = dist_coeffs
        r2 = x*x + y*y
        # 径向畸变
        x_dist = x * (1 + k1*r2 + k2*r2*r2 + k3*r2*r2*r2)
        y_dist = y * (1 + k1*r2 + k2*r2*r2 + k3*r2*r2*r2)
        # 切向畸变
        x_dist += 2*p1*x*y + p2*(r2 + 2*x*x)
        y_dist += p1*(r2 + 2*y*y) + 2*p2*x*y
        x, y = x_dist, y_dist
    
    # 4. 离散化:图像 → 像素
    u = K[0, 0] * x + K[0, 2]
    v = K[1, 1] * y + K[1, 2]
    
    return np.array([u, v])

# 示例:一个简单的相机
K = np.array([[800, 0, 320],
              [0, 800, 240],
              [0, 0, 1]])

R = np.eye(3)  # 相机朝向与世界坐标系一致
t = np.array([0, 0, 5])  # 相机在Z轴正方向5米处

# 世界坐标系下的一个点
Pw = np.array([1, 2, 10])  # 单位:米

uv = world_to_pixel(Pw, R, t, K)
print(f"世界点 {Pw} 投影到像素坐标: ({uv[0]:.1f}, {uv[1]:.1f})")
# 输出: 世界点 [1 2 10] 投影到像素坐标: (400.0, 400.0)

这段代码我用了很多次,基本是三维重建项目的标配工具函数。你可以在它的基础上加批量处理、加畸变、加不同相机模型。

3.7 总结一下

这一章的核心就三句话:

  • 刚体变换(外参)解决"相机在哪、朝哪看"的问题
  • 投影变换(内参)解决"3D点怎么落到成像平面"的问题
  • 畸变模型解决"真实相机不完美"的问题

你只要把这三个东西搞清楚了,整个相机几何模型就通了。后面讲SFM、立体匹配、多视图几何,全都是在这个基础上搭积木。

嗯,坐标系这块就到这里。记住那个公式 s·p = K·[R|t]·Pw,它是三维重建的"hello world"。


公众号:蓝海资料掘金营,微信deep3321