VSLAM学习记录3——总结VO部分并完成从0到1手写VO(前端里程计)

VSLAM学习记录3——总结VO部分并完成从0到1手写前端里程计VO

范数理解

我们都知道,函数与几何图形往往是有对应关系的,这个很好想象,特别是在三维以下的空间内,函数是几何图像的数学概括,而几何图像是函数的高度形象化,比如一个函数对应几何空间上若干点组成的图形。 但当函数与几何超出三维空间时,就难以获得较好的想象,于是就有了映射的概念,映射表达的就是一个集合通过某种关系转为另外一个集合。通常数学书是先说映射,然后再讨论函数,这是因为函数是映射的一个特例。 为了更好的在数学上表达这种映射关系,(这里特指线性关系)于是就引进了矩阵。这里的矩阵就是表征上述空间映射的线性关系。而通过向量来表示上述映射中所说的这个集合,而我们通常所说的基,就是这个集合的最一般关系。于是,我们可以这样理解,一个集合(向量),通过一种映射关系(矩阵),得到另外一个集合(另外一个向量)

那么向量的范数表示这个原有集合的大小。

矩阵的范数表示这个变化过程的大小的一个度量。

简单说:

0范数表示向量中非零元素的个数(即为其稀疏度)。

1范数表示为:绝对值之和。

2范数则指模,即向量的大小(长度)

直接法

在这里插入图片描述

在这里插入图片描述

直接法根据像素的亮度信息来估计相机运动,不需要知道点之间的对应关系,而是通过最小化光度误差来求得。

回忆特征点法中,由于我们通过匹配描述子,知道了 p1, p2 的像素位置,所以可以计算重投影的位置(PnP中 BA——>最小重投影误差)。但在直接法中,由于没有特征匹配,我们无从知道哪一个 p2 与 p1 对应 着同一个点。直接法的思路是根据当前相机的位姿估计值,来寻找 p2 的位置。**但若相机位姿不够好,p2 的外观和 p1 会有明显差别。于是,为了减小这个差别,我们优化相机的位姿,来寻找与 p1 更相似的 p2。**这同样可以通过解一个优化问题,但此时最小化的不是 重投影误差,而是光度误差(Photometric Error),也就是 P 的两个像的亮度误差。

在这里插入图片描述

在这里插入图片描述

使用李代数解决求导问题的思路分为两种:

1、用李代数表示姿态,然后根据李代数加法对李代数求导。

2、对李群左乘或右乘微小扰动,然后对扰动求导,称为左扰动和右扰动模型。

对于第一种方法可以理解,求得的导数就是某函数关于李代数的导数。但是,对于第二种说法,一个函数对李群扰动进行求导得到的是什么呢?求得的导数有什么用呢?

比如一个观测方程的误差函数记作J,那么对于第一种方法,用J对李代数求导,然后不断迭代下降,可以通过不断改变李代数来最小化J,从而得到最合适的李代数,也就是位姿。

  • 在使用高斯牛顿下降法优化的过程中需要求解增量方程,该过程会涉及到误差函数对ΔX求导。

把高斯牛顿法应用到SLAM中,用来优化观测方程的误差函数,变换矩阵SE(3)作为不断更新的量,越来越接近实际值,从而使误差函数不断减小。而这个过程中要求解增量方程中的ΔX就是SE(3)的扰动量!!求解过程中会计算误差函数对此扰动量的求导。也就用到了扰动模型!

  • 为什么广泛使用扰动模型?

相比于直接对姿态求导,姿态的扰动为小量。我们对小量进行泰勒展开后,二阶以上的项都可以忽略(因为实在是太小了),只保留一阶导数,这样做的好处是线性化程度更高(相比于直接对姿态求导),泰克展开的拟合也更精确。而在BA优化的迭代求解中,要不断计算雅克比(一阶偏导数矩阵),如果我们把扰动作为状态量,梯度的计算会更精确,收敛也会更快。

在这里插入图片描述
在这里插入图片描述

小结:

从上面是推导可以看到,直接法的雅可比项有一个图像梯度因子

  • 因此,在图像梯度不明显的地方,对相机运动估计的贡献就小

根据使用的图像信息不同(P的来源),可分为:

  • 稀疏直接法:只处理稀疏角点或关键点

    P 来自于稀疏关键点,我们称之为稀疏直接法。通常我们使用数百个至上千个关键 点,并且像 L-K 光流那样,假设它周围像素也是不变的。这种稀疏直接法不必计算 描述子,并且只使用数百个像素,因此速度最快,但只能计算稀疏的重构。

  • 稠密直接法:使用所有像素

    P 为所有像素,称为稠密直接法。稠密重构需要计算所有像素(一般几十万至几百万 个),因此多数不能在现有的 CPU 上实时计算,需要 GPU 的加速。但是,如前面 所讨论的,梯度不明显的点,在运动估计中不会有太大贡献,在重构时也会难以估计 位置。

  • 半稠密直接法:使用部分梯度明显的像素

    P 来自部分像素。我们看到式(8.16)中,如果像素梯度为零,整一项雅可比就为零, 不会对计算运动增量有任何贡献。因此,可以考虑只使用带有梯度的像素点,舍弃像 素梯度不明显的地方。这称之为半稠密(Semi-Dense)的直接法,可以重构一个半稠 密结构。

从稀疏到稠密重构,都可以用直接法来计算。它们的计算量是逐渐增长的。 稀疏方法可以快速地求解相机位姿,而稠密方法可以建立完整地图。具体使用哪种方法,需要视机器人的应用环境而定。特别地,在低端的计算平台上,稀疏直接法可以做到非常快 速的效果,适用于实时性较高且计算资源有限的场合

实践

为了保持程序简单,我们使用 RGB-D 数据而非单目数据,这样可 以省略掉单目的深度恢复部分。基于特征点的深度恢复已经介绍过了(三角测量)。所以我们来考虑 RGB-D 上的稀疏直接法 VO。 由于求解直接法最后等价于求解一个优化问题,因此我们可以使用 g2o 或 Ceres 这些 优化库帮助我们求解。

在使用 g2o 之前,需要把直接法抽象成一个图优化问题。显然,直接法是由以下顶点和边组成的:

  1. 优化变量为一个相机位姿,因此需要一个位姿顶点。由于我们在推导中使用了李代 数,故程序中使用李代数表达的 SE(3) 位姿顶点。(初始位姿)
  2. 误差项为单个像素的光度误差。由于整个优化过程中 I1(p1) 保持不变,我们可以把 它当成一个固定的预设值,然后调整相机位姿,使 I2(p2) 接近这个值。于是,这种 边只连接一个顶点,为一元边。由于 g2o 中本身没有计算光度误差的边,我们需要 自己定义一种新的边。

直接法图优化问题是由一个相机位姿顶点与许多条一元边组成的。 如果使用稀疏的直接法,那我们大约会有几百至几千条这样的边;稠密直接法则会有几十万条边。优化问题对应的线性方程是计算李代数增量,本身规模不大(6×6),所以主要的计算时间会花费在每条边的误差与雅可比的计算上。

稀疏直接法
#include <iostream>
#include <fstream>
#include <list>
#include <vector>
#include <chrono>
#include <ctime>
#include <climits>

#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/features2d/features2d.hpp>

#include <g2o/core/base_unary_edge.h>
#include <g2o/core/block_solver.h>
#include <g2o/core/optimization_algorithm_levenberg.h>
#include <g2o/solvers/dense/linear_solver_dense.h>
#include <g2o/core/robust_kernel.h>
#include <g2o/types/sba/types_six_dof_expmap.h>

using namespace std;
using namespace g2o;

/********************************************
 * 本节演示了RGBD上的稀疏直接法 
 ********************************************/

//这里定义了一个结构体Measurement,用来方便存储每次测量得到的值。
//同时定义了结构体的构造函数:其内部有两个成员变量,分别为三维向量pos_world与浮点数grayscale,
//用来分别存储某个特征点的相机坐标与灰度值。这里其实将Measurement定义为一个class的结果也是一样的。

// 一次测量的值,包括一个世界坐标系下三维点与一个灰度值
struct Measurement
{
    Measurement ( Eigen::Vector3d p, float g ) : pos_world ( p ), grayscale ( g ) {}
    Eigen::Vector3d pos_world; //三维世界坐标
    float grayscale; //h灰度值
};

//这里定义了两个内联函数project2Dto3D与project3Dto2D,分别返回一个三维坐标与一个二维坐标,
//用于进行相机坐标与像素坐标的转化。这里使用内联函数是为了提高程序的运行效率。
inline Eigen::Vector3d project2Dto3D ( int x, int y, int d, float fx, float fy, float cx, float cy, float scale )
{
    float zz = float ( d ) /scale;
    float xx = zz* ( x-cx ) /fx;
    float yy = zz* ( y-cy ) /fy;
    return Eigen::Vector3d ( xx, yy, zz );
}

inline Eigen::Vector2d project3Dto2D ( float x, float y, float z, float fx, float fy, float cx, float cy )
{
    float u = fx*x/z+cx;
    float v = fy*y/z+cy;
    return Eigen::Vector2d ( u,v );
}

// 直接法估计位姿
// 输入:测量值(空间点的灰度),新的灰度图,相机内参; 输出:相机位姿
// 返回:true为成功,false失败

//这个函数声明则是本程序的重点:poseEstimationDirect,即用来使用直接法进行位姿估计。
//传入的参数有一个存储Measurement类变量的容器,一个指向Mat类的指针,一个3×3的矩阵,和一个4×4的矩阵,
//分别存储了特征点的空间位置及灰度信息、当前帧的图像、相机内参、解算出的位姿。
bool poseEstimationDirect ( const vector<Measurement>& measurements, cv::Mat* gray, Eigen::Matrix3f& intrinsics, Eigen::Isometry3d& Tcw );


