【SLAM】G2O优化库超详细解析

news2024/10/5 20:42:13

G2O,与ceres并列为目前视觉SLAM中应用最广泛的优化算法库。它最大的特点就是基于图优化设计,这对于视觉SLAM来说是异常适配的。在很多的SLAM库的中都使用到它来进行优化操作,比如ORB-SLAM。

代码仓库:https://github.com/RainerKuemmerle/g2o。

本文所对照的源码版本是2023.9.1日期,因此和网上其他的一些教程版本有些出入,但是总体相差不大。总体来说,差别在BlockSolver的接口由裸指针变成了unique_ptr、VertexSBAPointXYZ类被删除等。具体可以看:SLAM十四讲ch7代码调整(undefined reference to symbol)。


G2O库简介

G2O的全称为:General Graphic Optimization,通用图优化。为何叫通用呢?

G2O的核里带有各种各样的求解器,而它的顶点、边的类型则多种多样。通过自定义顶点和边,事实上,只要一个优化问题能够表达成图,那么就可以用G2O去求解它。常见的,比如bundle adjustment、ICP、数据拟合,都可以用G2O来做。

所谓图优化,是把优化问题表现成图的一种方式,这里的图是图论意义上的图。一个图由若干个顶点,以及连着这些顶点的边组成。在这里,我们用顶点表示优化变量,而用边表示残差项

下图展示的就是一些优化问题的图的表现形式,左图是诸如曲线拟合等一元边,右图是诸如BA等二元边的情况:

在这里插入图片描述


G2O使用流程

下图是G2O的基础框架结构,它几乎包含了所有重要的类之间的关系:

在这里插入图片描述

其中:

  • SparseOptimizer:稀疏优化器,它有基类OptimizableGraph(可优化图),又有基类HyperGraph(超图)
  • Vertex:顶点,它是图的数据成员,会有很多定制化的子类
  • Edge:,它是图的数据成员,会有很多定制化的子类
  • OptimizationAlgorithm:优化算法,它是图的数据成员,可以指定Gauss-Newton、Levernberg-Marquardt、Powell’s dogleg等迭代算法
  • Solver:求解器,它是优化算法的数据成员,它有两个部分,计算稀疏(Schur舒尔补)的雅可比和Hessian矩阵(SparseBlockMatrix),计算迭代过程中最关键的一步 H Δ x = − b H\Delta x=-b HΔx=b(LinearSolver)

从顶层到底层梳理完整个的基础框架结构,在编程实现时需要反过来,从底层开始搭建框架一直到顶层:

  1. 【STEP1】构建一个求解器LinearSolver、BlockSolver、Solver

  2. 【STEP2】构建稀疏优化器SparseOptimizer

  3. 【STEP3】定义并添加图的顶点Vertex、边Edge

  4. 【STEP4】设置优化参数,开始执行优化

一个简单的代码实现是:

typedef g2o::BlockSolver<g2o::BlockSolverTraits<3, 1> > Block;  // 待优化变量维度为3,测量值维度为1

// STEP1:构建求解器
std::unique_ptr<Block::LinearSolverType> linearSolver(new g2o::LinearSolverCSparse<Block::PoseMatrixType>());
std::unique_ptr<Block> solver_ptr(new Block(std::move(linearSolver)));
g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg(std::move(solver_ptr));

// STEP2:构建稀疏优化器
g2o::SparseOptimizer optimizer;
optimizer.setAlgorithm(solver);   // 设置求解器
optimizer.setVerbose(true);       // 打开调试输出

// STEP3:定义并添加图的顶点、边
optimizer.addVertex(new MyVertex());
optimizer.addEdge(new MyEdge());

// STEP4:设置优化参数,开始执行优化
optimizer.initializeOptimization();
optimizer.optimize(100);

下面将会对其中的重要的几个STEP进行详细讲解。


构建求解器(STEP1)

求解器的有三种:LinearSolver、BlockSolver、Solver。

线性求解器LinearSolver

由于在迭代的过程中,基本都需要求解近似于 H Δ x = − b H\Delta x=-b HΔx=b 的增量方程

通常情况下想到的方法就是直接求逆,也就是 Δ x = − H − 1 b \Delta x=-H^{-1}b Δx=H1b。看起来好像很简单,但这有个前提,就是H的维度较小,此时只需要矩阵的求逆就能解决问题。但是当H的维度较大时,矩阵求逆变得很困难,求解问题也变得很复杂。

因此,G2O提供了各种求解该线性方程的线性求解器,这里就直接总结一下几种预设:

// 使用sparse cholesky分解法
LinearSolverCholmod : public LinearSolverCCS<MatrixType>;
// 依赖项只有eigen,使用eigen中sparse Cholesky求解,因此编译好后可以方便的在其他地方使用,性能和CSparse差不多
LinearSolverEigen : public LinearSolverCCS<MatrixType>;
// 使用CSparse法
LinearSolverCSparse : public LinearSolverCCS<MatrixType>;
// 使用preconditioned conjugate gradient 法
LinearSolverPCG : public LinearSolver<MatrixType>
// 使用dense cholesky分解法
LinearSolverDense : public LinearSolver<MatrixType>

当然,它们的源码在g2o/g2o/solver目录下,感兴趣的可以自行查看一下。

BlockSolver

BlockSolver内部包含LinearSolver,这就需要用到上面定义的线性求解器LinearSolver来对它进行初始化。在该类中,会对稀疏(Schur舒尔补)的雅可比和Hessian矩阵进行计算,这部分由SparseBlockMatrix负责。但由于SparseBlockMatrix部分比较固定,一般不做什么修改或自定义,因此,初始化的时候仅有LinearSolver。

在源码g2o/g2o/core/block_solver.h中,可以发现它主要有两种形式:

// 固定尺寸的BlockSolver
// p:pose的维度(注意一定是流形manifold下的最小表示)
// l:landmark的维度(通常也是残差地维度)
template <int p, int l>
using BlockSolverPL = BlockSolver<BlockSolverTraits<p, l> >;

