SLAM后端优化

一、什么是后端优化

上一篇文章介绍了视觉里程计的设计与实现,也就是所谓的“前端”。既然有前端就一定有后端,本文就来介绍一下SLAM的后端优化。

前端的视觉里程计可以给出一个增量式的地图,但由于不可避免的误差累计,这个地图在长时间内是不准确的。而SLAM致力于构建一个长生命周期的可靠的解决方案,因此只有前端是远远不够的。当地图增长到一定程度后,累计误差会使后来的数据越来越不准确。这时我们需要把所有地图数据放到一起做一次完整的优化,从而降低各部分的误差。

后端优化有很多种方案,过去采用以扩展卡尔曼滤波(Extended Kalman Filter,EKF)为主的滤波器方案,现在大多都采用非线性优化方案。EKF由于假设了马尔可夫性质,只利用前一状态来估计当前状态的值,这有点像视觉里程计中只考虑相邻两帧的关系一样,很难做到全局的优化。而现在常用的非线性优化方法,则是把所有数据都考虑进来,放在一起优化,虽然会增大计算量,但效果好得多。

二、Bundle Adjustment(BA)

本文主要来介绍一下采用Bundle Adjustment的非线性优化方法。

其实在之前的文章《3D-2D相机位姿估计》《3D-3D相机位姿估计》中,我们都用了BA来做非线性优化,但只是优化相邻两张图片间的位姿和路标点。而现在,对于后端优化来说,我们需要优化整个地图的全部位姿和全部路标点,数据量比之前大了不知多少倍。

虽然理论上来说,数据量大并不影响BA方法。但唯一的障碍是数据量大会导致计算时间急剧增大。因为在用梯度下降法求解时,每一轮迭代至少要解一个线性方程组,这就等同于求一个矩阵的逆。矩阵求逆的时间复杂度是O(n3),于是巨大的数据量导致这个矩阵维度极高,从而使求解用时大的离谱。这也就解释了为什么EKF曾经是后端优化的主流,因为它计算量小呀。

那为什么非线性优化又后来居上了呢?

21世纪以来,人们逐渐意识到如果矩阵具有一定形式的稀疏性,可以加速求逆的过程。而SLAM后端的非线性优化恰恰可以利用这一性质!

SLAM的相机位姿和路标点其实具有非常特殊的结构,并非随机产生。相机位姿和路标点之间是多对多的关系,一个相机位姿可以观测到多个路标点,一个路标点也可以被多个相机位姿观测到。由于相机的大范围运动,局部区域的路标点只会被局部的几个相机位姿观测到,而其它大部分相机位姿都观测不到这些点,这是产生稀疏性的根源。当我们构建了非线性优化的代价函数后,需要求代价函数对所有优化变量的偏导数,稀疏性意味着这些偏导数大部分为0,只有小部分不为0,这些不为0的项对应着相机位姿与其能够观测到的路标点的组合。Schur消元法利用矩阵的稀疏性求逆,是BA中求解增量方程的常用手段。

原理大概是这么个原理,具体到实际操作,又会遇到很多问题。我们以g2o图优化方法构建BA为例看一看如何实现一个后端优化。

三、g2o求解BA

与之前的做法一样,用顶点表示相机位姿和路标点,用边表示它们之间的观测。自定义的顶点和边如下。

// 相机位姿顶点,维度为9
class VertexCameraBAL : public g2o::BaseVertex<9,Eigen::VectorXd>
{
public:
    EIGEN_MAKE_ALIGNED_OPERATOR_NEW;
    VertexCameraBAL() {}

    virtual bool read ( std::istream& /*is*/ )
    {
        return false;
    }

    virtual bool write ( std::ostream& /*os*/ ) const
    {
        return false;
    }

    virtual void setToOriginImpl() {}

    virtual void oplusImpl ( const double* update )
    {
        Eigen::VectorXd::ConstMapType v ( update, VertexCameraBAL::Dimension );
        _estimate += v;
    }

};

// 路标点顶点,维度为3
class VertexPointBAL : public g2o::BaseVertex<3, Eigen::Vector3d>
{
public:
    EIGEN_MAKE_ALIGNED_OPERATOR_NEW;
    VertexPointBAL() {}

