☰
PythonRobotics 实战:基于真实 SE(2) 数据集的 Graph SLAM 图优化迭代全流程
2026/10/14 4:17:55 网站建设 项目流程

PythonRobotics 实战:基于真实 SE(2) 数据集的 Graph SLAM 图优化迭代全流程

【免费下载链接】PythonRoboticsPython sample codes and textbook for robotics algorithms.项目地址: https://gitcode.com/GitHub_Trending/py/PythonRobotics

本篇技术指南以 PythonRobotics 仓库中的 GraphBasedSLAM 模块为核心,讲解如何用graphslam求解器对一个真实世界的 SE(2) 位姿图(Intel Research Lab 数据集,1228 个顶点、1483 条边)进行加载、误差分析、边类型拆分与迭代优化。读完本文,你将掌握.g2o格式的加载原理、里程计边与扫描匹配边(回环)的区分方法、χ² 误差的量化评估方式,以及 Gauss-Newton 式迭代优化的完整收敛过程,并能直接复现文中的所有代码与可视化结果。

引言:从位姿图到全局一致性

Graph SLAM(基于图的 SLAM)将机器人的位姿估计问题建模为一个位姿图(pose graph):每个历史位姿对应一个顶点(vertex),任意两次位姿之间的相对变换测量对应一条边(edge)。优化的目标是通过调整所有顶点位姿,使整张图在全部约束下达到全局一致——这正是消除里程计随时间累积漂移的关键手段。

PythonRobotics 的 GraphBasedSLAM 模块包含两套互补的实现:

  • graph_based_slam.py:自带仿真器(RFID 路标 + 里程计),展示如何从原始观测自动构造边,是理解建图环节的入门示例;
  • graphslam/:一个功能完整的 Graph SLAM 求解器(源自 python-graphslam 项目),直接读取真实世界的.g2o位姿图文件并做迭代优化——这正是本文关联文档 graphSLAM_SE2_example.rst 所讲解的核心内容。

本文围绕后者展开:用一行load_g2o_se2载入真实数据集,用calc_chi2量化误差,用optimize完成迭代求解,全程以真实数据说话。

数据集与加载:真实世界的 1228 个位姿

示例使用的数据是 Intel Research Lab(英特尔实验室)的公开 SLAM 数据集,经 Luca Carlone 许可收录于仓库,其格式与引用信息记录在 data/README.rst。数据文件位于 data/input_INTEL.g2o,共 2711 行。

.g2o是一种广泛使用的图优化文本格式,文件内每一行描述一个元素。仓库数据的开头几行如下:

VERTEX_SE2 0 0.000000 0.000000 0.000000 VERTEX_SE2 1 0.000000 0.000000 -0.000642 VERTEX_SE2 2 0.000000 0.000000 -0.001180

其中VERTEX_SE2 <id> <x> <y> <yaw>定义一个平面位姿顶点,yaw单位为弧度;而EDGE_SE2 <id1> <id2> <dx> <dy> <dyaw> <Ω上三角6个元素>则定义一条约束边:前 5 个数是相对变换测量值,后 6 个数是 3×3 信息矩阵 Ω 的上三角部分。

加载逻辑见 load.py:load_g2o_se2逐行解析,VERTEX_SE2行构造PoseSE2与Vertex,EDGE_SE2行用upper_triangular_matrix_to_full_matrix把上三角 6 个元素还原为完整 3×3 信息矩阵,再封装为EdgeOdometry,最终统一交给Graph组装。不支持的文本行会输出警告日志,避免静默丢失数据。

示例中的加载与统计代码如下:

from graphslam.graph import Graph from graphslam.load import load_g2o_se2 g = load_g2o_se2("data/input_INTEL.g2o") print("Number of edges: {}".format(len(g._edges))) print("Number of vertices: {}".format(len(g._vertices)))

运行输出:

Number of edges: 1483 Number of vertices: 1228

即该数据集包含 1228 个机器人位姿顶点,以及连接这些位姿的 1483 条测量约束。

两类边:里程计约束与扫描匹配(回环)约束

数据集中的每条边都代表一次SE(2)相对变换测量:把“两顶点位姿之间测量到的相对变换”与“根据当前位姿估计计算出的相对变换”做比较,其差值构成边误差。这些边按来源可分为两类:

  1. 里程计边(odometry edges):约束两个相邻顶点(ID 连续),相对变换测量直接来自里程计数据,体现机器人局部运动的连续性;
  2. 扫描匹配边(scan-matching edges):约束两个不相邻顶点,测量可来自 2D LiDAR 扫描匹配或路标观测。这类边把远处的位姿“拉”回一起,正是 SLAM 文献中常说的回环(loop closure),是修正累积漂移的关键信息来源。至于这些约束如何由传感器数据求得,已超出本示例范围。

由于两类边的顶点 ID 差正好是 1 与否,可以非常简单地完成拆分——文档与源码中均采用这一判据:

