SLAM-G2O分析

简介:由图中的关系,可以知道,g2o问题的食物链顶层是SparseOptimizer,一个SparseOptimizer包含优化算法,g2o里面有三种优化算法,分别为GN , LM , Dogleg,而每一种算法都需要执行Ax=b的求解,而求解这些矩阵就需要求解器Solver,用的最多的就是线性求解器,g2o里面有几种线性求解器分别为cholmod, csparse, dense, eigen, pcg。这些求解器针对什么问题后面会详细介绍。下面可以说图优化的流程了:

1.构建SparseOptimizer , 选一种优化算法,一共有三种分别为 OptimizationAlgorithmLevenberg,OptimizationAlgorithmDogleg,OptimizationAlgorithmGaussNewton,然后在给选定的算法一个求解器。这个过程的代码如下,我们是以BA为例:

  

    g2o::SparseOptimizer optimizer;
    g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg(
        new g2o::BlockSolverX(
            new g2o::LinearSolverEigen<g2o::BlockSolverX::PoseMatrixType>()));
   optimizer.setAlgorithm(solver);

2:构建属于本问题的节点,我们首先要明白,我们的节点一共有几种,所谓的节点就是我们需要优化的变量,对于BA问题我们要优化相机的位姿和地图点的三维坐标,先说相机的位姿:

  2.1 构建位姿节点

    (1)继承BaseVertex,写自己的节点,这个时候要清楚,自己的优化变量多少维度,类型是什么,比如g2o里面提供的位姿节点VertexSE3Expmap,就是6维的,然后类型是SE3Quat;  

    (2)设置节点的输入输出流

    (3)设定默认值,这个时候操纵变量_estimate

    (4)最重要的一步oplusImpl()函数,在这个函数里面参数是传入的是节点的更新量,在这个函数里面要完成之前的估计值_estimate和这个增量作用之后得到的估计值,将这个估计值再设置给_estimate,完成节点的更新。

     

class  VertexSE3Expmap : public BaseVertex<6, SE3Quat>{ /////第一个参数是节点的维度,第二个参数是节点的类型
public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW

  VertexSE3Expmap();

  bool read(std::istream& is);

  bool write(std::ostream& os) const;

  virtual void setToOriginImpl() {
    _estimate = SE3Quat(); 
  }

  virtual void oplusImpl(const double* update_)  {
    Eigen::Map<const Vector6d> update(update_);
    setEstimate(SE3Quat::exp(update)*estimate());//////setEstimate就是用来设置节点值的
  }
};

   2.2 构建地图点节点

 class VertexSBAPointXYZ : public BaseVertex<3, Vector3d>
{
  public:
    EIGEN_MAKE_ALIGNED_OPERATOR_NEW    
    VertexSBAPointXYZ();
    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 Vector3d> v(update);
      _estimate += v;
    }
};

 3.构建边,每一个边就是误差函数,既然我们要估计两种变量,那就是两元边,这个边链接了相机的位姿和地图点。两元边的设置需要设置,观测维度,观测类型,关联节点类型两个种。自定义二元边需要继承BaseBinaryEdge.这里我们以EdgeSE3ProjectXYZ为例说一下继承之后需要重写哪些函数,,首先computeError是一个比较重要的函数,如何根据节点的值计算误差,那有可能人有人问,既然两个类型节点,我怎么知道计算的时候用哪一个,是这样的,_vertices[0]代表VertexSBAPointXYZ,_vertices[1]代表VertexSE3Expmap,;其实这里我们要保证一个约定就是,我们在给边添加节点的时候一般用setVertex函数,里面有指定序号的,这里的序号指定和我们public BaseBinaryEdge<2, Vector2d, VertexSBAPointXYZ, VertexSE3Expmap>中的节点顺序,以及我们利用_vertices取值的时候的索引先后关系要一一对应好。如下面代码:

  

    

class  EdgeSE3ProjectXYZ: public  BaseBinaryEdge<2, Vector2d, VertexSBAPointXYZ, VertexSE3Expmap>{ /// 2代表观测维度为2,也就是我们的像素x y ; Vector2d是观测量的类型,VertexSBAPointXYZ是一个节点的类型对应_vertices[0], VertexSE3Expmap是另一个节点的类型
public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW

  EdgeSE3ProjectXYZ();

  bool read(std::istream& is);

  bool write(std::ostream& os) const;

  void computeError()  {
    const VertexSE3Expmap* v1 = static_cast<const VertexSE3Expmap*>(_vertices[1]);
    const VertexSBAPointXYZ* v2 = static_cast<const VertexSBAPointXYZ*>(_vertices[0]);
    Vector2d obs(_measurement);
    _error = obs-cam_project(v1->estimate().map(v2->estimate()));
  }