// project a 3d point into an image plane, the error is photometric error
// an unary edge with one vertex SE3Expmap (the pose of camera)

//这里定义了一个边,继承自BaseUnaryEdge,在模板参数中填入测量值的维度、类型,以及连接此边的顶点。
//同时其内部定义了两个虚函数computeError与linearizeOplus,分别用来计算误差与雅可比矩阵。
//在定义完边之后,便可直接在g2o中使用此类型的边进行优化。
class EdgeSE3ProjectDirect: public BaseUnaryEdge< 1, double, VertexSE3Expmap>
{
public:
    EIGEN_MAKE_ALIGNED_OPERATOR_NEW

    EdgeSE3ProjectDirect() {}

    //构造函数
    EdgeSE3ProjectDirect ( Eigen::Vector3d point, float fx, float fy, float cx, float cy, cv::Mat* image )
        : x_world_ ( point ), fx_ ( fx ), fy_ ( fy ), cx_ ( cx ), cy_ ( cy ), image_ ( image )
    {}

    virtual void computeError()
    {
        const VertexSE3Expmap* v  =static_cast<const VertexSE3Expmap*> ( _vertices[0] );
        Eigen::Vector3d x_local = v->estimate().map ( x_world_ );
        float x = x_local[0]*fx_/x_local[2] + cx_;
        float y = x_local[1]*fy_/x_local[2] + cy_;
        // check x,y is in the image
        if ( x-4<0 || ( x+4 ) >image_->cols || ( y-4 ) <0 || ( y+4 ) >image_->rows )
        {
            _error ( 0,0 ) = 0.0;
            this->setLevel ( 1 );
        }
        else
        {
           // 注意我们在程序中的误差计算里,使用了 I2(p2) − I1(p1) 的形式,因此前面的负号可以省去,只需把像素梯度乘以像素到李代数的梯度即可。
            _error ( 0,0 ) = getPixelValue ( x,y ) - _measurement;
        }
    }

    // plus in manifold
    virtual void linearizeOplus( )
    {
        if ( level() == 1 )
        {
            _jacobianOplusXi = Eigen::Matrix<double, 1, 6>::Zero();
            return;
        }
        VertexSE3Expmap* vtx = static_cast<VertexSE3Expmap*> ( _vertices[0] );
        Eigen::Vector3d xyz_trans = vtx->estimate().map ( x_world_ );   // q in book

        double x = xyz_trans[0];
        double y = xyz_trans[1];
        double invz = 1.0/xyz_trans[2];
        double invz_2 = invz*invz;

        float u = x*fx_*invz + cx_;
        float v = y*fy_*invz + cy_;

        // jacobian from se3 to u,v
        // NOTE that in g2o the Lie algebra is (\omega, \epsilon), where \omega is so(3) and \epsilon the translation
        Eigen::Matrix<double, 2, 6> jacobian_uv_ksai;

        jacobian_uv_ksai ( 0,0 ) = - x*y*invz_2 *fx_;
        jacobian_uv_ksai ( 0,1 ) = ( 1+ ( x*x*invz_2 ) ) *fx_;
        jacobian_uv_ksai ( 0,2 ) = - y*invz *fx_;
        jacobian_uv_ksai ( 0,3 ) = invz *fx_;
        jacobian_uv_ksai ( 0,4 ) = 0;
        jacobian_uv_ksai ( 0,5 ) = -x*invz_2 *fx_;

        jacobian_uv_ksai ( 1,0 ) = - ( 1+y*y*invz_2 ) *fy_;
        jacobian_uv_ksai ( 1,1 ) = x*y*invz_2 *fy_;
        jacobian_uv_ksai ( 1,2 ) = x*invz *fy_;
        jacobian_uv_ksai ( 1,3 ) = 0;
        jacobian_uv_ksai ( 1,4 ) = invz *fy_;
        jacobian_uv_ksai ( 1,5 ) = -y*invz_2 *fy_;

        Eigen::Matrix<double, 1, 2> jacobian_pixel_uv;

        jacobian_pixel_uv ( 0,0 ) = ( getPixelValue ( u+1,v )-getPixelValue ( u-1,v ) ) /2;
        jacobian_pixel_uv ( 0,1 ) = ( getPixelValue ( u,v+1 )-getPixelValue ( u,v-1 ) ) /2;

        _jacobianOplusXi = jacobian_pixel_uv*jacobian_uv_ksai;
    }

    // dummy read and write functions because we don't care...
    virtual bool read ( std::istream& in ) {}
    virtual bool write ( std::ostream& out ) const {}

protected:
    
    //相机位姿是用浮点数表示的,投影到像素坐标也是浮点形式。为了更精细地计算像素亮度,我们要对图像进行插值。我们这里采用了简单的双线性插值,也可以使用更复杂的插值方式,但计算代价可能会变高一些。
    
    // get a gray scale value from reference image (bilinear interpolated)
    //在我们迭代的dx,dy不是整数时,如何获取像素灰度值?采用双线性插值的方法
    //灰度值函数是对图像进行双线性插值得到的,下面是具体实现
    inline float getPixelValue ( float x, float y )
    {
        //image_为Mat*类型,图像指针,所以调用data时用->符号,
        //data为图像矩阵首地址,支持数组形式访问,data[]就是访问到像素的值了,此处为像素的灰度值,类型为uchar
        //data[]中括号的式子有点复杂,总的意思就是y行乘上每行内存数,定位到行,然后在加上x,定位到像素
        //image_->data[int(y)*image_->step + int(x)]这一步读到了x,y处的灰度值,类型为uchar,
        //但是后面由于线性插值,需要定位这个像素的位置,而不是他的灰度值,所以取其地址,记住它的位置,后面使用
        uchar* data = & image_->data[ int ( y ) * image_->step + int ( x ) ];

        //由于x,y这里有可能带小数,但是像素位置肯定是整数,所以,问题来了,(1.2, 4.5)像素坐标处的灰度值为多少呢?OK,线性插值!
        //说一下floor(),std中的cmath函数。向下取整,返回不大于x的整数。例floor(4.9)=4
        //xx和yy,就是取到小数部分。例:x=4.9的话,xx=x-floor(x)就为0.9。y同理
        float xx = x - floor ( x );
        float yy = y - floor ( y );
        return float (
                   ( 1-xx ) * ( 1-yy ) * data[0] +
                   xx* ( 1-yy ) * data[1] +
                   ( 1-xx ) *yy*data[ image_->step ] +
                   xx*yy*data[image_->step+1]
               );
    }
public:
    Eigen::Vector3d x_world_;   // 3D point in world frame
    float cx_=0, cy_=0, fx_=0, fy_=0; // Camera intrinsics
    cv::Mat* image_=nullptr;    // reference image
};

