今天我们来学习SLAM中常用的优化库g2o,在ORB-SLAM中优化位姿主要用的就是g2o。
一 、图优化是什么
SLAM的后端一般分为两种处理方法,一种是以扩展卡尔曼滤波(EKF)为代表的滤波方法,一种是以图优化为代表的非线性优化方法。不过,目前SLAM研究的主流热点几乎都是基于图优化的。
滤波方法的优缺点:
优点:在当时计算资源受限、待估计量比较简单的情况下,EKF为代表的滤波方法比较有效,经常用在激光SLAM中。
缺点:它的一个大缺点就是存储量和状态量是平方增长关系,因为存储的是协方差矩阵,因此不适合大型场景。而现在基于视觉的SLAM方案,路标点(特征点)数据很大,滤波方法根本吃不消,所以此时滤波的方法效率非常低。
图是由节点和边构成,SLAM问题怎么构成图呢?在graph-based SLAM中,机器人的位姿是一个节点(node)或顶点(vertex),位姿之间的关系构成边(edge)。具体而言比如t+1时刻和t时刻之间的odometry关系构成边,或者由视觉计算出来的位姿转换矩阵也可以构成边。一旦图构建完成了,就要调整机器人的位姿去尽量满足这些边构成的约束。
比如一个机器人在房屋里移动,它在某个时刻 t 的位姿(pose)就是一个顶点,这个也是待优化的变量。而位姿之间的关系就构成了一个边,比如时刻 t 和时刻 t+1 之间的相对位姿变换矩阵就是边,边通常表示误差项。
所以图优化SLAM问题能够分解成两个任务:
1. 构建图,机器人位姿当做顶点,位姿间关系当做边,这一步常常被成为前端(front-end),往往是传感器信息的堆积。
2. 优化图,调整机器人的位姿(顶点)来尽量满足边的约束,使得误差最小,这一步称为后端(back-end)。
二、 g2o理论了解
is-a:是一个,子类指向父类
has-a:有一个,父类包含子类,包含关系,比如SparseOptimizer 包含一个优化算法(OptimizationAlgorithm)的对象。;
has-many:有一些,比如超图有顶点和边。
SparseOptimizer是整个图的核心,我们注意右上角的 is-a (是一个)实心箭头,这个SparseOptimizer它是一个Optimizable Graph,从而也是一个超图(HyperGraph)。
这个超图包含了许多顶点(HyperGraph::Vertex)和边(HyperGraph::Edge)。而这些顶点顶点继承自 Base Vertex,也就是OptimizableGraph::Vertex,而边可以继承自 BaseUnaryEdge(单边), BaseBinaryEdge(双边)或BaseMultiEdge(多边),它们都叫做OptimizableGraph::Edge。
整个图的核心SparseOptimizer 包含一个优化算法(OptimizationAlgorithm)的对象。OptimizationAlgorithm是通过OptimizationWithHessian 来实现的。其中迭代策略可以从Gauss-Newton(高斯牛顿法,简称GN), Levernberg-Marquardt(简称LM法), Powell’s dogleg 三者中间选择一个(我们常用的是GN和LM)
OptimizationWithHessian 内部包含一个求解器(Solver),这个Solver实际是由一个BlockSolver组成的。这个BlockSolver有两个部分,一个是SparseBlockMatrix ,用于计算稀疏的雅可比和Hessian矩阵;一个是线性方程的求解器(LinearSolver),它用于计算迭代过程中最关键的一步HΔx=−b,LinearSolver有几种方法可以选择:PCG, CSparse, Choldmod。
三 、G20代码实战
typedef g2o::BlockSolver< g2o::BlockSolverTraits<3,1> > Block; // 每个误差项优化变量维度为3,误差值维度为1
// 第1步:创建一个线性求解器LinearSolver
Block::LinearSolverType* linearSolver = new g2o::LinearSolverDense<Block::PoseMatrixType>();
// 第2步:创建BlockSolver。并用上面定义的线性求解器初始化
Block* solver_ptr = new Block( linearSolver );
// 第3步:创建总求解器solver。并从GN, LM, DogLeg 中选一个,再用上述块求解器BlockSolver初始化
g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg( solver_ptr );
// 第4步:创建终极大boss 稀疏优化器(SparseOptimizer)
g2o::SparseOptimizer optimizer; // 图模型
optimizer.setAlgorithm( solver ); // 设置求解器
optimizer.setVerbose( true ); // 打开调试输出
// 第5步:定义图的顶点和边。并添加到SparseOptimizer中
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 );
}
// 第6步:设置优化参数,开始执行优化
optimizer.initializeOptimization();
optimizer.optimize(100);
3.1 g2o具体使用步骤:
1、创建一个线性求解器LinearSolver—用于计算迭代过程中最关键的一步:HΔx=−b
我们要求的增量方程的形式是:H△X=-b,通常情况下想到的方法就是直接求逆,也就是△X=-H.inv*b。看起来好像很简单,但这有个前提,就是H的维度较小,此时只需要矩阵的求逆就能解决问题。但是当H的维度较大时,矩阵求逆变得很困难,求解问题也变得很复杂。
g20里对矩阵进行求逆的方法:
LinearSolverCholmod :使用sparse cholesky分解法。继承自LinearSolverCCS
LinearSolverCSparse:使用CSparse法。继承自LinearSolverCCS
LinearSolverPCG :使用preconditioned conjugate gradient 法,继承自LinearSolver
LinearSolverDense :使用dense cholesky分解法。继承自LinearSolver
LinearSolverEigen: 依赖项只有eigen,使用eigen中sparse Cholesky 求解,因此编译好后可以方便的在其他地方使用,性能和CSparse差不多。继承自LinearSolver
2、创建BlockSolver—用于计算稀疏的雅可比和Hessian矩阵
块求解器 BlockSolver 构造线性方程求解器所需要的矩阵块(H 和 b),需要用到边的雅克比
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-55EPCRB4-1665801011465)(/home/cidi/2_学习记录文档cidi/007-orbslam3/25.02 orb-slam中的优化.assets/image-20221013113628979.png)]
BlockSolver 内部包含 LinearSolver,用上面我们定义的线性求解器LinearSolver来初始化。它的定义在如下文件夹内:
g2o/g2o/core/block_solver.h
你点进去会发现 BlockSolver有两种定义方式
一种是指定的固定变量的solver,我们来看一下定义
using BlockSolverPL = BlockSolver< BlockSolverTraits<p, l> >;
其中p代表pose的维度(注意一定是流形manifold下的最小表示),l表示landmark的维度
需要指定优化变量的维度,常见的有以下几种,其中 6 表示待优化变量的维度, 3 表示误差项的维度,可以设置为动态的 BlockSolverX
// variable size solver
using BlockSolverX = BlockSolverPL<Eigen::Dynamic, Eigen::Dynamic>;
// solver for BA/3D SLAM
using BlockSolver_6_3 = BlockSolverPL<6, 3>;
// solver fo BA with scale
using BlockSolver_7_3 = BlockSolverPL<7, 3>;
// 2Dof landmarks 3Dof poses
using BlockSolver_3_2 = BlockSolverPL<3, 2>;
另外你看block_solver.h的最后,预定义了比较常用的几种类型,如下所示:
BlockSolver_6_3 :表示pose 是6维,观测点是3维。用于3D SLAM中的BA
BlockSolver_7_3:在BlockSolver_6_3 的基础上多了一个scale
BlockSolver_3_2:表示pose 是3维,观测点是2维
另一种是可变尺寸的solver,定义如下
using BlockSolverX = BlockSolverPL<Eigen::Dynamic, Eigen::Dynamic>;
在某些应用场景,我们的Pose和Landmark在程序开始时并不能确定,那么此时这个块状求解器就没办法固定变量,此时使用这个可变尺寸的solver,所有的参数都在中间过程中被确定
3、创建总求解器solver—选择下降迭代策略,从GN, LM, DogLeg 中选一个,再用上述块求解器BlockSolver初始化
我们来看g2o/g2o/core/ 目录下,发现Solver的优化方法有三种:分别是高斯牛顿(GaussNewton)法,LM(Levenberg–Marquardt)法、Dogleg法,如下图所示,也和前面的图相匹配
4、创建终极大boss 稀疏优化器(SparseOptimizer),并用已定义求解器作为求解方法。
创建稀疏优化器
g2o::SparseOptimizer optimizer;
用前面定义好的求解器作为求解方法:
SparseOptimizer::setAlgorithm(OptimizationAlgorithm* algorithm)
其中setVerbose是设置优化过程输出信息用的
SparseOptimizer::setVerbose(bool verbose)
5、定义图的顶点和边,并添加到SparseOptimizer中。
3.2 如何自己定义顶点?
我们知道了顶点的基本类型是 BaseVertex,那么下一步关心的就是如何使用了,因为在不同的应用场景(二维空间,三维空间),有不同的待优化变量(位姿,空间点),还涉及不同的优化类型(李代数位姿、李群位姿)。
g2o本身内部定义了一些常用的顶点类型,大概这些:
VertexSE2 : public BaseVertex<3, SE2> //2D pose Vertex, (x,y,theta)
VertexSE3 : public BaseVertex<6, Isometry3> //6d vector (x,y,z,qx,qy,qz) (note that we leave out the w part of the quaternion)
VertexPointXY : public BaseVertex<2, Vector2>
VertexPointXYZ : public BaseVertex<3, Vector3>
VertexSBAPointXYZ : public BaseVertex<3, Vector3>
// SE3 Vertex parameterized internally with a transformation matrix and externally with its exponential map
VertexSE3Expmap : public BaseVertex<6, SE3Quat>
// SBACam Vertex, (x,y,z,qw,qx,qy,qz),(x,y,z,qx,qy,qz) (note that we leave out the w part of the quaternion.
// qw is assumed to be positive, otherwise there is an ambiguity in qx,qy,qz as a rotation
VertexCam : public BaseVertex<6, SBACam>
// 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;
virtual void oplusImpl(const number_t* update);
virtual void setToOriginImpl();
read,write:分别是读盘、存盘函数,一般情况下不需要进行读/写操作的话,仅仅声明一下就可以
setToOriginImpl:顶点重置函数,设定被优化变量的原始值。
oplusImpl:顶点更新函数。非常重要的一个函数,主要用于优化过程中增量△x 的计算。我们根据增量方程计算出增量之后,就是通过这个函数对估计值进行调整的,因此这个函数的内容一定要重视。
自己定义 顶点一般是下面的格式:
class myVertex: public g2::BaseVertex<Dim, Type>
{
public:
EIGEN_MAKE_ALIGNED_OPERATOR_NEW
myVertex(){}
virtual void read(std::istream& is) {}
virtual void write(std::ostream& os) const {}
virtual void setOriginImpl()
{
_estimate = Type();
}
virtual void oplusImpl(const double* update) override
{
_estimate += /*update*/;
}
}
顶点举例一: 以 SLAM 十四讲中曲线拟合的曲线模型顶点为例
- 定义顶点为
CurveFittingVertex
,顶点维度为 3,类型为Eigen::Vector3d
; - 初始值设为为
0, 0, 0
; - 更新函数中由于是向量直接加上更新量
_estimate += Eigen::Vector3d(update)
; - 读写函数留空。
// 曲线模型的顶点,模板参数:优化变量维度和数据类型
class CurveFittingVertex: public g2o::BaseVertex<3, Eigen::Vector3d>
{
public:
EIGEN_MAKE_ALIGNED_OPERATOR_NEW
virtual void setToOriginImpl() // 重置
{
_estimate << 0,0,0;
}
virtual void oplusImpl( const double* update ) // 更新
{
_estimate += Eigen::Vector3d(update);
}
// 存盘和读盘:留空
virtual bool read( istream& in ) {}
virtual bool write( ostream& out ) const {}
};
我们可以看到上面代码中顶点初值设置为0,更新时也是直接把更新量 update 加上去的(更新是 x + △x ),对于这个例子是可以直接加,因为顶点类型是Eigen::Vector3d,属于向量,是可以通过加法来更新的。但是但是有些例子就不行,比如下面这个复杂点例子:李代数表示位姿VertexSE3Expmap
顶点举例二: 以李代数表示的位姿作为顶点,位于 g2o/types/sba/types_six_dof_expmap.h
中
- 定义顶点类为
VertexSE3Expmap
,优化变量是 6 自由度的李代数; - 更新函数采用李代数的增量扰动更新
/**
* \brief SE3 Vertex parameterized internally with a transformation matrix
and externally with its exponential map
*/
class VertexSE3Expmap : public BaseVertex<6, SE3Quat>{
public:
EIGEN_MAKE_ALIGNED_OPERATOR_NEW
// 构造函数.
VertexSE3Expmap();
// 1. 读盘.
bool read(std::istream& is);
// 2. 写盘.
bool write(std::ostream& os) const;
// 3. 顶点重置函数,设定被优化变量的原始值
virtual void setToOriginImpl() {
_estimate = SE3Quat();
}
// 4. 顶点更新函数,增量更新
virtual void oplusImpl(const double* update_) {
Eigen::Map<const Vector6d> update(update_);
setEstimate(SE3Quat::exp(update)*estimate());
}
};
BaseVertex<6, SE3Quat>
第一个参数6 表示内部存储的优化变量维度,这是个6维的李代数
第二个参数是优化变量的类型,这里使用了g2o定义的相机位姿类型:SE3Quat。
它内部使用了四元数表达旋转,然后加上位移来存储位姿,同时支持李代数上的运算,比如对数映射(log函数)、李代数上增量(update函数)等操作.
这个例子更新时没有像上面那样直接加上去,原因是变换矩阵不满足加法封闭。那我再问你,为什么相机位姿顶点类VertexSE3Expmap使用了李代数表示相机位姿,而不是使用旋转矩阵和平移矩阵?
这是因为旋转矩阵是有约束的矩阵,它必须是正交矩阵且行列式为1。使用它作为优化变量就会引入额外的约束条件,从而增大优化的复杂度。而将旋转矩阵通过李群-李代数之间的转换关系转换为李代数表示,就可以把位姿估计变成无约束的优化问题,求解难度降低。
顶点举例3三–空间点
三维向量表示的三维点作为顶点,位于 g2o/types/types_sba.h
中
- 定义顶点类为
VertexSBAPointXYZ
,优化变量是 3 维度的 Vector3d 向量; - 重置与更新函数类似于举例一中的形式,直接相加。
我们继续看例子,刚才是位姿的例子,下面是三维点的例子,空间点位置 VertexPointXYZ,维度为3,类型是Eigen的Vector3,比较简单,就不解释了
class G2O_TYPES_SBA_API VertexSBAPointXYZ : public BaseVertex<3, Vector3>
{
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 number_t* update)
{
Eigen::Map<const Vector3> v(update);
_estimate += v;
}
};
如何向图中添加顶点?
举例一:曲线拟合
往图中增加顶点比较简单,我们还是先看看第一个曲线拟合的例子,setEstimate(type) 函数来设定初始值;setId(int) 定义节点编号
// 往图中增加顶点
CurveFittingVertex* v = new CurveFittingVertex();
v->setEstimate( Eigen::Vector3d(0,0,0) );
v->setId(0);
optimizer.addVertex( v );
举例二:三维坐标点
这个是添加 VertexSBAPointXYZ 的例子,都很容易看懂
/ch7/pose_estimation_3d2d.cpp
int index = 1;
for ( const Point3f p:points_3d ) // landmarks
{
g2o::VertexSBAPointXYZ* point = new g2o::VertexSBAPointXYZ();
point->setId ( index++ );
point->s