// 可变尺寸的BlockSolver
using BlockSolverX = BlockSolverPL<Eigen::Dynamic, Eigen::Dynamic>;

为何会有可变尺寸的BlockSolver呢?

这是因为在某些应用场景,我们的Pose和Landmark在程序开始时并不能确定,那么此时这个块状求解器就没办法固定变量,此时使用这个可变尺寸的BlockSolver,所有的参数都在中间过程中被确定。

当然,G2O也预定义了比较常用的几种类型:

// 表示pose是6维,观测点是3维,常用于3D SLAM中的BA操作
BlockSolver_6_3 = BlockSolverPL<6, 3>;
// 在BlockSolver_6_3的基础上多了一个scale
BlockSolver_7_3 = BlockSolverPL<7, 3>;
// 表示pose是3维,观测点是2维
BlockSolver_3_2 = BlockSolverPL<3, 2>;

Solver

总求解器Solver用于指定优化算法,主要有三种:分别是高斯牛顿(GaussNewton)法,LM(Levenberg–Marquardt)法、Dogleg法。它们都定义在源码g2o/g2o/core目录下。

这三种优化算法分别对应下面的三个类:

// 高斯牛顿(GaussNewton)法
g2o::OptimizationAlgorithmGaussNewton
// LM(Levenberg–Marquardt)法
g2o::OptimizationAlgorithmLevenberg
// Dogleg法
g2o::OptimizationAlgorithmDogleg 

顶点Vertex(STEP3)

顶点表示待优化变量。如果需要估计3D位置、3D位姿等,直接就定义/创建该内容对应的顶点类型对象即可。

定义顶点

顶点Vertex的主要继承关系为:HyperGraph::Vertex(最基类)、OptimizableGraph::Vertex(较基类)、BaseVertex、BaseDynamicVertex(派生类)。其中,后两者是比较通用的适合大部分情况的模板,另外两个类都比较底层,通常不会直接使用。

BaseVertex、BaseDynamicVertex的源码定义在g2o/g2o/core文件夹内:

template <int D, typename T>
class BaseVertex : public OptimizableGraph::Vertex {
 public:
  using EstimateType = T;
  static const int Dimension = D;  ///< dimension of the estimate (minimal) in the manifold space

  ...
};

template <typename T>
class BaseDynamicVertex : public BaseVertex<-1, T> {
  ...
};

其中:

  • D:int类型的数字,表示Vertex的最小维度,更准确的说,是其在流形空间(manifold)的最小表示,比如3D空间中旋转是3维的,那么这里D=3
  • T:待估计的Vertex的数据类型,比如用四元数表达三维旋转的话,T就是Quaternion类型

我们可以看到,G2O的顶点分为两种类型的顶点,固定长度的顶点(BaseVertex)、可变长度的顶点(BaseDynamicVertex)。

因此,我们就需要根据不同的场景实现特定的Vertex就行了。这里的不同场景包括:不同的应用场景(二维空间、三维空间),不同的待优化变量(位姿、空间点),不同的优化类型(李代数位姿、李群位姿)等。

G2O也预设了一些比较常用的顶点类型,可以直接使用:

// 2D二维点Vertex
VertexPointXY : public BaseVertex<2, Vector2>
// 3D空间点Vertex
VertexPointXYZ : public BaseVertex<3, Vector3>

// 2D位姿Vertex,(x、y、theta)
VertexSE2 : public BaseVertex<3, SE2>
// 3D位姿Vertex,(x、y、z、qx、qy、qz),四元数表示旋转,qw通过模长为1约束计算
VertexSE3 : public BaseVertex<6, Isometry3>
// 3D位姿Vertex,(x、y、z、se(3)),李代数表示旋转
VertexSE3Expmap : public BaseVertex<6, SE3Quat>

// Sim3 Vertex, (x,y,z,qw,qx,qy,qz),7d vector,(x,y,z,qx,qy,qz) (note that we leave out the w part of the quaternion.
VertexSim3Expmap : public BaseVertex<7, Sim3>

当然我们可以直接用这些,但是有时候我们需要的顶点类型这里面没有,就得自己定义了。重新定义顶点一般需要考虑重写如下函数(有时也需要重写其他函数):

// 读盘操作,一般情况下不需要进行读操作的话,仅仅声明一下就可以
virtual bool read(std::istream& is);
// 存盘操作,一般情况下不需要进行写操作的话,仅仅声明一下就可以
virtual bool write(std::ostream& os) const;

// 顶点更新函数。非常重要的一个函数,主要用于优化过程中增量△x的计算。我们根据增量方程计算出增量之后,就是通过这个函数对估计值进行调整的,因此这个函数的内容一定要重视
virtual void oplusImpl(const number_t* update);
// 顶点重置函数,设定被优化变量的原始值
virtual void setToOriginImpl();

除了上面几个成员函数,还有几个重要的成员变量和函数也一并解释一下:

// 顶点的估计值
EstimateType _estimate;
// 设置顶点的初始值
void setEstimate(const EstimateType& et);
// 设置顶点的id
virtual void setId(int id);
// 该顶点在优化期间应被视为固定
void setFixed(bool fixed);
// 该顶点应在优化过程中被边缘化
void setMarginalized(bool marginalized);

就以官方源码中预设的VertexPointXYZ顶点类型为例:

class VertexPointXYZ : public BaseVertex<3, Vector3> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  VertexPointXYZ() {}

  // 读盘/存盘操作
  virtual bool read(std::istream& is);
  virtual bool write(std::ostream& os) const;

  // 重置操作
  virtual void setToOriginImpl() { _estimate.fill(0.); }

  // 增量更新操作
  virtual void oplusImpl(const double* update_) {
    Eigen::Map<const Vector3> update(update_);
    _estimate += update;
  }

  ...  // 其他的一些数据成员的get()/set()函数
};

再以官方源码中较复杂的预设的VertexSE3顶点类型为例:

class VertexSE3 : public BaseVertex<6, Isometry3> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  // 在进行一定次数更新后,需要重新进行正交化
  static const int orthogonalizeAfter =
      1000;  //< orthogonalize the rotation matrix after N updates

  VertexSE3();

  // 读盘/存盘操作
  virtual bool read(std::istream& is);
  virtual bool write(std::ostream& os) const;

  // 重置操作
  virtual void setToOriginImpl() { _estimate = Isometry3::Identity(); }

  // 增量更新操作
  // update:(x、y、z、qx、qy、qz),前3个是平移,后3个是旋转(四元数表示)
  // 四元数qw、qx、qy、qz,其中qw可以通过 ||(qw,qx,qy,qz)|| == 1 约束,计算出来
  virtual void oplusImpl(const double* update) {
    Eigen::Map<const Vector6> v(update);
    Isometry3 increment = internal::fromVectorMQT(v);     // 转化为旋转矩阵的形式
    _estimate = _estimate * increment;                    // 更新操作
    if (++_numOplusCalls > orthogonalizeAfter) {
      _numOplusCalls = 0;
      internal::approximateNearestOrthogonalMatrix(
          _estimate.matrix().topLeftCorner<3, 3>());      // 正交化
    }
  }

  ...  // 其他的一些数据成员的get()/set()函数

 protected:
  int _numOplusCalls;  ///< store how often opluse was called to trigger
                       ///< orthogonaliation of the rotation matrix
};

添加顶点

往优化器中增加顶点比较简单,一般来说直接调用接口就行了:

virtual bool addVertex(Vertex* v);

边Edge(STEP3)

边表示残差项。每条边对应着若干个顶点( ≥ 1 \ge 1 1)和一个测量值,依靠对应的顶点可以计算出一个和测量值意义一致的估计值,而这个估计值和测量值之间的误差则表示对应的残差

定义边

边Edge的主要继承关系为:HyperGraph::Edge(最基类)、OptimizableGraph::Edge、BaseEdge(较基类)、BaseUnaryEdge、BaseBinaryEdge、BaseMultiEdge(派生类)。其中,后两者是比较通用的适合大部分情况的模板,另外三个类都比较底层,通常不会直接使用。

BaseUnaryEdge、BaseBinaryEdge、BaseMultiEdge的源码定义在g2o/g2o/core文件夹下:

template <int D, typename E>
class BaseEdge : public OptimizableGraph::Edge {
 public:
  typedef E Measurement;
  BaseEdge() : OptimizableGraph::Edge() { _dimension = D; }
  ...
};

template <int D, typename E, typename... VertexTypes>
class BaseFixedSizedEdge : public BaseEdge<D, E> {
 public:
  template <int VertexN>
  using VertexXnType = NthType<VertexN, VertexTypes...>;
};

template <int D, typename E, typename VertexXi>
class BaseUnaryEdge : public BaseFixedSizedEdge<D, E, VertexXi> {
  ...
};

template <int D, typename E, typename VertexXi, typename VertexXj>
class BaseBinaryEdge : public BaseFixedSizedEdge<D, E, VertexXi, VertexXj> {
  ...
};

template <int D, typename E>
class BaseVariableSizedEdge : public BaseEdge<D, E> {
  ...
};

template <int D, typename E>
using BaseMultiEdge = BaseVariableSizedEdge<D, E>;

其中:

  • D:int类型的数字,表示测量值的最小维度
  • T:测量值的数据类型
  • VertexTypes:该边对应的顶点的数据类型

我们可以看到,G2O的边分为两种类型的边,固定长度的边(BaseFixedSizedEdge)、可变长度的边(BaseVariableSizedEdge)。其中,固定长度的边又分为BaseUnaryEdge(一元边)、BaseBinaryEdge(二元边),可变长度的边又引申出了BaseMultiEdge(多元边)。

因此,我们就需要根据不同的场景实现特定的Edge就行了。这里的不同场景包括:不同的测量值数据类型,不同的顶点类型等。假设,我们用边来表示三维点投影到图像平面的重投影残差:

BaseBinaryEdge<2, Vector2D, VertexPointXYZ, VertexSE3>

其中:

  • 2:测量值的维度
  • Vector2D:测量值的数据类型
  • VertexPointXYZ:顶点类型,空间三维点
  • VertexSE3:顶点类型,3D位姿

也就是说,此时该边连接的是两个顶点,空间三维点VertexPointXYZ、3D位姿VertexSE3,根据这两个点再结合已知的相机内参可以计算出该空间三维点在图像平面的坐标。而此时该边还有一个测量值Vector2D,它的维度为2,也是一个图像平面的坐标。此时重投影误差就可以通过两个坐标计算出来了。

G2O也预设了一些比较常用的边类型,可以直接使用:

// 测量值:平面2D位姿,顶点:平面2D位姿
EdgeSE2Prior : public BaseUnaryEdge<3, SE2, VertexSE2>
// 测量值:平面2D点,顶点:平面2D位姿
EdgeSE2XYPrior : public BaseUnaryEdge<2, Vector2, VertexSE2>
// 测量值:平面2D点,顶点:空间3D点
EdgeXYPrior : public BaseUnaryEdge<2, Vector2, VertexPointXY>
// 测量值:旋转矩阵,顶点:空间3D位姿(四元数)
EdgeSE3Prior : public BaseUnaryEdge<6, Isometry3, VertexSE3>
// 测量值:空间3D点,顶点:空间3D位姿(四元数)
EdgeSE3XYZPrior : public BaseUnaryEdge<3, Vector3, VertexSE3>
// 测量值:空间3D点,顶点:空间3D点
EdgeXYZPrior : public BaseUnaryEdge<3, Vector3, VertexPointXYZ>


