你是一位帐篷搭建队长,带着队员扛着一捆帐篷杆和一盘“魔性”卷尺——这卷尺每次量都会偏一点点。你从入口拱门开始,一根一根地接杆子,每接一根就用卷尺量一量,把杆子的位置记在本子上。搭到第 8 根杆时你回头一望:本该正对着入口拱门的第 10 根杆,竟然歪到了左边好几米!问题不是哪根杆“搭错了”,而是每一段的微小误差像滚雪球一样越滚越大,最后整座帐篷的框架拧成了一团麻花。真正的高手不会一根根拆了重搭,而是把每一段测量都想象成一根有“理想长度”的橡皮筋,绑在对应的两根杆之间,然后松手,让整张网络自己收紧——每根杆子都微微挪动,最终停在“所有橡皮筋张力总和最小”的位置上。这就是图优化 SLAM 的核心思想。本篇文章将把这张“橡皮筋网络”背后的数学讲清楚,并带你上手 g2o 与 GTSAM 两套主流工具,搞懂机器人到底是怎么“越走越偏、再被拉回来”的。

岗位关联

对 AI 从业者而言,图优化后端决定了机器人在地图上的“一致性”,是无人车、仓储机器人、AR 设备定位精度的最后一道防线,也是你从“能跑通 Demo”进阶到“撑起量产系统”的分水岭技能。