int main ( int argc, char** argv )
{
    //输入的参数依然为data文件夹的存储路径,argc的判别值为2。
    if ( argc != 2 )
    {
        cout<<"usage: useLK path_to_dataset"<<endl;
        return 1;
    }
    //利用系统时间,近似随机地生成随机数种子。
    srand ( ( unsigned int ) time ( 0 ) );
    string path_to_dataset = argv[1];
    string associate_file = path_to_dataset + "/associate.txt";

    ifstream fin ( associate_file );

    string rgb_file, depth_file, time_rgb, time_depth;
    cv::Mat color, depth, gray;

    //这里就是那个测量值数组,应该就是一堆灰度值
    vector<Measurement> measurements;
    // 相机内参
    float cx = 325.5;
    float cy = 253.5;
    float fx = 518.0;
    float fy = 519.0;

    //这里设置了单位换算比例,RGBD相机出来的单位是毫米,而三维世界空间点坐标单位是米
    float depth_scale = 1000.0;
    Eigen::Matrix3f K;
    K<<fx,0.f,cx,0.f,fy,cy,0.f,0.f,1.0f;

    Eigen::Isometry3d Tcw = Eigen::Isometry3d::Identity();

    //这里的prev_color就是指第一帧,因为整个程序中,只在第一帧中对它赋值了,之后再也没动过
    //也就是还是整个过程的参考帧就只有第一帧,而不是循环流动用当前帧的上一帧做参考帧
    cv::Mat prev_color;
    // 我们以第一个图像为参考,对后续图像和参考图像做直接法
    for ( int index=0; index<10; index++ )
    {
        cout<<"*********** loop "<<index<<" ************"<<endl;
        fin>>time_rgb>>rgb_file>>time_depth>>depth_file;
        color = cv::imread ( path_to_dataset+"/"+rgb_file );
        depth = cv::imread ( path_to_dataset+"/"+depth_file, -1 );
        if ( color.data==nullptr || depth.data==nullptr )
            continue;

        //调用OpenCV内置的图像颜色空间转换函数cvtColor将RGB图像color转为灰度图gray,
        // 在之后的直接法求取位姿中使用灰度图作为每一帧图像输入。
        cv::cvtColor ( color, gray, cv::COLOR_BGR2GRAY );
        if ( index ==0 )
        {
            // 对第一帧提取FAST特征点
            vector<cv::KeyPoint> keypoints;
            cv::Ptr<cv::FastFeatureDetector> detector = cv::FastFeatureDetector::create();
            detector->detect ( color, keypoints );
            // 去掉邻近边缘处的点(半稠密不需要这一块)

            /***
            这里进行了几次判断,分别判断首帧特征点的像素坐标是否在图像边缘20像素的范围内,以及特征点所查询到的的深度值是否为零。
            去掉不符合标准的特征点后,调用project2Dto3D计算特征点的3d坐标,
            并查询该特征点像素坐标所对应的深度信息,最后一并存入容器measurements中。

            进而,使用计算好的特征点深度信息与灰度值信息,调用poseEstimationDirect函数进行直接法的位姿求取。
            求取得到相机位姿变换矩阵Tcw后将其进行输出展示,并利用其进行计算特征点在当前帧中的位置,
            最后进行圈画并以图片的形式进行展示。
             ***/

            for ( auto kp:keypoints )
            {
                // 去掉邻近边缘处的点
                //判断首帧特征点的像素坐标是否在图像边缘20像素的范围内
                if ( kp.pt.x < 20 || kp.pt.y < 20 || ( kp.pt.x+20 ) >color.cols || ( kp.pt.y+20 ) >color.rows )
                    continue;
                ushort d = depth.ptr<ushort> ( cvRound ( kp.pt.y ) ) [ cvRound ( kp.pt.x ) ];
                //特征点所查询到的的深度值是否为零
                if ( d==0 )
                    continue;
                Eigen::Vector3d p3d = project2Dto3D ( kp.pt.x, kp.pt.y, d, fx, fy, cx, cy, depth_scale );
                float grayscale = float ( gray.ptr<uchar> ( cvRound ( kp.pt.y ) ) [ cvRound ( kp.pt.x ) ] );
                measurements.push_back ( Measurement ( p3d, grayscale ) );
            }
            prev_color = color.clone();
            continue;
        }
        // 使用直接法计算相机运动
        chrono::steady_clock::time_point t1 = chrono::steady_clock::now();
        //调用poseEstimationDirect函数进行直接法的位姿求取
        poseEstimationDirect ( measurements, &gray, K, Tcw );
        chrono::steady_clock::time_point t2 = chrono::steady_clock::now();
        chrono::duration<double> time_used = chrono::duration_cast<chrono::duration<double>> ( t2-t1 );
        cout<<"direct method costs time: "<<time_used.count() <<" seconds."<<endl;
        cout<<"Tcw="<<Tcw.matrix() <<endl;

        // plot the feature points
        cv::Mat img_show ( color.rows*2, color.cols, CV_8UC3 );
        prev_color.copyTo ( img_show ( cv::Rect ( 0,0,color.cols, color.rows ) ) );
        color.copyTo ( img_show ( cv::Rect ( 0,color.rows,color.cols, color.rows ) ) );

        //这些代码便是将每一帧图像与第一帧进行拼接,并将两帧图像中的特征点进行圈画及连线。
        for ( Measurement m:measurements )
        {
            // 这是为了适当减少所圈画的特征点的个数,即只取五分之一的特征点进行展示
            if ( rand() > RAND_MAX/5 )
                continue;
            Eigen::Vector3d p = m.pos_world;
            Eigen::Vector2d pixel_prev = project3Dto2D ( p ( 0,0 ), p ( 1,0 ), p ( 2,0 ), fx, fy, cx, cy );
            Eigen::Vector3d p2 = Tcw*m.pos_world;
            Eigen::Vector2d pixel_now = project3Dto2D ( p2 ( 0,0 ), p2 ( 1,0 ), p2 ( 2,0 ), fx, fy, cx, cy );
            if ( pixel_now(0,0)<0 || pixel_now(0,0)>=color.cols || pixel_now(1,0)<0 || pixel_now(1,0)>=color.rows )
                continue;

            float b = 255*float ( rand() ) /RAND_MAX;
            float g = 255*float ( rand() ) /RAND_MAX;
            float r = 255*float ( rand() ) /RAND_MAX;
            cv::circle ( img_show, cv::Point2d ( pixel_prev ( 0,0 ), pixel_prev ( 1,0 ) ), 8, cv::Scalar ( b,g,r ), 2 );
            cv::circle ( img_show, cv::Point2d ( pixel_now ( 0,0 ), pixel_now ( 1,0 ) +color.rows ), 8, cv::Scalar ( b,g,r ), 2 );
            cv::line ( img_show, cv::Point2d ( pixel_prev ( 0,0 ), pixel_prev ( 1,0 ) ), cv::Point2d ( pixel_now ( 0,0 ), pixel_now ( 1,0 ) +color.rows ), cv::Scalar ( b,g,r ), 1 );
        }
        cv::imshow ( "result", img_show );
        cv::waitKey ( 0 );

    }
    return 0;
}

bool poseEstimationDirect ( const vector< Measurement >& measurements, cv::Mat* gray, Eigen::Matrix3f& K, Eigen::Isometry3d& Tcw )
{
    // 初始化g2o
    typedef g2o::BlockSolver<g2o::BlockSolverTraits<6,1>> DirectBlock;  // 求解的向量是6*1的 灰度值是一维,李代数是6维
    DirectBlock::LinearSolverType* linearSolver = new g2o::LinearSolverDense< DirectBlock::PoseMatrixType > ();
    DirectBlock* solver_ptr = new DirectBlock ( linearSolver );
    // g2o::OptimizationAlgorithmGaussNewton* solver = new g2o::OptimizationAlgorithmGaussNewton( solver_ptr ); // G-N
    g2o::OptimizationAlgorithmLevenberg* solver = new g2o::OptimizationAlgorithmLevenberg ( solver_ptr ); // L-M
    g2o::SparseOptimizer optimizer;
    optimizer.setAlgorithm ( solver );
    optimizer.setVerbose( true );

    g2o::VertexSE3Expmap* pose = new g2o::VertexSE3Expmap();
    pose->setEstimate ( g2o::SE3Quat ( Tcw.rotation(), Tcw.translation() ) );
    pose->setId ( 0 );
    optimizer.addVertex ( pose );

    // 添加边
    int id=1;
    for ( Measurement m: measurements )
    {
        EdgeSE3ProjectDirect* edge = new EdgeSE3ProjectDirect (
            m.pos_world,
            K ( 0,0 ), K ( 1,1 ), K ( 0,2 ), K ( 1,2 ), gray
        );
        edge->setVertex ( 0, pose );
        edge->setMeasurement ( m.grayscale );
        edge->setInformation ( Eigen::Matrix<double,1,1>::Identity() );
        edge->setId ( id++ );
        optimizer.addEdge ( edge );
    }
    cout<<"edges in graph: "<<optimizer.edges().size() <<endl;
    optimizer.initializeOptimization();
    optimizer.optimize ( 30 );
    Tcw = pose->estimate();
}


半稠密