    virtual bool read ( std::istream& /*is*/ )
    {
        return false;
    }

    virtual bool write ( std::ostream& /*os*/ ) const
    {
        return false;
    }

    virtual void setToOriginImpl() {}

    virtual void oplusImpl ( const double* update )
    {
        Eigen::Vector3d::ConstMapType v ( update );
        _estimate += v;
    }
};

// 代表观测的边,是二元边,两端分别连接相机位姿顶点和路标点顶点
class EdgeObservationBAL : public g2o::BaseBinaryEdge<2, Eigen::Vector2d,VertexCameraBAL, VertexPointBAL>
{
public:
    EIGEN_MAKE_ALIGNED_OPERATOR_NEW;
    EdgeObservationBAL() {}

    virtual bool read ( std::istream& /*is*/ )
    {
        return false;
    }

    virtual bool write ( std::ostream& /*os*/ ) const
    {
        return false;
    }

    virtual void computeError() override   // The virtual function comes from the Edge base class. Must define if you use edge.
    {
        const VertexCameraBAL* cam = static_cast<const VertexCameraBAL*> ( vertex ( 0 ) );
        const VertexPointBAL* point = static_cast<const VertexPointBAL*> ( vertex ( 1 ) );

        ( *this ) ( cam->estimate().data(), point->estimate().data(), _error.data() );

    }

    template<typename T>
    bool operator() ( const T* camera, const T* point, T* residuals ) const
    {
        T predictions[2];
        CamProjectionWithDistortion ( camera, point, predictions );
        residuals[0] = predictions[0] - T ( measurement() ( 0 ) );
        residuals[1] = predictions[1] - T ( measurement() ( 1 ) );

        return true;
    }


    virtual void linearizeOplus() override
    {
        // use numeric Jacobians
        // BaseBinaryEdge<2, Vector2d, VertexCameraBAL, VertexPointBAL>::linearizeOplus();
        // return;
        
        // using autodiff from ceres. Otherwise, the system will use g2o numerical diff for Jacobians

        const VertexCameraBAL* cam = static_cast<const VertexCameraBAL*> ( vertex ( 0 ) );
        const VertexPointBAL* point = static_cast<const VertexPointBAL*> ( vertex ( 1 ) );
        typedef ceres::internal::AutoDiff<EdgeObservationBAL, double, VertexCameraBAL::Dimension, VertexPointBAL::Dimension> BalAutoDiff;

        Eigen::Matrix<double, Dimension, VertexCameraBAL::Dimension, Eigen::RowMajor> dError_dCamera;
        Eigen::Matrix<double, Dimension, VertexPointBAL::Dimension, Eigen::RowMajor> dError_dPoint;
        double *parameters[] = { const_cast<double*> ( cam->estimate().data() ), const_cast<double*> ( point->estimate().data() ) };
        double *jacobians[] = { dError_dCamera.data(), dError_dPoint.data() };
        double value[Dimension];
        // 注意,这里使用了Ceres的自动求导,第一个参数对象必须包含一个重载的函数调用运算符,也就是前面定义的operator()
        bool diffState = BalAutoDiff::Differentiate ( *this, parameters, Dimension, value, jacobians );

        // copy over the Jacobians (convert row-major -> column-major)
        if ( diffState )
        {
            _jacobianOplusXi = dError_dCamera;
            _jacobianOplusXj = dError_dPoint;
        }
        else
        {
            assert ( 0 && "Error while differentiating" );
            _jacobianOplusXi.setZero();
            _jacobianOplusXi.setZero();
        }
    }
};

由于g2o只有数值求导,所以这里用了Ceres自动求导以提高效率。

使用上面定义顶点和边构建图优化问题的代码如下。

