【OpenCV】第二十二章: 图像特征检测之SIFT算法

第二十二章: 图像特征检测之SIFT算法

本章是二十一章的延续,继续讲图像特征的提取。我们知道Harris角点检测最大的缺陷是不具有尺度不变性,当图片放大后,原来能检测到的角点就变成边线了,就检测不到了。本章讲SIFT算法提取特征点。

SIFT,全称是Scale-Invariant Feature Transform,是与缩放无关的特征检测,也就是具有尺度不变性。SIFT算法可以说是传统CV领域中达到巅峰的一个算法,这个算法强大到即使对图片进行放缩、变形、模糊、明暗变化、光照变化、添加噪声,甚至是使用不同的相机拍摄不同角度的照片的情况下,SIFT都能检测到稳定的特征点,并建立对应关系。时至今日,即使深度学习已经成为主流研究方向,SIFT特征点检测依旧是最好的局部特征检测方法之一(如果不考虑速度因素,这个“之一”是可以去掉的),而SIFT特征描述也在人工设计特征的时代启发了大量梯度方向直方图的特征。

SIFT算法是由加拿大英属哥伦比亚大学教授David Lowe在1999年发表,2004年完善总结,谷歌学术显示2004年SIFT就已经被引用多达55841次。


Lowe非常有商业头脑,他很快就将这个算法申请了专利,所以很长时间以来,SIFT在OpenCV的代码一直在Non-Free模块,因为有专利保护,不能在商业场景随便使用。时至2020年3月6日,专利权满20年,已经到期!SIFT才成为了全人类的公共技术,任何人和组织都可以免费使用!OpenCV官方也把SIFT挪出Non-Free模块(不过业内人士都觉得OpenCV实现的SIFT很烂,建议大家使用VLFeat里的SIFT)。

在今天,SIFT依然具有商业价值,在一些相关领域仍然被广泛使用。应用范围包括:物体或场景识(object or scence recognition)、立体匹配(stereo correspondence)、运动跟踪(motion tracking)、解决多图片3D构建问题(solve for 3D structure from mutiple images)、机器人地图感知与导航、影像缝合、手势识别、影像追踪、动作对比等领域。
SIFT唯一的缺点是非常慢,检测静态图像还可以,如果检测视频就不行,实时识别会慢到你无法忍受。

当然这个算法也非常的复杂和麻烦,本来只想简单写写opencv中的api,结果一深入研究,步骤多到咬牙切齿,不得不为这个算法单独开辟一章写。网上关于这个算法的资料是五花八门,云里雾里,而论文本身也是讲得非常模糊,所以网上充斥着各种误读。限于本人目前的知识以及编程能力,有一些细节我也没明白,也没找到说得明白的资料,但是写了很久,放弃挺可惜的,所以本版作为一个初版,以后我继续完善这篇文章。

  • SIFT是怎么检测的?
    SIFT检测也是借鉴人眼看物体的过程,我们人眼看物体时不仅要看物体的局部细节还要看物体的整体轮廓;而且我们人眼近处看物体,物体比较大,远处看物体物体就比较小,这和相机拍照是一致的,近大远小。SIFT就把图像处理成不同尺度空间的一系列图片,这一些列图片既有局部的清晰细节又有整体的模糊轮廓,还有不同的大小尺寸(远大近小的效果),然后再在这一系列图片中找一些稳定的点做为特征点提取。
  • SIFT都能检测到一些什么特征点?
    SIFT查找的特征点是一些十分突出的点,比如角点、边缘点、暗区的亮点、亮区的暗点、局部平坦区域也会检测出一个点,用这个点代表这个局部区域。并且这些点不会因为平移、旋转、缩放等仿射变换、光照的亮度变换、噪声等因素的影响而检测不到。所以使用SIFT算法检测出来的特征点,使用效果非常好。就是用SIFT查找的特征点用在匹配、定位、追踪等方面效果非常好。论文的原话是:该方法能够对于不同视角下的物体或场景实现可靠匹配,算法提取到的特征对图像的尺度和旋转具有不变性。当对图像进行仿射畸变差(affine distortion)、改变三维视角(change in 3D viewpoint)、额外增加噪声(addition of noise)、改变光照强度(change in illumination)等变化,我们提取到的特征都表现出了很好的鲁棒性(robust)。此外,在实时识别的过程中,无论识别物体是杂乱还是被遮挡,该方法都能识别出来,鲁棒性很好。This approach to recognition can robustly identify objects amongclutter and occlusion while achieving near real-time performance。

同时该算法能够提取到大量的特征点,它们密集的覆盖了整个图像的尺度和位置,并且这些特征都是高度独立的。例如,对于一个500*500像素的图片将能够产生大约2000个稳定的特征。

SIFT提取的特征点,也叫关键点、极值点、kp。这些点不仅有x、y坐标信息(也就是位置信息,就是kp这个点在原图的哪里),还有点的方向和大小信息(注意这里的大小可不是指特征点的像素值的大小,是算出来的像素点的梯度大小幅值)。而SIFT提取的这些特征点目的就是用这些稳定的点进行特征匹配的,所以SIFT还生成了这些特征点的信息描述,信息描述是一组向量,也称为描述子,或者des,长度是128,这组向量表示的是这个特征点周围对其有贡献的像素点的信息,是不受仿射变换、光照等影响的一组变量。正是有了特征点的描述子,而描述子又不受仿射变换、光照、噪音等因素的影响,我们就可以根据描述子把两张图片中相同的特征点匹配出来,达到匹配的目的。下图左图template是右图3楼第2个房间的窗户照片,我们用SIFT算法在右图中找到这个窗户。

图像匹配是计算机视觉领域诸多问题中的一个基础性方面。关键点描述子的根本作用就是用于图像匹配,我们可以用特征匹配算法(下下章讲解),将搜索图像的des描述子和图片库图片的des进行匹配,匹配到之后,我们就可以按匹配值进行检索了,这就是以图搜图了。对于图像拼接来说,如果我们查找到两张图中有相同的des,就确定了相互匹配的kp对儿了,就可以进行图像拼接了,如果两张图之间完全没有相同的特征描述子,那我们是无法进行拼接的。

  • SIFT算法的步骤
    Lowe将SIFT算法分解为4步:
    1、Scale-space extrema detection--尺度空间极值检测
    2、Keypoint localization--关键点定位
    3、Orientation assignment--关键点方向确定
    4、Keypoint descriptor--关键点描述

这个步骤是Lowe给出的一个大概的整体描述,中间非常多的细节参照下面每小步的分解讲解去理解。

一、尺度空间极值检测

SIFT算法是在不同尺度空间上查找关键点,所以第一步首先是构建一张图像的不同尺度空间,然后再在这些不同的尺度空间里初步极值检测 。
而构建不同尺度空间的步骤又分两小步:第一小步是构建高斯金字塔,第二小步是将高斯金字塔的相邻层相减得到高斯差分金字塔。所以尺度空间就是一张图片的高斯金字塔和高斯差分金字塔。
而初步极值检测也分为两小步:第一小步是用阈值先去除一些噪声极值点,第二小步是在高斯差分金字塔中,在同层以及垂直方向上的26个邻域内初步找极值点 。

所以,本步可以分解为:构建高斯金字塔--构建高斯差分金字塔--阈值化--初步检测极值点 4个小步。

说明:有的参考资料第一大步就叫构建高斯尺度空间,第二大步叫极值点检测,其实个人感觉这样更清晰一些。但论文原文是尺度空间极值检测,所以论文本身就说得非常模糊,其他的解读也是众说纷纭,这里为了尊重作者,就按作者的步骤。

  • 我们先看一下高斯金字塔长的什么样子:

    这个金字塔和我们第十一章讲的金字塔有些不一样,这个金字塔从整体上看是多个普通金字塔重叠在一起的金字塔。
  • 这里首先要明确几个概念:
    (1)上面高斯金字塔的'层'不叫'层',叫'组',或者直接叫Octave,Octave直译是八度音阶的意思。
    (2)金字塔的每组(每个Octave)都有多个‘层’,就是每个Octave里面都有图像尺寸(或者说图像分辨率)相同的好几张(层)图像。就是不同层的图片尺寸是一样的,不同组里的图片尺寸是不一样的。
    (3)上面的金字塔我们叫它为高斯金字塔,有的地方又叫高斯尺度空间(或者也叫Gaussian Scale-Space S(x,y,δ) )
    (4)高斯差分金字塔(Difference of Gaussian(DOG))是高斯金字塔相邻层做差分得到的。所以DOG整体的样子和高斯金字塔是一样的,组数Octave一样,就是每组中的层数不一样,DOG的层比高斯金字塔的层少一层。因为做差分,所以少一层。假如高斯金字塔一组是5层,那DOG一组就是4层。

这里给大家统一这么多名称和叫法,就是因为参考资料五花八门,统一一下再看论文原文或者其他参考资料就不会云里雾里了。