/********************************************
 * 本节演示了RGBD上的半稠密直接法 
 ********************************************/
	
	//这里的prev_color就是指第一帧,因为整个程序中,只在第一帧中对它赋值了,之后再也没动过
    //也就是还是整个过程的参考帧就只有第一帧,而不是循环流动用当前帧的上一帧做参考帧
    cv::Mat prev_color;
    // 我们以第一个图像为参考,对后续图像和参考图像做直接法
    for ( int index=0; index<10; index++ )
    {
        cout<<"*********** loop "<<index<<" ************"<<endl;
        fin>>time_rgb>>rgb_file>>time_depth>>depth_file;
        color = cv::imread ( path_to_dataset+"/"+rgb_file );
        depth = cv::imread ( path_to_dataset+"/"+depth_file, -1 );
        if ( color.data==nullptr || depth.data==nullptr )
            continue;

        //调用OpenCV内置的图像颜色空间转换函数cvtColor将RGB图像color转为灰度图gray,在之后的直接法求取位姿中使用灰度图作为每一帧图像输入。
        cv::cvtColor ( color, gray, cv::COLOR_BGR2GRAY );
        if ( index ==0 )
        {
            // select the pixels with high gradiants
            // 对第一帧提取FAST特征点
            for ( int x=10; x<gray.cols-10; x++ )
                for ( int y=10; y<gray.rows-10; y++ )
                {

                    //灰度值与像素值的关系: 如果对于一张本身就是灰度图像(8位灰度图像)来说,他的像素值就是它的灰度值,如果是一张彩色图像,则它的灰度值需要经过函数映射来得到。
                    //灰度图像是由纯黑和纯白来过渡得到的,在黑色中加入白色就得到灰色,纯黑和纯白按不同的比例来混合就得到不同的灰度值。
                    //R=G=B=255为白色,R=G=B=0为黑色,R=G=B=小于255的某个整数时,此时就为某个灰度值。

                    //这里就是梯度向量delta,可以看一下,
                    //它是以(x,y)像素右减左像素灰度差为x方向梯度值,
                    //  以(x,y)像素下减上像素灰度差为y方向梯度值。
                    //发现(x,y)处的像素梯度跟(x,y)处的灰度值没啥关系,只跟它的上下左右的像素有关

                    Eigen::Vector2d delta (
                        gray.ptr<uchar>(y)[x+1] - gray.ptr<uchar>(y)[x-1], 
                        gray.ptr<uchar>(y+1)[x] - gray.ptr<uchar>(y-1)[x]
                    );

                    //如果模长小于50,即任务就是梯度不明显,continue掉,其他的就开始对应深度和空间点,往measurements中push了
                    //说白了跟稀疏的比就是在第一帧中多取了一些点而已。稠密的就是不用说了,所有点全push进measurements
                    if ( delta.norm() < 50 )
                        continue;
                    ushort d = depth.ptr<ushort> (y)[x];
                    if ( d==0 )
                        continue;
                    Eigen::Vector3d p3d = project2Dto3D ( x, y, d, fx, fy, cx, cy, depth_scale );
                    float grayscale = float ( gray.ptr<uchar> (y) [x] );
                    measurements.push_back ( Measurement ( p3d, grayscale ) );
                }
            prev_color = color.clone();
            cout<<"add total "<<measurements.size()<<" measurements."<<endl;
            continue;
        }


直接法的讨论

相比于特征点法,直接法完全依靠优化来求解相机位姿。从式(8.16)中可以看到,像 素梯度引导着优化的方向。如果我们想要得到正确的优化结果,就必须保证大部分像素梯 度能够把优化引导到正确的方向。

举例:

假设对于参考图像,我们 测量到一个灰度值为 229 的像素。并且,由于我们知道它的深度,可以推断出空间点 P 的 位置(图 8-6 中在 I1 中测量到的灰度)

此时我们又得到了一张新的图像,需要估计它的相机位姿。这个位姿是由一个初值不 断地优化迭代得到的。假设我们的初值比较差,在这个初值下,空间点 P 投影后的像素灰度值是 126。于是,这个像素的误差为 229 − 126 = 103。为了减小这个误差,我们希望微调相机的位姿,使像素更亮一些。 怎么知道往哪里微调,像素会更亮呢?这就需要用到局部的像素梯度。我们在图像中 发现,沿 u 轴往前走一步,该处的灰度值变成了 123,即减去了 3。同样地,沿 v 轴往前 走一步,灰度值减 18,变成 108。在这个像素周围,我们看到梯度是 [−3, −18],为了提高 亮度,会建议优化算法微调相机,使 P 的像往左上方移动。在这个过程中,我们用像 素的局部梯度近似了它附近的灰度分布,不过请注意真实图像并不是光滑的,所以这个梯度在远处就不成立了。

但是,优化算法不能只听这个像素的一面之词,还需要听取其他像素的建议。综合听 取了许多像素的意见之后,优化算法选择了一个和我们建议的方向偏离不远的地方,计算出一个更新量 exp(ξ ∧)(扰动模型)。加上更新量后,图像从 I2 移动到了 I ′ 2,像素的投影位置也变到了 一个更亮的地方。我们看到,通过这次更新,误差变小了。在理想情况下,我们期望误差会不断下降,最后收敛。

**但是实际是不是这样呢?**我们是否真的只要沿着梯度方向走,就能走到一个最优值?注意到,直接法的梯度是直接由图像梯度确定的,因此我们必须保证沿着图像梯度走时,灰度误差会不断下降。然而,图像通常是一个很强烈的非凸函数,如图 8-7 所示。实际当中, 如果我们沿着图像梯度前进,很容易由于图像本身的非凸性(或噪声)落进一个局部极小值中,无法继续优化。只有当相机运动很小,图像中的梯度不会有很强的非凸性时,直接 法才能成立。 在例程中,我们只计算了单个像素的差异,并且这个差异是由灰度直接相减得到的。 然而,单个像素没有什么区分性,周围很可能有好多像素和它的亮度差不多。所以,我们有时会使用小的图像块(patch),并且使用更复杂的差异度量方式,例如归一化相关性 (Normalized Cross Correlation,NCC)等。(上述的代码实现里用的是误差的平方和)

在这里插入图片描述

直接法优缺点总结

优点如下:

  • 可以省去计算特征点、描述子的时间。
  • 只要求有像素梯度即可,无须特征点。因此,直接法可以在特征缺失的场合下使用。 比较极端的例子是只有渐变的一张图像。它可能无法提取角点类特征,但可以用直接 法估计它的运动。
  • 可以构建半稠密乃至稠密的地图,这是特征点法无法做到的。

另一方面,它的缺点也很明显:

  • 非凸性——直接法完全依靠梯度搜索,降低目标函数来计算相机位姿。其目标函数中 需要取像素点的灰度值,而图像是强烈非凸的函数。这使得优化算法容易进入极小, 只在运动很小时直接法才能成功。
  • 单个像素没有区分度。找一个和他像的实在太多了!——于是我们要么计算图像块, 要么计算复杂的相关性。由于每个像素对改变相机运动的“意见”不一致。只能少数 服从多数,以数量代替质量。
  • 灰度值不变是很强的假设。如果相机是自动曝光的,当它调整曝光参数时,会使得图像整体变亮或变暗。光照变化时亦会出现这种情况。特征点法对光照具有一定的容忍 性,而直接法由于计算灰度间的差异,整体灰度变化会破坏灰度不变假设,使算法失败。针对这一点,目前的直接法开始使用更细致的光度模型标定相机,以便在曝光时间变化时也能让直接法工作。

到此视觉里程计前端VO结束了

VSLAM(2022.10.2)

简单设计并实现VO

(从0到1搭建)

确定程序的框架

视觉里程计分单目、双目、RGB-D 三大类。单目视觉相对复杂,而 RGB-D 最为简单,没有初始化,也没有尺度问题。为了方便做实验,将使用数据集而非实际的 RGB-D 相机。

在编写一个小规模的库时,我们通常会建立一些文件夹,把源代码、头文件、文档、测试数据、配置文件、日志等等分类存放, 这样会显得很有条理。

  1. bin 用来存放可执行的二进制;
  2. include/myslam 存放 slam 模块的头文件,主要是.h。这种做法的理由是,当你把包 含目录设到 include 时,在引用自己的头文件时,需要写 include ”myslam/xxx.h”, 这样不容易和别的库混淆。
  3. src 存放源代码文件,主要是 cpp;
  4. test 存放测试用的文件,也是 cpp
  5. lib 存放编译好的库文件;
  6. config 存放配置文件;
  7. cmake_modules 第三方库的 cmake 文件,在使用 g2o 之类的库中会用到它。

确定基本数据结构

这好比构成房屋的一个个的柱子和砖块。那么,在一个 SLAM 程序中,有哪些结构是最基本的呢?我们抽象出几条基本概念:

  1. 帧:一个帧是相机采集到的图像单位。它主要包含一个图像(RGB-D 情形下是一对图像——>通常RGB图像和Depth图像是配准的,因而像素点之间具有一对一的对应关系。)。此外,还有特征点、位姿、内参等信息。 在视觉 SLAM 中我们会谈论关键帧(Key-frame)。由于相机采集的数据很多,存储 所有的数据显然是不现实的。不然的话,如果相机放在桌上不动,程序的内存占用也 会越来越高最后导致无法接受。通常的做法是把某些我们认为更重要的帧保存起来, 并认为相机轨迹就可以用这些关键帧来描述。

  2. 路标:路标点即图像中的特征点。当相机运动之后,还能估计它们的 3D 位置。 通常,会把路标点放在一个地图当中,并将新来的帧与地图中的路标点进行匹配,估 计相机位姿。 帧的位姿与路标的位置估计相当于一个局部的 SLAM 问题。

除此之外,我们还需要一 些工具,让程序写起来更流畅。

例如:

  1. 配置文件:在写程序中你会经常遇到各种各样的参数,比如相机的内参、特征点的数 量、匹配时选择的比例等等。你可以把这些数写在程序中,但那不是一个好习惯。你 会经常修改这些参数,但每次修改后都要重新编译一遍程序。当它们数量越来越多 时,修改就变得越来越困难。所以,更好的方式是在外部定义一个配置文件,程序运 行时读取该配置文件中的参数值。这样,每次只要修改配置文件内容就行了,不必对 程序本身做任何修改。
  2. 坐标变换:你会经常需要在坐标系间进行坐标变换。例如世界坐标到相机坐标、相机 坐标到归一化相机坐标、归一化相机坐标到像素坐标等等。定义一个类把这些操作都 放在一起将更方便些。

​ 所以下面我们就来定义帧、路标这几个概念,在 C++ 中都以类来表示。我们尽量保证 一个类有单独的头文件和源文件,避免把许多个类放在同一个文件中。然后,把函数声明 放在头文件,实现放在源文件里(除非函数很短,也可以写在头文件中)。在过程较为复杂的算法中,我们会把它分解成若干步骤,例如特征提取和匹配应该分别在不同的函数中实现,这样,当我们想修改算法流程时,就不需要修改整 个运行流程,只需调整局部的处理方式即可。

我们开始一共写五个类:Frame 为帧,Camera 为相机模型,MapPoint 为特征点/路标点,Map 管理特征点,Config 提供配置参数。

common_include.h

(只是一些常用头文件,省略)

camera.h

Camera 类存储相机的内参和外参,并完成相机坐标系、像素坐标系、和世界坐标系 之间的坐标变换。当然,在世界坐标系中需要一个相机的(变动的)外参,我们以参数 的形式传入

//ifndef 宏定义: 防止头文件重复引用的 。如果没有这个宏,在两处引用此头文件时将出现类的重复定义。
// #ifdef #define  #endif
#ifndef CAMERA_H
#define CAMERA_H

//把一些常用的头文件放在一个 common_include.h 文件中,这样就可以避免每次书写一个很长的一串 include。
#include "myslam/common_include.h"

//用命名空间 namespace myslam 将类定义包裹起来,
//命名空间可以防止我们不小心定义出别的库里同名的函数,也是一种比较安全和规范的做法.
namespace myslam
{

// Pinhole RGBD camera model
class Camera
{
public:
    //这是关于typedef的用法,定义新类型,在Camera类中不占据内存,只是定义了一个新类型,封装在了类中,便于使用。
    //在里程计中,会构建比较多的类,也就有相应的封在类中的类型,在初始化新类的时候调用该类型即可,使用方便。
    //shared_ptr是一种智能指针(smart pointer),作用如同指针,但会记录有多少个shared_ptrs共同指向一个对象。这便是所谓的引用计数(reference counting)。
    //每个智能指针离开之间的作用域之后,计数器就会递减。一旦最后一个这样的指针被销毁,也就是一旦某个对象的引用计数变为0,这个对象会被自动删除。
    typedef std::shared_ptr<Camera> Ptr;
    //相机内参   depth_scale_:深度比例(对后面获取某点的深度进行一个比例缩放)
    float   fx_, fy_, cx_, cy_, depth_scale_;  // Camera intrinsics


    Camera();
    //c++ 函数后面加一个冒号的含义: 冒号后面跟的是赋值,这种写法是C++的特性
    //构造函数后加冒号是初始化表达式
    Camera ( float fx, float fy, float cx, float cy, float depth_scale=0 ) :
        fx_ ( fx ), fy_ ( fy ), cx_ ( cx ), cy_ ( cy ), depth_scale_ ( depth_scale )
    {}

    // coordinate transform: world, camera, pixel
    //用 Sophus::SE3 来表达相机的位姿
    //p_w是世界坐标系坐标,p_c是相机坐标系的坐标,T_w_c是相机外参(R与t),p_p是像素坐标系坐标。
    //pixel2world的实现是能过两步实现的: pixel -> camera ,进而 camera -> world ,反之, world2pixel 也一样。
    Vector3d world2camera( const Vector3d& p_w, const SE3& T_c_w );
    Vector3d camera2world( const Vector3d& p_c, const SE3& T_c_w );
    Vector2d camera2pixel( const Vector3d& p_c );
    Vector3d pixel2camera( const Vector2d& p_p, double depth=1 ); 
    Vector3d pixel2world ( const Vector2d& p_p, const SE3& T_c_w, double depth=1 );
    Vector2d world2pixel ( const Vector3d& p_w, const SE3& T_c_w );

};

}
#endif // CAMERA_H

camera.cpp

#include "myslam/camera.h"

namespace myslam
{

Camera::Camera()
{
}

//世界to相机:w_to_c变换矩阵乘世界坐标系的点位
Vector3d Camera::world2camera ( const Vector3d& p_w, const SE3& T_c_w )
{
    return T_c_w*p_w;
}


//相机to世界:c_to_w变换矩阵乘相机坐标系的点位置
Vector3d Camera::camera2world ( const Vector3d& p_c, const SE3& T_c_w )
{
    return T_c_w.inverse() *p_c;
}


//相机to坐标系:书中公式5.5,p_c虽然是3*1矩阵,但如下输出没问题,和p_c(n)一样
Vector2d Camera::camera2pixel ( const Vector3d& p_c )
{
    return Vector2d (
        fx_ * p_c ( 0,0 ) / p_c ( 2,0 ) + cx_,
        fy_ * p_c ( 1,0 ) / p_c ( 2,0 ) + cy_
    );
}

//和上面相反
Vector3d Camera::pixel2camera ( const Vector2d& p_p, double depth )
{
    return Vector3d (
        ( p_p ( 0,0 )-cx_ ) *depth/fx_,
        ( p_p ( 1,0 )-cy_ ) *depth/fy_,
        depth
    );
}

//pixel2world的实现是能过两步实现的: pixel -> camera ,进而 camera -> world ,反之, world2pixel 也一样。
Vector2d Camera::world2pixel ( const Vector3d& p_w, const SE3& T_c_w )
{
    return camera2pixel ( world2camera ( p_w, T_c_w ) );
}

//p_p是像素坐标系坐标
Vector3d Camera::pixel2world ( const Vector2d& p_p, const SE3& T_c_w, double depth )
{
    return camera2world ( pixel2camera ( p_p, depth ), T_c_w );
}

}

frame.h

这里的 Frame 类只提供基本的 数据存储和接口。

#ifndef FRAME_H
#define FRAME_H

#include "myslam/common_include.h"
#include "myslam/camera.h"

namespace myslam 
{
    
// forward declare 前向声明
/*    1. 前向声明的类不能定义对象。

    2. 可以用于定义指向这个类型的指针和引用。

    2. 用于申明使用该类型作为形参或返回类型的函数。*/
class MapPoint;
//Frame 类只提供基本的数据存储和接口

class Frame
{
public:
    typedef std::shared_ptr<Frame> Ptr;
    unsigned long                  id_;         // ID id of this frame
    double                         time_stamp_; // 时间戳 when it is recorded
    SE3                            T_c_w_;      // 位姿 transform from world to camera
    Camera::Ptr                    camera_;     // 相机 Pinhole RGBD Camera model
    Mat                            color_, depth_; //彩色图和深度图 color and depth image
    
public: // data members 
    Frame();
    Frame( long id, double time_stamp=0, SE3 T_c_w=SE3(), Camera::Ptr camera=nullptr, Mat color=Mat(), Mat depth=Mat() );
    ~Frame();
    
    // factory function 创建 Frame
    //静态函数  从实现来看就是调用了默认构造函数创建了个空白帧对象,唯一赋值的参数就是给id_赋了个值 (具体见实现的cpp)
    static Frame::Ptr createFrame(); 
    
    // find the depth in depth map   在color图寻找给定点对应的深度
    double findDepth( const cv::KeyPoint& kp );
    
    // Get Camera Center 获取相机光心
    Vector3d getCamCenter() const;
    
    // check if a point is in this frame  判断某个点是否在帧内
    bool isInFrame( const Vector3d& pt_world );
};

}

#endif // FRAME_H

frame.cpp

#include "myslam/frame.h"

namespace myslam
{
Frame::Frame()
: id_(-1), time_stamp_(-1), camera_(nullptr)
{

}

Frame::Frame ( long id, double time_stamp, SE3 T_c_w, Camera::Ptr camera, Mat color, Mat depth )
: id_(id), time_stamp_(time_stamp), T_c_w_(T_c_w), camera_(camera), color_(color), depth_(depth)
{

}

Frame::~Frame()
{

}

Frame::Ptr Frame::createFrame()
{
    //这里注意下,由factory_id++一个数去构造Frame对象时,调用的是默认构造函数,由于默认构造函数全都有默认值,所以就是按坑填,先填第一个id_,
    //所以也就是相当于创建了一个只有ID号的空白帧。
    static long factory_id = 0;
    return Frame::Ptr( new Frame(factory_id++) );
}

//注意看这个函数参数类型是cv::KeyPoint&,所以也就是color图已经检测出keypoint了,需要去寻找depth
double Frame::findDepth ( const cv::KeyPoint& kp )
{
    //cvRound()对一个 float 型的数进行四舍五入
    int x = cvRound(kp.pt.x);
    int y = cvRound(kp.pt.y);
    //这个还是.ptr模板函数定位像素值的方法,记住用法
    //这里的 depth_.ptr(y)[x] 就是指向depth _ 的第y 行的第x个数据,数据类型为无符号的短整型。
    ushort d = depth_.ptr<ushort>(y)[x];
    if ( d!=0 )
    {
        //读取的深度除以相机的深度比例尺得到这个点在彩色图当中的深度
        return double(d)/camera_->depth_scale_;
    }
    else  //为0的情况呢?这是实际中有可能遇到的坑。因为rgbd相机极有可能某个点没采集到深度值
    {
        // check the nearby points
        //找最近的四个点 也就是上下左右 找到他们的深度计算然后返回
        int dx[4] = {-1,0,1,0};
        int dy[4] = {0,-1,0,1};
        for ( int i=0; i<4; i++ )
        {
            d = depth_.ptr<ushort>( y+dy[i] )[x+dx[i]];
            if ( d!=0 )
            {
                return double(d)/camera_->depth_scale_;
            }
        }
    }
    return -1.0;  //如果还没有,就返回-1.0,表示访问失败
}

//取相机光心的话,这里注意!.translation()是取平移部分!不是取转置!!T_c_w_.inverse()求出来的平移部分就是R^(-1)*(-t),也就是相机坐标系下的(0,0,0)在世界坐标系下的坐标,也就是相机光心的世界坐标!
Vector3d Frame::getCamCenter() const
{
    return T_c_w_.inverse().translation();
}


bool Frame::isInFrame ( const Vector3d& pt_world )
{
    Vector3d p_cam = camera_->world2camera( pt_world, T_c_w_ );
    if ( p_cam(2,0)<0 ) //小于0直接return false
        return false;
    Vector2d pixel = camera_->world2pixel( pt_world, T_c_w_ ); //世界坐标to像素坐标
    return pixel(0,0)>0 && pixel(1,0)>0  //xy值都大于0并且小于color图的行列 则在帧内
        && pixel(0,0)<color_.cols 
        && pixel(1,0)<color_.rows;
}

}

mappoint.h

MapPoint 表示路标点(特征点)。我们将估计它的世界坐标,并且我们会拿当前帧提取到的特征点与地图中的路标点匹配,来估计相机的运动,因此还需要存储它对应的描述子。此外, 我们会记录一个点被观测到的次数和被匹配到的次数,作为评价它的好坏程度的指标。

namespace myslam
{

//前向声明
class Frame;
class MapPoint
{
public:
    typedef shared_ptr<MapPoint> Ptr;
    unsigned long      id_; // ID
    Vector3d    pos_;       // 坐标
    Vector3d    norm_;      // 归一化后的,代表了方向
    Mat         descriptor_; // 描述子
    int         observed_times_;    // 被观测的次数
    int         correct_times_;     // 被正确匹配的次数
    
    MapPoint();
    MapPoint( long id, Vector3d position, Vector3d norm );
    
    // factory function 创建一个空白帧对象(创建特征点)
    static MapPoint::Ptr createMapPoint();
};
}

#endif // MAPPOINT_H

mappoint.cpp

内容简单省略

map.h

Map 类管理着所有的路标点,并负责添加新路标、删除不好的路标等工作。VO 的匹配过程只需要和 Map 打交道即可。

#include "myslam/common_include.h"
#include "myslam/frame.h"
#include "myslam/mappoint.h"
namespace myslam
{
class Map
{
public:
    typedef shared_ptr<Map> Ptr;
    unordered_map<unsigned long, MapPoint::Ptr >  map_points_;        // 所有特征点
    unordered_map<unsigned long, Frame::Ptr >     keyframes_;         // 所有关键帧

    Map() {}
    
    void insertKeyFrame( Frame::Ptr frame );
    void insertMapPoint( MapPoint::Ptr map_point );
};
}

#endif // MAP_H

map.cpp

#include "myslam/map.h"

namespace myslam
{

void Map::insertKeyFrame ( Frame::Ptr frame )
{
    cout<<"Key frame size = "<<keyframes_.size()<<endl;
    //可能是这个意思,我还在思考。。原理上应该是关键帧里没有,那么插入
    //根据帧的id去找这个帧的位置(序号)看是否与关键帧最后一位相等
    if ( keyframes_.find(frame->id_) == keyframes_.end() )
    {

        //如果相等,则以pair形式插入该帧,该pair包括id和frame
        //make_pair:无需写出类别, 就可以生成一个pair对象
        //直观一点:std::make_pair(42, '@'); 而不必费力写成:std::pair<int, char>(42, '@')
        keyframes_.insert( make_pair(frame->id_, frame) );
    }
    else
    {
        keyframes_[ frame->id_ ] = frame;
    }
}

void Map::insertMapPoint ( MapPoint::Ptr map_point )
{

    //地图点的插入  与上面思路一样
    if ( map_points_.find(map_point->id_) == map_points_.end() )
    {
        map_points_.insert( make_pair(map_point->id_, map_point) );
    }
    else 
    {
        map_points_[map_point->id_] = map_point;
    }
}
}

config.h

Config 类负责参数文件的读取,并在程序任意地方都可随时提供参数的值。

config.cpp

(主要是读一些参数 内参啊之类的 不重要 省略)

至此,我们定义了 SLAM 程序的基本数据结构,书写了若干个基本类。这好比是造房子的砖头和水泥。

特征提取与匹配

下面我们来实现 VO,先来考虑特征点法。它 VO任务是,根据输入的图像,计算相 机运动和特征点位置。前面我们都讨论的是在两两帧间的位姿估计,然而我们将发现仅凭两帧的估计是不够的。我们会把特征点缓存成一个小地图,计算当前帧与地图之间的位置关系。但那样程序会复杂一些,所以,先暂时从两两帧间的运动估计出发。

两两帧的视觉里程计

如果像前面讲的一样,只关心两个帧之间的运动估计,并且不优化特征点的位置。然 而把估得的位姿“串”起来,也能得到一条运动轨迹。这种方式可以看成两两帧间的,无结构的 VO,实现起来最为简单,但是效果不佳。但是我们现在就是从简单入手,感受一下。
在这里插入图片描述

在这种 VO 里,我们定义了参考帧(Reference)和当前帧(Current)这两个概念。以参考帧为坐标系,我们把当前帧与它进行特 征匹配,并估计运动关系。假设参考帧相对世界坐标的变换矩阵为 Trw,当前帧与世界坐 标系间为 Tcw,则待估计的运动与这两个帧的变换矩阵构成左乘关系:

​ Tcr, s.t. Tcw = TcrTrw.

在 t − 1 到 t 时刻,我们以 t − 1 为参考,求取 t 时刻的运动。这可以通过特征点匹 配、光流或直接法得到,但这里我们只关心运动,不关心结构。换句话说,只要通过特征点成功求出了运动,我们就不再需要这帧的特征点了。这种做法当然会有缺陷,但是忽略掉数量庞大的特征点可以节省许多的计算量。然后,在 t 到 t + 1 时刻,我们又以 t 时刻 为参考帧,考虑 t 到 t + 1 间的运动关系。如此往复,就得到了一条运动轨迹。

在匹配特征点的方式中,最重要的参考帧与当前帧之间 的特征匹配关系,它的流程可归纳如下:

  1. 对新来的当前帧,提取关键点和描述子。
  2. 如果系统未初始化,以该帧为参考帧,根据深度图计算关键点的 3D 位置,返回 1。
  3. 估计参考帧与当前帧间的运动。
  4. 判断上述估计是否成功。
  5. 若成功,把当前帧作为新的参考帧,回 1。
  6. 若失败,计连续丢失帧数。当连续丢失超过一定帧数,置 VO 状态为丢失,算法 结束。若未超过,返回 1。

下面是实现

visual_odometry.h

namespace myslam 
{
class VisualOdometry
{
public:
    typedef shared_ptr<VisualOdometry> Ptr;
    //定义枚举来表征VO状态,分别为:初始化、正常、丢失
    enum VOState {
        INITIALIZING=-1,
        OK=0,
        LOST
    };

    //这里为两两帧VO所用到的参考帧和当前帧。还有VO状态和整个地图。
    VOState     state_;     // 当前VO状态
    Map::Ptr    map_;       // 地图
    Frame::Ptr  ref_;       // 参考帧
    Frame::Ptr  curr_;      // 当前帧

    //这里是两帧匹配需要的:keypoints,descriptors,matches
    cv::Ptr<cv::ORB> orb_;  // orb的检测与计算
    vector<cv::Point3f>     pts_3d_ref_;        // 参考帧的3D点
    vector<cv::KeyPoint>    keypoints_curr_;    // 当前帧的特征点
    Mat                     descriptors_curr_;  // 当前帧的描述子
    Mat                     descriptors_ref_;   // 参考帧的描述子
    vector<cv::DMatch>      feature_matches_;   //特征匹配

    //这里是匹配结果T,还有内点数和丢失数
    SE3 T_c_r_estimated_;  // 当前帧的估计位姿
    int num_inliers_;        // 好的特征点数量
    int num_lost_;           // 丢失数
    
    // 参数
    int num_of_features_;   // 特征点数量
    double scale_factor_;   // 图像金字塔
    int level_pyramid_;     // 图像金字塔的层数
    float match_ratio_;      // 选择好的匹配的系数(匹配关系)
    int max_num_lost_;      // 持续丢失数量的最大值
    int min_inliers_;       // 最小内点数

    //用于判定是否为关键帧的标准,就是规定一定幅度的旋转和平移,大于这个幅度就归为关键帧
    double key_frame_min_rot;   // 两个关键帧之间的最小旋转
    double key_frame_min_trans; // 两个关键帧之间的最小平移
    
public: // functions 
    VisualOdometry();
    ~VisualOdometry();

    //这个函数为核心处理函数,将帧添加进来,然后处理
    bool addFrame( Frame::Ptr frame );      // 增加新的帧
    
protected:  
    // 内部处理函数(内点),用于特征匹配
    void extractKeyPoints(); //提取特征点
    void computeDescriptors();  //计算描述子
    void featureMatching(); //特征匹配
    void poseEstimationPnP();  //pnp位姿估计
    void setRef3DPoints(); // 设置参考帧的3D点

    //这里是关键帧的一些功能函数
    void addKeyFrame(); //增加关键帧
    bool checkEstimatedPose(); //根据内点(inlier)的数量及运动的大小做一个简单的检测:认为内点不可太少,而运动不可能过大。
    bool checkKeyFrame(); //检查关键帧
    
};
}

visual_odometry.cpp

namespace myslam
{

    //默认构造函数,提供默认值、读取配置参数
VisualOdometry::VisualOdometry() :
    state_ ( INITIALIZING ), ref_ ( nullptr ), curr_ ( nullptr ), map_ ( new Map ), num_lost_ ( 0 ), num_inliers_ ( 0 )
{
    num_of_features_    = Config::get<int> ( "number_of_features" );
    scale_factor_       = Config::get<double> ( "scale_factor" );
    level_pyramid_      = Config::get<int> ( "level_pyramid" );
    match_ratio_        = Config::get<float> ( "match_ratio" );
    max_num_lost_       = Config::get<float> ( "max_num_lost" );
    min_inliers_        = Config::get<int> ( "min_inliers" );
    key_frame_min_rot   = Config::get<double> ( "keyframe_rotation" );
    key_frame_min_trans = Config::get<double> ( "keyframe_translation" );

    //这个create(),之前用的时候,都是用的默认值,所以没有任何参数,这里传入了一些参数,
    orb_ = cv::ORB::create ( num_of_features_, scale_factor_, level_pyramid_ );
}

VisualOdometry::~VisualOdometry()
{

}

//最核心的函数:添加帧,参数即为新的一帧,根据VO当前状态选择是进行初始化还是计算T
bool VisualOdometry::addFrame ( Frame::Ptr frame )
{
    //根据VO状态来进行不同处理
    switch ( state_ )
    {
        //第一帧,则进行初始化
    case INITIALIZING:
    {
        //更改状态为OK
        state_ = OK;
        //因为是初始化,所以当前帧和参考帧都是这个第一帧
        curr_ = ref_ = frame;
        //将此帧插入地图
        map_->insertKeyFrame ( frame );
        // 从这一帧中提取特征点
        extractKeyPoints();
        //计算描述子
        computeDescriptors();
        // compute the 3d position of features in ref frame
        //这里提取出的特征点姚形成3D坐标,所以要调用set函数去求关键点的深度
        setRef3DPoints();
        break;
    }
    //如果VO状态为正常,则匹配并调用PnP()计算T
    case OK:
    {
        curr_ = frame;
        extractKeyPoints();
        computeDescriptors();
        featureMatching();
        //位姿估计
        poseEstimationPnP();

        //根据位姿估计的结果进行检验
        if ( checkEstimatedPose() == true ) // a good estimation
        {
            //如果是一个好的估计(检查内点和运动之后),计算当前位姿
            curr_->T_c_w_ = T_c_r_estimated_ * ref_->T_c_w_;  // T_c_w = T_c_r*T_r_w
            //把当前帧赋值给参考帧,接下来,它将作为下一帧的参考帧
            ref_ = curr_;
            //补全参考帧的深度值,求参考帧的3D点
            setRef3DPoints();
            num_lost_ = 0;
            //再检验是否为关键帧,是的话就加入关键帧(两帧之间的旋转和平移)
            if ( checkKeyFrame() == true ) // is a key-frame
            {
                addKeyFrame();
            }
        }
        else // 否则就是坏的估计(原因有很多)
        {
            //坏的估计则将丢失数+1
            num_lost_++;
            //判断是否大于这个最大丢失数,如果是,则切换VO状态为lost
            if ( num_lost_ > max_num_lost_ )
            {
                state_ = LOST;
            }
            return false;
        }
        break;
    }
    case LOST:
    {
        cout<<"vo has lost."<<endl;
        break;
    }
    }

    return true;
}

//提取特征点
void VisualOdometry::extractKeyPoints()
{
    orb_->detect ( curr_->color_, keypoints_curr_ );
}

//计算描述子
void VisualOdometry::computeDescriptors()
{
    orb_->compute ( curr_->color_, keypoints_curr_, descriptors_curr_ );
}

//特征匹配
void VisualOdometry::featureMatching()
{
    // 匹配参考帧和当前帧的描述子 使用暴力匹配
    vector<cv::DMatch> matches;
    cv::BFMatcher matcher ( cv::NORM_HAMMING );
    matcher.match ( descriptors_ref_, descriptors_curr_, matches );
    //寻找最小距离
    //总之这的作用就是找到matches数组中最小的距离,然后赋值给min_dis
    //std::min_element 用于寻找范围内的最小元素
    float min_dis = std::min_element (
                        matches.begin(), matches.end(),
                        [] ( const cv::DMatch& m1, const cv::DMatch& m2 )
    {
        return m1.distance < m2.distance;
    } )->distance;

    //根据最小距离,对matches数组进行刷选,只有小于最小距离匹配比率或者小于30的才能push_back进数组。
    //最终得到筛选过的,距离控制在一定范围内的可靠匹配
    feature_matches_.clear();
    for ( cv::DMatch& m : matches )
    {
        if ( m.distance < max<float> ( min_dis*match_ratio_, 30.0 ) )
        {
            feature_matches_.push_back(m);
        }
    }
    cout<<"good matches: "<<feature_matches_.size()<<endl;
}

//新的帧来的时候,是一个2D数据,因为PNP需要的是参考帧的3D,当前帧的2D。
    //所以在当前帧迭代为参考帧时,有个工作就是加上depth数据。也就是设置参考帧的3D点。
void VisualOdometry::setRef3DPoints()
{
    // select the features with depth measurements
    //3D点数组先清空,后面重新装入
    pts_3d_ref_.clear();
    //参考帧的描述子也是构建一个空Mat
    descriptors_ref_ = Mat();
    //对当前的特征点数组进行遍历
    for ( size_t i=0; i<keypoints_curr_.size(); i++ )
    {
        //找到对应的深度数据赋值给d
        double d = ref_->findDepth(keypoints_curr_[i]);
        //如果大于0,则正确,进行构造
        if ( d > 0)
        {
            //由像素坐标求得相机下的3D坐标
            Vector3d p_cam = ref_->camera_->pixel2camera(
                Vector2d(keypoints_curr_[i].pt.x, keypoints_curr_[i].pt.y), d
            );
            //坐标是列向量,所以按行push_back近参考帧的3D点
            pts_3d_ref_.push_back( cv::Point3f( p_cam(0,0), p_cam(1,0), p_cam(2,0) ));
            //参考帧描述子这里就按照当前帧描述子按行push_back。算出来的Mat类型的描述子,读取时需要遍历行。
            descriptors_ref_.push_back(descriptors_curr_.row(i));
        }
    }
}

//核心:用PnP估计位姿
void VisualOdometry::poseEstimationPnP()
{
    // 3D  2D
    vector<cv::Point3f> pts3d;
    vector<cv::Point2f> pts2d;
    
    for ( cv::DMatch m:feature_matches_ )
    {
        //pts_3d_ref_本来就是3dpoint数组,所以直接定位索引就是3d点了
        pts3d.push_back( pts_3d_ref_[m.queryIdx] );
        //而这里keypoints_curr_是keypoint数组,所以定位索引后类型是keypoint,还需一步.pt获取关键点像素坐标。
        pts2d.push_back( keypoints_curr_[m.trainIdx].pt );
    }

    //构造内参矩阵K
    Mat K = ( cv::Mat_<double>(3,3)<<
        ref_->camera_->fx_, 0, ref_->camera_->cx_,
        0, ref_->camera_->fy_, ref_->camera_->cy_,
        0,0,1
    );

    //旋转向量,平移向量,内点数组
    Mat rvec, tvec, inliers;
    //整个核心就是用这个cv::solvePnPRansac()去求解两帧之间的位姿变化
    cv::solvePnPRansac( pts3d, pts2d, K, Mat(), rvec, tvec, false, 100, 4.0, 0.99, inliers );
    //内点数量为内点行数,所以为列存储。
    num_inliers_ = inliers.rows;
    cout<<"pnp inliers: "<<num_inliers_<<endl;
    //根据旋转和平移构造出当前帧相对于参考帧的T,这样一个T计算完成了。循环计算就能得到轨迹。
    T_c_r_estimated_ = SE3(
        SO3(rvec.at<double>(0,0), rvec.at<double>(1,0), rvec.at<double>(2,0)), 
        Vector3d( tvec.at<double>(0,0), tvec.at<double>(1,0), tvec.at<double>(2,0))
    );
}

//简单的位姿检验函数  分为两点:内点不能过少,运动不能过大
bool VisualOdometry::checkEstimatedPose()
{

    //这里简单的做一下位姿估计判断,主要有两个,一就是匹配点太少的话,直接false,
    // 或者变换向量模长太大的话,也直接false
    if ( num_inliers_ < min_inliers_ )
    {
        cout<<"reject because inlier is too small: "<<num_inliers_<<endl;
        return false;
    }

    //将变换矩阵取log操作得到变换向量。
    Sophus::Vector6d d = T_c_r_estimated_.log();
    //根据变换向量的模长来判断运动的大小。过大的话返回false
    if ( d.norm() > 5.0 )
    {
        cout<<"reject because motion is too large: "<<d.norm()<<endl;
        return false;
    }
    //如果让面两项都没return,说明内点量不少,运动也没过大,是一个好的位姿估计,return true
    return true;
}

bool VisualOdometry::checkKeyFrame()
{
    //说一下这个是否为关键帧的判断,也很简单,
    //关键帧并不是之前理解的轨迹比较长了,隔一段选取一个,
    //而还是每一帧的T都判断一下,比较大就说明为关键帧,说明在这一帧中,要么平移比较大,要么拐弯导致旋转比较大,所以添加,
    //如果在运动上一直就是小运动,运动多久都不会添加为关键帧。
    //另外上方的判断T计算错误也是运动过大,但是量级不一样,判断计算错误是要大于5,
    //而关键帧,在配置文件中看只需要0.1就定义为关键帧了,所以0.1到5的差距,导致这两个函数并不冲突
    Sophus::Vector6d d = T_c_r_estimated_.log();
    Vector3d trans = d.head<3>();
    Vector3d rot = d.tail<3>();
    if ( rot.norm() >key_frame_min_rot || trans.norm() >key_frame_min_trans )
        return true;
    return false;
}

void VisualOdometry::addKeyFrame()
{
    //关键帧添加,直接调用insertKeyFrame()将当前帧插入就好了。
    cout<<"adding a key-frame"<<endl;
    map_->insertKeyFrame ( curr_ );
}
}

测试程序run_vo

int main ( int argc, char** argv )
{
    //terminal运行时需要添加参数文件命令行,这里加一步判断
    //运行:bin/run_vo config/default.yaml
    if ( argc != 2 )
    {
        cout<<"usage: run_vo parameter_file"<<endl;
        return 1;
    }

    //读取参数文件并设置
    myslam::Config::setParameterFile ( argv[1] );
    //构造VO,类型就是在VisualOdometry类中定义的指向自身类型的指针,然后用New开辟内存
    myslam::VisualOdometry::Ptr vo ( new myslam::VisualOdometry );

    //读取数据集文件地址
    string dataset_dir = myslam::Config::get<string> ( "dataset_dir" );
    cout<<"dataset: "<<dataset_dir<<endl;

    //读取数据文件中的associate.txt文件
    ifstream fin ( dataset_dir+"/associate.txt" );
    //没读取成功则输出
    if ( !fin )
    {
        cout<<"please generate the associate file called associate.txt!"<<endl;
        return 1;
    }

    //定义图片名数组和时间戳数组,用于存放associate.txt文件中所示的时间戳对齐的RGB图像和depth图像
    vector<string> rgb_files, depth_files;
    vector<double> rgb_times, depth_times;
    //循环读取直至文件的末尾
    while ( !fin.eof() )
    {
        string rgb_time, rgb_file, depth_time, depth_file;
        fin>>rgb_time>>rgb_file>>depth_time>>depth_file;

        //push.back()进各个数组 atof是讲一个单字节字符串转换成一个浮点数
        rgb_times.push_back ( atof ( rgb_time.c_str() ) );
        depth_times.push_back ( atof ( depth_time.c_str() ) );
        rgb_files.push_back ( dataset_dir+"/"+rgb_file );
        depth_files.push_back ( dataset_dir+"/"+depth_file );

        //.good()返回是否读取到文件末尾,文件末尾处此函数会返回false。所以跳出循环
        if ( fin.good() == false )
            break;
    }

    //创建相机
    myslam::Camera::Ptr camera ( new myslam::Camera );
    
    // visualization可视化
    //可视化内容,用到opencv中的viz模块

    //1. 创建一个可视化窗口,参数为窗口名称
    cv::viz::Viz3d vis("Visual Odometry");
    //2. 这里是创建了两个做坐标系,以widget部件类型存在  一个是世界坐标系,一个是相机坐标系
    //构造参数就是坐标系的长度
    cv::viz::WCoordinateSystem world_coor(1.0), camera_coor(0.5);
    //这里设置坐标系部件属性,然后添加到视图窗口上去
    //首先利用setRenderingProperty()函数设置渲染属性,
    // 第一个参数是个枚举,对应要渲染的属性这里是线宽,后面是属性值
    world_coor.setRenderingProperty(cv::viz::LINE_WIDTH, 2.0);
    camera_coor.setRenderingProperty(cv::viz::LINE_WIDTH, 1.0);
    //显示在窗口中  "动的坐标系"
    vis.showWidget( "World", world_coor );
    vis.showWidget( "Camera", camera_coor );

    /**
     至此,窗口中已经显示了全部需要显示的东西,就是两个坐标系:世界坐标系,相机坐标系。
    世界坐标系就是写死不动的了,所以后面也没有再提起过世界坐标系。需要做的就是计算出各个帧的相机坐标系位置
    后续的核心就是下面的for循环,在循环中,不断的给相机坐标系设置新的pose,然后达到动画的效果。
     **/



    //3. 设置视角。这步是非必要步骤,进行设置有利于观察,
    //不设置也会有默认视角,就是可能比较别扭。而且开始后拖动鼠标,也可以改变观察视角。
    //构建三个3D点,这里其实就是构造makeCameraPose()函数需要的三个向量:
    //相机位置坐标、相机焦点坐标、相机y轴朝向
    //蓝色-Z,红色-X,绿色-Y
    cv::Point3d cam_pos( 0, -1.0, -1.0 ), cam_focal_point(0,0,0), cam_y_dir(0,1,0);

    //由这三个参数,用makeCameraPose()函数构造Affine3d类型的相机位姿,这里其实是视角位姿,也就是程序开始时处在什么视角看
    cv::Affine3d cam_pose = cv::viz::makeCameraPose( cam_pos, cam_focal_point, cam_y_dir );
    //用setViewerPose()设置观看视角
    vis.setViewerPose( cam_pose );


    //输出RGB图像信息,共读到多少文件数
    cout<<"read total "<<rgb_files.size() <<" entries"<<endl;
    //因为整个可视化要呈现动态更新,所以用for
    for ( int i=0; i<rgb_files.size(); i++ )
    {
        //读取图像
        Mat color = cv::imread ( rgb_files[i] );
        Mat depth = cv::imread ( depth_files[i], -1 );
        if ( color.data==nullptr || depth.data==nullptr )
            break;
        //创建帧
        myslam::Frame::Ptr pFrame = myslam::Frame::createFrame();
        pFrame->camera_ = camera;
        pFrame->color_ = color;
        pFrame->depth_ = depth;
        pFrame->time_stamp_ = rgb_times[i];

        //这里加个每帧的运算时间,看看实时性
        boost::timer timer;
        //这里加个每帧的运算时间,看看实时性
        vo->addFrame ( pFrame );
        cout<<"VO costs time: "<<timer.elapsed()<<endl;

        //VO丢失
        if ( vo->state_ == myslam::VisualOdometry::LOST )
            break;

        //这里要说一下,可视化窗口中动的是相机坐标系,所以本质上是求取相机坐标系下的点在世界坐标系下的坐标,
        //Pw=Twc*Pc
        SE3 Tcw = pFrame->T_c_w_.inverse();
        
        // show the map and the camera pose
        //用Twc构造Affine3d类型的pose所需要的旋转矩阵和平移矩阵
        //这里为啥要构造成Affine3d类型呢,因为在上面第3步我们需要构造视角位姿(大概是这么个用法 在viz中显示)
        /**
         * 构造位姿: cv::Affine3d M(旋转矩阵,平移向量);
         */
        cv::Affine3d M(
            cv::Affine3d::Mat3( 
                Tcw.rotation_matrix()(0,0), Tcw.rotation_matrix()(0,1), Tcw.rotation_matrix()(0,2),
                Tcw.rotation_matrix()(1,0), Tcw.rotation_matrix()(1,1), Tcw.rotation_matrix()(1,2),
                Tcw.rotation_matrix()(2,0), Tcw.rotation_matrix()(2,1), Tcw.rotation_matrix()(2,2)
            ), 
            cv::Affine3d::Vec3(
                Tcw.translation()(0,0), Tcw.translation()(1,0), Tcw.translation()(2,0)
            )
        );

        //两个窗口同时显示,一个是图像
        cv::imshow("image", color );
        cv::waitKey(1);

        //一个是viz中对应的坐标系变换
        vis.setWidgetPose( "Camera", M);
        vis.spinOnce(1, false);

    }

    return 0;
}

总结

至此,我们实现了一个简单的两两帧间的视觉里程计,然而不管从速度还是精度上来 说,它的效果都不理想。似乎这种看似简单的思路并不能得到很好的结果。

我们来考虑一 下有哪几种可能的原因:

  1. 在位姿估计时,我们使用了 RANSAC 求出的 PnP 解。由于 RANSAC 只采用少数 几个随机点来计算 PnP,虽然能够确定 inlier,但该方法容易受噪声影响。在 3D-2D 点存在噪声的情形下,我们要用 RANSAC 的解作为初值,再用非线性优化求一个最 优值。下一节将说明这种做法是优于现在的做法的。

  2. 由于现在的 VO 是无结构的,特征点的 3D 位置被当作真值来估计运动。但实际上, RGB-D 的深度图必定带有一定的误差,特别是那些深度过近或过远的地方。并且,由 于特征点往往位于物体的边缘处,那些地方的深度测量值通常是不准确的。所以现在 的做法不够精确,我们需要把特征点也放在一起优化。

  3. 只考虑参考帧/当前帧的方式,一方面使得位姿估计过于依赖参考帧。如果参考帧质 量太差,比如出现严重遮挡、光照变化的情况下,跟踪容易会丢失。并且,当参考帧 位姿估计不准时,还会出现明显的漂移。另一方面,仅使用两帧数据显然没有充分地 利用所有的信息。更自然的方式是比较当前帧和地图,而不是比较当前帧和参考帧。 于是,我们要关心如何把当前帧与地图进行匹配,以及如何优化地图点的问题。

  4. 由于输出了各步骤的运行时间,我们可以对计算量有一个大概的了解,特征点的提取和匹配占据了绝大多数的计算时间,而看似复杂的 PnP 优 化,计算量与之相比基本可以忽略。因此,如何提高特征提取、匹配算法的速度,将是特征点方法的一个重要的主题。一种可预见的方式是使用直接法/光流,可有效地避开繁重的特征计算工作。
    cv::Affine3d::Mat3(
    Tcw.rotation_matrix()(0,0), Tcw.rotation_matrix()(0,1), Tcw.rotation_matrix()(0,2),
    Tcw.rotation_matrix()(1,0), Tcw.rotation_matrix()(1,1), Tcw.rotation_matrix()(1,2),
    Tcw.rotation_matrix()(2,0), Tcw.rotation_matrix()(2,1), Tcw.rotation_matrix()(2,2)
    ),
    cv::Affine3d::Vec3(
    Tcw.translation()(0,0), Tcw.translation()(1,0), Tcw.translation()(2,0)
    )
    );

     //两个窗口同时显示,一个是图像
     cv::imshow("image", color );
     cv::waitKey(1);
    
     //一个是viz中对应的坐标系变换
     vis.setWidgetPose( "Camera", M);
     vis.spinOnce(1, false);
    

    }

    return 0;
    }