def parse_edges(g): """将图 g 拆分为两个子图:仅含里程计边的图与仅含扫描匹配边的图""" edges_odom = [] edges_scan = [] for e in g._edges: if abs(e.vertex_ids[1] - e.vertex_ids[0]) == 1: edges_odom.append(e) else: edges_scan.append(e) g_odom = Graph(edges_odom, g._vertices) g_scan = Graph(edges_scan, g._vertices) return g_odom, g_scan

拆分后分别统计边数并计算 χ² 误差:

g_odom, g_scan = parse_edges(g) print("Number of odometry edges: {:4d}".format(len(g_odom._edges))) print("Number of scan-matching edges: {:4d}".format(len(g_scan._edges))) print("\nχ^2 error from odometry edges: {:11.3f}".format(g_odom.calc_chi2())) print("χ^2 error from scan-matching edges: {:11.3f}".format(g_scan.calc_chi2()))

输出:

Number of odometry edges: 1227 Number of scan-matching edges: 256 χ^2 error from odometry edges: 0.232 χ^2 error from scan-matching edges: 7191686.151

结果非常直观:1227 条里程计边与 1228 个顶点一一衔接,形成一条连续轨迹,其初始误差仅 0.232;而仅 256 条扫描匹配边的误差高达约 719 万。这说明初始位姿估计(通常来自纯里程计积分)与扫描匹配测量之间存在巨大矛盾——这正是“小误差随时间传播成大误差”的典型体现,也解释了为什么必须做全局优化。

Graph.plot()支持分别绘制子图以可视化两类边的拓扑差异(源码见 graph.py),例如g_odom.plot(title="Odometry edges")与g_scan.plot(title="Scan-matching edges")。

χ² 误差的数学定义与计算实现

边误差与 χ² 的定义是理解整个优化的基石。在 edge_odometry.py 中:

  • 边误差:$\mathbf{e}_j = \mathbf{z}_j - (p_2 \ominus p_1)$,即测量值 $\mathbf{z}_j$ 与两个位姿估计之差($p_2 \ominus p_1$ 表示逆位姿合成,即从 $p_1$ 坐标系观察 $p_2$ 的相对变换)的差异;
  • χ² 误差:$\mathbf{e}_j^T \Omega_j \mathbf{e}_j$,用信息矩阵 $\Omega_j$(测量协方差的逆)对误差做加权,权重越高代表该测量越可信。

Graph.calc_chi2()则把所有边的 χ² 求和,得到整张图的全局误差(见 graph.py)。因此 χ² 既是优化前的“体检指标”,也是优化过程中监控收敛的核心标量。

PoseSE2类(见 pose/se2.py)通过__sub__(逆位姿合成 $\ominus$)与__add__(位姿合成 $\oplus$)运算符重载,以 numpy 子类的方式实现 SE(2) 上的几何运算,角度一律归一化到 $[-\pi, \pi)$,这是数值稳定性的重要细节。

优化流程:数值 Jacobian 与迭代收敛

Graph SLAM 的优化目标是寻找一组位姿,使整图的 χ² 误差最小。求解器在 graph.py 的optimize(tol=1e-4, max_iter=20, fix_first_pose=True)中实现,采用 Gauss-Newton 风格迭代:

  1. 对每条边计算误差、梯度贡献与 Hessian 贡献(_calc_chi2_gradient_hessian用reduce聚合所有边);
  2. 组装稀疏 Hessian 矩阵 $H$ 与梯度向量 $\mathbf{b}$;
  3. 通过scipy.sparse.linalg.spsolve求解线性系统 $H\Delta\mathbf{x} = -\mathbf{b}$,得到位姿更新量;
  4. 更新所有位姿后进入下一轮迭代,直到 χ² 相对下降量小于tol或达到max_iter。

一个值得注意的设计点是:示例刻意使用数值微分代替解析 Jacobian。在_calc_jacobian中,求解器对每个位姿维度施加微扰 $\epsilon = 10^{-6}$,用差商近似导数(见 edge_odometry.py)。这样做的代价是计算量略增,但换来了实现的简洁与通用性——不需要为每种边类型手工推导导数。默认fix_first_pose=True会固定第一个位姿以消除位姿基准(gauge)自由度,保证线性系统可解。

调用优化仅需一行:

g.optimize()

运行时会打印每次迭代的 χ² 与相对变化量:

Iteration chi^2 rel. change --------- ----- ----------- 0 7191686.3825 1 320031728.8624 43.500234 2 125083004.3299 -0.609154 3 338155.9074 -0.997297 4 735.1344 -0.997826 5 215.8405 -0.706393 6 215.8405 -0.000000

观察收敛日志可以发现一个经典现象:第 1 轮迭代 χ² 先暴涨(相对变化 +43.5)——因为初始解与约束严重冲突,大步长更新先“剧烈震荡”一下;随后第 2~4 轮以超过 99% 的相对下降率急剧收敛,第 5 轮后趋于平稳,第 6 轮相对变化为 0,满足tol=1e-4而提前终止,只用了 6 轮而非默认上限 20 轮。这一过程说明该数据集上问题的非线性程度可控,Gauss-Newton 收敛速度很快。