// 测量值:平面2D点,顶点:平面2D点、平面2D点
EdgePointXY : public BaseBinaryEdge<2, Vector2, VertexPointXY, VertexPointXY>
// 测量值:平面2D位姿,顶点:平面2D位姿、平面2D位姿
EdgeSE2 : public BaseBinaryEdge<3, SE2, VertexSE2, VertexSE2>
// 测量值:平面2D点,顶点:平面2D位姿、平面2D点
EdgeSE2PointXY : public BaseBinaryEdge<2, Vector2, VertexSE2, VertexPointXY>
// 测量值:空间3D点,顶点:空间3D点、空间3D点
EdgePointXYZ : public BaseBinaryEdge<3, Vector3, VertexPointXYZ, VertexPointXYZ>
// 测量值:旋转矩阵,顶点:空间3D位姿(四元数)、空间3D位姿(四元数)
EdgeSE3 : public BaseBinaryEdge<6, Isometry3, VertexSE3, VertexSE3>
// 测量值:空间3D点,顶点:空间3D位姿(四元数)、空间3D点
EdgeSE3PointXYZ : public BaseBinaryEdge<3, Vector3, VertexSE3, VertexPointXYZ>
// 测量值:空间3D点,顶点:空间3D点、空间3D位姿(SE3)
EdgeStereoSE3ProjectXYZ : public BaseBinaryEdge<3, Vector3, VertexPointXYZ, VertexSE3Expmap>
// 测量值:平面2D点,顶点:空间3D点、空间3D位姿(SE3)
EdgeSE3ProjectXYZ : public BaseBinaryEdge<2, Vector2, VertexPointXYZ, VertexSE3Expmap>
// 测量值:平面2D点,顶点:空间3D点、空间3D位姿(SE3)
EdgeProjectXYZ2UV : public BaseBinaryEdge<2, Vector2, VertexPointXYZ, VertexSE3Expmap>
// 测量值:空间3D点,顶点:空间3D点、空间3D位姿(SE3)
EdgeProjectXYZ2UVU : public BaseBinaryEdge<3, Vector3, VertexPointXYZ, VertexSE3Expmap>
// 测量值:Sim3,顶点:Sim3、Sim3
EdgeSim3 : public BaseBinaryEdge<7, Sim3, VertexSim3Expmap, VertexSim3Expmap>
// 测量值:空间2D点,顶点:空间3D点、Sim3
EdgeSim3ProjectXYZ : public BaseBinaryEdge<2, Vector2, VertexPointXYZ, VertexSim3Expmap>
// 测量值:空间2D点,顶点:空间3D点、Sim3
EdgeInverseSim3ProjectXYZ : public BaseBinaryEdge<2, Vector2, VertexPointXYZ, VertexSim3Expmap>

当然我们可以直接用这些,但是有时候我们需要的边类型这里面没有,就得自己定义了。重新定义边一般需要考虑重写如下函数(有时也需要重写其他函数):

// 读盘操作,一般情况下不需要进行读操作的话,仅仅声明一下就可以
virtual bool read(std::istream& is);
// 存盘操作,一般情况下不需要进行写操作的话,仅仅声明一下就可以
virtual bool write(std::ostream& os) const;
// 边残差计算函数,使用当前边对应的顶点的值计算的测量值与真实的测量值之间的残差
virtual void computeError();
// 边雅可比函数,计算在当前顶点的值下,该残差对优化变量的偏导数
virtual void linearizeOplus();

需要注意的是,一旦指定了linearizeOplus(),就会使用该雅可比矩阵进行优化;如果不对该函数进行重写,就会使用自动求导的方式进行优化

除了上面几个成员函数,还有几个重要的成员变量和函数也一并解释一下:

// 边的测量值
Measurement _measurement;
// 残差量,computeError()函数计算后的值
ErrorVector _error;
// 顶点信息,存储顺序与调用setVertex()的入参有关
VertexContainer _vertices;
// 设置边的id
void setId(int id);
// 设置边上编号为i的顶点
void setVertex(size_t i, Vertex* v);
// 设置边的测量值
virtual void setMeasurement(const Measurement& m);
// 设置边的信息矩阵(协方差矩阵的逆)
void setInformation(const InformationType& information);

需要注意的是,_vertices内存放了该边对应的顶点信息,它可以通过方括号[]运算符进行取顶点操作,它的顺序完全由setVertex()的第一个size_t类型的参数决定的

就以官方源码中预设的EdgeSE3边类型为例(边对应的两个顶点都是3D位姿,对应的观测值是两个顶点之间的相对3D位姿测量):

class EdgeSE3
    : public BaseBinaryEdge<6, Isometry3, VertexSE3, VertexSE3> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  EdgeSE3();

  // 读盘/存盘操作
  virtual bool read(std::istream& is);
  virtual bool write(std::ostream& os) const;

  // 计算残差操作
  void EdgeSE3::computeError() {
    VertexSE3* from = static_cast<VertexSE3*>(_vertices[0]);
    VertexSE3* to = static_cast<VertexSE3*>(_vertices[1]);
    Isometry3 delta =
        _inverseMeasurement * from->estimate().inverse() * to->estimate();    // 相对位姿测量值和相对位姿估计值的差
    _error = internal::toVectorMQT(delta);
  }

  // 雅可比求解操作
  void EdgeSE3::linearizeOplus() {
    VertexSE3* from = static_cast<VertexSE3*>(_vertices[0]);
    VertexSE3* to = static_cast<VertexSE3*>(_vertices[1]);
    Isometry3 E;
    const Isometry3& Xi = from->estimate();
    const Isometry3& Xj = to->estimate();
    const Isometry3& Z = _measurement;
    internal::computeEdgeSE3Gradient(E, _jacobianOplusXi, _jacobianOplusXj, Z, Xi,
                                    Xj);
  }

  virtual void setMeasurement(const Isometry3& m) {
    _measurement = m;
    _inverseMeasurement = m.inverse();
  }

  ...

 protected:
  Isometry3 _inverseMeasurement;      // 相对位姿测量值的逆
};

再以官方源码中较复杂的预设的EdgeProjectXYZ2UV边类型为例(边对应的两个顶点一个是空间3D点,一个是此时对应的3D位姿,对应的观测值是该空间3D点对应的图像2D坐标,其实就是重投影误差模型):