1、构建高斯金字塔

  • 构建完成后的效果如下:

    上面的图像就是高斯本人的头像
  • 论文里的实现步骤:
    第一步:将原图放大一倍
    用双线性插值法(我们第五章将图像缩放时有讲过这个插值方法)将图像的宽高分别增加一倍。就是将图像分辨率翻倍,放大一倍。就是上图中第一行的单独的那张图片。
    论文原话是:the algorithm first doubles the width and height of its input using bilinear interpolation. That's the first picture above,the one in its own row.
    说明:先将图像放大一倍是因为为了尽可能多的保留原始图像信息,所以要先升采样一次。扩大一倍后的图像我们称为Octave-1。但是有时我们也可以不做这一步,直接在原图上操作,这一步不是必须的。 如果需要做这步,调用opencv中的cv2.resize()函数即可完成。 第二步:进行高斯卷积操作
    将第一步得到的图像进行高斯卷积操作,并且将卷积结果继续用更大的高斯卷积核进行卷积,一直卷到图像模糊。
    论文原话是:this picture is subsequently blurred using a Gaussian convolution. That's indicated by the orange arrow.
    what follows is a sequence of further convolutions with increasing standard deviation. Each picture further to the right is the result of convoluting its left neighbor, as indicated by the green arrow.
    我们知道高斯卷积的作用就是平滑图像,使图像变得模糊。这步操作结果就是上图中的第二行的6张图片,也就是高斯金字塔第1组(Octave1)里面的各层图片。这组图片也就是仿照了我们人眼看物体时的细节和轮廓两方面,清晰时看到的是细节,模糊时看到的是轮廓。
    要完成这步操作,一是要构建高斯核,二是进行卷积。这块是一个重点!这里Lowe是用不同的高斯核对图像进行卷积得到高斯尺度空间。
    因为图像都是二维的,所以得用二维高斯核。而生成二维高斯核用到的数学公式是二维高斯函数:
    其中,δ又叫尺度空间因子,就是标准差,就是论文中的increasing standard deviation,这个高斯核我们又称尺度空间滤波器。不同的高斯核指的就是不同sigam的高斯核。 有了高斯核就可以卷积计算了,卷积计算和卷积神经网络的计算方式一样,就是对应位置相乘相加,这里就不描述了。 第三步:进行下采样,重复第二步操作,得到后4组Octave,完成高斯金字塔的构建。
    这步操作就类似于仿照了人眼看物体的近大远小的效果,从近处看物体很大,从近处看物体很小,也符合照相机的拍摄效果。
    此时我们就得到了高斯金字塔,又称高斯尺度空间S(x,y,δ) 或者称为Gaussian Scale-Space S(x,y,δ) 。
    论文原话是:Finally, the antepenultimate picture of each row is downsampled - see the blue arrow. This starts another row of convolutions. we repeat this process until the picture are too small to proceed.(By the way, each row is usually called an octave since the sampling rate is decreased by a factor of two per stage.)
    what we now have constructed is called a scale space. The point of doing this is to simulate different scales of observation(as you move further down in the table) and to suppress fine-scale structures(as you move to the right).
  • 具体细节和说明:

(1)高斯金字塔的组数如何确定?
用公式算:,其中,M,N是原始图像的宽和高。
利用这个公式就可以知道你需要建多高的金字塔了,也就是我们要重复几次步骤2。具体为啥是这个公式,可能是Lowe的经验吧,反正论文是这么求的,没有找到解释。

(2)提前简单说明一下,高斯差分金字塔是怎么生成的?
看下图:

该图左边是高斯金字塔,右边是高斯差分金字塔。左下是高斯金字塔的第一组Octave1,里面有5张图片;左上是高斯金字塔的第二组Octave2,里面也有5张图片。图右下是高斯差分金字塔的第一组Octave1,里面有4张图片;右上是高斯差分金字塔的第二组Octave2,里面也有4张图片。因为高斯差分金字塔是高斯金字塔的相邻层相减而得来的,图中的带圈减号,其实就是单纯的减。所以,高斯金字塔和高斯差分金字塔的组数是一样的,对应组里的图像尺寸也是一样的,只是数量少1(就是层数少1)。

(3)提前简单说明一下,生成高斯差分金字塔后,如何在高斯差分金字塔上提取极值点的?
我们生成的高斯差分金字塔里面的所有图片,是一个连续的不同尺度空间里的一系列图片。当我们要判断某张图片上某个点是否是极值点的时候,我们不仅要看这个点周围的点,就是同层里的其他点,我们还要看这个点对应上下层位置上的点的大小,才判断它是否是极值点的。就是既要考虑同层还要考虑上下相邻层,才能判断一个点是否是极值点。

(4)每一组Octave里面的图片的数量怎么确定?
用公式:S=n+3,其中,n表示在每组中你想在多少张图片中提取特征,假如我想每组提取2张图片的特征,那么根据公式2+3=5,我们就需要先生成5张图片。也就是步骤2中我们要高斯模糊5次,得到5张模糊程度不同的照片。 为什么是一个加3的关系呢?因为只有生成5张图片,也就是高斯金字塔的每个Octave有5层,我们从高斯金字塔生成高斯差分金字塔后,每个Octave就变成4层了。而我们在找极值点的时候不仅要在同一张图片中找,还需要在垂直方向上对比是否是极值点。由于每组中的第一层图片和最后一层图片在垂直方面没有图片,所以就无法求导,就无法在垂直方向找极值,所以找极值点只能在除了第一层和最后一层外的中间层找。所以就只有两张图片可用于提取特征了。所以是一个加3的关系。

(5)每次高斯模糊的时候,sigma怎么定?
看下图:

图中公式中的n,和前面的n是一个n,表示在每组中你想在多少张图片中提取特征。通过n可以计算出k,通过k就知道每张图片的sigma。
假设第一组第一层用sigma0,第一组第二层就用k*sigma0,第一组第三层就用k^2*sigma0,,,一直这样计算下去,就是第一组图片的高斯核。
也就是我们在原图的基础(可以放大一倍也可以不用放大),将原图分别用尺寸为sigma0、k*sigma0、k^2*sigma0...等一系列高斯核进行模糊,模糊后的图片就是第一组中各层图片。

第二组的第一层是把第一组倒数第三层图片取出来,然后删除偶数行偶数列,也就是隔点取点(也就是降采样),缩小一半后,假设是图像B,对图像B再用2sigma0的高斯核进行卷积模糊。
第二组第二层就是k*2sigma0的高斯核对图像B进行模糊,第三层就是k^2*2sigma0,...依次类推,生成和第一组同样多个层。

第三组的各层同理,是把第二组倒数第三层图片取出来,然后删除偶数行偶数列,也就是隔点取点,缩小一半后,假设是图像C,对图像C再用4sigma0的高斯核进行卷积模糊。为啥是4sigma0?k=2^1/n, k^n+2δ不就是4δ吗!同理,三组各层的sigma就是:4sigma0、k*4sigma0、k^2*4sigma0...

为啥要用前面组的倒数第3层作为下一组的起始模糊图像?
假如一组有n层,倒数第三层的sigma就是k^n*sigma0, k=2^(1/n), 这样第二组第一层的sigma就是2sigma0,实际上就是为了凑这个2sigma0的。依次类推都是为了凑一个连续的数,这样做出了的尺度空间才是一个连续的、尽量包含了各个尺度的一个图像尺寸空间。才能为我们后面找稳定的、有效的特征点打下基础。

那么sigma0怎么确定?
至于sigma0,lewo通过多次实验后给出了一个1.6的经验值,但是我们的相机拍摄完照片后是自动有一个高斯模糊矫正,我们假设相机的高斯核是0.5的sigma。
由于高斯核方差有一个符合勾股定理形式的性质,就是一张图像如果先用方差为0.5的高斯核模糊后,再用方差为1.52的高斯核第二次模糊,那两次模糊的结果就相当于用1.6的高斯核进行模糊的效果。就是上图中的δ0公式,根据勾股定理的性质,我们用1.52的sigma0即可。

(6)高斯金字塔每组里面的图片是怎样生成的?(这里主要是想单独再强调一下降采样)
第一组是在原图或者原图放大一倍的基础上,用5个不同的高斯核(具体是哪5个核也在前面将明白了)模糊,生成5张图像,就是第一组的5个层。
第二组的5层图像是:先将第一组的倒数第三层图像取出来(为什么取这张前面刚刚讲过了),然后进行降采样,这里的降采样和构建普通金字塔采用的降采样是一样的,前面也提到了,就是隔点取点,或者说就是删除偶数行偶数列,将图片尺寸缩小到原来的一半。然后我们再在降采样后的图片上,用5个不同的高斯核(具体是哪5个核也在前面具体说了)模糊,生成5层图像,就是第二组的5个层。
依次类推,生成所有组的所有层,高斯金字塔构建完毕。

