1. 视觉惯性里程计后端优化的核心逻辑拆解
视觉惯性里程计(VIO)的第六章,通常标志着整个系统从“前端数据预处理与初始化”正式跨入“后端联合优化”的大门。很多刚接触VIO的朋友,在前五章啃完了相机模型、IMU预积分、滑动窗口和特征点管理之后,到了第六章往往会卡住——因为这一章不再是单一模块的孤立推导,而是要把视觉重投影误差和IMU预积分残差放在同一个非线性优化框架里,用图优化的方式联合求解。
我当初做这一章作业的时候,最大的感受就是:公式多、耦合强、代码调试极其痛苦。但一旦把这一章吃透,你对VIO的理解会从“知道每个模块在干什么”跃升到“明白整个系统为什么能跑起来”。这一章的核心目标很明确:构建一个基于滑动窗口的视觉惯性联合优化器,让IMU的预积分约束和视觉的观测约束共同作用于同一组状态变量,通过迭代求解得到高精度的位姿估计。
为什么一定要做联合优化?因为视觉和IMU的误差特性是互补的。视觉在纹理丰富、光照稳定的场景下,相对位姿估计精度很高,但遇到快速运动或纹理缺失就容易跟丢;IMU在短时间内积分能提供高频的位姿变化,但零偏和噪声会随时间累积漂移。单独用任何一个都不够稳,只有把两者的残差放在一起优化,才能互相约束、互相修正。这就是VIO后端优化的底层逻辑。
这一章适合谁?如果你已经掌握了IMU预积分的基本推导,知道视觉重投影误差怎么构建,也大致了解滑动窗口的边际化操作,那这一章就是你的进阶必修课。如果你还没到这一步,建议先把前五章的基础打牢,否则第六章的公式推导会让你非常痛苦。
1.1 联合优化框架的数学本质
从数学上看,第六章要解决的问题可以统一成一个非线性最小二乘问题。我们定义整个滑动窗口内的状态变量,包括各个关键帧的位姿、速度、IMU零偏,以及相机和IMU之间的外参。然后,我们把所有约束分成两类:一类是IMU预积分残差,另一类是视觉重投影残差。最终的目标函数就是这两类残差的加权平方和。
用公式表达就是:我们要最小化一个关于状态变量的目标函数,这个函数由IMU残差项和视觉残差项组成,每一项都通过信息矩阵进行加权。信息矩阵的作用是告诉优化器:哪些残差更可信,哪些残差噪声更大。IMU的噪声通常用连续时间的噪声密度来描述,经过预积分后会转换成离散时间的协方差矩阵;视觉的噪声则通常假设为像素级别的各向同性高斯噪声。
这里有一个非常关键的细节:IMU预积分残差和视觉重投影残差的量纲完全不同。IMU残差涉及位置、速度、旋转,单位分别是米、米每秒、弧度;视觉残差是归一化平面上的坐标差,本质上是无量纲的。如果不做合理的加权,优化器会偏向某一类残差,导致另一类约束被“淹没”。所以信息矩阵的设定不是随便填的,它直接决定了优化结果的精度和鲁棒性。
我在实际推导时发现,很多教程只告诉你“用信息矩阵加权”,但不说这个矩阵怎么来的。其实IMU的信息矩阵就是预积分协方差矩阵的逆,而预积分协方差是通过递推传播得到的。视觉的信息矩阵通常取焦距的平方乘以单位矩阵,因为像素噪声在归一化平面上的标准差约等于像素噪声除以焦距。这个细节在代码里如果搞错了,优化结果会明显变差。
1.2 滑动窗口与边际化的工程考量
为什么非要用滑动窗口?直接做全局BA不行吗?理论上可以,但计算量会随时间无限增长,实时性根本无法保证。滑动窗口的思路是:只保留最近一段时间内的关键帧,旧的状态要么被丢弃,要么通过边际化转换成先验信息保留下来。
边际化的本质是舒尔补。当我们决定移除某个旧关键帧时,不能简单地把它删掉,因为它和窗口内的其他状态之间存在约束关系。如果直接删除,这些约束信息就丢失了,优化结果会出现偏差。正确的做法是把旧状态和当前状态之间的联合信息矩阵做舒尔补,得到一个关于剩余状态的先验信息矩阵和先验残差。这个先验项会作为后续优化的一个额外约束,保证被移除的信息不会完全丢失。
但边际化有一个非常棘手的副作用:它会引入填充(fill-in),让原本稀疏的信息矩阵变得稠密。这是因为舒尔补操作会把原本不直接相关的状态通过被移除的状态连接起来。填充会导致计算量增加,所以滑动窗口的大小不能太大,通常控制在10到20个关键帧之间。我在调试时试过把窗口开到30,结果单次优化耗时直接翻倍,实时性完全崩掉。
另一个工程上的坑是:边际化的顺序很重要。如果先边际化视觉观测再边际化IMU约束,和反过来做,得到的先验信息是不一样的。一般来说,我们会把最旧的关键帧及其相关的所有约束一起边际化掉,保证信息的一致性。这个顺序在代码里必须严格固定,否则优化器会出现不可预期的行为。
2. 公式推导的完整链路与关键细节
第六章的公式推导是很多人的噩梦,因为涉及大量的矩阵运算和坐标系变换。但如果你把推导链路拆开,其实每一步都有明确的物理意义。我当初是把整个推导过程手写了三遍,才真正理解每个矩阵是怎么来的。下面我按照自己的理解,把核心推导链路重新梳理一遍。
2.1 IMU预积分残差的构建与雅可比
IMU预积分残差衡量的是:用IMU测量值积分得到的位姿变化,和用状态变量估计出来的位姿变化之间的差异。具体来说,对于两个相邻关键帧,我们有预积分得到的位置增量、速度增量和旋转增量。同时,我们也可以用状态变量中的位姿、速度、零偏来计算出理论上的增量。两者的差就是残差。
残差的表达式涉及旋转矩阵、四元数、位置和速度。推导雅可比的时候,最麻烦的是旋转部分的扰动。四元数的扰动通常采用左乘或右乘微小旋转的方式,不同的扰动定义会导致雅可比的形式不同。我建议在推导时统一采用一种扰动方式,比如全部采用右乘扰动,这样雅可比的形式会比较规整。
IMU残差对零偏的雅可比也是重点。因为零偏在优化过程中会不断更新,预积分量需要根据零偏的变化进行一阶近似修正。这个修正公式在推导时容易漏掉,但代码里必须加上,否则零偏的优化会不准确。具体来说,预积分的位置、速度、旋转对零偏的雅可比需要在预积分过程中递推计算,这些雅可比矩阵会参与到后续的联合优化中。
还有一个容易忽略的点:IMU残差中的重力向量。重力向量在初始化阶段被估计出来,但在后续优化中通常固定不变。如果要把重力也作为优化变量,那雅可比还要多一项。不过大多数实现里重力是固定的,因为重力变化非常缓慢,优化它带来的收益很小,反而增加计算量。
2.2 视觉重投影残差的构建与雅可比
视觉重投影残差衡量的是:一个路标点被投影到当前帧的归一化平面上,和实际观测到的归一化坐标之间的差异。这个残差涉及相机外参、关键帧位姿和路标点深度。推导雅可比时,需要用到链式法则,从像素坐标一路反传到状态变量。
视觉残差的雅可比推导有一个技巧:先把残差对归一化坐标求导,再对相机坐标系下的三维点求导,最后对状态变量求导。这样分层推导不容易出错。对位姿的雅可比涉及旋转矩阵的扰动,对路标点的雅可比则涉及深度的逆。如果路标点采用逆深度参数化,雅可比的形式会更简洁,因为逆深度的不确定性更接近高斯分布。
我在推导时踩过一个坑:视觉残差对IMU零偏的雅可比。严格来说,视觉观测不直接依赖IMU零偏,但通过状态变量中的位姿和速度,零偏会间接影响视觉残差。不过在实际实现中,我们通常忽略这一项,因为零偏对视觉残差的影响非常小,忽略后优化结果几乎不变,但计算量会降低不少。这个近似在大多数场景下是合理的。
2.3 联合雅可比矩阵的组装与稀疏性利用
当我们把IMU残差和视觉残差的雅可比都推导出来后,下一步是把它们组装成一个大的雅可比矩阵。这个矩阵的行对应残差,列对应状态变量。由于滑动窗口内的状态变量很多,这个矩阵的维度会很大,但它是稀疏的,因为每个残差只依赖于少数几个状态。
利用稀疏性是高效求解的关键。通常我们会用Hessian矩阵的稀疏模式来加速求解,比如使用舒尔补把路标点先消掉,得到一个只关于位姿、速度、零偏的简化系统,然后再求解。这个操作叫“边缘化路标点”,它和滑动窗口的边际化不是一回事,但数学工具都是舒尔补。
组装雅可比矩阵时,要注意状态变量的排列顺序。如果排列顺序不合理,Hessian矩阵的稀疏性会变差,求解效率会降低。一般来说,我们会把位姿、速度、零偏放在前面,路标点放在后面,这样舒尔补操作会比较方便。这个顺序在代码里要统一,否则调试时会很混乱。
3. 编程实现的完整流程与核心代码思路
公式推导清楚之后,编程实现就是把这些公式翻译成代码。但实际写代码时,你会发现有很多工程细节是公式里不会写的。我当初写这一章作业,光调试就花了整整一周,踩了无数坑。下面我把完整的实现流程和关键代码思路分享出来,希望能帮你少走弯路。
3.1 状态变量定义与数据结构设计
首先要把滑动窗口内的所有状态变量定义清楚。通常包括:每个关键帧的位姿(位置和旋转)、速度、IMU零偏(加速度计零偏和陀螺仪零偏),以及相机和IMU之间的外参。如果要做在线外参标定,外参也作为优化变量;否则可以固定。
数据结构的设计直接影响代码的可读性和调试效率。我建议用一个结构体或类来管理单个关键帧的状态,然后用一个容器来管理整个滑动窗口。每个状态变量都要有明确的索引,方便在雅可比矩阵中定位。索引的管理可以用一个哈希表或者直接用一个数组来映射。
路标点的管理也很重要。每个路标点会被多个关键帧观测到,需要记录它被哪些帧观测、在每帧中的归一化坐标、以及它的逆深度。逆深度的初始值通常由三角化得到,如果三角化失败,可以设一个默认值,比如1.0。
注意:状态变量的索引一旦确定,在整个优化过程中不能改变。如果中途增删状态变量,索引会错乱,导致雅可比矩阵组装错误。这是新手最容易犯的错误之一。
3.2 残差计算与雅可比矩阵的代码实现
残差计算是优化器的核心。对于IMU残差,需要从预积分对象中取出预积分量、协方差矩阵和雅可比矩阵,然后根据当前状态变量计算残差。对于视觉残差,需要遍历每个路标点的每个观测,计算重投影误差。
雅可比矩阵的计算要严格按照推导的公式来。我建议在写代码之前,先把每个雅可比的表达式写在纸上,然后逐项翻译成代码。写完后用数值求导的方式验证一遍,确保解析雅可比和数值雅可比一致。这个验证步骤非常关键,能帮你发现绝大多数推导错误。
数值求导的验证方法很简单:对每个状态变量施加一个微小扰动,计算残差的变化,然后除以扰动量,得到数值雅可比。和解析雅可比对比,如果误差在1e-6量级,说明推导正确;如果误差很大,说明推导有误。我当初就是靠这个方法发现了好几个符号错误。
3.3 优化求解与迭代更新策略
组装好雅可比矩阵和残差向量后,就可以调用优化库求解了。常用的优化库有Ceres、g2o等,它们都支持自定义残差和雅可比。如果不想依赖第三方库,也可以自己实现高斯-牛顿或列文伯格-马夸尔特迭代。
迭代更新的策略也很重要。每次迭代后,状态变量会更新,预积分量需要根据零偏的变化重新传播或做一阶修正。如果零偏变化很小,可以用一阶修正;如果变化较大,建议重新传播预积分。这个判断阈值需要根据实际场景调整,我一般设为零偏变化超过0.01时重新传播。
收敛判断通常看残差的变化量或状态变量的更新量。如果更新量小于某个阈值,比如1e-6,就认为收敛。但要注意,有时候优化会陷入局部极小值,残差下降很慢但就是不收敛。这时候可以尝试增加迭代次数,或者调整信息矩阵的权重。
4. 常见问题排查与调试技巧实录
这一章的内容复杂度高,调试过程中遇到的问题也特别多。我把当初踩过的坑和排查思路整理成了一张速查表,希望能帮你快速定位问题。
4.1 优化不收敛或残差震荡的排查思路
优化不收敛是最常见的问题,可能的原因有很多。首先检查雅可比矩阵是否正确,用数值求导验证一遍。如果雅可比没问题,再检查信息矩阵的权重是否合理。IMU和视觉的权重如果相差太大,优化器会偏向一方,导致另一方约束失效。
另一个常见原因是初值太差。如果初始位姿或路标点深度偏差太大,优化器可能无法收敛到正确解。这时候可以先做一次纯视觉的初始化,或者用IMU积分给出一个粗略的初值,再联合优化。
还有一种情况是边际化引入的先验信息不一致。如果边际化时使用的状态变量顺序和后续优化不一致,先验信息会出错,导致优化震荡。检查边际化的代码,确保状态变量的索引和顺序完全一致。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 残差下降后震荡 | 信息矩阵权重不合理 | 检查IMU和视觉的协方差设定 | 调整权重,使两类残差量级相当 |
| 优化完全不收敛 | 雅可比矩阵错误 | 数值求导验证解析雅可比 | 修正推导中的符号或公式错误 |
| 优化结果偏差大 | 初值太差 | 检查初始化和三角化结果 | 改进初始化,或用纯视觉初值 |
| 优化速度极慢 | 稀疏性未利用 | 检查Hessian矩阵的稀疏模式 | 使用舒尔补边缘化路标点 |
| 边际化后结果跳变 | 先验信息不一致 | 检查边际化顺序和索引 | 固定边际化顺序,统一索引管理 |
4.2 数值稳定性与计算效率的平衡技巧
VIO后端优化对数值稳定性要求很高,因为矩阵条件数可能很大。有几个技巧可以提升稳定性:一是对状态变量做归一化,比如把位置除以一个尺度因子,让数值范围更集中;二是使用列文伯格-马夸尔特方法,它比高斯-牛顿更鲁棒,能处理病态矩阵;三是在求解线性方程时使用QR分解或Cholesky分解,避免直接求逆。
计算效率方面,除了利用稀疏性,还可以用多线程加速残差计算和雅可比组装。如果使用Ceres,可以开启自动多线程;如果自己实现,可以把路标点的残差计算并行化。另外,预积分的传播也可以缓存,避免重复计算。
提示:在调试阶段,建议先用小规模数据测试,比如只保留3到5个关键帧,确认优化逻辑正确后再扩大规模。直接上大规模数据调试,问题定位会非常困难。
4.3 从仿真数据到真实数据的迁移经验
仿真数据通常很干净,噪声模型也理想,优化很容易收敛。但真实数据有各种问题:时间戳不同步、IMU和相机外参标定不准、特征点误匹配、运动模糊等。从仿真迁移到真实数据时,需要做很多额外的处理。
时间戳同步是第一个要解决的问题。IMU频率通常远高于相机,需要做时间对齐。如果时间戳有偏差,预积分的结果会完全错误。我一般会用互相关或者手动对齐的方式,确保时间戳误差在1毫秒以内。
外参标定也很关键。如果外参不准,视觉和IMU的约束会互相矛盾,优化结果会明显偏差。可以先用标定工具得到粗略外参,然后在优化中把外参也作为变量,让它慢慢收敛到正确值。
特征点误匹配是另一个大问题。误匹配会产生错误的视觉残差,把优化带偏。可以使用鲁棒核函数,比如Huber核,降低误匹配的影响。同时,在优化前做一次RANSAC剔除明显的外点,也能提升鲁棒性。
5. 个人实操体会与进阶建议
这一章我前前后后做了三遍,第一遍照着公式抄代码,结果优化完全不收敛;第二遍重新推导了所有雅可比,用数值求导验证通过,但真实数据上还是不稳定;第三遍加入了鲁棒核函数和外参在线标定,才终于跑出了比较满意的结果。整个过程虽然痛苦,但收获非常大。
我个人最大的体会是:公式推导和代码实现之间的鸿沟,只能靠调试来填平。不要指望推导一遍就能写出正确的代码,一定要用数值求导验证,一定要用小规模数据测试,一定要可视化中间结果。比如把优化前后的位姿画出来对比,把残差的变化曲线画出来,这些可视化手段能帮你快速定位问题。
另外,不要害怕推倒重来。如果优化结果一直不对,与其在现有代码上修修补补,不如重新推导一遍公式,重新写一遍核心代码。我第二遍就是完全重写的,虽然花了两天时间,但比第一遍调试一周的效率高多了。
最后分享一个小技巧:在组装雅可比矩阵时,可以先把每个残差块的雅可比单独算出来,存到一个临时容器里,最后再统一组装。这样调试时可以单独检查每个残差块的雅可比是否正确,比直接组装成大矩阵再排查要容易得多。这个习惯我一直保留到现在,每次写新的优化器都会这么做。