OpenCV-12-关键点和描述子A

1 摘要

本章主要介绍图像中包含大量信息的特征点。首先我们会介绍角点(Corners),以及它们在亚像数域下的定义。接下来我们将会学习如何使用光流追踪这些角点。角点追踪也逐渐演化成为关键点理论,这些知识在本文中也会介绍到。并且还会对OpenCV提供的关键点特征检查器和描述器进行广泛的讨论,在很多时候我们都需要使用到这些技术。

角点和关键点的概念主要目的都是使用一种不变的或者至少很相似的形式,描述在同一个场景的多张相似图片中的相同的图像或者对象。角点指的是一小块包含丰富的局部信息图片,这些特征信息使得它有可能在其他图片中被识别。关键点是这个概念的扩展,它对图像小块区域信息进行编码,从而具有很高的辨识度,至少在理论上是高度唯一的。关键点的描述性信息被归纳为它的描述子(Descriptor),描述子具有比形成关键点的像素块更低的维度,因此更容易被识别。

直观的看,你可以将关键点理解为是拼图游戏中的一个碎片。当你进行游戏时,会发现一些碎片比较容易识别,如门把手、脸、教堂的尖顶。你可以立即将这些关键点和拼图盒子上的完整图像相互关联,从而快速将它们放到正确的位置。此外如果你有两套拼图,并且想判断它们是否是同一个场景,你可以将它们分别组装完成比较,也可以仅比较那些特征碎片,显然第二种方式的效率更高。

本章我们先会通过早起的模式特征点即Harris角点,来讨论关键点点基本理论。也会讨论关于这些角点的光流(Optical Flow)概念,它包含了在视频帧序列里从一张图片到另一张图片追踪这些特征的基本思想。接下来会介绍现代的关键点知识以及它们的描述子,并讨论OpenCV是如何找到它们的,并且在多个视频帧中对它们进行比较。最后将会介绍一下在原图上可视化关键点的便利方法。

2 角点

2.1 角点简介

一幅图片中能够被追踪的局部特征的种类很多,因此值得花一些时间弄清构成特征的基本元素。很明显如果我们选择一大块空白墙壁上的一个点作为特征,那么在视频的其它帧内很难找到这个特征点。

如果选取的点在当前帧内有很多的相似点,则在下一帧内就不容易追踪到这个点。反之,如果选择的点是唯一的,则在后续视频帧内就有很高的概率找到该点。在实践中,选择的特征点应当是唯一的,或者是近乎唯一的,并且它应当可以被参数化,从而可以与其他图片内的点进行比较。下图演示了高低质量的两类不同特征点。

通常我们总是希望选择的特征点在其内部变化比较剧烈,例如其微分值较大,也就是像素强度变化剧烈,具有较高的梯度。事实证明这并不够,仅仅是个开始。因为在某个边缘上的一系列关键点都具有高微分值特点,但是它们几乎是相同的,不利于特征追踪。但是如果从两个方向都观察到了高微分值特征,那么这个点就很可能是唯一的。也正因为这个原因,所以很多可追踪的特征被称为角点。

最常用的角点定义是Harris提出的,该定义以数学形式描述啦前文所讲到的那些直觉。接下来将会介绍到该定义的一些细节,但是现阶段最重要的事情是知道可以通过OpenCV提供的函数找出图片中的一些关键点,它们是能够被追踪的点的高质量候选者,并且会通过Harris的方法对它们进行鉴定。

2.2 角点检测

下面的函数实现了Harris的方法,但是Shi和Tomasi在性能上进行了一定改进。该函数计算了导数,经过分析,返回了那些符合我们定义的利于追踪的点列表。函数原型如下。

// image:待检测图像,元素格式为CV_8UC1或者CV_32FC1
// corners:检测到的符合期望的角点
//     如果传入的类型为vector<>,则返回的时cv::Point2f的向量
//     如果传入的类型为cv::Mat,则返回N✖️2大小的矩阵,两列分别表示角点的x和y坐标
// maxCorners:现在寻找的最大角点数量
// qualityLevel:角点质量,通常取值区间为【0.01,0.1】,绝对不能超过1
// minDistance:相邻角点的最小间距
// mask:蒙版矩阵,值为0的点对应的待检测像素点不会被认为是角点,尺寸和参数image相同
// blockSize:计算角点时像素块点大小,当处理高分辨率图像时,可以适当增加该值
// useHarrisDetector:是否使用Harris算法计算精确角点位置,值为false时会选用Shi和Tomasi的算法
// k:使用Harris算法时需要的参数,最好维持默认值,后面小节详细介绍
void cv::goodFeaturesToTrack(cv::InputArray image, cv::OutputArray corners, int maxCorners,
                             double qualityLevel, double minDistance, cv::InputArray mask = noArray(),
                             int blockSize = 3, bool useHarrisDetector = false, double k = 0.04);

2.3 亚像素角点

如果你想要从图像中提取精确测量值,而不是用于识别的特征点,你通常需要比函数cv::goodFeaturesToTrack提供的结果具有更高分辨率的方法。也就是说该函数提供的点坐标都是整数,而有时我们需要使用小数表示的坐标。

例如当你想要在一幅照片中找一个很小的星星图案时,通常其中心并不刚好和像素之间的交点重合,即通常不能使用整数表示。同样的,精确的角点坐标通常也不是整数。为了解决这个问题,通常需要使用像素强度值拟合出一条曲线,然后使用一点数学手段找到这些像素之间的峰值。亚像数角点检测技术就是基于这种方法实现的。这种技术常用于三维重建追踪,相机标定、以自然的方式拼接一个场景的重叠视图,以及寻找外部信号,如在卫星图片中寻找建筑物的精确位置。

最常用的一种亚像素计算技巧基于一个数学观察规律,即两个正交向量的乘积为0。如在下图中,假定点p是计算出的角点整型坐标,点q是真实的角点浮点型坐标。则在图a中,点p位于一个色块内是,其周围的梯度为0,因此向量ap和p点点梯度乘积为0。在图b中,点p位于色块边界上,其周围的梯度不为0,但是梯度方向点向量与向量qp正交,因此其乘积也为0。也就是说无论点p位于何处,向量qp和点p附近点梯度向量乘积一定等于。

基于这个事实,我们可以通过已知整型角点坐标p推导出多个点q,这些点位于坐标p周围的整型像素附近。这样就能得到多个方差组,通过求解线性系统,我们就可以得到更精确的角点亚像素坐标q。OpenCV中实现这个方法的函数为cv::cornerSubPix,其原型如下。

// image:待检测图像
// corners:作为输入参数,传入角点整型坐标,可以通过函数cv::goodFeaturesToTrack处理同一个image图像的到
//     作为输出参数,保存计算后的亚像素角点坐标,为浮点型
// winSize:搜索亚像素坐标的窗口范围
//     以已知整型坐标为中心,向两侧扩展winSize个单位
// zeroZone:搜索亚像素坐标的忽略窗口范围
//     即已知整型坐标为中心,向两侧扩展zeroZone个单位内不会考虑为亚像素坐标,设置为Size(-1,-1)表示没有需要忽略的窗口
// criteria:函数内部迭代的终止条件
void cv::cornerSubPix(cv::InputArray image, cv::InputOutputArray corners, cv::Size winSize,
                      cv::Size zeroZone, cv::TermCriteria criteria);

根据前文提到的多个候选点q和已知整型像素点p之间的向量,和q点附近的梯度乘积为0的规律,可以建立一个线性系统,通过一个单自关联矩阵(Single Autocorrelation Matrix)可以求得该系统的解。由于十分接近点p的q值得到的线性方程总产生小的特征值,这使得改线性系统得到的矩阵并不总是可逆的。为了处理这种情况,可以通过参数zeroZone设置一个亚像素坐标搜索的忽略窗口。

当一次迭代完成,算法会将计算得到的点作为新的p点,并迭代算法,直至达到用户指定的条件。通过函数cv::TermCriteria()你可以构建cv::TermCriteria::MAX_ITER或者cv::TermCriteria::EPS条件,分别控制最大迭代数和计算结果的精度,当然你可以同事指定这两个条件 ,只要满足任意一个即退出循环。如当指定精度为0.1时,则计算结果精度大于0.1个像素单位时就会退出循环。

3 光流

光流(Optical Flow)问题尝试找到一张图片里的大多数点,有可能的话所有的点和其在第二张图里对应点之间的移动向量。该问题尤其见于处理视频文件时,因为对于视频文件而言,某帧上的点通常都能在第二帧画面内找到。光流问题可以用于计算场景中物体的运动估计,甚至是相机的自运动估计。在很多应用程序中,如安全监控领域,移动的区域都会被处理称为兴趣区域。光流的示意图如下。

左上图中,I(t)为视频文件中t时刻的视频帧,I(t+1)为t+1时刻的视频帧,P1~P4为特征点,计算得到的运动向量vt表示如右上图。下图为视频文件中连续两帧计算出的光流。

光流算法的理想输出是与一组视频帧中每个像素的速度估计相关的量,或者等效的位移向量。如果处理的是图像中的所有像素,称为稠密光流(Dense Optical Flow)算法,它们预计会被包含在OpenCV的更高版本中,后面的章节会再次介绍。当只计算图像中的部分像素时,则称为稀疏光流(Sparse Optical Flow)算法。稀疏光流算法通常计算速度更快,也更可靠,因为它们只追踪了图像中的那些特定的点。在大多数应用程序中通常使用的都是稀疏光流算法,在一些特殊领域,如电影制作才会花更多的时间成本去使用稠密光流算法。

OpenCV中标识适合追踪的点的方法有很多,前文介绍点查找角点的方法只是其中的一种。本章会介绍一种稀疏光流算法,接下来将会介绍一下稀疏光流算法能够使用的强大工具,最后会讨论稠密光流算法。

3.1 Lucas-Kanade算法

Lucas-Kanade(LK)算法在1981年提出,最初的目的是为了处理稠密光流问题。由于该算法很容易推广到图像的像素子集,因此它也成为了处理稀疏光流问题的一个重要技术。这种推广的基础是将兴趣点周围的一个窗口看作是一个个独立的图像。该方法的一个缺点是,如果运量量足够大,则窗口内的大部分点都会移动到窗口外,使得该算法不能在下一帧画面中找到这些点。为了解决该算法发展出了金字塔(Pyramid)LK算法,该算法从图像金字塔中包含最低细节(分辨率最低)的最高层,向最高细节的最底层追踪像素。这样在最低分辨率的图像中像素的绝对移动距离较小,就不会移动到窗口外部。