目录
  1. 可视化演示
  2. SLAM问题本质:机器人为什么会越走越偏
  3. 因子图构建:把橡皮筋分类挂到图板上
  4. 误差函数设计:橡皮筋的拉伸量怎么算
  5. 图优化求解:网络是如何“松手”收敛的
  6. g2o框架实战:第一家施工队的标准流程
  7. GTSAM框架实战:第二家施工队的增量绝活
  8. 工程落地技巧:老队长的避坑手册
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示深入理解图优化SLAM(g2的核心机制,通过输入/处理/存储/输出四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

深入理解图优化SLAM(g2:因子图构 因子图构建与SLAM图表示 误差函数与雅可比矩阵计算 核心机制可视化 深入理解图优化SLAM(g2整体架构与关键组件

SLAM问题本质:机器人为什么会越走越偏

帐篷里的卷尺每次只偏一点点,但架不住“每一步都依赖上一步”。你量第一根杆时偏了 1 厘米,搭第二根时在 1 厘米基础上又多偏 1 厘米……到第十根时,几十厘米的偏差就攒出来了。机器人也一样:它靠轮子转圈数(里程计)或惯性传感器推算自己走了多远,每一步推算都带噪声,这些噪声顺着时间链条一路叠加,最终“估计轨迹”和“真实轨迹”像两股越岔越远的线——这就是漂移(drift)。

SLAM 的全称是“同时定位与建图”(Simultaneous Localization and Mapping),它天生是个连环套:机器人要知道自己在哪,才能把传感器看到的障碍物放进地图;可它又得靠地图来确认自己在哪。在没有 GPS 的室内,它只能靠“小步累加”的方式摸黑前进。而回环检测(loop closure)就像是你在帐篷里绕了一大圈后,突然认出了入口拱门——这个“我见过这里”的瞬间,把累积的矛盾彻底暴露了出来。

帐篷队长的解决方案不是换一把更准的卷尺,而是把已经量过的“所有相对关系”全部保留下来,做成一张橡皮筋网,一次性全局收紧。图优化的思想正是如此:不纠结于某一步算得准不准,而是把所有约束放在一起,寻找一个让全局矛盾最小的状态。

xt=g(xt−1,ut)+wt,wt∼N(0,Σw)\displaystyle x_t = g(x_{t-1}, u_t) + w_t, \quad w_t \sim \mathcal{N}(0, \Sigma_w)
这个式子描述机器人的“每一步推算”:当前位姿由上一刻位姿加控制量推出来,再叠加一个随机噪声。噪声虽然小,却会随着步数不断累积——这正是漂移的数学根源。
  1. 状态变量 $x_t$表示机器人在地图中的位置与朝向,相当于帐篷接节点的空间坐标。
    xt∈R3=(x,y,θ)x_t \in \mathbb{R}^3 = (x, y, \theta)
  2. 运动模型 $g(\cdot)$根据上一步状态与控制量预测当前状态的函数,相当于你根据卷尺读数推断下一段杆子的落点。
    xt=g(xt−1,ut)x_t = g(x_{t-1}, u_t)
  3. 控制量 $u_t$机器人“自以为”走的那一步,来自轮子编码器或惯性测量单元。
    ut=里程计读数u_t = \text{里程计读数}
  4. 噪声项 $w_t$每一步推算里混入的随机误差,方差越大说明这段推算越不靠谱。
    wt∼N(0,Σw)w_t \sim \mathcal{N}(0, \Sigma_w)

因子图构建:把橡皮筋分类挂到图板上

队长开始整理手头的测量记录:相邻两根杆之间的距离(里程计约束)、杆与地面钉子的相对位置(路标观测)、以及绕回来时看到的拱门(回环约束)。每一条记录都是一根“需求理想长度”的橡皮筋,而杆子的位置就是要调整的变量。把变量画成圆点,把测量画成方块,圆点和方块之间连上线——这张图就是因子图(factor graph)。

在数学上,因子图把“在给定所有测量 Z 的条件下,变量 X 的可能性”拆解成许多小函数的乘积。每个小函数只管与自己相连的那一小撮变量,就像每根橡皮筋只关心它绑住的两个接头,根本不必关心整张帐篷。

g2o 中把变量称为“顶点”(Vertex),测量称为“边”(Edge);GTSAM 中把测量称为“因子”(Factor)。它们描述的是同一张橡皮筋网,只是术语不同。值得一提的是,因子图与贝叶斯网络互为“倒数”关系:因子表示测量如何由变量生成,贝叶斯网络表示给定变量的条件下测量的概率分布,二者可以通过归一化互相转换。知道谁和谁连着,比知道每个节点的绝对坐标更重要——这正是图模型最优雅的地方。

P(X∣Z)∝∏kfk(Xk)\displaystyle P(X \mid Z) \propto \prod_{k} f_k(X_k)
这个式子是因子图的“总纲”:整张图的可能性等于所有因子(橡皮筋)的乘积。每个因子只依赖一小部分变量,这让问题可以被拆开求解。
  1. 后验概率 $P(X \mid Z)$已知全部测量后,对位姿和路标真实位置的置信度。
    P(X∣Z)P(X \mid Z)
  2. 连乘符号 $\prod$把所有因子的“贡献”乘在一起,对应每根橡皮筋的拉扯合力。
    ∏kfk(Xk)\prod_{k} f_k(X_k)
  3. 因子 $f_k$一个测量约束对应的函数,输入是它所关联的变量子集,输出是“这条测量有多可信”。
    fk(Xk)=exp⁡(−12ek⊤Ωkek)f_k(X_k) = \exp\left(-\frac{1}{2} e_k^\top \Omega_k e_k\right)
  4. 变量子集 $X_k$第 k 条边实际连接的节点,比如某次观测只涉及一个位姿和一个路标。
    Xk={xi,ℓj}X_k = \{ x_i, \ell_j \}

误差函数设计:橡皮筋的拉伸量怎么算

每根橡皮筋都有两个长度:理想长度(测量值 z)和实际长度(根据当前节点位置反算出的预测值 h)。两者之差就是橡皮筋的“拉伸量”,也就是残差(residual)。拉伸量越大,这根橡皮筋越不满。但橡皮筋还有粗细之分:精密激光测距仪给的约束是粗橡皮筋,便宜的轮式里程计给的约束是细橡皮筋——粗的更难被拉伸,这就是信息矩阵(information matrix)的作用。

信息矩阵 $\Omega_k$ 是测量协方差矩阵的逆:测量越准,协方差越小,信息矩阵越大,这条橡皮筋就越“粗硬”。整张网的总张力就是所有橡皮筋能量的加权和:$F(x) = \sum_k e_k^\top \Omega_k e_k$。还有一根特殊的“智能橡皮筋”——鲁棒核函数(如 Huber)。当回环检测认错了杆(误匹配)时,残差会异常巨大,普通橡皮筋会疯狂拉扯把整张网撕坏;Huber 核让拉伸量超过阈值后不再“加倍用力”,把野值的影响压在可控范围内,保住整张网的大局。网的质量不取决于某根橡皮筋多完美,而取决于如何平衡所有橡皮筋的拉扯。

ek=zk−hk(Xk)\displaystyle e_k = z_k - h_k(X_k)
残差的定义是整个优化的“零件”:测量值减去预测值。后面所有迭代都是在想方设法把这些残差压小。
  1. 残差向量 $e_k$第 k 条测量的“拉伸量”,是一个多维向量(位置误差、角度误差等)。
    ek=zk−hk(Xk)e_k = z_k - h_k(X_k)
  2. 测量值 $z_k$传感器直接读到的数据,比如激光测到路标在“前方 3 米偏左 10 度”,对应橡皮筋的理想长度。
    zk=传感器读数z_k = \text{传感器读数}
  3. 预测函数 $h_k$根据当前节点位置“算出”如果一切完美,传感器应该读到什么,对应橡皮筋当前被拉长后的实际长度。
    hk(Xk)=根据节点位姿的几何计算h_k(X_k) = \text{根据节点位姿的几何计算}
  4. 加权平方把残差乘以信息矩阵再做内积,就得到这条边的“能量”。
    ek⊤Ωkeke_k^\top \Omega_k e_k

图优化求解:网络是如何“松手”收敛的

真正放开手让橡皮筋网自己收——但数学世界没有物理世界那么听话。总张力函数是一个复杂的非线性曲面,我们不知道最低点在哪,只能“摸着石头下坡”:站在当前状态,把每根橡皮筋的拉伸-位移关系近似成一条直线(线性化),于是局部就变成一个二次函数,可以直接求出最优的微小移动量;挪一步,再重新线性化。这个过程就是高斯牛顿法(Gauss-Newton)。

实际操作中,工程上常用带阻尼的 LM 算法(Levenberg-Marquardt):它对 Hessian 矩阵加上一个阻尼项 $\lambda I$。$\lambda$ 大时接近“小心试探”的梯度下降,$\lambda$ 小时接近“大步快跑”的高斯牛顿,算法会自动调节这个旋钮。由于每条橡皮筋只连接少数节点,Hessian 矩阵天然是稀疏的——大量位置根本没有橡皮筋相连,对应的矩阵块为零。用稀疏 Cholesky 分解或稀疏 QR 分解求解,速度可以比稠密求解快几个数量级。这就是为什么几百个节点的回环优化在手机上也能毫秒级完成。注意,这类算法只能保证收敛到“局部”最优点:网络从哪里开始松手,很大程度决定了它最后停在哪个坑里(初值问题留到后面专门讲)。

F(x)=∑kek(x)⊤Ωk ek(x)\displaystyle F(x) = \sum_{k} e_k(x)^{\top} \Omega_k \, e_k(x)
这是图优化的总目标函数:所有橡皮筋加权拉伸能量之和。优化就是不断调整节点位置,让这个总能量尽可能小。
  1. 总能量 $F(x)$整张网的“不满程度”,是所有橡皮筋张力的总和。
    F(x)=∑kek⊤ΩkekF(x) = \sum_k e_k^\top \Omega_k e_k
  2. 残差 $e_k(x)$编写成关于节点位置的函数,位置一变,拉伸量就变。
    ek(x)=zk−hk(x)e_k(x) = z_k - h_k(x)
  3. 信息矩阵 $\Omega_k$把每条边的置信度转成权重,高置信度边的能量占比更大。
    Ωk=Σk−1\Omega_k = \Sigma_k^{-1}
  4. 求和 $\sum$把每条边的“不满”叠加成全局的“不满”,优化就是让这个全局量最小化。
    min⁡xF(x)\min_x F(x)

g2o框架实战:第一家施工队的标准流程

g2o(General Graph Optimization)是图优化界的“老牌施工队”。它的工作流程非常标准:先在图纸上标出所有接头(顶点),再挂上所有橡皮筋(边),然后配置一套滑轮组(求解器),最后统一松手(迭代优化)。g2o 的核心抽象恰好对应我们前面的比喻——顶点是你关心的变量,边是你的测量约束,而框架本身只负责“怎么把网拉紧”。

在代码层面,g2o 的典型流程是:定义位姿顶点 VertexSE2 或 3D 的 VertexSE3Expmap,定义路标顶点 VertexPointXY;然后定义误差边,如里程计边 EdgeSE2、路标观测边 EdgeSE2PointXY;接着创建 BlockSolver 与 LinearSolverEigen,套上 OptimizationAlgorithmLevenberg;最后调用 initializeOptimization() 与 optimize(30) 开始批量迭代。g2o 支持给边挂 setRobustKernel 来使用 Huber 核,也支持在每次迭代后通过 chi2 指标观察总误差的下降曲线来判定是否收敛。它的定位是“批量松弛”:图纸一次备齐,统一放开所有橡皮筋,非常适合离线建图和后端全局优化场景。

Δx∗=−(J⊤ΩJ+λI)−1J⊤Ωe\displaystyle \Delta x^{*} = -\left( J^{\top} \Omega J + \lambda I \right)^{-1} J^{\top} \Omega e
这就是 LM 算法的核心一步:通过解这个线性方程组,得到所有节点本次应该移动的增量 $\Delta x^*$。
  1. 雅可比矩阵 $J$每个残差对每个节点位置的导数,描述“往哪边挪能最快减小拉伸量”。
    J=∂e∂xJ = \frac{\partial e}{\partial x}
  2. 近似 Hessian $J^\top \Omega J$由所有橡皮筋的“刚度”拼成的大矩阵,决定网络上各处的刚性连接关系。
    H=J⊤ΩJH = J^\top \Omega J
  3. 阻尼项 $\lambda I$LM 算法加的对角“保险丝”,防止某一步移动过大导致网络崩坏。
    λI\lambda I
  4. 右侧向量 $J^\top \Omega e$所有橡皮筋的“拉力合力”投影到变量空间,驱动节点往能量下降的方向走。
    b=J⊤Ωeb = J^\top \Omega e

GTSAM框架实战:第二家施工队的增量绝活

GTSAM 是另一家施工队,师承佐治亚理工,专攻“边干活边收网”。它的独门绝技是:不把整张网拆了重拉,而是每次只来一根新橡皮筋,就只调整它周围一小片区域;只有在回环这种“大事”发生时,才惊动更大范围。这套思路被命名为 iSAM(增量平滑与建图),它的数学基础是把因子图不断转换成贝叶斯网络——一种“已经理清因果”的表达方式,新测量到达时只需更新受影响的部分。

GTSAM 的 API 以因子图为中心:用 Pose2、Point2 定义变量,用 BetweenFactor<Pose2> 表示里程计约束,用 BearingRangeFactor 表示观测约束,NonlinearFactorGraph 收集所有因子,交给 ISAM2 增量求解器迭代。它与 g2o 最本质的区别是:g2o 倾向于“批量、简单、直接”,适合地图规模可控的离线场景;GTSAM 强调“概率语义 + 增量维护”,适合长时间在线运行的机器人——比如送货机器人跑一整天,随时插入新回环,系统依然能保持实时响应。两种武器没有绝对的优劣,选型取决于你的“帐篷”有多大、施工是白天集中做还是全天不断有新料进场。

p(X∣Z1:t)∝p(X∣Z1:t−1)⋅ft(Xt)\displaystyle p(X \mid Z_{1:t}) \propto p(X \mid Z_{1:t-1}) \cdot f_t(X_t)
这是增量优化的核心:新时刻的后验概率等于上一时刻的后验概率再乘上新到达的因子。老信息“打包留存”,新信息“局部吸收”。
  1. 当前后验 $p(X \mid Z_{1t})$:截至 t 时刻所有测量都考虑进来后,对位姿与路标的置信度。
    p(X∣Z1:t)p(X \mid Z_{1:t})
  2. 上一时刻后验 $p(X \mid Z_{1t-1})$:之前已经消元、压缩好的“知识快照”,相当于已经松弛完的旧网格。
    p(X∣Z1:t−1)p(X \mid Z_{1:t-1})
  3. 新因子 $f_t(X_t)$t 时刻新到的测量约束,只关联当前局部的变量。
    ft(Xt)=exp⁡(−12et⊤Ωtet)f_t(X_t) = \exp\left(-\tfrac{1}{2} e_t^\top \Omega_t e_t\right)
  4. 正比关系 $\propto$两边差一个归一化常数,不影响最大值位置,所以可以直接相乘更新。
    p(X∣Z1:t)∝p(X∣Z1:t−1)⋅ft(Xt)p(X \mid Z_{1:t}) \propto p(X \mid Z_{1:t-1}) \cdot f_t(X_t)

工程落地技巧:老队长的避坑手册

帐篷队长的经验是用一次次塌帐篷换来的。第一条:初值决定成败——绳子从“大致合理”的形态开始松,才能落到正确的低能量位置;如果初始状态乱成一团,橡皮筋网会卡在局部极小值里,怎么拉都回不来。所以工程上通常先用里程计推算位姿作为初值,再做图优化微调。第二条:方向单一的网会“滑移”——在长走廊里,前后方向的约束很紧,左右方向的约束几乎为零,Hessian 矩阵就病了。

第三条:数值陷阱——角度是个周期性变量,359° 和 -1° 其实只差 2°,但直接相减会得到 360°,所以在残差计算中必须把角度差归一化到 $[-\pi, \pi]$。浮点方面,大场景建议用双精度,否则信息矩阵的数值差异可能被吞掉。第四条:调试可视化——g2o 自带 g2o_viewer 可以直接查看网络松弛过程,GTSAM 也可以导出轨迹和协方差,配合残差直方图判断哪条边是“害群之马”。质量指标方面,常用绝对轨迹误差(ATE)与回环前后误差对比来衡量建图精度的提升。就像聪明的帐篷队长会给平行的绳子加“斜撑”约束,面对退化场景的工程师也要主动补充先验因子、地面约束或 GPS 弱约束,让网络在每个方向上都“抓得住”。

κ(H)=λmax⁡(H)λmin⁡(H)\displaystyle \kappa(H) = \frac{\lambda_{\max}(H)}{\lambda_{\min}(H)}
条件数衡量矩阵的“健康程度”:最大特征值与最小特征值之比越大,优化在某个方向上就越“无力”,一点噪声都会被无限放大。
  1. 条件数 $\kappa(H)$描述优化问题在各界方向上“好算程度”的指标,1 是完美,越大越危险。
    κ(H)\kappa(H)
  2. 最大特征值 $\lambda_{\max}$网络中最“硬”的方向,这个方向上约束极强。
    λmax⁡(H)\lambda_{\max}(H)
  3. 最小特征值 $\lambda_{\min}$网络中最“软”的方向,这个方向上几乎没有约束,对应退化。
    λmin⁡(H)\lambda_{\min}(H)

AI 应用场景

场景一:仓储物流 AGV 的全局一致性地图更新

阶段:定位与建图(后端优化)

解决的问题:多楼层多辆 AGV 长期运行后,各自积累的地图会漂移,导致“明明同一面墙,两张地图却差了几十厘米”。传统做法是定期停机人工重测,成本极高;图优化后端把所有车辆的历史轨迹、地标观测和回环约束统一收进因子图,全局优化后一次性消除矛盾。

具体案例:某电商仓库部署 50 台 AGV,采用 GTSAM 的 ISAM2 增量优化,每天车辆回库充电时把当天新增的位姿与回环因子插入图模型,十分钟内完成局部重消元。实施后地图拼接误差从 40 厘米降到 5 厘米,机器人穿行货架不再“撞墙”。

场景二:自动驾驶众包高精地图的批量建图

阶段:离线地图生产与质检

解决的问题:众包车队在不同时段采集同一路段的轨迹,单车轨迹漂移各不相同,直接叠加无法形成车道级地图。g2o 擅长在离线阶段把成百上千条轨迹的约束一次性全局优化,得到一致的高精地图基线。

具体案例:某自动驾驶公司用 20 辆采集车跑完一段 30 公里的城市快速路后,后端用 g2o 构建包含 5 万位姿节点、20 万路标点的因子图,通过回环约束和路面特征约束联合优化,将车道中心线的横向误差从 30 厘米压缩到 10 厘米以内,地图通过率显著提升。

Python 演示

python106 行
# -*- coding: utf-8 -*-"""极简位姿图优化:用高斯牛顿法最小化橡皮筋网络总张力"""import math class PoseGraph:    def __init__(self):        self.poses = []           # [(x, y, theta)] 位姿节点        self.edges = []           # (i, j, dx, dy, dtheta, w) 因子边     def add_pose(self, x, y, theta):        self.poses.append([x, y, theta])     def add_edge(self, i, j, dx, dy, dtheta, w):        self.edges.append((i, j, dx, dy, dtheta, w))     def residual(self, i, j, dx, dy, dtheta):        xi, yi, ti = self.poses[i]        xj, yj, tj = self.poses[j]        c, s = math.cos(ti), math.sin(ti)        ex = (xj - xi) * c + (yj - yi) * s - dx        ey = -(xj - xi) * s + (yj - yi) * c - dy        et = tj - ti - dtheta        while et > math.pi: et -= 2 * math.pi        while et < -math.pi: et += 2 * math.pi        return [ex, ey, et]     def total_error(self):        total = 0.0        for e in self.edges:            r = self.residual(*e[:5])            total += r[0] ** 2 + r[1] ** 2 + r[2] ** 2        return total     def numerical_jacobian(self, e, eps=1e-6):        i, j = e[0], e[1]        J = []        for k in (i, j):            for d in range(3):                old = self.poses[k][d]                self.poses[k][d] = old + eps                rp = self.residual(*e[:5])                self.poses[k][d] = old - eps                rm = self.residual(*e[:5])                self.poses[k][d] = old                J.append([(rp[m] - rm[m]) / (2 * eps) for m in range(3)])        return J     def optimize(self, iters=50):        n = len(self.poses) * 3        for it in range(iters):            H = [[0.0] * n for _ in range(n)]            b = [0.0] * n            for e in self.edges:                i, j = e[0], e[1]                r = self.residual(*e[:5])                J = self.numerical_jacobian(e)                cols = [3 * i + d for d in range(3)] + [3 * j + d for d in range(3)]                for a in range(6):                    for c in range(6):                        H[cols[a]][cols[c]] += sum(J[a][m] * J[c][m] for m in range(3))                    b[cols[a]] += sum(J[a][m] * r[m] for m in range(3))            for d in range(3):                H[d] = [0.0] * n                H[d][d] = 1.0                b[d] = 0.0   # 锚定第一个位姿,消除零空间            dx = self.solve(H, b)            for k in range(len(self.poses)):                for d in range(3):                    self.poses[k][d] += dx[3 * k + d]            if it % 10 == 0:                print("iter %3d, 总张力 %.3f" % (it, self.total_error()))     def solve(self, H, b):        n = len(b)        M = [row[:] for row in H]        rhs = b[:]        for col in range(n):            pivot = max(range(col, n), key=lambda r: abs(M[r][col]))            M[col], M[pivot] = M[pivot], M[col]            rhs[col], rhs[pivot] = rhs[pivot], rhs[col]            for r in range(col + 1, n):                if abs(M[r][col]) < 1e-12: continue                t = M[r][col] / M[col][col]                for c in range(col, n):                    M[r][c] -= t * M[col][c]                rhs[r] -= t * rhs[col]        x = [0.0] * n        for r in range(n - 1, -1, -1):            x[r] = (rhs[r] - sum(M[r][c] * x[c] for c in range(r + 1, n))) / M[r][r]        return x def main():    g = PoseGraph()    g.add_pose(0.0, 0.0, 0.0)          # 位姿0:锚点    g.add_pose(10.2, 0.5, 0.1)         # 位姿1:带一点漂移    g.add_pose(20.6, 1.6, 0.23)        # 位姿2:漂移更大    g.add_edge(0, 1, 10.0, 0.0, 0.0, 1.0)   # 里程计边:不太可信    g.add_edge(1, 2, 10.0, 0.0, 0.0, 1.0)    g.add_edge(2, 0, 20.0, 0.4, 0.42, 5.0) # 回环边:非常可信    print("初始总张力: %.3f" % g.total_error())    g.optimize(50)    print("优化后位姿:", [[round(v, 3) for v in p] for p in g.poses])    print("最终总张力: %.6f" % g.total_error()) if __name__ == "__main__":    main()
目的:从零实现一个 2D 位姿图优化器,用高斯牛顿法迭代松弛一个由 3 个位姿和 3 条边组成的“迷你橡皮筋网”,演示图优化后端最核心的数学流程。
  1. self.poses` / `self.edges以列表存储位姿节点和因子边,对应图中的“杆接头”与“橡皮筋”。
  2. def residual(self, i, j, dx, dy, dtheta)计算两帧之间的相对位姿残差,相当于量橡皮筋的拉伸量。
  3. while et > math.pi: et -= 2 * math.pi角度归一化,防止 359° 减 -1° 变成 360° 的数值陷阱。
  4. numerical_jacobian用中心差分近似雅可比矩阵,避免手推复杂的导数公式。
  5. cols = [3 * i + d ...]把残差对两个节点的导数映射到全局矩阵的位置,体现稀疏装配。
  6. H[cols[a]][cols[c]] += sum(...)累加 $J^\top J$ 与 $J^\top r$,构造正规方程。
  7. H[d][d] = 1.0; b[d] = 0.0锚定第一个位姿,消除全局平移旋转的不可观零空间。
  8. dx = self.solve(H, b)用高斯消元求解线性方程组,得到本次迭代的节点移动增量。

常见误区

误区我刚接触时以为回环闭合就是把整张图从头到尾再优化一遍,所以规模一大肯定卡成幻灯片,只适合离线跑。

正解图优化利用矩阵稀疏性加速求解:大多数量测只关联局部节点,Hessian 矩阵里绝大多数块为零,稀疏 Cholesky 分解让批量优化在数百节点的规模下依然毫秒级完成。而 GTSAM 的 iSAM 更进一步,用贝叶斯树做增量更新,新测量只“打扰”局部区域,回环时才做小范围重消元。橡皮筋网不必每次全部拆散重拉。

为何常见:线性代数对小白是黑盒,容易把“解方程组”想象成“从头遍历所有数据”,忽略了稀疏结构的威力。

误区我调参时总盯着总误差,觉得优化完误差越小说明系统越强,甚至希望误差降到 0。

正解测量本身带噪声,橡皮筋的理想长度本身就不精确。把误差压到远低于测量噪声水平,往往意味着模型在“过度拟合”错误数据——比如把回环误匹配也硬生生拉平。正确的做法是按信息矩阵加权、配合 Huber 核函数抑制野值,让误差降到与测量噪声匹配的量级即可。

为何常见:人在直觉上喜欢“越小越好”的评价指标,忽略了测量噪声这一物理下限。

误区我以为调用 `addVertex`、`addEdge` 就是在“自动建图”,框架会替我设计误差函数和权重。

正解框架只负责数值优化,也就是“拉绳子”;而“绳子绑在哪、理想长度多少、橡皮筋多粗”完全由使用者定义。你需要自己建模:顶点类型选什么、残差函数怎么写、信息矩阵如何从传感器协方差换算、是否加核函数。类比的比喻里,施工队只负责收紧,图纸必须你自己画。

为何常见:框架 API 封装得太顺滑,“添加顶点、添加边、运行”三步让初学者误以为模型已自动生成。

误区我刚开始做位姿优化时,把初值全设成零,心想迭代算法自己会找到正确位置。

正解高斯牛顿和 LM 都是局部优化方法,收敛结果取决于起点,就像橡皮筋网从一团乱麻开始松手,会卡在某个局部小能量状态。工程上惯例是用里程计轨迹作为初值,它已经“大致正确”,优化只是做一致性微调。回环检测质量差、初值严重错误时,先做粗对齐(如 ICP)再进入图优化。

为何常见:教程里的收敛曲线都画得很漂亮,让人误以为算法具有全局搜索能力,忽略了“局部”二字。

误区我以为设了 Huber 核之后,误匹配的坏测量就被自动删除了,不会再影响结果。

正解核函数并没有删除测量,只是把超过阈值的残差在梯度上的“拉力”压平——相当于对一根拉得过长的橡皮筋松手,让它不再拼命拽网,但绳子仍然绑着。对于真正恶性的误匹配,还是要在回环检测层面做几何校验(如 RANSAC)拒掉,核函数只是最后一道保险。

为何常见:把“压制影响”误当成“数据删除”,混淆了鲁棒统计中“降权”与“剔除”两个概念。

面试题

Q1. SLAM 图优化直接优化的对象是什么?

单选题 · easy

  • A.图像像素的颜色分布
  • B.相机的内外参数校准
  • C.位姿与路标的位置← 正确答案
  • D.传感器的采样频率

答案:C。图优化就是把机器人位姿和路标点当作变量,通过调整它们来最小化所有测量残差。像素颜色属于图像处理,内外参标定是另一个问题,采样频率与优化无关。

Q2. 因子图中的圆形节点通常代表什么?

单选题 · easy

  • A.一次完整的建图任务
  • B.待估计的变量与路标← 正确答案
  • C.传感器的原始图像帧
  • D.优化算法的迭代次数

答案:B。因子图里圆节点是待估计变量(位姿、路标位置),方块节点是测量因子。在橡皮筋比喻中,圆点就是帐篷杆的接头,方块是绑在上面的橡皮筋。

Q3. 误差函数中信息矩阵 Ω 的根本作用是什么?

单选题 · mid

  • A.决定算法迭代总次数
  • B.表示各测量的置信度← 正确答案
  • C.将非线性问题线性化
  • D.提取图像特征点坐标

答案:B。信息矩阵是测量协方差的逆,协方差小则信息大,对应“更粗更硬的橡皮筋”。它只影响权重分配,不决定迭代次数,也不负责线性化或特征提取。

Q4. 高斯牛顿法与 LM 算法的关键差异是什么?

单选题 · mid

  • A.LM 引入阻尼调节步长← 正确答案
  • B.高斯牛顿无需雅可比
  • C.LM 只能处理一维问题
  • D.二者在数学上完全等价

答案:A。LM 在高斯牛顿的近似 Hessian 上加了阻尼项 λI:λ 大时接近梯度下降的保守试探,λ 小时接近高斯牛顿的快速收敛。两者都需要雅可比,都支持多维,并不等价。

Q5. 位姿图优化必须锚定一个节点的原因是什么?

单选题 · mid

  • A.为了压缩矩阵存储空间
  • B.存在不可观的零空间← 正确答案
  • C.防止噪声继续累积
  • D.让误差收敛到负值

答案:B。如果所有约束都是相对测量,整张网可以整体平移或旋转而不改变任何橡皮筋的拉伸量,这就是零空间。锚定一个节点(或加先验因子)相当于把帐篷钉在地上,消除不可观的自由度。

Q6. 回环闭合在因子图中具体表现为?

单选题 · hard

  • A.删除一段冗余轨迹
  • B.增加一条回环因子边← 正确答案
  • C.提高图像采集帧率
  • D.重标定所有传感器

答案:B。回环检测发现“到过这里”后,会在当前位姿与历史位姿之间新增一条约束边——相当于把一根粗橡皮筋从远处拉回拱门,让整个网络重新分配误差,从而抑制累积漂移。

Q7. 鲁棒核函数(如 Huber)被引入主要是为了?

单选题 · easy

  • A.保证目标函数可微凸
  • B.抑制野值残差的权重← 正确答案
  • C.提升数值求解的速度
  • D.取代信息矩阵的作用

答案:B。当残差超过阈值,Huber 核会让梯度的“拉力”不再线性增长,避免一条错误测量的粗橡皮筋撕裂整张网。它不保证凸性,也不加速求解,更不替代信息矩阵。

Q8. 关于 g2o 框架的正确说法包括?

多选题 · mid

  • A.用户需定义顶点和边← 正确答案
  • B.内建稀疏线性求解器← 正确答案
  • C.框架自动生成误差函数
  • D.支持高斯牛顿与 LM← 正确答案

答案:ABD。g2o 要求使用者定义顶点类型与边(含误差函数),它负责数值优化和线性求解,内置 Eigen/Cholmod 等稀疏求解器并支持多种优化算法。误差函数必须自己写,框架不会自动生成。

Q9. GTSAM 增量优化(iSAM)的特点是?

多选题 · hard

  • A.新因子到达时局部更新← 正确答案
  • B.每次回环都全量重算
  • C.维护贝叶斯树结构← 正确答案
  • D.只适合离线批量建图

答案:AC。iSAM 把因子图消元成贝叶斯树,新因子只影响局部变量,回环时也仅重消元受影响子树,而非全量重算。它正是为长时间在线运行设计的,D 的说法完全相反。

Q10. 优化过程出现退化(病态)时应采取的措施?

多选题 · extreme

  • A.增加退化方向的约束← 正确答案
  • B.检查网络的可观性← 正确答案
  • C.无条件加大迭代次数
  • D.加入先验信息矩阵← 正确答案

答案:ABD。退化意味着某些方向约束不足(如长走廊的横向),加大迭代次数只会空转。正确做法是分析可观性、增加该方向的约束(如地面特征、GPS 弱先验),或直接给信息矩阵加先验项,就像给平行绳子加斜撑。