
秋招聊到SLAMVINS里的光流法几乎是个绕不开的话题。我当年被问过好几轮刚开始只能背结论后来为了搞明白它到底在VINS前端做什么把源码翻了几遍又拿各种bag文件反复测才算真正想通。VINSVisual-Inertial Navigation System的视觉前端主要靠稀疏光流跟踪——不是暴力特征匹配而是假设相邻帧间像素运动很小直接利用图像梯度搜索对应点。这篇文章结合我自己的准备和实操经验把VINS中的光流法从原理、源码细节到bag调试方法完整拆一遍也聊聊秋招面试官真正想听到的回答。适合准备SLAM方向秋招、刚接触VINS源码或者想改进视觉前端鲁棒性的同学。1. VINS整体结构里光流法扮演什么角色1.1 VINS前端的数据流VINS不只是一个“匹配像素”的程序它是一整套视觉惯性状态估计系统。打开VINS-Mono或VINS-Fusion的代码一般能看到几个核心模块feature_tracker负责光流跟踪imu预积分负责在两次图像之间机械地递推状态后端基于滑动窗口做非线性优化回环检测模块再兜底。光流法就活在最前端的feature_tracker节点里它决定了后端拿到的“视觉观测”质量。feature_tracker的输入通常是一张新图像和IMU消息输出则是带id的特征点集合。具体流程可以简化成先检测新角点然后用KLT光流把上一帧留下的特征点追到当前帧接着做外点剔除最后把内点对应的归一化平面坐标通过sensor_msgs::PointCloud发出去。后端看到的是这些归一化坐标而不是原始像素。也就是说光流跟踪解决的是“哪个点和哪个点是对应关系”的问题后端的三角化、对极约束、PnP、BA全部建立在这个关系之上。这个角色定位很关键。很多同学以为光流法只是“找点”实际上它承担了VINS的视觉关联层。如果光流跟丢了、跟歪了后面几何估计做得再好也白搭。所以面试官问到光流时往往会进一步追问“外点怎么剔除”“怎么保证跟踪稳定”本质是想看你对前端到后端的链路有没有完整认知。1.2 为什么选光流而不是暴力特征匹配VINS在视觉前端坚定地选择了光流而不是ORB、SIFT这类特征匹配方案原因可以归结为三个字快、稳、省。快很好理解。现在相机频率普遍30帧每秒左右实时SLAM里留给特征跟踪的时间非常有限。如果每帧都重新提取描述子再和上一帧做暴力匹配或者词袋匹配CPU和内存开销都会明显上升。而光流只要在上一帧特征点附近的小窗口内搜索计算量远低于描述子提取和匹配。VINS的feature_tracker在常见配置下跑在10Hz甚至更高用KLT光流能稳定支住这个频率。稳和省则更微妙。特征匹配把图像看成“关键点描述子”本质是让视觉内容具有旋转不变性、尺度不变性。但SLAM里的相邻帧运动并不会特别剧烈尤其配合上高帧率相机后缩放和旋转都限制在小范围内这时描述子带来的“不变性”属于过度投入。光流直接使用灰度窗口的梯度信息对弱纹理区域也有一定跟踪能力字面上更贴合VINS这种“短时间惯性猜测”的工作方式。再加上不需要为每个特征点计算高维描述子内存占用小整套前端更轻便。当然光流不是银弹。它对亮度一致、小运动、空间一致性有强假设遇到剧烈光照变化、快速旋转和遮挡时容易跟丢。VINS的选择实际上是“在合理假设下用更便宜的手段解决问题”并在后端用鲁棒估计和IMU先验兜底。1.3 光流与IMU预积分的协同关系光流和IMU预积分在VINS里是互补关系。IMU以远高于相机的频率通常200Hz左右输出角速度和加速度能在图像之间估计出相机自身的旋转和平移。虽然预积分结果的漂移需要视觉修正但它给了我们一个很强的先验相邻两帧的运动不会突然跳变。具体到前端IMU先验可以在光流跟踪时帮大忙。基础VINS版本里光流通常直接以上一帧的特征点像素位置作为金字塔初始猜测。这在平移为主、运动平缓时没问题但如果相机快速旋转直接把上一帧的像素坐标扔给下一帧搜索起始点会离真实位置很远金字塔也不一定救得回来。我自己的做法是先用IMU积分算出两帧间的相对旋转把上一帧特征点的归一化坐标旋转到当前帧相机系下再投影回像素平面用这个旋转补偿后的坐标作为calcOpticalFlowPyrLK的初始值。这个改动对快速旋转场景的提升非常明显。反过来光流跟踪的稳定特征也约束了IMU预积分的漂移。后端优化时视觉残差和IMU残差是一起被最小化的。假如光流跟踪了大量错误点视觉和IMU会对同一个运动产生矛盾优化结果就会骑墙最后表现出来就是轨迹扭曲甚至发散。所以VINS里IMU和光流的关系不是上下游单向串联而是“IMU给光流提供运动先验光流给IMU提供长期修正”这是我在后续项目里体会很深的一点。2. 光流法原理从LK到金字塔KLT2.1 光流三大假设与亮度不变方程光流的出发点是一个物理直觉同一个空间点在相邻两帧图像里的投影位置变了但这个小块区域的灰度外观应该保持不变。用公式写就是I(x dx, y dy, t dt) ≈ I(x, y, t)这个式子隐含了亮度恒定假设。它要求场景里的光源稳定、表面材质不强烈自发光也不能有大面积高光突变。现实中很难完美满足但多数室内和机器人场景下近似可用。把等式左边做一阶泰勒展开忽略高阶项就能得到光流约束方程I_x * u I_y * v I_t 0其中I_x、I_y是图像梯度I_t是像素灰度随时间的变化量u、v就是我们要求的光流速度。一个像素只能提供一个方程但u和v是两个未知数所以方程是欠约束的。这也是为什么单点光流没法解必须引入额外假设。第二条假设是小运动。光流约束方程成立的前提是dx、dy、dt足够小偏移太大时泰勒展开的高阶项就不能忽略。第三条假设是空间一致性也就是认为一个局部小窗口里的所有像素具有相似的运动。这正好为后续LK方法提供了足够的方程数。三大假设相互补充亮度恒定给出方程形式小运动让泰勒展开成立空间一致性把窗口内所有像素的方程聚合起来最终把欠约束问题变成超定问题。2.2 LK最小二乘求解与Hessian矩阵Lucas-Kanade方法的核心就是使用空间一致性假设。假设一个大小为N×N的窗口内所有像素的运动向量都是(u, v)那么每个像素都贡献一个光流约束方程把这些方程堆起来就得到一个超定线性系统[I_x1 I_y1; I_x2 I_y2; ...; I_xN I_yN] * [u; v] -[I_t1; I_t2; ...; I_tN]标准做法是取最小二乘解也就是求A^T A x A^T b。这里的A^T A是一个2×2矩阵通常称为Hessian近似矩阵即[[Σ I_x^2, Σ I_x I_y], [Σ I_x I_y, Σ I_y^2]]这个矩阵必须可逆解才稳定。如果窗口内所有像素的梯度方向都一致比如一条纯直线边缘那么Hessian长得很胖估计出的垂直于边缘方向的分量会很不准。这实际上是“孔径问题”的数学体现。所以光流跟踪点一般要选角点或者至少要有明显的纹理变化而不能选在无纹理的墙面或单一边缘上。VINS也用Shi-Tomasi角点检测来保证选点质量。窗口大小也直接影响求解。窗口太小对噪声敏感窗口太大空间一致性假设又会被破坏导致运动场过于平滑、细节丢失。VINS默认窗口一般在21×21附近这个值是在计算量和鲁棒性之间折中的结果。实际过程中如果要调我建议先看图像分辨率分辨率越高可以适当加大窗口。2.3 金字塔为什么能解决大运动原始LK方法受小运动假设限制帧间位移超过几个像素就会失效。实际机器人运动中光流位移十几个像素甚至几十个像素都很常见所以VINS直接用带金字塔的KLT也就是calcOpticalFlowPyrLK中的Pyr。金字塔的思路很朴素先把原始图像连续降采样成多层高层图像分辨率低同样的像素位移换算到高层就变“小”了。求解过程是coarse-to-fine先从最顶层估计一个粗糙运动把它当作下一层的初始猜测逐层下放到原始分辨率并在每一层用LK做微调。这样即使原图上的实际位移很大只要它经过若干层缩放后小于单层搜索窗口的收敛范围就能被追出来。VINS里一般金字塔层数设为4层缩放系数默认0.5。4层相当于把原图缩小到1/16可以处理的帧间位移比单层LK大不少。但金字塔不是万能的它解决的是“位移幅度”问题解决不了“外观变化”问题。快速旋转、尺度变化剧烈、局部窗口内容被遮挡时金字塔照样拟合不到正确的极值点。所以在工程里除了金字塔还要结合IMU旋转补偿和严格的异常剔除。2.4 光流误差的来源光流误差来自多个层面。第一层是成像噪声图像传感器在高感光度下会产生噪点梯度计算不稳定Hessian的最小特征值会波动。第二层是亮度突变比如相机自动曝光导致整帧亮度变化这时灰度不变假设被打破KLT很容易跑到错误位置。第三层是遮挡和出现/消失空间点被前面的物体挡住或者离开视野后光流会在局部找到“最像”但实际上错误的点。第四层是重复纹理和局部极值图像窗口里出现周期性结构梯度指向多个相似解LK搜索会卡在错误的局部最优。理解误差来源才能理解VINS为什么在光流后面接那么多剔除逻辑。光流只是给出“候选对应”真正进入后端优化之前必须用RANSAC、对极约束、前后向校验等手段过滤掉脏数据。这也是面试官非常看重的点——你不光要知道光流怎么算还要知道怎么防止算错。3. VINS里的光流实现细节不读源码容易踩坑3.1 特征点检测与均匀化VINS的feature_tracker默认用cv::goodFeaturesToTrack检测角点底层是Shi-Tomasi分数。这个函数一次会返回一堆角点按响应值降序排列并且内部会通过最小距离做简单的非极大值抑制。VINS里常见的参数是max_cnt设为150min_dist设为30像素也就是输出特征点数量控制在150以内相邻特征点之间至少相隔30像素。这里有一个细节容易被忽略为了保持特征点尽可能均匀分布VINS会在每帧检测之前对已有跟踪点生成一个mask把已经有点位的区域“涂黑”然后只在剩余区域里检测新点。这样做的目的不是单纯地让视觉上好看而是为了后端几何更稳定。如果特征点全挤在图像一角三角化和PnP在其余区域的约束就非常弱位姿解算病态漂移也会增加。min_dist不能拍脑袋设。设太大弱纹理区域的点数会不够设太小特征扎堆后端优化时信息冗余鲁棒性变差。我在自己数据集上调整过发现30到50像素之间是比较稳的区间具体要看图像分辨率。图像是640×480时30像素效果就不错如果换成1920×1080我会适当提高到50左右。3.2 去畸变与归一化平面坐标VINS处理特征点时有“原始像素坐标”和“归一化平面坐标”两个坐标系。光流跟踪发生在原始图像上也就是没有做去畸变和裁剪的图像因为去畸变重采样会引入额外插值模糊反而可能破坏光流假设。跟踪出当前帧的像素坐标之后再利用相机内参和畸变系数把它投影到归一化平面得到(x, y, 1)形式的坐标。这一步的精度直接影响后端。归一化坐标只要偏几个像素在深度比较远时就会导致角度误差放大。所以相机内参和畸变系数的标定必须靠谱。VINS-Fusion一般从配置文件读取fx、fy、cx、cy以及k1、k2、p1、p2等参数bag录制前最好确认相机参数没有变化。如果是仿真数据或者Euroc这种官方数据集参数一般没问题如果是自录bag强烈建议先用Kalibr做完整标定再接VINS。3.3 金字塔光流参数设置VINS调用OpenCV的calcOpticalFlowPyrLK参数大致有这么几个winSize是搜索窗口大小maxLevel是金字塔层数criteria是迭代终止条件。默认配置里winSize常见是21×21maxLevel为4迭代次数30终止条件设置为COUNTEPS意味着同时限制迭代次数和更新量阈值。这些参数反映了一个工程取舍。winSize如果只有11×11速度快但对大位移和模糊图像敏感改成31×31之后找到正确点的概率增加但无纹理区域的误匹配也更容易发生。maxLevel太浅追不上快速运动太深则低分辨率层丢失细节反而可能产生错误引导。VINS的默认值不是随便填的它是从大量真实场景里调出来的稳定点。初学者先不要乱改等对成本函数和误差来源有感觉之后再做针对性调整。迭代数目也不宜过小。KLT是迭代优化过程每轮迭代都在更新光流估计。迭代太少可能没收敛迭代太多在传感器噪声下还可能过拟合到局部噪声。30次左右的设置对普通场景足够极端弱纹理时才需要配合其他约束。3.4 外点剔除RANSAC、极线约束、前后向校验光流跟出来的点不能全信。VINS里有一个经典函数rejectWithF核心用cv::findFundamentalMat配合RANSAC计算基础矩阵F然后判断每个特征点对是否满足该F矩阵下的极线约束不满足的直接标记为status0排掉。基础矩阵反映了两帧相机之间的对极几何关系它不关心空间点的具体深度只要求一对匹配点必须落在对应的极线上。RANSAC的作用是从一堆可能含外点的匹配里挑出最大一致集。这一招能有效去除大部分错误对应尤其是那些因为光照或遮挡被误跟到别的物体上的点。但它有一个前提两帧之间不能是纯旋转否则基础矩阵退化极线约束失效。VINS本身是视觉惯性系统移动过程中通常存在平移这个前提多数时候成立。为了更稳我自己的工程代码还会额外加一道前后向光流校验先从上一帧到当前帧算一次光流再从当前帧把同一组点往回算一次比较两次结果的距离。如果回投误差超过0.5或1个像素就认定这个点不可靠。这个思路实现很便宜但对遮挡、重复纹理非常有效。VINS基础版本不一定带这个功能面经里提到“改进前端”时这也是一个很自然的加分项。3.5 特征点管理age、id、次数VINS需要长期维护特征点的生命周期不能每帧都把上一帧的点扔掉。每个特征点会携带一个整数id表示它从出生到当前帧一路被跟下来的身份。代码里通常还会维护一个“跟踪次数”或“age”用来衡量这个特征点被连续跟踪了多少帧。age越长的点说明它的观测越稳定在后端优化中提供的约束越可信。但age长也有代价。空间点可能已经被重复观测数十帧继续保留大量老点会让滑动窗口的信息冗余。VINS的策略是该换就换当跟踪点数量低于阈值时在mask限制下补充新的角点同时如果某些点已经跟了很久、位置和深度估计已经收敛后端会根据边缘化策略来决定是否移除。初学者容易忽略一点前端送进后端的特征点数量和质量直接决定了滑动窗口求解的可观性和计算负担。我自己在调VINS时遇到过一个问题某些带id的特征点明明在图像里还看得见却因为频繁插入新点而被“遗忘”。后来发现是mask半径和max_cnt的配合没调好导致新点抢占老点的位置。解决思路是在检测新点前先保留age较长的稳定点再对剩余区域补点不要一刀切。4. 实操用自己的bag文件跑通VINS-Fusion的光流前端4.1 准备bag和配置文件如果你手里已经有一份VINS-Fusion源码但没有跑过实际bag我建议先拿官方提供的EuRoC数据集跑通流程因为它的相机参数、IMU频率、图像时间戳都是对齐好的能保证你看到“标准答案”是什么样的。官方数据用rosbag格式发布一般包含左目图像、右目图像、IMU数据以及ground truth。拿到bag之后需要在realsense或者自己的配置目录下新建yaml文件。yaml里最重要的是相机内参、畸变系数、IMU到相机的外参、图像话题名、IMU话题名。如果话题名和bag里不一致VINS会一直等不到数据。一个常见坑是bag里的图像话题是/cam0/image_raw你配置文件里写的却是/left/image_raw。启动前可以先用rosbag info查一下bag里有哪些topic。整个运行链路如果只观察光流甚至不用把后端estimator起完整单独跑feature_tracker和rviz就能看到前端效果。但为了验证整体是否跑通通常还是把VINS-Fusion的estimation模块一起拉起来看最终轨迹和三维点云。4.2 运行流程与rostopic/话题观察推荐的做法是开三个终端第一个roscore第二个启动VINS-Fusion的feature_tracker和estimator节点第三个playbag。启动命令要注意按数据集类型区分单目、双目和双目IMU。EuRoC一般用双目IMU配置roslaunch文件里也会指定yaml路径。跑起来之后用rostopic list可以看到一堆话题其中比较重要的是/feature_tracker/feature_img和/feature_tracker/feature。前者是带光流轨迹绘制结果的图像后者是归一化坐标和id。用rostopic hz /feature_tracker/feature可以确认发布频率用rostopic echo可以查看具体点列表。如果频率远低于预期多半是图像分辨率太高或者特征点数量太大可以在配置里把max_cnt调低。这里的实操价值在于你能直观地看到光流跟踪是否稳定。如果某一个特征点轨迹突然跳得很远基本就是外点没有被剔除干净如果老点大量消失又不断补新点说明角点质量或者金字塔设置有问题。把话题可视化之后对比一下时间戳也能排查出图像和IMU不同步的问题。4.3 可视化光流跟踪结果rviz是观察光流前端最直接的工具。添加一个Image display图像话题选/feature_tracker/feature_img。你会看到当前帧图像上叠了一层彩色点直线连着上一帧位置到当前帧位置颜色深浅通常表示特征年龄。年龄越大的点越亮或越红这说明这个特征被稳定跟踪了很久。再添加一个PointCloud2 display话题选/feature_tracker/feature就能看到归一化平面上的特征分布。稀疏光流做得好点云里的点在空间上应该比较均匀不会全部挤在图像某一块。如果看到大量点在边缘聚集且频繁闪烁那基本可以判断均匀化mask没有生效或者外点剔除太激进。我调试的时候习惯把fx、fy、cx、cy临时改成一个不准确的值看画面会崩成什么样以此反向理解归一化坐标的重要性。这个方法虽然简单但比盯着代码看印象深得多。4.4 常见bag格式问题bag文件最常见的坑有以下几类。一是图像话题名称不一致解决方法是修改yaml中的topic字段或者用rosbag remap在播放时把话题改过去。二是图像编码和尺寸不匹配VINS默认要求输入是8UC1灰度图或彩色图如果bag里是压缩图像compressed前端不一定直接支持需要先解码成sensor_msgs::Image再喂进去。三是相机内参缺失最典型的是只在话题里发布图像没有CameraInfo此时必须确保yaml里的内参完全正确否则光流虽然能跑但后端几何全错。四是IMU频率太低或时间戳抖动太大严重时会直接导致优化崩溃。如果你录的是自己的数据集我强烈建议先用Kalibr做相机内参和相机-IMU外参标定再把时间戳对齐好。没有时间同步硬件的话至少录完后用时间戳插值对齐IMU和图像。很多同学在自己录的bag上跑VINS一旦发散第一反应是调后端权重实际上问题往往出在前端数据和标定上。5. 秋招面试官想听到的回答5.1 高频问题与回答思路秋招面试里光流法相关的问题会一层层往下问每一层都有对应的考察点。第一层是“光流的原理是什么”。想拿到基础分至少要把亮度不变假设、泰勒展开、光流约束方程、LK最小二乘、金字塔coarse-to-fine说清楚。回答时不要只背公式最好提一句“我理解光流是把对应搜索从全局匹配缩减到局部梯度迭代”这样显得有工程直觉。第二层是“VINS为什么用光流而不用ORB”。这题考察方案选型能力。建议从实时性、短基线假设、IMU互补性三个角度回答并坦率承认光流在剧烈光照和快速旋转时不如特征匹配稳所以VINS才会加上IMU先验和外点剔除。第三层是“光流跟丢了怎么办”。考官想听的是完整鲁棒性机制特征点检测后要做mask均匀化跟踪后要用RANSAC剔除错误匹配还要用极线约束做几何校验可以加前后向光流校验此外前端可以结合IMU做旋转补偿。能把这些串成一个闭环就说明你真正懂工程实现。第四层是“你说你改进过光流具体改了什么”。如果你写在简历上一定要准备好细节。比如我加了IMU旋转补偿就可能会被追问“为什么能补偿旋转而不是平移”“补偿到哪一步”“对指标提升多少”这些都需要结合自己的实验数据来回答。5.2 手撕代码光流跟踪的伪代码级流程面试手撕光流不要求把OpenCV源码默写出来但最好能写出清晰的伪代码。我的常用表达是// 输入: prev_img, cur_img, prev_pts, cur_pts, status // 1. 对上一帧特征点逐一做金字塔LK光流 calcOpticalFlowPyrLK(prev_img, cur_img, prev_pts, cur_pts, status); // 2. 用基础矩阵RANSAC剔除不满足极线约束的外点 status rejectWithF(cur_img, prev_pts, cur_pts, status); // 3. 统计内点数若过少则补充新特征点 if (goodCount max_cnt) { setMask(); // 在已有点周围建mask goodFeaturesToTrack(cur_img, new_pts, max_cnt - goodCount, ...); append(new_pts); } // 4. 更新feature id和age发布归一化坐标如果面试官继续问“rejectWithF怎么实现”可以简单说成随机采样8对点求基础矩阵计算所有点的极线距离小于阈值算内点重复N次取内点数最多的模型。这里不用展开到矩阵分解但要把RANSAC的随机性说明白。5.3 从光流引申出的深挖考点光流只是视觉SLAM前端的一环。面试官最喜欢从光流开始一路追问到后端考察你的体系化程度。常见进阶点包括光流获得的匹配点如何三角化两帧之间的基础矩阵与本质矩阵有什么区别本质矩阵分解出的R、t上游四组解怎么选择滑动窗口里的特征点和光流跟踪出的点是同一个东西吗这些问题看似零散实际都在考察你是否理解“前端跟踪和后端优化如何衔接”。我建议准备面试时画一条逻辑链图像→角点检测→光流跟踪→外点剔除→归一化坐标→三角化/PnP→滑窗优化→IMU预积分约束。把每个环节的数据格式和输入输出搞清楚串联成一套自己的话术。这样被深挖时不会乱。另外VINS的bag文件在面试中也经常被提起。面试官可能会问“你用什么数据验证的”“Euroc数据集和自采数据有什么区别”这时候你可以重点强调bag里的时间戳同步、真实运动幅度对光流压力的影响。能说出“自采bag时快速旋转会让光流密集失效必须依赖IMU补偿”这种话明显比只背概念更有说服力。5.4 实战项目怎么写在简历上如果要在简历里写VINS相关项目不要只写“熟悉VINS-Mono”。我建议写成一个能体现个人贡献的项目描述比如“基于VINS-Fusion的视觉惯性里程计前端优化使用KLT金字塔光流跟踪特征点通过mask均匀化保证特征空间分布在原始图像上跟踪后去畸变利用RANSAC基础矩阵约束剔除异常点增加双向光流误差校验减少快速运动场景下的误匹配结合IMU预积分旋转补偿初始猜测提升快速旋转场景鲁棒性。”这样的写法能立刻让面试官知道你做了哪些实事。原理、实现、改进、验证一条线全串起来。之后被追问时你也有话说。最后再分享一个小经验我当年准备光流这模块花了很大力气背公式但真正让知识留在脑子里的是对着bag一帧一帧看光流结果、然后反复调参的那几天。光流看起来只是“找点”可它背后藏着整个视觉SLAM前端的基本功。只要把这个环节吃透后面的特征匹配、对极几何、BA优化学起来都会顺畅很多。