3、g2o库入门:安装配置与第一个图优化问题

说到图优化,g2o 绝对是个绕不开的库。我最早接触它是在做后端优化的时候,那时候自己手写高斯牛顿,写到最后发现矩阵求逆都成瓶颈了。后来换了 g2o,嗯,真香。

这一节我们就来聊聊 g2o 的安装、配置,以及怎么用它构建第一个图优化问题。说白了,就是让你能跑起来一个最小二乘的 demo,感受一下图优化的流程。

3.1 g2o 是什么?

g2o 的全称是 General Graph Optimization,一个通用的图优化库。它把优化问题抽象成「顶点」和「边」——顶点表示待优化的变量,边表示观测约束。你想想看,SLAM 里的位姿、路标点,是不是天然就能用图来表示?

我个人习惯把 g2o 看作一个「搭积木」的工具:你定义好顶点类型、边类型,然后往图里塞数据,最后调用优化器跑一下,结果就出来了。底层的高斯牛顿、LM 算法,它都帮你封装好了。

核心概念速览:

  • 顶点 (Vertex):待优化的变量,比如相机位姿、三维点坐标
  • 边 (Edge):观测约束,比如重投影误差、里程计约束
  • 优化器 (Optimizer):负责迭代求解,默认用 LM 算法

3.2 安装与配置

g2o 的安装其实不复杂,但有几个坑。我曾经在 Ubuntu 18.04 上折腾了一下午,最后发现是依赖没装全。这里我把步骤整理一下,你照着来就行。

3.2.1 依赖安装

先装基础依赖,缺一不可:

sudo apt-get install cmake libeigen3-dev libsuitesparse-dev libqt4-dev qt4-qmake libqglviewer-dev

注意,libqglviewer-dev 这个包在 Ubuntu 20.04 以后改名叫 libqglviewer-dev-qt5 了。如果你用的是新系统,记得换成:

sudo apt-get install libqglviewer-dev-qt5

避坑指南: 我曾经因为没装 libsuitesparse-dev,编译 g2o 时一直报 cholmod 相关的错误。这个包是稀疏矩阵求解的核心,千万别漏了。

3.2.2 编译安装

从 GitHub 上 clone 下来,然后编译:

git clone https://github.com/RainerKuemmerle/g2o.git
cd g2o
mkdir build && cd build
cmake ..
make -j4
sudo make install

编译时有个小技巧:如果你不需要 GUI 可视化,可以在 cmake 时加上 -DBUILD_WITH_QT4=OFF,这样能省掉 Qt 的依赖。我个人习惯是保留 GUI,因为调试时能看到图结构,挺方便的。

3.2.3 验证安装

装完后,写个简单的 CMakeLists.txt 来验证:

cmake_minimum_required(VERSION 3.0)
project(g2o_test)

find_package(g2o REQUIRED)
include_directories(${G2O_INCLUDE_DIRS})

add_executable(test_g2o test_g2o.cpp)
target_link_libraries(test_g2o ${G2O_LIBS})

如果能顺利找到 g2o 的库,说明安装成功了。如果报找不到,多半是安装路径没在默认搜索目录里,可以手动设置 CMAKE_PREFIX_PATH

3.3 构建第一个图优化问题

好了,环境搭好了,我们来写一个最简单的图优化 demo。这个例子不涉及 SLAM,就是一个曲线拟合问题——说白了,就是给定一些带噪声的点,拟合出一条二次曲线。

3.3.1 问题定义

假设真实曲线是:

y = exp( a * x^2 + b * x + c )

我们观测到一些带噪声的 (x, y) 点,想反推出 a, b, c 三个参数。在图优化里,a, b, c 就是三个顶点,每个观测点就是一条边。

3.3.2 代码实现

先定义顶点和边。g2o 里已经提供了 BaseVertexBaseUnaryEdge 基类,我们继承一下就行:

#include <iostream>
#include <g2o/core/base_vertex.h>
#include <g2o/core/base_unary_edge.h>
#include <g2o/core/block_solver.h>
#include <g2o/core/optimization_algorithm_levenberg.h>
#include <g2o/solvers/dense/linear_solver_dense.h>
#include <Eigen/Core>

// 顶点:待优化的参数 (a, b, c)
class CurveFittingVertex : public g2o::BaseVertex<3, Eigen::Vector3d> {
public:
    EIGEN_MAKE_ALIGNED_OPERATOR_NEW
    virtual void setToOriginImpl() override {
        _estimate << 0, 0, 0;
    }
    virtual void oplusImpl(const double* update) override {
        _estimate += Eigen::Vector3d(update);
    }
    virtual bool read(std::istream& in) override {}
    virtual bool write(std::ostream& out) const override {}
};

