本次主要介绍一下ORBSLAM2中特征点提取器的构造函数ORBextractor::ORBextractor
,这个函数非常重要,里面主要完成了每一层图像金字塔分配层对应的特征点数量的分配以及计算特征点旋转方向需要的索引值的计算。
首先是构造函数的参数:
ORBextractor::ORBextractor(int _nfeatures, //指定要提取的特征点数目
float _scaleFactor, //指定图像金字塔的缩放系数
int _nlevels, //指定图像金字塔的层数
int _iniThFAST, //指定初始的FAST特征点提取阈值,可以提取出最明显的角点
int _minThFAST): //如果初始阈值没有检测到角点,降低到这个阈值提取出弱一点的角点
nfeatures(_nfeatures), scaleFactor(_scaleFactor), nlevels(_nlevels),
iniThFAST(_iniThFAST), minThFAST(_minThFAST)//设置这些参数
在构造函数中主要完成了以下功能:
-
对每一层图像金字塔分配对应的特征点数量:
首先,我们需要了解一下什么是图像金字塔,如下图所示
图像金字塔其实就是将原始图像按照一定的比例逐级缩放,形成一个金字塔形状的排列,层数越高,对应的图像尺寸越小。那么,我们为什么需要图像金字塔呢?主要原因是在两帧之间一个相同的特征点距离相机的远近是不同的,也就是他们的尺度不同。那么如果我们都在一样大小的图像上提取这两个特征点,本来对应同一个点的两个特征点由于近大远小的关系得到的描述子就会不同,这显然不是我们所期望的。所以我们需要使用图像金字塔来保证特征点提取的尺度不变性。那么既然图像金字塔中每一层图像的大小都不同,特征点数量当然不能进行均匀分配,这里作者选择的是根据图像的缩放因子,也可以理解为边长进行分配。注意这里作者选择的不是面积而是
边长
。具体的数学推导可以参考这里我们来看对应的代码:
//存储每层图像缩放系数的vector调整为符合图层数目的大小 mvScaleFactor.resize(nlevels); //存储这个sigma^2,其实就是每层图像相对初始图像缩放因子的平方 mvLevelSigma2.resize(nlevels); //对于初始图像,这两个参数都是1 mvScaleFactor[0]=1.0f; mvLevelSigma2[0]=1.0f; //然后逐层计算图像金字塔中图像相当于初始图像的缩放系数 for(int i=1; i<nlevels; i++) { //其实就是这样累乘计算得出来的 mvScaleFactor[i]=mvScaleFactor[i-1]*scaleFactor; //原来这里的sigma^2就是每层图像相对于初始图像缩放因子的平方 mvLevelSigma2[i]=mvScaleFactor[i]*mvScaleFactor[i]; } //接下来的两个向量保存上面的参数的倒数 mvInvScaleFactor.resize(nlevels); mvInvLevelSigma2.resize(nlevels); for(int i=0; i<nlevels; i++) { mvInvScaleFactor[i]=1.0f/mvScaleFactor[i]; mvInvLevelSigma2[i]=1.0f/mvLevelSigma2[i]; } //调整图像金字塔vector以使得其符合设定的图像层数 mvImagePyramid.resize(nlevels); //每层需要提取出来的特征点个数,这个向量也要根据图像金字塔设定的层数进行调整 mnFeaturesPerLevel.resize(nlevels); //图片降采样缩放系数的倒数 float factor = 1.0f / scaleFactor; //第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); //累计 sumFeatures += mnFeaturesPerLevel[level]; //乘系数 nDesiredFeaturesPerScale *= factor; } //由于前面的特征点个数取整操作,可能会导致剩余一些特征点个数没有被分配,所以这里就将这个余出来的特征点分配到最高的图层中 mnFeaturesPerLevel[nlevels-1] = std::max(nfeatures - sumFeatures, 0);
-
将计算描述子时需要用到的
pattern
从int []
转换成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_; //使用std::back_inserter的目的是可以快覆盖掉这个容器pattern之前的数据 //其实这里的操作就是,将在全局变量区域的、int格式的随机采样点以cv::point格式复制到当前类对象中的成员变量中 std::copy(pattern0, pattern0 + npoints, std::back_inserter(pattern));
-
计算提取圆形区域内像素灰度来计算灰度重心需要的边界索引:
首先我们需要明白为什么计算灰度重心选择的是圆形区域而不是方形区域,先看下面一张图
从图中我们可以看出如果我们选方形区域,那么旋转之后同样的方形区域内的像素和旋转之前的并不是完全相同的像素,这样计算出来的灰度质心代表的并不是旋转之前区域的灰度质心,那么在进行反向旋转之后得到的区域也是不同的。这样两个相同的特征点计算出来的描述子就会是不同的,无法保证旋转不变性,所以这里我们选择圆形区域。
当选择了圆形区域后,另外一个问题就出现了,如何索引一个圆形区域内的像素,或者说如何获得圆形区域对应的边界点索引,这里为了理解代码我们需要看下面一幅图:
那么如何理解这幅图呢?前面我们说了要想索引圆形区域最关键的就是获取圆形区域边界的索引,在这里其实就是圆形区域边界圆弧上点的坐标。作者选择的方法是勾股定理,其中的v代表边界圆弧上点的纵坐标,对应的u就代表了根据勾股定理计算出来的点的横坐标。这里由于像素坐标是整数,所以对勾股定理计算出来的浮点数需要进行向下取整,这就可能导致出现索引不对称的情况,所以作者选择分成了两个八分之一圆弧进行计算,首先计算下半部分//cvFloor返回不大于参数的最大整数值,cvCeil返回不小于参数的最小整数值,cvRound则是四舍五入 int v, //循环辅助变量 v0, //辅助变量 vmax = cvFloor(HALF_PATCH_SIZE * sqrt(2.f) / 2 + 1); //计算圆的最大行号,+1应该是把中间行也给考虑进去了 //NOTICE 注意这里的最大行号指的是计算的时候的最大行号,此行的和圆的角点在45°圆心角的一边上,之所以这样选择 //是因为圆周上的对称特性 //这里的二分之根2就是对应那个45°圆心角 int vmin = cvCeil(HALF_PATCH_SIZE * sqrt(2.f) / 2); //半径的平方 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; }
注意这里的umax[v]其实就代表了右上部分四分之一圆弧上v行对应的最大的u值