坐标系基础:世界坐标系、相机坐标系、图像坐标系、像素坐标系
做传感器标定这么多年,我见过太多人在坐标系转换上栽跟头。说实话,这玩意儿看着简单,但真要动手写代码的时候,坐标轴方向搞反、单位换算出错的情况比比皆是。今天咱们就把这四个坐标系彻底捋清楚。
为什么需要四个坐标系?
你想想看,一个三维空间里的点,要变成屏幕上显示的像素,中间得经过多少道工序?我刚开始接触这个领域时也觉得麻烦,后来在项目中调试一个双目视觉系统,才发现每个坐标系都有它存在的必要性。
简单来说:
- 世界坐标系:描述物体在真实世界中的位置,单位是米或毫米
- 相机坐标系:以相机光心为原点,描述物体相对于相机的位置
- 图像坐标系:在成像平面上,用物理单位(毫米)表示像素位置
- 像素坐标系:最终屏幕上看到的,以像素为单位的坐标
嗯,说白了就是从三维到二维的降维打击过程。
世界坐标系 → 相机坐标系
这是第一步,也是最直观的一步。世界坐标系里的点,通过旋转和平移,就能变换到相机坐标系下。
我记得有一次做车载摄像头标定,世界坐标系的原点选在了车辆后轴中心。结果发现相机安装位置有偏差,旋转矩阵算出来总是不对。后来才意识到,坐标系定义必须统一,不然所有计算都是白搭。
核心公式:
P_cam = R * P_world + T
其中 R 是 3x3 旋转矩阵,T 是 3x1 平移向量。
这里有个细节要注意:旋转矩阵是正交矩阵,它的逆等于它的转置。我在做手眼标定时,经常利用这个性质来简化计算。
相机坐标系 → 图像坐标系
这一步用到了小孔成像模型。相机坐标系下的点 (X, Y, Z),投影到成像平面上,得到图像坐标 (x, y)。
公式很简单:
x = f * X / Z
y = f * Y / Z
f 是焦距,单位是毫米。这里 Z 是深度信息,一旦投影到平面上,深度就丢失了。这也是为什么单目相机无法直接恢复深度——你想想看,一个近处的小物体和一个远处的大物体,在图像上可能一模一样。
我曾经踩过的坑: 在做鱼眼相机标定时,直接用这个针孔模型去套,结果畸变校正后图像边缘全是扭曲的。后来才明白,鱼眼相机用的是等距投影模型,不是针孔模型。不同相机模型,投影公式完全不同。
图像坐标系 → 像素坐标系
图像坐标系的单位是毫米,但最终显示在屏幕上的是像素。这一步就是做单位换算和原点平移。
公式:
u = x / dx + u0
v = y / dy + v0
其中 dx、dy 是每个像素的物理尺寸(毫米/像素),(u0, v0) 是图像中心在像素坐标系下的坐标。
我个人习惯把这一步写成矩阵形式,方便后续做批量计算:
[u] [1/dx 0 u0] [x]
[v] = [0 1/dy v0] [y]
[1] [0 0 1 ] [1]
完整的转换链路
把上面三步串起来,就得到了从世界坐标到像素坐标的完整变换:
Z * [u] [fx 0 cx] [R | T] [X_w]
[v] = [0 fy cy] * [Y_w]
[1] [0 0 1 ] [Z_w]
[1 ]
这个 3x4 的矩阵,就是传说中的相机投影矩阵 P。fx、fy 是焦距的像素表示,cx、cy 是主点坐标。
实战建议: 在写代码时,我习惯用齐次坐标来处理所有变换。这样旋转和平移可以统一成一个矩阵乘法,代码简洁很多。OpenCV 里的 cv2.projectPoints 就是基于这个原理实现的。
坐标系转换的SVG结构图
下面这张图展示了四个坐标系之间的转换关系,以及每一步用到的参数:
常见问题与避坑指南
在实际项目中,坐标系转换最容易出问题的几个地方:
- 坐标轴方向搞反:OpenCV 的图像坐标系 y 轴向下,而数学里通常 y 轴向上。我刚开始做项目时,就因为没注意这个,投影出来的点全在图像外面。
- 单位不统一:世界坐标用米,焦距用毫米,像素尺寸用微米。三个单位混在一起,算出来的结果肯定不对。我的习惯是全部统一成毫米。
- 齐次坐标忘记归一化:从齐次坐标转回笛卡尔坐标时,要除以最后一个分量。这个步骤虽然简单,但写代码时很容易漏掉。
我曾经犯过的错: 在做多相机联合标定时,把左相机的坐标系当成了世界坐标系。结果右相机的位姿算出来全是错的。后来才意识到,世界坐标系应该选一个固定的参考系,比如标定板上的某个角点。
代码实现示例
下面是一个用 Python + NumPy 实现的完整转换函数:
import numpy as np
def world_to_pixel(P_world, R, T, K):
"""
世界坐标 → 像素坐标
P_world: 世界坐标系下的3D点 (N, 3)
R: 旋转矩阵 (3, 3)
T: 平移向量 (3, 1)
K: 内参矩阵 (3, 3)
"""
# 世界 → 相机
P_cam = (R @ P_world.T + T).T # (N, 3)
# 相机 → 图像(归一化平面)
x = P_cam[:, 0] / P_cam[:, 2]
y = P_cam[:, 1] / P_cam[:, 2]
# 图像 → 像素
uv_hom = K @ np.vstack([x, y, np.ones_like(x)])
u = uv_hom[0, :] / uv_hom[2, :]
v = uv_hom[1, :] / uv_hom[2, :]
return np.vstack([u, v]).T
这段代码我用了好几年,基本没出过问题。唯一要注意的是输入的点要保证在相机前方,也就是 Z_cam > 0,否则投影到图像上的点会是反的。
小技巧: 调试时可以用棋盘格标定板来验证转换是否正确。先检测出角点的像素坐标,再用已知的世界坐标反投影回去,看看误差有多大。我一般要求重投影误差小于 0.5 像素才算通过。
好了,坐标系基础就讲到这里。这四个坐标系之间的转换,是外参标定的基本功。你只要把每一步的物理意义搞清楚了,后面学手眼标定、多传感器融合都会轻松很多。