资讯动态

G2O图优化库入门:从顶点边设计到SLAM位姿优化实战

发布时间:2026/10/4 5:25:15 来源:尧图企业网站定制
G2O是SLAM、三维重建、以及各类传感器融合项目中绕不开的一个基础库。它的全称是General Graph Optimization直译过来就是通用图优化核心功能就是帮你构建一个由顶点和边组成的图模型然后通过非线性最小二乘方法去求解这个图中所有参数的最优值。很多刚接触SLAM的开发者第一个被劝退的地方往往就是G2O因为它的类结构多、继承关系复杂、代码写起来也绕入门成本确实不小。我这篇内容就专门来拆解G2O从核心概念、安装过程到一步步写代码建图求解把那些踩过的坑、绕过的弯都摊开说明白。无论你是正在看ORB-SLAM源码而不解的初学者还是需要在项目中自己实现一个位姿优化的开发者这篇文章都能给你提供一个直接可参考的实操路径。我会尽量避开晦涩的数学推导用直观的类比和代码示例把图优化这套东西讲透。1. 内容整体设计与思路拆解1.1 G2O到底在解决什么问题先从一个最朴素的问题说起你手上有一堆传感器测量值比如里程计告诉我们机器人每两帧之间的相对位姿变化视觉SLAM告诉我们在某个点观察到了某个路标点IMU告诉我们旋转是多少。这些测量值各有各的误差打着打着就会互相矛盾。在只有一个机器人和几个路标的时候我们可以手算当状态量达到几百上千个时就需要一个通用的框架来自动求解。G2O就是这样一个通用框架。它把要优化的状态量建模为图中的顶点把状态量之间的约束关系建模为图中的边。每一条边都是一项误差项整个优化问题就变成了让所有误差项的加权平方和最小化的过程。这个思路本质上是图优化但G2O把整个流程封装得很好你只需要定义顶点类型、定义边的误差函数然后把测量值一条条塞进去剩下的矩阵构建、稀疏求解、迭代更新框架都会自动帮你处理。1.2 为什么SLAM社区普遍选择G2O市面上做图优化的库其实不少比如Ceres Solver、g2o、GTSAM大家功能上有重叠但在设计哲学上各有侧重。Ceres是Google出品接口规范但需要自己写的问题结构比较多GTSAM用因子图建模倾向于SLAM的特定场景而G2O的优势在于专门为SLAM的图模型设计顶点和边的抽象非常直观特别是在位姿图优化和Bundle AdjustmentBA这两类经典问题上经过了充分验证。G2O的灵活性也让它成为“万金油”选手不仅可以用来做纯位姿图优化也可以把路标点作为顶点一起优化也就是完整的BA问题。因此很多经典ORB-SLAM、LSD-SLAM、ORB-SLAM2等开源项目都选择G2O作为后端优化器。哪怕你没有做完整的SLAM只是在做一个两两相机标定、多传感器外参标定也可以把它当做一个通用的最小二乘求解器来用。1.3 G2O的整体设计哲学G2O的核心架构可以在一句话里概括图就是模型边就是误差顶点就是状态。顶点就是你要优化的量比如相机的位姿T旋转平移、三维点坐标、相机内参等。不同的状态类型就做成不同的顶点类G2O提供了内置的顶点类和边类例如表示位姿的VertexSE3Expmap、表示三维点的VertexSBAPointXYZ同时也允许你自己派生类重新定义read、write、oplusImpl、computeError等虚函数。边的设计则更加灵活一元边只连接一个顶点适用于先验约束二元边连接两个顶点适用于两个量之间的测量约束多元边甚至可以连接三个以上的顶点在IMU预积分中常用。每个边内部搞一个误差向量这个误差向量乘以信息矩阵之后就构成整个最小二乘问题的一项。G2O的优化流程是每条边计算误差和雅可比矩阵框架把所有边的贡献累加成一个大的稀疏矩阵然后通过线性求解器求解增量再更新顶点重复若干次直到收敛。1.4 上手G2O的思维转变如果说你之前习惯用Matlab或者Scipy的curve_fit来做非线性拟合那你会觉得G2O把简单问题复杂化了。但当你面对的是一个包含上百个变量、上万条边的SLAM问题时G2O的价值就体现出来了。你可以把不同来源的测量信息自由组合进同一个优化问题缺失某些边也完全不影响其他边的优化哪怕只有两个顶点一条边它也能正常求解出结果。这种设计上的弹性正是SLAM工程化的核心需求。你在实际开发中采集的数据总是支离破碎的有的地方有闭环约束有的地方只有局部里程计约束有的传感器临时掉线了几秒。把所有这些信息丢进一个图模型里G2O就能自动在约束最足的地方压紧误差在约束稀疏的地方保持松弛最终给出一个全局一致的解。这个能力是手写最小二乘很难做到的。2. 核心细节解析与实操要点2.1 G2O的安装与依赖配置G2O的安装本来不复杂但由于它依赖项较多且不同Ubuntu版本对依赖的处理不一致很多新手会在编译阶段就被劝退。这里详细说明一套经过验证的安装流程适用于Ubuntu 16.04及以上版本其他Linux发行版思路类似。首先安装依赖项sudo apt-get install libeigen3-dev libsuitesparse-dev libglew-dev qt5-qmake libqt5opengl5-dev其中Eigen是必须的G2O做矩阵运算离不开它suitesparse提供了稀疏矩阵求解所需的CHOLMOD和CSPARSE库qt和glew是可选的主要用于G2O自带的可视化GUI界面如果不装就必须在CMake选项中关闭GUI否则会编译失败。然后克隆源码并编译git clone https://github.com/RainerKuemmerle/g2o.git cd g2o mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j4 sudo make install这里有一个常见的坑如果你不指定构建类型默认构建的是Debug版本优化库在Debug版本下性能会差出好几个数量级使用起来会明显感觉求解速度很慢。建议在cmake时务必加上Release。2.2 顶点Vertex的设计与实现G2O中所有顶点类都继承自BaseVertex。设计顶点时最关键的是三个环节第一确定顶点代表的参数维度D也就是自由度的个数第二确定参数的存储类型T是四维四元数还是六维向量第三实现oplusImpl函数也就是状态更新函数。对位姿顶点来说常用的内置类是VertexSE3Expmap它的参数由一个四元数和一个平移向量组成。具体说四元数表示旋转平移向量表示位移合起来就是一个SE(3)变换矩阵。这个类已经很好地实现了oplusImpl核心思路是左乘扰动模型在优化过程中增量向量通过指数映射转换为SE(3)左乘到当前估计上从而保证旋转矩阵的正交性。如果你需要自定义顶点比如优化相机内参、优化IMU偏置就需要自己写一个类。下面是自定义一个优化相机内参顶点的示例class VertexIntrinsics : public g2o::BaseVertex4, Eigen::Matrixdouble, 4, 1 { public: EIGEN_MAKE_ALIGNED_OPERATOR_NEW virtual void setToOriginImpl() { _estimate fx, fy, cx, cy; } virtual void oplusImpl(const double* update) { _estimate[0] update[0]; _estimate[1] update[1]; _estimate[2] update[2]; _estimate[3] update[3]; } virtual bool read(std::istream in) { return false; } virtual bool write(std::ostream out) const { return false; } };注意这个例子只是为了演示结构实际使用时fx, fy, cx, cy最好从外部传入。边和顶点的read和write函数是给G2O的保存加载功能用的通常不需要实现直接return false就行但如果以后需要调试保存中间结果建议还是认真实现一下。2.3 边Edge的设计与实现边是G2O中最需要动脑子的部分。一条边需要做三件事定义连接哪些顶点、定义误差向量的维度、实现computeError函数计算残差。最简单的边是一元边通常用来表达一个先验约束。比如我们知道某个路标点大致在某个位置可以把路标点作为顶点把这个先验位置作为测量值构造一条一元边。但实际中最常用的是二元边用来表达两个顶点之间的测量关系。以视觉SLAM中最经典的重投影误差边为例。二元边连接一个相机位姿顶点和一个路标点顶点测量值是像素坐标。误差函数定义如下class EdgeProjectXYZ2UV : public g2o::BaseBinaryEdge2, Eigen::Vector2d, g2o::VertexSE3Expmap, g2o::VertexSBAPointXYZ { public: EIGEN_MAKE_ALIGNED_OPERATOR_NEW virtual void computeError() { const g2o::VertexSE3Expmap* v1 static_castconst g2o::VertexSE3Expmap*(_vertices[0]); const g2o::VertexSBAPointXYZ* v2 static_castconst g2o::VertexSBAPointXYZ*(_vertices[1]); Eigen::Vector2d obs(_measurement); _error obs - cam_project(v1-estimate().map(v2-estimate())); } virtual void linearizeOplus() { // 计算雅可比矩阵 } virtual bool read(std::istream in) { return false; } virtual bool write(std::ostream out) const { return false; } };这里的map函数表示把三维点从世界坐标系变换到相机坐标系cam_project表示相机投影函数把三维点投影为像素坐标。误差就是实际观测到的像素坐标减去投影坐标。这里要特别说明雅可比矩阵的问题。如果不实现linearizeOplus函数G2O会使用数值求导自动计算雅可比这在顶点数量少的时候勉强能用但在大规模优化中数值求导的精度和性能都很差。因此实际工程中务必要认真推导和实现解析雅可比这也是G2O区别于Ceres最明显的学习曲线之一。2.4 求解器的选择逻辑G2O提供了一个可插拔的求解器架构它把问题建模和线性求解分开。你需要指定一个线性求解器用于实际解算每一次迭代中的增量方程。目前主流的做法是使用Eigen的稀疏求解器。G2O 2020版本之后官方推荐使用Eigen的稀疏求解器比如LinearSolverEigen它使用简单性能也不错。传统上还有基于Cholmod的求解器Ceres项目也在使用但需要额外编译依赖。求解器的选择要看问题规模。对于小规模的位姿图优化用Gauss-Newton就足够了对于大规模的BA问题推荐用Levenberg-Marquardt因为阻尼项能在迭代初期更稳健。2.5 信息矩阵的含义与设置很多新手在用G2O时经常忽略边的信息矩阵这个参数。其实信息矩阵是整个优化问题的权重来源它告诉优化器这条边的测量值可信度有多高。信息矩阵是测量噪声协方差矩阵的逆。比如你有一条观测像素坐标的边假设像素噪声是各向同性的方差是一个像素那么信息矩阵可以设为Eigen::Matrix2d information; information 1.0 / (sigma_x * sigma_x), 0, 0, 1.0 / (sigma_y * sigma_y);信息矩阵里的数值越大代表这条边的权重越高优化器就会更拼命地让这条边的误差趋近于零。如果你把信息矩阵设得特别大本质上就是把这个约束当成硬约束来处理。在多传感器融合时信息矩阵的设置就更讲究了。比如视觉约束和IMU约束的度量单位完全不同视觉误差是像素量级IMU误差是米/弧度量级如果不做合理的权重标定最后优化的结果会被量纲更小的约束主导。G2O本身不会帮你做这件事这是使用者的责任千万不要忽略这一步。我在实际项目中通常先用经验值初始化权重然后用一段真实采集的数据做误差统计分析反向推导出各传感器合理的协方差矩阵。3. 实操过程与核心环节实现3.1 一个最小可用的G2O示例为了不让你被复杂的概念淹没我们从最简单的例子开始用G2O解一个二维曲线拟合问题。这个例子虽然简单但完整覆盖了G2O的所有核心流程理解了它后续把顶点换成位姿、把边换成重投影误差就是水到渠成的事。需求已知若干个带噪声的观测点(x_i, y_i)希望拟合曲线 y exp(a * x^2 b * x c) 中的参数a、b、c。在这个问题中顶点就是待优化的三个参数每条边就是根据当前参数计算出的预测值和实际观测值之间的残差。3.2 定义曲线拟合的顶点和边定义三个待优化参数为一个顶点class CurveFittingVertex : public g2o::BaseVertex3, Eigen::Vector3d { public: EIGEN_MAKE_ALIGNED_OPERATOR_NEW virtual void setToOriginImpl() { _estimate 0, 0, 0; } virtual void oplusImpl(const double* update) { _estimate[0] update[0]; _estimate[1] update[1]; _estimate[2] update[2]; } virtual bool read(std::istream in) { return false; } virtual bool write(std::ostream out) const { return false; } };定义一条一元边连接这个顶点误差为预测值减观测值class CurveFittingEdge : public g2o::BaseUnaryEdge1, double, CurveFittingVertex { public: EIGEN_MAKE_ALIGNED_OPERATOR_NEW CurveFittingEdge(double x) : BaseUnaryEdge(), _x(x) {} virtual void computeError() { const CurveFittingVertex* v static_castconst CurveFittingVertex*(_vertices[0]); const Eigen::Vector3d abc v-estimate(); _error(0, 0) _measurement - std::exp(abc(0, 0) * _x * _x abc(1, 0) * _x abc(2, 0)); } virtual bool read(std::istream in) { return false; } virtual bool write(std::ostream out) const { return false; } public: double _x; };3.3 构建图模型并执行优化创建优化器并配置求解器g2o::SparseOptimizer optimizer; auto* solver new g2o::OptimizationAlgorithmLevenberg( g2o::make_uniqueg2o::BlockSolverX( g2o::make_uniqueg2o::LinearSolverEigeng2o::BlockSolverX::PoseMatrixType () ) ); optimizer.setAlgorithm(solver); optimizer.setVerbose(true);添加顶点CurveFittingVertex* v new CurveFittingVertex(); v-setEstimate(Eigen::Vector3d(0, 0, 0)); v-setId(0); optimizer.addVertex(v);添加边for (int i 0; i N; i) { CurveFittingEdge* edge new CurveFittingEdge(x_data[i]); edge-setId(i); edge-setVertex(0, v); edge-setMeasurement(y_data[i]); edge-setInformation(Eigen::Matrixdouble, 1, 1::Identity()); optimizer.addEdge(edge); }初始化并执行优化optimizer.initializeOptimization(); optimizer.optimize(100);优化完成后参数估计值就存放在v-estimate()中。这里要提醒几个容易踩的坑。第一顶点id必须唯一且从0开始连续编号不要把id设成完全随机的数字第二边的vertices索引是0-basedsetVertex(0, v)表示连接第0个顶点第三信息矩阵要用Identity如果忘了设置G2O会默认使用单位阵但最好显式设置避免后续问题。初始化也很关键如果初始值和真实值差距太大非线性优化很容易收敛到局部极小值所以在真实SLAM系统中通常先用里程计或IMU做一次粗略位姿估计再把结果作为G2O的初值而不是从全零开始。3.4 一个稍复杂的位姿图优化示例曲线拟合只是热身我们来看在实际SLAM中更常见的位姿图优化问题。假设机器人在环境中移动每个时刻有一个位姿相邻位姿之间通过里程计得到一个相对位姿约束。此外当机器人在某个时刻回到曾经去过的地方闭环检测给出一个闭环约束。我们要通过所有这些约束优化出每个时刻的最优位姿。顶点定义g2o::VertexSE3Expmap* v new g2o::VertexSE3Expmap(); v-setId(i); v-setEstimate(initial_pose); optimizer.addVertex(v);对于每个顶点可以设置一个固定的顶点第0帧作为参考否则整个图存在尺度和旋转的漂移自由度求解器可能无法收敛。设置固定的方式是setFixed(true)。边定义g2o::EdgeSE3* edge new g2o::EdgeSE3(); edge-setVertex(0, v_i); edge-setVertex(1, v_j); edge-setMeasurement(relative_pose); edge-setInformation(information_matrix); optimizer.addEdge(edge);EdgeSE3是G2O内置的用于位姿之间相对约束的边误差定义为误差 实际相对变换的逆 乘以 估计相对变换当估计的相对变换接近实际观测时误差约等于单位阵的六维向量表示接近零。G2O内部已经实现了误差计算和雅可比矩阵推导所以我们不需要自己写。3.5 从位姿图到完整BA的扩展思路如果你已经理解了位姿图优化那么扩展到包含路标点的完整BA问题就只是增加一类顶点和一类边的事。在视觉SLAM的BA中顶点有两类一类是相机位姿顶点VertexSE3Expmap一类是路标点顶点VertexSBAPointXYZ。边是重投影误差边就是我们之前定义的EdgeProjectXYZ2UV。构建图时每个相机位姿对应一个顶点在某些关键帧上观测到的每个路标点也对应一个顶点每个观测像素坐标对应一条边。关键在于G2O有一个智能的处理机制它内部会自动把顶点按照类型分组求解时利用问题的稀疏结构做消元。对BA问题来说只需要保证路标点的顶点id赋值合理求解器就能自动进行边缘化处理也就是先消去路标点再求解相机位姿极大地提高求解效率。这里有个实际工程中值得注意的细节在ORB-SLAM的局部BA中只优化和当前关键帧共视关系附近的一小部分位姿和路标点其他位姿作为固定顶点参与优化但不被更新。这种设计既能控制计算量又能保证优化结果和全局地图保持一致性。在用G2O实现时只需对不参与优化的顶点调用setFixed(true)即可。3.6 代码组织与CMake配置G2O项目在实际使用中建议按功能拆分为几个文件顶点定义头文件存放所有自定义顶点类边定义头文件存放所有自定义边类优化器封装类负责创建优化器、配置求解器、添加顶点边、执行优化对应的CMakeLists.txt核心配置如下find_package(g2o REQUIRED) include_directories(${G2O_INCLUDE_DIR}) target_link_libraries(your_project ${G2O_LIBRARIES})如果g2o安装到了非标准路径需要使用set(G2O_ROOT /your/custom/path/g2o)来指定安装位置。这个坑我也踩过当时用默认的find_package找不到库排查了很久才发现是因为自己把G2O装到了/opt/g2o下而不是/usr/local。4. 常见问题与排查技巧实录4.1 优化不收敛或发散最典型的症状是优化过程中误差反而增加或者优化结果完全不符合物理意义。这类问题80%出在信息矩阵设置不合理或顶点初值设置不当上。信息矩阵过大会导致优化器拼命压低某一条边的误差其他约束完全被忽略等价于把这条边做了硬约束如果测量值有误整个优化就崩了。排查思路是逐条检查边的误差输出。G2O的setVerbose(true)会输出每次迭代的误差总量可以观察到整体误差的变化趋势。如果发现某一次迭代误差突然猛增优先怀疑是哪条边产生了巨大残差可以用单个边的computeError单独调试。初值问题则更隐蔽。非线性优化只在初值附近的邻域内能收敛到好的结果。以位姿图优化为例初始位姿最好由轮式里程计或IMU积分给出来即便有漂移至少大方向是对的如果直接从随机值出发G2O几乎必然收敛到错误解。4.2 编译错误找不到G2O头文件或链接失败这个问题在自定义安装G2O时特别常见。首先确认安装是否成功build目录下有没有生成的libg2o.so或libg2o.a文件。如果安装成功但找不到多半是CMake缓存没有刷新清除build目录重新配置就好。另一个常见问题是G2O版本不同导致API差异。2020版本之前的G2O使用g2o::BlockSolver_6_3这样硬编码的模板类型而新版本推荐使用g2o::BlockSolverX配合make_unique。如果你看的教程是几年前的代码在新版本下编译会报错。解决方法有两个要么把G2O降到和教程匹配的版本要么把代码改成新API。建议尽量使用G2O最新稳定版遇到API问题时查一下官方文档。4.3 求解速度慢当顶点数量达到数万、边数量达到数十万时G2O的求解性能就会成为瓶颈。解决方法之一是选用更好的线性求解器。在G2O中LinearSolverEigen是最通用的选择但性能不是最优的如果安装了suitesparse可以改用LinearSolverCholmod在稀疏矩阵分解上效率会明显提升。方法之二是利用G2O的Schur消元接口选择正确的BlockSolver。如果你的问题恰好像是BA那样存在两类顶点而且一类顶点数量远大于另一类用BlockSolver_6_3这种知道矩阵分块结构的求解器会比通用BlockSolverX快很多。实际测试中相同规模的BA问题用分块求解器能快一倍以上。方法之三是控制优化策略。G2O默认每次迭代都重建整个线性系统如果允许的话可以使用G2O内置的稀疏增量更新机制从第二次迭代起只更新发生变化的部分。不过增量更新的代码复杂度更高通常在大规模全局优化时才值得做小规模问题用默认策略就行。4.4 顶点和边的id管理混乱在SLAM系统中顶点和边的id经常需要动态分配和回收如果id不连续或重复G2O在内部使用id做索引就会触发错误。一个实用的做法是维护一个全局的id计数器每次新建顶点时分配一个自增id即使该顶点在后续过程中被移除也不回收其id防止出现重复。边的id管理相对宽松因为边的索引用途不像顶点那样关键但也建议保持唯一以排查问题时定位。4.5 内存对齐与Eigen的坑Eigen在和STL容器或C新特性混用时有一个著名的对齐问题。如果你的顶点类中包含Eigen类型成员并且你的项目启用了C17的过对齐内存分配编译器可能直接报错。解决方法是给类加上EIGEN_MAKE_ALIGNED_OPERATOR_NEW宏就像前面代码示例中写的那样。如果遇到align相关的编译错误还可以尝试在CMake中关闭过对齐内存分配add_definitions(-DEIGEN_MAX_ALIGN_BYTES16)这个方法能解决绝大多数Eigen对齐问题但本质上是降低了Eigen的性能能不用就不用。4.6 G2O中的核函数使用在实际SLAM数据中由于特征匹配错误经常会出现外点。如果不做处理这些外点会把优化结果拉偏。G2O支持核函数来抑制大误差边的影响在添加边时使用g2o::RobustKernelHuber* kernel new g2o::RobustKernelHuber; kernel-setDelta(1.0); edge-setRobustKernel(kernel);这个Huber核函数的作用是当误差小于delta时按平方项处理当误差大于delta时按线性项处理避免大误差对整体目标函数的影响过度放大。但需要注意的是核函数的引入会让目标函数不再是纯二次型这会降低收敛速度在最终收敛阶段可以移除核函数做一次精优化。这是SLAM后端的一种常见做法先用鲁棒核剔除外点影响等优化收敛后再把核函数换成单位权重重新做一次优化得到更精确的最终结果。4.7 保存和加载优化结果调试大型SLAM问题时保存中间结果非常有用。G2O内置了save和load函数可以将当前图的状态保存为文本文件。如果你的顶点和边类没有实现write函数保存时这些顶点和边会被跳过所以如果依赖保存功能需要把write和read认真实现。我在实际项目中通常会写一个简单的小工具把G2O的优化结果导出为TUM格式的轨迹文件然后用EVO等工具做精度评估。这个流程几乎是每个SLAM项目的标配。示例如下std::ofstream fout(trajectory.txt); for (int i 0; i optimizer.vertices().size(); i) { g2o::VertexSE3Expmap* v dynamic_castg2o::VertexSE3Expmap*(optimizer.vertices()[i]); if (v) { Eigen::Isometry3d pose v-estimate(); fout v-id() pose.translation().transpose() Eigen::Quaterniond(pose.rotation()).coeffs().transpose() std::endl; } }5. 实操中的心得体会与后续扩展G2O这套框架我在不同项目里反复用过很多次。从一开始照搬ORB-SLAM的代码到后来自己定制顶点边再到把多传感器约束揉进同一个图里每一次都在加深对它的理解。这个过程中最深的体会就是G2O的学习曲线陡峭但一旦跨过去你会发现图优化这个思路几乎是所有多源传感器融合问题的通用解法。如果你现在正在学G2O建议不要一上来就啃源码先跑通一个最小例子亲手把顶点、边、信息矩阵、求解器这几个核心组件都过一遍再去看ORB-SLAM2或VINS-Fusion中的G2O代码就会清晰很多。也别急着用最高级的求解器先从LinearSolverEigen开始后面有性能需求再换。对于已经能熟练使用G2O的开发者我还想分享一个扩展思路G2O不仅适用于SLAM。你完全可以把相机到激光雷达的外参标定问题建模成一个图优化问题把每次观测到的平面、角点作为顶点把共视约束作为边用G2O求解。也可以把多台相机之间的相对位姿标定建模成位姿图。G2O的通用性远超你的想象。另外要说的是G2O并不是唯一的选项Ceres和GTSAM在某些场景下也有各自的优势。Ceres接口更现代对自动求导支持更好GTSAM在IMU预积分和因子图框架上非常优雅。算法工程师最好都掌握工程落地时选择最顺手的一个。最后建议每个用G2O的同学都准备一个小本本记录自己在项目中用到的顶点类型、边类型、信息矩阵的设置值以及最终效果。因为这个库的参数调试是一个经验积累的过程同样的设置在A数据集上表现好换一个场景可能就完全不行了。有了实验记录排查问题会高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