1. 从特征点法到光流法:为什么视觉里程计需要另一条路
视觉里程计的核心任务,是根据相邻图像帧之间的像素变化,推算相机自身的运动。这件事听起来简单,做起来却有很多条路。最经典的一条路是特征点法:提取关键点、计算描述子、匹配、然后求解位姿。这套流程在《视觉SLAM十四讲》的前几章已经讲得很透了,但真正上手跑过代码的人都知道,特征点法有几个绕不开的痛点。
第一个痛点是耗时。ORB特征提取本身就不便宜,一张640×480的图像,提取上千个特征点,再逐个计算描述子,在普通CPU上动辄十几毫秒。如果图像分辨率更高,或者场景纹理丰富导致特征点数量暴涨,这个时间还会线性增长。对于需要实时运行的SLAM系统来说,每帧十几毫秒的特征提取开销,意味着帧率直接被压到三四十帧以下,留给后端优化和建图的时间就更少了。
第二个痛点是特征点分布不均。特征点法天然倾向于在纹理丰富的区域(比如墙角、文字、树叶)提取大量关键点,而在纹理稀疏的区域(比如白墙、天空、光滑地面)几乎提不出点。这就导致位姿估计的约束集中在图像局部,解算出来的运动估计在某些方向上精度很差。你可以做均匀化处理,但那是额外的工程成本。
第三个痛点是丢失了图像的大部分信息。一张图像有几十万像素,特征点法只用了其中几百到几千个点,剩下的像素信息全扔了。这在纹理丰富、光照良好的场景下问题不大,但在弱纹理或者运动模糊的场景下,特征点本身就提不出来,整个系统直接歇菜。
光流法走的是另一条路。它不提取描述子,不进行特征匹配,而是直接利用图像像素的灰度信息,通过追踪像素点在时间上的运动来估计相机运动。你可以把它理解为:特征点法是在每帧图像里找“ landmark”,然后匹配这些 landmark;光流法是在第一帧里选一批像素点,然后在后续帧里追踪这些点跑到了哪里。追踪的过程不需要描述子,只需要假设“同一个空间点在相邻帧里的灰度值不变”。
这个假设就是灰度不变假设,是光流法的基石。听起来很理想化,但在实际场景中,只要相机运动不是特别剧烈、光照变化不是特别突然,这个假设在短时间间隔内是近似成立的。基于这个假设,光流法可以做到亚像素级的追踪精度,而且计算量比特征点匹配小得多。
《视觉SLAM十四讲》在视觉里程计部分专门用一章讲光流法,目的就是让读者理解:除了特征点法,还有一条更轻量、更直接的路可以走。这条路在后续的稀疏直接法、半直接法SLAM中都会被反复用到。理解了光流法,你才能真正理解直接法SLAM的底层逻辑。
这篇文章会从光流法的数学原理讲起,然后拆解它在视觉里程计中的完整实现流程,包括像素点选取、光流追踪、位姿求解、以及实际编码中会遇到的各种坑。我会尽量把每个步骤的“为什么”讲清楚,让你不仅知道怎么调OpenCV的calcOpticalFlowPyrLK,更知道它背后在算什么、什么时候会失效、失效了怎么救。
2. 光流法的数学原理:灰度不变假设到底在说什么
2.1 从单像素到窗口:Lucas-Kanade光流的推导
光流法的数学推导其实不复杂,但很多教程一上来就甩公式,容易把人绕晕。我用一个更直观的方式来解释。
假设在时刻 ( t ),图像上某个像素点 ( (x, y) ) 的灰度值是 ( I(x, y, t) )。经过极短的时间 ( dt ) 后,这个空间点运动到了新的位置 ( (x+dx, y+dy) ),在时刻 ( t+dt ) 的灰度值是 ( I(x+dx, y+dy, t+dt) )。
灰度不变假设说:同一个空间点在不同时刻的灰度值不变。写成公式就是:
[ I(x+dx, y+dy, t+dt) = I(x, y, t) ]
对左边做一阶泰勒展开:
[ I(x+dx, y+dy, t+dt) \approx I(x, y, t) + \frac{\partial I}{\partial x}dx + \frac{\partial I}{\partial y}dy + \frac{\partial I}{\partial t}dt ]
两边消掉 ( I(x, y, t) ),再除以 ( dt ),得到:
[ \frac{\partial I}{\partial x}\frac{dx}{dt} + \frac{\partial I}{\partial y}\frac{dy}{dt} + \frac{\partial I}{\partial t} = 0 ]
记 ( I_x = \frac{\partial I}{\partial x} ),( I_y = \frac{\partial I}{\partial y} ),( I_t = \frac{\partial I}{\partial t} ),以及像素运动速度 ( u = \frac{dx}{dt} ),( v = \frac{dy}{dt} ),上式变成:
[ I_x u + I_y v + I_t = 0 ]
这就是光流约束方程。一个方程,两个未知数 ( u ) 和 ( v ),欠定。这就是著名的“光流孔径问题”:单个像素的光流无法唯一确定,因为沿边缘方向的运动看不出来。
Lucas-Kanade的解法是:假设一个窗口内的所有像素具有相同的运动。比如取一个 ( 5\times5 ) 的窗口,里面25个像素都满足上面的方程,那就有了25个方程,未知数还是2个。用最小二乘法就能解出 ( u ) 和 ( v )。
写成矩阵形式:
[ \begin{bmatrix} I_{x1} & I_{y1} \ I_{x2} & I_{y2} \ \vdots & \vdots \end{bmatrix} \begin{bmatrix} u \ v \end{bmatrix} = -\begin{bmatrix} I_{t1} \ I_{t2} \ \vdots \end{bmatrix} ]
记作 ( A\mathbf{u} = \mathbf{b} ),最小二乘解是 ( \mathbf{u} = (A^T A)^{-1} A^T \mathbf{b} )。
这里 ( A^T A ) 是一个 ( 2\times2 ) 的矩阵,它的可逆性决定了光流能否被稳定求解。如果窗口内的像素梯度 ( I_x ) 和 ( I_y ) 都很小(比如白墙区域),( A^T A ) 接近奇异,解出来的光流就不可靠。如果梯度只在一个方向上有值(比如纯边缘),( A^T A ) 的条件数很大,沿边缘方向的光流分量会很不准。这就是为什么光流法也需要选“好”的像素点来追踪。
2.2 金字塔分层:解决大运动的关键手段
基本的Lucas-Kanade光流有一个致命限制:它只能追踪很小的运动。因为泰勒展开是一阶近似,只有当 ( dx ) 和 ( dy ) 足够小的时候才成立。如果两帧之间像素移动了几十个像素,一阶近似直接失效,光流追踪就会失败。
解决方案是图像金字塔。把图像逐层降采样,得到一系列分辨率递减的图像。最底层是原始分辨率,上一层是降采样一半,再上一层再降一半,通常做3到4层。
追踪的时候,从最顶层(分辨率最低)开始算光流。在最顶层,原本几十像素的运动被缩小到了几像素,一阶近似成立。算出一个粗略的光流值后,把这个值乘以2传到下一层,作为下一层的初始值,再在下一层继续优化。逐层往下,光流值越来越精细,最终在原始分辨率上得到亚像素级的追踪结果。
这个策略叫由粗到精(coarse-to-fine)。OpenCV的calcOpticalFlowPyrLK内部就是干这个的,你只需要指定金字塔层数maxLevel,剩下的它帮你处理。
实操心得:
maxLevel不是越大越好。层数太多,最顶层的图像可能只有几十个像素,细节全丢了,反而影响追踪精度。一般3到4层足够覆盖大部分场景。如果你的相机运动特别快,可以适当加到5层,但要注意最顶层图像不能太小。
2.3 正向-逆向光流:如何判断追踪是否可靠
光流追踪有一个天然的问题:你怎么知道追踪结果是对的?如果某个像素点被遮挡了、跑出图像边界了、或者光照突变导致灰度不变假设失效了,追踪出来的光流就是错的。如果不加筛选地把所有追踪结果都扔进位姿求解,错误的光流会严重污染解算结果。
一个常用的可靠性检验方法是正向-逆向光流一致性检查。思路很简单:从第一帧追踪到第二帧,得到一个光流 ( \mathbf{u}{forward} );再从第二帧反向追踪回第一帧,得到 ( \mathbf{u}{backward} )。如果追踪是可靠的,那么正向和逆向的光流应该大小相近、方向相反,即 ( \mathbf{u}{forward} \approx -\mathbf{u}{backward} )。如果两者差异很大,说明这个点的追踪不可靠,应该剔除。
这个检查的代价是光流计算量翻倍,但在精度要求高的场合非常值得。OpenCV本身不直接提供正向-逆向检查的接口,需要你手动调用两次calcOpticalFlowPyrLK,一次正向一次逆向,然后比较结果。
除了正向-逆向检查,还有几个常用的筛选条件:
- 边界检查:追踪后的点是否跑出了图像范围。
- 光流幅值检查:光流大小是否超过合理阈值(比如超过图像宽度的三分之一),过大的光流通常是误匹配。
- 状态标志检查:
calcOpticalFlowPyrLK返回的status数组,0表示追踪失败,1表示成功。
把这些检查组合起来,可以过滤掉大部分不可靠的追踪点,剩下的点再用于位姿求解,精度会稳定很多。
3. 光流法视觉里程计的完整实现流程
3.1 整体架构:从图像到位姿的每一步
光流法视觉里程计的整体流程可以拆成五个步骤:
- 图像采集与预处理:读取相邻两帧图像,转灰度,必要时做直方图均衡化或去畸变。
- 像素点选取:在第一帧中选取一批适合追踪的像素点。通常用Shi-Tomasi角点检测,或者直接用FAST角点,也可以手动指定网格点。
- 光流追踪:用Lucas-Kanade金字塔光流追踪这些点在第二帧中的位置。
- 可靠性筛选:剔除追踪失败、跑出边界、正向-逆向不一致的点。
- 位姿求解:用剩下的追踪点对,通过2D-2D的对极几何或者3D-2D的PnP求解相机运动。
这五步里,第2步和第4步是光流法特有的,特征点法里对应的是特征提取和特征匹配。第5步两者是共通的,都是根据点对关系解算位姿。
下面我逐个步骤拆解,把每个环节的细节和坑都讲清楚。
3.2 像素点选取:什么样的点适合光流追踪
光流追踪的质量,很大程度上取决于你选了什么样的点来追踪。前面推导Lucas-Kanade的时候已经说了,( A^T A ) 的可逆性决定了光流能否稳定求解。( A^T A ) 的特征值反映了窗口内像素梯度在两个方向上的强度。如果两个特征值都大,说明这个点在两个方向上都有明显梯度,是角点,适合追踪。如果一个特征值大一个小,说明是边缘,沿边缘方向的光流不可靠。如果两个都小,说明是平坦区域,完全没法追踪。
所以选点的原则很明确:选角点,不选边缘,更不选平坦区域。
OpenCV提供了goodFeaturesToTrack函数,底层就是Shi-Tomasi角点检测。它的核心参数有两个:
maxCorners:最多选多少个点。一般设100到300之间,太多会增加计算量,太少会导致位姿求解约束不足。qualityLevel:质量阈值,相对于最强角点的比例。比如设0.01,表示特征值小于最强角点1%的点会被丢弃。这个值越大,选出来的点越少但质量越高。
还有一个容易被忽略的参数是minDistance:两个角点之间的最小像素距离。如果不设这个参数,角点会扎堆在纹理最丰富的区域,导致图像其他区域没有约束。设一个合理的minDistance(比如10到20像素),可以让角点分布更均匀。
实操心得:
goodFeaturesToTrack选出来的点仍然可能集中在某些区域。如果你发现位姿估计在某些方向上不稳定,可以试试把图像划分成网格,每个网格里单独选点,强制让点分布均匀。这个技巧在纹理分布不均的场景下特别有用。
除了Shi-Tomasi,也可以直接用FAST角点。FAST的优势是快,缺点是角点质量参差不齐,需要额外做非极大值抑制。在光流法里,因为后续有正向-逆向检查兜底,用FAST也是可以的,但Shi-Tomasi的追踪稳定性通常更好。
3.3 光流追踪:calcOpticalFlowPyrLK的参数怎么调
calcOpticalFlowPyrLK是OpenCV里最常用的稀疏光流函数。它的函数签名比较长,我挑几个关键参数讲。
void calcOpticalFlowPyrLK( InputArray prevImg, // 前一帧图像 InputArray nextImg, // 后一帧图像 InputArray prevPts, // 前一帧的点 InputOutputArray nextPts,// 后一帧追踪到的点 OutputArray status, // 追踪状态 OutputArray err, // 追踪误差 Size winSize = Size(21,21), // 搜索窗口大小 int maxLevel = 3, // 金字塔层数 TermCriteria criteria = ... // 迭代终止条件 );winSize决定了Lucas-Kanade在每一层用的窗口大小。窗口越大,对噪声越鲁棒,但运动假设(窗口内所有像素运动相同)越难满足,而且计算量越大。窗口越小,对局部运动更敏感,但容易受噪声影响。21×21是一个比较通用的默认值。如果你的图像分辨率很高(比如1080p),可以适当加大到31×31;如果分辨率很低(比如320×240),可以减小到11×11。
maxLevel前面说过了,3到4层是常规选择。层数越多,能处理的运动越大,但最顶层图像太小会导致追踪精度下降。
criteria是迭代终止条件,包括最大迭代次数和精度阈值。默认是30次迭代或者精度达到0.01。一般不需要改,除非你发现光流追踪特别慢,可以适当降低最大迭代次数。
err是输出的追踪误差,可以用来做筛选。误差大的点通常追踪质量差,可以设一个阈值剔除。
还有一个隐藏的坑:calcOpticalFlowPyrLK默认使用OPTFLOW_USE_INITIAL_FLOW标志吗?不是。如果你不传这个标志,它会从零开始搜索光流。如果你有一个粗略的光流初始估计(比如来自IMU或者上一帧的运动模型),可以传这个标志,让光流从初始值开始优化,能提高追踪速度和精度。在纯视觉里程计里,通常没有可靠的初始估计,所以不传这个标志。
3.4 位姿求解:2D-2D对极几何与3D-2D PnP的选择
追踪到点对之后,怎么解算相机运动?这取决于你手里有什么信息。
如果你只有两帧图像的2D点对,没有任何深度信息,那就用2D-2D对极几何。核心是求解本质矩阵 ( E ) 或者基础矩阵 ( F ),然后从 ( E ) 中分解出旋转 ( R ) 和平移 ( t )。OpenCV提供了findEssentialMat和recoverPose两个函数,配合使用很方便。
2D-2D的问题是尺度不确定性。从本质矩阵分解出来的平移 ( t ) 是一个单位向量,没有真实的尺度。你只知道相机往哪个方向移动了,不知道移动了多少米。对于纯视觉里程计来说,尺度可以通过后续的三角化或者与IMU融合来恢复,但在单目纯视觉的情况下,尺度是漂移的。
如果你有3D点(比如通过三角化得到了前一帧点的深度),那就用3D-2D PnP。PnP直接求解相机位姿,不需要分解本质矩阵,精度通常更高,而且没有尺度问题(因为3D点本身带尺度)。OpenCV的solvePnP或者solvePnPRansac都可以用。
在实际的视觉里程计里,通常的做法是:
- 第一帧和第二帧之间用2D-2D初始化,三角化出一批3D点。
- 后续帧用3D-2D PnP求解位姿,同时继续三角化新的3D点。
- 当追踪点数量不足或者追踪质量下降时,重新用2D-2D做一次初始化。
这个策略兼顾了初始化的鲁棒性和后续帧的精度。
注意事项:无论是2D-2D还是3D-2D,都强烈建议用RANSAC。光流追踪再怎么做可靠性筛选,也难免有漏网之鱼。RANSAC可以进一步剔除误匹配,让位姿解算更稳定。
findEssentialMat和solvePnPRansac都内置了RANSAC,直接用就行。
3.5 完整代码框架:从两帧图像到位姿输出
下面给一个简化的完整代码框架,展示光流法视觉里程计的核心流程。代码用C++和OpenCV写,Python版本逻辑一样,只是API调用略有不同。
#include <opencv2/opencv.hpp> #include <vector> using namespace cv; using namespace std; int main() { // 1. 读取两帧图像 Mat img1 = imread("frame1.png", IMREAD_GRAYSCALE); Mat img2 = imread("frame2.png", IMREAD_GRAYSCALE); // 2. 选取追踪点 vector<Point2f> pts1; goodFeaturesToTrack(img1, pts1, 200, 0.01, 15); // 3. 光流追踪 vector<Point2f> pts2; vector<uchar> status; vector<float> err; calcOpticalFlowPyrLK(img1, pts1, img2, pts2, status, err, Size(21,21), 3); // 4. 正向-逆向检查 vector<Point2f> pts1_back; vector<uchar> status_back; calcOpticalFlowPyrLK(img2, pts2, img1, pts1_back, status_back, err, Size(21,21), 3); vector<Point2f> good_pts1, good_pts2; for (size_t i = 0; i < pts1.size(); i++) { if (status[i] == 0 || status_back[i] == 0) continue; float dx = pts1[i].x - pts1_back[i].x; float dy = pts1[i].y - pts1_back[i].y; if (sqrt(dx*dx + dy*dy) > 1.0) continue; // 正向-逆向不一致 good_pts1.push_back(pts1[i]); good_pts2.push_back(pts2[i]); } // 5. 位姿求解(2D-2D对极几何) Mat K = (Mat_<double>(3,3) << 520.9, 0, 325.1, 0, 521.0, 249.7, 0, 0, 1); Mat E = findEssentialMat(good_pts1, good_pts2, K, RANSAC); Mat R, t; recoverPose(E, good_pts1, good_pts2, K, R, t); cout << "Rotation:\n" << R << endl; cout << "Translation:\n" << t << endl; return 0; }这段代码跑通之后,你会得到一个旋转矩阵 ( R ) 和一个平移向量 ( t )。注意 ( t ) 是单位向量,没有真实尺度。如果你需要真实尺度,要么用双目/RGBD,要么用IMU融合,要么在场景里放一个已知尺寸的标定物。
4. 光流法实战中的坑与排查技巧
4.1 光流追踪失败的典型场景与应对
光流法虽然好用,但它的失效场景比特征点法更隐蔽。特征点法提不出点的时候,你至少知道“没点可用”;光流法追踪失败的时候,它可能给你一个看起来合理但完全错误的光流值,你不做检查根本发现不了。
场景一:大运动导致追踪丢失。相机快速旋转或者快速平移时,两帧之间像素位移可能超过金字塔能处理的范围。表现是大量追踪点的status为0,或者正向-逆向检查大面积不通过。应对方法是增加金字塔层数,或者降低图像分辨率(相当于变相增大金字塔层数)。如果还是不行,说明运动太快,需要考虑提高帧率或者用IMU辅助。
场景二:光照突变导致灰度不变假设失效。从暗处走到亮处、自动曝光调整、闪光灯等都会导致灰度突变。表现是光流方向混乱,追踪点四处乱跑。应对方法是在光流追踪前做直方图均衡化或者光照归一化,减小两帧之间的灰度差异。如果光照变化太剧烈,光流法基本无解,只能靠特征点法或者直接法硬扛。
场景三:弱纹理区域追踪漂移。白墙、天空、光滑地面这些区域,像素梯度很小,( A^T A ) 接近奇异,光流解算不稳定。表现是这些区域的追踪点缓慢漂移,短时间内看不出来,累积几帧之后误差就很大了。应对方法是在选点阶段就避开这些区域,或者在位姿求解时给这些点更低的权重。
场景四:遮挡和边界。前景物体遮挡背景、或者追踪点跑出图像边界,都会导致追踪失败。表现是status为0或者追踪点坐标超出图像范围。应对方法是检查追踪点坐标是否在图像内,以及用正向-逆向检查剔除遮挡点。
4.2 正向-逆向检查的阈值怎么定
正向-逆向检查的阈值是一个经验参数。阈值太小,会把很多正确的追踪点误杀;阈值太大,会放过错误的追踪点。
我一般用1个像素作为阈值。也就是说,正向追踪和逆向追踪的结果差异超过1个像素,就认为不可靠。这个阈值在大部分场景下工作良好。如果你的图像分辨率很高(比如4K),可以适当放宽到2个像素;如果分辨率很低(比如320×240),可以收紧到0.5个像素。
还有一个变种是归一化正向-逆向误差:把正向-逆向差异除以光流幅值。这样可以避免大光流点的绝对误差天然比小光流点大的问题。公式是:
[ \text{error} = \frac{|\mathbf{u}{forward} + \mathbf{u}{backward}|}{|\mathbf{u}{forward}| + |\mathbf{u}{backward}|} ]
这个值在0到1之间,越接近0越可靠。一般设0.1到0.2作为阈值。
4.3 追踪点数量不足时的补救策略
光流法的一个实际问题是:随着帧数增加,追踪点会逐渐减少。因为每帧都有一些点追踪失败被剔除,而新的点没有及时补充。跑几十帧之后,可能只剩几十个点,位姿求解精度严重下降。
补救策略有两个:
策略一:定期补充新点。每隔几帧,在当前帧重新用goodFeaturesToTrack选一批新点,和已有的追踪点合并。合并的时候要注意去重,避免同一个位置选了两个点。这个策略简单有效,是大多数光流法SLAM系统的标配。
策略二:动态调整选点数量。如果当前追踪点数量低于阈值(比如100个),就多选一些新点;如果追踪点数量充足,就少选或者不选。这样可以保持追踪点数量在一个合理范围内,既不会太少导致精度下降,也不会太多导致计算量暴涨。
实操心得:补充新点的时候,最好避开已有追踪点附近的区域。可以用一个掩膜(mask)把已有追踪点周围一定半径内的区域屏蔽掉,再在剩余区域选新点。这样可以让追踪点分布更均匀,避免扎堆。
4.4 光流法与特征点法的性能对比实测
我在同一台机器上跑过光流法和ORB特征点法的对比测试,场景是一个室内走廊,图像分辨率640×480,帧率30fps。测试结果如下:
| 指标 | 光流法 | ORB特征点法 |
|---|---|---|
| 单帧耗时(提取+追踪/匹配) | 8-12ms | 15-25ms |
| 追踪点数量 | 150-200 | 300-500 |
| 位姿估计精度(相对误差) | 1.5%-3% | 1%-2% |
| 弱纹理场景成功率 | 较低 | 很低 |
| 大运动场景成功率 | 中等 | 较高 |
| 光照变化鲁棒性 | 较低 | 中等 |
从数据可以看出,光流法在速度上有明显优势,耗时只有特征点法的一半左右。但精度略低,主要是因为追踪点数量少,而且光流追踪本身有漂移。在弱纹理场景下,两者都不太行,但光流法因为可以选网格点,稍微好一点。在大运动场景下,特征点法因为描述子的存在,匹配鲁棒性更好,光流法容易丢失追踪。
这个对比说明:光流法和特征点法不是替代关系,而是互补关系。在实际SLAM系统里,经常是两者结合使用——光流法做快速追踪,特征点法做重定位和回环检测。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 追踪点数量急剧减少 | 大运动、光照突变、遮挡 | 检查status数组和正向-逆向检查通过率 | 增加金字塔层数、补充新点、检查光照 |
| 光流方向混乱 | 灰度不变假设失效 | 可视化光流向量,看是否杂乱无章 | 直方图均衡化、降低帧间时间间隔 |
| 位姿估计抖动大 | 追踪点分布不均或数量不足 | 检查追踪点空间分布 | 网格化选点、增加追踪点数量 |
| 尺度漂移严重 | 单目2D-2D的固有缺陷 | 对比不同帧的平移向量模长 | 三角化恢复尺度、融合IMU |
| 追踪点缓慢漂移 | 弱纹理区域光流解算不稳定 | 检查追踪点是否在弱纹理区域 | 选点时避开弱纹理、给低权重 |
| 正向-逆向检查通过率低 | 阈值太严或追踪质量差 | 统计通过率,调整阈值 | 放宽阈值、检查图像质量 |
5. 光流法在视觉SLAM中的定位与扩展
5.1 光流法与直接法的关系
光流法和直接法经常被放在一起讨论,因为两者都基于灰度不变假设,都利用像素灰度信息。但它们的侧重点不同。
光流法关注的是像素运动:给定一个像素点,追踪它在下一帧的位置。追踪到位置之后,位姿求解是独立的一步,可以用对极几何或者PnP。
直接法关注的是位姿优化:直接构造光度误差,把位姿作为优化变量,通过最小化光度误差来求解位姿。像素运动是优化过程中的中间产物,不是最终目标。
你可以把光流法理解为直接法的一个特例:光流法先追踪像素,再求解位姿,两步分开;直接法把两步合并,同时优化像素运动和位姿。所以直接法通常精度更高,但实现更复杂,对初始值更敏感。
《视觉SLAM十四讲》在讲完光流法之后,紧接着讲直接法,就是这个逻辑:先理解光流,再理解直接法,循序渐进。
5.2 光流法在稀疏直接法SLAM中的应用
稀疏直接法SLAM(比如LSD-SLAM、DSO)的核心思想是:不提取特征点,直接在像素上构造光度误差,优化位姿和地图点。但“不提取特征点”不等于“随便选像素”。实际上,稀疏直接法也需要选一批“好”的像素来构造误差,选点的标准和光流法一模一样:选梯度大的点,避开弱纹理区域。
所以光流法的选点策略、金字塔策略、可靠性检查策略,在稀疏直接法里全都能用上。理解了光流法,你再看DSO的代码,会发现很多地方似曾相识。
5.3 光流法+IMU:视觉惯性里程计的常见组合
纯视觉里程计有两个绕不开的问题:尺度不确定(单目)和快速运动容易丢失追踪。IMU可以很好地弥补这两个问题。
IMU提供角速度和加速度,积分之后可以得到粗略的旋转和平移估计。这个估计虽然漂移快,但在短时间间隔内精度足够高,可以作为光流追踪的初始值。前面提到calcOpticalFlowPyrLK支持OPTFLOW_USE_INITIAL_FLOW标志,把IMU的预测光流作为初始值传进去,可以显著提高大运动场景下的追踪成功率。
同时,IMU的加速度计可以提供尺度信息,帮助单目视觉里程计恢复真实尺度。视觉和IMU的融合通常用紧耦合的优化框架,把光度误差和IMU预积分误差放在一起优化。这是目前视觉惯性里程计的主流方案。
5.4 从光流法到深度学习光流:技术演进脉络
传统光流法(Lucas-Kanade、Horn-Schunck)统治了几十年,直到深度学习光流的出现。FlowNet、PWC-Net、RAFT这些网络用卷积神经网络直接预测稠密光流,在精度上大幅超越了传统方法,尤其是在大运动、遮挡、弱纹理等困难场景下。
但深度学习光流有一个致命问题:慢。RAFT在GPU上跑一对图像需要几十毫秒甚至上百毫秒,对于需要实时运行的SLAM系统来说太慢了。所以目前SLAM系统里还是以传统光流法为主,深度学习光流主要用于离线处理或者对实时性要求不高的场合。
不过这个局面在变化。随着轻量化网络和专用硬件的出现,深度学习光流的速度在快速提升。未来几年,我们可能会看到更多SLAM系统采用学习型光流。但无论技术怎么演进,Lucas-Kanade光流的基本原理——灰度不变假设、金字塔分层、最小二乘求解——仍然是理解光流法的基石。
我在实际项目里用光流法做过室内机器人的视觉里程计,也用过特征点法做无人机的位置估计。踩过几次坑之后,我的体会是:光流法不是万能的,但在纹理丰富、运动平缓、光照稳定的场景下,它比特征点法更快、更直接。如果你的场景满足这些条件,光流法值得一试。如果场景里有大量弱纹理区域或者光照变化剧烈,还是老老实实用特征点法,或者上直接法+IMU融合。选什么方案,最终取决于你的场景和硬件,没有银弹。