1. ExtractORB
代码实现
/**
* @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()
// 仿函数又称为函数对象,是一个能行使函数功能的类,必须重载operator()运算符
// 仿函数可以拥有自己的数据成员和成员变量,通常比一般函数速度更快
(*mpORBextractorLeft)(im, // 待提取特征点的图像
cv::Mat(), // 掩摸图像, 实际没有用到
mvKeys, // 输出变量,用于保存提取后的特征点
mDescriptors); // 输出变量,用于保存特征点的描述子
else
// 右图的话就需要使用右图指定的特征点提取器,并将提取结果保存到对应的变量中
// 双目模式下,会分别使用mpORBextractorLeft和mpORBextractorRight提取ORB特征点
(*mpORBextractorRight)(im,cv::Mat(),mvKeysRight,mDescriptorsRight);
}
仿函数实现
(1)大致流程
- 检查输入图像的有效性
- ComputePyramid:利用输入图像,构建图像金字塔
- ComputeKeyPointsOctTree:计算图像金字塔中的特征点,并进行均匀分配
- GaussianBlur:对图像金字塔中的每层图像进行高斯模糊操作
- computeDescriptors:对高斯模糊后的每层图像计算描述子
- 将图像金字塔中的特征点恢复到第0层,并保存到_keypoints中
(2)代码实现
// /src/ORBextractor.cc
/**
* @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 ); // 判断图像的格式是否正确,要求是单通道灰度值
// Step 2 构建图像金字塔
ComputePyramid(image);
// Step 3 计算图像的特征点,并且将特征点进行均匀化, 均匀的特征点可以提高位姿计算精度
vector<vector<KeyPoint> > allKeypoints; // 保存提取的特征点
// 使用四叉树的方式计算每层图像的特征点并进行分配
ComputeKeyPointsOctTree(allKeypoints); // 特征点均匀化分配
// Step 4 开辟特征点的描述子空间,并转为cv::Mat的格式descriptors
Mat descriptors; // 存储整个图像金字塔中所有特征点对应的描述子
int nkeypoints = 0; // 获取特征点个数
for (int level = 0; level < nlevels; ++level)
nkeypoints += (int)allKeypoints[level].size();
if( nkeypoints == 0 )
// 若特征点个数为0,则清空描述子
_descriptors.release();
else
{
// 若存在特征点,则开辟对应的描述子空间_descriptors
_descriptors.create(nkeypoints, // 矩阵的行数,对应为特征点的总个数
32, // 矩阵的列数,对应为使用32*8=256位描述子
CV_8U); // 矩阵元素的格式
// 将描述子空间转成cv::Mat的形式
descriptors = _descriptors.getMat();
}
// 清空用作返回特征点提取结果的vector容器
_keypoints.clear();
// 并预分配正确大小的空间
_keypoints.reserve(nkeypoints);
// 寻址的偏移量,用于在总描述子mat中进行辅助定位
int offset = 0;
// 开始遍历每一层图像
for (int level = 0; level < nlevels; ++level)
{
// 获取当前层的特征点
vector<KeyPoint>& keypoints = allKeypoints[level];
// 获取当前层的特征点个数
int nkeypointsLevel = (int)keypoints.size();
// 如果特征点数目为0,跳出本次循环,继续下一层金字塔
if(nkeypointsLevel==0)
continue;
// Step 5 为了避免图像噪声的影响, 对图像进行高斯模糊
Mat workingMat = mvImagePyramid[level].clone(); // 深拷贝(.clone)当前层图像
GaussianBlur(workingMat, // 源图像
workingMat, // 输出图像
Size(7, 7), // 高斯滤波器kernel大小,必须为正的奇数
2, // 高斯滤波在x方向的标准差
2, // 高斯滤波在y方向的标准差
BORDER_REFLECT_101); // 边缘拓展点插值类型
Mat desc = descriptors.rowRange(offset, offset + nkeypointsLevel);
// Step 6 计算高斯模糊后当前层图像的描述子
computeDescriptors(workingMat, // 高斯模糊之后的当前层图像
keypoints, // 当前层图像中的特征点
desc, // 存储计算出来的当前层图像特征点的描述子
pattern); // 计算描述子的模板
// 更新偏移量的值,将上面计算出来的当前层描述子desc存储到descriptors的当前层相应位置处
offset += nkeypointsLevel;
// Step 7 将图像金字塔中的特征点恢复到第0层图像(原图像)上
if (level != 0)
{
// 获取当前图层上的缩放系数
float scale = mvScaleFactor[level];
// 遍历本层所有的特征点
for (vector<KeyPoint>::iterator keypoint = keypoints.begin(),
keypointEnd = keypoints.end(); keypoint != keypointEnd; ++keypoint)
// 特征点本身直接乘缩放倍数就可以了
keypoint->pt *= scale;
}
// 获取图像金字塔中恢复到第0层图像上的所有特征点
_keypoints.insert(_keypoints.end(), keypoints.begin(), keypoints.end());
}
}
1.1 构建图像金字塔ComputePyramid
(1)实现逻辑
大致过程:将原始图像作为第0层,根据每层的缩放系数scale逐层缩放,获取每层的图像,以此构成图像金字塔。另外,在进行特征提取之前需要执行高斯滤波操作(目的是去除噪点),由于图像边界处无法进行高斯滤波,因此需要对图像金字塔的每层进行边界扩充,但在原始代码中并没有应用到边界扩充(应该是个bug)。
在对图像金字塔的每层图像进行特征提取时,由于无法对边界部分进行特征提取操作,因此图像边界需要外扩3个像素以提取FAST角点(FAST-16需要半径为3个像素的圆)。于是,整体的图像描述如下:
最中间的灰色区域为图像的像素部分——称为有效像素图
附加绿色部分的minBorder边界的图像用于提取FAST角点——称为半径扩充图像
外扩EDGE_THRESHOLD的整个图像用于高斯模糊
(2)代码实现
/**
* 构建图像金字塔
* @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[level] = temp(Rect(EDGE_THRESHOLD, EDGE_THRESHOLD,
sz.width, sz.height));
// 对于非第0层图像,先根据缩放系数进行resize,然后使用copyMakeBorder逐层填充当前层图形
if( level != 0 )
{
resize(mvImagePyramid[level-1], // 输入图像
mvImagePyramid[level], // 输出图像
sz, // 输出图像的尺寸
0, // 水平方向上的缩放系数,留0表示自动计算
0, // 垂直方向上的缩放系数,留0表示自动计算
cv::INTER_LINEAR); // 图像缩放的差值算法类型,这里的是线性插值算法
copyMakeBorder(mvImagePyramid[level], // 源图像
temp, // 目标图像
EDGE_THRESHOLD, // top扩充的border大小
EDGE_THRESHOLD, // bottom扩展的border大小
EDGE_THRESHOLD, // left扩充的border大小
EDGE_THRESHOLD, // right扩展的border大小
BORDER_REFLECT_101+BORDER_ISOLATED); // 扩充方式
}
else
{
// 对于第0层未缩放图像,直接将图像深拷贝到temp的中间
// 并且对其周围进行边界扩展。此时temp就是对原图扩展后的图像
copyMakeBorder(image, //这里是原图像
temp, EDGE_THRESHOLD, EDGE_THRESHOLD, EDGE_THRESHOLD,
EDGE_THRESHOLD, BORDER_REFLECT_101);
}
// ! 原代码mvImagePyramid 并未扩充,应该添加下面一行代码
// mvImagePyramid[level] = temp;
}
}
1.2 ComputeKeyPointsOctTree
(1)大致流程
- 遍历图像金字塔的每一层,对于遍历到的每一层,执行如下操作:
- 确定半径扩充的有效图像边界[minBorderX, maxBorderX, minBorderY, maxBorderY]
- 在半径扩充的有效图像上划分网格cell
- 以网格cell为单位,先行后列进行遍历。对于遍历到的网格cell,使用opencv提取FAST特征点(为防止提取不到,可降低阈值再次提取)
- 将提取到的FAST特征点坐标恢复到基于半径扩充的有效图像上
- DistributeOctTree:将提取到的FAST特征点使用四叉树实现特征点均匀化分布,并剔除冗余密集特征点
- 将保留下来的基于半径扩充的有效图像区域的特征点恢复到基于EDGE_THRESHOLD外扩区域上
- computeOrientation:计算所有层上特征点的主方向
(2)代码实现
void ORBextractor::ComputeKeyPointsOctTree(vector<vector<KeyPoint> >& allKeypoints)
{
allKeypoints.resize(nlevels); // 重新调整图像层数
const float W = 30; // 划分的网格尺寸
// 遍历所有层
for (int level = 0; level < nlevels; ++level)
{
// 确定图形的有效边界,包括外扩3个像素便于提取FAST特征点
//[minBorderX, minBorderY, maxBorderX, maxBorderY]为前面图像中的绿色有效区域
const int minBorderX = EDGE_THRESHOLD-3;
const int minBorderY = minBorderX;
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);
// 计算进行特征点提取的图像区域尺寸(前面图像中的灰色区域尺寸)
const float width = (maxBorderX-minBorderX);
const float height = (maxBorderY-minBorderY);
const int nCols = width/W; // 当前层的图像在列上的网格cell个数
const int nRows = height/W; // 当前层的图像在行上的网格cell个数
const int wCell = ceil(width/nCols); // 当前层的每个图像网格cell所占的像素列数
const int hCell = ceil(height/nRows); // 当前层的每个图像网格cell所占的像素行数
// 以网格cell为单位,进行行遍历
for(int i=0; i<nRows; i++)
{
// 计算当前网格初始行坐标(y)
const float iniY =minBorderY+i*hCell;
/*
当i = nRows时,遍历到最后一行,考虑到提取FAST特征点的外扩6个像素,理论最大Y边界为:minBorderY+nRows*hCell+6, 需要与实际物理最大Y边界maxBorderY进行比较,以确定有效区域
*/
float maxY = iniY+hCell+6;
// 如果初始的行坐标就已经超出有效的图像区域(绿色范围), 则跳过这一行
if(iniY>=maxBorderY-3)
continue;
// 如果图像的大小导致最后一行网格不能完整划分,导致Y边界超过有效区域,则最后一行网格的最大行坐标为有效区域Y边界maxBorderY
if(maxY>maxBorderY)
maxY = maxBorderY;
// 以网格cell为单位,进行列遍历
for(int j=0; j<nCols; j++)
{
// 计算初始的列坐标(x)
const float iniX =minBorderX+j*wCell;
// 计算这列网格的最大列坐标,+6的含义和前面相同
float maxX = iniX+wCell+6;
// 同前面一样,判断初始列是否超过有效图像区域 !BUG 正确应该是maxBorderX-3
if(iniX>=maxBorderX-6)
continue;
// 同前面一样,最后一列网格的边界判断
if(maxX>maxBorderX)
maxX = maxBorderX;
vector<cv::KeyPoint> vKeysCell; // 存储提取出来的FAST特征点
// 调用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);
}
/*
由于检测到的FAST特征点是基于对应网格cell的,即是在[iniX, iniY, maxX, maxY]区域上检测到的
需要将其坐标恢复到图像有效区域上,即[minBorderX, maxBorderX, minBorderY, maxBorderY]上
*/
if(!vKeysCell.empty())
{
// 遍历其中的所有FAST角点
for(vector<cv::KeyPoint>::iterator vit=vKeysCell.begin();
vit!=vKeysCell.end();vit++)
{
// 当i,j均为0时,iniX = minBorderX, iniY = minBorderY
// 故是将特征点坐标恢复到基于有效图像区域上
(*vit).pt.x+=j*wCell;
(*vit).pt.y+=i*hCell;
// 将提取到的特征点存储到vToDistributeKeys中
vToDistributeKeys.push_back(*vit);
}
}
}
}
/*
声明一个对当前图层的特征点的容器的引用, 并且调整其大小为欲提取出来的特征点个数(当然这里是扩大了的,因为不可能所有的特征点都是在这一个图层中提取出来的)
*/
vector<KeyPoint> & keypoints = allKeypoints[level];
keypoints.reserve(nfeatures);
// 对存储到vToDistributeKeys中的当前层特征点使用四叉树实现特征点均匀化分别
keypoints = DistributeOctTree(vToDistributeKeys, // 待分配的特征点
minBorderX, maxBorderX, // 特征点区域
minBorderY, maxBorderY,
mnFeaturesPerLevel[level], // 保留的当前层特征点个数
level); // 当前层
// PATCH_SIZE是对于底层的初始图像来说的
// 现在要根据当前图层的尺度缩放倍数进行缩放, 得到缩放后的PATCH大小 和特征点的方向计算有关
const int scaledPatchSize = PATCH_SIZE*mvScaleFactor[level];
// 获取剔除过程后保留下来的特征点数目
const int nkps = keypoints.size();
// 将基于半径扩充的有效图像区域的特征点 转换到 基于EDGE_THRESHOLD外扩区域上
for(int i=0; i<nkps ; i++)
{
keypoints[i].pt.x+=minBorderX;
keypoints[i].pt.y+=minBorderY;
keypoints[i].octave=level; // 记录特征点来源的图像金字塔图层
keypoints[i].size = scaledPatchSize; // 每层计算灰度质心时的半径
}
}
// 计算每层图像上特征点的主方向
for (int level = 0; level < nlevels; ++level)
computeOrientation(mvImagePyramid[level], // 对应图层的图像
allKeypoints[level], // 这个图层中提取并保留下来的特征点
umax); // 在构造ORBextractor时提前计算出的
}
1.2.1 DistributeOctTree
(1)大致流程
- 根据图像宽高比取整来确定初始的节点数目:假设图像的宽度 w w w大于高度 h h