LK算法的基本思想基于三个假设。

  • 亮度恒定(Brightness Constancy)
    图像中的同一个对象在相邻帧内即使发生移动,其像素的颜色强度不会发生改变。
  • 时间持续性(Temporal Persistence)
    时间持续性或者说微小移动(Small Movements),指的是目标随时间移动缓慢。实际上,如果帧间间隔足够小,则目标在帧间移动距离忽略不计。需要注意这里不是说的两帧画面之间的移动量忽略不计,而是指该点的微分为0,下文还会详细介绍。
  • 空间一致性(Spatial Coherence)
    同一个曲面的邻近点具有相似的移动,它们在图像平面上的投影距离也很近。

这三条假设的示意图如下。

时刻为t的视频帧内,位置为x的像素亮度可以表示为I(x, y, t)。则经过dt时刻后,该像素的亮度可以表示为I(x+dx, y+dy, t+dt),根据亮度恒定假设,得到如下公式。

则对该等式应用多元泰勒级数展开式如下。

HOT是泰勒级数的高阶小项,当dx、dy和dt趋于0时,其值约等于0,因此可以得到如下等式。

经过一系列运算后可以得到如下公式。

使用Ix表示亮度在点P的x轴方向上的导数,Iy表示亮度在点P的y轴方向上的导数,It表示亮度在点P的时间轴方向上的导数,则上式可以进一步改写为。

第二条假设时间持续性表示相邻视频帧间特征点的移动距离很小,换句话说同一位置的像素在帧间的差值可以视为亮度基于时间的偏导数,即It。为了更好的理解该算法,我们先简化问题,只考虑一维度图像,即如下公式。

下图形象的演示了光流的计算过程,图中粗细实线分别表示的是t和t+1时刻亮度随时间的变化曲线。而点p是位于边缘上的某个特征点,其沿着x轴的正方向移动,光流使用向量v表示,即需要计算的未知数。

下图的Ix表示亮度在x方向的空间偏导数,即过点p处的亮度函数斜率。而It根据第二条假设可以直接使用帧间差分计算得到。

回忆前文介绍的泰勒级数余项中将高阶项近似看为0,因此前文的等式应该表示为如下,从而可以计算出光流。需要注意的是,这里只考虑了一维情况,对于多维情况可以理解为多元函数方程,需要多个方程构成一元线性方程组求解,后文再介绍,现在我们继续考虑一维情况。

再继续观察上面的图中t和t+1时刻的亮度曲线,会发现亮点具有不稳定性,即第一条假设并不完全成立。另外时间的部长和运动的距离相比通常也不够快,即It也存在一定的误差。也就是说我们计算得到的光流并不是准确值,但是通过迭代的方式可以计算得到精度要求的光流值。

下图演示了这种迭代计算过程,图中It表示第二次计算得到的亮度基于时间的偏导数。根据第一条假设亮度恒定,因此第二次迭代时直接使用第一次计算得到的亮度基于空间的导数Ix,这样可以提高算法效率。这种方式称为牛顿法(Newton’s Method),如果一开始得到的值足够接近,则大约经过5次迭代就能得到足够精确的计算结果。如果一开始得到的值不够接近,则牛顿法会产生偏离。

现在需要将一维情况推广到二维图像,回忆前文提到的计算二维图像光流的关键公式如下,其中包含两个未知数Vx和Vy,此时需要一元线性方程组计算出这两个未知数的值。

而根据第三条假设,像斑中每个像素的移动规律近似,因此可以对以点p为中心,半径为d的窗口内的所有像素应用光流公式,从而得到一元线性方程组,最终求解方程组计算光流向量。

需要注意从数学角度看如果这些方程之间线性相关则方程组无解,即无法计算出光流向量。从图像学的角度看如果窗口不足以覆盖足够的特征,则无法计算出光流向量。例如下图演示了通过一个窗口观察一个物体移动时,如果窗口只包含目标的边缘,则无法成功计算出光流向量。

当窗口大小选择为5✖️5时,我们可以得到如下一元线性方程组,并计算出速度向量d。

当矩阵ATA可逆时,即其满秩,秩为2时该方程有解,此时该矩阵有两个最大特征向量。也就是说当追踪的窗口以角点为中心时,矩阵具有最优解。

前文说过LK算法的第二和第三个假设条件分别是,两帧画面之间物体移动距离足够小,像斑内的像素具有相似的移动。但是实际上对于大多数帧率为30Hz的视频而言,这两个条件并不总是满足。实际上正因为这个原因,基本的LK算法的效果并不是很好。

为了解决这个问题,通常借用图像金字塔技术,先在低分辨率低顶层图像上计算光流,再逐层向下优化计算结果,直至原始分辨率的底层图像。这样就能使得最低程度的违背LK算法的假设条件,从而追踪更长和更快的流动。这种更精细的函数被称为金字塔Lucas-Kanade光流(Pyramid Lucas-Kanade Optical Flow),其计算示例图如下。

3.2 金字塔LK光流算法

OpenCV中实现该算法的函数为cv::calcOpticalFlowPyrLK(),该函数可以使用前文介绍的角点寻找函数的计算结果作为输入,并尝试追踪这些特征点在目标图像中的位置,其原型如下。

// prevImg:t-1时刻的图像,元素格式为CV_8UC1
// nextImg:t时刻的图像,元素格式为CV_8UC1
// prePts:t-1时刻图像中的特征点,基本数据格式为CV_32F
// nextPts:在t时刻内追踪到的对应特征点坐标,基本数据格式为CV_32F,元素索引和prePts对应
// status:prePts中的特征点追踪情况,1表示追踪到,0表示未追踪到
// err:追踪到的特征点点错误度量
// winSize:搜索窗口的大小
// maxLevel:图像金字塔的层数
// criteria:迭代的终止条件
// flags:算法策略,下文介绍
// minEigThreshold:滤波器,若某个特定像素构建出的线性方程系数矩阵最小特征值小于该值,则不会追踪该特征点
void cv::calcOpticalFlowPyrLK(cv::InputArray prevImg, cv::InputArray nextImg,
                              cv::InputArray prevPts, cv::InputOutputArray nextPts,
                              cv::OutputArray status, cv::OutputArray err,
                              cv::Size winSize = Size(15,15), int maxLevel = 3,
                              cv::TermCriteria criteria = TermCriteria(cv::TermCriteria::COUNT |
                                                          cv::TermCriteria::EPS, 30, 0.01),
                              int flags = 0, double minEigThreshold = 1e-4);

在使用该函数之前需要使用例如角点搜索函数等手段查找到某个时刻视频帧内的特征点,再将该时刻视频帧、下一时刻的视频帧、以及该时刻的特征点作为输入参数。当函数执行完毕后,检测status参数确定哪些特征点被成功追踪,然后检查参数nextPts寻找到这些特征点的最新坐标。

参数prePts和nextPts可以是N✖️2的矩阵,或者是由点组成的向量。当参数maxLevel设置为0的时候,表示算法不使用图像金字塔技术。参数criteria是算法迭代的终止条件,在很多涉及到迭代运算的函数中都有此参数,结构体cv::TermCriteria的定义如下。

struct cv::TermCriteria(
public:
    enum {
        COUNT = 1,
        MAX_ITER = COUNT,
        EPS = 2
    };

    TermCriteria();
    TermCriteria(int _type, int_maxCount, double _epsilon);

    int type,
    int max_iter,
    double epsilon
);

在大多数情况下该参数使用默认值即可,但是如果你的图像分辨率很大,可以稍微增加迭代次数以获得更好的结果。

参数flags的取值有如下两个。
cv::OPTFLOW_LK_GET_MIN_EIGENVALS
默认情况下参数err返回的是追踪到的特征点新旧位置搜索窗口内所有像素点平均强度差值,而当该值被设定时,参数err返回的就是和角点相关的Harris矩阵的最小特征值。
cv::OPTFLOW_USE_INITIAL_FLOW
默认情况下算法会以上一帧的特征点位置为初始位置搜索特征点,而当该值被设定时,参数nextPts保存的位置将会作为搜索的初始位置。

示例程序LK使用函数cv::goodFeaturesToTrack()寻找图像内部的特征点,然后使用函数cv::calcOpticalFlowPyrLK()追踪这些特征点在下一帧内的位置,最后可视化计算得到的光流结果,其运行效果如下。

4 关键点和描述子

在理解追踪、物体检测和一些其他相关话题之前,需要先理解两个基本概念,分别是关键点(Keypoints)和描述子(Descriptors)。特征点事图像内的一个像块,它具有独特性,能够在其他相关的图像中被定位。描述子是一种数学结构,通常但并不总是由浮点数据组成的向量,在某种环境下它可以用于判定两个关键点是否相同。

历史上第一个重要的关键点类型就是本章开头介绍到的Harris角点。回想Harris角点的定义,图像中任意在两个不同方向都有强烈的像素强度变化的点都是高质量的候选关键点,它们很容易在如相邻视频帧等相关图片中定位。下图演示了在一个包含大量文本的图片中寻找Harris角点,出左下角部分图片由于聚焦问题,在上半部分图片中检测到了大量的Harris角点。仔细观察会发现这些角点都处于字符的开始或者结束位置,以及字符内部线段的交点。

在我们的眼中,这些关键点都是不同的,而描述子解决的问题就是如何让计算机能够区分这些不同的关键点。我们可以使用任意方式来构建一个关键点描述子,如使用以关键点为中心大小为3✖️3的窗口内的像素强度创建一个向量(和观察方向相同)作为描述子。但是这种方式的问题是如果稍微改变观察的方向,则得到的描述子就不同。通常情况下,我们描述子都期望描述子具有旋转不变性,理想情况下在三维空间中具有仿射变换不变性。当然是否需要这种性质取决于应用程序的具体环境。

在检测和追踪人物时,重力在创造一个不对称世界中扮演了重要的角色,人的头通常在顶部,脚通常在底部。在这种应用程序中,即使描述符不具备旋转不变性也无关紧要。相比之下,当飞机沿着不同方向航行时,拍摄的地面图像会发生旋转,因此得到的图像看上去是沿随机方向拍摄的。

4.1 光流、追踪和识别

在前文介绍LK算法时讨论了光流这个话题,该算法是OpenCV提供给我们的一个高层级的抽象工具,用于定位一组关键点在其他相关图片中的最佳匹配位置。在前文的例子中这些关键点并没有足够的结构和标识度,它们包含的信息只足以用于在连续的相似视频帧内定位新的位置。广义的关键点可以包含关联的描述子,这使得它们足够强大,不仅能够在连续的视频帧内,还能够在完全不同图片中寻找自身的匹配点。这样我们就能在新的环境中定位物体,或者是在复杂的变化的场景中追踪物体等。

关键点和描述子的主要应用领域有三个,分别是追踪(Tracking)、目标识别(Object Recognition)和立体视觉(Stereoscopy)。

