ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

投影空间中的点与线:消失点与单应矩阵的齐次变换

投影空间中的点与线:消失点与单应矩阵的齐次变换

有位做视觉项目的同学在某段代码里看到了[VP]这个前缀,第一反应是 Viewport,后来才发现全称是 Vanishing Point,也就是消失点。真正把他卡住的,是后面那句“投影空间坐标和线的齐次变换”:为什么同一个 3×3 的矩阵H,点直接用x' = H x就完事,线却要用l' = H^{-T} l?这个问题看起来只是两个公式,但几乎是一切消失点检测、单应矩阵拼接、相机标定和三维重建的地基。这篇笔记就把点、线、齐次变换之间的关系从头捋一遍,后面附可以直接跑的验证代码。适合第一次接触单应矩阵、想搞懂消失点为什么能算出来的开发者,也适合被“线变换到底该乘哪边”折磨过的人。

1. 为什么投影空间里坐标和线都要用齐次表示

1.1 点为什么非要多一个分量

普通二维坐标是(x, y),但在这个表示下,平移不是线性操作。你想把一幅图像上的所有点整体往右平移 30 像素、往上平移 10 像素,一个 2×2 矩阵做不到,因为平移需要加法,而矩阵乘法天生只能做线性组合。解决办法是给坐标加一个固定的尾巴,写成(x, y, 1),然后所有几何变换都统一成矩阵乘法。

更一般地说,齐次坐标是(wx, wy, w),其中w是比例因子。取w=1就是最常见的欧氏坐标;取w为其他非零值也没关系,因为投影空间里的点只看比例,不看绝对大小。(2, 4, 2)和(1, 2, 1)在投影几何里是同一个点,它们都对应平面上的(1, 2)。

这个“多出来的 w”不是多了一个空间维度。你依然活在二维平面上,只是给坐标换了一套允许整体缩放的记账方式。这样做最大的好处是:旋转、缩放、平移,甚至透视变形,都可以塞进同一个矩阵里。以后在做相机投影、图像矫正、消失点估计时,所有环节都用同一套代数规则,不用来回切换特殊写法。

1.2 直线也是三个数

在投影平面里,一条直线同样用一个三维向量表示。设直线为l = (a, b, c),它的方程是:

a x + b y + c w = 0

当w=1时,就是熟悉的a x + b y + c = 0。(a, b)是直线的法向量,c控制直线到原点的偏移。比如x = 0这条竖直线,可以写成l = (1, 0, 0);y = 0这条水平线,可以写成l = (0, 1, 0);直线y = 2x + 3,移项得到2x - y + 3 = 0,所以l = (2, -1, 3)。

和点一样,直线的三个数也不是唯一的。l = (1, -2, 300)和l = (2, -4, 600)表示同一条直线,因为两边同时乘了一个非零常数,方程不变。这个性质在实际代码里非常重要:当你从两个端点求出一条直线后,得到的(a, b, c)可能带着任意尺度,不能想当然地直接拿它和其他直线做数值比较。

1.3 w=0 不是 bug,是消失点的入口

如果齐次坐标的w=0,点就变成了“无穷远点”。比如(400, 200, 0),它表示沿方向(400, 200)的无穷远处,或者说这是一个方向,而不是一个有限位置。两条平行直线在欧氏几何里永远不相交,但在投影几何里,它们会在无穷远处交于同一个点,这正是消失点的雏形。

消失点在透视图像里是真实可见的:铁路轨道的两条平行线延伸到远处,最终在画面的某一点会合,这个点就是那组平行线的消失点。在投影几何中,我们不需要额外发明一套“无穷远”的规则,直接用w=0的齐次坐标就能表达。理解这一点,后面就会明白为什么一个普通的叉积操作能算出消失点。

2. 齐次变换下的点与线:一个正乘,一个倒乘

2.1 点的变换:x' = H x

二维投影变换(也叫单应变换)用一个 3×3 的可逆矩阵H表示。平面上一个齐次点x = (x, y, 1),变换后是:

x' = H x

H可以编码旋转、缩放、平移,还可以编码由相机角度引起的透视变形。假设H是:

H = [[1, 0, 0], [0, 1, 0], [0.001, 0, 1]]

这个矩阵会让原始点的w变成1 + 0.001x,于是水平坐标会出现一种近大远小的压缩效果。变换完以后,如果x'的第三分量不是 1,需要再除以w回到欧氏坐标。这是很多视觉代码最容易忽略的一步:

import numpy as np H = np.array([ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.001, 0.0, 1.0] ]) # 原始点用齐次坐标表示 p = np.array([100.0, 50.0, 1.0]) p_t = H @ p # 去齐次化:除以 w p_2d = p_t[:2] / p_t[2]

2.2 线的变换:l' = H^{-T} l

点变换是顺着H乘,直线变换却不是l' = H l,而是l' = H^{-T} l。这里的H^{-T}是“先求逆,再转置”,也等价于“先转置,再求逆”。

推导过程其实很直接。一条原始直线l满足约束:

l^T x = 0

意思是所有在直线上的点,都和直线向量的内积为零。现在点做了变换x' = H x,反解出x = H^{-1} x',代入上面的约束:

l^T H^{-1} x' = 0

这个式子说明,新坐标系下的点x'如果想还在变换后的直线上,它的约束向量应该是l^T H^{-1}。把两边转置,就得到:

l' = H^{-T} l

为什么直觉上感觉“绕”?因为点是一个位置,跟着变换走就行;线是一个约束,它定义的是“哪些点在这条线上”。坐标系都变了,约束自然要逆着原坐标系回到新的表示里去。打个比方:你在一张透明纸上画了一条线,然后整张纸被一个投影仪映射到墙上。线上的每个点都被映射到了新位置,但那条线的方程描述的是纸上的坐标。要写出一条“用墙上坐标表示的线”,必须把墙上的坐标先映射回纸上的坐标,再套进原来的直线方程,这个过程天然就需要逆矩阵。转置则是把向量的角色翻转:位置向量和约束向量在坐标变换下本来就是对偶的。

2.3 复合变换的先后顺序,别被“倒着乘”绕晕

如果一个点依次经过H1和H2两次变换,总变换是:

H_total = H2 @ H1 x' = H_total @ x

点变换好办,按顺序从左往右乘就好。直线变换要小心,线使用总矩阵的逆转置:

l' = H_total^{-T} l = (H2 @ H1)^{-T} l = H1^{-T} @ H2^{-T} @ l

注意这里H1^{-T}在前面,H2^{-T}在后面,和点的顺序正好是反的。写成代码时最稳妥的做法是直接算总数:

H_total = H2 @ H1 l_new = np.linalg.inv(H_total).T @ l

如果为了性能想拆开算,那就要按H1^{-T} @ H2^{-T}的顺序来。我见过不少项目上线之前死在这:点、线一起做变换,点怎么测都对,线就是差一点,最后发现复合变换的顺序写反了。

2.4 三维投影空间的扩展:点、平面和线的 Plücker 表示

这套“点正乘、线倒乘”的规律在三维投影空间里依然成立。三维齐次点是四维向量X = (X, Y, Z, W),一个平面用四维向量π = (π1, π2, π3, π4)表示,点在平面上的条件是π^T X = 0。在 4×4 的可逆变换H下,点的变换是X' = H X,平面的变换是:

π' = H^{-T} π

三维空间里的直线稍微复杂一些,因为一条直线不能只用四个数唯一表示。常见的做法是 Plücker 坐标:一条直线可以由线上两个点p、q组成一个反对称矩阵:

L = p q^T - q p^T

在变换H下,p变成H p,q变成H q,所以:

L' = H L H^T

这就是三维投影空间里直线的齐次变换公式。如果你以后做多视角几何、极线约束或者射线求交,会发现这些公式都是同一棵树长出来的不同分支。搞清楚二维的l' = H^{-T} l,三维的很多结论就只是“抬高一个维度、换一种坐标”而已。

