g2o实践笔记

g2o实践笔记

本文旨在学习通过《SLAM十四讲》中曲线拟合pnp的对比,梳理g2o求解优化问题的实现步骤和具体操作,对具体原理不做探讨,感兴趣的可以移步:

g2o学习笔记 - 简书 (jianshu.com)
理解图优化,一步步带你看懂g2o代码 - 云+社区 - 腾讯云 (tencent.com)

深入理解图优化与g2o:g2o篇 - 半闲居士 - 博客园 (cnblogs.com)

图优化分为9步:

  1. 重写顶点类
  2. 重写边类
  3. 创建BlockSolver
  4. 创建LinearSolver
  5. 创建总求解器Solver
  6. 创建稀疏优化器
  7. 在图中添加顶点
  8. 在图中添加边
  9. 设置优化参数,开始执行优化

图中包含顶点和边,简单地讲,顶点就是待优化的变量的,边就是链接顶点的约束条件

1.重写顶点类

1.1源代码对比

曲线拟合顶点定义:

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(istream &in) {}

  virtual bool write(ostream &out) const {}
};

pnp顶点定义:

class VertexPose : public g2o::BaseVertex<6, Sophus::SE3d> {
public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  virtual void setToOriginImpl() override {
    _estimate = Sophus::SE3d();
  }

  /// left multiplication on SE3
  virtual void oplusImpl(const double *update) override {
    Eigen::Matrix<double, 6, 1> update_eigen;
    update_eigen << update[0], update[1], update[2], update[3], update[4], update[5];
    _estimate = Sophus::SE3d::exp(update_eigen) * _estimate;
  }

  virtual bool read(istream &in) override {}

  virtual bool write(ostream &out) const override {}
};

可以看到,对于定义顶点我们要做的四件事:

1.2定义顶点要重写四个函数

  1. virtual void setToOriginImpl()
  2. virtual void oplusImpl(const double *update)
  3. virtual bool read(istream &in)
  4. virtual bool write(ostream &out)
    一般情况下,write和read置空即可,我们重点讨论setToOriginImpl()oplusImpl()

1.2.1setToOriginImpl()

setToOriginImpl()函数的用途就是初始化估计值_estimate,初始化的具体形式随_estimate数据类型而定,_estimate类型可以通过指定BaseVertex模板参数指定

曲线拟合中class CurveFittingVertex : public g2o::BaseVertex<3, Eigen::Vector3d> 指定了_estimate是一个Eigen::Vector3d ,3是指Eigen::Vector3d可以由3个量来确定。

pnp中class VertexPose : public g2o::BaseVertex<6, Sophus::SE3d> 指定了_estimate是一个Sophus::SE3d ,6是指Sophus::SE3d>可以由6个量来确定。

不同的类型决定了不同的初始化方式,在曲线拟合中是初始化一个向量,在pnp中则是初始化一个位姿。

1.2.2oplusImpl()

oplusImpl()函数的作用是定义增量\Delta x,以及每一次优化迭代的“加法”,这是很有必要的,我们知道,在优化问题中,我们需要计算x_{k+1} = x_{k} +\Delta x,这个公式在三维空间是很好解决的,就是向量的每一维相加即可,但如果是在李群上(李群没有加法),这个“+”好就变成了左乘,因此我们必须对oplusImpl()重写。

曲线拟合中_estimate += Eigen::Vector3d(update); ,就是普通的向量相加。

pnp中,由于_estimate是一个李群,因而加法就变成了左乘_estimate = Sophus::SE3d::exp(update_eigen) * _estimate;

2.重写边

边就是链接节点之间的约束条件,在曲线拟合问题中,定义为单边,在pnp中定义为双边

2.1源代码对比

曲线拟合问题:

// 误差模型 模板参数:观测值维度,类型,连接顶点类型
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();
    _error(0, 0) = _measurement - std::exp(abc(0, 0) * _x * _x + abc(1, 0) * _x + abc(2, 0));
  }

  // 计算雅可比矩阵
  virtual void linearizeOplus() override {
    const CurveFittingVertex *v = static_cast<const CurveFittingVertex *> (_vertices[0]);
    const Eigen::Vector3d abc = v->estimate();
    double y = exp(abc[0] * _x * _x + abc[1] * _x + abc[2]);
    _jacobianOplusXi[0] = -_x * _x * y;
    _jacobianOplusXi[1] = -_x * y;
    _jacobianOplusXi[2] = -y;
  }

  virtual bool read(istream &in) {}

  virtual bool write(ostream &out) const {}