追踪指的是通过连续的图像流在逐渐变化的场景中追踪某个目标的运动。它分为两个子领域,分别是在静态场景中追踪目标对象,以及追踪场景本身从而估计相机的运动。通常术语追踪指的是前者,而后者被称为视觉测距(Visual Odometry)。当然,需要同时完成这两个任务的情况也很常见。

目标检测指的是在场景中查找某个已知对象是否存在。目标检测的思想是如果发现了足够多的与目标对象关联的特征点,则可以认为目标对象存在于当前场景中。

立体视觉指的是通过在同一个场景的不同相机视角拍摄的图片中定位相关点,再使用这些点的位置、相机的位置以及相机的光学属性计算出这些点在三维空间中的位置。

OpenCV提供了多个方法用于处理不同类型的关键点以及关键点描述子,同时还提供了方法用于匹配这些关键点。这些匹配方法包括了以通过稀疏光流算法,在追踪和可视化测距、或者立体视觉领域,在一对图像中进行匹配。也包括了在物体追踪领域,在已知图像和检测图像数据库之间进行匹配。

4.2 关键点检测和描述子相关操作

当你在执行追踪操作,或者其他一些需要关键点和描述子的任务时,通常需要做三件事情。第一步是根据一种关键点点定义找到图像中的所有关键点。第二步是为这些关键点创建描述子。第三步是将这些描述子和已有的描述子集合进行比较,看是否能够找到匹配项。在追踪应用程序中,最后一步涉及在相邻帧内查找符合追踪目标的特征点集合,并比较这两个集合中的特征点移动。在目标检测应用程序中,经常需要在图像数据库(通常很大)中对每个图片筛查匹配的特征点。

对于上述的每一步,OpenCV都遵循使用类封装逻辑(函数子)的原则提供了一个泛化机制。即对每一步都提供了一个抽象基类,其中定义了通用接口,然后再基于该基类衍生出了各个功能子类,实现具体的算法。

4.2.1 cv::KeyPoint

对于关键点,我们关心的核心问题是它的位置,这是每个关键点都必须具备的属性。当然关键点还需要一些其他的可选属性,其定义如下。

class cv::KeyPoint {

public:
    // 关键点中心坐标
    cv::Point2f pt;
    // 关键点邻域的直径
    float size;
    // 关键点的计算方向,即对于有些关键点,需要将方向对齐后比较其描述子,如果关键点没有该属性,其值为-1
    float angle;
    // 关键点被检测到的响应,在某些情况下可以理解为待检测图像中关键点匹配的程度
    float response;
    // 关键点在应用图像金字塔时,其被识别的层级,可以指示在待检测图像中在该层级附近更容易识别到关键点
    int octave;
    // 目标标识,用于将关键点按其关联的目标对象分类,在讨论关键点描述子匹配的小节中会详细介绍
    int class_id;

    // 构造函数
    cv::KeyPoint(cv::Point2f _pt, float _size, float _angle = -1, float _response = 0,
                 int _octave = 0, int _class_id = -1);
    cv::KeyPoint(float x, float y, float _size, float _angle = -1, float _response = 0,
                 int octave = 0, int _class_id = -1);
...
};

实际上除非你需要自己写关键点查找器,你不需要构建关键点实例,在OpenCV提供的关键点检测、描述子构建和比较等函数中,OpenCV内部会创建该实例。

4.2.2 关键点及描述子算法抽象基类

OpenCV中查找关键点及计算描述子,或者同时完成这两个任务的抽象类是cv::Feature2D。OpenCV提供了两套分别适用于当个图像和图像组的处理,它们都能检测特征点,从磁盘读取和向其写入数据,以及根据如类型名称(字符串)来创建特征检测器衍生类的实例。一次处理多张图片相比于一次处理一张图片能够带来一定程度的效率提升,这对于某些检测器而言更明显。下面列举了一些相关代码。

class cv::Feature2D : public cv::Algorithm {

public:
    // image:需要检测的图像
    // keypoints:检测到的关键点
    // mask:图像蒙版,只会检测值为1对应的像素点
    virtual void detect(cv::InputArray image, vector<cv::KeyPoint>& keypoints,
                        cv::InputArray mask = cv::noArray()) const;

    // images:需要检测的图像数组,由矩阵对象组成的STL向量实例
    virtual void detect(cv::InputArrayOfArrays images, vector<vector<cv::KeyPoint>>& keypoints, 
                        cv::InputArrayOfArrays masks = cv::noArray ()) const;

    // image:需要计算描述子的关键点源图像
    // keypoints:已知的关键点
    // mask:计算得到的关键点描述子,大小为M✖️N,其中M等于关键点的个数,N等于描述子的大小
    virtual void compute(cv::InputArray image, std::vector<cv::KeyPoint>& keypoints,
                         cv::OutputArray descriptors);

    // image:需要计算描述子的关键点源图像数组,由矩阵对象组成的STL向量实例
    // mask:计算得到的关键点描述子,格式为由大小Mi✖️N矩阵组成的向量,其中向量的长度等于图片的个数
    //       Mi等于在第i张图片中关键点的个数,N等于描述子的大小
    virtual void compute(cv::InputArrayOfArrays image,
                         std::vector<std::vector<cv::KeyPoint>>& keypoints,
                         cv::OutputArrayOfArrays descriptors);

    // 检测关键点并计算描述子
    // keypoints:作为输入为已知的关键点,作为输出为检测到的关键点
    // useProvidedKeypoints:是否使用已知的关键点,为true时需要在参数keypoints中传入已知的关键点,
    //                       否则使用在图片中识别的关键点
    virtual void detectAndCompute(cv::InputArray image, cv::InputArray mask,
                                  std::vector<cv::KeyPoint>& keypoints, cv::OutputArray descriptors,
                                  bool useProvidedKeypoints = false);

    // 描述子的元素个数,如果是二进制类型的描述子,则返回的是位数
    virtual int descriptorSize() const;
    // 描述子的类型,如CV_32FC1表示32位浮点型,CV_8UC1表示8位二进制类型等
    // 目前所有描述子的类型都是单通道,即使处理多通道图像也会将其压缩成单通道,如10维表示灰度图像,
    // 则30维表示3通道的彩色图像
    virtual int descriptorType() const;
    // 比较描述符使用的推荐和默认范数类型,通常二进制类型描述符使用NORM_HAMMING范数,
    // 其他类型描述子使用L2范数(但是使用L1范数也可能获得一样或者更好的结果)
    virtual int defaultNorm() const;

    // 从磁盘中读取数据
    virtual void read(const cv::FileNode&);
    // 向磁盘写入数据
    virtual void write(cv::FileStorage&) const;
...
};

在具体的衍生子类中,对于如FAST等算法的子类而言只实现了函数cv::Feature2D::detect(),对于如FREAK等纯技术描述子算法而言只实现了函数cv::Feature2D::compute(),而对于如SIFT、SURF、ORB和BRISK等完整算法的子类而言,实现了cv::Feature2D::detectAndCompute()函数,内部会隐式调用detect()compute()函数。

需要记住实际上不同类型检测器识别到的关键点可能是不相同的,这取决于其内部某些参数是否被使用以及怎样被使用。这意味着对于所有类型的特征描述子一个方法检测出的特征点可能不是通用的。当识别出特征点后,下一步就是计算其描述子。前文已经提到过比较特征点相似程度的标准是它们的外观,即描述子,而不是其位置。这是追踪和目标识别的基础。

当一个算法提供了函数cv::Feature2D::detectAndCompute()时,强烈建议使用该函数而不是使用cv::Feature2D::detect()cv::Feature2D::compute()的组合。最明显的理由就是前者具有更高的性能,因为通常情况下这些算法都需要一种特殊的图片表示形式(尺度空间表示,Scale-Space Representation),而这种表示的计算成本往往很高。如果单独调用这两个独立的函数,这个计算过程就会重复两次。

4.2.3 cv::Dmatch

在深入理解匹配器工作原理之前,首先了解匹配结果是如何描述的。一般情况下,匹配器会尝试将已知关键点和来自于另一张图片或者一组图片的关键点进行比较。在比较完成后会将这些匹配的项用类cv::DMatch的实例表示,并输出由该类实例构成的标准模版库向量。类cv::DMatch定义如下。

class cv::DMatch {
public:
    // 默认构造函数,会将属性distance设置为std::numeric_limits<float>::max()
    DMatch();
    // 构造函数
    DMatch(int _queryIdx, int _trainIdx, float _distance);
    DMatch(int _queryIdx, int _trainIdx, int _imgIdx, float _distance);

    // 在新图像中匹配的特征点点索引
    int queryIdx;
    // 在旧(已知)图像中匹配的特征点点索引
    int trainIdx;
    // 包含特征点的旧(已知)图像在图像集合中的索引
    int imgIdx;
    // 两个特征点描述子的距离,可以用于表示匹配的质量
    float distance;

    // 重载的比较运算符,基于属性distance比较
    bool operator<(const DMatch &m) const;
}

前文提到过通常情况下描述子使用L2范数进行比较,即属性distance表示的是连个描述子的L2范数,即在多维向量空间中的欧式距离。虽然该属性并不总是衡量标准,但是距离越小,表示匹配的程度越高。

4.2.4 描述子匹配

通常在两种场景下需要使用比较器,分别是目标识别和追踪。在目标识别的应用场景中,首先需要将关键点和目标对象相关联形成“字典”,然后在新场景的图片中提取关键点再和这个字典进行比较,判定字典中的哪些对象在新的场景中存在。在追踪应用场景中,需要找到某张图片(特别是视频的某一帧)中的所有关键点,然后在另外一张图片中(通常是视频的上一帧或者下一帧)搜索所有的这些特征点。

描述子比较的抽象基类是cv::DescriptorMatcher,其内部提供了两套方法分别用于上述的两种应用场景。对于目标识别应用,首先需要使用一个描述子和目标对象构成的字典训练匹配器,然后再提供一个描述子列表让匹配器告诉我们其内部存储的描述和列表内的描述子的匹配情况。对于追踪应用场景,需要提供两个描述子列表,并让匹配器告诉我们这两个列表间的匹配情况。

该类提供的接口包含match()knnMatch()radiusMatch(),每类接口又包含两个原型,分别处理对应前文描述的两种应用场景。其部分定义如下。