小结:
(1)高斯金字塔的组数和层数都是可以自己调的,但是我们一般都采取Lowe给出的经验值。
(2)在同一组内,不同层图像的尺寸是一样的,后一层图像的高斯平滑因子sigma是前一层图像平滑因子的k倍;在不同组内,后一组第一个图像是前一组倒数第三个图像的二分之一采样,图像大小是前一组的一半;
(3)不同的sigma决定了图像的模糊程度,在大尺度下(δ值大),图像模糊,表现的就是图像的概貌信息,在小尺度下(δ值小),图像清晰,表现的就是图像的细节信息。
(4)SIFT之所以具有放缩不变性,就是因为它构建的金字塔的方式很精巧。一方面,我们自下而上看金字塔,这个金字塔模拟的就是人眼看物体的效果,就是近大远小,就是一个物体近看大,远看小,金字塔底层的物体大,越往上,同一物体就变得很小了。另一方面,我们看金字塔的组,组里的图片是用不同方差的高斯核卷积得来的,它模拟的实际上就是近看清晰远看模糊的效果。所以高斯金字塔模拟了人眼的远近观察和局部与整体的观察,两个层面的模拟,所以高斯金字塔包含了一张图像的远(小)、近(大)、细节(局部)、轮廓(整体)的全部特点,所以它叫图片的尺度空间。这是SIFT具有各种不变性的重要原因之一。

说明:
(1)在计算高斯函数的离散近似时,在大概3δ距离之外的像素都可以看作不起作用,这些像素的计算也就可以忽略。所以在实际应用中只计算(6δ+1)*(6δ+1)的高斯卷积核就可以保证相关像素影响。
(2)在数学上有证明,论文《Scale-space theory: A basic tool for analysing structures at different scales》证明了高斯核是实现尺度空间变换的唯一变换核,并且是唯一的线性核,并且是唯一一个可以模拟近处清晰远处模糊的线性核。就是即使其他核也具有模糊效果,但也不能用,只能用高斯核。

  • 再看尺度空间
    图像的尺度空间解决的问题是如何对图像在所有尺度下描述的问题。 在高斯金字塔中一共生成O组L层不同尺度的图像,这两个量合起来(O,L)就构成了高斯金字塔的尺度空间,也就是说以高斯金字塔的组O作为二维坐标系的一个坐标,不同层L作为另一个坐标,则给定的一组坐标(O,L)就可以唯一确定高斯金字塔中的一幅图像。 尺度空间的形象表述:

2、构建高斯差分金字塔

高斯差分金字塔,DOG(Difference of Gaussian),是在高斯金字塔的基础上构建起来的,其实生成高斯金字塔的目的就是为了构建DOG金字塔。我们提取极值点就是在DOG中提取的。

高斯差分金字塔的第1组第1层是由高斯金字塔的第1组第2层减第1组第1层得到的。以此类推,逐组逐层生成每一个差分图像,所有差分图像构成差分金字塔。概括为DOG金字塔的第O组第i层图像是由高斯金字塔的第O组第i+1层减第O组第i层得到的。

这个图在上面已经展示过了。左边是高斯金字塔,右边是高斯差分金字塔,中间的带圈减号就是单纯的减号。高斯差分金字塔比高斯金字塔每组少一层。

说明:此后我们要找的特征点就是在这个金字塔中找的。但是这里我省去了很大一部分数学证明,证明为啥这个空间可以检测到稳定的关键点。Tony Lindeberg指出尺度规范化的LoG(Laplacion of Gaussian)算子具有真正的尺度不变性(前面章节有讲过这个算子),Lowe是使用高斯差分金字塔近似LoG算子。Mikolajczyk又在2002年证明了为什么能用高斯差分金字塔来近似LoG算子,这里面的数学推导我就不写了,想看详细解释的同学参考尺度不变特征转换-SIFT - 知乎 ,这篇文章里面写得非常清晰。

  • 看一下论文中的差分金字塔可视化后的效果:

    Note that the representation above has been normalized - see the gray chart at its bottom. This will be especially noticeable for low-contrast images.(An input with full contrast will have black at 0.00 and white at 1.00.)
    上图是Lowe将DOG图像归一化后的可视化效果(如果不归一化,可视化后都是一片黑,我们人眼啥也看不到),可见,DOG图像还是包含很多特征的,这些特征在不同模糊程度、不同尺度下都依旧存在,就是这些点不管怎么变换都稳定不变,就是包含有效信息的点,这些点也正是Sift要提取的“稳定”的特征点。
  • 3、阈值化(去除噪声极值点)
    从高斯差分图像上看,我们要提取的特征就是一些,要么是很亮的点要么是很暗的点,这些是我们要提取的稳定的特征,而这些点正是极值位置上的点,就是一些极大值点和极小值点。但是我们不是一上来就去找极值点,而是先用阈值去除一部分点:

Lowe给出的公式是: abs(val)>0.5*T/n,其中,T=0.04是Lowe给出的一个经验值,n依然是上面的n,就是你想在差分金字塔每组中几张图片里提取特征。

这里我们还以n=2为例,那么abs(val)需要大于0.01我们才保留。意思就是如果一个点的绝对值小于0.01这个阈值,那这个点我们排除掉,就是这个点就不可能是我们要找的点,它很可能就是一个噪声点(很多参考资料都说是噪声点,我个人认为是一些特征不明显的点,或者说是一些较为平坦区域的点,说噪声点的理由是什么呢?)。
还有如果这里有的人问为啥取绝对值,那就是你没好好看前面的内容了,这步是在差分金字塔中,差分是做减法,自然有正有负,而且这里的数也已经不是整数了,都是浮点数了。

  • 4、初步检测极值点(在高斯差分金字塔中寻找极致点)
    这里说的初步,就是说下面还需要进一步处理,进一步处理的步骤放在了第二大步。(就是这么不合理,可原论文就是这样写的)
    阈值处理完后,我们开始在高斯差分金字塔中真正筛选极值点了,筛选方法是:

    Maxima and miniam of the difference-of-Gaussian images are detected by comparing a pixel(marked with X)to its 26 neighbors in 3x3 regions at the current and adjacent scales(marked with circles).
    In order to detect the local maxima and minima of D(x,y,sigma),each sample point is compared to its eight neighbors in the current image and nine neighbors in the scale above and below. It is selected only if it is larger than all of these neighbors or smaller than all of them.
    比如我们要看中间层画X的那个像素点是否是极值点,我们就和它同层的8个像素点,以及上下层各9个像素点,共9+8+9=26个像素点进行比较。这步非常简单,就是比较即可,如果它比它周围26个点都大或者都小,那它就是极值点。就是一个局部极值,就可能是一个关键点。

第一大步终于结束,下面开始第二大步!

二、关键点定位

本步包括:
1、调整极值点位置,精确定位极值点
2、舍去低对比度的点
3、去除边缘效应

1、调整极值点位置,精确定位极值点
前面我们已经找到了极值点,但是这些极值点不是真正的极值点,为什么?
一是,即使是同层的图片,图片的像素点值也是不连续的,都是离散值;二是,不同层的图片,像素点的值更是离散的,上层的高斯模糊核是下层的k倍,依次类推,所以更离散。总之就是像素点是离散的,不同尺度空间更是离散的。如下面图所示:

所以我们初步找到的极值点就不是真正的极值点,而很可能是真正极值点附近的点,后文我都称这些点是伪极值点。所以我们需要调整伪极值点位置,找到真正的极值点。

  • 那么如何利用已知的初步找到的伪极值点,找到真正的极值点?
    用泰勒展开公式呀,泰勒展开式的本质就是用一个函数在某点的信息,描述其附近取值的公式。或者说就是用一个多项式函数去逼近一个函数,逼近的时候是从这个函数上的某个点展开的。
    所以我们可以在初步检测到的伪极值点X(x0,y0,sigma0).T处做三元二阶泰勒展开,这里的操作思路可以借鉴角点检测的思路,角点检测也是做的泰勒展开,只不过角点检测中不存在sigma这个参数,所以是二元二阶泰勒展开,这里是三元二阶泰勒展开。我们可以把泰勒展开看作是原函数f(x,y,sigma)在点X(x0,y0,sigma0).T处的近似:

这个展开的矢量形式就是:

也就是说,我们就找到了伪极值点X(x0,y0,sigma0).T的原函数了,有了原函数,我们要想求原函数的极值点,自然就是对原函数求导,再令导数等于0,就求到真正极值点的x,y,sigma了,然后把x,y,sigma再带回原函数,就求出真正极值点的值了。数学推导如下图:
对f(x)求导:

令导数等于零:,这个值也就相当于一个位移量。

代入f(x)求得极值点的值:

  • 说明1:这一步算法实现的过程不是直接算,而是一个迭代的过程,所以是有迭代次数限制的,原论文是说:
    情况1:当求得的x,y,sigma三个位移量都小于0.5时,就说明位移量已经很小了,就是收敛了,就是很接近极值位置了,就停止迭代,此时找到的点就是精确的极值点。
    情况2:如果超过了迭代次数,x,y,sigma还是没有全部小于0.5,我们就认为可能是不能收敛了,我们也停止迭代,并且把这个点抛弃,就是不要这个极值点了。
    情况3:即使收敛了,但x,y,sigma带回原函数后的值超出一定范围,我们就也抛弃这个点,因为我们的泰勒展开是在这个点的附近模拟原函数,所以这个解不能和这个点偏离太大,如果偏离太大,说明我们的泰勒展开没有很好的拟合原函数,那后面求导回代原函数都是无意义的,所以要舍去。
  • 说明2:泰勒展开那块有求导数的,对图像来说,求导数就是求差分,如下图所示:
     
    其中f所对应的下标就是图中的数字。 上面是利用差分求导的全部过程,读者可以对应相关图片进行代码编写。
    至此我们就完成了真正极值点的定位。这步不要被数学推导吓怕了,如果你比较深入的理解了泰勒展开,就对这步的原理和求解觉得再正常不过了。
  • 2、舍去低对比度的点