// set up the vertexs and edges for the bundle adjustment. 
void BuildProblem(const BALProblem* bal_problem, g2o::SparseOptimizer* optimizer, const BundleParams& params)
{
    const int num_points = bal_problem->num_points();
    const int num_cameras = bal_problem->num_cameras();
    const int camera_block_size = bal_problem->camera_block_size();
    const int point_block_size = bal_problem->point_block_size();

    // Set camera vertex with initial value in the dataset.
    const double* raw_cameras = bal_problem->cameras();
    for(int i = 0; i < num_cameras; ++i)
    {
        ConstVectorRef temVecCamera(raw_cameras + camera_block_size * i,camera_block_size);
        VertexCameraBAL* pCamera = new VertexCameraBAL();
        pCamera->setEstimate(temVecCamera);   // initial value for the camera i..
        pCamera->setId(i);                    // set id for each camera vertex 
  
        // remeber to add vertex into optimizer..
        optimizer->addVertex(pCamera);
        
    }

    // Set point vertex with initial value in the dataset. 
    const double* raw_points = bal_problem->points();
    // const int point_block_size = bal_problem->point_block_size();
    for(int j = 0; j < num_points; ++j)
    {
        ConstVectorRef temVecPoint(raw_points + point_block_size * j, point_block_size);
        VertexPointBAL* pPoint = new VertexPointBAL();
        pPoint->setEstimate(temVecPoint);   // initial value for the point i..
        pPoint->setId(j + num_cameras);     // each vertex should have an unique id, no matter it is a camera vertex, or a point vertex 

        // remeber to add vertex into optimizer..
        pPoint->setMarginalized(true);
        optimizer->addVertex(pPoint);
    }

    // Set edges for graph..
    const int  num_observations = bal_problem->num_observations();
    const double* observations = bal_problem->observations();   // pointer for the first observation..

    for(int i = 0; i < num_observations; ++i)
    {
        EdgeObservationBAL* bal_edge = new EdgeObservationBAL();

        const int camera_id = bal_problem->camera_index()[i]; // get id for the camera; 
        const int point_id = bal_problem->point_index()[i] + num_cameras; // get id for the point 
        
        if(params.robustify)
        {
            g2o::RobustKernelHuber* rk = new g2o::RobustKernelHuber;
            rk->setDelta(1.0);
            bal_edge->setRobustKernel(rk);
        }
        // set the vertex by the ids for an edge observation
        bal_edge->setVertex(0,dynamic_cast<VertexCameraBAL*>(optimizer->vertex(camera_id)));
        bal_edge->setVertex(1,dynamic_cast<VertexPointBAL*>(optimizer->vertex(point_id)));
        bal_edge->setInformation(Eigen::Matrix2d::Identity());
        bal_edge->setMeasurement(Eigen::Vector2d(observations[2*i+0],observations[2*i + 1]));

       optimizer->addEdge(bal_edge) ;
    }

}

这里,每个路标点都调用了setMarginalized方法以利用矩阵的稀疏性。每条边都调用了setRobustKernel方法添加了Huber核函数,以避免异常值对结果产生过大影响。

启动优化的代码这里就不贴出来了。我们运行程序,采用一个未优化的点云数据作为输入,并将优化后的结果输出,将它们同时显示在Meshlab上,效果如下。

左边是优化前的点云,右边是优化后的点云。明显优化后的点云看起来整齐干净了许多,而优化前的点云则比较杂乱。

本文使用了高翔博士的示例代码,地址是:https://github.com/gaoxiang12/slambook/tree/master/ch10/g2o_custombundle
大家可以下载测试。

四、参考资料

《视觉SLAM十四讲》第10讲 后端1 高翔

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

推荐阅读更多精彩内容

  • 一、“续”的由来 上一篇文章已经讲了SLAM的后端优化,使用的是Bundle Adjustment方法。而且介绍了...
    金戈大王阅读 4,060评论 7 3
  • 1. 前言 开始做SLAM(机器人同时定位与建图)研究已经近一年了。从一年级开始对这个方向产生兴趣,到现在为止,...
    壹米玖坤阅读 1,141评论 4 8
  • 协作SLAM通常是部署在多台机器人上的多个SLAM系统,它们各自实现自己的定位和建图,当地图区域出现重叠时进行地图...
    金戈大王阅读 2,669评论 0 2
  • 一、前言 视觉里程计与传统的里程计不同,不使用码盘等设备,只利用摄像头拍摄的连续图像帧就可以计算里程,非常方便,因...
    金戈大王阅读 8,904评论 0 11
  • 前进的路上有汗水和泪水,难免为了某个人或者某某事情而感到伤心难过,但我们绝不能沉迷在已经成为过去时的伤心难过中,更...
    我就是李小草阿阅读 377评论 0 1