class cv::DescriptorMatcher {
public:
// 添加训练用的描述子
virtual void add(InputArrayOfArrays descriptors);
// 清除所有训练用的描述子
virtual void clear();
// 是否不包含任何描述子
virtual bool empty() const;
// 训练匹配器
void train();
// 是否支持使用蒙板
virtual bool isMaskSupported() const = 0;
// 获取训练使用的描述子
const vector<cv::Mat>& getTrainDescriptors() const;

// 目标识别使用的函数,使用描述子列表和已有的训练集进行比较
// 尝试为列表中的每个描述子寻找一个最佳匹配项
// mask:描述子比较蒙版,矩阵基本数据格式为CV_8U,下文介绍
void match(InputArray queryDescriptors, vector<cv::DMatch>& matches,
           InputArrayOfArrays masks = noArray ());
// 尝试为列表中的每个描述子寻找k个最近匹配项
// 这里的距离由具体子类定义的度量衡决定,可能是,也可能不是欧式距离
// matches:匹配结果,其顶层向量元素表示的是列表中某个描述子的匹配结果,按质量降序,距离升序排列
// compactResult:是否压缩搜索结果
// 设置为false时,无论列表内的描述子是否寻找到匹配项都会创建一个向量,即参数matches的长度等于描述子列表内的元素数量
// 设置为true时,当列表内的描述子无法寻找到匹配项则不会创建向量,即参数matches的长度小于等于描述子列表内的元素数量
void knnMatch(InputArray queryDescriptors, vector<vector<cv::DMatch>>& matches,
              int k,
              InputArrayOfArrays masks = noArray (), bool compactResult = false);
// 尝试为列表中的每个描述子寻找指定半径内的所有匹配项
// matches:匹配结果,其顶层向量元素表示的是列表中某个描述子的匹配结果,按距离升序排列
void radiusMatch(InputArray queryDescriptors, vector<vector<cv::DMatch>>& matches,
                 float maxDistance,
                 InputArrayOfArrays masks = noArray (), bool compactResult = false);

// 追踪使用的函数,匹配两个描述子列表
// 为待查询的描述子列表的每个元素寻找最佳匹配
void match(InputArray queryDescriptors, InputArray trainDescriptors,
           vector<cv::DMatch>& matches, InputArray mask = noArray ()) const;
// 为待查询的描述子列表的每个元素寻找k个最佳匹配
void knnMatch(InputArray queryDescriptors, InputArray trainDescriptors, 
              vector<vector<cv::DMatch>>& matches, int k, InputArray mask = noArray(),
              bool compactResult = false) const;
// 为待查询的描述子列表的每个元素寻找指定距离内的所有匹配
void radiusMatch(InputArray queryDescriptors, InputArray trainDescriptors,
                 vector<vector<cv::DMatch>>& matches, float maxDistance,
                 InputArray mask = noArray (), bool compactResult = false) const;

// 从磁盘读取匹配器
virtual void read(const FileNode&);
// 将匹配器写入到磁盘中
virtual void write( FileStorage& ) const;

// 复制构造函数
// emptyTrainData:是否需要清空内部的训练集
virtual cv::Ptr<cv::DescriptorMatcher> clone(bool emptyTrainData=false ) const = 0;
// 使用描述子类型名称构建一个匹配器
descriptorMatcherType:匹配器类型名称
static cv::Ptr<cv::DescriptorMatcher> create(const string& descriptorMatcherType);
..
};

第一组方法用于构建目标识别所需要的匹配器模型。通常情况下我们会准备一组图片,其中每幅图片只包含一个已知的目标对象。然后使用类cv::Feature2D的子类从这组图片中提取关键点,然后再调用匹配器的add()函数构建该模型。该函数参数类型通常为std::vector<cv::Mat>,向量的每一个元素都表示一幅已知目标对象图片的特征点描述子,其中每个矩阵的大小为N✖️D,其中N等于对应索引图像的描述子数量,D等于描述子的长度。

当加载完所有的关键点描述子后,你可能需要调用函数train(),只有部分cv::DescriptorMatcher的子类要求必须调用。该函数的作用主要是通知匹配器已经完成图像加载,匹配器可以预先计算一些用于比较描述子的内部必要信息。例如,如果匹配器仅使用欧式距离来匹配关键点,则可以通过构建四叉树或者类似的数据结构来极大的加速匹配最近关键点的任务。这种数据结构的计算成本可能很高,只需要在所有图像加载完成后计算一次即可。通常情况下,如果一个匹配器提供了函数train(),则必须要在调用任何匹配方法之前调用该函数。

接下来是一组用于目标识别的接口,它们都将传入的描述子列表作为查询列表(Query List),将其和已经添加的训练集字典进行比较。其中参数mask的含义需要特殊说明,和其他函数中mask通常用于像素维度比较不同,在该函数中基于描述子维度。mask[k].at<unchar>(i, j)表示查询列表中的第i个描述子,是否需要和训练集中的第k幅图像的第j个描述子进行比较,如果其值为1则表示需要比较,反正则不用。

接下来是一组用于追踪的接口,其中三个函数不依赖于匹配器内部的训练集,而是直接通过参数获取。因此和它们对应的用于目标识别版本的接口相比,这三个函数额外增加了参数trainDescriptors,其他行为和对应的函数类型。另外使用这三个函数之前不需要再调用train()函数和add()函数。

然后是一组文件的读写接口,当你处理包含大型训练集的匹配器时,这组函数就十分有用。

最后定义的是复制构造函数,以及根据描述子匹配器类型名构建匹配器的函数,其中对于后者,参数descriptorMatcherType的取值列表如下。需要注意,其中的Hamming距离只能应用于元素格式为CV_8UC1的描述子矩阵。

descriptorMatcherType取值 匹配器类型及比较方法
FlannBased FLANN (近似近邻快速库,Fast Library for Approximate Nearest Neighbors),默认使用L2范数
BruteForce 逐元素使用L2范数直接比较
BruteForce-SL2 逐元素使用平方L2范数直接比较
BruteForce-L1 逐元素使用L1范数直接比较
BruteForce-Hamming 逐元素使用Hamming距离(对应位置不同字符/位的数量)直接比较
BruteForce-Hamming(2) 逐元素使用多级Hamming距离(二级)直接比较

4.3 核心关键点算法

近十年来,追踪和图像识别取得了巨大的进展,其中一个重要的主题就是关键点。人们认为大脑将视网膜对环境的反应传递的信号分组为更高级别的信息块,至少其中一些和关键点中包含的信息类似。早期的研究聚焦在如角点检测的概念上,人们试图通过更复杂的描述符来表示复杂的关键点。这些复杂的描述符呈现来大量想要的计算机视觉特征,如旋转和缩放不变性,以及微小尺度仿射变换的不变性,这些是更早的关键点检测所不能实现的。

快速的发展产生了大量的关键点检测算法,以及关键点描述符,当然其中并没有一种算法一定比其他算法更好。因此OpenCV提供了一个通用的接口,希望能够鼓励和帮助个人开发者能够探索这些算法各自的优点。其中一些算法的计算速度较快,当然也有些算法计算速度相当慢。一些算法会呈现一致性的特征,有时这些特征对你的应用程序是必须的。

在这节中,我们会熟悉每一个关键点检测算法,并讨论它们各自的优点以及算法的原理,从而使得能够了解各个算法的使用场景,以及它们之间的差异。如前文所讲,对于每一种描述符类型,都有一个关键点检测器和描述符提取器,下文会详细介绍。

通常情况下,使用的关键点检测器和描述符提取器并不一定要一一对应。大多数情况下,使用不同关键点检测器和描述符提取器的组合都是有意义的。然而通常这两个逻辑是一并开发的,因此OpenCV中的接口也对这两个功能进行的组合封装。

4.3.1 Harris-Shi-Tomasi算法

关于角点的定义有很多,但是常用的是Harris提出的版本,这种角点称为Harris角点(Harris Corner),可以认为是关键点的原型。下面这组图像展示了Harris角点的检测结果。左右两张图中都发现了1000个Harris-Shi-Tomasi角点,但是有图中大部分角点都集中在背景区域。

Harris角点的定义和邻域内像素强度值的变化剧烈程度相关,算法内部计算单个像素P(x, y)附近窗口W内的每个像素点和经过一定位移后的像素点,的像素强度值差的加权平方和的公式如下。其中I(x, y)表示点P(x, y)的强度值,W是以点P为中心的窗口,wij是该窗口内点Po(i, j)的权重。另外权重wij为高斯权重,即离窗口中心越近的像素计算结果对最终结果影响更大。

由于x和y轴方向的增量都足够下,即△x和△y足够小,因此利用泰勒级数展开,并忽略高阶相,可以得到如下公式。

I(i + △x, j + △y) = I(i, j) + Ix(i, j)△x + Iy(I, j)△x

上述公式中Ix和Iy是亮度函数在x和y轴方向的偏导数,则经过一系列代数变换后可以得到如下公式。

其中矩阵M(x, y)表示如下。

哈尔角点的定义是在两个方向上像素强度发生剧烈改变,即上述的自关联矩阵具有两个较大特征值,如果只有一个较大特征值时表示该点位于边缘上,如果没有较大特征值表示该点位于某个色块儿中心。这种只考虑自相关矩阵的方式具有旋转不变性,这在某些实际的应用中很重要,如在追踪可能发生旋转的目标时。

计算得到的自相关矩阵特征值不仅可以用作判定某个点是否是特征点,还可以用于特征点描述符。尽管不总这样,但是通常关键点的特征和它们的描述符以这种方式紧密关联。很多时候,关键点的描述符达到了某个阈值就会被定义为关键点,在此处是自相关矩阵的特征值。需要注意的是Harris-Shi-Tomasi特征检测器的关键点判定标准和Harris原始的版本有所区别,前者在大多数追踪程序性能更好。

Harris的原始定义包括计算该矩阵的行列式,并将其减去矩阵迹的平方和权重值,其公式如下。

然后在局部区域内搜索该函数的最大值,并将其预先定义的阈值进行比较,然后得到关键点。这里的H称为Harris测量(Harris Measure),它可以再不具体计算矩阵M的特征值(这里是λ1和λ2)前提下,有效的对其进行比较。该公式中包含敏感系数k,其数值越小,算法敏感度越高,其取值区间为[0, 0.24],但是通常设置为0.04。下图演示了Harris角点检测的效果,为了易于理解,其中图片的部分被放大。

图a中黑点是Harris-Shi-Tomasi方法筛选出的角点,b、c、d三幅图是图中黑框放大后的效果图,其中椭圆的半径表示对应方向上特征值的倒数。b图中的黑点在两个方向上的特征值都较小,不是特征点。c图中的黑点在一个方向上具有较大的特征值,为边缘像素,也不是特征点。d图中的黑点在两个方向上都有较大特征值,它们都是特征点。

Shi和Tomasi随后发现只要较小的特征值大于最小阈值就能够标识出较好的角点。它们的方法不仅仅更充分,并且在很多情况下都能得到更满意的结果。类cv::GFTTDetector使用了Shi和Tomasi的测量标准,下文介绍的其他关键点检测器使用的是Harris的原始度量标准或者其变体。

相关接口

Harris-Shi-Tomasi角点检测器是基类cv::Feature2D最简单的实现,其构建函数如下。需要注意的是该算法会先确定角点的候选点,然后再返回最终的角点列表。