class EdgeProjectXYZ2UV
    : public BaseBinaryEdge<2, Vector2, VertexPointXYZ, VertexSE3Expmap> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  EdgeProjectXYZ2UV();

  // 读盘/存盘操作
  bool read(std::istream& is);
  bool write(std::ostream& os) const;
  
  // 计算残差操作
  void EdgeProjectXYZ2UV::computeError() {
    const VertexSE3Expmap* v1 = static_cast<const VertexSE3Expmap*>(_vertices[1]);
    const VertexPointXYZ* v2 = static_cast<const VertexPointXYZ*>(_vertices[0]);
    const CameraParameters* cam =
        static_cast<const CameraParameters*>(parameter(0));
    // cam_map():将相机坐标系下三维点用内参转换为图像坐标
    // map():将世界坐标系下三维点变换到相机坐标系
    _error = measurement() - cam->cam_map(v1->estimate().map(v2->estimate()));
  }

  // 雅可比求解操作
  virtual void EdgeProjectXYZ2UV::linearizeOplus() {
    VertexSE3Expmap* vj = static_cast<VertexSE3Expmap*>(_vertices[1]);
    SE3Quat T(vj->estimate());
    VertexPointXYZ* vi = static_cast<VertexPointXYZ*>(_vertices[0]);
    Vector3 xyz = vi->estimate();
    Vector3 xyz_trans = T.map(xyz);

    double x = xyz_trans[0];
    double y = xyz_trans[1];
    double z = xyz_trans[2];
    double z_2 = z * z;

    const CameraParameters* cam =
        static_cast<const CameraParameters*>(parameter(0));

    // 重投影误差对3D点的雅可比
    Eigen::Matrix<double, 2, 3, Eigen::ColMajor> tmp;
    tmp(0, 0) = cam->focal_length;
    tmp(0, 1) = 0;
    tmp(0, 2) = -x / z * cam->focal_length;

    tmp(1, 0) = 0;
    tmp(1, 1) = cam->focal_length;
    tmp(1, 2) = -y / z * cam->focal_length;

    _jacobianOplusXi = -1. / z * tmp * T.rotation().toRotationMatrix();

    // 重投影误差对3D位姿的雅可比
    _jacobianOplusXj(0, 0) = x * y / z_2 * cam->focal_length;
    _jacobianOplusXj(0, 1) = -(1 + (x * x / z_2)) * cam->focal_length;
    _jacobianOplusXj(0, 2) = y / z * cam->focal_length;
    _jacobianOplusXj(0, 3) = -1. / z * cam->focal_length;
    _jacobianOplusXj(0, 4) = 0;
    _jacobianOplusXj(0, 5) = x / z_2 * cam->focal_length;

    _jacobianOplusXj(1, 0) = (1 + y * y / z_2) * cam->focal_length;
    _jacobianOplusXj(1, 1) = -x * y / z_2 * cam->focal_length;
    _jacobianOplusXj(1, 2) = -x / z * cam->focal_length;
    _jacobianOplusXj(1, 3) = 0;
    _jacobianOplusXj(1, 4) = -1. / z * cam->focal_length;
    _jacobianOplusXj(1, 5) = y / z_2 * cam->focal_length;
  }

 public:
  CameraParameters* _cam;  // TODO make protected member?
};

这里的雅可比计算需要一些BA的基本功,不太清楚的可以查看文章:BA优化中Jacobian矩阵的计算。这不是本文的重点,就不展开讲述了。

添加边

往优化器中增加边比较简单,一般来说直接调用接口就行了:

virtual bool addEdge(Edge* e);

执行优化(STEP4)

G2O执行优化的代码很简单:

optimizer.initializeOptimization();
optimizer.optimize(100);

如果想要了解一下这两个函数的内部实现流程,这部分代码的实现在源码:g2o/sparse_optimizer.cpp。

有一篇博客中讲解了其中的一些内容,可以参考博文:g2o学习笔记。


G2O实战案例

CmakeLists.txt配置

cmake_minimum_required(VERSION 2.8)
project(ceres)

find_package(Ceres REQUIRED)
include_directories(${CERES_INCLUDE_DIRS})

add_executable(test test.cpp)
target_link_libraries(test ${CERES_LIBRARIES})

示例:曲线拟合

拟合非线性函数的曲线 y = e a x 2 + b x + c y=e^{ax^2+bx+c} y=eax2+bx+c(其中, a = 1 a = 1 a=1 b = 2 b = 2 b=2 c = 1 c = 1 c=1,待拟合数据点: [ 0 , 1 ] [ 0 , 1] [0,1]之间均匀生成的100个数据点,加上方差为1的白噪声)。代码如下:

#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/core/optimization_algorithm_gauss_newton.h>
#include <g2o/core/optimization_algorithm_dogleg.h>
#include <g2o/solvers/dense/linear_solver_dense.h>
#include <Eigen/Core>
#include <opencv2/core/core.hpp>
#include <cmath>

// 曲线模型的顶点,模板参数:优化变量维度和数据类型
class CurveFittingVertex: public g2o::BaseVertex<3, Eigen::Vector3d> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW

  // 存盘/读盘操作
  virtual bool read( istream& in ) {}
  virtual bool write( ostream& out ) const {}

  // 重置操作
  virtual void setToOriginImpl() {
    _estimate << 0, 0, 0;
  }
  
  // 增量更新操作
  virtual void oplusImpl( const double* update ) {
    _estimate += Eigen::Vector3d(update);
  }
};

// 残差模型,模板参数:观测值维度,观测值类型,连接顶点类型
class CurveFittingEdge: public g2o::BaseUnaryEdge<1, double, CurveFittingVertex> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW

  CurveFittingEdge( double x ): BaseUnaryEdge(), _x(x) {}

  // 存盘/读盘操作
  virtual bool read( istream& in ) {}
  virtual bool write( ostream& out ) const {}

  // 计算残差操作
  void computeError() {
    const CurveFittingVertex* v = static_cast<const 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)) ;
  }

 public:
  double _x;
};