用泰勒展开求出真正极值点后,我们还要舍去低对比度的点。Lewo给出的公式是:
若|f(X)|<T/n,则舍去点X,其中,T还是给出的经验值0.04,n还是上面提到过几次的n。
意思就是,通过上面的泰勒展开求导解出的极值,如果这个极值小于T/n,n还是假设是2,就是小于0.02,我们就认为这个极值点的对比度太低了,也舍去。

  • 3、去除边缘效应(Eliminating Edge Responses)

由于高斯差分函数沿着边缘会有很强的反应(这点可以参考第九章图像梯度里面的一幅图来理解,那幅图非常直观),就是函数对边界非常敏感,会产生较强的边缘响应。就是说经过上面层层筛选出来的点,有些点是边缘点,而边缘点可不是我们想要的特征点,但如果是角点我们就要。Harris角点检测里面说过角点是一类性质非常好的点。所以这里我们还要去除一些边缘点。那如何去? 其实我们在角点检测中已经详细说过,角点检测不仅可以区分出哪些是角点,还能区分出哪些是边缘点哪些是平坦区域的点。而区分的关键就是那个M矩阵,通过M矩阵的特征值相对大小关系来区分哪些是角点哪些是边缘点哪些是平坦区域点。所以这里我们也是这个原理和思路,也是利用M矩阵去除边缘点。
很多地方都是直接上来就讲海森矩阵,其实这个海森矩阵就是M矩阵,这里不明白的同学建议仔细看看角点检测。所以这里我也称M矩阵为海森矩阵,其实它本来就是一个海森矩阵,也是一个实对称矩阵。

角点检测里面是找矩阵的两个特征值都比较大的点,而这里是要消除边缘效应,就是去掉边缘点的,所以这里我们是去掉两个特征值相差很大的点。就是保留两个特征值都大的点(角点)和两个特征值都小的点(平坦区的点),去掉两个特征值相差较大的点(边缘点)。其实harris算法对这三类点的区分度还是非常好的,我就曾经见过一个人用harris做边缘检测,检测的边缘效果还是非常好的。这一步就类似于建立一个Harris角点检测器。

所以这里就有2种情况:
情况1:如果海森矩阵的行列式<0,就直接舍去。因为行列式小于0,就说明矩阵的两个特征值是异号,都异号了差异就很大了,就是边缘点了,所以直接舍去。
情况2:如果两个特征值同号,但是之间差别也过大,大于某个经验阈值就舍去。这步Lowe给出的公式是:

其中,H就是海森矩阵,α和β是H的两个特征值,Tr(H)就是矩阵的迹,Det(H)就是矩阵的行列式。至于为什么这样建议想看Harris角点检测章节,矩阵的特征值不好求,而行列式和迹好求呀。
设大的特征值为α,小的特征值为β,Γ = α/β
我们希望 “α和β相差不大”,就是希望 "Γ尽量接近于1”(这里Lowe建议取Γ=10)
因为α>β,所以Γ>1,而上式在[1, +∞]上单调递增,所以"Γ尽量接近于1” 就转化为为下面的条件:

至此,SIFT算法第二大步完成!

三、关键点方向确定

通过第二大步求出极值点后,首先要把这个极值点映射回到高斯金字塔对应的位置上,再进行方向赋值。 过程如下图所示:

  • 1、如何映射?
    首先我们要明白,第二大步筛出来的极值点都是有x,y,sigma三个参数的,而且x,y,sigma都可以不是整数的,sigma也可以不落在任何一个层上,因为上面做了一次泰勒展开求导得出来的x,y,sigma的嘛。所以我们要这样找:比如极值点X(x,y,sigma),我们先找高斯金字塔中哪层的sigma和X的sigma最接近,如果最接近,我们就认为X点对应在高斯金字塔的那个层里。而x,y就是极值点X在那个层中的坐标位置。这样就把极值点映射到高斯金字塔中的特征点。
    这一小步没什么难的,就是按照极值点的sigma,将极值点对应到高斯金字塔中。这样高斯差分金字塔中的极值点就统统映射到高斯金字塔中了,我们将这些点就叫特征点。
  • 2、求特征点所在的局部区域的所有像素点的梯度幅值和梯度方向
    以特征点为圆心,以该特征点所在的高斯图像的尺度的1.5倍为半径的圆内,统计圆内所有像素点的梯度幅值以及梯度方向。
    梯度幅值以及梯度方向用下面的公式:

    梯度幅值和梯度方向其实我们在第十章canny边缘检测里面有详细讲过。像素点的梯度是有2个方向的,一个是水平方向的差值,一个是垂直方向的差值,最后用两个方向的差值的平方和再开方,作为该像素点的梯度值。
    而梯度方向则是arctan(垂直梯度/水平梯度),求角度嘛,反正切函数。
  • 3、确定特征点的梯度方向和梯度幅值
    上面所有圈住的像素点的梯度方向是从(-π,π),我们把-π到π平均分成8个区间(论文里是分成36个区间,10度一个区间),所有像素点的梯度方向落在哪个区间哪个区间就加一个数,这个数是这么算的:用1.5倍sigma的高斯滤波给被套住的像素点的梯度幅值一个权值作为这个像素点的幅值累加到区间柱里面。就是一个加权投票的过程,权值是1.5倍sigma。这样我们就得到上图中的右图。
    然后我们将右图中最高的那个方向当作特征点的主方向,那个柱子的幅值就当作特征点的幅值。

说明:一个特征点有时会有两个方向,一个是主方向还有一个是辅方向。辅方向是因为另外一个柱子的大小是主方向柱子大小的80%以上,另外一个柱子就是这个特征点的辅方向。对于这样的特征点,我们就认为这是两个特征点,只不过是位于同一位置、同一尺度、不同方向的两个特征点。

至此我们就确定了高斯金字塔上所有图片的特征点(x,y坐标),而且我们还确定了这些点的梯度幅值和梯度方向。
如果我们想把这些特征点可视化,我们还得通过x,y坐标找到原图中对应的坐标。假如这个特征点是在高斯金字塔的第二组第二层,那这个特征点就要先映射到第一组的尺度上,再映射到原图中。

四、关键点描述

本部分讲的就是如何生成描述子。
如果我们要匹配两张图片,那我们是分别找到这两张图片的特征点,然后把相同的特征点匹配起来。虽然我们已经有了特征点的xy坐标、像素值、梯度幅值和梯度方向4个维度的数据,但是由于两张图片大小、形变、光照、噪声等都不一样,所以我们是无法知道哪两个特征点是想匹配的,此时就要用到描述子。描述子是专门用来匹配,描述子是一个128维的向量。如果两个特征点的描述子非常接近,就是可以匹配,那就说明这两个特征点是匹配的,用直线将这两个特征点连接起来即可。也就是本文一开始的那个匹配图像。
描述子生成的步骤:找区域--旋转区域--找梯度方向--生成128维向量,如下图所示:

  • 找区域
    参照上图图1,图1是高斯金字塔中图,因为我们找的特征点就是高斯金字塔中,也就是在图像的不同尺度空间中。
    以特征点为圆心,画圆,圈住一些像素点。
    论文里画圆的半径是:mδ(d+1)*√2/2,其中,m论文中是取3,mδ指的是一个小区域的边长,d表示要取多少个小区域,就是图3中的公式。
  • 旋转区域
    参照上图图2,由于我们是希望描述子具有旋转不变性的,就是不管相机是从哪个角度拍摄到这个特征点,这个特征点的描述子都是不变的,这样才能匹配嘛,所以我们需要将圈住的像素点进行旋转,就是做一个仿射变换,就是旋转到这个像素点的主方向上,就是图2。
    而旋转过程中肯定要差值,论文用的三线性插值,但是这里你可以用其他插值方法也可以,影响不大。
  • 找梯度方向,生成128维向量,就是描述子 、 图4,也就是第二行第一个图,这个图就是旋转插值完后我们圈住的区域,我们可以看到,小区域有16个,每个小区域是一个8维的向量,就是每个小区域都有8个方向的梯度幅值,我们把16个小区域的向量按顺序写出来就是一个128维的向量。这个128维向量就是我们要的描述子。
    每个小区域的8维向量怎么来的?每个小区域里面都有16个像素点(有时不是16,是按第一步的公式来计算,计算出来是多少个就是多少个),我们计算每个像素点的梯度方向和梯度幅值,将这些梯度方向放到8个方向区间,就形成一个8维的向量。16个小区域同理。

至此,SIFT实现步骤、以及其中的原理就介绍完毕!累死,发誓以后再也步写原理。

五、手动代码讲解

1、倒库:

import numpy as np
import matplotlib.pyplot as plt
from PIL import Image

2、构建高斯核

# 构建高斯核
def GuassianKernel(sigma,ksize):  #生成高斯核的函数,第一个参数是δ,第二个参数是核的尺寸,就是你想生成3x3还是5x5的高斯核  
    temp = [t - (ksize // 2) for t in range(ksize)]  #生成一个列表,假如ksize=5,temp=[-2, -1, 0, 1, 2]
    kernel_x = []
    for i in range(ksize):          #用循环生成一个方形的核
        kernel_x.append(temp)  
    kernel_x = np.array(kernel_x)  #转array类型  
    gaussian = (1.0 / (2*sigma*sigma*np.pi)) * np.exp(-(kernel_x**2 + (kernel_x.T)**2)/(2*sigma*sigma))  #二元高斯函数计算公式
    result = gaussian/gaussian.sum()   #归一化处理,这步一定要有,gaussian.sum()返回0.4787148085297054,所以一定要归一化  
    return result
#测试上面的函数,我们生成一个δ=1.5,尺寸为3的高斯核
GuassianKernel(1.5,3) #生成一个σ=1.5、尺寸是3x3的高斯核
array([[0.09474166, 0.11831801, 0.09474166],
       [0.11831801, 0.14776132, 0.11831801],
       [0.09474166, 0.11831801, 0.09474166]])

上面代码实现的效果如下:

有的地方提供的代码没有归一化,我个人觉得不对。
说明:GuassianKernel(10,3)返回的高斯核中的数值都差不多是0.11左右。所以方差越大,高斯核可视化后越扁平,方差越小,高斯核越尖高。因为高斯函数是概率函数,方差越小说明数据都集中在均值附近 所以均值点处的概率越大,所以高斯核越尖高。越扁平就说明数据方差越大,数据越分散,离均值很大,所以均值附近的数的概率不大,所以扁平。

  • 下面直观的看一下sigma:
    假设这里我们n取2,那么高斯金字塔的层数就是5;假设我们的原始图片是(600, 868, 3),那么金字塔的组数就是6;k就是根号2;假设sigma0是lowe建议的1.52,
    那么这个高斯金字塔所有组所有层的sigma结果就如下代码运行的结果:
import cv2
img = cv2.imread(r'C:\Users\25584\Desktop\building.jpg')  #img.shape返回(600, 868, 3)
n = 2   #想在2个层里面提取特征
S= n+3  #5,高斯金字塔每组的层数
O = int(np.log2(min(img.shape[0],img.shape[1])))-3   #计算结果O=6,所以金字塔有6组,所以高斯金字塔有30张图片,应该有30个sigma
k = 2**(1.0/n)   #k=根号2
sigma0=1.52
sigma = [[(k**s)*sigma0*(1<<o) for s in range(S)] for o in range(O)]
sigma
[[1.52,
  2.149604614807105,
  3.040000000000001,
  4.29920922961421,
  6.080000000000002],
 [3.04,
  4.29920922961421,
  6.080000000000002,
  8.59841845922842,
  12.160000000000004],
 [6.08,
  8.59841845922842,
  12.160000000000004,
  17.19683691845684,
  24.320000000000007],
 [12.16,
  17.19683691845684,
  24.320000000000007,
  34.39367383691368,
  48.640000000000015],
 [24.32,
  34.39367383691368,
  48.640000000000015,
  68.78734767382736,
  97.28000000000003],
 [48.64,
  68.78734767382736,
  97.28000000000003,
  137.5746953476547,
  194.56000000000006]]

从结果我们可以看到: 高斯金字塔第一组第一层的sigma是1.52,第二层是√2*1.52,第三层是√2*√2*1.52,第四层是√2*√2*√2*1.52,第五层是√2*√2*√2*√2*1.52
高斯金字塔第二组第一层的sigma是(√2*√2*1.52),第二层是√2*(√2*√2*1.52),第三层是√2*√2*(√2*√2*1.52),第四层是√2*√2*√2*(√2*√2*1.52),第五层是√2*√2*√2*√2*(√2*√2*1.52)
高斯金字塔第三组第一层的sigma是(√2*√2*√2*√2*1.52),第二层是√2*(4*1.52),第三层是√2*√2*(4*1.52),第四层是√2*√2*√2*(4*1.52),第五层是√2*√2*√2*√2*(4*1.52)
高斯金字塔第四组第一层的sigma是(8*1.52),第二层是√2*(8*1.52),第三层是√2*√2*(8*1.52),第四层是√2*√2*√2*(8*1.52),第五层是√2*√2*√2*√2*(8*1.52)
高斯金字塔第五组第一层的sigma是(16*1.52),第二层是√2*(16*1.52),第三层是√2*√2*(16*1.52),第四层是√2*√2*√2*(16*1.52),第五层是√2*√2*√2*√2*(16*1.52)
高斯金字塔第六组第一层的sigma是(32*1.52),第二层是√2*(32*1.52),第三层是√2*√2*(32*1.52),第四层是√2*√2*√2*(32*1.52),第五层是√2*√2*√2*√2*(32*1.52)

3、定义卷积操作

#定义卷积操作
def convolve(guassionKernel, img, padding, strides):  #高斯核;要卷积的图片;四周用0填充,是一个四个向量的数组;卷积操作的步长,是一个两个向量的数组  
    img_size = img.shape
    kernel_size = guassionKernel.shape
    if len(img_size)==3:
        channel=[]
        for i in range(img_size[-1]):
            pad_img = np.pad(img[:,:,i], ((padding[0],padding[1]), (padding[2], padding[3])), 'constant')  #对每个通道都填充 A
            temp = []
            for j in range(0, img_size[0], strides[1]):
                temp.append([])
                for k in range(0, img_size[1], strides[0]):
                    val = (guassionKernel*pad_img[j*strides[1]:j*strides[1]+kernel_size[0], k*strides[0]:k*strides[0]+kernel_size[1]]).sum()
                    temp[-1].append(val)
            channel.append(np.array(temp))
        channel=tuple(channel)
        result = np.dstack(channel)
    elif len(img_size)==2:
        channel=[]
        pad_img = np.pad(img, ((padding[0], padding[1]),(padding[2], padding[3])), 'constant')
        for j in range(0, img_size[0], strides[1]):
            channel.append([])
            for k in range(0, img_size[1], strides[0]):
                val = (guassionKernel*pad_img[j*strides[1]:j*strides[1]+kernel_size[0],k*strides[0]:k*strides[0]+kernel_size[1]]).sum()
                channel[-1].append(val)
        result = np.array(channel)
    return result

说明: A:np.pad()第一个参数是要填充的图像,我们要分别把三个通道单独拿出来都填充。第二个参数是:图像上边填充padding[0]行,下边填充padding[1]行,左边填充padding[2]列,右边填充padding[3]列。第三个参数表示用0填充。

4、定义下采样操作,就是删除偶数行偶数列,隔点取点

#定义下采样操作
def undersampling(img, step=2):
    return img[::step, ::step]

5、生成高斯金字塔和高斯差分金字塔

#构建高斯金字塔和高斯差分金字塔  
def getDoG(img,n,sigma0,S= None,O=None):   #img:原图;n:想要在多少层中提取特征;sigma0:高斯核;S:高斯金字塔每组有多少层图像;O: 金字塔有几组;
    if S == None:
        S = n + 3 # 高斯金字塔的层数,n最小取1,就有4层
    if O == None:
        O = int(np.log2(min(img.shape[0], img.shape[1]))) - 3  # 高斯金字塔的组数,lewo给出的公式: O=log2(min(img长,img宽))-3
    k = 2 ** (1.0/n)
    sigma = [[(k ** s) * sigma0 * (1 << o) for s in range(S)] for o in range(O)]  # 参照上面关于sigma的讲解
    sample = [undersampling(img, 1 << o) for o in range(O)]  # 降采样取图片作为该层的输入
    Guass_Pyramid = []
    for i in range(O):
        Guass_Pyramid.append([]) #声明二维空数组
        for j in range(S):
            ksize = int (6*sigma[i][j]+1) # 通常,图像处理只需要计算(6*sigma+1)*(6*sigma+1)的矩阵就可以保证相关像素影响,前面也说过这个点
            #ksize = int(9)  #留一个对比的代码
            if ksize % 2 == 0: #防止高斯核不是奇数
                ksize += 1
            Guass_Pyramid[-1].append(convolve(GuassianKernel(sigma[i][j], ksize), sample[i],[ksize//2, ksize//2, ksize//2, ksize//2],[1, 1]))  
    DoG_Pyramid = [[Guass_Pyramid[o][s + 1] - Guass_Pyramid[o][s] for s in range(S - 1)] for o in range(O)]  #每一层中 上一张减去下一张得到高斯差分金字塔
    return Guass_Pyramid, DoG_Pyramid, O  #返回高斯金字塔和高斯差分金字塔
  • 测试一下上面的代码,看高斯金字塔和高斯差分金字塔:
  • import cv2
    img = cv2.imread(r'C:\Users\25584\Desktop\building.jpg')  #img.shape返回(600, 868, 3)
    Guass_Pyramid,DoG_Pyramid,O = getDoG(img,2,1.52,S= None,O=None)   #假设n=2,sigma0=1.52

    运行结果:
    len(Guass_Pyramid) #返回6 ,就是有6组
    len(Guass_Pyramid[0])、len(Guass_Pyramid[1])、len(Guass_Pyramid[2])、len(Guass_Pyramid[3])、len(Guass_Pyramid[4]) 、len(Guass_Pyramid[5]) #都返回5,就是每组都有5层图片
    Guass_Pyramid[0][0].shape、Guass_Pyramid[0][1].shape、Guass_Pyramid[0][2].shape、Guass_Pyramid[0][3].shape、Guass_Pyramid[0][4].shape,都返回(600, 868, 3),每层图片尺寸一样
    Guass_Pyramid[1][0].shape。。。Guass_Pyramid[1][4].shape,也都返回(300, 434, 3)
    Guass_Pyramid[2][0].shape返回:(150, 217, 3)
    Guass_Pyramid[3][0].shape返回:(75, 109, 3)
    Guass_Pyramid[4][0].shape返回:(38, 55, 3)
    Guass_Pyramid[5][0].shape返回:(19, 28, 3)

    len(DoG_Pyramid) #返回6
    len(DoG_Pyramid[0])\len(DoG_Pyramid[1])....都返回4,就是每组有4层图片
    DoG_Pyramid[0][0].shape,返回:(600, 868, 3)
    其他都对就不罗列了。

    6、关键点定位之:设定阈值去除噪声点、初步定位关键点

# 函数2.1.1 adjustLocalExtrema
# 功能:通过泰勒展开精调位置精调位置
def adjustLocalExtrema(DoG, o, s, x, y, contrastThreshold, edgeThreshold, sigma, n, SIFT_FIXPT_SCALE):
    # 在检测到的极值点(x,y,sigma)做三元二阶泰勒展开
    SIFT_MAX_INTERP_STEPS = 5
    SIFT_IMG_BORDER = 5

    point = []

    img_scale = 1.0 / (255 * SIFT_FIXPT_SCALE)
    deriv_scale = img_scale * 0.5
    second_deriv_scale = img_scale
    cross_deriv_scale = img_scale * 0.25

    img = DoG[o][s]
    i = 0
    while i < SIFT_MAX_INTERP_STEPS:
        if s < 1 or s > n or y < SIFT_IMG_BORDER or y >= img.shape[1] - SIFT_IMG_BORDER or x < SIFT_IMG_BORDER or x >= \
                img.shape[0] - SIFT_IMG_BORDER:
            return None, None, None, None

        img = DoG[o][s]
        prev = DoG[o][s - 1]
        next = DoG[o][s + 1]

        dD = [(img[x, y + 1] - img[x, y - 1]) * deriv_scale,
              (img[x + 1, y] - img[x - 1, y]) * deriv_scale,
              (next[x, y] - prev[x, y]) * deriv_scale]

        v2 = img[x, y] * 2
        dxx = (img[x, y + 1] + img[x, y - 1] - v2) * second_deriv_scale
        dyy = (img[x + 1, y] + img[x - 1, y] - v2) * second_deriv_scale
        dss = (next[x, y] + prev[x, y] - v2) * second_deriv_scale
        dxy = (img[x + 1, y + 1] - img[x + 1, y - 1] - img[x - 1, y + 1] + img[x - 1, y - 1]) * cross_deriv_scale
        dxs = (next[x, y + 1] - next[x, y - 1] - prev[x, y + 1] + prev[x, y - 1]) * cross_deriv_scale
        dys = (next[x + 1, y] - next[x - 1, y] - prev[x + 1, y] + prev[x - 1, y]) * cross_deriv_scale

        H = [[dxx, dxy, dxs],
             [dxy, dyy, dys],
             [dxs, dys, dss]]

        X = np.matmul(np.linalg.pinv(np.array(H)), np.array(dD))

        xi = -X[2]
        xr = -X[1]
        xc = -X[0]

        if np.abs(xi) < 0.5 and np.abs(xr) < 0.5 and np.abs(xc) < 0.5:
            break

        y += int(np.round(xc))
        x += int(np.round(xr))
        s += int(np.round(xi))

        i += 1

    if i >= SIFT_MAX_INTERP_STEPS:
        return None, x, y, s
    if s < 1 or s > n or y < SIFT_IMG_BORDER or y >= img.shape[1] - SIFT_IMG_BORDER or x < SIFT_IMG_BORDER or x >= \
            img.shape[0] - SIFT_IMG_BORDER:
        return None, None, None, None

    t = (np.array(dD)).dot(np.array([xc, xr, xi]))

    contr = img[x, y] * img_scale + t * 0.5
    # 确定极值点位置第四步:舍去低对比度的点 :|fx| < T/n
    if np.abs(contr) * n < contrastThreshold:
        return None, x, y, s

    # 确定极值点位置第五步:边缘效应的去除。 利用Hessian矩阵的迹和行列式计算主曲率的比值
    # H(x,y)=[Dxx Dxy
    #         Dxy Dyy]
    tr = dxx + dyy
    det = dxx * dyy - dxy * dxy
    if det <= 0 or tr * tr * edgeThreshold >= (edgeThreshold + 1) * (edgeThreshold + 1) * det:
        return None, x, y, s

    point.append((x + xr) * (1 << o))
    point.append((y + xc) * (1 << o))
    point.append(o + (s << 8) + (int(np.round((xi + 0.5)) * 255) << 16))
    point.append(sigma * np.power(2.0, (s + xi) / n) * (1 << o) * 2)

    return point, x, y, s
def GetMainDirection(img, r, c, radius, sigma, BinNum):
    expf_scale = -1.0 / (2.0 * sigma * sigma)

    X = []
    Y = []
    W = []
    temphist = []

    for i in range(BinNum):
        temphist.append(0.0)

    # 图像梯度直方图统计的像素范围
    k = 0
    for i in range(-radius, radius + 1):
        y = r + i
        if y <= 0 or y >= img.shape[0] - 1:
            continue
        for j in range(-radius, radius + 1):
            x = c + j
            if x <= 0 or x >= img.shape[1] - 1:
                continue

            dx = (img[y, x + 1] - img[y, x - 1])
            dy = (img[y - 1, x] - img[y + 1, x])

            X.append(dx)
            Y.append(dy)
            W.append((i * i + j * j) * expf_scale)
            k += 1

    length = k

    W = np.exp(np.array(W))
    Y = np.array(Y)
    X = np.array(X)
    Ori = np.arctan2(Y, X) * 180 / np.pi
    Mag = (X ** 2 + Y ** 2) ** 0.5

    # 计算直方图的每个bin
    for k in range(length):
        bin = int(np.round((BinNum / 360.0) * Ori[k]))
        if bin >= BinNum:
            bin -= BinNum
        if bin < 0:
            bin += BinNum
        temphist[bin] += W[k] * Mag[k]

    # smooth the histogram
    # 高斯平滑
    temp = [temphist[BinNum - 1], temphist[BinNum - 2], temphist[0], temphist[1]]
    temphist.insert(0, temp[0])
    temphist.insert(0, temp[1])
    temphist.insert(len(temphist), temp[2])
    temphist.insert(len(temphist), temp[3])  # padding

    hist = []
    for i in range(BinNum):
        hist.append(
            (temphist[i] + temphist[i + 4]) * (1.0 / 16.0) + (temphist[i + 1] + temphist[i + 3]) * (4.0 / 16.0) +
            temphist[i + 2] * (6.0 / 16.0))

    # 得到主方向
    maxval = max(hist)

    return maxval, hist
# 函数2.1 LocateKeyPoint
# 功能:关键点定位,共分为5步
def LocateKeyPoint(DoG, sigma, GuassianPyramid, n, BinNum=36, contrastThreshold=0.04, edgeThreshold=10.0):
    SIFT_ORI_SIG_FCTR = 1.52
    SIFT_ORI_RADIUS = 3 * SIFT_ORI_SIG_FCTR
    SIFT_ORI_PEAK_RATIO = 0.8

    SIFT_INT_DESCR_FCTR = 512.0
    # SIFT_FIXPT_SCALE = 48
    SIFT_FIXPT_SCALE = 1

    KeyPoints = []
    O = len(DoG)
    S = len(DoG[0])
    for o in range(O):
        for s in range(1, S - 1):
            # 第一步:设定阈值
            threshold = 0.5 * contrastThreshold / (n * 255 * SIFT_FIXPT_SCALE)# 用于阈值化,去噪
            img_prev = DoG[o][s - 1]
            img = DoG[o][s]
            img_next = DoG[o][s + 1]
            for i in range(img.shape[0]):
                for j in range(img.shape[1]):
                    val = img[i, j]
                    eight_neiborhood_prev = img_prev[max(0, i - 1):min(i + 2, img_prev.shape[0]), max(0, j - 1):min(j + 2, img_prev.shape[1])]
                    eight_neiborhood = img[max(0, i - 1):min(i + 2, img.shape[0]), max(0, j - 1):min(j + 2, img.shape[1])]
                    eight_neiborhood_next = img_next[max(0, i - 1):min(i + 2, img_next.shape[0]), max(0, j - 1):min(j + 2, img_next.shape[1])]
                    # 第二步:阈值化,在高斯差分金字塔中找极值
                    if np.abs(val) > threshold and \
                            ((val > 0 and (val >= eight_neiborhood_prev).all() and (val >= eight_neiborhood).all() and (
                                    val >= eight_neiborhood_next).all())
                             or (val < 0 and (val <= eight_neiborhood_prev).all() and (
                                            val <= eight_neiborhood).all() and (val <= eight_neiborhood_next).all())): # 如果某点大于阈值,并且 比周围8个点、上下2*9个点共26个点都大或都小,则认为是关键点
                        # 第三步:精调位置,通过函数2.1.1 adjustLocalExtrema:实现
                        point, x, y, layer = adjustLocalExtrema(DoG, o, s, i, j, contrastThreshold, edgeThreshold,
                                                                sigma, n, SIFT_FIXPT_SCALE)
                        if point == None:
                            continue
                        scl_octv = point[-1] * 0.5 / (1 << o)
                        # GetMainDirection:(确定极值点的位置以后就)求主方向
                        omax, hist = GetMainDirection(GuassianPyramid[o][layer], x, y,
                                                      int(np.round(SIFT_ORI_RADIUS * scl_octv)),
                                                      SIFT_ORI_SIG_FCTR * scl_octv, BinNum)
                        mag_thr = omax * SIFT_ORI_PEAK_RATIO
                        for k in range(BinNum):
                            if k > 0:
                                l = k - 1
                            else:
                                l = BinNum - 1
                            if k < BinNum - 1:
                                r2 = k + 1
                            else:
                                r2 = 0
                            if hist[k] > hist[l] and hist[k] > hist[r2] and hist[k] >= mag_thr:
                                bin = k + 0.5 * (hist[l] - hist[r2]) / (hist[l] - 2 * hist[k] + hist[r2])
                                if bin < 0:
                                    bin = BinNum + bin
                                else:
                                    if bin >= BinNum:
                                        bin = bin - BinNum
                                temp = point[:]
                                temp.append((360.0 / BinNum) * bin)
                                KeyPoints.append(temp)

    return KeyPoints
# calcSIFTDescriptor:更小的计算描述符函数
def calcSIFTDescriptor(img, ptf, ori, scl, d, n, SIFT_DESCR_SCL_FCTR=3.0, SIFT_DESCR_MAG_THR=0.2,
                       SIFT_INT_DESCR_FCTR=512.0, FLT_EPSILON=1.19209290E-07):
    dst = []
    pt = [int(np.round(ptf[0])), int(np.round(ptf[1]))]  # 坐标点取整
    # 旋转到主方向
    cos_t = np.cos(ori * (np.pi / 180))  # 余弦值
    sin_t = np.sin(ori * (np.pi / 180))  # 正弦值
    bins_per_rad = n / 360.0
    exp_scale = -1.0 / (d * d * 0.5)
    hist_width = SIFT_DESCR_SCL_FCTR * scl
    # radius: 统计区域边长的一半
    radius = int(np.round(hist_width * 1.4142135623730951 * (d + 1) * 0.5))
    cos_t /= hist_width
    sin_t /= hist_width

    rows = img.shape[0]
    cols = img.shape[1]

    hist = [0.0] * ((d + 2) * (d + 2) * (n + 2))
    X = []
    Y = []
    RBin = []
    CBin = []
    W = []

    k = 0
    for i in range(-radius, radius + 1):
        for j in range(-radius, radius + 1):

            c_rot = j * cos_t - i * sin_t
            r_rot = j * sin_t + i * cos_t
            rbin = r_rot + d // 2 - 0.5
            cbin = c_rot + d // 2 - 0.5
            r = pt[1] + i
            c = pt[0] + j

            if rbin > -1 and rbin < d and cbin > -1 and cbin < d and r > 0 and r < rows - 1 and c > 0 and c < cols - 1:
                dx = (img[r, c + 1] - img[r, c - 1])
                dy = (img[r - 1, c] - img[r + 1, c])
                X.append(dx)
                Y.append(dy)
                RBin.append(rbin)
                CBin.append(cbin)
                W.append((c_rot * c_rot + r_rot * r_rot) * exp_scale)
                k += 1

    length = k
    Y = np.array(Y)
    X = np.array(X)
    Ori = np.arctan2(Y, X) * 180 / np.pi
    Mag = (X ** 2 + Y ** 2) ** 0.5
    W = np.exp(np.array(W))

    for k in range(length):
        rbin = RBin[k]
        cbin = CBin[k]
        obin = (Ori[k] - ori) * bins_per_rad
        mag = Mag[k] * W[k]

        r0 = int(rbin)
        c0 = int(cbin)
        o0 = int(obin)
        rbin -= r0
        cbin -= c0
        obin -= o0

        if o0 < 0:
            o0 += n
        if o0 >= n:
            o0 -= n

        # histogram update using tri-linear interpolation
        v_r1 = mag * rbin
        v_r0 = mag - v_r1

        v_rc11 = v_r1 * cbin
        v_rc10 = v_r1 - v_rc11

        v_rc01 = v_r0 * cbin
        v_rc00 = v_r0 - v_rc01

        v_rco111 = v_rc11 * obin
        v_rco110 = v_rc11 - v_rco111

        v_rco101 = v_rc10 * obin
        v_rco100 = v_rc10 - v_rco101

        v_rco011 = v_rc01 * obin
        v_rco010 = v_rc01 - v_rco011

        v_rco001 = v_rc00 * obin
        v_rco000 = v_rc00 - v_rco001

        idx = ((r0 + 1) * (d + 2) + c0 + 1) * (n + 2) + o0
        hist[idx] += v_rco000
        hist[idx + 1] += v_rco001
        hist[idx + (n + 2)] += v_rco010
        hist[idx + (n + 3)] += v_rco011
        hist[idx + (d + 2) * (n + 2)] += v_rco100
        hist[idx + (d + 2) * (n + 2) + 1] += v_rco101
        hist[idx + (d + 3) * (n + 2)] += v_rco110
        hist[idx + (d + 3) * (n + 2) + 1] += v_rco111

    # finalize histogram, since the orientation histograms are circular
    for i in range(d):
        for j in range(d):
            idx = ((i + 1) * (d + 2) + (j + 1)) * (n + 2)
            hist[idx] += hist[idx + n]
            hist[idx + 1] += hist[idx + n + 1]
            for k in range(n):
                dst.append(hist[idx + k])

    # copy histogram to the descriptor,
    # apply hysteresis thresholding
    # and scale the result, so that it can be easily converted
    # to byte array
    nrm2 = 0
    length = d * d * n
    for k in range(length):
        nrm2 += dst[k] * dst[k]
    thr = np.sqrt(nrm2) * SIFT_DESCR_MAG_THR

    nrm2 = 0
    for i in range(length):
        val = min(dst[i], thr)
        dst[i] = val
        nrm2 += val * val
    nrm2 = SIFT_INT_DESCR_FCTR / max(np.sqrt(nrm2), FLT_EPSILON) # 归一化
    for k in range(length):
        dst[k] = min(max(dst[k] * nrm2, 0), 255)

    return dst
# calcDescriptors:计算描述符
def calcDescriptors(gpyr, keypoints, SIFT_DESCR_WIDTH=4, SIFT_DESCR_HIST_BINS=8):
    # SIFT_DESCR_WIDTH = 4,描述直方图的宽度
    # SIFT_DESCR_HIST_BINS = 8
    d = SIFT_DESCR_WIDTH
    n = SIFT_DESCR_HIST_BINS
    descriptors = []

    # keypoints(x,y,低8位组数次8位层数,尺度,主方向)
    for i in range(len(keypoints)):
        kpt = keypoints[i]
        o = kpt[2] & 255  # 组序号
        s = (kpt[2] >> 8) & 255  # 该特征点所在的层序号
        scale = 1.0 / (1 << o)  # 缩放倍数
        size = kpt[3] * scale  # 该特征点所在组的图像尺寸
        ptf = [kpt[1] * scale, kpt[0] * scale]  # 该特征点在金字塔组中的坐标
        img = gpyr[o][s]  # 该点所在的金字塔图像

        descriptors.append(calcSIFTDescriptor(img, ptf, kpt[-1], size * 0.5, d, n))  # calcSIFTDescriptor:更小的计算描述符函数
    return descriptors
def SIFT(img, showDoGimgs=False):
    # 1. 建立高斯差分金字塔,
    SIFT_SIGMA = 1.6
    SIFT_INIT_SIGMA = 0.5  # 假设的摄像头的尺度
    sigma0 = np.sqrt(SIFT_SIGMA ** 2 - SIFT_INIT_SIGMA ** 2) #初始sigma0
    n = 2######

    DoG, GuassianPyramid,octaves = getDoG(img, n, sigma0)  # 函数1.1,getDoG:得到高斯金字塔和高斯差分金字塔

    if showDoGimgs:
        plt.figure(1)
        for i in range(octaves):
            for j in range(n + 3):
                array = np.array(GuassianPyramid[i][j], dtype=np.float32)
                plt.subplot(octaves, n + 3, j + (i) * octaves + 1)
                plt.imshow(array.astype(np.uint8), cmap='gray')
                plt.axis('off')
        plt.show()

        plt.figure(2)
        for i in range(octaves):
            for j in range(n + 2):
                array = np.array(DoG[i][j], dtype=np.float32)
                plt.subplot(octaves, n + 3, j + (i) * octaves + 1)
                plt.imshow(array.astype(np.uint8), cmap='gray')
                plt.axis('off')
        plt.show()

    #2. 确定关键点位置,为关键点赋予方向
    KeyPoints = LocateKeyPoint(DoG, SIFT_SIGMA, GuassianPyramid, n)  # 函数2.1,LocateKeyPoint:关键点定位

    #3. 计算关键点的描述符
    discriptors = calcDescriptors(GuassianPyramid, KeyPoints)  # 函数3.1,calcDescriptors:计算描述符

    return KeyPoints, discriptors
def Lines(img, info, color=(255, 0, 0), err=700):
    if len(img.shape) == 2:
        result = np.dstack((img, img, img))
    else:
        result = img
    k = 0
    for i in range(result.shape[0]):
        for j in range(result.shape[1]):
            temp = (info[:, 1] - info[:, 0])
            A = (j - info[:, 0]) * (info[:, 3] - info[:, 2])
            B = (i - info[:, 2]) * (info[:, 1] - info[:, 0])
            temp[temp == 0] = 1e-9
            t = (j - info[:, 0]) / temp
            e = np.abs(A - B)
            temp = e < err
            if (temp * (t >= 0) * (t <= 1)).any():
                result[i, j] = color
                k += 1
    #print(k)

    return result
def drawLines(X1, X2, Y1, Y2, dis, img, num=10):
    info = list(np.dstack((X1, X2, Y1, Y2, dis))[0])
    info = sorted(info, key=lambda x: x[-1])
    info = np.array(info)
    info = info[:min(num, info.shape[0]), :]
    img = Lines(img, info)
    # plt.imsave('./sift/3.jpg', img)

    if len(img.shape) == 2:
        plt.imshow(img.astype(np.uint8), cmap='gray')
    else:
        plt.imshow(img.astype(np.uint8))
    plt.axis('off')
    # plt.plot([info[:,0], info[:,1]], [info[:,2], info[:,3]], 'c')
    # fig = plt.gcf()
    # fig.set_size_inches(int(img.shape[0]/100.0),int(img.shape[1]/100.0))
    plt.savefig('result.jpg')
    plt.show()
if __name__ == '__main__':
    origimg = plt.imread('./index_1.bmp')  # 读第一张图片
    if len(origimg.shape) == 3:#如果是彩色图,就按照三通道取均值的方式转成灰度图
        img = origimg.mean(axis=-1)
    else:
        img = origimg
    keyPoints, discriptors = SIFT(img)  # 用SIFT算法计算关键点(x坐标,y坐标,sigma,主方向,梯度幅值)和描述符(128维的向量)

    origimg2 = plt.imread('./index_3.bmp')  # 读第二张图片
    if len(origimg.shape) == 3:
        img2 = origimg2.mean(axis=-1)
    else:
        img2 = origimg2
    ScaleRatio = img.shape[0] * 1.0 / img2.shape[0]
    img2 = np.array(Image.fromarray(img2).resize((int(round(ScaleRatio * img2.shape[1])), img.shape[0]), Image.BICUBIC))
    keyPoints2, discriptors2 = SIFT(img2)  # 用SIFT算关键点和描述符

    indexs = []
    deltas = []
    for i in range(len(keyPoints2)):
        ds = discriptors2[i]
        mindetal = 10000000
        index = -1
        detal = 0
        for j in range(len(keyPoints)):
            ds0 = discriptors[j]
            d = np.array(ds)-np.array(ds0)
            detal = d.dot(d)
            if( detal <= mindetal):
                mindetal = detal
                index = j
        indexs.append(index)
        deltas.append(mindetal)


    keyPoints = np.array(keyPoints)[:,:2]
    keyPoints2 = np.array(keyPoints2)[:,:2]

    keyPoints2[:, 1] = img.shape[1] + keyPoints2[:, 1]

    origimg2 = np.array(Image.fromarray(origimg2).resize((img2.shape[1],img2.shape[0]), Image.BICUBIC))
    result = np.hstack((origimg,origimg2))


    keyPoints = keyPoints[indexs[:]]

    X1 = keyPoints[:, 1]
    X2 = keyPoints2[:, 1]
    Y1 = keyPoints[:, 0]
    Y2 = keyPoints2[:, 0]
    drawLines(X1,X2,Y1,Y2,deltas,result) #把匹配的结果放到这里画线

说明:后面我代码能力有限,上面的代码就是我从github上找的,不是我自己写的。 

六、opencv中SIFT算法api
  • cv2.xfeatures2d.SIFT_create()
    这是一个类,这个类在cv2.xfeatures2d模块下面,因为sift算法在opencv的扩展包中,并不在基础包中。
    (1)先实例化一个sift类:sift = cv2.xfeatures2d.SIFT_create()
    (2)进行检测,获取关键点kp:kp = sift.detect(img, ...)
    (3)获取关键点的描述子des:kp, des = sift.compute(img, kp),第一个参数是指对哪个图像计算描述子,第二个参数是关键点,同时返回kp和des描述子
    或者2、3步合并:kp, des = sift.detectAndCompute(img,mask),这个方法可以同时返回kp和des,参数img是指你要在哪张图中查找kp和des,mask指对img中哪个区域进行计算,实操中我们一般都用这个方法
    (4)绘制kp:cv2.drawKeypoints(image, kp, outputimage, color, flags)
    image:原始图像
    kp:关键点信息,将其绘制在图像上
    outputimage:输出图片,可以是原始图像
    color:通过修改(b,g,r)的值,更改画笔的颜色
    flags:绘图功能的标识设置
      cv2.DRAW_MATCHES_FLAGS_DEFAULT:创建输出图像矩阵,使用现存的输出图像绘制匹配对和特征点,对每一个关键点只绘制中间点。
      cv2.DRAW_MATCHES_FLAGS_DRAW_OVER_OUTIMG:不创建输出图像矩阵,而是在输出图像上绘制匹配对
      cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS:对每一个特征点绘制带大小和方向的关键点图形
      cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS:单点的特征点不会被绘制 
#例22.1 调用SIFT算法检测图像的特征点  
import cv2
import numpy as np
import matplotlib.pyplot as plt

img = cv2.imread(r'C:\Users\25584\Desktop\building.jpg')  #原图  (600, 868, 3)
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)    #转灰度图   (600, 868)

#------------分两步提取特征点-----------------------------------------
sift0 = cv2.xfeatures2d.SIFT_create()              #创建sift对象
kp0 = sift0.detect(img_gray, None)        #检测关键点。第二个参数是一个掩码,可以对img_gray中的某个区域进行检测,对整个图片进行检测时就设置为none
kp_, des0 = sift0.compute(img_gray, kp0)

img0 = img.copy()                       #绘制keypoint
cv2.drawKeypoints(img_gray, kp0, img0)
print(des0)    
print('----------------------------------------')  

#---------------一步提取特征点----------------------------------
sift1 = cv2.xfeatures2d.SIFT_create()    #创建sift对象
kp1, des1 = sift1.detectAndCompute(img_gray, None)        #进行检测

img1 = img.copy()                       #绘制keypoint
cv2.drawKeypoints(img1, kp1, img1, flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
print(des1)  

#---------------可视化----------------------------------
plt.figure(figsize=(10,8), dpi=100)
ret = np.hstack((img[:,:,::-1], img0[:,:,::-1], img1[:,:,::-1]))
plt.imshow(ret), plt.title('SIFT detect'), plt.xticks([]), plt.yticks([])
plt.show()
[[  9.   6.   9. ...   0.   0.   0.]
 [  2.   1.   7. ...   1.   1.   3.]
 [  0.   0.   0. ...   8.   1.   2.]
 ...
 [ 71.   4.   0. ...   0.   0.  20.]
 [102.   2.   0. ...   0.   0.  15.]
 [  8.   2.   0. ...   0.   0.   0.]]
----------------------------------------
[[  9.   6.   9. ...   0.   0.   0.]
 [  2.   1.   7. ...   1.   1.   3.]
 [  0.   0.   0. ...   8.   1.   2.]
 ...
 [ 71.   4.   0. ...   0.   0.  20.]
 [102.   2.   0. ...   0.   0.  15.]
 [  8.   2.   0. ...   0.   0.   0.]]

说明:一步或者两步提取的特征点都是一模一样的,描述子也是一模一样的。
len(kp)返回:4566,就是说找到了4566个关键点。des.shape返回:(4566, 128),就是说每个关键点都返回一个一个长为128的向量,用这个向量来描述这个这个关键点。

猜你喜欢

转载自blog.csdn.net/friday1203/article/details/134846584
今日推荐