## 总结

至此,我们实现了一个简单的两两帧间的视觉里程计,然而不管从速度还是精度上来 说,它的效果都不理想。似乎这种看似简单的思路并不能得到很好的结果。

我们来考虑一 下有哪几种可能的原因: 

1. 在位姿估计时,我们使用了 RANSAC 求出的 PnP 解。由于 RANSAC 只采用少数 几个随机点来计算 PnP,虽然能够确定 inlier,但该方法容易受噪声影响。在 3D-2D 点存在噪声的情形下,我们要用 RANSAC 的解作为初值,再用非线性优化求一个最 优值。下一节将说明这种做法是优于现在的做法的。 
2. 由于现在的 VO 是无结构的,特征点的 3D 位置被当作真值来估计运动。但实际上, RGB-D 的深度图必定带有一定的误差,特别是那些深度过近或过远的地方。并且,由 于特征点往往位于物体的边缘处,那些地方的深度测量值通常是不准确的。所以现在 的做法不够精确,我们需要把特征点也放在一起优化。
3. 只考虑参考帧/当前帧的方式,一方面使得位姿估计过于依赖参考帧。如果参考帧质 量太差,比如出现严重遮挡、光照变化的情况下,跟踪容易会丢失。并且,当参考帧 位姿估计不准时,还会出现明显的漂移。另一方面,仅使用两帧数据显然没有充分地 利用所有的信息。更自然的方式是比较当前帧和地图,而不是比较当前帧和参考帧。 于是,我们要关心如何把当前帧与地图进行匹配,以及如何优化地图点的问题。
4. 由于输出了各步骤的运行时间,我们可以对计算量有一个大概的了解,特征点的提取和匹配占据了绝大多数的计算时间,而看似复杂的 PnP 优 化,计算量与之相比基本可以忽略。因此,如何提高特征提取、匹配算法的速度,将是特征点方法的一个重要的主题。一种可预见的方式是使用直接法/光流,可有效地避开繁重的特征计算工作。
  • 15
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值