class cv::GFTTDetector : public cv::Feature2D {
public:
// maxCorners:最大搜索角点数
// qualityLevel:被标识为角点的较小特征值的阈值系数
//     图片中计算出的所有特征值中较小者的最大值和该系数的乘积为x,只有特征值中较小者大于等于该值的点才会被确定为角点
//     取值不要超过1,通常取值区间为[0.01, 0.1]
// minDistance:角点之间的最小距离
// blockSize:计算自相关矩阵使用的窗口直径
// useHarrisDetector:是否使用Harris原始的测量标准
// k:Harris原始测量标准里的敏感系数
static Ptr<GFTTDetector> create(int maxCorners = 1000, double qualityLevel = 0.01,
                                double minDistance = 1, int blockSize = 3,
                                bool useHarrisDetector = false, double k = 0.04);
...
};

当真正需要检测关键点时,调用该实例继承自父类的函数detect()即可。此外该函数还支持通过setget方法来设置内部的属性并改变算法的行为,如你可以通过代码gfttdetector->setHarrisDetector(true)来启用Harris原始的测量标准。

补充介绍

函数cv::goodFeaturesToTrack()和类cv::GFTTDetector是等价的,它们都包含几个特定的阶段,分别是计算自相关矩阵M,分析这个矩阵,应用阈值。关键步骤由函数cv::cornerHarris()cv::cornerMinEigenVal()完成,它们的原型如下。

// src:原始图像,元素格式为CV_8UC1
// dst:Harris度量计算结果,元素格式为CV_32FC1
// blockSize:自关联矩阵计算时使用的窗口大小
// ksize:Sobel算子的大小
// k:Harris原始测量标准里的敏感系数
// borderType:边界像素推断方式
void cv::cornerHarris(cv::InputArray src, cv::OutputArray dst, int blockSize,
                      int ksize, double k, int borderType = cv::BORDER_DEFAULT);

// dst:最小特征值,元素格式为CV_32FC1
void cv::cornerMinEigenVal(cv::InputArray src, cv::OutputArray dst, int blockSize,
                           int ksize = 3, int borderType = cv::BORDER_DEFAULT);

如果你需要自定义GFTT算法,OpenCV提供如下函数计算图像中每个点的特征值和特征向量。

// dst:计算结果,元素类型为CV_32FC6,6个通道分别表示(λ1, λ2, x1, y1, x2, y2),
//      其中λ1和λ2是两个特征值,(x1, y1)和(x2, y2)分别是它们对应的特征向量
void cornerEigenValsAndVecs(cv::InputArray src, cv::OutputArray dst,
                            int blockSize, int ksize, int borderType = cv::BORDER_DEFAULT);
4.3.2 简单气泡算法

斑点(Blob)是角点的一种替代概念,它也是寻找关键点的一种手段。下图是斑点法检测关键点的示意图。

可以看出两种图形中通过斑点法检测出的关键点几乎都不相同。和角点明确关注很局部范围的变化不同,气泡关注的是随时间变化可能具有稳定性的区域。因此该方法更适合用于在简单的环境中定位轮廓明显的目标。下图是通过斑点法检测关键点的另一幅示意图。其中左图是一幅乡村风景图,中间是取6个不同阈值得到的二值图,其中黑色部分是对最终检测出的特征点有贡献的轮廓。右图是一组重叠的斑点候选项,它们对应了原图底部中心的建筑物。这些候选项最终会合并为一个特征点。

斑点检测法的具体实现由很多,其中最简单的是cv::SimpleBlobDetector,除此之外还有高斯差分算法(Difference of Gaussian, DoG)、LoG算法(Laplacian of Gaussian)和DoH算法(Determinant of Hessian)。简单斑点检测算法首先将输入图像转换为灰度图,然后再得到一系列的阈值图。通过参数控制最小阈值、最大阈值和步长,从而控制阈值图的细节。接下来使用如函数cv::findContours()等手段分析联通分量,并计算轮廓的中心,它们就是候选特征点。接下来通过参数控制最小距离,从而将空间上相邻,并且来自于相邻阈值图像的特征点进行组合。当这些组合确定后,根据组合内所有的轮廓计算出中心和半径,得到的结果就是特征点。

一旦完成了斑点的定位,在输出最终特征点之前还可以对其进行过滤,从而减少特征点的数量。斑点过滤的条件可以是颜色,对于灰度图而言实际上就是像素强度,也可以是大小,即特征点的面积,也可以是圆度,即构成特征点轮廓点实际面积和特征点半径计算出来的圆的面积,也可以是惯性比(Inertia Ratia),即二阶矩矩阵的特征值比,或者是凸度,即构成特征点轮廓点实际面积和他的突包面积的比值。

相关接口

简单斑点检测器的定义如下。

class SimpleBlobDetector : public Feature2D {
public:
struct Params {
Params();
// 提取阈值图像时使用的最小阈值
// 通常设置为50~64,构建图像时包含该阈值
float minThreshold;
// 提取阈值图像时使用的最大阈值
// 通常设置为220~235,构建图像时不包含该阈值
float maxThreshold;
// 提取阈值图像时使用的阈值步长
float thresholdStep;

// 最小连续的阈值图像数量
// 在判定特征点时,只有候选点出现在该属性要求数量的连续阈值图像内才会被判定为特征点,当然候选点
// 的位置在这些阈值图像中可以有偏移,但是偏移量必须满足小于或等于minDistBetweenBlobs
// 通常设置为较小整数,但是很少小于2
size_t minRepeatability;
// 特征点之间的最小间距
// 间距小于该值的候选点都被认为是和同一个潜在特征点关联
// 单位为像素,因此需要根据实际的图片分辨率合理的设置该值,默认值为10,适用于分辨率为640✖️480的图片
float minDistBetweenBlobs;

// 是否通过颜色过滤特征点
bool filterByColor;
// 颜色过滤的条件,即颜色值为该属性的特征点才被保留(对应内外轮廓)
// 由于处理的是二值图像,值只有0或者255才有意义
uchar blobColor;

// 是否通过面积过滤
bool filterByArea;
// 最小(包含)、最大(不包含)的特征面积
float minArea, maxArea;

// 是否通过圆度过滤特征点
bool filterByCircularity;
// 最小(包含)、最大(不包含)圆度
float minCircularity, maxCircularity;

// 是否通过惯性比过滤特征点
bool filterByInertia;
// 最小(包含)、最大(不包含)的惯性比
float minInertiaRatio, maxInertiaRatio;

// 是否根据突度过滤
bool filterByConvexity;
// 最小(包含)、最大(不包含)突度
float minConvexity, maxConvexity;

// 文件操作接口
void read(const FileNode& fn);
void write(FileStorage& fs) const;
};

// 构建函数
static Ptr<SimpleBlobDetector> create(const SimpleBlobDetector::Params &parameters 
                                          = SimpleBlobDetector::Params());

// 文件操作接口
virtual void read(const FileNode& fn);
virtual void write(FileStorage& fs) const;
...
};

上面的代码段展示了该类定义的一些主要的结构,另外在实际执行检测任务时,需要调用继承于父类的函数cv::FeatureDetector

4.3.3 FAST特征检测

基于加速段测试特征(Features from Accelerated Segments Test,FAST)的特征检测算法最早由Rosten和Drummond提出,它的基本思想是直接将某个点和以其为中心的某个原上的所有点直接比较,假如圆上的点只有很少的一部分和中心点P相似,则点P就是一个不错的特征点。

一个使用该算法检测特征点的例子如下。下面两张图都用FAST算法检测出了前1000个质量最好的特征点,但是和Harris-Shi-Tomasi算法一样,在右图中大部分的特征点都来自于背景。

基于FAST算法思想的更早期版本是SUSAN算法,它比较懒点P周围的所有点。而FAST算法可以看作是该算法的继承者,他通过两种方式优化了该算法。第一个优化是FAST算法只使用了以点P为中心点一个圆上的像素。第二个优化是该算法将圆上的点分为比点P更亮、更暗和近似的点三类。分类依赖于阈值t,即根据Ip-t和Ip+t将这些点分为三类,其中Ip是点P的亮度。接下来算法会统计连续的比点P更亮或者更暗的点的数量,只有该数量达到一定的条件,点P才会被视为特征点。具体的说,如果圆上的总像素数为M,则这个条件值为N/2+1。

这个算法已经足够快,但是可能我们还会想到通过只测试四个等距点来优化该算法。此时如果不存在1对以上的点同时比中心暗或者比中心亮,则中心点P就不可能是特征点。到这一步还有一些问题需要处理,就是目前到逻辑可能会将多个相邻的像素标记为特征点,很明显这并不是我们想要的结果。如在下图中点P上面点像素按照目前的计算方式都会被标记为关键点。图中P是一个FAST关键点的候选项,以该点为中心半径为3的圆上所有像素点使用0到15索引标识,通过它们的像素强度可以判定P为候选关键点。

为了解决这个问题,FAST算法通过一个公式来计算每个候选点的得分,并删除和高分关键点相邻的所有关键点。该公式定义如下。

相关接口

FAST特征检测器相关类定义如下。

class cv::FastFeatureDetector : public cv::Feature2D {
public:
// 测试圆环类型
enum {
// 周长为8,最低连续不同分类像素数为5
TYPE_5_8 = 0,
// 周长为12,最低连续不同分类像素数为7
TYPE_7_12 = 1,
// 周长为16,最低连续不同分类像素数为9
TYPE_9_16 = 2};

// 创建实例
// threshold:像素分类时使用的阈值t
// 通常设置的值较大,如30,当该值过小时会得到很多像素强度轻微抖动形成的假关键点
// nonmaxSupression:是否提出得分较低的邻近关键点
// type:测试圆类型
static Ptr<FastFeatureDetector> create(int threshold = 10,
                                       bool nonmaxSupression = true,
                                       int type = TYPE_9_16);
...
};
4.3.4 SIFT算法

缩放不变特征变换(Scale Invariant Feature Transform, SIFT)特征检测算法最早由David Lowe在2004年提出,它被广泛使用,并且基于次后续发展出了很多其他特征算法。该算法相较于其他特征识别算法计算成本更高,但是具有高度表达能力,因此很适合追踪和图像识别任务。

下图是使用SIFT算法进行特征识别的例子,其中左图发现了237个特征点,右图发现了490个特征点。车上的特征点相对于其他算法的检测结果而言更稳定,在两幅图中能够看到很多对应项。尽管右图在背景中发现的特征点更多,但是两幅图和车相关的特征点近似相同。

下图简单演示了SIFT算法的计算逻辑,最左边是输入的原始图像,首先使用尺度递增的的高斯计算图像的卷积,得到中间的图像组。算法接下来计算这些图像中相邻的层的差值,从而得到最右侧的一组新的图像,其中的值可以看作是高斯算子(Difference of Gaussian)的差值。然后对于这组新图像中的每一个像素点,如黑色的实心方块,比较其和本层以及相邻层图像的值,如果它比相邻的26个像素(灰色方块)的值都强,则认为它是高斯算子差分的尺度空间极值(Scale Space Extremum)。