public:
  double _x;  // x 值, y 值为 _measurement
};

pnp问题:

class EdgeProjection : public g2o::BaseUnaryEdge<2, Eigen::Vector2d, VertexPose> {
public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  EdgeProjection(const Eigen::Vector3d &pos, const Eigen::Matrix3d &K) : _pos3d(pos), _K(K) {}

  virtual void computeError() override {
    const VertexPose *v = static_cast<VertexPose *> (_vertices[0]);
    Sophus::SE3d T = v->estimate();
    Eigen::Vector3d pos_pixel = _K * (T * _pos3d);
    pos_pixel /= pos_pixel[2];
    _error = _measurement - pos_pixel.head<2>();
  }

  virtual void linearizeOplus() override {
    const VertexPose *v = static_cast<VertexPose *> (_vertices[0]);
    Sophus::SE3d T = v->estimate();
    Eigen::Vector3d pos_cam = T * _pos3d;
    double fx = _K(0, 0);
    double fy = _K(1, 1);
    double cx = _K(0, 2);
    double cy = _K(1, 2);
    double X = pos_cam[0];
    double Y = pos_cam[1];
    double Z = pos_cam[2];
    double Z2 = Z * Z;
    _jacobianOplusXi
      << -fx / Z, 0, fx * X / Z2, fx * X * Y / Z2, -fx - fx * X * X / Z2, fx * Y / Z,
      0, -fy / Z, fy * Y / (Z * Z), fy + fy * Y * Y / Z2, -fy * X * Y / Z2, -fy * X / Z;
  }

2.2如何确定父类边的模板参数

在重写边的过程中,要继承边,分为单边,双边,多边,这里结合具体代码介绍一下单边。

2.2.1单边BaseUnaryEdge<D,E,VertexXi>

首先讲什么情况下选择单边,仅优化一个变量地时候选择单边,在曲线拟合中仅优化向量(a,b,c),在pnp中仅优化相机位姿pose

  • D即为观测值维度
  • E为观测值的类型
  • VertexXi为边链接的节点类型
    具体结合曲线拟合来说:
    曲线拟合是要求解这样一个优化问题,
    \min_{a,b,c} {1 \over 2}\sum_{i=1} ^n ||y_i - exp(ax_i^2+bx_i+c)||_2^2
    我们的观测值是y_i,这是一个一维的double类型,并且便连接的只有一种节点,所以模板参数定义为:BaseUnaryEdge<1, double, CurveFittingVertex> ,这也就是后面程序使用的_measurement。
    具体结合pnp来说:

pnp是要求解这样一个优化问题,

T^* =\mathop{\arg\max}\limits_{T} {1 \over 2}\sum_{i=1}^n||u_i - KTP_i||_2^2

我们要求解T的估计,因为选用单边,我们的观测值是u_i,即像素坐标,是一个2维的double向量,这也就是后面程序使用的_measurement,并且图中含有两种顶点,所以声明为BaseUnaryEdge<2, Eigen::Vector2d, VertexPose>,请注意,我们根据仅优化一个变量****VertexPose ,而选择了单边 BaseUnaryEdge ,而非根据图中具有的顶点种类。

2.3重写边类中的五个函数

一般边内需要重写5个函数

  1. 边的构造函数
  2. virtual void computeError()
  3. virtual void linearizeOplus()
  4. virtual bool read(istream &in)
  5. virtual bool write(ostream &out)

其中,read以及write函数,置空即可,重点探讨剩下的三个函数

2.3.1边的构造函数

构造函数关键在于需要传入什么参数构造边

在曲线拟合中,_estimate代表(a,b,c)的估计,_measurement代表y_i,那么为了估计这个问题

\min_{a,b,c} {1 \over 2}\sum_{i=1} ^n ||y_i - exp(ax_i^2+bx_i+c)||_2^2

我们还需要知道x_i,因此我们选择在构造函数中传入x:CurveFittingEdge(double x) : BaseUnaryEdge(), _x(x) {}

在pnp问题中,_estimate代表T的估计,_measurment代表u_i,那么为了估计这个问题

T^* =\mathop{\arg\max}\limits_{T} {1 \over 2}\sum_{i=1}^n||u_i - KTP_i||_2^2

我们还需要知道P_i,K,因此需要在构造函数中传入P_i,KEdgeProjection(const Eigen::Vector3d &pos, const Eigen::Matrix3d &K) : _pos3d(pos), _K(K){} ,pos代表三维空间点,K代表相机参数

2.3.2virtual void computeError()

这个函数需要计算估计和测量值的差,也就是最小二乘问题中的e

在曲线拟合问题中e表现为y_i - exp(ax_i^2+bx_i+c),因此函数表示为

virtual void computeError() override {
    //1、定义要估计的顶点
    const CurveFittingVertex *v = static_cast<const CurveFittingVertex *> (_vertices[0]);
    //2、取得顶点的估计
    const Eigen::Vector3d abc = v->estimate();
    //3、计算误差e
    _error(0, 0) = _measurement - std::exp(abc(0, 0) * _x * _x + abc(1, 0) * _x + abc(2, 0));
}

在pnp问题中e表现为u_i - KTP_i,因此函数表示为