3. 消失点计算实战:从线变换到 VP

3.1 为什么平行线会交于一点

三维世界里的一组平行线共享同一个方向d。在投影几何里,它们共同的交点是方向d对应的无穷远点,齐次坐标写作(d, 0)。相机投影本质上是把三维齐次点乘一个 3×4 的投影矩阵P,而P作用于(d, 0)时,会得到一个有限的齐次图像点,这就是消失点。

换句话说,消失点不是一个奇怪的现象,而是投影变换把无穷远点映射到了画面里的有限位置。铁路轨道、马路标线、高楼竖直边缘,在照片里看起来汇聚到一起,实际上是投影几何的必然结果。

3.2 在图像里求消失点:叉积的香之处

在二维图像中,如果知道两条直线的方程l1和l2,它们的交点可以用叉积直接算出来:

v = l1 × l2

为什么叉积能求交点?因为叉积得到的向量同时垂直于l1和l2,于是l1^T v = 0且l2^T v = 0,恰好满足“同时落在两条直线上”的约束。比如检测轨道两边的边缘,分别拟合成直线l1和l2,交点就是画面的消失点。

如果两条直线真的完全平行,交点会是w=0的无穷远点。但在透视图像里,轨道两侧的边缘一般不会完全平行,所以交点通常落在有限位置。实际项目中,直线从像素点拟合出来会带噪声,几条直线不会精确交于同一个点,这时不要只靠一对直线做叉积,最好用多条直线的最小二乘交点,或者直接用 RANSAC 拟合所有直线的公共交点。

3.3 完整验证代码:点变换和线变换殊途同归

下面这个例子用同一个H同时验证两件事:一条直线在变换后确实要用H^{-T}来算;以及“用变换后的两条线求交点”和“直接对原始交点做点变换”得到的是同一个消失点。

import numpy as np def dehom(x): """把齐次坐标转成欧氏坐标""" return x[:2] / x[2] H = np.array([ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.001, 0.0, 1.0] ]) # 两条原始平行线:x - 2y + 300 = 0, x - 2y + 100 = 0 l1 = np.array([1.0, -2.0, 300.0]) l2 = np.array([1.0, -2.0, 100.0]) # 原始交点:(400, 200, 0),w=0,是无穷远点 v_inf = np.cross(l1, l2) print("原始交点(齐次):", v_inf) # 线变换:l' = H^{-T} l H_inv_T = np.linalg.inv(H).T l1_t = H_inv_T @ l1 l2_t = H_inv_T @ l2 v_from_line = dehom(np.cross(l1_t, l2_t)) # 点变换:v' = H v v_from_point = dehom(H @ v_inf) print("通过线变换得到 VP:", v_from_line) print("通过点变换得到 VP:", v_from_point) print("结果一致:", np.allclose(v_from_line, v_from_point))

运行这段代码后,你会发现原始交点确实是(400, 200, 0),也就是无穷远点;经过带透视分量的H映射后,它的w变成了0.4,归一化后成为一个有限消失点。两条路线得到的消失点完全一致。这个验证看起来简单,但在工程里特别实用:任何一次对直线变换的怀疑,都可以用这个最小例子先自检一遍。

3.4 消失线与仿射矫正:直线变换的另一个用途

消失点不止用来找“轨道交汇处”。在图像矫正里,如果同时拿到两个主方向上的消失点v1和v2,它们的叉积就是消失线:

l_inf_img = v1 × v2

消失线是图像中“无穷远直线”的像。投影几何里,仿射矫正的本质就是构造一个单应矩阵H,让这条消失线映射回标准的无穷远直线(0, 0, 1)。一旦完成这个映射,图像里的平行关系就恢复了,测量长度比、判断真实方向的很多问题都会变得简单。

在这个过程中,所有原始直线的方程都会按照l' = H^{-T} l被映射到矫正后的图像坐标系。你不需要重新做一遍边缘检测,只要把旧直线的三个系数丢进这个公式,就能得到矫正后的直线方程,后面再做交点、测角度,都是同一套齐次代数。

