ORB-SLAM2源码——ORBextractor.cc
本篇主要关于ORB特征点提取。
关键的原理部分分别是特征点的方向和描述子的计算,因此可以先看这一篇文章带入了解一下特征点提取原理。常用特征点及其描述子总结,为了方便理解,稍微调换了一下本来 ORBextractor.cc 中的一些函数的顺序。
帮助自己更好的理解代码,并且添加一些自己在看代码时候的想法。
里面涉及到的高斯图像金字塔的知识,参考图像处理中的高斯金字塔和拉普拉斯金字塔
const int PATCH_SIZE = 31; ///<使用灰度质心法计算特征点的方向信息时,图像块的大小,或者说是直径
const int HALF_PATCH_SIZE = 15; ///<上面这个大小的一半,或者说是半径
const int EDGE_THRESHOLD = 19; ///<算法生成的图像边
//生成这个边的目的是进行图像金子塔的生成时,需要对图像进行高斯滤波处理,为了考虑到使滤波后的图像边界处的像素也能够携带有正确的图像信息,作者就将原图像扩大了一个边。
/**
* @brief 这个函数用于计算特征点的方向,这里是返回角度作为方向。
*/
static float IC_Angle(const Mat& image, Point2f pt, const vector<int> & u_max){
}
///乘数因子,一度对应着多少弧度
const float factorPI = (float)(CV_PI/180.f);
/**
* @brief 计算ORB特征点的描述子。注意这个是全局的静态函数,只能是在本文件内被调用
*/
static void computeOrbDescriptor(const KeyPoint& kpt, const Mat& img, const Point* pattern, uchar* desc){
}
//下面就是预先定义好的随机点集,256是指可以提取出256bit的描述子信息,每个bit由一对点比较得来;4=2*2,前面的2是需要两个点(一对点)进行比较,后面的2是一个点有两个坐标
static int bit_pattern_31_[256*4]
1. 提取特征点的原理部分代码
1.1. 计算特征点的方向
用于计算图像中关键点的“主方向”,使得提取的特征点具有旋转不变性。关键点指的是某个特征点在图像中的位置。ORB-SLAM2中使用的是灰度质心法。也就是下面的 IC_Angle 函数。在 computeOrientation 函数中遍历图像中的特征点,然后进行方向的计算。
/**
* @brief 计算特征点的方向
* @param[in] image 特征点所在当前金字塔的图像
* @param[in & out] keypoints 特征点向量
* @param[in] umax 每个特征点所在图像区块的每行的边界 u_max 组成的vector
*/
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
}
}
灰度质心法函数 IC_Angle
灰度质心法。灰度质心是将图像中像素的灰度值作为权重的中心。一般取关键点周围的圆形区域进行计算。需要注意几何中心和灰度质心的区别以及两个点之间的联系,因为最后是以几何中心和灰度质心的连线作为特征点方向的。上面定义的前两个变量 PATCH_SIZE 和 HALF_PATCH_SIZE,表示的就是关键点取像素的圆形区域的直径和半径。
/**
* @brief 这个函数用于计算特征点的方向,这里是返回角度作为方向。
* @param[in] image 要进行操作的某层金字塔图像
* @param[in] pt 当前特征点的坐标
* @param[in] u_max 图像块的每一行的坐标边界 u_max
* @return float 返回特征点的角度,范围为[0,360)角度,精度为0.3°
*/
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));
//这条v=0中心线的计算需要特殊对待
//后面是以中心行为对称轴,成对遍历行数,所以PATCH_SIZE必须是奇数
for (int u = -HALF_PATCH_SIZE; u <= HALF_PATCH_SIZE; ++u)
//注意这里的center下标u可以是负的!中心水平线上的像素按x坐标(也就是u坐标)加权
m_10 += u * center[u];
// 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°。以X轴正方向为0°方向,角度确定按照逆时针方向,值域为 -pai 到 pai。
return fastAtan2((float)m_01, (float)m_10);
}
1.2. 计算特征点描述子
计算某层金字塔图像上特征点的描述子。
前面确定了关键点之后,需要用描述子对关键点的像素信息进行量化。描述子计算的相关原理可以参考该博客:ORB描述子steer brief计算原理。在 computeDescriptors 函数中遍历某一层金字塔的图像中的特征点,然后进行描述子的计算。
/**
* @brief 计算某层金字塔图像上特征点的描述子
*
* @param[in] image 某层金字塔图像
* @param[in] keypoints 特征点vector容器
* @param[out] descriptors 描述子
* @param[in] pattern 计算描述子使用的固定随机点集
*/
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)); //提取出来的描述子的保存位置
}
计算ORB特征点描述子函数 computeOrbDescriptor。
首先BRIEF是一个二进制的描述子,计算和匹配的速度都很快。基本思想是:以关键点为中心,规定一个区域,在选定的区域中找n个点对(按一定规则,n=256),比较点对的灰度大小,将结果存为1或0。256个点对,所以描述子是256位的二进制数。匹配是根据汉明距离,查看不相同的位数有多少。但不具备旋转不变性。
而 ORB-SLAM2 中的 Rotation BRIEF 在原本的 BRIEF 的基础上因为特征点的方向已知,通过 IC_Angle 函数算出。然后先旋转到特征点方向,再去找点对进行比较,通过加入关键点的方向来计算描述子,使其具有较好旋转不变特性。
但是这样会导致特征描述量的可区分性下降(一种理解是有向角特征点在二值测试中体现出更高均衡性。)RBRIEF 不使用BRIEF传统取点对的方法,而是使用统计学习的方法来重新选择点对集合,目的是增大其特征描述量的可区分性。
ORB-SLAM2中有一套预先定义好的选点模板,static int bit_pattern_31_[256*4] 用来保证描述子具有较高的辨识度。其中,256是指可以提取出 256bit 的描述子信息,每个bit由一对点比较得来;4=2*2,前面的2是需要两个点(一对点)进行比较,后面的2是一个点有两个坐标。
注意:
- computeOrbDescriptor 这个函数是全局静态函数,只能是在本文件 ORBextractor.cc 内被调用。
- 函数中宏定义部分总体的意思为:给一点idx,从pattern表中找到一点pattern[idx],然后计算这个点经过旋转之后的点,获得旋转后的点的灰度值。其中 y’* step + x’ 是图像内存中存储的点的位置,step是一行的字节总数。然后再通过 center 获得该位置的点的灰度值 (.at 函数)。
/**
* @brief 计算ORB特征点的描述子
* @param[in] kpt 特征点对象
* @param[in] img 提取特征点的图像
* @param[in] pattern 预定义好的采样模板
* @param[out] desc 用作输出变量,保存计算好的描述子,维度为32*8 = 256 bit
*/
static void computeOrbDescriptor(const KeyPoint& kpt, const Mat& img, const Point* pattern, uchar* desc)
{
//得到特征点的角度,用弧度制表示。其中kpt.angle是角度制,范围为[0,360)度
float angle = (float)kpt.angle*factorPI;
//计算这个角度的余弦值和正弦值
float a = (float)cos(angle), b = (float)sin(angle);
//获得图像中心指针
const uchar* center = &img.at<uchar>(cvRound(kpt.pt.y), cvRound(kpt.pt.x));
//获得图像的每行的字节数
const int step = (int)img.step;
//原始的BRIEF描述子没有方向不变性,通过加入关键点的方向来计算描述子,称之为Steered BRIEF,具有较好旋转不变特性
//具体地,在计算的时候需要将这里选取的采样模板中点的x轴方向旋转到特征点的方向。
//获得采样点中某个idx所对应的点的灰度值,这里旋转前坐标为(x,y), 旋转后坐标(x',y'),他们的变换关系:
// x'= xcos(θ) - ysin(θ), y'= xsin(θ) + ycos(θ)
// 下面表示 y'* step + x'
#define GET_VALUE(idx) center[cvRound(pattern[idx].x*b + pattern[idx].y*a)*step + cvRound(pattern[idx].x*a - pattern[idx].y*b)]
//brief描述子由 32*8 位组成
//其中每一位是来自于两个像素点灰度的直接比较,所以每比较出 8bit 结果,需要16个随机点,这也就是为什么pattern需要+=16的原因
for (int i = 0; i < 32; ++i, pattern += 16)
{
int t0, //参与比较的第1个特征点的灰度值
t1, //参与比较的第2个特征点的灰度值
val; //描述子这个字节的比较结果,0或1
t0 = GET_VALUE(0); t1 = GET_VALUE(1);
val = t0 < t1; //描述子本字节的bit0
t0 = GET_VALUE(2); t1 = GET_VALUE(3);
val |= (t0 < t1) << 1; //描述子本字节的bit1
t0 = GET_VALUE(4); t1 = GET_VALUE(5);
val |= (t0 < t1) << 2; //描述子本字节的bit2
t0 = GET_VALUE(6); t1 = GET_VALUE(7);
val |= (t0 < t1) << 3; //描述子本字节的bit3
t0 = GET_VALUE(8); t1 = GET_VALUE(9);
val |= (t0 < t1) << 4; //描述子本字节的bit4
t0 = GET_VALUE(10); t1 = GET_VALUE(11);
val |= (t0 < t1) << 5; //描述子本字节的bit5
t0 = GET_VALUE(12); t1 = GET_VALUE(13);
val |= (t0 < t1) << 6; //描述子本字节的bit6
t0 = GET_VALUE(14); t1 = GET_VALUE(15);
val |= (t0 < t1) << 7; //描述子本字节的bit7
//保存当前比较的出来的描述子的这个字节
desc[i] = (uchar)val;
}
//为了避免和程序中的其他部分冲突在,在使用完成之后就取消这个宏定义
#undef GET_VALUE
}
1.3. ORB 特征点均匀化
特征点往往集中在纹理丰富的区域,而缺乏特征的区域特征点数量会少很多。会导致一部分特征点是冗余的,且特征点分布过于集中也会影响位姿的解算。ORB-SLAM2 中采用了特征点均匀化的方法来避免特征点过于集中,原理部分可以参考这个文章。ORB特征点均匀化。
在特征点均匀化的流程中引入了四叉树的方法(后面的代码会有)。
原理中的特征点的分配策略是根据图像的面积,将总的特征点数目根据面积比例均摊到每一层图像上。但是在源码中不是按照面积来均摊的,而是按照面积的开方来均摊的,即不是 s^2 而是 s 。相关的代码在特征点提取器的构造函数 ORBextractor::ORBextractor 中。此处截取了其中均匀化部分的代码,其他初始化部分的代码在后面。
//图片降采样缩放系数的倒数
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);
使用四叉树法对一个图像金字塔图层中的特征点进行平均和分发 ORBextractor::DistributeOctTree
四叉树的计算原理可以参考这个文章。四叉树实现ORB特征点均匀化分布。
注意:
- static_cast是可以使用的最简单的类型转换。它是编译时强制转换。它可以在类型之间进行隐式转换(例如int到float,或指针到void*),它还可以调用显式转换函数(或隐式转换函数)。
- 下面的 ORBextractor::DistributeOctTree 代码中提到的响应值,一般角点响应值越高,说明越符合要求。所以最后会从每个节点中选出角点响应值最高的特征点作为最终结果。
- 函数中定义node 的 ExtractorNode 是一个类,用来分配四叉树时用到的结点类型。其中的 vKeys 保存有当前定义的节点中的特征点。
- 将当前的节点再细分为四个子区域的代码 DivideNode(n1,n2,n3,n4); 函数在下面有详解,比较简单。看一下了解即可。
/**
* @brief 使用四叉树法对一个图像金字塔图层中的特征点进行平均和分发
* @param[in] vToDistributeKeys 等待进行分配到四叉树中的特征点
* @param[in] minX 当前图层的图像的边界,坐标都是在“半径扩充图像”坐标系下的坐标
* @param[in] maxX
* @param[in] minY
* @param[in] maxY
* @param[in] N 希望提取出的特征点个数
* @param[in] level 指定的金字塔图层,并未使用
* @return vector<cv::KeyPoint> 已经均匀分散好的特征点vector容器
*/
vector<cv::KeyPoint> ORBextractor::DistributeOctTree(const vector<cv::KeyPoint>& vToDistributeKeys, const int &minX,
const int &maxX, const int &minY, const int &maxY, const int &N, const int &level)
{
// Compute how many initial nodes
// Step 1 首先确定初始的节点(node)数目,根据宽高比确定初始节点数目
//计算应该生成的初始节点个数,根节点的数量nIni是根据边界的宽高比值确定的,一般是1或者2。
// ! bug: 如果宽高比小于0.5,nIni=0, 后面hx会报错
const int nIni = round(static_cast<float>(maxX-minX)/(maxY-minY));
//一个初始的节点的x方向有多少个像素
const float hX = static_cast<float>(maxX-minX)/nIni;
//存储有提取器节点的 链表
list<ExtractorNode> lNodes;
//存储初始提取器节点指针的 vector容器
vector<ExtractorNode*> vpIniNodes;
//重新设置其大小
vpIniNodes.resize(nIni);
// 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();
}
//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);
}
// 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++;
}
//结束标志位清空
bool bFinish = false;
//记录迭代次数,只是记录,并未起到作用
int iteration = 0;
//声明一个vector用于存储节点的vSize和句柄对
//这个变量记录了在一次分裂循环中,那些可以再继续进行分裂的节点中包含的特征点数目和其句柄
vector<pair<int,ExtractorNode*> > vSizeAndPointerToNode;
//调整大小,这里的意思是一个初始化节点将“分裂”成为四个
vSizeAndPointerToNode.reserve(lNodes.size()*4);
// Step 5 利用四叉树方法对图像进行划分区域,均匀分配特征点
while(!bFinish)
{
//更新迭代次数计数器,只是记录,并未起到作用
iteration++;
//保存当前节点个数,prev在这里理解为“保留”比较好
int prevSize = lNodes.size();
//重新定位迭代器指向列表头部
lit = lNodes.begin();
//需要展开的节点计数,这个一直保持累计,不清零
int nToExpand = 0;
//因为是在循环中,前面的循环体中可能污染了这个变量,所以清空
//这个变量也只是统计了某一个循环中的点
//这个变量记录了在一次分裂循环中,那些可以再继续进行分裂的节点中包含的特征点数目和其句柄
vSizeAndPointerToNode.clear();
// 将目前的子区域进行划分
//开始遍历列表中所有的提取器节点,并进行分解或者保留
while(lit!=lNodes.end())
{
//如果提取器节点只有一个特征点,
if(lit->bNoMore)
{
// If node only contains one point do not subdivide and continue
//那么就没有必要再进行细分了
lit++;
//跳过当前节点,继续下一个
continue;
}
else
{
// If more than one point, subdivide
//如果当前的提取器节点具有超过一个的特征点,那么就要进行继续分裂
ExtractorNode n1,n2,n3,n4;
//再细分成四个子区域
lit->DivideNode(n1,n2,n3,n4);
// Add childs if they contain points
//如果这里分出来的子区域中有特征点,那么就将这个子区域的节点添加到提取器节点的列表中
//注意这里的条件是,有特征点即可
if(n1.vKeys.size()>0)
{
//注意这里也是添加到列表前面的
lNodes.push_front(n1);
//再判断其中子提取器节点中的特征点数目是否大于1
if(n1.vKeys.size()>1)
{
//如果有超过一个的特征点,那么待展开的节点计数加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();
}
}
//后面的操作都是相同的,这里不再赘述
if(n2.vKeys.size()>0)
{
lNodes.push_front(n2);
if(n2.vKeys.size()>1)
{
nToExpand++;
vSizeAndPointerToNode.push_back(make_pair(n2.vKeys.size(),&lNodes.front()));
lNodes.front().lit = lNodes.begin();
}
}
if(n3.vKeys.size()>0)
{
lNodes.push_front(n3);
if(n3.vKeys.size()>1)
{
nToExpand++;
vSizeAndPointerToNode.push_back(make_pair(n3.vKeys.size(),&lNodes.front()));
lNodes.front().lit = lNodes.begin();
}
}
if(n4.vKeys.size()>0)
{
lNodes.push_front(n4);
if(n4.vKeys.size()>1)
{
nToExpand++;
vSizeAndPointerToNode.push_back(make_pair(n4.vKeys.size(),&lNodes.front()));
lNodes.front().lit = lNodes.begin();
}
}
//当这个母节点expand之后就从列表中删除它了,能够进行分裂操作说明至少有一个子节点的区域中特征点的数量是>1的
// 分裂方式是后加的节点先分裂,先加的后分裂
lit=lNodes.erase(lit);
//继续下一次循环,其实这里加不加这句话的作用都是一样的
continue;
}//判断当前遍历到的节点中是否有超过一个的特征点
}//遍历列表中的所有提取器节点
// Finish if there are more nodes than required features or all nodes contain just one point
//停止这个过程的条件有两个,满足其中一个即可:
//1、当前的节点数已经超过了要求的特征点数
//2、当前所有的节点中都只包含一个特征点
//prevSize中保存的是分裂之前的节点个数,如果分裂之前和分裂之后的总节点个数一样,说明当前所有的节点区域中只有一个特征点,已经不能够再细分了
if((int)lNodes.size()>=N || (int)lNodes.size()==prevSize)
{
//停止标志置位
bFinish = true;
}
// Step 6 当再划分之后所有的Node数大于要求数目时,就慢慢划分直到使其刚刚达到或者超过要求的特征点个数
//可以展开的子节点个数nToExpand x3,是因为一分四之后,会删除原来的主节点,所以乘以3
/**
* //?BUG 但是我觉得这里有BUG,虽然最终作者也给误打误撞、稀里糊涂地修复了
* 注意到,这里的nToExpand变量在前面的执行过程中是一直处于累计状态的,如果因为特征点个数太少,跳过了下面的else-if,又进行了一次上面的遍历
* list的操作之后,lNodes.size()增加了,但是nToExpand也增加了,尤其是在很多次操作之后,下面的表达式:
* ((int)lNodes.size()+nToExpand*3)>N
* 会很快就被满足,但是此时只进行一次对vSizeAndPointerToNode中点进行分裂的操作是肯定不够的;
* 理想中,作者下面的for理论上只要执行一次就能满足,不过作者所考虑的“不理想情况”应该是分裂后出现的节点所在区域可能没有特征点,因此将for
* 循环放在了一个while循环里面,通过再次进行for循环、再分裂一次解决这个问题。而我所考虑的“不理想情况”则是因为前面的一次对vSizeAndPointerToNode
* 中的特征点进行for循环不够,需要将其放在另外一个循环(也就是作者所写的while循环)中不断尝试直到达到退出条件。
* */
else if(((int)lNodes.size()+nToExpand*3)>N)
{
//如果再分裂一次那么数目就要超了,这里想办法尽可能使其刚刚达到或者超过要求的特征点个数时就退出
//这里的nToExpand和vSizeAndPointerToNode不是一次循环对一次循环的关系,而是前者是累计计数,后者只保存某一个循环的
//一直循环,直到结束标志位被置位
while(!bFinish)
{
//获取当前的list中的节点个数
prevSize = lNodes.size();
//保留那些还可以分裂的节点的信息, 这里是深拷贝(int 说明有多少个特征点,ExtractorNode说明其地址)
vector<pair<int,ExtractorNode*> > vPrevSizeAndPointerToNode = vSizeAndPointerToNode;
//清空
vSizeAndPointerToNode.clear();
// 对需要划分的节点进行排序,对pair对的第一个元素进行排序,默认是从小到大排序
// 优先分裂特征点多的节点,使得特征点密集的区域保留更少的特征点
//! 注意这里的排序规则非常重要!会导致每次最后产生的特征点都不一样。建议使用 stable_sort
sort(vPrevSizeAndPointerToNode.begin(),vPrevSizeAndPointerToNode.end());
//遍历这个存储了pair对的vector,注意是从后往前遍历
for(int j=vPrevSizeAndPointerToNode.size()-1;j>=0;j--)
{
ExtractorNode n1,n2,n3,n4;
//对每个需要进行分裂的节点进行分裂
vPrevSizeAndPointerToNode[j].second->DivideNode(n1,n2,n3,n4);
// Add childs if they contain points
//其实这里的节点可以说是二级子节点了,执行和前面一样的操作
if(n1.vKeys.size()>0)
{
lNodes.push_front(n1);
if(n1.vKeys.size()>1)
{
//因为这里还有对于vSizeAndPointerToNode的操作,所以前面才会备份vSizeAndPointerToNode中的数据
//为可能的、后续的又一次for循环做准备
vSizeAndPointerToNode.push_back(make_pair(n1.vKeys.size(),&lNodes.front()));
lNodes.front().lit = lNodes.begin();
}
}
if(n2.vKeys.size()>0)
{
lNodes.push_front(n2);
if(n2.vKeys.size()>1)
{
vSizeAndPointerToNode.push_back(make_pair(n2.vKeys.size(),&lNodes.front()));
lNodes.front().lit = lNodes.begin();
}
}
if(n3.vKeys.size()>0)
{
lNodes.push_front(n3);
if(n3.vKeys.size()>1)
{
vSizeAndPointerToNode.push_back(make_pair(n3.vKeys.size(),&lNodes.front()));
lNodes.front().lit = lNodes.begin();
}
}
if(n4.vKeys.size()>0)
{
lNodes.push_front(n4);
if(n4.vKeys.size()>1)
{
vSizeAndPointerToNode.push_back(make_pair(n4.vKeys.size(),&lNodes.front()));
lNodes.front().lit = lNodes.begin();
}
}
//删除母节点,在这里其实应该是一级子节点
lNodes.erase(vPrevSizeAndPointerToNode[j].second->lit);
//判断是是否超过了需要的特征点数?是的话就退出,不是的话就继续这个分裂过程,直到刚刚达到或者超过要求的特征点个数
//作者的思想其实就是这样的,再分裂了一次之后判断下一次分裂是否会超过N,如果不是那么就放心大胆地全部进行分裂(因为少了一个判断因此
//其运算速度会稍微快一些),如果会那么就引导到这里进行最后一次分裂
if((int)lNodes.size()>=N)
break;
}//遍历vPrevSizeAndPointerToNode并对其中指定的node进行分裂,直到刚刚达到或者超过要求的特征点个数
//这里理想中应该是一个for循环就能够达成结束条件了,但是作者想的可能是,有些子节点所在的区域会没有特征点,因此很有可能一次for循环之后
//的数目还是不能够满足要求,所以还是需要判断结束条件并且再来一次
//判断是否达到了停止条件
if((int)lNodes.size()>=N || (int)lNodes.size()==prevSize)
bFinish = true;
}//一直进行nToExpand累加的节点分裂过程,直到分裂后的nodes数目刚刚达到或者超过要求的特征点数目
}//当本次分裂后达不到结束条件但是再进行一次完整的分裂之后就可以达到结束条件时
}// 根据兴趣点分布,利用4叉树方法对图像进行划分区域
// Retain the best point in each node
// Step 7 保留每个区域响应值最大的一个兴趣点
//使用这个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;
}
2. 主体代码框架部分
2.1. 特征点提取器的构造函数
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的长度,也就是点的个数,这里的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));
//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则是四舍五入
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;
}
}
3. 涉及到的内部的功能函数
3.1. DivideNode 用于在上面的四叉树中,将提取器节点分成4个子节点,同时也完成图像区域的划分、特征点归属的划分,以及相关标志位的置位
注意:ceil:在英文中,是天花板的意思,有向上的意思,所以,此函数是向上取整,它返回的是大于或等于函数参数,并且与之最接近的整数。
/**
* @param[in & out] n1 提取器节点1:左上
* @param[in & out] n2 提取器节点1:右上
* @param[in & out] n3 提取器节点1:左下
* @param[in & out] n4 提取器节点1:右下
*/
void ExtractorNode::DivideNode(ExtractorNode &n1,
ExtractorNode &n2,
ExtractorNode &n3,
ExtractorNode &n4)
{
//得到当前提取器节点所在图像区域的一半长宽,当然结果需要取整
const int halfX = ceil(static_cast<float>(UR.x-UL.x)/2);
const int halfY = ceil(static_cast<float>(BR.y-UL.y)/2);
//Define boundaries of childs
//下面的操作大同小异,将一个图像区域再细分成为四个小图像区块
//n1 存储左上区域的边界
n1.UL = UL;
n1.UR = cv::Point2i(UL.x+halfX,UL.y);
n1.BL = cv::Point2i(UL.x,UL.y+halfY);
n1.BR = cv::Point2i(UL.x+halfX,UL.y+halfY);
//用来存储在该节点对应的图像网格中提取出来的特征点的vector
n1.vKeys.reserve(vKeys.size());
//n2 存储右上区域的边界
n2.UL = n1.UR;
n2.UR = UR;
n2.BL = n1.BR;
n2.BR = cv::Point2i(UR.x,UL.y+halfY);
n2.vKeys.reserve(vKeys.size());
//n3 存储左下区域的边界
n3.UL = n1.BL;
n3.UR = n1.BR;
n3.BL = BL;
n3.BR = cv::Point2i(n1.BR.x,BL.y);
n3.vKeys.reserve(vKeys.size());
//n4 存储右下区域的边界
n4.UL = n3.UR;
n4.UR = n2.BR;
n4.BL = n3.BR;
n4.BR = BR;
n4.vKeys.reserve(vKeys.size());
//Associate points to childs
//遍历当前提取器节点的vkeys中存储的特征点
for(size_t i=0;i<vKeys.size();i++)
{
//获取这个特征点对象
const cv::KeyPoint &kp = vKeys[i];
//判断这个特征点在当前特征点提取器节点图像的哪个区域,更严格地说是属于那个子图像区块
//然后就将这个特征点追加到那个特征点提取器节点的vkeys中
//NOTICE BUG REVIEW 这里也是直接进行比较的,但是特征点的坐标是在“半径扩充图像”坐标系下的,而节点区域的坐标则是在“边缘扩充图像”坐标系下的
if(kp.pt.x<n1.UR.x)
{
if(kp.pt.y<n1.BR.y)
n1.vKeys.push_back(kp);
else
n3.vKeys.push_back(kp);
}
else if(kp.pt.y<n1.BR.y)
n2.vKeys.push_back(kp);
else
n4.vKeys.push_back(kp);
}//遍历当前提取器节点的vkeys中存储的特征点
//判断每个子特征点提取器节点所在的图像中特征点的数目(就是分配给子节点的特征点数目),然后做标记
//这里判断是否数目等于1的目的是确定这个节点还能不能再向下进行分裂
if(n1.vKeys.size()==1)
n1.bNoMore = true;
if(n2.vKeys.size()==1)
n2.bNoMore = true;
if(n3.vKeys.size()==1)
n3.bNoMore = true;
if(n4.vKeys.size()==1)
n4.bNoMore = true;
}
3.2. ComputePyramid 输入原图像,对其构建图像金字塔
/**
* @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是扩展了边界的图像,masktemp并未使用
Mat temp(wholeSize, image.type()), masktemp;
// mvImagePyramid 刚开始时是个空的vector<Mat>
// 把图像金字塔该图层的图像指针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应该改为如下
// 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给出的解释:
/*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;
}
}