考虑一幅除了中心有个白斑的全黑图像,如下图,当选择的高斯核差值函数(G1-G2)和x轴的交点正好是白斑边缘时,使用该核对原始图像进行卷积的差值图像具有最强的响应。而我们选用的高斯核尺度是递增的,因此总能找到这样的高斯核对以及对应的色斑中心。很明显,此时高斯和差值负数部分和0像素相乘,正数部分和白色像素相乘。而对于色斑中心附近的其他点,或者不同大小的色斑中心点而言,同一个高斯核差值函数(G1-G2)和x轴的交点不位于色斑边缘,因此对应的差值图像具有的响应更弱。这样就可以找到白色区域特征的位置和大小了。

当找到这样一个候选点后,算法会计算该点的质量判定是否可以称为特征点,并且会再次计算它位于原始图像中的精确位置。算法通过以极值点为中心,大小为3✖️3✖️3的三维空间中拟合出一个曲面来完成这个计算。通过这一步计算可以得到两个重要的信息,第一个是计算关键点精确位置所需的偏移量,出了计算位置外该偏移量还可以用于在原始的高斯卷积图像集中进行差值计算。第二个信息是该极值附近的局部曲率估计,它以Hessian矩阵的形式表示。该矩阵的行列式可以作为判断特征点的阈值,低于某个值的候选点辨别度不够高,可以被过滤。另外该矩阵的纯空间部分的特质值比值如果很大,则表示该特征点很可能是在边缘上,而不是角点,因此这些候选点也可以被过滤掉。

当所有符合条件的特征点被提取出来后,算法会为每个点计算描述符,其计算过程如下图所示。在图a中,首先为每个特征点关联一个特征方向得到图b,具体计算方式是在原图中根据已经确定的比例缩放关键点周围的像素,通过Sobel差分算子计算x和y方向上的导数,再将其转化为极数坐标(幅值和角度)形式(一组x和y方向的导数可以转化为1个极数坐标)。对于特征点周围区域内的所有像素执行同样的计算,然后根据角度生成直方图,每个方向上的值根据幅值进行加权。最后使用抛物线拟合直方图中的最高项和其直接相邻项,抛物线最大值对应的方向就是特征点的特征方向。

当特征方向被找到后,可以根据这个特征方向分配合适的描述符,这使得SIFT特征描述符不仅具有缩放不变性,还具有旋转不变性。即我们在经过缩放和旋转后的图像中仍能定位关键点。

接下来根据特征点点特征方向将其旋转到指定角度,然后将以关键点分成多个区域,通常是4✖️4共16个区域或者更多,当然在图c和d中分割成了8✖️8共64个区域。然后计算每个区域中每个像素点梯度,将其转化为极坐标后按方向分量构建直方图,通常而言直方图分为8组,显然在图e中每个直方图包含16个分组。如图f,最后使用一个向量连续表示出所有的这些直方图每一个分组的值就是计算得到的最终SIFT关键点描述符。这种计算方式使得SIFT关键点描述符具有高度的描述性。

相关接口

SIFT算法的实现类是cv::xfeatures2d::SIFT,由于该算法申请了专利,因此其位于库opencv_contrib中,具体在模块xfeatures2d中,其定义如下。该类同时实现了特征点检测,和描述符计算功能,因此推荐使用其继承于父类的函数detectAndCompute()同时完成两个任务。

class SIFT : public Feature2D {
public:
// 构建实例函数
// nfeatures:需要提取的最大特征点数量
//     值为0时会返回所有找到的特征点
// nOctaveLayers:图像金字塔中每层图像执行卷积的次数
//     需要注意实际执行卷积的次数为nOctaveLayers+3,如在前文讲到的例子中金字塔每层图像执行了5次卷积,该值为2
// contrastThreshold:过滤最大极致的阈值
//     计算极值附近的局部曲率估计时得到的Hessian矩阵的行列式阈值,高于此值当被认为是具有足够的辨识度,可以进行下一步测试
// edgeThreshold:过滤边缘候选点的阈值
//     计算极值附近的局部曲率估计时得到的Hessian矩阵的特征值比值阈值,高于此值的被认为是边缘,不认为是特征点
// sigma:第一层高斯卷积使用的方差
//     当图像中存在很多噪声时可以适当增加该属性的值
static Ptr<SIFT> create (int nfeatures = 0, int nOctaveLayers = 3,
double contrastThreshold = 0.04, double edgeThreshold = 10, double sigma = 1.6);

// 特征点描述子的长度,总为128
int descriptorSize() const;
// 特征点描述子的类型,总为CV_32F
int descriptorType() const;
...
};

该类重载了继承自父类的函数detectAndCompute(),该函数在检测关键点部分需要三个参数,分别是imgmaskkeypoints。参数img为待提取特征点的图像,可以是彩色图像和灰度图像,其基本数据类型为CV_8U,但是需要注意的是如果使用彩色图像,算法内部会首先将其转化为灰度图像。参数mask用于标记不需要提取关键点的区域,元素格式为CV_8UC1,如果不需蒙版,传入cv::noArray()即可。参数keypoints类型为标准向量,其元素类型为cv::KeyPoint

函数detectAndCompute()用于计算描述符的参数为descriptorsuseProvidedKeyPoints。其中参数descriptors用于存储计算好的描述符,其类型为cv::Mat,其行数等于描述符的个数,列数等于描述符的长度,即矩阵的每一行构成一个描述符。当参数useProvidedKeyPointstrue时,算法内部不会再执行特征点检测操作,而是直接使用参数keypoints提供的关键点。

4.3.5 SURF算法

SURF(Speed-Up Robust Features)特征检测器最早由Bay等人在2006年提出,它是刚讨论的SIFT特征的一种衍生版本。SURF使用更高效的技术技术替代了SIFT特征算法的不同逻辑模块,这些技术使得该算法在识别任务中可能得到相似或者更好的性能。因此SURF算法不仅计算速度更快,在很多场景中该算法微简的性质使其对于方向或者明度变化相较于SIFT算法具有更强的稳定性(Robustness)。

一个使用SURF算法检测特征点的例子如下,左图中公检测到224个特征点,右图中共检测到591个特征点,这些额外的特征点都和右图中点背景相关。SURF算法和SIFT算法一样具备方向一致性,即图像旋转不影响特征点的检测。该例子中Hessian阈值设置为1500。

前面的章节提到过积分图(Integral Image)的概念,通过积分图可以快速计算任意矩形内所有像素的强度和。而SURF算法内部的大量计算都可以通过积分图技术来加速。

和很多其他算法类似,SURF算法通过指定点周围的局部Hessian矩阵来判定关键点。Hessian矩阵通用的定义是某个点的二阶导数矩阵,但在此处其定义为高斯二阶导数,其计算公式如下。其中G(x, σ)表示核大小为σ的标准高斯向量。图像在计算导数之前已经经过卷积运算。

上文介绍为了引入尺度的概念,SIFT算法使用了不同宽度核的高斯卷积差分来计算Hessian矩阵。而SURF使用盒式滤镜卷积来计算Hessian矩阵,这种计算的值和高斯卷积差分方式计算出的值类似。而盒式滤波卷积可以使用积分图技术提高算法的运算速度。下图中,图a演示了高斯差分函数,图b演示了计算垂直方向上的二阶导数的9✖️9卷积核(这里命名为DoG意思是高斯差分,它的波形和高斯拉普拉斯算子类似,可以用于计算原图在该点的二阶导数),图c演示了与图b计算结果近似的盒式滤镜卷积核。

由于通过积分图技术盒式滤镜的计算成本不随着核大小改变而改变,因此SURF特征提取法没有必要像SIFT法一样生成图像的尺度金字塔。实际上该算法使用更大的不同尺寸的盒式滤镜来计算不同尺寸的Hessian矩阵。算法将得到的Hessian矩阵的行列式计算结果高于某个阈值的局部极大值对应的点判定为关键点。

和SIFT特征一样,SURF特征也包含方向概念,它通过积分图计算特征点周围区域的梯度(Bulk Gradient)近似值。其计算方式如下图,图a是原始图像,图b中发现了一个极值点,即圆圈中心。如果特征的尺度为s,则使用尺度为4s的图c表示的小波(Wavelte),在以特征点为中心半径为6s的圆形区域内,以s为间距,通过与原图卷积计算近似局部梯度值。最后将这些计算结果转化为极坐标,并表示如图d。再设定一个角度为pi/3的滑动窗口,即图中灰色部分,累积其中包含的所有样本的幅度值,选择积最大的滑动窗口的中间弧度,即黑色箭头方向作为特征点的方向。这个计算过程看上去复杂,但实际的计算量并不算很大。该算法计算出的特征和SIFT算法一样包含特征方向,因此特征点都具有旋转不变性。

SURF算法计算特征点描述符的方式也与SIFT算法类似。在下图图a中,对于一个尺度为s的确定特征点,在其周围以特征方向为轴选择20s✖️20s的正方形区域,并将其分为4✖️4的网格。在图b中对每个网格中的25元素分别使用上图c中的Haar小波计算s和y方向的导数。然后如图c分别计算x和y轴方向的导数和,以及其绝对值之和。这样对于4✖️4网格中的每个元素共得到4个特征值,则整个区域能够得到64个特征值。使用标准容器库内的向量来存储这些元素,就得到特征点的描述符。

此外该算法还有一种变体,成为扩展SURF特征(Extended SURF Feature)。和基本的SURF特征不同的是,扩展SURF特征的描述符包含8个分量,它既是的仍然是x和y轴方向的导数和,以及其绝对值之和,但是在统计x方向导数时它区分了对应的y方向导数正负性,同样在统计y方向导数时也区分了x方向导数的正负性,因此共8个分量。这种方式使得该特征的描述符更长,在匹配时计算成本更高,但是在有些场景下这种信息更丰富的描述符能够提示程序的识别性能。

相关接口

SURF算法的相关类定义如下。

class cv::xfeatures2d::SURF : public cv::Feature2D {

public:
// 特征识别器的构建函数
// Hessian矩阵的阈值
//     高于该阈值的局部极大值对应的点会被判定为是关键点
//     默认值在实践中过小,通常设置为一个更大的值,如1500
// nOctaves:虚拟图像金字塔层数,这里的图像金字塔是通过成倍改变滤镜核大小实现,具体下文介绍
// nOctaveLayers:每个虚拟图像金字塔层内卷积核的应该具备多少个不同的尺寸
// extended:是否使用扩展SURF特征
// upright:是否需要计算特征点方向,不计算方向时算法速度更快
static Ptr<SURF> create (double hessianThreshold = 100, int nOctaves = 4,
                         int nOctaveLayers = 3, bool extended = false,
                         bool upright = false);

// 描述符长度,64或者128
int descriptorSize() const;
// 模式符格式,CV_32F
int descriptorType() const;
...
};