int main( int argc, char** argv )
{
  double a = 1.0, b = 2.0, c = 1.0;         // 真实参数值
  int N = 100;                              // 数据点
  double w_sigma = 1.0;                     // 噪声Sigma值
  cv::RNG rng;                              // OpenCV随机数产生器
  double abc[3] = {0, 0, 0};                // abc参数的估计值

  vector<double> x_data, y_data;            // 数据准备
  for (int i = 0; i < N; i++) {
    double x = i / 100.0;
    x_data.push_back(x);
    y_data.push_back(exp(a * x * x + b * x + c) + rng.gaussian(w_sigma));
  }
  
  // 构建图优化,先设定g2o
  typedef g2o::BlockSolver<g2o::BlockSolverTraits<3, 1> > Block;
  std::unique_ptr<Block::LinearSolverType> linearSolver(new g2o::LinearSolverCSparse<Block::PoseMatrixType>());
  std::unique_ptr<Block> solver_ptr(new Block(std::move(linearSolver)));
  g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg(std::move(solver_ptr));

  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 < N; 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() * 1 / (w_sigma * w_sigma));    // 信息矩阵:协方差矩阵之逆
    optimizer.addEdge(edge);
  }
  
  // 执行优化
  optimizer.initializeOptimization();
  optimizer.optimize(100);
  
  // 输出优化值
  Eigen::Vector3d abc_estimate = v->estimate();
  std::cout << "estimated model: " << abc_estimate.transpose()<<endl;
  
  return 0;
}

示例:3D与2D的PNP问题

已知一批空间点世界坐标系的3D坐标、它们对应到图像坐标系的像素级2D坐标、相机的内参、已通过其他方法诸如PNP求解出位姿,此时需要通过优化的方法求解出更精准的空间3D点坐标、相机的位姿。

完整代码位置:pose_estimation_3d2d.cpp。关键代码如下所示:

void bundleAdjustment(const std::vector<cv::Point3f> points_3d,
  const std::vector<cv::Point2f> points_2d,
  const cv::Mat& K, cv::Mat& R, cv::Mat& t) {
  // 构建图优化,先设定g2o
  typedef g2o::BlockSolver<g2o::BlockSolverTraits<6, 3> > Block;
  std::unique_ptr<Block::LinearSolverType> linearSolver(new g2o::LinearSolverCSparse<Block::PoseMatrixType>());
  std::unique_ptr<Block> solver_ptr(new Block(std::move(linearSolver)));
  g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg(std::move(solver_ptr));

  g2o::SparseOptimizer optimizer;         // 图模型
  optimizer.setAlgorithm(solver);         // 设置求解器
  optimizer.setVerbose(true);             // 打开调试输出

  // 往图中增加顶点
  g2o::VertexSE3Expmap* pose = new g2o::VertexSE3Expmap();
  Eigen::Matrix3d R_mat;
  R_mat << R.at<double>(0, 0), R.at<double>(0, 1), R.at<double>(0, 2),
    R.at<double>(1, 0), R.at<double>(1, 1), R.at<double>(1, 2),
    R.at<double>(2, 0), R.at<double>(2, 1), R.at<double>(2, 2);
  pose->setId(0);
  pose->setEstimate(g2o::SE3Quat(R_mat,
    Eigen::Vector3d(t.at<double>(0, 0), t.at<double>(1, 0), t.at<double>(2, 0))));
  optimizer.addVertex(pose);

  int index = 1;
  for (const cv::Point3f p : points_3d ) {
    g2o::VertexPointXYZ* point = new g2o::VertexPointXYZ();
    point->setId(index++);
    point->setEstimate(Eigen::Vector3d(p.x, p.y, p.z));
    point->setMarginalized(true);
    optimizer.addVertex(point);
  }

  // 添加参数模块
  g2o::CameraParameters* camera = new g2o::CameraParameters(
    K.at<double>(0, 0), Eigen::Vector2d(K.at<double>(0, 2), K.at<double>(1, 2)), 0);
  camera->setId(0);
  optimizer.addParameter(camera);

  // 往图中增加边
  index = 1;
  for (const cv::Point2f p : points_2d) {
    g2o::EdgeProjectXYZ2UV* edge = new g2o::EdgeProjectXYZ2UV();
    edge->setId(index);
    edge->setVertex(0, dynamic_cast<g2o::VertexPointXYZ*>(optimizer.vertex(index)));
    edge->setVertex(1, pose);
    edge->setMeasurement(Eigen::Vector2d(p.x, p.y));
    edge->setParameterId(0, 0);     // 第一个参数为:需要用到参数模块的顶点类型,第二个参数为:优化器内参数模块的id
    edge->setInformation(Eigen::Matrix2d::Identity());
    optimizer.addEdge(edge);
    index++;
  }

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

  // 输出优化值
  std::cout << "T: " << Eigen::Isometry3d(pose->estimate()).matrix() << std::endl;
}

示例:3D与3D的ICP问题

已知一批空间点两个坐标系的3D坐标、已通过其他方法诸如ICP求解出的两个坐标系之间的相对位姿,此时需要通过优化的方法求解出更精准的相对位姿(这里没有优化3D空间点,实际上也是可以进行优化的)。

由于G2O并没有预先设定该类型的边,因此需要手动创建该边的类型。

完整代码位置:pose_estimation_3d3d.cpp。关键代码如下所示:

// 自定义边,测量值:空间3D点,顶点:空间3D位姿
class EdgeProjectXYZRGBDPoseOnly : public g2o::BaseUnaryEdge<3, Eigen::Vector3d, g2o::VertexSE3Expmap> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  EdgeProjectXYZRGBDPoseOnly(const Eigen::Vector3d& point) : _point(point) {}

  // 读盘/存盘操作
  bool read(istream& in) {}
  bool write(ostream& out) const {}

  // 计算残差操作
  virtual void computeError() {
    const g2o::VertexSE3Expmap* pose = static_cast<const g2o::VertexSE3Expmap*>(_vertices[0]);
    _error = _measurement - pose->estimate().map(_point);       // 用3D位姿将3D点转换到另一个坐标系中,再计算残差
  }

  // 雅可比计算操作
  virtual void linearizeOplus() {
    g2o::VertexSE3Expmap* pose = static_cast<g2o::VertexSE3Expmap *>(_vertices[0]);
    g2o::SE3Quat T(pose->estimate());
    Eigen::Vector3d xyz_trans = T.map(_point);
    double x = xyz_trans[0];
    double y = xyz_trans[1];
    double z = xyz_trans[2];

    _jacobianOplusXi(0,0) = 0;
    _jacobianOplusXi(0,1) = -z;
    _jacobianOplusXi(0,2) = y;
    _jacobianOplusXi(0,3) = -1;
    _jacobianOplusXi(0,4) = 0;
    _jacobianOplusXi(0,5) = 0;

    _jacobianOplusXi(1,0) = z;
    _jacobianOplusXi(1,1) = 0;
    _jacobianOplusXi(1,2) = -x;
    _jacobianOplusXi(1,3) = 0;
    _jacobianOplusXi(1,4) = -1;
    _jacobianOplusXi(1,5) = 0;

    _jacobianOplusXi(2,0) = -y;
    _jacobianOplusXi(2,1) = x;
    _jacobianOplusXi(2,2) = 0;
    _jacobianOplusXi(2,3) = 0;
    _jacobianOplusXi(2,4) = 0;
    _jacobianOplusXi(2,5) = -1;
  }

 protected:
  Eigen::Vector3d _point;           // 一组空间点的3D坐标
};

void bundleAdjustment(
  const std::vector<cv::Point3f>& pts1, const std::vector<cv::Point3f>& pts2,
  cv::Mat& R, cv::Mat& t) {
  // 构建图优化,先设定g2o
  typedef g2o::BlockSolver<g2o::BlockSolverTraits<6, 3> > Block;
  std::unique_ptr<Block::LinearSolverType> linearSolver(new g2o::LinearSolverCSparse<Block::PoseMatrixType>());
  std::unique_ptr<Block> solver_ptr(new Block(std::move(linearSolver)));
  g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg(std::move(solver_ptr));

  g2o::SparseOptimizer optimizer;         // 图模型
  optimizer.setAlgorithm(solver);         // 设置求解器
  optimizer.setVerbose(true);             // 打开调试输出

  // 往图中增加顶点
  g2o::VertexSE3Expmap* pose = new g2o::VertexSE3Expmap();
  pose->setId(0);
  pose->setEstimate(g2o::SE3Quat(Eigen::Matrix3d::Identity(),
    Eigen::Vector3d(0, 0, 0)));
  optimizer.addVertex(pose);

  // 往图中增加边
  int index = 1;
  for (size_t i = 0; i < pts1.size(); i++) {
    EdgeProjectXYZRGBDPoseOnly* edge = new EdgeProjectXYZRGBDPoseOnly(
      Eigen::Vector3d(pts2[i].x, pts2[i].y, pts2[i].z));
    edge->setId(index);
    edge->setVertex(0, dynamic_cast<g2o::VertexSE3Expmap*>(pose));
    edge->setMeasurement(Eigen::Vector3d(pts1[i].x, pts1[i].y, pts1[i].z));
    edge->setInformation(Eigen::Matrix3d::Identity() * 1e4);
    optimizer.addEdge(edge);
    index++;
  }

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

  // 输出优化值
  std::cout << "T: " << Eigen::Isometry3d(pose->estimate()).matrix() << std::endl;
}

相关阅读

  • 从零开始一起学习SLAM | 理解图优化,一步步带你看懂g2o代码
  • 从零开始一起学习SLAM | 掌握g2o顶点编程套路
  • 从零开始一起学习SLAM | 掌握g2o边的代码套路
  • [代码实践] G2O 学习记录(一):2D 位姿图优化
  • [代码实践] G2O 学习记录(二):3D 位姿图优化

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/978191.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

渗透测试漏洞原理之---【不安全的反序列化】

文章目录 1、序列化与反序列化1.1、引入1.2、序列化实例1.2.1、定义一个类1.2.2、创建 对象1.2.3、反序列化1.2.4、对象注入 2、漏洞何在2.1、漏洞触发2.1.2、定义一个类2.1.3、定义一个对象2.1.3、反序列化执行代码 2.2 为什么会这样 3、反序列化漏洞攻防3.1、PHP反序列化实例…

[杂谈]-快速了解LoRaWAN网络以及工作原理

快速了解LoRaWAN网络以及工作原理 文章目录 快速了解LoRaWAN网络以及工作原理1、LoRaWAN网络元素1.1 终端设备&#xff08;End Devices&#xff09;1.2 网关&#xff08;Gateways&#xff09;1.3 网络服务器&#xff08;Net Server&#xff09;1.4 应用服务器&#xff08;Appli…

计算机网络的故事——确认访问用户身份的认证

确认访问用户身份的认证 HTTP使用的认证方式&#xff1a;BASIC认证&#xff08;基本认证&#xff09;、DIGEST&#xff08;摘要认证&#xff09;、SSL客户端认证、FormBase认证&#xff08;基于表单认证&#xff09;。 基于表单的认证&#xff1a;涉及到session管理以及cookie…

Mock 基本使用

mock解决的问题 开发时&#xff0c;后端还没完成数据输出&#xff0c;前端只好写静态模拟数据。数据太长了&#xff0c;将数据写在js文件里&#xff0c;完成后挨个改url。某些逻辑复杂的代码&#xff0c;加入或去除模拟数据时得小心翼翼。想要尽可能还原真实的数据&#xff0c…

【Springcloud】Sentinel熔断和降级