4. 实际项目中的高频翻车现场与排查心得

4.1 忘掉“除以 w”去归一化

这是最常见,也最容易排查的问题。齐次坐标里(200, 100, 2)对应的欧氏点是(100, 50),如果你忘记除以w,画面上的点就会差一个倍数。尤其是求消失点时,np.cross出来的结果几乎不可能刚好w=1,直接拿来渲染或者画点,点会飞到图像外面。

我的习惯是封装一个dehom函数,所有从齐次坐标转欧氏坐标的地方都走同一个入口,并且在确认w为零的时候单独处理。这样至少能保证“至少有一处除以 w”不会漏掉。

4.2 把H^{-T}写成H^T

H^{-T}和H^T在数值上很可能完全不一样。一个绕 z 轴旋转角度的变换H,它的转置恰好是反向旋转,但如果是带透视分量的矩阵,转置和逆转置就差得远了。用错之后最典型的现象是:点变换全部正确,线变换结果歪掉;或者消失点用点变换是对的,用线变换却对不上。排查时先检查代码里求线变换的位置是不是np.linalg.inv(H).T,别写成了H.T或者np.linalg.inv(H.T)。注意np.linalg.inv(H).T和np.linalg.inv(H.T)在数值上相等,但很多人会在这两个写法之间犹豫,最后手一抖写成H.T。

4.3 行向量、列向量与内存布局的暗坑

数学里默认点是列向量,变换是左乘H @ x。但不少图形库、深度学习框架的接口会把点存成行,或者要求你传(N, 2)的数组,内部再帮你补齐次坐标。这时候最容易出现“明明公式对,但结果总差一个转置”的怪问题。

我的建议是:在核心几何计算里,统一用(3,)或(N, 3)的数组,显式写成H @ p这种列向量形式。如果需要调用其他库的现成接口,先用一个已知点做一次最小验证,确认它内部到底是H @ p还是p @ H.T。这一步验证花不了两分钟,但能省掉后面几小时的 debug。

4.4 线不严格相交时的数值炸弹

真实图像里的直线由离散像素拟合而来,两条线往往不会精确相交于一点。如果直接用np.cross求交点,结果会被噪声带着跑。更稳的方式是收集多条直线,把每条直线当作一个约束,构造齐次线性方程组A x = 0,再用最小二乘或 SVD 解公共交点。做法是让每条直线的系数(a, b)先归一化到单位长度,这样不同直线在约束里的权重才一致:

def normalize_line(l): norm = np.hypot(l[0], l[1]) return l / norm

归一化不是改变直线方程,它只是把尺度统一,让数值稳定性好很多。对消失点检测这类任务,尽量用多条直线而不是两条,RANSAC 也不需要每次求严格交点,都是基于残差迭代的。

4.5 判断两条线是不是同一条:不要直接比较坐标

因为直线向量乘以任意非零常数都表示同一条直线,直接l1 == l2几乎永远返回 False。判断两条线是否重合,标准做法是看叉积的模长:

cross_norm = np.linalg.norm(np.cross(l1, l2))

如果这个值非常接近零,说明两条线在投影意义上重合。判断两个点是否相同也一样,先归一化w=1,再比较坐标,或者比较叉积趋近于零。很多人踩过这个坑后,会养成一个习惯:所有几何对象都先规范化再比较。

写到这里,我自己又想起第一次写消失点检测代码时的情景:点、线、矩阵全都验证过,最后却因为一行H_inv_T写成了H.T,让所有消失点都落在画面外。从那以后,我把“点乘 H,线乘 H 的逆转置”当成一条固定口诀贴在项目文档里。后来这个经验也用在了三维空间直线的 Plücker 变换上,只不过公式从H^{-T}变成了H L H^T。如果你也在处理投影空间里的坐标和线变换,记住两点变换的对称关系,再配合最小例子验证,基本不会出大问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进