  bool isDepthPositive() {
    const VertexSE3Expmap* v1 = static_cast<const VertexSE3Expmap*>(_vertices[1]);
    const VertexSBAPointXYZ* v2 = static_cast<const VertexSBAPointXYZ*>(_vertices[0]);
    return (v1->estimate().map(v2->estimate()))(2)>0.0;
  }
    

  virtual void linearizeOplus();

  Vector2d cam_project(const Vector3d & trans_xyz) const;

  double fx, fy, cx, cy;
};            

3.1 linearizeOplus()也是一个很重要的函数,对应雅克比矩阵,对于二元边,我们要得到误差函数相对于每一个状态变量的雅克比,那二元边就有两种雅克比,那这两种雅克比我们都要设定,如下面的代码:_jacobianOplusXi 对应第0个状态变量的雅克比矩阵,和_vertices[0]对应,_jacobianOplusXj是第二个状态变量的雅克比矩阵,这是二元边的专属。对于一元边linearizeOplus()中只需要设置_jacobianOplusXi就可以了。

void EdgeSE3ProjectXYZ::linearizeOplus() {
  VertexSE3Expmap * vj = static_cast<VertexSE3Expmap *>(_vertices[1]);
  SE3Quat T(vj->estimate());
  VertexSBAPointXYZ* vi = static_cast<VertexSBAPointXYZ*>(_vertices[0]);
  Vector3d xyz = vi->estimate();
  Vector3d 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;

  Matrix<double,2,3> tmp;
  tmp(0,0) = fx;
  tmp(0,1) = 0;
  tmp(0,2) = -x/z*fx;

  tmp(1,0) = 0;
  tmp(1,1) = fy;
  tmp(1,2) = -y/z*fy;

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

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

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

 4. 这一步就要给我们的优化器添加节点了:我们这里要注意节点的id是连续增长的。

  4.1 先添加位姿节点

    

        g2o::VertexSE3Expmap * vSE3 = new g2o::VertexSE3Expmap();
        vSE3->setEstimate(Converter::toSE3Quat(pKF->GetPose()));////设置估计值
        vSE3->setId(pKF->mnId);////设置节点id
        vSE3->setFixed(pKF->mnId==0);/////节点是否固定
        optimizer.addVertex(vSE3);//添加进优化器

   4.2 添加地图点节点

  

        g2o::VertexSBAPointXYZ* vPoint = new g2o::VertexSBAPointXYZ();
        vPoint->setEstimate(Converter::toVector3d(pMP->GetWorldPos()));
        const int id = pMP->mnId+maxKFid+1;
        vPoint->setId(id);
        vPoint->setMarginalized(true);
        optimizer.addVertex(vPoint);

 5. 下面我们要设置边,每一个关键帧的一个位姿节点和很多地图点之间可以构建边,构建部分包括 设置边的节点,设置观测,设置信息矩阵,设置核函数:

   g2o::EdgeSE3ProjectXYZ* e = new g2o::EdgeSE3ProjectXYZ();

                e->setVertex(0, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(id)));
                e->setVertex(1, dynamic_cast<g2o::OptimizableGraph::Vertex*>(optimizer.vertex(pKF->mnId)));
                //e->setVertex(2, dynamic_cast<g2o::OptimizableGraph::Vertex*>(vCam));
                e->setMeasurement(obs);
                const float &invSigma2 = pKF->mvInvLevelSigma2[kpUn.octave];
                e->setInformation(Eigen::Matrix2d::Identity()*invSigma2);

                if(bRobust)
                {
                    g2o::RobustKernelHuber* rk = new g2o::RobustKernelHuber;
                    e->setRobustKernel(rk);
                    rk->setDelta(thHuber2D);
                }

                e->fx = pKF->fx;
                e->fy = pKF->fy;
                e->cx = pKF->cx;
                e->cy = pKF->cy;

                optimizer.addEdge(e);

 6. 开始优化

    optimizer.initializeOptimization();
    optimizer.optimize(nIterations);

 7:优化完成之后我们可以通过优化器中取出对应序号的节点,里面estimate()函数得到估计值,如下:

        g2o::VertexSE3Expmap* vSE3 = static_cast<g2o::VertexSE3Expmap*>(optimizer.vertex(pKF->mnId));
        g2o::SE3Quat SE3quat = vSE3->estimate();

 8:优化完成后,对每一条边都进行检查,剔除误差较大的边(认为是错误的边),并设置setLevel为0,即下次不再对该边进行优化,其中vpEdgesMono里面保存着边的指针。另外,用chi2函数来获得(加权)最小二乘误差等,这些可以用来判断某条边的误差是否过大。最后,边允许获得/设置level,将边设置为不同的level,在优化时就可以分开优化不同level的边(orb-slam2的优化就采用了这种multi-level optimization)。其实chi()就是误差和信息矩阵的加权乘积,如下:

      virtual double chi2() const 
      {
        return _error.dot(information()*_error);
      }
 