  virtual void computeError() override {
    //1、定义要估计的顶点
    const VertexPose *v = static_cast<VertexPose *> (_vertices[0]);
    //2、取得顶点的估计
    Sophus::SE3d T = v->estimate();
    //3、计算误差e
    Eigen::Vector3d pos_pixel = _K * (T * _pos3d);
    pos_pixel /= pos_pixel[2];
    _error = _measurement - pos_pixel.head<2>();
  }

2.3.3virtual void linearizeOplus()

这个函数我们计算e关于估计量的雅可比,如果我们没有给出雅可比,g2o也会进行数值求导,但是会比显示给出雅可比慢。

在曲线拟合问题中,根据e关于估计量(a,b,c)的雅可比,我们可以写出这个virtual void linearizeOplus()

  // 计算雅可比矩阵
  virtual void linearizeOplus() override {
    const CurveFittingVertex *v = static_cast<const CurveFittingVertex *> (_vertices[0]);
    const Eigen::Vector3d abc = v->estimate();
    double y = exp(abc[0] * _x * _x + abc[1] * _x + abc[2]);
    _jacobianOplusXi[0] = -_x * _x * y;
    _jacobianOplusXi[1] = -_x * y;
    _jacobianOplusXi[2] = -y;
  }

同样的,在pnp问题中,virtual void linearizeOplus()的形式是

  virtual void linearizeOplus() override {
    const VertexPose *v = static_cast<VertexPose *> (_vertices[0]);
    Sophus::SE3d T = v->estimate();
    Eigen::Vector3d pos_cam = T * _pos3d;
    double fx = _K(0, 0);
    double fy = _K(1, 1);
    double cx = _K(0, 2);
    double cy = _K(1, 2);
    double X = pos_cam[0];
    double Y = pos_cam[1];
    double Z = pos_cam[2];
    double Z2 = Z * Z;
    _jacobianOplusXi
      << -fx / Z, 0, fx * X / Z2, fx * X * Y / Z2, -fx - fx * X * X / Z2, fx * Y / Z,
      0, -fy / Z, fy * Y / (Z * Z), fy + fy * Y * Y / Z2, -fy * X * Y / Z2, -fy * X / Z;
  }

3.创建BlockSolver

3.1源码对比

在曲线拟合问题中:

typedef g2o::BlockSolver<g2o::BlockSolverTraits<3, 1>> BlockSolverType;

在pnp问题中

typedef g2o::BlockSolver<g2o::BlockSolverTraits<6, 3>> BlockSolverType;

g2o::BlockSolverTraits<p,l>,p是估计值的维度,l为误差维度,在曲线拟合中,估计值(a,b,c)维度为3,误差维度1,在pnp中,估计值即位姿6维,这里的3比较难理解,u_i - KTP_i的维度是3,这里将u_i处理为齐次坐标,即(u_x,u_y,1)

g2o还提供了其他的块求解器:

  • BlockSolver_6_3 :表示pose 是6维,观测点是3维,用于BA。
  • BlockSolver_7_3:在BlockSolver_6_3 的基础上多了一个scale。
  • BlockSolver_3_2:表示pose 是3维,观测点是2维。

特别地,当你不想了解到底是多少维,可以使用typedef g2o::BlockSolverX BlockSolverType; 他会自定推测数据的维度。

4.创建线性求解器LinearSolver

这一步中我们可以选择不同的求解方式来求解线性方程

,g2o中提供的求解方式主要有:

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

4.1源码对比

曲线拟合

typedef g2o::LinearSolverDense<BlockSolverType::PoseMatrixType> LinearSolverType;

pnp:

typedef g2o::LinearSolverDense<BlockSolverType::PoseMatrixType> LinearSolverType;

5.创建总求解器solver

有三种求解器可以选择

