零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】

写在前边的话

该文章将介绍ORB-SLAM2源代码中特征提取部分的主题内容,涉及整体流程、原理简介、源代码讲解,那我们开始吧!
强烈建议配合B站视频食用,效果更更佳!也是我自己做der:
正在刷夜的李哈哈B站主页
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第1张图片

框架梳理

mono_kitti.cc 1.0

我们采用mono_kitti.cc来开始程序,也就是针对单目kitti数据集来研究。
进入main函数,定义了两个容器来存放每张图片的路径和时间戳:

// Retrieve paths to images
vector<string> vstrImageFilenames; //图像序列中每张图片存放路径
vector<double> vTimestamps;        //图像序列中每张图片时间戳

然后执行LoadImages函数:

//!这是一个非常关键的读文件函数
//argv[3]存放的是图像序列的存放路径,是从外界输入的路径
LoadImages(string(argv[3]), vstrImageFilenames, vTimestamps);

该函数也在mono_kitti.cc文件中,函数定义如下:

//以下为LoadImages函数定义,这是该文件夹唯一的函数
// 函数定义:获取图像序列中每一张图像的访问路径和时间戳
void LoadImages(const string &strPathToSequence, vector<string> &vstrImageFilenames, vector<double> &vTimestamps)
{
    // step 1 读取时间戳文件
    ifstream fTimes; //定义一个输入流fTimes来读取文件中的时间戳(ifstream用来读取)
    //调用数据集中的时间戳文件
    string strPathTimeFile = strPathToSequence + "/times.txt"; //strPathTimeFile是一个路径,末位追踪到time.txt

    //c_str是string类的一个函数,可以把string类型变量转换成char*变量
    //open()要求的是一个char*字符串
    //当文件名是string时需要转换,当文件名是字符数组型时就不需要此转换
    fTimes.open(strPathTimeFile.c_str()); //使用输入流读取文件,调用了open()函数
    while (!fTimes.eof())                 //.eof()函数判断文件夹是否读到最后
    {
        string s;
        getline(fTimes, s); //自动读取下一行时间戳文件
        // 如果该行字符串不是空的,就写进流,读成double类型放入vector(用来改变数据类型)
        if (!s.empty())
        {
            //流对象,用于流的输入输出
            stringstream ss;
            ss << s;
            double t;
            ss >> t;
            // 保存时间戳
            vTimestamps.push_back(t);
            //!至此,vTimestamps储存了所有的时间戳
        }
    }

    // step 1 使用左目图像, 生成左目图像序列中的每一张图像的文件名
    //组装mono_kitti数据集中image_0目录的路径
    string strPrefixLeft = strPathToSequence + "/image_0/";

    const int nTimes = vTimestamps.size(); //有多少个时间戳
    vstrImageFilenames.resize(nTimes);     //重定义有多少个时间戳就有多少个图像,保持维度的一致性,但是之前也没规定有多少少图像

    for (int i = 0; i < nTimes; i++)
    {
        stringstream ss;
        //std::setw :需要填充多少个字符,默认填充的字符为' '空格
        //std::setfill:设置std::setw将填充什么样的字符,如:std::setfill('*')
        //ss总共为6位,i之外的前边几位用0来填充,得到的结果为000001 000099之类
        ss << setfill('0') << setw(6) << i; //填6个0,如果来了个i,则代替0的位置,也就是总共有6位,末尾是i,其他用0填充
        vstrImageFilenames[i] = strPrefixLeft + ss.str() + ".png";
        //!至此,组装形成包含图像路径和编号的vector
    }
}

执行完该函数后,成功将每张图片的路径及时间戳读取到刚刚定义的两个vector容器中
main函数继续执行,定义了变量nImages来存储有多少个图片:

int nImages = vstrImageFilenames.size(); //有nImages个图片

main函数继续执行,实例化一个SLAM对象:

//!实例化SLAM对象
// Create SLAM system. It initializes all system threads and gets ready to process frames.
//argv1是词袋地址,argv2是配置文件的地址,第三个参数是传感器类型,第四个参数为是否选择使用可视化界面
//argv[1]为vocfile 里边存储的是词汇
//argv[2]为settingfile 里边存储摄像机校准和畸变参数和ORB相关参数
//这里创建了System类型的SLAM对象,SLAM构造函数中初始化了系统所有线程和相关参数,并准备好处理帧,代码留待后边详细分析
//读入词包路径,读入YAML配置文件,设置SLAM为mono状态,启用viewer的线程简要说明
ORB_SLAM2::System SLAM(argv[1], argv[2], ORB_SLAM2::System::MONOCULAR, true);

这里说是实例化SLAM对象,其实是指创建了一个System类的对象,并取名为SLAM,该类的定义在System.cc文件

System.cc 1.0

打开该文件,映入眼帘的是System类的有参构造函数:

namespace ORB_SLAM2
{
    //!系统的构造函数,将会启动其他的线程
    System::System(const string &strVocFile,      //词典文件路径
                   const string &strSettingsFile, //配置文件路径
                   const eSensor sensor,          //传感器类型
                   const bool bUseViewer)         //是否使用可视化界面
        :                                         //下方为成员变量默认初始化内容
          mSensor(sensor),                        //初始化传感器类型
          mpViewer(static_cast<Viewer *>(NULL)),  //空。。。对象指针?  TODO
          mbReset(false),                         //无复位标志
          mbActivateLocalizationMode(false),      //没有这个模式转换标志
          mbDeactivateLocalizationMode(false)     //没有这个模式转换标志
    {
        //!这里是构造函数的具体内容

可以看到,这里有一个叫做ORB_SLAM2的名称空间,仔细翻看各个源文件,发现都是在开头有个这样的名称空间,可以先认为就是一个大名,我还不知道有什么特别的用处?
在该构造函数中,依次完成了如下步骤:
①在终端输出欢迎信息
②在终端输出传感器类型
③读取词袋,存入在System类中定义的ORBVocabulary类的类指针==*mpVocabulary==,该定义在System.h中,读取语句如下:

mpVocabulary = new ORBVocabulary(); //来自第三方库,暂时不需要很详细的了解

④利用词袋初始化关键帧数据库,存入关键帧数据库指针mpKeyFrameDatabase
⑤创建一个Map类,存储指向所有关键帧和数据点的指针mpMap
⑥创建两个窗口,用来绘制帧和地图mpFrameDrawer``mpMapDrawer
⑦初始化Tracking线程
⑧初始化Local Mapping线程
⑨初始化Loop Closing线程
⑩初始化Viewer线程
⑪在不同线程间设置指针,创建资源联系
首先要从Tracking线程讲起,在该构造函数的第⑦步中,相关代码如下:

//在本主进程中初始化追踪线程
        //Initialize the Tracking thread
        //(it will live in the main thread of execution, the one that called this constructor)
        //这里初始化的Tracking线程是在main线程中运行的(其实也就是main线程),所以不需要调用new thread来创建
        //!这里是重点,Tracking函数
        mpTracker = new Tracking(this,               //现在还不是很明白为什么这里还需要一个this指针  TODO
                                 mpVocabulary,       //字典
                                 mpFrameDrawer,      //帧绘制器
                                 mpMapDrawer,        //地图绘制器
                                 mpMap,              //地图
                                 mpKeyFrameDatabase, //关键帧地图
                                 strSettingsFile,    //设置文件路径
                                 mSensor);           //传感器类型iomanip

这里根据类Tracking的有参构造函数,传入了一系列参数,比如刚刚提到的词袋指针mpVocabulary
进入类Tracking的定义,跳转到定义该类的Tracking.cc文件

Tracking.cc 1.0

这里看似一个函数,其实完成了一系列操作,构造函数代码如下:

//Tracking类的构造函数
Tracking::Tracking(
   System *pSys,                 //系统实例
    ORBVocabulary *pVoc,          //BOW字典
    FrameDrawer *pFrameDrawer,    //帧绘制器
    MapDrawer *pMapDrawer,        //地图点绘制器
    Map *pMap,                    //地图句柄
    KeyFrameDatabase *pKFDB,      //关键帧产生的词袋数据库
    const string &strSettingPath, //配置文件路径
    const int sensor)
    :                        //传感器类型
    mState(NO_IMAGES_YET), //当前系统还没有准备好
    mSensor(sensor),
    mbOnlyTracking(false), //处于SLAM模式
    mbVO(false),           //当处于纯跟踪模式的时候,这个变量表示了当前跟踪状态的好坏
    mpORBVocabulary(pVoc),
    mpKeyFrameDB(pKFDB),
    mpInitializer(static_cast<Initializer *>(NULL)), //暂时给地图初始化器设置为空指针
    mpSystem(pSys),
    mpViewer(NULL), //注意可视化的查看器是可选的,因为ORB-SLAM2最后是被编译成为一个库,所以对方人拿过来用的时候也应该有权力说我不要可视化界面(何况可视化界面也要占用不少的CPU资源)
    mpFrameDrawer(pFrameDrawer),
    mpMapDrawer(pMapDrawer),
    mpMap(pMap),
    mnLastRelocFrameId(0) //恢复为0,没有进行这个过程的时候的默认值
{
    // Load camera parameters from settings file
    // Step 1 从配置文件中加载相机参数
    cv::FileStorage fSettings(strSettingPath, cv::FileStorage::READ);
    float fx = fSettings["Camera.fx"];
    float fy = fSettings["Camera.fy"];
    float cx = fSettings["Camera.cx"];
    float cy = fSettings["Camera.cy"];

    //     |fx  0   cx|
    // K = |0   fy  cy|
    //     |0   0   1 |
    //构造相机内参矩阵
    cv::Mat K = cv::Mat::eye(3, 3, CV_32F);
    K.at<float>(0, 0) = fx;
    K.at<float>(1, 1) = fy;
    K.at<float>(0, 2) = cx;
    K.at<float>(1, 2) = cy;
    K.copyTo(mK);

    // 图像矫正系数,读取畸变参数
    // [k1 k2 p1 p2 k3]
    cv::Mat DistCoef(4, 1, CV_32F);
    DistCoef.at<float>(0) = fSettings["Camera.k1"];
    DistCoef.at<float>(1) = fSettings["Camera.k2"];
    DistCoef.at<float>(2) = fSettings["Camera.p1"];
    DistCoef.at<float>(3) = fSettings["Camera.p2"];
    const float k3 = fSettings["Camera.k3"];
    //有些相机的畸变系数中会没有k3项
    if (k3 != 0)
    {
            DistCoef.resize(5);
            DistCoef.at<float>(4) = k3;
    }
    //畸变参数拷贝给成员变量
    DistCoef.copyTo(mDistCoef);

    // 双目摄像头baseline * fx 50
    mbf = fSettings["Camera.bf"];

    float fps = fSettings["Camera.fps"];
    if (fps == 0)
            fps = 30;

    // Max/Min Frames to insert keyframes and to check relocalisation
    mMinFrames = 0;
    mMaxFrames = fps;

    //输出
    cout << endl
            << "Camera Parameters: " << endl;
    cout << "- fx: " << fx << endl;
    cout << "- fy: " << fy << endl;
    cout << "- cx: " << cx << endl;
    cout << "- cy: " << cy << endl;
    cout << "- k1: " << DistCoef.at<float>(0) << endl;
    cout << "- k2: " << DistCoef.at<float>(1) << endl;
    if (DistCoef.rows == 5)
            cout << "- k3: " << DistCoef.at<float>(4) << endl;
    cout << "- p1: " << DistCoef.at<float>(2) << endl;
    cout << "- p2: " << DistCoef.at<float>(3) << endl;
    cout << "- fps: " << fps << endl;

    // 1:RGB 0:BGR
    int nRGB = fSettings["Camera.RGB"];
    mbRGB = nRGB;

    if (mbRGB)
            cout << "- color order: RGB (ignored if grayscale)" << endl;
    else
            cout << "- color order: BGR (ignored if grayscale)" << endl;

    // Load ORB parameters

    // Step 2 加载ORB特征点有关的参数,并新建特征点提取器

    // 每一帧提取的特征点数 1000
    int nFeatures = fSettings["ORBextractor.nFeatures"];
    // 图像建立金字塔时的变化尺度 1.2
    float fScaleFactor = fSettings["ORBextractor.scaleFactor"];
    // 尺度金字塔的层数 8
    int nLevels = fSettings["ORBextractor.nLevels"];
    // 提取fast特征点的默认阈值 20
    int fIniThFAST = fSettings["ORBextractor.iniThFAST"];
    // 如果默认阈值提取不出足够fast特征点,则使用最小阈值 8
    int fMinThFAST = fSettings["ORBextractor.minThFAST"];

    // !tracking过程都会用到mpORBextractorLeft作为特征点提取器(单目就是left)
    // !开始提取ORB特征!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    mpORBextractorLeft = new ORBextractor(
            nFeatures, //参数的含义还是看上面的注释吧
            fScaleFactor,
            nLevels,
            fIniThFAST,
            fMinThFAST);

    // 如果是双目,tracking过程中还会用用到mpORBextractorRight作为右目特征点提取器
    if (sensor == System::STEREO)
            mpORBextractorRight = new ORBextractor(nFeatures, fScaleFactor, nLevels, fIniThFAST, fMinThFAST);

    // 在单目初始化的时候,会用mpIniORBextractor来作为特征点提取器
    if (sensor == System::MONOCULAR) //单目初始化要提取两倍的特征feature
            mpIniORBextractor = new ORBextractor(2 * nFeatures, fScaleFactor, nLevels, fIniThFAST, fMinThFAST);

    cout << endl
            << "ORB Extractor Parameters: " << endl;
    cout << "- Number of Features: " << nFeatures << endl;
    cout << "- Scale Levels: " << nLevels << endl;
    cout << "- Scale Factor: " << fScaleFactor << endl;
    cout << "- Initial Fast Threshold: " << fIniThFAST << endl;
    cout << "- Minimum Fast Threshold: " << fMinThFAST << endl;

    if (sensor == System::STEREO || sensor == System::RGBD)
    {
            // 判断一个3D点远/近的阈值 mbf * 35 / fx
            //ThDepth其实就是表示基线长度的多少倍
            mThDepth = mbf * (float)fSettings["ThDepth"] / fx;
            cout << endl
                    << "Depth Threshold (Close/Far Points): " << mThDepth << endl;
    }

    if (sensor == System::RGBD)
    {
            // 深度相机disparity转化为depth时的因子
            mDepthMapFactor = fSettings["DepthMapFactor"];
            if (fabs(mDepthMapFactor) < 1e-5)
                    mDepthMapFactor = 1;
            else
                    mDepthMapFactor = 1.0f / mDepthMapFactor;
    }
}

Tracking类的构造函数完成了一系列操作:
①从配置文件读取相机参数,并存入内参矩阵
②读取畸变参数,并存入去畸变参数矩阵
③读取图片的颜色格式
④读取ORB特征点相关参数,新建特征点提取器(点数、层数、比例、阈值)
这之后开始提取ORB特征,代码如下:(这段代码就在上边一堆代码最后)

// !tracking过程都会用到mpORBextractorLeft作为特征点提取器(单目就是left)
// !开始提取ORB特征!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
mpORBextractorLeft = new ORBextractor(
    nFeatures, //参数含义就在下边的程序框框(CSDN)
    fScaleFactor,
    nLevels,
    fIniThFAST,
    fMinThFAST);

// 如果是双目,tracking过程中还会用用到mpORBextractorRight作为右目特征点提取器
if (sensor == System::STEREO)
        mpORBextractorRight = new ORBextractor(nFeatures, fScaleFactor, nLevels, fIniThFAST, fMinThFAST);

// 在单目初始化的时候,会用mpIniORBextractor来作为特征点提取器
if (sensor == System::MONOCULAR) //单目初始化要提取两倍的特征feature
        mpIniORBextractor = new ORBextractor(2 * nFeatures, fScaleFactor, nLevels, fIniThFAST, fMinThFAST);

这里首先在Tracking类中实例化了一个类ORBextractor的类指针==*mpORBextractorLeft==,其余的两个相似的类指针是用于初始化的
进入类ORBextractor的构造函数,这就进入了ORBextractor.cc文件

ORBextractor.cc 1.0

值得一提的是,ORBextractor.cc文件并不是把构造函数放到了开头,而是在描述子点对数组之后,也就是程序500行左右的位置,上边还有其他的东西
构造函数的参数,将传递到在.h文件里定义的5个成员变量:

int nfeatures;      ///<整个图像金字塔中,要提取的特征点数目
double scaleFactor; ///<图像金字塔层与层之间的缩放因子
int nlevels;        ///<图像金字塔的层数
int iniThFAST;      ///<初始的FAST响应值阈值
int minThFAST;      ///<最小的FAST响应值阈值

分配图像金字塔各层特征点数目

ORBextractor类构造函数继续执行,计算了一些最基本的参数和容器数值,进而计算每层金字塔应该分配的特征点个数:

//每个单位缩放系数所希望的特征点个数(其实是第0层分配的特征点数量)
float nDesiredFeaturesPerScale = nfeatures * (1 - factor) / (1 - (float)pow((double)factor, (double)nlevels));
//用于在特征点个数分配的,特征点的累计计数清空
int sumFeatures = 0;
//开始逐层计算要分配的特征点个数,顶层图像除外(看循环后面)
for (int level = 0; level < nlevels - 1; level++)
{
	//分配 cvRound : 返回个参数最接近的整数值
	mnFeaturesPerLevel[level] = cvRound(nDesiredFeaturesPerScale); //cvRound取整
	//累计
	sumFeatures += mnFeaturesPerLevel[level];
	//乘系数
	nDesiredFeaturesPerScale *= factor;
}
//由于前面的特征点个数取整操作,可能会导致剩余一些特征点个数没有被分配,所以这里就将这个余出来的特征点分配到最高的图层中
mnFeaturesPerLevel[nlevels - 1] = std::max(nfeatures - sumFeatures, 0);
//!至此,完成了对每层图像金字塔分配特征点数目的操作

图像金字塔就是一种降采样的图像特征提取策略,用来实现一幅图片不同尺度下的特征提取,如下图所示,第0层是最下边的一层,之后逐层根据比例调整图像大小:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第2张图片
在SLAM问题中,我们可以得到一张图片一共要采多少个特征点,根据缩放因子来计算各层的特征点数量,首先计算第0层特征点数量,然后使用缩放因子累乘即可
公式推导如下:
首先计算所有层图像面积之和,假设第0层面积(像素个数)为S0,每层的缩放因子为s,这样总面积S为:
S = S 0 × s 0 + S 0 × s 1 + ⋅ ⋅ ⋅ + S 0 × s n − 1 = S 0 × 1 − s n 1 − s S=S_0×s^0+S_0×s^1+···+S_0×s^{n-1}\\ =S_0×\frac{1-s^n}{1-s} S=S0×s0+S0×s1++S0×sn1=S0×1s1sn
设我们总共要在图像金字塔中提取N个特征点,那么单位面积上要提取的特征点数量为:
N S = N × ( 1 − s ) S 0 × ( 1 − s n ) \frac{N}{S}=\frac{N×(1-s)}{S_0×(1-s^n)} SN=S0×(1sn)N×(1s)
这样,第0层应当提取的特征点数量就是:
S 0 × N S = N × ( 1 − s ) ( 1 − s n ) S_0×\frac{N}{S}=\frac{N×(1-s)}{(1-s^n)} S0×SN=(1sn)N×(1s)
利用缩放因子累乘,就可以得到各层的特征点数目了,第α层特征点数量为:
N α = s α × N × ( 1 − s ) ( 1 − s n ) N_α=s^α×\frac{N×(1-s)}{(1-s^n)} Nα=sα×(1sn)N×(1s)
再来看一下刚刚的代码:

//每个单位缩放系数所希望的特征点个数(其实是第0层分配的特征点数量)
float nDesiredFeaturesPerScale = nfeatures * (1 - factor) / (1 - (float)pow((double)factor, (double)nlevels));
//用于在特征点个数分配的,特征点的累计计数清空
int sumFeatures = 0;
//开始逐层计算要分配的特征点个数,顶层图像除外(看循环后面)
for (int level = 0; level < nlevels - 1; level++)
{
	//分配 cvRound : 返回个参数最接近的整数值
	mnFeaturesPerLevel[level] = cvRound(nDesiredFeaturesPerScale); //cvRound取整
	//累计
	sumFeatures += mnFeaturesPerLevel[level];
	//乘系数
	nDesiredFeaturesPerScale *= factor;
}
//由于前面的特征点个数取整操作,可能会导致剩余一些特征点个数没有被分配,所以这里就将这个余出来的特征点分配到最高的图层中
mnFeaturesPerLevel[nlevels - 1] = std::max(nfeatures - sumFeatures, 0);
//!至此,完成了对每层图像金字塔分配特征点数目的操作

这里先利用公式求出了第0层的特征点数量,然后用缩放因子来计算到倒数第二层的特征点数量,因为程序计算过程中会有小数,而特征点数是整数,所以除最后一层都进行了取整运算
这里用了一个变量sumFeatures在统计特征点数,最后一层直接用总数减去已经分配的点数,若是小于0则分配为0,这里还是很严谨的!

读取描述子256对点进入pattren

ORBextractor类构造函数继续执行,开始读取描述子点对进入一个vector,该容器以cv::Point为元素,代码如下:

//成员变量pattern的长度,也就是点的个数,这里的512表示512个点(上面的数组中是存储的坐标所以是256*2*2)
const int npoints = 512;
//获取用于计算BRIEF描述子的随机采样点点集头指针
//注意到pattern0数据类型为Points*,bit_pattern_31_是int[]型,所以这里需要进行强制类型转换
const Point *pattern0 = (const Point *)bit_pattern_31_; //bit_pattern_31_就是存放描述子的大数组
//使用std::back_inserter的目的是可以快覆盖掉这个容器pattern之前的数据
//其实这里的操作就是,将在全局变量区域的、int格式的随机采样点以cv::point格式复制到当前类对象中的成员变量中(关键)
std::copy(pattern0, pattern0 + npoints, std::back_inserter(pattern)); //pattern是类ORBextractor的成员变量

ORB-SLAM2采用了256对点,所以数组内共有512个坐标数值,然后使用copy函数,将在数组中存放的点存入了容器pattern

计算灰度质心法圆形区域边界并存入vector < int > umax

我们使用灰度质心法来计算特征点的方向,从而使FAST角点具有旋转不变性
在此之前,我们需要有一个圆形来计算,构造这个圆形的代码如下:

//This is for orientation
//下面的内容是和特征点的旋转计算有关的
// pre-compute the end of a row in a circular patch
//预先计算圆形patch中行的结束位置
//+1中的1表示那个圆的中间行
umax.resize(HALF_PATCH_SIZE + 1);

//cvFloor返回不大于参数的最大整数值,cvCeil返回不小于参数的最小整数值,cvRound则是四舍五入(cvFloor向下取整,cvCeil向上取整)
int v,                                                   //循环辅助变量
    v0,                                                  //辅助变量
    vmax = cvFloor(HALF_PATCH_SIZE * sqrt(2.f) / 2 + 1); //计算圆的最大行号,+1应该是把中间行也给考虑进去了(半径×二分之根号二+1)15→11
//NOTICE 注意这里的最大行号指的是计算的时候的最大行号,此行的和圆的角点在45°圆心角的一边上,之所以这样选择
//是因为圆周上的对称特性

//这里的二分之根2就是对应那个45°圆心角

int vmin = cvCeil(HALF_PATCH_SIZE * sqrt(2.f) / 2); //15→11
//半径的平方
const double hp2 = HALF_PATCH_SIZE * HALF_PATCH_SIZE;

//利用圆的方程计算每行像素的u坐标边界(max)
for (v = 0; v <= vmax; ++v)
    umax[v] = cvRound(sqrt(hp2 - v * v)); //结果都是大于0的结果,表示x坐标在这一行的边界

// Make sure we are symmetric
//这里其实是使用了对称的方式计算上四分之一的圆周上的umax,目的也是为了保持严格的对称(如果按照常规的想法做,由于cvRound就会很容易出现不对称的情况,
//同时这些随机采样的特征点集也不能够满足旋转之后的采样不变性了)
for (v = HALF_PATCH_SIZE, v0 = 0; v >= vmin; --v)
{
    while (umax[v0] == umax[v0 + 1])
        ++v0;
    umax[v] = v0;
    ++v0;
}
//! 至此,灰度质心法圆形建立好了

这里有一个宏定义来保存灰度质心圆的半径HALF_PATCH_SIZE

const int HALF_PATCH_SIZE = 15; ///<灰度质心法大小的“一半”,或者说是半径

解释一下这里的代码,用一个vectorumax来存储四分之一扇形区域第v行的最大距离
源码的算法比较奇怪,分了两半来进行计算最大横坐标
将源码简单整理为如下的程序进行观察

#include 
#include 
#include 
using namespace std;
//using namespace cv;

#define HALF_PATCH_SIZE 15

int main()
{
    int v,                 //循环辅助变量
        v0,                //辅助变量
        vmax = 11;         //计算圆的最大行号,+1应该是把中间行也给考虑进去了(半径×二分之根号二+1)15→11
    int vmin = 11;         //15→11
    std::vector<int> umax; ///<计算特征点方向的时候,有个圆形的图像区域,这个vector中存储了每行u轴的边界(四分之一,其他部分通过对称获得)
    umax.resize(HALF_PATCH_SIZE + 1);
    const double hp2 = HALF_PATCH_SIZE * HALF_PATCH_SIZE;

    for (v = 0; v <= vmax; ++v)
    {
        umax[v] = round(sqrt(hp2 - v * v)); //结果都是大于0的结果,表示x坐标在这一行的边界
        cout << "umax[" << v << "]=" << umax[v] << endl;
    }
    cout << "step1" << endl;
    for (v = HALF_PATCH_SIZE, v0 = 0; v >= vmin; --v)
    {
        while (umax[v0] == umax[v0 + 1])
            ++v0;
        umax[v] = v0;
        ++v0;
        cout << "umax[" << v << "]=" << umax[v] << endl;
    }
    cout << "over" << endl;
    return 0;
}

输出如下的结果:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第3张图片将图中输出结果用网格展示,效果如下:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第4张图片可以看到,从第0行到第15行,均找到了一个对应的最大行号
这里之所以要用这种方式计算,是为了找到一个比较对称的点对关系,但是这怎么算是对称呢
然而我刚刚说完上边那句话,决定把图片画完整一探究竟,发现真的是对称的!!!
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第5张图片收回刚刚那句话,我大意了啊,没有闪
原来SLAM竟是如此有趣呀,不得不佩服这些前辈的工作
这时回到Tracking.cc,继续Tracking类的构造函数

mono_kitti.cc 2.0

到这思路可能有点乱了,我们一起整理以下:
moco_kitti.cc内的main函数读取图片和时间戳后,实例化了一个System类对象,对象名字叫做SLAM
System.cc内的类System的构造函数中,先读取了.yaml配置文件以及词袋,之后实例化了一个Tracking类指针对象*mpTracker
Tracking.cc内的类Tracking的构造函数中,先读取了一堆参数,之后实例化了一个ORBextractor类指针对象*mpORBextractorLeft
ORBextractor.cc内的类ORBextractor的构造函数中,依次完成了构建图像金字塔、读取描述子坐标、构建灰度质心圆的工作
这样,类指针*mpORBextractorLeft就完成了构造,回到了Tracking的构造函数中,进而发现构造也基本结束,回到上一层
System.cc中,又实例化了一系列线程,但是我们的目标是特征提取,在此不做研究,回到上一层
至此,又回到了moco_kitti.cc,完成了实例化一个名为SLAMSystem类对象

大循环:读取图片+处理

main函数继续执行,就进入了一个for循环:

for (int ni = 0; ni < nImages; ni++)
{……}

在该循环中,每次读取一张图片及相应的时间戳,之后使用对象SLAM的成员函数TrackMonocular将图片及时间戳传给Tracking线程:

// Pass the image to the SLAM system
//!(不完全)将读取的图像im传给Tracking线程,其实只是完成了调用,具体的在函数内部完成
SLAM.TrackMonocular(im, tframe);

在调用该函数之后,将通过几个简单的操作记录每张图片处理用的时间
该操作貌似是为了之后进行评估工作,在此不做深入研究
因为TrackMonocular是类System的成员函数,所以我们又回到System.cc

System.cc 2.0

这一次进入该文件,我们重点研究TrackMonocular成员函数
进入该函数,先依次完成了传感器检测、检查是否更换模式、检查是否需要复位,之后就进入了真正的位姿估计环节:

//Tcw保存相机位姿的估计结果
//!(最终的)输入图片和时间戳给Tracking线程 重点!!!!!!
//通过输入的照片和时间戳获取相机的位姿结果
cv::Mat Tcw = mpTracker->GrabImageMonocular(im, timestamp);

这里类指针mpTracker调用了其成员函数GrabImageMonocular(im, timestamp)
因为这是类Tracking的成员函数,所以其定义在Tracking.cc文件内

Tracking.cc 2.0

我们进入该函数:

cv::Mat Tracking::GrabImageMonocular(const cv::Mat &im, const double &timestamp)

第一步为通过一个通道转换将原始图像转换为灰度图,保存在cv::Mat mImGray
第二步为构造帧Frame,代码如下:

// Step 2 :构造Frame
//判断该帧是不是初始化(条件为没有初始化或者初始化还没开始)
if (mState == NOT_INITIALIZED || mState == NO_IMAGES_YET) //没有成功初始化的前一个状态就是NO_IMAGES_YET
        mCurrentFrame = Frame(                            //?为什么这里是等式,Frame类这是什么语法(创建对象并调用构造函数?)(拷贝构造函数同时用了匿名类?)
            mImGray,
            timestamp,
            mpIniORBextractor, //初始化ORB特征点提取器会提取2倍的指定特征点数目
            mpORBVocabulary,
            mK,
            mDistCoef,
            mbf,
            mThDepth);
else
        mCurrentFrame = Frame(
            mImGray,
            timestamp,
            mpORBextractorLeft, //正常运行的时的ORB特征点提取器,提取指定数目特征点
            mpORBVocabulary,
            mK,
            mDistCoef,
            mbf,
            mThDepth);

其中,mCurrentFrame是类Tracking中的一个类Frame的成员变量(类作为成员变量)
这里完成了实例化这个Frame类mCurrentFrame,相应的Frame类的构造函数在Frame.cc

Frame.cc 1.0

进入Frame类的构造函数,首先完成了帧ID的自增:

// Frame ID
// Step 1 帧的ID 自增
mnId = nNextId++;

这里的nNextId是一个静态变量,可以认为它不会随着程序函数调用等被消灭掉,可以保持当前处理图片的ID号
具体怎么搞的还没太清楚❓,变量定义如下:

//下一个生成的帧的ID,这里是初始化类的静态成员变量
long unsigned int Frame::nNextId = 0;

Frame.h内该变量的声明如下:

//类的静态成员变量,这些变量则是在整个系统开始执行的时候被初始化的——它在全局区被初始化
static long unsigned int nNextId; ///< Next Frame id.下一帧ID

继续执行,完成了图像金字塔参数的获取:

// Step 2 计算图像金字塔的参数
//获取图像金字塔的层数
mnScaleLevels = mpORBextractorLeft->GetLevels();
//这个是获得层与层之前的缩放比
mfScaleFactor = mpORBextractorLeft->GetScaleFactor();
//计算上面缩放比的对数, NOTICE log=自然对数,log10=才是以10为基底的对数
mfLogScaleFactor = log(mfScaleFactor);
//获取每层图像的缩放因子
mvScaleFactors = mpORBextractorLeft->GetScaleFactors();
//同样获取每层图像缩放因子的倒数
mvInvScaleFactors = mpORBextractorLeft->GetInverseScaleFactors();
//高斯模糊的时候,使用的方差
mvLevelSigma2 = mpORBextractorLeft->GetScaleSigmaSquares();
//获取sigma^2的倒数
mvInvLevelSigma2 = mpORBextractorLeft->GetInverseScaleSigmaSquares();

这里是从类指针mpORBextractorLeft直接获取的,因为之前需要的数据已经计算过了
继续执行,正式开始提取ORB特征点:

// ORB extraction
// Step 3 对这个单目图像进行提取特征点, 第一个参数0-左图, 1-右图
//! 下面开始提取ORB特征点
ExtractORB(0, imGray);

该函数依然是Frame类的成员函数,仍在Frame.cc,函数定义如下:

/**
 * @brief 提取图像的ORB特征点,提取的关键点存放在mvKeys,描述子存放在mDescriptors
 * 
 * @param[in] flag          标记是左图还是右图。0:左图  1:右图
 * @param[in] im            等待提取特征点的图像
 */
void Frame::ExtractORB(int flag, const cv::Mat &im)
{
// 判断是左图还是右图
if (flag == 0) //如果是左图
    // 左图的话就套使用左图指定的特征点提取器,并将提取结果保存到对应的变量中
    // 这里使用了仿函数来完成,重载了括号运算符 ORBextractor::operator()
    (*mpORBextractorLeft)(im,            //待提取特征点的图像
                          cv::Mat(),     //掩摸图像, 实际没有用到
                          mvKeys,        //输出变量,用于保存提取后的特征点
                          mDescriptors); //输出变量,用于保存特征点的描述子
else
    // 右图的话就需要使用右图指定的特征点提取器,并将提取结果保存到对应的变量中
    (*mpORBextractorRight)(im, cv::Mat(), mvKeysRight, mDescriptorsRight);
}

对于单目而言,我们只用左图,flag = 0,之后执行语句重载了括号运算符
*mpORBextractorLeft是ORBextractor类的一个类指针,该类对括号运算符进行了重载
所谓运算符重载就是定义了一种此类特有的运算法则,比如类不能直接相加,重载+号就可以实现一些“加法运算”
通过运算符重载,可以构造一个仿函数,这是一个能行使函数功能的类,仿函数的语法几乎和我们普通的函数调用一样,不过作为函数的类,都必须重载operator()运算符,仿函数有如下特点:

1.仿函数可以拥有自己的数据成员和成员变量,这意味着仿函数拥有状态,这在一般函数中是不可能的
2.仿函数通常比一般函数有更好的速度

运算符的重载不方便直接跳转,这里我们到ORBextractor.cc中,点击VScode这里在下拉菜单可以找到重载运算符
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第6张图片

ORBextractor.cc 2.0

构建每张图片的图像金字塔

进入该仿函数的定义,首先完成了检查图像是否为空以及构建图像金字塔,代码如下:

/**
 * @brief 用仿函数(重载括号运算符)方法来计算图像特征点!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
 * 
 * @param[in] _image                    输入原始图的图像
 * @param[in] _mask                     掩膜mask
 * @param[in & out] _keypoints                存储特征点关键点的向量
 * @param[in & out] _descriptors              存储特征点描述子的矩阵
 */
void ORBextractor::operator()(InputArray _image, InputArray _mask, vector<KeyPoint> &_keypoints,
                              OutputArray _descriptors)
{
    //*****************准备阶段*********************//
    // Step 1 检查图像有效性。如果图像为空,那么就直接返回
    if (_image.empty())
        return;

    //获取图像的大小
    Mat image = _image.getMat();
    //判断图像的格式是否正确,要求是单通道灰度值
    assert(image.type() == CV_8UC1);

    // Pre-compute the scale pyramid
    // Step 2 构建图像金字塔(一个关键操作)
    ComputePyramid(image);
    //本函数未结束

这里调用了ComputPyramid()函数来构建图像金字塔,他也是类ORBextractor的一个成员函数,进入该函数,具体代码如下:

    /**
 * 构建图像金字塔
 * @param image 输入原图像,这个输入图像所有像素都是有效的,也就是说都是可以在其上提取出FAST角点的
 */
void ORBextractor::ComputePyramid(cv::Mat image)
{
    //开始遍历所有的图层
    for (int level = 0; level < nlevels; ++level)
    {
        //获取本层图像的缩放系数
        float scale = mvInvScaleFactor[level]; //这里的缩放系数是行列的缩放系数
        //计算本层图像的像素尺寸大小
        Size sz(cvRound((float)image.cols * scale), cvRound((float)image.rows * scale));
        //全尺寸图像。包括无效图像区域的大小。将图像进行“补边”,EDGE_THRESHOLD区域外的图像不进行FAST角点检测
        Size wholeSize(sz.width + EDGE_THRESHOLD * 2, sz.height + EDGE_THRESHOLD * 2);
        // 定义了两个变量:temp是扩展了边界的图像,这里的类型应该是CV_8UC1,masktemp貌似并未使用
        Mat temp(wholeSize, image.type()), masktemp;
        // mvImagePyramid 刚开始时是个空的vector
        // 把图像金字塔该图层的图像指针mvImagePyramid指向temp的中间部分(这里为浅拷贝,内存相同)
        mvImagePyramid[level] = temp(Rect(EDGE_THRESHOLD, EDGE_THRESHOLD, sz.width, sz.height));

        // Compute the resized image
        //计算第0层以上resize后的图像
        if (level != 0)
        {
            //将上一层金字塔图像根据设定sz缩放到当前层级
            // resize(mvImagePyramid[level-1],	//输入图像
            // 	   mvImagePyramid[level], 	//输出图像
            // 	   sz, 						//输出图像的尺寸
            // 	   0, 						//水平方向上的缩放系数,留0表示自动计算
            // 	   0,  						//垂直方向上的缩放系数,留0表示自动计算
            // 	   cv::INTER_LINEAR);		//图像缩放的差值算法类型,这里的是线性插值算法

            //!  原代码mvImagePyramid 并未扩充,上面resize应该改为如下
            //此时mvImagePyramid[level]中还是一个感兴趣的小区域
            resize(image,                 //输入图像
                   mvImagePyramid[level], //输出图像
                   sz,                    //输出图像的尺寸
                   0,                     //水平方向上的缩放系数,留0表示自动计算
                   0,                     //垂直方向上的缩放系数,留0表示自动计算
                   cv::INTER_LINEAR);     //图像缩放的差值算法类型,这里的是线性插值算法
                                          //此时该区域填充上了缩放后的图形

            //把源图像拷贝到目的图像的中央,四面填充指定的像素。图片如果已经拷贝到中间,只填充边界
            //这样做是为了能够正确提取边界的FAST角点
            //EDGE_THRESHOLD指的这个边界的宽度,由于这个边界之外的像素不是原图像素而是算法生成出来的,所以不能够在EDGE_THRESHOLD之外提取特征点
            copyMakeBorder(mvImagePyramid[level],                 //源图像
                           temp,                                  //目标图像(此时其实就已经有大了一圈的尺寸了)
                           EDGE_THRESHOLD, EDGE_THRESHOLD,        //top & bottom 需要扩展的border大小
                           EDGE_THRESHOLD, EDGE_THRESHOLD,        //left & right 需要扩展的border大小
                           BORDER_REFLECT_101 + BORDER_ISOLATED); //扩充方式,opencv给出的解释:
                                                                  //此时temp被扩充,但

            /*Various border types, image boundaries are denoted with '|'
* BORDER_REPLICATE:     aaaaaa|abcdefgh|hhhhhhh
* BORDER_REFLECT:       fedcba|abcdefgh|hgfedcb
* BORDER_REFLECT_101:   gfedcb|abcdefgh|gfedcba
* BORDER_WRAP:          cdefgh|abcdefgh|abcdefg
* BORDER_CONSTANT:      iiiiii|abcdefgh|iiiiiii  with some specified 'i'
*/

            //BORDER_ISOLATED	表示对整个图像进行操作
            // https://docs.opencv.org/3.4.4/d2/de8/group__core__array.html#ga2ac1049c2c3dd25c2b41bffe17658a36
        }
        else
        {
            //对于第0层未缩放图像,直接将图像深拷贝到temp的中间,并且对其周围进行边界扩展。此时temp就是对原图扩展后的图像
            copyMakeBorder(image, //这里是原图像
                           temp, EDGE_THRESHOLD, EDGE_THRESHOLD, EDGE_THRESHOLD, EDGE_THRESHOLD,
                           BORDER_REFLECT_101);
        }
        //! 原代码mvImagePyramid 并未扩充,应该添加下面一行代码
        mvImagePyramid[level] = temp;
    }
}

这里的代码虽然不多,但是看起来很绕,有必要逐行阅读
该函数用来构建图像金字塔,最终目的是将不同尺寸的图片存入图像金字塔,并且给每幅图像扩充合适的边界
进入函数,首先是一个金字塔维度次数的循环
之后在每次循环中,首先读取了该层的缩放系数,注意是边长的缩放系数,而不是图像面积的缩放系数:

//获取本层图像的缩放系数
float scale = mvInvScaleFactor[level]; //这里的缩放系数是行列的缩放系数

之后使用了Size来定义了两个图像尺寸,关于Size的理解可以认为是存储了一个尺寸,相关的成员定义可以参考下方链接:
CvPoint,CvSize,CvRect,CvScalar结构
这里的代码是这样的:

//计算本层图像的像素尺寸大小
Size sz(cvRound((float)image.cols * scale), cvRound((float)image.rows * scale));
//全尺寸图像。包括无效图像区域的大小。将图像进行“补边”,EDGE_THRESHOLD区域外的图像不进行FAST角点检测
Size wholeSize(sz.width + EDGE_THRESHOLD * 2, sz.height + EDGE_THRESHOLD * 2);

sz存储了本层图像的大小,就是原始图像在不同层的大小,代码是分别把原始的长宽分别乘上缩放系数
wholeSize存储了一个扩充后的图像大小,是在sz的基础上长宽各加了一个EDGE_THRESHOLD的结果
简单展示一下处理后的图片什么样子:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第7张图片中央深灰色区域是每层原始图像缩放后的大小,用来作为特征点提取的区域
外边浅灰色边界是扩展了一个EDGE_THRESHOLD之后的大小,用来进行之后的高斯滤波
绿色边界是提取FAST角点扩充的三个像素,提取边界上的FAST角点需要向外扩充半径为3的范围
计算完两个图像尺寸后,定义了一个中间变量temp

// 定义了两个变量:temp是扩展了边界的图像,这里的类型应该是CV_8UC1,masktemp貌似并未使用
Mat temp(wholeSize, image.type()), masktemp;

temp的大小为整个最大的画幅,类型为输入图像的类型,该中间变量会在后边反复使用
这之后使mvImagePyramid[level]指向temp中的该层图像部分(深灰色区域):

// mvImagePyramid 刚开始时是个空的vector
// 把图像金字塔该图层的图像指针mvImagePyramid指向temp的中间部分(这里为浅拷贝,内存相同)
mvImagePyramid[level] = temp(Rect(EDGE_THRESHOLD, EDGE_THRESHOLD, sz.width, sz.height));

这里用到了一个函数Rect,具体介绍可以参考如下链接内的文章:
关于openCV中Rect()的解释
简单介绍一下Rect():

前两个参数表示矩形框的起始位置,分别是哪一列和哪一行
后两个参数是矩形区域的宽度和高度

然后经过一个简单的判断,区别对待第0层和之后的各层,我们只介绍后者,即先执行如下语句:

//此时mvImagePyramid[level]中还是一个感兴趣的小区域
resize(image,                 //输入图像
       mvImagePyramid[level], //输出图像
       sz,                    //输出图像的尺寸
       0,                     //水平方向上的缩放系数,留0表示自动计算
       0,                     //垂直方向上的缩放系数,留0表示自动计算
       cv::INTER_LINEAR);     //图像缩放的差值算法类型,这里的是线性插值算法
                              //此时该区域填充上了缩放后的图形

这里使用了resize()函数,具体功能为压缩原始图片
将原始图像image按照sz的尺寸以线性压缩方式压缩至mvImagePyramid[level]
resize()函数的具体功能可以参照如下链接:
cv.resize()详解
之后将填充该层压缩后的图像周围扩充区域的像素值,代码如下:

//把源图像拷贝到目的图像的中央,四面填充指定的像素。图片如果已经拷贝到中间,只填充边界
//这样做是为了能够正确提取边界的FAST角点
//EDGE_THRESHOLD指的这个边界的宽度,由于这个边界之外的像素不是原图像素而是算法生成出来的,所以不能够在EDGE_THRESHOLD之外提取特征点
copyMakeBorder(mvImagePyramid[level],                 //源图像
               temp,                                  //目标图像(此时其实就已经有大了一圈的尺寸了)
               EDGE_THRESHOLD, EDGE_THRESHOLD,        //top & bottom 需要扩展的border大小
               EDGE_THRESHOLD, EDGE_THRESHOLD,        //left & right 需要扩展的border大小
               BORDER_REFLECT_101 + BORDER_ISOLATED); //扩充方式,opencv给出的解释:
                                                      //此时temp被扩充,但mvImagePyramid[level]还是中间的区域

这里我们使用copyMakeBorder()函数来进行边界的扩充
具体可以概括为将源图像拷贝同时依据指定的边界参数扩充方式扩充到目标图像中
这里的拷贝方式为BORDER_REFLECT_101,具体效果为镜像扩充,举例如下:

					    9  8  7  8  9  8  7
					    6  5  4  5  6  5  4
1  2  3 				3  2  1  2  3  2  1
4  5  66  5  4  5  6  5  4
7  8  9					9  8  7  8  9  8  7
					    6  5  4  5  6  5  4
					    3  2  1  2  3  2  1

关于copyMakeBorder()函数的具体使用方法可以参照如下链接内文章:
opencv之边界扩展copyMakeBorder
到这一步,我们已经把目标的每层图像求出来了,只不过保存在了temp里,所以需要最后一步:

//! 原代码mvImagePyramid 并未扩充,应该添加下面一行代码
mvImagePyramid[level] = temp;

至此,图像金字塔建立完毕,并且保存在了mvImagePyramid[]
回到重载括号运算符的仿函数中,开始特征点提取和分配

特征点提取和分配

特征点的提取与分配“只有两行”代码:

//*****************特征点提取和分配*********************//
// Step 3 计算图像的特征点,并且将特征点进行均匀化。均匀的特征点可以提高位姿计算精度
// 存储所有的特征点,注意此处为二维的vector,第一维存储的是金字塔的层数,第二维存储的是那一层金字塔图像里提取的所有特征点
vector<vector<KeyPoint>> allKeypoints;
//使用四叉树的方式计算每层图像的特征点并进行分配(一个关键操作)(平均分配图像特征点)
ComputeKeyPointsOctTree(allKeypoints);

首先创建了一个二维的vector变量allKeypoints
其第一维是金字塔的层数,第二维是该层金字塔图像中提取的所有特征点
这里容器的类型是KeyPoint,它是OpenCV中的一种类型,用来存储特征点坐标及其他一系列信息:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第8张图片相关介绍可以参照如下链接内文章:
OpenCV中KeyPoint类

之后调用ComputeKeyPointsOctTree()函数进行特征点提取以及四叉树分配特征点

特征点提取

进入函数,首先重新调整图像层数:

//重新调整图像层数
allKeypoints.resize(nlevels);

之后设置了图像网格的边长:

//图像cell的尺寸,是个正方形,可以理解为边长in像素坐标
//30×30的图像网格
const float W = 30;

首先简单解释一下网格的含义,这里我们进行特征提取的时候,是对待处理图像逐个网格进行特征点提取的
也就是对图像区域逐个W×W进行提取特征点,这样处理的目的之后会在解释❓
之后进入对每层图像金字塔的循环,是此处的一个大循环
(最大的循环在main函数中对每张图片循环,这里是对某张图片的各层金字塔进行循环)
逐层循环代码部分如下:

    //对每一层图像做处理
    //逐层遍历所有图像
    for (int level = 0; level < nlevels; ++level)
    {
//!有效图像边界
//计算这层图像的坐标边界, NOTICE 注意这里是坐标边界,EDGE_THRESHOLD指的应该是可以提取特征点的有效图像边界,后面会一直使用“有效图像边界“这个自创名词
const int minBorderX = EDGE_THRESHOLD - 3; //这里的3是因为在计算FAST特征点的时候,需要建立一个半径为3的圆
const int minBorderY = minBorderX;         //minY的计算就可以直接拷贝上面的计算结果了
const int maxBorderX = mvImagePyramid[level].cols - EDGE_THRESHOLD + 3;
const int maxBorderY = mvImagePyramid[level].rows - EDGE_THRESHOLD + 3;

//存储需要进行平均分配的特征点(用于暂存特征点)
vector<cv::KeyPoint> vToDistributeKeys;
//一般地都是过量采集,所以这里预分配的空间大小是nfeatures*10
vToDistributeKeys.reserve(nfeatures * 10);

//计算进行特征点提取的图像区域尺寸(包括了扩展的半径3)
const float width = (maxBorderX - minBorderX);
const float height = (maxBorderY - minBorderY);
//计算网格在当前层的图像有的行数和列数(其实是计算横竖各有多少个网格)
const int nCols = width / W; //舍入误差怎么说?int是保留整数部分
const int nRows = height / W;
//计算每个图像网格所占的像素行数和列数
//?为什么还要计算一遍,不是30×30吗?经过分析,这样算下来,网格行列一定是大于等于30的
const int wCell = ceil(width / nCols); //ceil是向上取整
const int hCell = ceil(height / nRows);
……

首先计算了有效的图像边界,什么是有效的图像边界呢,我们仍需要观察下图:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第9张图片图像边界的四个参数其实就是两个点的坐标,两个点围成的区域就是图中绿色部分
之所以要拿出来这个区域,是因为接下来的FAST角点提取要在这里进行
之后创建了一个一个元素为cv::KeyPoint的vector,它的名字就叫做vToDistributeKeys,之后为其预分配空间
该vector用于暂存需要分配的特征点,后边提取FAST时会用到
之后一通操作依次完成计算绿色区域长宽、计算长宽各有多少个网格、计算每个网格新的长宽
这里我李哈哈愿称之为迷之操作,于是我自己写了一个模拟程序来进行测试:

#include 
#include 
#include 
using namespace std;
//using namespace cv;

#define HALF_PATCH_SIZE 15

int main()
{
    //图像cell的尺寸,是个正方形,可以理解为边长in像素坐标
    //30×30的图像网格
    const float W = 30;

    const float width = 241;
    cout << "width = " << width << endl;
    //计算网格在当前层的图像有的行数和列数(其实是计算横竖各有多少个网格)
    const int nCols = width / W; //舍入误差怎么说?int是保留整数部分
    cout << "nCols = " << nCols << endl;

    //计算每个图像网格所占的像素行数和列数
    const int wCell = ceil(width / nCols); //ceil是向上取整
    cout << "width/nCols = " << width / nCols << endl;
    cout << "wCell = " << wCell << endl;
    cout << "over" << endl;
    return 0;
}

改变width长度,观察网格个数变化以及边长变化:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第10张图片
这段程序的最终结果是计算出了待处理区域行列各有多少个网格,同时计算了处理时网格的长宽各是多少
测试程序以长为例,初始网格长度为30
输如不同的图像长度尺寸(239-241)可以看到网格数量有变化(来源于int类型计算只保留整数部分)
同时使用ceil(天花板函数)向上取整重新计算了网格长度
由于上述的舍入误差,这里算出的新的网格宽度一定大于等于原网格长度(35 30 31)
概括以下这里做了什么:

通过绿色区域角点计算了其边长
在int类型下,计算了30边长的网格横纵数量,只保留了整数部分
在float类型下,用图像长宽除以网格个数,算出新的网格长宽(新值>=原值)

之后开始遍历网格,行遍历在外层,列遍历在内层,代码如下:

//开始遍历图像网格,还是以行开始遍历的
for (int i = 0; i < nRows; i++)
{
    //计算当前网格初始行坐标
    const float iniY = minBorderY + i * hCell;
    //计算当前网格最大的行坐标,这里的+6=+3+3,即考虑到了多出来3是为了cell边界像素进行FAST特征点提取用
    //前面的EDGE_THRESHOLD指的应该是提取后的特征点所在的边界,所以minBorderY是考虑了计算半径时候的图像边界
    //目测一个图像网格的大小是25*25啊(L:这句注释不对吧)
    float maxY = iniY + hCell + 6;

    //判断是否在图像中
    //如果初始的行坐标就已经超过了有效的图像边界了,这里的“有效图像”是指原始的、可以提取FAST特征点的图像区域
    if (iniY >= maxBorderY - 3)
        //那么就跳过这一行
        continue;

    //如果图像的大小导致不能够正好划分出来整齐的图像网格,那么就要委屈最后一行了
    if (maxY > maxBorderY)
        maxY = maxBorderY;

    //开始列的遍历
    for (int j = 0; j < nCols; j++)
    {
        //计算初始的列坐标
        const float iniX = minBorderX + j * wCell;
        //计算这列网格的最大列坐标,+6的含义和前面相同
        float maxX = iniX + wCell + 6;
        //判断坐标是否在图像中
        //TODO 不太能够明白为什么要-6,前面不都是-3吗
        //!BUG  正确应该是maxBorderX-3
        if (iniX >= maxBorderX - 6)
            continue;
        //如果最大坐标越界那么委屈一下
        if (maxX > maxBorderX)
            maxX = maxBorderX;

        // FAST提取兴趣点, 自适应阈值
        //这个向量存储这个cell中的特征点
        vector<cv::KeyPoint> vKeysCell;
        //调用opencv的库函数来检测FAST角点
        FAST(mvImagePyramid[level].rowRange(iniY, maxY).colRange(iniX, maxX), //待检测的图像,这里就是当前遍历到的图像块
             vKeysCell,                                                       //存储角点位置的容器
             iniThFAST,                                                       //一般情况下检测阈值
             true);                                                           //使能非极大值抑制

        //降低阈值提取,如果这个图像块中使用默认的FAST检测阈值没有能够检测到角点
        if (vKeysCell.empty())
        {
            //那么就使用更低的阈值来进行重新检测
            FAST(mvImagePyramid[level].rowRange(iniY, maxY).colRange(iniX, maxX), //待检测的图像
                 vKeysCell,                                                       //存储角点位置的容器
                 minThFAST,                                                       //更低的检测阈值
                 true);                                                           //使能非极大值抑制
        }

        //当图像cell中检测到FAST角点的时候执行下面的语句
        if (!vKeysCell.empty())
        {
            //遍历其中的所有FAST角点
            for (vector<cv::KeyPoint>::iterator vit = vKeysCell.begin(); vit != vKeysCell.end(); vit++)
            {
                //NOTICE 到目前为止,这些角点的坐标都是基于图像cell的,现在我们要先将其恢复到当前的【坐标边界】下的坐标
                //这样做是因为在下面使用八叉树法整理特征点的时候将会使用得到这个坐标
                //在后面将会被继续转换成为在当前图层的扩充图像坐标系下的坐标
                (*vit).pt.x += j * wCell;
                (*vit).pt.y += i * hCell;
                //然后将其加入到”等待被分配“的特征点容器中
                vToDistributeKeys.push_back(*vit);
            } //遍历图像cell中的所有的提取出来的FAST角点,并且恢复其在整个金字塔当前层图像下的坐标
        }     //当图像cell中检测到FAST角点的时候执行下面的语句
    }         //开始遍历图像cell的列
}             //开始遍历图像cell的行

进入行遍历,首先计算当前网格初始行坐标,然后计算一个图像网格的具体大小
前者是在minBorderY基础上加上i个计算后的网格行高hCell
后者是在前者的基础上加了一个hCell再加6
这里还是用Excel像素大法来表示:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第11张图片在图中,我已经标出了相应的变量对应的行列数,并且假设网格大小仍是“30×30”(初始给的是30×30)
浅灰色区域是mvImagePyramid[level]存储的图像,是扩展的图像金字塔,每个level存储了扩展后的图像(宽度19)
深灰色区域是每层原始图像缩放后的图像,为了方便,以后深灰色区域都叫做原始图像
浅绿色区域扩展的3像素的原始图像边界,用于在原始图像上进行FAST提取(宽度3)
橙色条形区域表示了第一次遍历时第一个网格所在的起点和终点(设边长为30+3+3)
绿色边框线部分是第一个网格的具体位置
红色边框是原始图像中待提取特征点的区域
小想法:
我希望大家在看到这里的时候认真看看上边的图片及上述几行文字,对于理解遍历过程非常有必要
写到这我对网格有了一个更加深入的理解,网格在遍历过程中是有重复的
为什么这么说,因为看图可以知道,第一次网格是绿色框区域,而提取了特征点的部分是红色框区域
换言之,网格区域包含的原始图像并不是都提取了特征点,仔细看程序也能看出这一点
这样一来,程序中的注释应该对提取特征点的区域另做命名,不然容易混淆(实际上前文介绍时也混淆了,这看懂了就行)
另外,程序中判断了是否超越了最大边界,对此有相应的处理办法


继续执行,创建了一个容器vKeysCell来存储当前网格(cell)中的特征点
进而使用OpenCV自带的FAST()函数来提取网格内的特征点:

//调用opencv的库函数来检测FAST角点
FAST(mvImagePyramid[level].rowRange(iniY, maxY).colRange(iniX, maxX), //待检测的图像,这里就是当前遍历到的图像块
     vKeysCell,                                                       //存储角点位置的容器
     iniThFAST,                                                       //一般情况下检测阈值
     true);                                                           //使能非极大值抑制

相关参数的含义已经写到了注释中
通过该函数将当前网格内的特征提取到了容器vKeysCell
源代码在提取一次后,会检查是否提取到关键点,若没有会降低阈值重新提取,两次阈值如下:

int iniThFAST;      ///<初始的FAST响应值阈值
int minThFAST;      ///<最小的FAST响应值阈值

关于FAST()函数,更清晰的解释可以参考如下链接中文章:
opencv3/C++ FAST特征检测
在此之后,若在当前网格提取出来特征点,就对特征点进行坐标调整
因为提取出的特征还是在网格内的,这里先把它调整到原始图像位置中,每次计算添加的坐标变化量也是一个小的提取区域的长度,最终将调整坐标后的特征点保存在容器vToDistributeKeys
之后声明了一个当前图层特征点容器的引用,并预留了足够的空间:

//声明一个对当前图层的特征点的容器的引用
vector<KeyPoint> &keypoints = allKeypoints[level];
//并且调整其大小为欲提取出来的特征点个数(当然这里也是扩大了的,因为不可能所有的特征点都是在这一个图层中提取出来的)
keypoints.reserve(nfeatures);

之后进行四叉树筛选特征点

特征点分配(筛选)

ORB-SLAM2采用四叉树进行特征点的分配
四叉树分配能够提取出分布均匀且质量好的特征点(函数中OCT代表8,不知道为何❓)
我们调用函数DistributeOctTree()来分配筛选特征点:

// 根据mnFeatuvector & keypoints = allKeypoints[level];resPerLevel,即该层的兴趣点数,对特征点进行剔除
//返回值是一个保存有特征点的vector容器,含有剔除后的保留下来的特征点
//得到的特征点的坐标,依旧是在当前图层下来讲的
keypoints = DistributeOctTree(vToDistributeKeys,      //当前图层提取出来的特征点,也即是等待剔除的特征点
                                                      //NOTICE 注意此时特征点所使用的坐标都是在“半径扩充图像”下的
                              minBorderX, maxBorderX, //当前图层图像的边界,而这里的坐标却都是在“边缘扩充图像”下的
                              minBorderY, maxBorderY,
                              mnFeaturesPerLevel[level], //希望保留下来的当前层图像的特征点个数
                              level);                    //当前层图像所在的图层

注意这里的参数中有两句注释:

//NOTICE 注意此时特征点所使用的坐标都是在“半径扩充图像”下的
//当前图层图像的边界,而这里的坐标却都是在“边缘扩充图像”下的

本来我以为这两句话写错了,但是后来仔细一想,发现没毛病
这里的特征点是“半径扩充图像”下的,可以这样分析,我们在提取特征点的时候,第一次的网格就是半径扩充了的
虽然提取的区域是内部的原始图像,但是其坐标是扩充了一个半径的,换句话说,原图中(0,0)点如果是特征点,那么在提取时,该点提取出来的应该是(3,3)
在进行特征点坐标调整的时候,因为“半径扩充图像”每个网格有重合,所以加的倍数没有问题
而第二条当前图层的边界是指那四个参数,这四个参数是在边缘扩展的前提下定义的
另外,这里是对某张图片的某个层进行处理,所以传入了当前层的层数以及希望保留的特征点数量


进入函数,首先确定了初始节点的数目:

// Compute how many initial nodes
// Step 1 根据宽高比确定初始节点数目
//计算应该生成的初始节点个数,根节点的数量nIni是根据边界的宽高比值确定的,一般是1或者2
// ! bug: 如果宽高比小于0.5,nIni=0, 后面hx会报错(默认了宽比高要长)kitti是 1241×376
const int nIni = round(static_cast<float>(maxX - minX) / (maxY - minY)); //static_cast是强制类型转换,将后方变量转换为< >内类型

通过计算半径扩充图像的长宽比四舍五入来进行计算
因为我们传入的参数虽然是在边缘扩充图像下定义的,但由于坐标的相对性,保留的就只有两个角点圈出来的半径扩充图像了
另外这里有个小Bug,因为如果宽高比小于0.5,那么初始节点数量就是0了
对于kitti数据集,图片尺寸为1241×376,所以长宽比为3.3,由四舍五入为3个初始节点
这里用到了一个强制转换操作static_cast<>()是将()内的数据转换为<>内的类型
具体参照如下链接:
static_cast和dynamic_cast详解
之后计算初始节点x方向多少个像素:

//一个初始的节点的x方向有多少个像素
const float hX = static_cast<float>(maxX - minX) / nIni;
//? 为什么这里都用浮点型?舍入误差怎么研究

之后创建了一个列表lNodes,用来存储节点,我给它取名为坐标
就像所有尤弥尔子民都通过路链接到始祖巨人一样,之后创建的节点也都是从这个列表“生根发芽”
然后又创建了一个存储初始节点指针的vectorvpIniNodes,其中元素依次指向各个初始节点:

//存储有提取器节点的列表
list<ExtractorNode> lNodes; //这里的ExtractorNode是提取器节点类型
//↑↓这俩货同屏出现,同屏赋值
//存储初始提取器节点指针的vector
vector<ExtractorNode *> vpIniNodes;

//然后重新设置其大小
vpIniNodes.resize(nIni);

以上一个list一个vector均为初始化,之后对其进行赋值
说一下类ExtractorNode,这就是专门为节点定义的,定义如下:

// 分配四叉树时用到的结点类型
class ExtractorNode
{
public:
   /** @brief 构造函数 */
   ExtractorNode() : bNoMore(false) {}

   /**
  * @brief 在八叉树分配特征点的过程中,实现一个节点分裂为4个节点的操作
  * 
  * @param[out] n1   分裂的节点1
  * @param[out] n2   分裂的节点2
  * @param[out] n3   分裂的节点3
  * @param[out] n4   分裂的节点4
  */
   void DivideNode(ExtractorNode &n1, ExtractorNode &n2, ExtractorNode &n3, ExtractorNode &n4);

   ///保存有当前节点的特征点
   std::vector<cv::KeyPoint> vKeys;
   ///当前节点所对应的图像坐标边界
   cv::Point2i UL, UR, BL, BR;
   //存储提取器节点的列表(其实就是双向链表)的一个迭代器,可以参考[http://www.runoob.com/cplusplus/cpp-overloading.html]
   //这个迭代器提供了访问总节点列表的方式,需要结合cpp文件进行分析
   std::list<ExtractorNode>::iterator lit;

   ///如果节点中只有一个特征点的话,说明这个节点不能够再进行分裂了,这个标志置位
   //这个节点中如果没有特征点的话,这个节点就直接被删除了
   bool bNoMore;
};

进入该类,构造函数使变量bNoMore默认为false,也就是说该节点默认包含多个特征点,还可以分裂

  • 该类定义了一个存放当前节点内特征点的vectorvKeys
  • 该类定义了该节点的坐标边界,也就是四个点:UL,UR,BL,BR
  • 还创建了一个迭代器lit,还不太清楚是做什么用的
  • 还有一个非常关键的函数void DivideNode(ExtractorNode &n1, ExtractorNode &n2, ExtractorNode &n3, ExtractorNode &n4);

该函数用来实现节点的分裂,具体原理之后介绍
而后进入一个初始节点次数的循环:

// Step 2 生成指定个数的初始提取器节点
for (int i = 0; i < nIni; i++)
{
    //生成一个提取器节点
    ExtractorNode ni;

    //设置提取器节点的图像边界
    //注意这里和提取FAST角点区域相同,都是“半径扩充图像”,特征点坐标从0 开始
    ni.UL = cv::Point2i(hX * static_cast<float>(i), 0);     //UpLeft
    ni.UR = cv::Point2i(hX * static_cast<float>(i + 1), 0); //UpRight
    ni.BL = cv::Point2i(ni.UL.x, maxY - minY);              //BottomLeft
    ni.BR = cv::Point2i(ni.UR.x, maxY - minY);              //BottomRight

    //重设vkeys大小
    ni.vKeys.reserve(vToDistributeKeys.size());

    //将刚才生成的提取节点添加到列表中
    //虽然这里的ni是局部变量,但是由于这里的push_back()是拷贝参数的内容到一个新的对象中然后再添加到列表中
    //所以当本函数退出之后这里的内存不会成为“野指针”
    lNodes.push_back(ni);
    //存储这个初始的提取器节点句柄
    vpIniNodes[i] = &lNodes.back();
}

在该循环中,首先初始化一个节点提取器ni,然后对其四个角点进行赋值
注意,初始节点是根据图像长边分配的,所以两个下方的角点纵坐标都是图像宽度maxY - minY
然后重设了vKeys的大小,也就是等待分配的全部特征点的数量(这里如果初始节点有多个,也是超量设置的)
之后将节点放入链表lNodes后边,并且设置指针容器vpIniNodes[i]指向当前链表lNodes的末位
总结一下:
该步完成了初始节点各节点角落位置坐标的定义,并且将初始节点依次放入节点链表,并用指针指向了他们
然后将特征点分配到各个初始节点中:

//Associate points to childs
// Step 3 将特征点分配到子提取器节点中
// 开始遍历等待分配的特征点
for (size_t i = 0; i < vToDistributeKeys.size(); i++)
{
    //获取这个特征点对象
    const cv::KeyPoint &kp = vToDistributeKeys[i];
    //按特征点的横轴位置,分配给属于那个图像区域的提取器节点(最初的提取器节点)
    vpIniNodes[kp.pt.x / hX]->vKeys.push_back(kp);
    //! 这里是不是很巧妙呀!!!!
}

这里对所有特征点进行了遍历,首先创建了一个中间变量&kp来保存某个特征点
然后我们刚刚不是把特征点保存进了指针容器vpIniNodes[i]中了吗
这里把特征点坐标的横坐标拿出来除以初始节点x轴长度hX,这样自然而然就落入了相应的节点中
这是怎么回事呢?
假设图片是128×64的,那么初始节点有两个,其x轴长度hX为64,如果这时有一个特征点x = 20,用20除以64取整就是0,所以落入了vpIniNodes[0]中,也就是第一个初始节点
我觉得这还是挺有趣的,程序很多地方都利用了这种“舍入关系”,很巧妙
这里将特征点依据横轴位置,放入了各个节点的关键点vectorvKey
程序继续执行,对这些节点进行有效性判定

// Step 4 遍历此提取器节点列表,标记那些不可再分裂的节点,删除那些没有分配到特征点的节点
// ? 这个步骤是必要的吗?感觉可以省略,通过判断nIni个数和vKeys.size() 就可以吧
list<ExtractorNode>::iterator lit = lNodes.begin();
while (lit != lNodes.end())
{
    //如果初始的提取器节点所分配到的特征点个数为1
    if (lit->vKeys.size() == 1)
    {
        //那么就标志位置位,表示此节点不可再分
        lit->bNoMore = true;
        //更新迭代器
        lit++;
    }
    ///如果一个提取器节点没有被分配到特征点,那么就从列表中直接删除它
    else if (lit->vKeys.empty())
        //注意,由于是直接删除了它,所以这里的迭代器没有必要更新;否则反而会造成跳过元素的情况
        lit = lNodes.erase(lit);
    else
        //如果上面的这些情况和当前的特征点提取器节点无关,那么就只是更新迭代器
        lit++;
}

首先创建了迭代器lit,用来遍历各个节点
在遍历过程中:

  • 若该节点特征点数为1,就置位标志位bNoMore,表示该节点不可分
  • 若该节点内没有特征点,就直接删除,注意这里迭代器不用加1,因为链表直接把该点抹去了

以下为年后更新内容:
上边的解释其实还不是很充分,它实际上是完成了对所有节点属性的一个判断,为之后遍历节点做了准备
这里的节点有三种属性:

节点内只有一个特征点,不可再分
节点中没有特征点,直接删除
节点中有多个特征点,可以进一步处理,在此处不做特殊“标记”

这之后开始对所有节点进行遍历
从这里开始其实也不再关心是不是初始节点或怎么样的,而是套用节点分裂的操作
首先定义了一个遍历结束标志位bFinish
然后定义了一个存储待分裂节点的变量vSizeAndPointerToNode

//声明一个vector用于存储节点的vSize和句柄对
//这个变量记录了在一次分裂循环中,那些可以再继续进行分裂的节点中包含的特征点数目和其句柄
vector<pair<int, ExtractorNode *>> vSizeAndPointerToNode;

这里使用了一个关键字pair简单理解就是将两种类型放到一起了,也就是说在这里一个元素包括了int类型和ExtractorNode类型
这在后边的赋值过程中同样能看出来
然后就是一个最大的循环:

while (!bFinish)
{……}

在结束标志位置位前,该循环将不断循环
进入循环,是一些基本的初始化清零等工作,然后进入迭代器遍历列表中每个节点:

while (lit != lNodes.end())
{
    //如果提取器节点只有一个特征点,
    if (lit->bNoMore)
    {
        // If node only contains one point do not subdivide and continue
        //那么就没有必要再进行细分了
        lit++;
        //跳过当前节点,继续下一个
        continue;
    }
    else
    {

首先对只有一个特征点的节点进行了跳过处理,该标志位来源于之前遍历的置位与否
之后就认为其他的节点都是可以继续分裂的
然后创建了四个节点n1-n4,用来作为当前节点的子节点
调用DivideNode()函数来实现节点的分裂,进入该函数:

//如果当前的提取器节点具有超过一个的特征点,那么就要进行继续细分
ExtractorNode n1, n2, n3, n4;

//再细分成四个子区域
lit->DivideNode(n1, n2, n3, n4);

依次完成了子节点边界信息的赋值、分配特征点进入各个子节点、标记是否不可继续分裂
所谓的子节点边界信息,可以参照下图:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第12张图片

回到原函数,对每个子节点还有进行一些操作:

if (n1.vKeys.size() > 0)
{
    //注意这里也是添加到列表前面的
    lNodes.push_front(n1);

    //再判断其中子提取器节点中的特征点数目是否大于1
    if (n1.vKeys.size() > 1)
    {
        //如果有超过一个的特征点,那么“待展开的节点计数++”
        nToExpand++;

        //保存这个特征点数目和节点指针的信息
        vSizeAndPointerToNode.push_back(make_pair(n1.vKeys.size(), &lNodes.front()));

        //?这个访问用的句柄貌似并没有用到?
        // lNodes.front().lit 和前面的迭代的lit 不同,只是名字相同而已
        // lNodes.front().lit是node结构体里的一个指针用来记录节点的位置
        // 迭代的lit 是while循环里作者命名的遍历的指针名称
        lNodes.front().lit = lNodes.begin();
    }
}

这里是只要有特征点,就将该节点放入列表中,注意是放到了列表的最前边
并且对于待分裂特征点,保存了句柄和特征点数目信息
并在最后重新标定节点的起始位置
这里是对一个子节点完成了操作,之后依次对其余三个也要这么做
执行完之后,删除当前的节点,因为已经完成了分裂:

//当这个母节点expand之后就从列表中删除它了,能够进行分裂操作说明至少有一个子节点的区域中特征点的数量是>1的
//? 分裂方式是后加的先分裂,先加的后分裂。
lit = lNodes.erase(lit);

然后进行一次判断,如果节点数超过了需求量或者此时节点数量等于了分裂前的节点数量prevSize,就认为不用再继续循环了:

//1、当前的节点数已经超过了要求的特征点数
//2、当前所有的节点中都只包含一个特征点
if ((int)lNodes.size() >= N            //判断是否超过了要求的特征点数
    || (int)lNodes.size() == prevSize) //prevSize中保存的是分裂之前的节点个数,如果分裂之前和分裂之后的总节点个数一样,说明当前所有的
//节点区域中只有一个特征点,已经不能够再细分了
{
    //停止标志置位
    bFinish = true;
}

或者再进行一次判断,若当前节点数量加上待分裂节点数量的3倍之和大于需求的特征点数目,意味着马上就要结束遍历了,就会进行特别的处理
首先解释为什么是加上3倍,因为对于待分裂的节点,1个变四个,所以就是1+1*3,这里的3就是这么来的
然后说一下什么特殊的处理,当满足判断条件后,又进入了一个以结束条件为循环条件的循环:while (!bFinish),和最外层的大循环一样
进去之后其实和之前的分裂没什么太大区别,我觉得比较关键的就是它在处理各个节点时,进行了排序:

// 对需要划分的节点进行排序,对pair对的第一个元素进行排序,默认是从小到大排序
// 优先分裂特征点多的节点,使得特征点密集的区域保留更少的特征点
//! 注意这里的排序规则非常重要!会导致每次最后产生的特征点都不一样。建议使用 stable_sort
sort(vPrevSizeAndPointerToNode.begin(), vPrevSizeAndPointerToNode.end()); //排序效果是特征点越少的靠前,越多的靠后

我个人还不是很懂为什么要优先处理特征点多的节点,我认为还是防止特征点太扎堆吧,如果特征点本来就稀疏还先处理,反而会更稀疏
和之前同样的条件满足后,就可以跳出这个循环了
这时我们获得了足够多的节点,但是有的节点内还有多个特征点,需要用非极大值抑制:

//使用这个vector来存储我们感兴趣的特征点的过滤结果
vector<cv::KeyPoint> vResultKeys;

//调整大小为要提取的特征点数目
vResultKeys.reserve(nfeatures);

//遍历这个节点列表
for (list<ExtractorNode>::iterator lit = lNodes.begin(); lit != lNodes.end(); lit++)
{
    //得到这个节点区域中的特征点容器句柄
    vector<cv::KeyPoint> &vNodeKeys = lit->vKeys;

    //得到指向第一个特征点的指针,后面作为最大响应值对应的关键点
    cv::KeyPoint *pKP = &vNodeKeys[0];

    //用第1个关键点响应值初始化最大响应值
    float maxResponse = pKP->response;

    //开始遍历这个节点区域中的特征点容器中的特征点,注意是从1开始哟,0已经用过了
    for (size_t k = 1; k < vNodeKeys.size(); k++)
    {
        //更新最大响应值
        if (vNodeKeys[k].response > maxResponse)
        {
            //更新pKP指向具有最大响应值的keypoints
            pKP = &vNodeKeys[k];
            maxResponse = vNodeKeys[k].response;
        }
    }

    //将这个节点区域中的响应值最大的特征点加入最终结果容器
    vResultKeys.push_back(*pKP);
}

//返回最终结果容器,其中保存有分裂出来的区域中,我们最感兴趣、响应值最大的特征点
return vResultKeys;

这里使用计算FAST时得到的响应值进行比较,遍历各个节点,最后每个节点仅保留响应值最大的那个特征点
返回最终的结果关键点向量vResultKeys

特征点角度计算(灰度质心)

书接上回,对于ComputeKeyPointsOctTree()函数,还有最后一点没有执行完,内容如下:

//PATCH_SIZE是对于底层的初始图像来说的,现在要根据当前图层的尺度缩放倍数进行缩放得到缩放后的PATCH大小 和特征点的方向计算有关
const int scaledPatchSize = PATCH_SIZE * mvScaleFactor[level]; //PATCH_SIZE是灰度质心圆最初始的直径

这里首先计算了当前层灰度质心圆周缩放后的直径大小,存入了scaledPatchSize
等式右侧的PATCH_SIZE是第零层的圆周直径大小
这里我还不清楚为什么要每层对特征点“半径”进行缩放,不知道这是要干嘛❓
然后对于这个初始的直径是31,这里我觉得设置这个大小是为了配合描述子计算点对
之前提到过描述子点对都在特征点附近半径15的圆形区域里,所以我就不理解为什么这个半径也要缩放了
继续执行,遍历该层所有的特征点,用来:恢复坐标、记录特征点所处层数、记录特征点半径:


// Add border to coordinates and scale information
//获取剔除过程后保留下来的特征点数目
const int nkps = keypoints.size();
//然后开始遍历这些特征点,恢复其在当前图层图像坐标系下的坐标
for (int i = 0; i < nkps; i++)
{
    //对每一个保留下来的特征点,恢复到相对于当前图层“边缘扩充图像下”的坐标系的坐标
    keypoints[i].pt.x += minBorderX;
    keypoints[i].pt.y += minBorderY;
    //记录特征点来源的图像金字塔图层
    keypoints[i].octave = level; //octave这是特征点的一个成员变量
    //?记录计算方向的patch,缩放后对应的大小, 又被称作为特征点半径(所放之后的哦,为什么要这样做嘞)
    keypoints[i].size = scaledPatchSize;
}

这里提一下坐标恢复的问题,在此之前对于原始图片(0,0)处的特征点,是处于半径扩充图像下的,也就是(3,3),此时进一步恢复到边缘扩充图像中,即再加一个minBorderXminBorderY
怕你忘了: 这两个变量是半径扩充图像的左上角坐标:
零基础学习ORB-SLAM2特征点提取-从原理到源码【李哈哈】_第13张图片到这里,对于各层的循环就结束了,紧接着又是一个对各层图像的循环:

// compute orientations
//然后计算这些特征点的方向信息,注意这里还是分层计算的
for (int level = 0; level < nlevels; ++level)
    computeOrientation(mvImagePyramid[level], //对应的图层的图像
                       allKeypoints[level],   //这个图层中提取并保留下来的特征点容器
                       umax);                 //以及PATCH的横坐标边界,umax是保存灰度质心圆不同行最大横坐标的向量

这里其实就是循环调用了一个函数computeOrientation(),该函数为每个特征点计算了方向
进入该函数:

static void computeOrientation(const Mat &image, vector<KeyPoint> &keypoints, const vector<int> &umax)
{
    // 遍历所有的特征点
    for (vector<KeyPoint>::iterator keypoint = keypoints.begin(),
                                    keypointEnd = keypoints.end();
         keypoint != keypointEnd; ++keypoint)
    {
        // 调用IC_Angle 函数计算这个特征点的方向
        keypoint->angle = IC_Angle(image,        //特征点所在的图层的图像
                                   keypoint->pt, //特征点在这张图像中的坐标
                                   umax);        //每个特征点所在图像区块的每行的边界 u_max 组成的vector
    }
}

其实就是遍历当前层各个特征点,为每个特征点赋值angle,这里又调用了函数IC_Angle()
函数内容如下:

static float IC_Angle(const Mat &image, Point2f pt, const vector<int> &u_max)
{
    //图像的矩,前者是按照图像块的y坐标加权,后者是按照图像块的x坐标加权
    int m_01 = 0, m_10 = 0;

    //获得这个特征点所在的图像块的中心点坐标灰度值的指针center
    const uchar *center = &image.at<uchar>(cvRound(pt.y), cvRound(pt.x));

    // Treat the center line differently, v=0
    //这条v=0中心线的计算需要特殊对待
    //由于是中心行+若干行对,所以PATCH_SIZE应该是个奇数
    for (int u = -HALF_PATCH_SIZE; u <= HALF_PATCH_SIZE; ++u)
        //注意这里的center下标u可以是负的!中心水平线上的像素按x坐标(也就是u坐标)加权
        m_10 += u * center[u]; //此时v等于0,所以不做纵坐标的加权

    // Go line by line in the circular patch
    //这里的step1表示这个图像一行包含的字节总数。参考[https://blog.csdn.net/qianqing13579/article/details/45318279]
    int step = (int)image.step1();
    //注意这里是以v=0中心线为对称轴,然后对称地每成对的两行之间进行遍历,这样处理加快了计算速度
    for (int v = 1; v <= HALF_PATCH_SIZE; ++v)
    {
        // Proceed over the two lines
        //本来m_01应该是一列一列地计算的,但是由于对称以及坐标x,y正负的原因,可以一次计算两行
        int v_sum = 0;
        // 获取某行像素横坐标的最大范围,注意这里的图像块是圆形的!
        int d = u_max[v];
        //在坐标范围内挨个像素遍历,实际是一次遍历2个
        // 假设每次处理的两个点坐标,中心线下方为(x,y),中心线上方为(x,-y)
        // 对于某次待处理的两个点:m_10 = Σ x*I(x,y) =  x*I(x,y) + x*I(x,-y) = x*(I(x,y) + I(x,-y))
        // 对于某次待处理的两个点:m_01 = Σ y*I(x,y) =  y*I(x,y) - y*I(x,-y) = y*(I(x,y) - I(x,-y))
        for (int u = -d; u <= d; ++u)
        {
            //得到需要进行加运算和减运算的像素灰度值
            //val_plus:在中心线下方x=u时的的像素灰度值
            //val_minus:在中心线上方x=u时的像素灰度值
            int val_plus = center[u + v * step], val_minus = center[u - v * step];
            //在v(y轴)上,2行所有像素灰度值之差
            v_sum += (val_plus - val_minus); //因为相对的纵坐标是负数,所以是减法哦
            //u轴(也就是x轴)方向上用u坐标加权和(u坐标也有正负符号),相当于同时计算两行
            m_10 += u * (val_plus + val_minus);
        }
        //将这一行上的和按照y坐标加权
        m_01 += v * v_sum;
    }

    //为了加快速度还使用了fastAtan2()函数,输出为[0,360)角度,精度为0.3°
    return fastAtan2((float)m_01, (float)m_10); //返回的是角度信息
}

这里的基本思想就是之前另外一篇博客里讲的为角点添加旋转属性,即计算灰度质心
有几个比较关键的点说一下:

  • const uchar *center定义了指针center来表示特征点中央位置像素地址,可以使用[]来解引用得到某点的像素值
  • 中央行遍历是单独的,之后每次遍历相对横轴对称的两行
  • 程序先计算了全部的加权坐标分子,然后直接调用最后的fastAtan2()函数得到了角度

这样我们就为每个特征点计算了角度,使之具备了旋转不变性

计算特征点描述子

到这里,函数ComputeKeyPointsOctTree()就执行完了
由此可见,该函数完成了特征点的提取和分配筛选,并计算了灰度质心,赋予了特征点角度信息
之后还回到仿函数,进行描述子计算,该部分包括以下几步:

  1. 逐层循环,对图像进行高斯滤波
  2. 计算描述子
  3. 将非零层金字塔坐标缩放至第零层坐标

源代码中,先进行了一些初始的复制工作,进入对图像金字塔的循环
首先完成了高斯滤波:

// 注意:提取特征点的时候,使用的是清晰的原图像;这里计算描述子的时候,为了避免图像噪声的影响,使用了高斯模糊
GaussianBlur(workingMat,          //源图像
             workingMat,          //输出图像
             Size(7, 7),          //高斯滤波器kernel大小,必须为正的奇数
             2,                   //高斯滤波在x方向的标准差
             2,                   //高斯滤波在y方向的标准差
             BORDER_REFLECT_101); //边缘拓展点插值类型

高斯滤波作用:
在提取特征点时用的是原图,而在计算描述子的时候,为了防止锐利的像素影响描述子计算,进行了高斯滤波
这里采用的是7×7的高斯滤波核,经过滤波,使原图更加平滑
高斯滤波后,进行描述子计算:

// Compute the descriptors 计算描述子
// desc存储当前图层的描述子 浅拷贝计算结果直接导致了descriptors的改变
Mat desc = descriptors.rowRange(offset, offset + nkeypointsLevel);
// Step 6 计算高斯模糊后图像的描述子
computeDescriptors(workingMat, //高斯模糊之后的图层图像
                   keypoints,  //当前图层中的特征点集合
                   desc,       //存储计算之后的描述子
                   pattern);   //随机采样点集

进入函数computeDescriptors()

static void computeDescriptors(const Mat &image, vector<KeyPoint> &keypoints, Mat &descriptors,
                                   const vector<Point> &pattern)
{
    //清空保存描述子信息的容器
    descriptors = Mat::zeros((int)keypoints.size(), 32, CV_8UC1);

    //开始遍历特征点
    for (size_t i = 0; i < keypoints.size(); i++)
        //计算这个特征点的描述子
        computeOrbDescriptor(keypoints[i],             //要计算描述子的特征点
                             image,                    //以及其图像
                             &pattern[0],              //随机点集的首地址
                             descriptors.ptr((int)i)); //提取出来的描述子的保存位置
}

这里对该层图像的各个特征点进行了遍历,计算描述子,进入函数computeOrbDescriptor()
在该函数中,完成了描述子的提取
这里使用了位操作,将描述子保存到变量desc
之后对于非零层金字塔,调整其坐标关系,使之相当于在第零层:

if (level != 0)
{
    // 获取当前图层上的缩放系数
    float scale = mvScaleFactor[level];
    // 遍历本层所有的特征点
    for (vector<KeyPoint>::iterator keypoint = keypoints.begin(),
                                    keypointEnd = keypoints.end();
         keypoint != keypointEnd; ++keypoint)
        // 特征点本身直接乘缩放倍数就可以了
        keypoint->pt *= scale;
}

之后将特征点放入向量_keypoints中,作为返回值:

_keypoints.insert(_keypoints.end(), keypoints.begin(), keypoints.end());

变量前边有_表示是函数的返回值
这样该仿函数就结束了,特征点及相应的描述子分别保存在了_keypoints_descriptors之中

你可能感兴趣的:(李哈哈的SLAM,slam,c++,linux,ubuntu,计算机视觉)