typedef SURF SurfFeatureDetector;
typedef SURF SurfDescriptorExtractor;

在诸如汽车和移动机器人应用程序中,可以认为相机的方向相对于被识别物体的方向是固定的。例如自动驾驶领域识别到的路牌几乎都是垂直方向的。通过参数upright控制算法跳过计算特征方向的逻辑能够提高算法效率。

前文讲到过SURF算法不用香SIFT算法那样构建图像金字塔,它通过成倍改变盒式滤镜核大小的方式生成虚拟的图像金字塔。其默认值为4,对于大多数应用场景这个值已经足够了。但是在处理超高分辨率低图像时,需要增加这个值。由于使用了积分图技术,该值的增加对算法性能的影响很小。

对于每一层虚拟的图像金字塔,算法都会使用几个不同尺寸的卷积核,虚拟金字塔最高层,即分辨率最低的层,使用的多个不同规格的卷积核中心尺寸为9✖️9。SURF算法在多个金字塔层之间并未均匀分配卷积核的尺寸,这意味着如果参数nOctaves大于等于2,则在相邻的虚拟金字塔层之中使用的卷积核大小会有重叠。这并不意味着每个虚拟金字塔层使用更多的不同规格卷积核是没用的,只是它不够直观而已。而每个虚拟金字塔层使用的卷积核尺寸数量由参数nOctaveLayers控制,其默认值为3,但是一些研究发现该值设置为4时也是有用的,尽管这样做在一定程度上会增加算法的成本。

该类重载了函数SURF::detect()SURF::compute()SURF::detectAndCompute()。当然如果你想同时获得关键点和其描述符,推荐直接使用第三个函数。

该类同样提供了很多方法来获取和设置检测器的一些属性,但是需要注意在检测过程中不要使用这些方法去改变检测器的属性。相反你应该通过实践找到合适的参数,然后使用这些参数运行程序,否则你可能得到不具备可比性的描述符。

4.3.6 Star/CenSurE算法

星特征(Star Feature)是为了解决视觉测距问题而被发明的,即根据图像数据测量相机自身的移动。在这种背景下如Harris角点和FAST特征就是很好的备选项,因为它们所描述的信息具有高度的局部性。而如SIFT特征等依赖于图像金字塔,在计算过程中随着向图像金字塔顶层移动,特征在原始图像空间中的“本地性”就会被破坏(Poorly Localized)。但是如Harrs角点和FAST特征由于缺少尺度空间的搜索,因此不具备SIFT等特征的缩放一致性(Scale Invariant)。

Star特征也被称为中心围绕极值(Center Surround Extremum, CenSurE)特征,它在保证缩放一致性的同时提供一种如Harris角点和FAST特征一样具有高度局部性的特征(Providing the Level Of Localization of Harris Corners)。该特征算法并不包含专用的描述符,在相关论文发表时,作者使用的是直立SURF(Upright SURF,U-SURF)特征描述符的一种变体。

一个使用Star特征算法检测关键点的示例如下,使用算法提供的默认参数在左右两幅图中检测出的关键点数量分别为137和336。它们在汽车上检测出的关键点几乎相同,右图中更多的关键点主要来自于背景。尽管相较于其他方法在汽车上检测出的关键点更少,但是两幅图中得到的汽车关键点几乎相同,说明相对于其他算法改算法更稳定。

Star特征算法分为两个阶段,第一个阶段快速的计算一种类似于SIFT等算法使用的高斯差分(DoG)的近似值,然后再提取一定区域内的极值。第二个阶段使用前文讲到的Harris度量的一个缩放版本剔除候选点都属于边缘点的项。

为了理解Star算法如何快速计算DoG的近似值,可以回想一下SURF使用的盒式滤镜技术。下图演示了Star算法使用的卷积核计算过程,图a是两个标准差相近的高斯分布的差值函数图像,图b是其近似函数图像,图c是根据改近似DoG函数图像生成的卷积核,其中白色区域与黑色区域的宽和高相等,该卷积核就是Star算法使用的卷积核。

Star算法会通过积分图技术快速的计算卷积结果,然后会使用不同大小的卷积核重复执行该运算。最小的核边长为4,理论上任意大小的核的卷积结果都能够很快被计算处理,但是实际上选用的核大小有一个指定的序列,为[4、6、8、11、12、16、22、23、42、46、64、90、128]。这一步的计算使得图像中的每个点都能够确定一个在尺度空间上,对特定DoG使用的标准差组合具有最强的响应。前文已经提到过通过这种现象能够推断出特征的大小,从而保证了Star算法在寻找关键点时的缩放不变性。

当图像中所有点都计算出了不同尺度的近似高斯差分值后,以x、y和使用的卷积核大小组合为3个基本维度,在这个三维空间中对于3✖️3✖️3空间内的27个样本取最大或者最小值作为极值,它们就是候选特征点。

接下来算法使用缩放适应Harris度量(Scale-Adapted Harris Measure)来过滤这些候选点中包含的边缘点。计算缩放适应Harris度量使用的矩阵和前文讲到Harris-Shi-Tomasi角点时使用的矩阵很相似。但是其中有两个明显的差异,第一个区别是Star算法用于计算自相关矩阵中个元素的和的窗口与特征点的大小成比例。第二个区别是构建自相关矩阵的元素是该像素的对高斯差分的最大响应而不是像素的颜色强度值。接下来使用Harris定义的度量公式进行判断,即比较矩阵的行列式,和矩阵的迹平方和敏感系数的乘积。

在OpenCV的实现版本中,在上述步骤后还会经历第二次测试。该测试和缩放适应Harris度量类似,只是这次构建自相关矩阵的元素和是窗口内每个点最高响应对应的高斯差分使用的标准差相关。该度量称为二值化缩放适应Harris度量(Binarized Scale-Adapted Harris Measure)。该度量取名“二值化”的原因是对于窗口内的每个点,根据该点相对于邻点的取得最大响应的DoG的标准差的变换率来分配值1、0或者-1。这种二值化测试衡量的是特定点是尺度空间极值的概率。

相关接口

Star算法由类cv::StarDetector实现,前文已经提到过Star算法没有包含描述符计算部分,因此该类只重载实现了detect()方法,其定义如下。

class cv::xfeatures2d::StarDetector : public cv::Feature2D {

public:
// 构建检测器的函数
// maxSize:算法内部使用的最大卷积核大小
// responseThreshold:算法内部初步筛查卷积结果时使用的阈值
// lineThresholdProjected:缩放适应Harris度量使用的阈值
// lineThresholdBinarized:二值化缩放适应Harris度量的阈值
// suppressNonmaxSize:特征点的最小间距,只保留该范围内的最佳特征值
static Ptr<StarDetector> create(int maxSize = 45, int responseThreshold = 30,
                                int lineThresholdProjected = 10, int lineThresholdBinarized = 8,
                                int suppressNonmaxSize = 5);
...
);

前文提到过对于图像中的每个点都会使用多个不同尺寸的核进行卷积运算,这些核的大小属于特定序列,为[4、6、8、11、12、16、22、23、42、46、64、90、128]。算法实际使用的核对应的大小取值为该序列中所有小于等于参数maxSize的元素,而参数maxSize也只能指定该序列内的值。

参数responseThreshold用于指定算法内部初步筛查卷积结果时使用的阈值,从而得到候选关键点。该参数指定的是使用尺寸为4的卷积核得到的响应使用的阈值,对于更高尺寸的卷积核,其核内的每个元素会按核大小经过标准化处理,使得使用不同尺寸的卷积核都可以使用参数指定的阈值。参数lineThresholdProjected实际上是Harris度量公式中使用的敏感系数的倒数,该值越高,点越容易被判定为边缘点。参数lineThresholdBinarized的含义类似,它强制要求候选点是尺度空间的极值。

4.3.7 BRIEF算法

BRIEF(Binary Robust Independent Elementary Features)是一种较新的算法,它由Calonder等人提出,因此也被称为Calonder特征。该算法不能识别关键点,只能为已经提取出的特征点计算描述符。该算法的基本原理基于一系列的测试,这些测试比较了特征内的两个不同像素点的明暗关系,并得到0或者1的结果。而这些结果组合称为一个二进制流就是BRIEF特征。为了避免图像噪声影响该算法的结果,该算法会先使用高斯卷积平滑图像。由于BRIEF特征是二进制流,因此它的计算、存储和比较都很方便和高效。

该算法的使用实例如下,整幅图片位于一个被圈定的关键点区域内,其中的每条线段的两个端点都是参与测试的像素。

生成测试像素对的方法有很多。其中一种最好的方式是根据围绕特征中心的高斯分布绘制一个点,然后从围绕前一个点使用一半的标准差的高斯分布中再绘制一个点,通过这样的方式随机生成所有的像素对。绘制点的区域,即特征的总面积称为像斑大小(Patch Size),而高斯分布的标准差被称为核大小(kernel Size)。在上图中,核大小和像斑大小的比值约为1:5。当前的OpenCV使用固定的比值,但是理论上它们是可调整的参数。测试对的数量通常是128、256或者512,但是遵照该算法创立者的习惯,通常使用描述符的字节数表示其长度,即分别为16、32和64字节。

相关接口

如前文所到BRIEF算法只包含描述符提取部分,因此其关联类只重载实现了计算描述符的函数。其定义如下。

class cv::xfeatures2d::BriefDescriptorExtractor : public cv::Feature2D {

public:
// 提取器构造函数
// bytes:描述符的字节长度
// use_orientation:是否根据关键点的特征方向进行旋转,从而保障选择不变性
//     如果不是处理如路牌识别等方向固定的场景,建议将该值设置为Yes
static Ptr<BriefDescriptorExtractor> create(int bytes = 32, bool use_orientation = false);

// 描述符的字节长度
virtual int descriptorSize() const;
// 描述符的元素格式,总是CV_8UC1
virtual int descriptorType() const;
};
4.3.8 BRISK算法

在BRIEF算法提出来不久后,一些使用相似手段的算法被开发出来,这些算法通过相似的点比较方式生成能够被快速比较的压缩描述符。Leutenegger等人在BRIEF描述符的基础上进行优化,发明了BRISK描述符。其优化主要体现在两个方面,首先该算法引入了自己的特征检测器,其次算法重新组织了这些二值比较结果,从而提升了特征的整体稳定性。

一个使用BRISK算法的例子如下,左右两图分别检测到了232和734个关键点。尽管右图中背景检测出了更多的关键点,但是汽车上的关键点无论在位值和数量上都相当稳定。