优化效果:轨迹与误差的量化对比

优化完成后重新绘制整图与两类边子图:

g.plot(title="Optimized") g_odom.plot(title="Odometry edges") g_scan.plot(title="Scan-matching edges")

并再次统计两类边的 χ²:

χ^2 error from odometry edges: 142.189 χ^2 error from scan-matching edges: 73.652

优化前 → 优化后的误差演变一目了然:

边类型边数优化前 χ²优化后 χ²
里程计边12270.232142.189
扫描匹配边2567191686.15173.652
合计14837191686.3825215.8405

解读:

  • 扫描匹配边(回环约束)的误差从约719 万骤降至73.65,下降约 5 个数量级,这是优化的主要收益来源——回环约束把“漂移出去”的轨迹拉回全局一致的位置;
  • 里程计边的误差从 0.232 上升到 142.189,说明优化并非只讨好单一数据源,而是在所有约束之间做全局折中:纯里程计的一致性略有牺牲,但换来了整张图(含回环)的整体一致;
  • 这正是 Graph SLAM 的核心优势:它能把多种异构数据源(里程计、扫描匹配、路标观测等)统一放进一个优化问题里共同求解,而不是依赖单一传感器。

下图对比了优化前后的整图轨迹(红色位姿点 + 蓝色约束边):优化前位姿点发散、轨迹杂乱无章、χ² 高达 719 万;优化后轨迹收敛为规则的闭环走廊形状、边结构清晰有序,直观体现了全局优化对位姿漂移的修复能力。

配套仿真示例:边是如何“长”出来的

为帮助理解上述真实数据集中边的来源,仓库还提供了自包含的仿真版实现 graph_based_slam.py。它用 5 个 RFID 路标与里程计模拟机器人运动,通过calc_edges在“观测到同一路标”的任意两个时刻之间构造边,并用calc_jacobian推导解析 Jacobian、fill_H_and_b组装稀疏矩阵 $H$ 与向量 $\mathbf{b}$,再以 $H\Delta\mathbf{x} = -\mathbf{b}$ 迭代求解(graph_based_slam.py)。

该示例默认参数包括:时间步DT=2.0s、仿真时长SIM_TIME=100.0s、最大观测距离MAX_RANGE=30.0m、最大迭代MAX_ITR=20,协方差参数C_SIGMA1/2=0.1、C_SIGMA3=1°。运行python graph_based_slam.py即可看到蓝色真实轨迹、黑色航位推算轨迹与红色优化轨迹的实时对比,图中黑色星号为用于生成边的路标。这一实现与graphslam求解器的区别在于:前者面向“从零生成图”的教学场景(解析 Jacobian),后者面向“加载并优化现成位姿图”的工程场景(数值 Jacobian),二者互为补充。

在仓库中运行与验证

要复现本文所有结果,可按如下步骤操作:

  1. 定位代码:求解器与示例位于 SLAM/GraphBasedSLAM/,真实数据集位于 data/input_INTEL.g2o;
  2. 准备环境:依赖 numpy、scipy、matplotlib,仓库根目录的 requirements/requirements.txt 中列出了完整依赖清单;
  3. 运行仿真示例:python SLAM/GraphBasedSLAM/graph_based_slam.py(若需关闭动画可把show_animation置为False);
  4. 运行数据集示例:在 Jupyter 环境中按本文代码块顺序执行加载 → 拆分 → 统计 → 优化 → 绘图;
  5. 运行测试:仓库提供了自动化测试 test_graph_based_slam.py,它通过缩短SIM_TIME=20.0并关闭动画来快速验证仿真主流程可运行。

需要说明的前提:.g2o文件加载、迭代收敛表现与数据集内容强相关;本文所有数值(1228/1483 顶点边数、各阶段 χ²、6 轮收敛)均来自仓库自带数据与求解器在当前版本下的实际输出。

延伸阅读

  • 算法完整数学推导见配套文档 graphSLAM_formulation.rst,其中包含误差、信息矩阵与线性化的详细公式;
  • 另一示例文档 graphSLAM_doc.rst 讲解基本用法与 API;
  • 总览页 graph_slam_main.rst 汇总了以上文档、主入口函数graph_based_slam的 API 说明与参考文献(Grisetti 等人关于图优化 SLAM 的经典教程);
  • 同类 SLAM 示例(EKF SLAM、FastSLAM1/2、ICP 匹配)可继续查阅 docs/modules/4_slam/ 下其他章节,或直接阅读 SLAM/ 目录下的对应源码。

通过本文,你已完整走通“加载真实 SE(2) 位姿图 → 拆分边类型 → 量化 χ² 误差 → 迭代优化 → 对比效果”的 Graph SLAM 实战链路,掌握了用 graphslam/ 求解器处理真实数据的完整方法论。

【免费下载链接】PythonRoboticsPython sample codes and textbook for robotics algorithms.项目地址: https://gitcode.com/GitHub_Trending/py/PythonRobotics

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询