3、坐标系与空间变换:世界坐标系、相机坐标系、图像坐标系、像素坐标系及其转换关系
做车载摄像头标定,说白了就是在玩坐标系之间的“翻译”游戏。
你想想看,摄像头拍到的是一张二维图片,但车外面的世界是三维的。怎么把三维空间里的一个点,映射到二维图像上?这中间就得经过好几层坐标系的转换。我刚开始接触这块时,也被这四个坐标系绕得头晕。后来亲手调过几款摄像头,踩过坑,才慢慢理清了脉络。
3.1 四个坐标系,一个都不能少
我们先搞清楚这四个坐标系分别长什么样,各自管什么事。
- 世界坐标系 (World Coordinate System):这是咱们定义的大地坐标系。你可以把原点放在车头、路中间,或者任意一个固定位置。Xw、Yw、Zw 三个轴,描述的是物体在真实世界里的位置。比如“前方10米处那个路标”,就是世界坐标系下的坐标。
- 相机坐标系 (Camera Coordinate System):这个坐标系的原点在相机的光心(就是镜头中心)。Zc 轴指向相机正前方,Xc 轴向右,Yc 轴向下。说白了,就是把世界坐标系“搬到”相机身上,以相机为原点看世界。
- 图像坐标系 (Image Coordinate System):这是二维坐标系,原点在相机的光轴与成像平面的交点(通常是图像中心)。x 轴向右,y 轴向下。单位是毫米(mm),描述的是物体在成像平面上的物理位置。
- 像素坐标系 (Pixel Coordinate System):这也是二维坐标系,但原点在图像的左上角。u 轴向右,v 轴向下。单位是像素(pixel)。我们最终看到的图片,就是像素坐标系下的数据。
核心记忆点:世界→相机→图像→像素,这是一条单向流水线。数据只能从三维往二维流,不能倒着来(除非你搞三维重建)。
3.2 转换关系:一步步拆解
好,现在咱们一步步走通这条流水线。我会用数学公式,但尽量讲得接地气。
3.2.1 从世界坐标系到相机坐标系(刚体变换)
这一步其实就是一个旋转加一个平移。把世界坐标系下的点 Pw (Xw, Yw, Zw),通过旋转矩阵 R 和平移向量 t,变成相机坐标系下的点 Pc (Xc, Yc, Zc)。
公式长这样:
| Xc | | r11 r12 r13 | | Xw | | tx |
| Yc | = | r21 r22 r23 | | Yw | + | ty |
| Zc | | r31 r32 r33 | | Zw | | tz |
写成齐次坐标形式更简洁:
| Xc | | R t | | Xw |
| Yc | = | | | Yw |
| Zc | | 0 1 | | Zw |
| 1 | | | | 1 |
这里的 R 是 3x3 的旋转矩阵,t 是 3x1 的平移向量。R 和 t 合起来,就是我们常说的 相机外参。
我的经验:外参标定,说白了就是求 R 和 t。我在项目里遇到过,如果车停在不平的路面上,外参里的俯仰角(pitch)会偏得离谱,导致车道线检测全歪。所以标定场地一定要平整,这是前提。
3.2.2 从相机坐标系到图像坐标系(透视投影)
这一步是三维到二维的关键。用的是小孔成像原理。
假设相机坐标系下有一个点 Pc (Xc, Yc, Zc),它在成像平面上的投影点 p (x, y) 满足:
x = f * Xc / Zc
y = f * Yc / Zc
其中 f 是焦距(单位:毫米)。写成矩阵形式:
| x | | f 0 0 0 | | Xc |
| y | = | 0 f 0 0 | | Yc |
| 1 | | 0 0 1 0 | | Zc |
| 1 |
注意:这里 Zc 不能为 0。如果 Zc=0,说明物体在相机光心平面上,投影会跑到无穷远。实际场景中,Zc 永远是正数(物体在相机前方)。
3.2.3 从图像坐标系到像素坐标系(离散化)
图像坐标系是连续的(毫米),像素坐标系是离散的(像素)。这一步就是把物理尺寸转换成像素个数。
假设每个像素在 x 方向上的物理尺寸是 dx(毫米/像素),在 y 方向上是 dy(毫米/像素)。图像坐标系原点 (ox, oy) 在像素坐标系中的位置是 (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 |
这里的 1/dx、1/dy、u0、v0,就是 相机内参 的一部分。
3.3 合在一起:完整的投影矩阵
把上面三步串起来,就得到了从世界坐标到像素坐标的完整转换:
| u | | fx 0 u0 0 | | R t | | Xw |
| v | = | 0 fy v0 0 | | | | Yw |
| 1 | | 0 0 1 0 | | 0 1 | | Zw |
| | | 1 |
其中 fx = f/dx,fy = f/dy。前面那个 3x4 矩阵叫 内参矩阵 K,后面那个 4x4 矩阵叫 外参矩阵 [R|t]。
整个公式可以简写为:
p = K * [R|t] * Pw
p 是像素坐标,Pw 是世界坐标。
一句话总结:标定的本质,就是求出内参 K 和外参 [R|t]。内参决定了“镜头怎么成像”,外参决定了“相机装在车上的什么位置、什么角度”。
3.4 实战中的坑与技巧
理论讲完了,说点实际的。我在做车载摄像头标定时,遇到过几个典型问题:
- 棋盘格要放平:我曾经为了省事,用手拿着棋盘格在车前晃。结果标出来的内参,畸变系数大得离谱。后来老老实实把棋盘格贴在平板上,一次过。
- 图片要覆盖全视野:标定图片如果只集中在画面中央,边缘的畸变参数就标不准。我建议至少拍 20 张,棋盘格要出现在画面的四个角和中心区域。
- Zc 的符号:在相机坐标系下,物体在相机前方,Zc 为正。如果你发现投影后的像素坐标是负数,说明物体在相机后面——检查一下外参的平移向量是不是反了。
避坑指南:我曾经在标定环视摄像头时,发现四个摄像头的投影拼接不到一起。查了半天,原来是其中一个摄像头的外参旋转矩阵 R 算错了符号。记住,旋转矩阵必须是正交矩阵,行列式为 +1。如果行列式是 -1,说明你搞了个镜像变换,赶紧检查代码。
3.5 代码示例:用 OpenCV 实现坐标转换
下面给一段 Python 代码,演示如何用 OpenCV 做坐标转换。实际项目中,你大概率会直接用 cv2.projectPoints,但理解底层逻辑很重要。
import cv2
import numpy as np
# 假设内参和畸变系数已经标定好
K = np.array([[fx, 0, u0],
[0, fy, v0],
[0, 0, 1]], dtype=np.float32)
dist_coeffs = np.array([k1, k2, p1, p2, k3], dtype=np.float32)
# 外参:旋转向量和平移向量
rvec = np.array([rx, ry, rz], dtype=np.float32) # 旋转向量
tvec = np.array([tx, ty, tz], dtype=np.float32) # 平移向量
# 世界坐标系下的点(例如:路面上一个点)
world_point = np.array([[Xw, Yw, Zw]], dtype=np.float32)
# 投影到像素坐标系
image_points, _ = cv2.projectPoints(world_point, rvec, tvec, K, dist_coeffs)
print("像素坐标:", image_points[0][0])
这段代码里,cv2.projectPoints 内部就完成了我们上面讲的三步转换。你只需要提供世界坐标、外参、内参,它就能算出像素坐标。
注意:OpenCV 的旋转向量 rvec 是 3x1 的向量,不是旋转矩阵。内部会用 Rodrigues 公式转成矩阵。如果你自己写代码,记得用 cv2.Rodrigues 做转换。
3.6 小结
嗯,坐标系转换这块,说白了就是“世界→相机→图像→像素”这条流水线。每个环节都有对应的数学工具:刚体变换、透视投影、离散化。标定的目标就是求出内参和外参。
我个人习惯在项目开始前,先在纸上把坐标系画一遍,标出每个轴的方向。这样写代码时不容易搞混。你也不妨试试。
下一章,我们会聊畸变校正——为什么镜头拍出来的直线是弯的,以及怎么把它掰直。