【Springcloud】Sentinel熔断和降级 【一】基本介绍【1】什么是熔断和降级【2】为什么使用熔断和降级【3】Sentinel熔断和降级【4】核心概念 【二】下载方式【1】Windows平台安装包下载【2】打开控制台 【三】使用案例【1】添加依赖【2】添加Sentinel配置【3】添加TestUserCont…

网络类型+二层封装技术

一、网络类型分类 1、点到点网络 在一个网段中&#xff0c;只能部署两个节点&#xff08;两个IP&#xff09;&#xff0c;如GRE环境就属于虚拟的点到点网络类型&#xff0c;通常是串线连接。 如下图当前只存在两个节点&#xff0c;但并不是点到点&#xff0c;因为它中间网段…

简易版人脸识别qt opencv

1、配置文件.pro #------------------------------------------------- # # Project created by QtCreator 2023-09-05T19:00:36 # #-------------------------------------------------QT core guigreaterThan(QT_MAJOR_VERSION, 4): QT widgetsTARGET 01_face TEMP…

【trie树】CF Edu12 E

Problem - E - Codeforces 题意&#xff1a; 思路&#xff1a; 这其实是一个套路题 区间异或转化成前缀异或&#xff0c;然后枚举 i 对于每一个 i&#xff0c;ai ^ x k&#xff0c;对 x 计数 先建一棵字典树&#xff0c;然后在字典树上计数 先去对 > k 的部分计数&a…

m8130kt GPS模块测试

m8130kt GPS模块测试 ✨在某宝捡电子垃圾&#xff0c;10块钱的价格&#xff0c;就是没有资料可以提供的。上面携带有u-blox UBX-M8130-KT芯片&#xff0c;是肯定的&#xff0c;至于HMC5883芯片不确定&#xff0c;拆了屏蔽罩&#xff0c;芯片实在是太小&#xff0c;使用40倍放大…

SpringCloud实战项目(1)---创建空项目 jdk17

创建空项目 New ProjectAdd Jdk17创建空白标准Maven项目不要选择Create from archetype选项填写相关项目信息创建项目得到一个标准的maven项目&#xff0c;作为一个Parent project存在的&#xff0c;需删除src文件夹。 New Project 使用Idea, File -> New ->Project Add …

语音特征提取与预处理

导入相关包 import librosa import librosa.display import soundfile as sf import numpy as np import matplotlib.pyplot as plt from playsound import playsound 语音读取与显示 file_path test1.wav data, fs librosa.load(file_path, srNone, monoTrue) librosa.d…

word如何插入图片?3种常用的方法

word作为一款常用的办公软件&#xff0c;不仅可以处理文本内容&#xff0c;还能够轻松地插入图片以丰富文档内容。插入图片可以使文档更具吸引力、可读性和信息传达能力。本文将为您介绍word如何插入图片的3种方法&#xff0c;帮助您在文档中灵活、高效地添加图像元素。 word插…

SLAM从入门到精通(矩阵的使用)

【 声明&#xff1a;版权所有&#xff0c;欢迎转载&#xff0c;请勿用于商业用途。 联系信箱&#xff1a;feixiaoxing 163.com】 学习SLAM&#xff0c;离开了矩阵肯定是玩不转的。大学数学里面除了微积分&#xff0c;剩下的就是线性代数和概率论。而矩阵就是线性代数的一部分。…

selenium 动态爬取页面使用教程以及使用案例

Selenium 介绍 概述 Selenium是一款功能强大的自动化Web浏览器交互工具。它可以模拟真实用户在网页上的操作&#xff0c;例如点击、滚动、输入等等。Selenium可以爬取其他库难以爬取的网站&#xff0c;特别是那些需要登录或使用JavaScript的网站。Selenium可以自动地从Web页面…

在多机多卡训练时,保存的文件无法读取,报错文件已经损坏

问题描述&#xff1a;多机多卡训练保存了optimizer.pt文件&#xff0c;但是该文件在被读取时显示已经损坏。 原来的报错&#xff1a; Traceback (most recent call last):File "/mnt/petrelfs/tongjingqi/train-moe/smoe/entrypoint/cpt_fpt.py", line 280, in <…

Android Aidl跨进程通讯(三)--进阶使用

学更好的别人&#xff0c; 做更好的自己。 ——《微卡智享》 本文长度为2478字&#xff0c;预计阅读6分钟 前言 Android的AIDL使用和异常报错都已经介绍过了&#xff0c;今天这篇还是在原来的Demo基础上加入几个AIDL的进阶使用方法。 】 AIDL进阶使用 微卡智享 in,out,inout的使…

定时线程池原理解析

基本使用 ScheduledThreadPoolExecutor继承自ThreadPoolExecutor。它主要用来在给定的延迟之后运行任务&#xff0c;或者定期执行任务。 public class ScheduledThreadPoolExecutorTest {public static void main(String[] args) {ScheduledThreadPoolExecutor threadPoolExecu…

QT DAY7

主要完成多人聊天室&#xff0c;注册与登录使用sql3数据库进行对密码的保存&#xff0c;避免了用户重复登录、错误密码登录、重复注册的问题&#xff0c;之后使用TCP通信&#xff0c;连接上服务器后可在聊天室多人交流

怎么处理zk或redis脑裂

很极端场景会出现脑裂 什么是分布式的脑裂 怎么理解zk脑裂 就是ZK&#xff0c;与客户端可能因为网络原因&#xff0c;客户端A还在跑着后续程序&#xff0c;而zk与客户端之前的心跳断了&#xff0c;此zk就把这节点给删除了&#xff0c;这时另一个客户会加锁成功&#xff0c;就样…

[Java]异常

目录 1.异常的概念与体系结构 1.1异常的概念 1.1.1算术异常 1.1.2数组越界异常 1.1.3空指针异常 1.2异常的体系结构 1.3异常的分类 2.异常的处理 2.1 防御式编程 2.2异常的抛出 2.3异常的捕获 2.3.1 异常声明throws 将光标放在抛出异常方法上&#xff0c;alt Insert …