  • g2o::OptimizationAlgorithmGaussNewton
  • g2o::OptimizationAlgorithmLevenberg
  • g2o::OptimizationAlgorithmDogleg

5.1源码对比:

曲线拟合:

auto solver = new g2o::OptimizationAlgorithmGaussNewton(
    g2o::make_unique<BlockSolverType>(g2o::make_unique<LinearSolverType>()));

pnp:

auto solver = new g2o::OptimizationAlgorithmGaussNewton(
    g2o::make_unique<BlockSolverType>(g2o::make_unique<LinearSolverType>()));

6.创建图优化的核心:稀疏优化器

创建稀疏优化器的过程有以下三步:

  1. 建立图模型
g2o::SparseOptimizer  optimizer;
  1. 设置求解方法:
SparseOptimizer::setAlgorithm(OptimizationAlgorithm* algorithm);
  1. 设置优化过程输出信息:
SparseOptimizer::setVerbose(bool verbose);

6.1源码对比

曲线拟合:

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

pnp:

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

7.在图中添加顶点

添加顶点分为三步:

  1. 创建点并初始化
  2. 定义节点编号
  3. 把节点添加到图中

7.1源码对比

曲线拟合:

  CurveFittingVertex *v = new CurveFittingVertex();//1.创建点
  v->setEstimate(Eigen::Vector3d(ae, be, ce));//1.设定初值
  v->setId(0);//2.定义节点标号
  optimizer.addVertex(v);//3.添加点到图中

pnp:

  VertexPose *vertex_pose = new VertexPose(); //1.创建点
  vertex_pose->setId(0);//2.定义节点标号
  vertex_pose->setEstimate(Sophus::SE3d());//1.设定初值
  optimizer.addVertex(vertex_pose);//3.添加点到图中

8.向图中添加边

添加边总共有五步:

  1. 创建边
  2. 设置边id
  3. 设置连接的顶点
  4. 设置观测值(_measurement)
  5. 设置信息矩阵
  6. 向图中添加边

8.1源码对比

曲线拟合:

  for (int i = 0; i < N; i++) {
    CurveFittingEdge *edge = new CurveFittingEdge(x_data[i]);//1.创建边
    edge->setId(i);                        //2.设置边的ID
    edge->setVertex(0, v);                //3.设置连接的顶点
    edge->setMeasurement(y_data[i]);      //4.设置观测数值
    edge->setInformation(Eigen::Matrix<double, 1, 1>::Identity() * 1 / (w_sigma * w_sigma));                //5.设置信息矩阵:协方差矩阵之逆
    optimizer.addEdge(edge);            //6.向图中添加边
  }

pnp:

  int index = 1;
  for (size_t i = 0; i < points_2d.size(); ++i) {
    auto p2d = points_2d[i];
    auto p3d = points_3d[i];
    EdgeProjection *edge = new EdgeProjection(p3d, K_eigen);//1.创建边
    edge->setId(index);                      //2.设置边的ID
    edge->setVertex(0, vertex_pose);         //3.设置连接的顶点
    edge->setMeasurement(p2d);               //4.设置观测值
    edge->setInformation(Eigen::Matrix2d::Identity());//5.设置信息矩阵
    optimizer.addEdge(edge);                 //6.向图中添加边
    index++;
  }

9.设置优化参数,开始执行优化

设置SparseOptimizer的初始化、迭代次数、保存结果等。

  1. 初始化:
SparseOptimizer::initializeOptimization(HyperGraph::EdgeSet& eset);
  1. 设置迭代次数:
SparseOptimizer::optimize(int iterations,bool online);

9.1源码对比

曲线拟合:

  optimizer.initializeOptimization();    //1.初始化
  optimizer.optimize(10);                //2.设置迭代次数

pnp:

  optimizer.initializeOptimization();    //1.初始化
  optimizer.optimize(10);                //2.设置迭代次数

参考:

SLAM从0到1——图优化g2o:从看懂代码到动手编写(长文)-技术圈 (proginn.com)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,529评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,015评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,409评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,385评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,387评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,466评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,880评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,528评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,727评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,528评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,602评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,302评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,873评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,890评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,132评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,777评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,310评论 2 342

推荐阅读更多精彩内容