// 边:单个观测点的误差
class CurveFittingEdge : public g2o::BaseUnaryEdge<1, double, CurveFittingVertex> {
public:
    EIGEN_MAKE_ALIGNED_OPERATOR_NEW
    CurveFittingEdge(double x) : BaseUnaryEdge(), _x(x) {}
    
    virtual void computeError() override {
        const CurveFittingVertex* v = static_cast<const CurveFittingVertex*>(_vertices[0]);
        const Eigen::Vector3d abc = v->estimate();
        double y_pred = exp(abc[0] * _x * _x + abc[1] * _x + abc[2]);
        _error(0, 0) = _measurement - y_pred;
    }
    
    virtual bool read(std::istream& in) override {}
    virtual bool write(std::ostream& out) const override {}
    
private:
    double _x;
};

接下来是主函数,构建图并求解:

int main() {
    // 生成模拟数据
    double a = 1.0, b = 2.0, c = 1.0;
    std::vector<double> x_data, y_data;
    for (int i = 0; i < 100; ++i) {
        double x = i / 100.0;
        double y = exp(a * x * x + b * x + c) + (rand() % 1000) / 1000.0 * 0.1;
        x_data.push_back(x);
        y_data.push_back(y);
    }

    // 构建图优化器
    typedef g2o::BlockSolver< g2o::BlockSolverTraits<3, 1> > BlockSolverType;
    typedef g2o::LinearSolverDense<BlockSolverType::PoseMatrixType> LinearSolverType;
    
    auto solver = new g2o::OptimizationAlgorithmLevenberg(
        g2o::make_unique<BlockSolverType>(g2o::make_unique<LinearSolverType>())
    );
    
    g2o::SparseOptimizer optimizer;
    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 < x_data.size(); ++i) {
        CurveFittingEdge* edge = new CurveFittingEdge(x_data[i]);
        edge->setId(i);
        edge->setVertex(0, v);
        edge->setMeasurement(y_data[i]);
        edge->setInformation(Eigen::Matrix<double, 1, 1>::Identity());
        optimizer.addEdge(edge);
    }

    // 执行优化
    optimizer.initializeOptimization();
    optimizer.optimize(100);

    // 输出结果
    Eigen::Vector3d abc_estimate = v->estimate();
    std::cout << "估计参数: " << abc_estimate.transpose() << std::endl;
    std::cout << "真实参数: " << a << " " << b << " " << c << std::endl;

    return 0;
}

3.3.3 运行结果

编译运行后,你会看到类似这样的输出:

iteration= 0     chi2= 1234.56    time= 0.001
iteration= 1     chi2= 567.89     time= 0.001
...
iteration= 99    chi2= 0.12       time= 0.001

估计参数: 0.998 2.001 0.997
真实参数: 1.0 2.0 1.0

你看,优化后的参数和真实值非常接近。chi2 从一千多降到零点几,说明误差收敛得很好。

个人经验: 第一次跑这个 demo 时,我忘了设置 setInformation,结果优化结果完全不对。信息矩阵相当于给每条边一个权重,不设的话默认是单位阵,但如果你观测噪声不均匀,一定要手动设置。

3.4 核心流程总结

用 g2o 做图优化,其实就四步:

  1. 定义顶点:继承 BaseVertex,实现 setToOriginImpl 和 oplusImpl
  2. 定义边:继承 BaseUnaryEdge 或 BaseBinaryEdge,实现 computeError
  3. 构建图:创建优化器,添加顶点和边
  4. 求解:调用 initializeOptimization 和 optimize

说白了,你只要把 SLAM 问题里的位姿、路标点映射成顶点,把观测约束映射成边,剩下的交给 g2o 就行。我刚开始用的时候,总觉得这玩意儿是个黑盒,后来自己手写了一遍高斯牛顿,才明白它底层到底在干什么。

3.5 本章知识体系

下面这张图帮你梳理了 g2o 的核心结构和工作流程:

g2o 图优化核心结构 顶点 (Vertex) 待优化变量 位姿 / 路标点 / 参数 继承 BaseVertex 边 (Edge) 观测约束 重投影误差 / 里程计 继承 BaseEdge 优化器 (Optimizer) 迭代求解 LM / 高斯牛顿 SparseOptimizer 工作流程 定义顶点 定义边 构建图 求解优化 顶点和边组成图结构,优化器负责迭代求解最小二乘

嗯,到这里 g2o 的入门就差不多了。你把这个 demo 跑通,基本就掌握了图优化的核心套路。后面在 SLAM 闭环检测里,我们会用 g2o 来优化整个位姿图,那时候你就知道今天打下的基础有多重要了。


公众号:蓝海资料掘金营,微信deep3321