// 优化完成后,进行Edge的检查
for(size_t i=0, iend=vpEdgesMono.size(); i<iend;i++)
{
    g2o::EdgeSE3ProjectXYZ* e = vpEdgesMono[i];
    MapPoint* pMP = vpMapPointEdgeMono[i];

    if(pMP->isBad())
        continue;

    // 基于卡方检验计算出的阈值(假设测量有一个像素的偏差)
    // 第二个判断条件,用于检查构成该边的MapPoint在该相机坐标系下的深度是否为正?
    if(e->chi2()>5.991 || !e->isDepthPositive())
    {
        e->setLevel(1);// 不优化
    }

    // 因为剔除了错误的边,所以下次优化不再使用核函数
    e->setRobustKernel(0);
}

 9:其实有时候我们每进行一次迭代,之后都可以检查误差,然后设置边的级别,然后再优化这样子。

杂项解析:

每种edge都需要用户来实现computeError函数。

1: 由于只链接一个节点,因此BaseUnaryEdge只多了一个模版参数:节点类型。并且多了一个雅可比矩阵的成员变量:_jacobianOplusXi。此外,由于HyperGraph::Edge类中用了vector<Vertex *>来存储一条边所连接的节点,因此,BaseUnaryEdge类在初始化的时候会将这个数组resize为1(对应地,BaseBinaryEdge类初始化时会将之resize为2)。

2: 其最主要的函数是计算雅可比矩阵的linearizeOplus函数。可以由用户提供雅可比矩阵的解析解,而默认是g2o自己进行数值求导:g2o使用中值求导方法,其步长delta为常数值。函数对节点的_estimate加上和减去对应步长,再计算对应的误差值,再除以2*delta来计算误差函数对于特定误差项的导数。此时,为了保存节点原来的值,就会使用push/pop函数往_backup变量中保持修改前的节点估计值。(数值求导部分我们后面再细说。)

3: 另一个比较主要的函数是constructQuadraticForm函数。该函数用来计算该误差项的系数矩阵H和对应的b向量。以BaseUnaryEdge为例,函数先调用chi2函数获得误差 ( [公式] ),如果没有使用鲁棒核函数,直接使用 [公式] 来计算系数矩阵H,用 [公式] 来计算b向量;否则,调用对应函数得到鲁棒核函数的值,及其一阶导、二阶导(rho,见后面的鲁棒核函数)。b向量可以直接通过 [公式] 来计算,但系数矩阵H则调用robustInformation函数来重新计算加权信息矩阵 (weightedOmega)。

如下面:

      InformationType robustInformation(const Eigen::Vector3d& rho)
      {
        InformationType result = rho[1] * _information;
        //ErrorVector weightedErrror = _information * _error;
        //result.noalias() += 2 * rho[2] * (weightedErrror * weightedErrror.transpose());
        return result;
      }

rho[0] = rho
rho[1] = rho'
rho[2]=rho''
新的权重计算公式为: rho'  +2rho'' * _error * _error^t.
然后最后的信息矩阵W= ( rho' +2rho'' * _error * _error^t )* _information(原来程序设置的信息矩阵).
这样原来的 H=J^t W J.
原来的 b = -rho'J^t_information_error.

4:BaseBinaryEdge链接两个节点,因此比BaseEdge多两个模版参数。对应的,自然也会有两个雅可比矩阵:_jacobianOplusXi和_jacobianOplusXj。其余本质上和BaseUnaryEdge并没有差别。需要注意的是,BaseBinaryEdge类也定义了一个_hessian变量,但这个海塞矩阵并不是残差对于观测(节点)进行求导得到的雅可比J得到的 [公式] ,而是通过 [公式] 得到的,显然,这是_Hpl矩阵,即位姿和路标点的协方差矩阵(或位姿和位姿间的协方差矩阵)。

BaseMultiEdge由于链接的节点数目是不固定的,因此其直接继承BaseEdge类而不在需要额外的模版参数。相关的雅可比矩阵也变成了一个vector变量:_jacobianOplus。链接多个节点并没有带来多大的不同。一些变量如_hessian和_jacobianOplus定义成了vector。而在计算雅可比矩阵(linearizeOplus)时,BaseUnaryEdge只计算一个雅可比矩阵,BaseBinaryEdge计算两个,而BaseMulitEdge利用循环计算了多个雅可比矩阵,分别存储_jacobianOplus[I]中;constructQuadraticForm函数也是同理。

关于方程求解器:

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

原文地址:https://www.cnblogs.com/mataiyuan/p/12884953.html