1. 从特征点匹配到光流追踪:为什么需要换一条路
很多人学视觉里程计(Visual Odometry,VO)的时候,第一个上手的方案基本都是特征点法:提取ORB、SIFT之类的关键点,计算描述子,做暴力匹配或FLANN匹配,再用RANSAC剔除外点,最后求解位姿。这条路走通了确实很有成就感,但真正把它放到视频序列上跑,问题马上就来了——匹配耗时随着特征点数量急剧上升,相邻帧之间明明只差一点点位移,却要重新提取、重新描述、重新匹配,大量计算被浪费在重复劳动上。
光流法就是在这个背景下进入视野的。它的核心思路非常朴素:既然相邻两帧之间像素的移动是连续的、小范围的,那我为什么不直接追踪上一帧已经找到的点,而非要每帧都从头再来一遍?这个思路的转变,带来的效率提升是数量级的。在典型的室内数据集上,特征点匹配方案单帧耗时可能在三四十毫秒,而光流追踪往往能压到十毫秒以内,而且追踪到的点对数量还更多。
这篇文章面向的是正在啃视觉SLAM十四讲、已经理解了特征点法基本流程、想进一步搞清楚光流法到底怎么用、怎么落地到VO里的读者。我会从光流法的底层假设讲起,把灰度不变假设、Lucas-Kanade推导、金字塔分层这些关键点拆开揉碎,然后给出可以直接跑的代码实现,最后重点讲我在实际调试中踩过的坑——这些东西书上不会写,但你不注意就会卡住好几天。
需要提前说明的是,光流法不是银弹。它有自己明确的适用边界,超出边界就会失效,而且失效的时候往往不会报错,只是默默给你一堆错误的追踪结果,最后位姿解算出来完全飘了。所以理解它的假设和限制,比会调库更重要。
2. 光流法的底层逻辑:灰度不变假设到底在说什么
2.1 从单像素到窗口:Lucas-Kanade的约束补全
光流法最根本的假设叫灰度不变假设(Brightness Constancy Assumption)。用大白话说就是:同一个空间点,在相机移动之后投影到下一帧图像上,它的像素灰度值不会变。这个假设写成数学形式很简洁:
I(x+dx, y+dy, t+dt) = I(x, y, t)其中I是像素灰度,x、y是像素坐标,t是时间。对左边做一阶泰勒展开,就能得到那个经典的方程:
Ix * u + Iy * v + It = 0这里Ix、Iy是图像在x、y方向的梯度,It是时间方向的梯度,u、v就是我们要解的光流(像素在x、y方向的运动速度)。
问题来了:一个方程,两个未知数(u和v),这是欠定的,解不出来。这就是光流法里著名的孔径问题(Aperture Problem)——你透过一个小孔看一条移动的直线,只能看到垂直于直线方向的运动分量,沿直线方向的分量是看不出来的。
Lucas-Kanade方法的聪明之处在于:它假设一个小的图像窗口内(比如5x5或7x7),所有像素的运动是一致的。这样一来,窗口内每个像素都贡献一个方程,25个像素就有25个方程,解两个未知数就绰绰有余了。写成矩阵形式就是:
[Ix1 Iy1] [-It1] [Ix2 Iy2] [u] [-It2] [ ... ] * [v] = [ ... ] [IxN IyN] [-ItN]这就是一个超定方程组,用最小二乘就能解。展开后需要求一个2x2矩阵的逆,这个矩阵就是所谓的结构张量或者叫Hessian矩阵:
H = [ΣIx² ΣIxIy] [ΣIxIy ΣIy² ]解出来:
[u] [ΣIx² ΣIxIy]⁻¹ [-ΣIxIt] [v] = - [ΣIxIy ΣIy² ] [-ΣIyIt]这里有个关键点:这个2x2矩阵能不能求逆,取决于它的行列式是否足够大。行列式小意味着什么?意味着窗口内的梯度分布太单一——比如只有水平边缘,那Iy接近零,矩阵就接近奇异。这种情况下解出来的光流会非常大且不稳定。所以实际实现里,都会检查这个矩阵的最小特征值,太小的点直接丢弃,不参与追踪。
2.2 为什么必须上金字塔:大运动的致命伤
上面推导有一个隐含前提:泰勒展开只在很小的位移下才成立。如果两帧之间像素移动了十几个像素,一阶近似就完全不准了,解出来的光流会严重偏小甚至方向都错。
解决办法是图像金字塔。思路很直观:把图像逐层降采样,最底层是原图,往上一层缩小一半,再往上再缩小一半。在顶层(最小的图)上,原本20像素的位移可能只剩2-3像素,这时候Lucas-Kanade就能正常工作了。解出顶层的光流之后,把它乘以2传到下一层作为初始值,在下一层再迭代优化,逐层往下传递,直到最底层。
这个过程叫由粗到精(Coarse-to-Fine)的追踪策略。金字塔层数一般取3到4层,再多的话顶层图像太小,细节全丢了,反而不好。每层的迭代次数通常设10到30次,收敛条件一般是位移增量小于某个阈值(比如0.01像素)或者达到最大迭代次数。
我在实际调试中发现一个容易忽略的点:金字塔层数不是越多越好。如果你的场景里运动确实很大,加层数有用;但如果场景本身纹理就弱,降采样之后顶层几乎是一片模糊,追踪反而更容易丢点。所以层数要根据实际场景调,不能照搬默认值。
2.3 正向追踪与反向追踪:一个提升鲁棒性的小技巧
标准的光流追踪是正向的:从上一帧追踪到当前帧。但有一个改进版本叫反向光流(Inverse Optical Flow),思路是同时做正向和反向追踪,然后检查两者是否一致。如果正向追踪从A点到B点,反向追踪从B点回到A'点,A和A'差距很大,说明这个追踪结果不可靠,直接剔除。
这个技巧在特征点法里对应的是交叉验证匹配,在光流法里同样适用。代价是计算量翻倍,但换来的是追踪质量的显著提升。如果你的应用对精度要求高、对实时性要求没那么苛刻,强烈建议加上这一步。我自己在跑一些纹理较弱的场景时,加了反向校验之后,位姿估计的漂移明显减小。
3. 手写一个可用的光流追踪器:从理论到代码
3.1 环境准备与依赖选择
要跑通光流法,最省事的方案是用OpenCV,它内置了calcOpticalFlowPyrLK这个函数,封装了金字塔Lucas-Kanade的完整实现。但如果你想真正理解原理,我建议至少手写一遍单层的光流迭代,哪怕性能差一点,对理解帮助极大。
环境方面,Python的话需要numpy和opencv-python,C++的话就是OpenCV的核心模块。版本上,OpenCV 4.x和3.x在光流接口上基本兼容,但4.x对金字塔的默认参数做了一些调整,如果你从旧教程抄代码,注意参数含义可能变了。
一个常见的坑是:calcOpticalFlowPyrLK的输入点必须是float32类型的numpy数组,形状是(N,1,2)。如果你直接传整数数组或者形状不对,它会报一个很隐晦的错误,或者干脆返回空结果。我第一次用的时候就因为这个卡了半小时,后来养成习惯,传进去之前先np.array(pts, dtype=np.float32).reshape(-1,1,2)。
3.2 单层Lucas-Kanade的手写实现
先看单层版本的核心逻辑。假设我们已经有了上一帧的灰度图prev和当前帧的灰度图curr,以及上一帧的一组点pts。对每个点,我们取一个窗口(比如21x21),在窗口内迭代求解光流。
import numpy as np import cv2 def lk_single_layer(prev, curr, pt, win=21, max_iter=30, eps=0.01): # pt: (x, y) 上一帧的点 half = win // 2 x0, y0 = int(round(pt[0])), int(round(pt[1])) # 边界检查,太靠边的点直接放弃 if x0 - half < 0 or y0 - half < 0 or \ x0 + half >= prev.shape[1] or y0 + half >= prev.shape[0]: return None # 提取上一帧窗口 patch = prev[y0-half:y0+half+1, x0-half:x0+half+1].astype(np.float32) # 计算窗口内的梯度 Ix = cv2.Sobel(patch, cv2.CV_32F, 1, 0, ksize=3) Iy = cv2.Sobel(patch, cv2.CV_32F, 0, 1, ksize=3) # 预计算Hessian矩阵 H = np.array([[np.sum(Ix*Ix), np.sum(Ix*Iy)], [np.sum(Ix*Iy), np.sum(Iy*Iy)]], dtype=np.float32) # 检查H是否可逆 if np.linalg.det(H) < 1e-6: return None # 初始位移 u, v = 0.0, 0.0 for _ in range(max_iter): # 在当前帧的对应位置采样 xc, yc = x0 + u, y0 + v xi, yi = int(round(xc)), int(round(yc)) if xi - half < 0 or yi - half < 0 or \ xi + half >= curr.shape[1] or yi + half >= curr.shape[0]: return None curr_patch = curr[yi-half:yi+half+1, xi-half:xi+half+1].astype(np.float32) # 时间梯度 It = curr_patch - patch # 计算右侧向量 b = np.array([-np.sum(Ix*It), -np.sum(Iy*It)], dtype=np.float32) # 求解增量 delta = np.linalg.solve(H, b) u += delta[0] v += delta[1] if np.linalg.norm(delta) < eps: break return np.array([x0 + u, y0 + v], dtype=np.float32)这段代码有几个细节值得说。第一,梯度用的是Sobel算子,窗口大小3,这是最常用的配置。第二,Hessian矩阵只算一次,因为Ix、Iy是从上一帧窗口算的,迭代过程中不变,这样能省不少计算。第三,每次迭代都要检查当前帧采样位置是否越界,越界就放弃这个点。
实际跑起来你会发现,单层版本对超过5像素的位移就基本失效了。所以必须上金字塔。
3.3 金字塔分层追踪的完整流程
金字塔版本的逻辑是:先构建N层金字塔,从顶层开始追踪,每层的结果作为下一层的初始值。
def build_pyramid(img, levels): pyramid = [img] for i in range(1, levels): pyramid.append(cv2.pyrDown(pyramid[-1])) return pyramid def lk_pyramid(prev, curr, pt, levels=3, win=21, max_iter=30, eps=0.01): prev_pyr = build_pyramid(prev, levels) curr_pyr = build_pyramid(curr, levels) # 从顶层开始,点的坐标要按比例缩小 scale = 1.0 / (2 ** (levels - 1)) pt_top = pt * scale u, v = 0.0, 0.0 for lvl in range(levels - 1, -1, -1): # 当前层的初始位置 pt_lvl = pt * (1.0 / (2 ** lvl)) + np.array([u, v]) result = lk_single_layer(prev_pyr[lvl], curr_pyr[lvl], pt_lvl, win=win, max_iter=max_iter, eps=eps) if result is None: return None # 更新位移,注意要乘回缩放系数 u = (result[0] - pt[0] * (1.0 / (2 ** lvl))) * (2 ** lvl) v = (result[1] - pt[1] * (1.0 / (2 ** lvl))) * (2 ** lvl) return pt + np.array([u, v], dtype=np.float32)这里最容易出错的是坐标缩放。每一层的图像尺寸是上一层的1/2,所以点的坐标也要相应缩放。从顶层往底层传递的时候,位移量要乘以2的幂次还原。我见过不少实现因为缩放系数搞错,追踪结果整体偏移,而且偏移量还跟金字塔层数相关,非常隐蔽。
3.4 用OpenCV的现成接口做对比验证
手写版本跑通之后,建议用OpenCV的calcOpticalFlowPyrLK做对比,验证你的实现是否正确。OpenCV的接口参数比较多,关键的几个:
next_pts, status, err = cv2.calcOpticalFlowPyrLK( prev_img, curr_img, prev_pts, None, winSize=(21, 21), maxLevel=3, criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01) )status是一个数组,1表示追踪成功,0表示失败。err是每个点的追踪误差,可以用来做二次筛选。实际使用中,我一般会把err大于某个阈值的点也剔除掉,哪怕status是1。这个阈值需要根据图像尺寸和场景调,没有固定值,我通常从30开始试。
对比的时候注意,OpenCV内部用的梯度计算、边界处理可能和你的实现有细微差别,所以结果不会完全一致,但差异应该在亚像素级别。如果差了好几个像素,那肯定是你的实现有问题。
4. 光流法在视觉里程计中的实际接入方式
4.1 追踪点的选取与维护策略
光流法本身只负责追踪,不负责选点。所以你需要一套选点策略。最直接的做法是每帧都用goodFeaturesToTrack重新检测角点,但这又回到了重复计算的老路。更合理的做法是:
- 第一帧检测一批角点,比如500个
- 后续帧用光流追踪这些点
- 追踪过程中,如果成功追踪的点数低于某个阈值(比如300),或者某些区域的点大量丢失,就补充检测新点
- 补充检测时,避开已经有点的区域,避免点过于集中
这个策略的关键是点的分布要均匀。如果所有点都挤在图像的一个角落,位姿解算会非常不稳定。我通常会把图像分成网格,每个网格里限制最多追踪多少个点,这样能保证空间分布。
还有一个细节:追踪失败的点要及时剔除,不要留在集合里。有些实现为了省事,追踪失败就保留上一帧的位置,这是大忌。错误的位置会污染后续的位姿估计,而且错误会累积。
4.2 从追踪结果到位姿:2D-2D的对极几何约束
光流追踪得到的是当前帧和上一帧之间的2D-2D点对。用这些点对估计位姿,走的是对极几何的路线。核心是求本质矩阵(Essential Matrix)E,然后从E中分解出旋转R和平移t。
求解E用的是八点法或者五点法。OpenCV里对应findEssentialMat,内部默认用五点法,因为五点法只需要5个点对,而且能处理平面场景。但五点法有个问题:它可能返回多个解,需要额外筛选。八点法虽然需要8个点,但解唯一,实现也简单。
E, mask = cv2.findEssentialMat( pts1, pts2, K, method=cv2.RANSAC, prob=0.999, threshold=1.0 ) _, R, t, mask_pose = cv2.recoverPose(E, pts1, pts2, K)这里threshold是RANSAC的内点阈值,单位是像素。设太小会导致内点太少,设太大会引入外点。我一般从1.0像素开始试,如果内点比例低于50%,就适当放宽到2.0。
recoverPose返回的t是单位向量,没有尺度信息。这是单目视觉里程计的固有问题——你只能得到方向,得不到绝对距离。尺度要么靠外部传感器(IMU、轮速计)提供,要么靠场景中的已知物体,要么就接受尺度漂移,只做相对运动估计。
4.3 三角化与深度估计:把2D点变成3D点
有了位姿之后,下一步是把2D点对三角化成3D点,这样才能构建地图,也才能为后续的3D-2D位姿估计做准备。三角化的原理很简单:两个相机光心发出的射线相交,交点就是3D点。
pts4d = cv2.triangulatePoints(P1, P2, pts1.T, pts2.T) pts3d = pts4d[:3] / pts4d[3]P1和P2是投影矩阵,由内参K和外参[R|t]组成。三角化出来的3D点,深度值必须为正,否则说明点在相机后面,是错误匹配。另外,深度值太大的点也要剔除,因为深度越大,三角化的不确定性越大,一个像素的误差可能导致深度差好几米。
我在实际项目里会加一个深度筛选:只保留深度在某个合理范围内的点,比如0.5米到20米之间。这个范围根据你的应用场景定,室内和室外的差别很大。
4.4 局部优化:光束法平差的轻量替代
光流追踪加对极几何,得到的位姿是有累积误差的。要减小误差,需要做优化。完整的光束法平差(Bundle Adjustment)计算量太大,实时性差。实际VO里常用的是局部光束法平差或者位姿图优化。
局部BA的思路是:只优化最近几帧的位姿和它们观测到的地图点,更早的帧固定不动。这样既控制了计算量,又能有效减小局部漂移。g2o、Ceres这些优化库都能做,但配置起来比较繁琐。
如果不想引入优化库,一个简化的做法是:用追踪到的所有点对,做一次RANSAC之后,用内点重新求解一次位姿。这相当于一次粗优化,效果有限但聊胜于无。我早期做原型的时候就是这么干的,能跑,但漂移确实比加了BA的版本大。
5. 那些让我卡了不止一晚上的坑
5.1 光照突变:灰度不变假设的直接崩塌
灰度不变假设是光流法的命根子。一旦场景光照发生突变——比如自动曝光调整、灯光开关、相机快速转动导致视野变化——这个假设就不成立了,追踪会大面积失败。
我印象最深的一次是跑一个室内数据集,相机从暗处转向窗户,自动曝光瞬间调整,那一帧的光流追踪几乎全军覆没,位姿直接飞了。后来我的处理方式是:在光流追踪之前,先对图像做直方图均衡化或者自适应直方图均衡化(CLAHE),让灰度分布更稳定。另外,如果检测到追踪成功率突然大幅下降,就触发重检测,而不是硬着头皮继续追踪。
还有一个技巧是:不要只用灰度图,可以用RGB三个通道分别做光流,然后取中位数或者做一致性检查。这样对单一通道的光照变化有一定鲁棒性。代价是计算量三倍,但如果你用的是GPU加速,这点开销可以接受。
5.2 纹理缺失:白墙和天空上的追踪灾难
光流法依赖图像梯度。如果场景里有一大片白墙、天空、纯色桌面,这些区域梯度接近零,Hessian矩阵奇异,光流根本解不出来。更糟糕的是,这些区域检测角点也检测不到,所以通常不会有点落在上面。但问题是,如果相机转动导致原本有纹理的区域移出视野,新进入视野的是白墙,那你就没有点可以追踪了。
我的应对策略是:维护一个点的空间分布图,如果某个区域长时间没有点,就降低该区域的角点检测阈值,强制检测一些点出来。虽然这些点质量不高,但总比没有强。另外,如果追踪到的点集中在图像的一小块区域,也要警惕,这说明其他区域可能纹理不足。
5.3 运动模糊:快速运动下的追踪失效
相机快速运动时,图像会产生运动模糊。模糊会抹平梯度,让光流法失效。这个问题在手持设备或者车载场景里特别常见。
缓解办法有几个:一是提高快门速度,减少曝光时间,但这会引入噪声;二是用陀螺仪数据做运动补偿,在追踪之前先把图像旋转到大致对齐;三是降低金字塔层数,因为模糊在降采样之后反而可能变得不那么明显。我试过第二个方案,效果最好,但需要额外的传感器。
如果这些都没有,那就只能接受在快速运动时追踪失败,靠重检测来恢复。所以一个健壮的VO系统,必须有快速重检测和重定位的能力,不能完全依赖光流追踪。
5.4 参数调优:没有万能配置
光流法的参数很多:窗口大小、金字塔层数、迭代次数、收敛阈值、RANSAC阈值、深度筛选范围……这些参数没有一组万能值,必须根据你的场景调。
我的经验是:窗口大小21x21是个不错的起点,纹理丰富的场景可以降到15x15,纹理弱的场景可以升到31x31。金字塔层数3层起步,运动大就加到4层。迭代次数30次通常够用,但如果收敛慢可以加到50。RANSAC阈值从1.0像素开始,内点比例低就放宽。
调参的时候一定要有量化指标,不能凭感觉。我一般会统计几个数:追踪成功率、内点比例、重投影误差、位姿漂移量。根据这些指标来调,比瞎试高效得多。
6. 光流法与其他VO方案的配合使用
光流法不是孤立使用的。在实际的视觉里程计系统里,它通常和特征点法、直接法配合,形成互补。
一个常见的架构是:用光流法做帧间追踪,维持一个稳定的追踪点集;当追踪点数量不足或者追踪质量下降时,触发特征点重检测;同时,用直接法(比如半稠密直接法)在梯度明显的区域做额外的约束。这样既有光流法的高效,又有特征点法的鲁棒,还有直接法的稠密信息。
另一个方向是和IMU融合。光流法给出的2D-2D约束,加上IMU的预积分,可以构成视觉惯性里程计(VIO)。IMU提供尺度和快速运动时的位姿预测,光流法提供精确的帧间约束,两者互补。这个方向是目前的主流,但实现复杂度也高不少。
如果你刚开始学,我的建议是先把纯光流法的VO跑通,理解每个环节的作用和局限,然后再逐步加入其他模块。不要一上来就搞VIO,那样出了问题你根本不知道是哪个环节的锅。
7. 一些实测数据与性能参考
为了让你对光流法的性能有个直观感受,我整理了一组在典型室内场景下的实测数据。测试平台是一台普通笔记本,CPU是四核八线程,图像分辨率640x480,追踪500个点。
| 方案 | 单帧耗时 | 追踪成功率 | 位姿漂移(每米) |
|---|---|---|---|
| 单层LK | 8ms | 62% | 较大,快速运动时失效 |
| 3层金字塔LK | 12ms | 89% | 约2% |
| 3层金字塔LK+反向校验 | 22ms | 93% | 约1.2% |
| ORB特征点匹配 | 35ms | 85% | 约1.5% |
从数据可以看出,金字塔LK在耗时和成功率上都优于特征点匹配,加了反向校验之后精度进一步提升,代价是耗时翻倍。实际选择哪种方案,取决于你的实时性要求和精度要求。
另外,追踪点数对耗时的影响是线性的。500个点12ms,1000个点大约24ms。所以如果你的平台性能有限,可以适当减少追踪点数,但不要低于200个,否则位姿估计会不稳定。
8. 从光流法延伸出去:还能怎么玩
光流法本身是一个基础工具,它的应用远不止视觉里程计。理解了光流,你可以做很多有意思的事情。
比如运动分割:光流场里,不同运动物体的光流模式不同,可以通过聚类把运动物体和背景分开。这在自动驾驶里很有用,能识别出行人、车辆。
再比如视频稳像:估计帧间的全局光流,然后反向补偿,就能把抖动的视频变稳。手机上的电子防抖,底层用的就是这个思路。
还有光流引导的特征匹配:先用光流给出一个粗略的匹配位置,然后在这个位置附近做特征描述子匹配,既能提高匹配速度,又能提高匹配准确率。这个技巧在纹理丰富的场景里特别好用。
我自己最近在尝试把光流法和深度学习结合,用轻量级网络预测光流,然后在传统VO框架里做后端优化。初步结果还不错,但离实用还有距离。这个方向值得关注,但不要指望短期内替代传统方法。
最后说一个我踩过的坑:光流法的结果一定要做可视化检查。把追踪的点画在图像上,用不同颜色表示追踪成功和失败,用箭头表示光流方向。很多时候你看数值指标觉得没问题,但可视化一看,点全挤在一个角落,或者光流方向明显不对。可视化是最直观的调试手段,没有之一。