BRISK算法的特征检测器部分本质上是基于类似于FAST的AGAST(Adaptive and Generic Corner Detection Based on the Accelerated Segment Test)检测器(该检测器OpenCV中并未单独实现),但是它尝试为特征标识一个方向和大小从而优化算法。算法的简要计算过程如下图,首先通过改变搜索半径的方式构建虚拟的图像金字塔层,然后在层间再进行插值得到次层(Inra-Octaves)。然后对所有的层和次层应用FAST技术(实际上是AGAST)搜索特征点,并过滤掉那些分数(在FAST算法中使用p0表示)不是邻域范围内的极大值的候选特征点。

当得到符合要求的候选特征点列表后,算法计算这些特征点在被发现的金字塔层以及相邻的层或者次层对应的AGAST分数,然后使用这三个值拟合出一条个二次函数,当函数取得极值时对应的大小就是识别到的特征点尺寸。在计算特征点位置时也使用的类似的插值手段从而得到高精度的坐标,即非整数的坐标。

为了理解BRISK算法是如何确定关键点特征方向,首先需要了解下算法在计算描述符时使用的采样模式。如下图a,算法围绕中心点构建了一系列的圆环,每个圆环有Ki个样本点,每个样本点都分配了一个以其为中心,以Ci/Ki为直径的圆形区域,其中Ci是第i个圆环的周长。对该区域对应的图像应用高斯卷积,高斯卷积核的标准差等于Ci/2Ki,然后对指定点即圆心进行采样。

指定点(如b和图c中点实心圆包含点样本点)与其他所有点组合称为测试项,根据测试项包含点两个样本点距离将它们分为两组。其中距离小于指定参数dmax的所有项为短距测试项,如图b,它们被用于计算关键点的特征方向,其中距离大于指定参数dmin的所有项为长距测试项,如图c,它们被用于计算关键点的描述符。

具体的计算方式是通过将一个测试项包含的两个像素强度差除以它们的距离,从而计算出一个近似的局部梯度。然后对所有的长距测试项计算出的局部梯度求和,就可以计算出一个特征方向。然后根据该特征方向计算所有短距测试项对应的梯度,再对其进行阈值处理,从而得到特征描述符,这样得到的描述符就具有旋转不变性。

通过调整每个圆环上采样点的个数和参数dmax,可以控制描述符为任意长度。按照惯例,在构建相关类实例时选择默认参数的描述符长度为512位。

相关接口

OpenCV中实现BRISK算法的类定义如下。

class cv::BRISK : public cv::Feature2D {

public:
// thresh:传递给FAST算法的阈值,用于像素分类
// octaves:虚拟图像金字塔的层数
// patternScale:Rescale default pattern
static Ptr<BRISK> create(int thresh = 30, int octaves = 3, float patternScale = 1.0f);

// 自定义BRISK描述符计算细节的提取器实例创建函数
// radiusList:采样圆环的半径列表
// numberList:每个圆环上的样本点数量
// dMax:用于判定短距测试项的阈值
// dMin:用于判定长距测试项的阈值
// indexChange:预留参数,还未使用,无任何含义
static Ptr<BRISK> create(const vector<float>& radiusList, const vector<int>& numberList,
float dMax = 5.85f, float dMin = 8.2f,
const vector<int>& indexChange = std::vector<int>());

// 描述符的长度
int descriptorSize() const;
// 描述符的元素格式
int descriptorType() const;
};

该类提供了两个构造实例的方法,如果你使用第一个方法,则在计算描述符时会根据库内不一个固定的查询表来确定采样点。另外该算法实现内部调用了算法FAST,其构建实例的参数thresh将会直接传递给内部调用的FAST算法。需要注意由于次层的存在,因此实际上尺度空间上的虚拟金字塔层数为2octaves - 1。

4.3.9 ORB算法

对于很多应用程序特征识别器的速率是十分重要的,特别是对于那些基于视频数据的我们希望能够实时运行的场景,如增强现实和机器人应用程序。也正是因为这个原因前文所描述的SURF特征被提出,它的目标是以更快的检测速度达到和SIFT特征相似的效果。同样的ORB特征的目标也是提供一种比SIFT和SURF特征更快的检测手段。该算法的关键点检测部分的实现基本是是基于FAST算法实现的,而描述符部分主要是基于BRIEF算法实现的。ORB算法通过额外的方向计算优化了BRIEF描述符,这使得该算法生成的描述符具有和SIFT和SURF描述符一样的旋转不变性。

该算法的一个使用实例如下,在两幅图中分别检测了500个关键点。ORB算法有一个特点,如果检测到了一个较强特征,则在其周围通常会发现很多歌不同尺寸的特征。

算法的第一步是使用FAST算法寻找图像中的候选关键点。FAST算法的开销不大,但是检测出的特征有一些缺陷。首先这些特征可能包含边缘点,为了解决这个问题ORB算法计算了这些候选点的Harris度量。前文已经提到过,Harris度量是对由关键点周围像素信息构建对自关联矩阵的特征值的限制。接下来构建一个图像金字塔,从而使得关键点的搜索能够在尺度空间上开展。由于Harris度量可以反映关键点的质量,因此搜索由x、y和尺度构成的三维空间中的极值从而确定最佳特征。通常情况下用户会指定搜索关键点的最大数量,算法内部通过Harris度量对特征点进行排序,返回指定数量的特征点。

ORB算法对于FAST特征(或者是Harris角度)而言最大的贡献就是为关键点引入了方向的概念。如下图,计算方向的过程分为两步。对于图A中已经检测到的关键点,首先对以特征点为中心点一个方形区域内的所有像素强度值计算一阶矩,如图b,即x和y坐标分量分别和对应像素强度乘积的和,在介绍轮廓的章节中有更详细的关于矩的介绍。方形区域的边长是特征尺度(为尺度给定的圆的半径近似)的两倍。然后如图C使用0阶矩对x和y方向一阶矩进行标准化,从而得到相对于特征中心的梯度方向。也因为这个计算过程ORB特征也被称为oFAST或者oriented-FAST特征。

接下来ORB算法根据此方向使用BRIEF算法的思想计算关键点的描述符,从而使其具有旋转不变性。ORB算法的描述符计算部分和BRIEF算法第二个显著的区别是,BRIEF算法通过分析一个大型图像集寻找具有特定属性(高方差)的测试对,从而得到了旋转感知描述符。然而在ORB算法中,构建描述符使用的测试点都和关键点的特征方向相关,作者在提出该算法的时候就构建了这个分析手段,因此下文将其称为算法内置分析方法。作者构建分析手段时使用的图片集为PASCAL-2006数据集,它是一个非常有名的图片集,其中包含各种类型的图片。它被广泛应用于计算机视觉领域,并且被很多论文所引用。

相关接口

ORB算法相关的特征识别器定义如下。

class ORB : public Feature2D {
public:
// 过滤边缘点使用的分数公式(只包含后两个枚举值,第一个枚举的含义暂不明确)
enum { 
kBytes = 32,
// 使用Harris度量
HARRIS_SCORE = 0,
// 使用FAST算法定义的度量
FAST_SCORE = 1
};

// 特征识别器实例的构建函数
// nfeatures:检测的最大关键点数量
// scaleFactor:构建图像金字塔之间的缩放系数
//     算法内部不会直接按照函数cv::buildPyramid()采用2倍增长的方式构建金字塔,因为这会损失太多细节
// nlevels:构建图像金字塔的层数
// edgeThreshold:图像边界不被搜索到距离
//     由于关键点具有特定的大小,因此图像边界一定距离内的区域不会参与关键点的检测
//     当参数patchSize的大小更新后,需要同步更新该属性,使之大于等于patchSize的值
// firstLevel:原图在金字塔中的层索引
//     将该值设为非0值可以使得金字塔中的某些层分辨率大于原图
// WTA_K:计算描述符时每个测试对包含的像素个数
//     取值为2、3或者4
// scoreType:过滤边缘点使用的分数公式
//     可选值为HARRIS_SCORE和FAST_SCORE
// patchSize:计算描述符选择测试对使用的像斑大小
//     ORB算法论文并没有解决缩放一致性问题,但是OpenCV的实现使用了金字塔图像解决,
//     因此这里应该隐含一个缩放操作使得ORB描述符具备缩放一致性
// fastThreshold:过滤边缘点使用的阈值
static Ptr<ORB> create(int nfeatures = 500, float scaleFactor = 1.2f, int nlevels = 8,
int edgeThreshold = 31, int firstLevel = 0, int WTA_K = 2,
int scoreType = 0, int patchSize = 31, int fastThreshold = 20);

// 描述符的字节数,总为32
int descriptorSize() const;
// 描述符的基本数据格式,总为CV_8U
int descriptorType() const;
};

尽管该类实例的构建函数包含的参数列表很长,但是很多属性保留默认值都能够得到很好的结果。由于ORB算法的描述符计算部分本质上依赖于图像的平滑版本,因此将参数firstLevel设置为大于0的值是有意义的。但是需要注意如果该属性过高,会产生一些主要由噪声引起的小尺寸关键点。

参数WTA_K不仅指定了每个测试对包含的像素个数,还指定了测试的方法。当使用默认值2时,表示使用前文描述的测试方法,即通过比较一个测试对的两个像素敏感从而得到0或者1的结果,每个比较结果占用描述符长度的1位。而当该属性指定为3时,则通过选择3个像素点,使用最亮的像素索引作为测试结果,可能是0、1或者2,这样每个测试结果占据描述符长度的2位。当该属性选择4时具有类似的含义。

需要注意的是,只有当属性WTA_K为2,属性patchSize为31,即它们都是默认值时,计算描述符选择的测试对像素坐标才是使用算法预先生成的,并且使用固定的组合。如果属性WTA_K为2,属性patchSize不为31,尽管使用的是算法预先生产的测试对像素坐标,但是组合并不是固定的。如果属性WTA_K不为2,属性patchSize也不等于31,则测试对的像素坐标是随机生成的。后两个方式对算法的结果影响实质上并没有太多的差异。

参数scoreType指定了检测特征时评价特征点质量以及过滤特征点的评判标准。当使用HARRIS_SCORE时如前文所讲会检测到大量的特征,并且会计算所有候选关键点的HARRIS度量,并只保留其中响应较强的点。这样做在两个方面都会增加计算成本,首先Harris度量的计算需要时间成本,其次需要计算的特征点数量会更多。使用FAST_SCORE时得到的质量相对较差,但是会稍微提升算法的整体计算效率。另外当使用HARRIS_SCORE时初次检测出的特征数量是使用FAST度量检测出特征数量的两倍,然后计算Harris度量,保留其中更好的一半。

该类同样支持一些列的get和set方法在实例创建后设置和获取内部属性。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267