这篇文章整理两个图像处理中非常重要的算法,一个是Harris角点检测算法,另一个是SIFT特征匹配算法,这两个算法本质上还是去找图像里面的关键特征点,帮助我们后续更好的理解图像以及做各种各样的分析。 由于这两个算法涉及到的数学原理会比较多,而我刚入门,所以只是从使用的角度,简单的描述到底在做什么事情,至于详细的数学细节或者推导,这里不过多整理,以掉包能完成任务为首要目的啦。
首先,先介绍Harris角点检测算法,角点在图像中是很重要的特征,信息含量很高,那么如何找到一个图像里面的角点呢? 这个算法就能轻松解决。但是呢?这个算法只考虑了旋转不变性,即一个角点旋转之后还是角点,没有考虑到尺度不变,尺度变化可能会导致角点变成边缘,所以有没有一种方法同时考虑这两个特性去找特征点呢? 这就是SIFT算法,这个算法也是用来侦测和描述影像中的局部性特征,基于位置,尺度,旋转不变性在空间尺度中寻找特征点。 这两个算法的使用场景非常广泛,比如图像配准,目标识别跟踪,图像对齐,图像拼接(全景图), 相机标定,三维场景重建等。最后,通过一个全景图拼接的Demo感受下这两个算法的魅力
大纲如下:
Ok, let’s go!
角点特征能在保留图像重要特征的同时,有效减少信息数据量,算是图像中较好的特征,比边缘特征更好的用于定位。 在图像处理中,检测角点特征的算法很多,最常用,最基础的就是Harris角点检测算法。
在说这个算法之前,先感受下啥子是角点:
如果我们人眼看的话,非常好理解,就是图像里面的物体的角呗。那么计算机看,定位这些角点可就不那么容易了。 如果想让计算机看,那么必须得搞明白一个事情: 这样的角点,与边界点,以及普通屏幕点有啥区别,即上图的红框,蓝框,黑框内的点在数值上会有啥区别呢?
Harris角点检测认为:特征点具有局部差异性。如何描述呢? 每一个点为中心,取一个窗口,比如窗口大小 5 × 5 5\times5 5×5或者 7 × 7 7\times7 7×7, 那么窗口描述了这个特征点周围的环境。
说是这么说,但具体应该怎么找到角点呢? Harris角点检测算法主要是三步:
下面详细展开。
这里的核心问题: 如何确定哪些窗口引起较大灰度值变化?
假设当前窗口中心的像素点位置 ( x , y ) (x,y) (x,y), 这个点像素值 I ( x , y ) I(x,y) I(x,y), 如果这个窗口分别向 x x x和 y y y方向分别移动了 u u u和 v v v步, 到了一个新的位置 ( x + u , y + v ) (x+u, y+v) (x+u,y+v), 像素值 I ( x + u , y + v ) I(x+u, y+v) I(x+u,y+v), 那么 I [ ( x + u , y + v ) − I ( x , y ) ] I[(x+u,y+v)-I(x,y)] I[(x+u,y+v)−I(x,y)]就是中心点移动引起的灰度值的变化。
那我们这是个窗口呀,比如 3 × 3 3\times3 3×3,那就有9个点,窗口一移动的话,这9个点都有引起的灰度值的变化,而把这个都加和,就得到了窗口在各个方向上移动 ( u , v ) (u,v) (u,v)造成的像素灰度值变化,这个应该很好理解,公式如下:
E ( u , v ) = ∑ ( x , y ) ∈ W ( x , y ) w ( x , y ) × [ I ( x + u , y + v ) − I ( x , y ) ] 2 E(u, v)=\sum_{(x, y)\in W(x,y)} w(x, y) \times[I(x+u, y+v)-I(x, y)]^{2} E(u,v)=(x,y)∈W(x,y)∑w(x,y)×[I(x+u,y+v)−I(x,y)]2
这里多出了一个 w ( x , y ) w(x,y) w(x,y)函数,表示窗口内各个像素的权重,可以设定为窗口中心为原点的高斯分布,如果窗口中心点像素是角点,那么窗口移动前后,中心点灰度值变化非常强烈,那么这个权重就大一些,表示该点对灰度变化贡献大。 而离着窗口中心较远的点,灰度变化较小,于是权重小一些,对灰度变化的贡献小。
这个公式就是我们的目标函数,如果是角点,这个函数值会比较大,所以我们就是最大化这个函数来得到图像中的角点。
But, 上面这个函数计算 E ( u , v ) E(u,v) E(u,v)会非常慢,比较涉及到了窗口内所有像素点的计算,所以,我们用泰勒,先对像素值函数进行近似。
I ( x + u , y + v ) = I ( x , y ) + I x ( x , y ) u + I y ( x , y ) v + O ( u 2 , v 2 ) ≈ I ( x , y ) + I x ( x , y ) u + I y ( x , y ) v I(x+u, y+v)=I(x, y)+I_{x}(x, y) u+I_{y}(x, y) v+O\left(u^{2}, v^{2}\right) \approx I(x, y)+I_{x}(x, y) u+I_{y}(x, y) v I(x+u,y+v)=I(x,y)+Ix(x,y)u+Iy(x,y)v+O(u2,v2)≈I(x,y)+Ix(x,y)u+Iy(x,y)v
其中, I x I_x Ix和 I y I_y Iy是 I I I偏微分, 在图像中是 x x x和 y y y方向上的梯度图,可通过cv2.Sobel()
得到
I x = ∂ I ( x , y ) ∂ x , I y = ∂ I ( x , y ) ∂ y I_{x}=\frac{\partial I(x, y)}{\partial x}, \quad I_{y}=\frac{\partial I(x, y)}{\partial y} Ix=∂x∂I(x,y),Iy=∂y∂I(x,y)
把上面的式子代入 E ( u , v ) E(u,v) E(u,v)化简得:
E ( u , v ) = ∑ ( x , y ) ∈ W ( x , y ) w ( x , y ) × [ I ( x , y ) + u I x + v I y − I ( x , y ) ] 2 = ∑ ( x , y ) ∈ W ( x , y ) w ( x , y ) × ( u I x + v I y ) 2 = ∑ ( x , y ) ∈ W ( x , y ) w ( x , y ) × ( u 2 I x 2 + v 2 I y 2 + 2 u v I x I y ) \begin{aligned} E(u, v) &=\sum_{(x, y)\in W(x,y)} w(x, y) \times\left[I(x, y)+u I_{x}+v I_{y}-I(x, y)\right]^{2} \\ &=\sum_{(x, y)\in W(x,y)} w(x, y) \times\left(u I_{x}+v I_{y}\right)^{2} \\ &=\sum_{(x, y)\in W(x,y)} w(x, y) \times\left(u^{2} I_{x}^{2}+v^{2} I_{y}^{2}+2 u v I_{x} I_{y}\right) \end{aligned} E(u,v)=(x,y)∈W(x,y)∑w(x,y)×[I(x,y)+uIx+vIy−I(x,y)]2=(x,y)∈W(x,y)∑w(x,y)×(uIx+vIy)2=(x,y)∈W(x,y)∑w(x,y)×(u2Ix2+v2Iy2+2uvIxIy)
这个用矩阵来表示, 线代的二次型转换:
E ( u , v ) ≈ [ u , v ] M ( x , y ) [ u v ] E(u, v) \approx[u, v] M(x,y)\left[\begin{array}{l} u \\ v \end{array}\right] E(u,v)≈[u,v]M(x,y)[uv]
其中, 矩阵 M M M如下:
M ( x , y ) = ∑ w [ I x ( x , y ) 2 I x ( x , y ) I y ( x , y ) I x ( x , y ) I y ( x , y ) I y ( x , y ) 2 ] = [ ∑ w I x ( x , y ) 2 ∑ w I x ( x , y ) I y ( x , y ) ∑ w I x ( x , y ) I y ( x , y ) ∑ w I y ( x , y ) 2 ] = [ A C C B ] M(x, y)=\sum_{w}\left[\begin{array}{cc} I_{x}(x, y)^{2} & I_{x}(x, y) I_{y}(x, y) \\ I_{x}(x, y) I_{y}(x, y) & I_{y}(x, y)^{2} \end{array}\right]=\left[\begin{array}{cc} \sum_{w} I_{x}(x, y)^{2} & \sum_{w} I_{x}(x, y) I_{y}(x, y) \\ \sum_{w} I_{x}(x, y) I_{y}(x, y) & \sum_{w} I_{y}(x, y)^{2} \end{array}\right]=\left[\begin{array}{ll} A & C \\ C & B \end{array}\right] M(x,y)=w∑[Ix(x,y)2Ix(x,y)Iy(x,y)Ix(x,y)Iy(x,y)Iy(x,y)2]=[∑wIx(x,y)2∑wIx(x,y)Iy(x,y)∑wIx(x,y)Iy(x,y)∑wIy(x,y)2]=[ACCB]
所以最终目标函数化成了:
E ( x , y ; u , v ) ≈ A u 2 + 2 C u v + B v 2 A = ∑ w I x 2 , B = ∑ w I y 2 , C = ∑ w I x I y \begin{aligned} &E(x, y ;u, v) \approx A u^{2}+2 C u v+B v^{2} \\ &A=\sum_{w} I_{x}^{2}, B=\sum_{w} I_{y}^{2}, C=\sum_{w} I_{x} I_{y} \end{aligned} E(x,y;u,v)≈Au2+2Cuv+Bv2A=w∑Ix2,B=w∑Iy2,C=w∑IxIy
到这里,应该很好理解,无非就是泰勒近似,以及线代里面二次型化简的东西,注意 M M M这里是一个协方差矩阵,主对角线可以看成是自己方向上梯度的方差,而副对角线是与其他方向梯度的协方差,并且这是一个对称矩阵。
下面说点不是很好理解的: 二次项函数本质上是椭圆函数,椭圆方程为:
[ u , v ] M ( x , y ) [ u v ] = 1 [u, v] M(x,y)\left[\begin{array}{l} u \\ v \end{array}\right]=1 [u,v]M(x,y)[uv]=1
可视化出来如下:
这里的 λ \lambda λ就是实对称矩阵 M M M的特征值。 这里可能并不是很好理解,我下面尝试从基变换的角度解释下。 首先, M M M是实对称矩阵,那么就一定满足:
R M R ⊤ = Λ = ( λ 1 0 0 λ 2 ) R M R^{\top}=\Lambda=\left(\begin{array}{llll} \lambda_{1} & 0 \\ 0& \lambda_{2} \end{array}\right) RMR⊤=Λ=(λ100λ2)
R R R是 M M M的特征向量组合, λ 1 , λ 2 \lambda_1, \lambda_2 λ1,λ2是 M M M的特征值。把这个式子代入上面的式子:
[ u , v ] R M R ⊤ [ u v ] = [ u , v ] Λ [ u v ] = [ u , v ] ( λ 1 0 0 λ 2 ) [ u v ] = λ 1 u 2 + λ 2 v 2 = u 2 1 λ 1 + v 2 1 λ 2 = 1 [u, v] R M R^{\top}\left[\begin{array}{l} u \\ v \end{array}\right] = [u, v] \Lambda\left[\begin{array}{l} u \\ v \end{array}\right]=[u, v] \left(\begin{array}{llll} \lambda_{1} & 0 \\ 0& \lambda_{2} \end{array}\right)\left[\begin{array}{l} u \\ v \end{array}\right]=\lambda_1u^2+\lambda_2v^2=\frac{u^2}{\frac{1}{\lambda_1}}+\frac{v^2}{\frac{1}{\lambda_2}}=1 [u,v]RMR⊤[uv]=[u,v]Λ[uv]=[u,v](λ100λ2)[uv]=λ1u2+λ2v2=λ11u2+λ21v2=1
这样就转成了标准的椭圆方程了,而 M M M的特征值平方根正好是表示着椭圆长短轴。那么特征值是啥? 其实就是将原始像素点映射到新的空间中(映射规则就是 [ u , v ] R [u,v]R [u,v]R), 在每组基方向上的梯度方差或者叫变化程度。所以我们可以用这个特征值去衡量某个方向上像素点的波动程度。这里如果再不明白,可以看下我这篇文章,补一下向量表示与基变换的相关知识。
于是乎, 通过上面的一番操作,就把某个方向是灰度变化程度大小转成了看 M M M矩阵的特征值上。
这里定义每个窗口的角点响应函数
R = det ( M ) − k ( trace ( M ) ) 2 = λ 1 λ 2 − k ( λ 1 + λ 2 ) 2 R=\operatorname{det}(M)-k(\operatorname{trace}(\mathrm{M}))^{2}=\lambda_1\lambda_2-k(\lambda_1+\lambda_2)^2 R=det(M)−k(trace(M))2=λ1λ2−k(λ1+λ2)2
这里的 k k k是一个经验常数,范围在 ( 0.04 , 0.06 ) (0.04,0.06) (0.04,0.06)之间。
根据 R 的值,将这个窗口所在的区域划分为平面、边缘或角点。为了得到最优的角点,我们还可以使用非极大值抑制。
当然,因为特征值 λ 1 \lambda_1 λ1和 λ 2 \lambda_2 λ2决定了 R R R的值,所以我们可以用特征值来决定一个窗口是平面、边缘还是角点:
一图胜千言:
Harris 角点检测的结果是带有这些分数 R R R的灰度图像,设定一个阈值,分数大于这个阈值的像素就对应角点。
注意:Harris 检测器具有旋转不变性,但不具有尺度不变性,也就是说尺度变化可能会导致角点变为边缘,如下图所示:
So, 如何找到旋转以及尺度不变的特征点呢?这就是SIFT算法干的事情了,但是介绍之前,先看看OpenCV中角点检测算法咋用。 理论一大推,但是用起来一个函数搞定。
这里的函数是cv2.cornerHarris()
:
img
: 数据类型为float32的输入图像blockSize
: 角点检测中指定区域的大小, 即 w w w窗口大小ksize
: Sobel求导中使用的窗口大小, 需要用sobel算子求梯度,这里是设置sobel算子求导的窗口k
:取值参数为[0.04, 0.06]看个例子:
img = cv2.imread('img/test_img.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 角点检测
dst = cv2.cornerHarris(gray, 2, 3, 0.04) # 这个是每个像素点的E值,即平移后灰度级变换程度值
img[dst>0.01*dst.max()] = [0, 0, 255]
下面是角点检测结果:
Scale Invariant Feature Transform(SIFT): 尺度不变特征转换用来侦测与描述影像中的局部性特征, 基于位置,尺度和旋转不变性在空间尺度中寻找极值点。
特点:
解决问题:目标自身状态,场景所处环境和成像器材的成像特性等因素影响图像配准/目标识别跟踪的性能。SIFT算法一定程度可解决:
SIFT算法的实质是在不同的尺度空间上查找关键点(特征点),并计算出关键点的方向。SIFT所查找到的关键点是一些十分突出,不会因光照,仿射变换和噪音等因素而变化的点,如角点、边缘点、暗区的亮点及亮区的暗点等。
SIFT算法主要下面四步:
由于这个算法理论上稍微复杂些,下面就简单整理了,首先,要先明白这个算法到底做的是什么事情?
我理解: 寻找图像中具有旋转,平移,以及尺度不变性的那些特征点,并且最终用一个向量表示出来。
究竟是怎么做到的呢? 大致上细节如下。
在一定范围内,无论物体时大还是小,人眼都可以分辨出来,然而计算机要有相同能力却很难,所有要让机器能够对物体在不同尺度下有一个统一的认知,就需要考虑图像在不同的尺度下都存在的特点。
尺度空间的获取通常使用高斯模糊来实现。
高斯模糊是一种图像滤波器,它使用正态分布(高斯函数)计算模糊模板,并使用该模板与原图像做卷积运算,达到模糊图像的目的。公式如下:
G ( r ) = 1 2 π σ 2 N e − r 2 / ( 2 σ 2 ) G(r)=\frac{1}{\sqrt{2 \pi \sigma^{2}} N} e^{-r^{2} /\left(2 \sigma^{2}\right)} G(r)=2πσ2N1e−r2/(2σ2)
σ \sigma σ参数是标准差,指定的越大,说明像素的变化幅度会越大,越偏离原始图像,即图像就会越模糊。 r r r模糊半径,指模板元素到模板中心的距离,假如二维模板大小维 m ∗ n m*n m∗n, 模板上元素 ( x , y ) (x,y) (x,y)对应的高斯计算公式:
G ( x , y ) = 1 2 π σ 2 e − ( x − m / 2 ) 2 + ( y − n / 2 ) 2 2 σ 2 G(x, y)=\frac{1}{2 \pi \sigma^{2}} e^{-\frac{(x-m / 2)^{2}+(y-n / 2)^{2}}{2 \sigma^{2}}} G(x,y)=2πσ21e−2σ2(x−m/2)2+(y−n/2)2
分布不为零的像素组成的卷积矩阵与原始图像做变换。每个像素的值都是周围相邻像素值的加权平均。原始像素的值有最大的高斯分布值,所以有最大的权重,相邻像素随着距离原始像素越来越远,其权重也越来越小。这样进行模糊处理比其它的均衡模糊滤波器更高地保留了边缘效果。
尺度空间使用高斯金字塔表示, 尺度规范化LoG(Laplacion of Gaussian)算子具有真正尺度不变性,Lowe使用高斯差分金字塔近似LoG算子,在尺度空间检测稳定关键点。
尺度空间在实现时,使用高斯金字塔表示,高斯金字塔构建主要分为两部分:
高斯金字塔应该不陌生了:
图像的金字塔模型是指,将原始图像不断降阶采样,得到一系列大小不一的图像,由大到小,从下到上构成的塔状模型。原图像为金子塔的第一层,每次降采样所得到的新图像为金字塔的一层(每层一张图像),每个金字塔共 n n n层。金字塔的层数根据图像的原始大小和塔顶图像的大小共同决定,其计算公式如下:
n = log 2 { min ( M , N ) } − t , t ∈ [ 0 , log 2 { min ( M , N ) } ) n=\log _{2}\{\min (M, N)\}-t, t \in\left[0, \log _{2}\{\min (M, N)\}\right) n=log2{min(M,N)}−t,t∈[0,log2{min(M,N)})
其中 M , N M,N M,N为原图像的大小, t t t为塔顶图像的最小维数的对数值。
在实际计算时,使用高斯金字塔每组中相邻上下两层图像相减,得到高斯差分图像。
这个就是高斯金字塔中的每一层的图片,进行差分,这样得到的结果中,像素点相差较大的位置,就是不同尺度的图片之间的不同。DoG公式定义如下:
D ( x , y , σ ) = [ G ( x , y , k σ ) − G ( x , y , σ ) ] ∗ I ( x , y ) = L ( x , y , k σ ) − L ( x , y , σ ) D(x, y, \sigma)=[G(x, y, k \sigma)-G(x, y, \sigma)] * I(x, y)=L(x, y, k \sigma)-L(x, y, \sigma) D(x,y,σ)=[G(x,y,kσ)−G(x,y,σ)]∗I(x,y)=L(x,y,kσ)−L(x,y,σ)
这个公式也非常好理解, 差分结果就等于第一次高斯滤波的结果,与第二次高斯滤波结果之差。
为了寻找尺度空间的极值点, 每个像素点要和其图像域(同一尺度空间)和尺度域(相邻的尺度空间)的所有相邻点进行比较, 当其大于(或者小于)所有相邻点时,该点就是极值点。 如下图所示, 中间的检测点要和其所在图像的 3 × 3 3\times 3 3×3邻域的8个像素点,以及其相邻的上下两层的 3 × 3 3\times 3 3×3领域的18个像素点,共26个像素点进行比较。
由于要在相邻尺度进行比较,如上上面那个图,每组含4层高斯差分金字塔,只能中间两层中进行两个尺度的极值点检测,为了在每组中检测 S S S个尺度的极值点,则DOG金字塔每组需要 S + 2 S+2 S+2层图像, 而DOG金字塔由高斯金字塔相邻两层相减得到, 则高斯金字塔每组需 S + 3 S+3 S+3层图像, 实际计算时 S S S在3-5之间。
当然这样产生的极值点并不全都是稳定的特征点,因为某些极值点响应较弱,而且DoG算子会产生较强的边缘响应。
值得一提的是,选出的高斯差分金字塔极值点只是候选的特征点。虽然高斯差分金字塔极值点已经能够较好地代表图像的特征并且具有尺度不变性,但在选取过程中没有考虑图像特征点对于图像噪声的鲁棒性,这样确定出的图像特征点在实际应用时易出现图像匹配不当等问题。
以上方法检测到的极值点是离散空间的极值点,以下通过拟合三维二次函数来精确确定关键点的位置和尺度,同时去除低对比度的关键点和不稳定的边缘响应点(因为DoG算子会产生较强的边缘响应),以增强匹配稳定性、提高抗噪声能力。
为了稍微好理解一点,先看看一维情况下,如何通过离散值拟合曲线:
放到三维情况下:
D ( Δ x , Δ y , Δ σ ) = D ( x , y , σ ) + [ ∂ D x ∂ D y ∂ D σ ] [ Δ x Δ y Δ σ ] + 1 2 [ Δ x Δ y Δ σ ] [ ∂ 2 D ∂ x 2 ∂ 2 D ∂ x ∂ y ∂ 2 D ∂ x ∂ σ ∂ 2 D ∂ y ∂ x ∂ 2 D ∂ y 2 ∂ 2 D ∂ y ∂ σ ∂ 2 D ∂ σ ∂ x ∂ 2 D ∂ σ ∂ y ∂ 2 D ∂ σ 2 ] [ Δ x Δ y Δ σ ] D(\Delta x, \Delta y, \Delta \sigma)=D(x, y, \sigma)+\left[\begin{array}{lll} \frac{\partial D}{x} & \frac{\partial D}{y} & \frac{\partial D}{\sigma} \end{array}\right]\left[\begin{array}{c} \Delta x \\ \Delta y \\ \Delta \sigma \end{array}\right]+\frac{1}{2}\left[\begin{array}{lll} \Delta x & \Delta y & \Delta \sigma \end{array}\right]\left[\begin{array}{ccc} \frac{\partial^{2} D}{\partial x^{2}} & \frac{\partial^{2} D}{\partial x \partial y} & \frac{\partial^{2} D}{\partial x \partial \sigma} \\ \frac{\partial^{2} D}{\partial y \partial x} & \frac{\partial^{2} D}{\partial y^{2}} & \frac{\partial^{2} D}{\partial y \partial \sigma} \\ \frac{\partial^{2} D}{\partial \sigma \partial x} & \frac{\partial^{2} D}{\partial \sigma \partial y} & \frac{\partial^{2} D}{\partial \sigma^{2}} \end{array}\right]\left[\begin{array}{c} \Delta x \\ \Delta y \\ \Delta \sigma \end{array}\right] D(Δx,Δy,Δσ)=D(x,y,σ)+[x∂Dy∂Dσ∂D]⎣⎡ΔxΔyΔσ⎦⎤+21[ΔxΔyΔσ]⎣⎢⎡∂x2∂2D∂y∂x∂2D∂σ∂x∂2D∂x∂y∂2D∂y2∂2D∂σ∂y∂2D∂x∂σ∂2D∂y∂σ∂2D∂σ2∂2D⎦⎥⎤⎣⎡ΔxΔyΔσ⎦⎤
这里的 Δ x \Delta x Δx表示相对窗口中心的偏移量,等价于上一节中的 u u u。 如果对上面进行求导,并令导数等于0,就可以得到较为准确的极值点和极值。
D ( x ) = D + ∂ D T ∂ x Δ x + 1 2 Δ x T ∂ 2 D T ∂ x 2 Δ x Δ x = − ∂ 2 D − 1 ∂ x 2 ∂ D ( x ) ∂ x D(x)=D+\frac{\partial D^{T}}{\partial x} \Delta x+\frac{1}{2} \Delta x^{T} \frac{\partial^{2} D^{T}}{\partial x^{2}} \Delta x \quad \Delta x=-\frac{\partial^{2} D^{-1}}{\partial x^{2}} \frac{\partial D(x)}{\partial x} D(x)=D+∂x∂DTΔx+21ΔxT∂x2∂2DTΔxΔx=−∂x2∂2D−1∂x∂D(x)
这里将每个候选极值点求出相应的导数,然后代入,得到 D ( x ) D(x) D(x), 把结果值非常小的(比如小于0.03)的先进行首轮剔除。
DoG算子会产生较强的边缘响应,即容易保留边界点,所以下面需要剔除不稳定的边缘响应点。具体做法如下:
首先,获取每个特征点出的Hessian矩阵, 这里其实和角点检测那里是一样的:
H ( x , y ) = [ D x x ( x , y ) D x y ( x , y ) D x y ( x , y ) D y y ( x , y ) ] H(x, y)=\left[\begin{array}{ll} D_{x x}(x, y) & D_{x y}(x, y) \\ D_{x y}(x, y) & D_{y y}(x, y) \end{array}\right] H(x,y)=[Dxx(x,y)Dxy(x,y)Dxy(x,y)Dyy(x,y)]
令 α \alpha α是最大的特征值, β \beta β是最小的特征值,下面找一个边界值:
Tr ( H ) = D x x + D y y = α + β Tr ( H ) 2 Det ( H ) = ( α + β ) 2 α β = ( γ + 1 ) 2 γ Det ( H ) = D x x D y y − ( D x y ) 2 = α β \begin{array}{cl} \operatorname{Tr}(H)=D_{x x}+D_{y y}=\alpha+\beta & \frac{\operatorname{Tr}(H)^{2}}{\operatorname{Det}(H)}=\frac{(\alpha+\beta)^{2}}{\alpha \beta}=\frac{(\gamma+1)^{2}}{\gamma} \\ \operatorname{Det}(H)=D_{x x} D_{y y}-\left(D_{x y}\right)^{2}=\alpha \beta & \end{array} Tr(H)=Dxx+Dyy=α+βDet(H)=DxxDyy−(Dxy)2=αβDet(H)Tr(H)2=αβ(α+β)2=γ(γ+1)2
导数由采样点相邻差估计得到. 这个比值越大,说明两个特征值的比值越大,即在某一个方向的梯度值越大,而在另一个方向的梯度值越小,而边缘恰恰就是这种情况。所以为了剔除边缘响应点,需要让该比值小于一定的阈值。所以,对于每个特征点,再通过下面公式:
Tr ( H ) 2 Det ( H ) < ( r + 1 ) 2 r \frac{\operatorname{Tr}(H)^{2}}{\operatorname{Det}(H)}<\frac{(r+1)^{2}}{r} Det(H)Tr(H)2<r(r+1)2
这个不等式成立的关键点保留下来,反之剔除掉。论文中 γ = 10 \gamma=10 γ=10
根据上面的一顿操作,就能找到比较好的一些候选关键点,但是在建立高斯差分金字塔以选取图像特征点时,算法考虑了关键点的尺度不变性。而对于图像特征而言,与尺度不变性同等重要的还有旋转不变性。
为了使描述具有旋转不变性,需要利用图像的局部特征为给每一个关键点分配一个主方向,该“主方向”以生成特征点周围的局部区域的梯度方向基准,使图像在旋转后仍能与旋转前保持相同的特征描述。
算法将关键点指定大小领域中的所有点计算梯度方向与赋值,并统计所有梯度方向对应的赋值和,作关键点周围 i i i邻域梯度方向直方图。梯度的模值 m ( x , y ) m(x,y) m(x,y)和方向 ( θ ( x , y ) (\theta(x,y) (θ(x,y)计算公式如下:
m ( x , y ) = [ L ( x + 1 , y ) − L ( x − 1 , y ) ] 2 + [ L ( x , y + 1 ) − L ( x , y − 1 ) ] 2 θ ( x , y ) = arctan L ( x , y + 1 ) − L ( x , y − 1 ) L ( x + 1 , y ) − L ( x − 1 , y ) \begin{aligned} &m(x, y)=\sqrt{[L(x+1, y)-L(x-1, y)]^{2}+[L(x, y+1)-L(x, y-1)]^{2}} \\ &\theta(x, y)=\arctan \frac{L(x, y+1)-L(x, y-1)}{L(x+1, y)-L(x-1, y)} \end{aligned} m(x,y)=[L(x+1,y)−L(x−1,y)]2+[L(x,y+1)−L(x,y−1)]2θ(x,y)=arctanL(x+1,y)−L(x−1,y)L(x,y+1)−L(x,y−1)
这样,对于每个关键点, 就有了三个信息 ( x , y , σ , θ ) (x,y,\sigma, \theta) (x,y,σ,θ), 即位置,尺度和方向。但关键点的主方向到底是啥呢?
对于一个关键点, 要统计它邻域内各个点的梯度直方图,
下图直方图为简化版本(只有8个bin,实际操作时算法会统计从0到360°步长为10°的共计36个梯度方向的幅值和,共有36个bin)。梯度方向直方图中最高的bin对应的方向即定义为该关键点的主方向,若存在任一方向的幅值大于主方向幅值的80%,则将其作为辅方向。所以,对于同一个关键点,可能有多个方向,这种情况在相同位置和尺度将会有多个关键点被创建但方向不同。实际编程实现中,就是把该关键点复制成多份关键点,并将方向值分别赋给这些复制后的关键点。
直方图的横轴是方向,纵轴是邻域内各个点对应方向梯度的累加和。
得到特征点二维位置、尺度位置、主方向的具体信息后,算法需要解决的最后一个问题就是生成关键点信息的描述子,即用一个向量描述图像中的特征点信息。使其不随各种变化而改变,比如光照变化、视角变化等等。这个描述子不但包括关键点,也包含关键点周围对其有贡献的像素点,并且描述符应该有较高的独特性,以便于提高特征点正确匹配的概率。
为了保证特征矢量的旋转不变性, 要以特征点为中心, 在附近领域内将坐标轴旋转 θ \theta θ角度,即将坐标轴旋转为特征点的主方向。
各个像素点的坐标变换是用下面的公式:
[ x ′ y ′ ] = [ cos θ − sin θ sin θ cos θ ] [ x y ] \left[\begin{array}{l} x^{\prime} \\ y^{\prime} \end{array}\right]=\left[\begin{array}{cc} \cos \theta & -\sin \theta \\ \sin \theta & \cos \theta \end{array}\right]\left[\begin{array}{l} x \\ y \end{array}\right] [x′y′]=[cosθsinθ−sinθcosθ][xy]
旋转完之后,算法将特征点周围的 16 × 16 16\times16 16×16邻域分为4个 8 × 8 8\times8 8×8的区域,再将 8 × 8 8\times8 8×8的区域划为 2 × 2 2\times2 2×2区域,即每个小区域为 4 × 4 4\times4 4×4的范围。统计每个 4 × 4 4\times4 4×4区域的梯度方向直方图(直方图为8个bin,8个方向),故共计 4 ∗ 4 ∗ 8 = 128 4*4*8=128 4∗4∗8=128个bin。对应生成128维向量(值为梯度方向的幅值)。该128维向量即该点的特征描述子。
每个像素的梯度幅值和方向(箭头方向代表梯度方向,长度代表梯度幅值), 然后利用高斯窗口对其加权运算(离中心点距离不一样), 最后在每个 4 × 4 4\times4 4×4的小块上绘制8个方向的梯度直方图, 计算每个梯度方向的累加值, 即可形成一个种子点, 即每个特征点的由 4 × 4 4\times4 4×4个种子点组成, 每个种子点由8个方向的向量信息。
论文中建议对每个关键点使用 4 × 4 4\times4 4×4共16个种子点来描述,每个种子点由 8 8 8个方向的梯度直方图描述,即8维向量, 这样一个关键点就会产生128维的SIFT特征向量
OK, 到这里,就把SIFT算法的大致流程整理了下,不过上面有些乱,下面集中总结下:
原始图片,先用高斯滤波做模糊操作的尺度变换,通过改变高斯核的标准差,得到 S S S图片
为了让后序的特征表达更加丰富,把这 S S S张图片,做成高斯金字塔
接下来,对于高斯金字塔的每一个level的 S S S张图片, 相邻图片进行差分运算,得到高斯差分图像, S − 1 S-1 S−1张
基于高斯差分图像,去寻找候选的极值点,所谓极值点,就是对差分图像中,不是首尾层的图像中的每个像素点,去对比不同尺度空间(上下相邻两层邻域)和同一尺度空间周围像素点的值与当前这个像素点值大小,拿到极大值点,作为候选的关键点。
定位精确的关键点,对尺度空间中DOG函数进行曲线拟合,计算其极值点,从而实现关键点的精确定位。这里用到了泰勒近似,拟合出函数来之后,把当前关键点代入,得到的函数值过小的(比如小于0.03)的关键点去掉。
消除边缘效应,对剩下的关键点,获取特征点处的梯度矩阵,使用的有限差分法求导,得到这个矩阵之后,根据特征值的比例再进行筛选,水平方向梯度与垂直方向梯度差不多的保留下来,相差很大,说明是边缘, 这种关键点要去掉,即消除边缘效应
通过上面步骤,就把关键点给选择了出来,但是仅仅从尺度不变性角度进行的考虑,接下来考虑平移不变性, 为每个关键点选择一个主方向
这个就是考虑关键点邻域内的所有像素点,用一个直方图去统计这个邻域内所有像素点的梯度方向以及梯度累加值,梯度方向由于是360度,这里进行了分桶操作,划分成了8个bins。拿到直方图之后,把梯度累加值最大的那个方向作为关键点的主方向, 当然次大的还可以作为辅方向,这样每个关键点就同时有了位置,尺度,方向三个特征描述(如果关键点还有辅方向的,就需要把这个观测点复制一份,保持位置,尺度不变,该变方向即可)。
每个观测点有了三种属性描述,接下来,需要转成特征,即想用一个向量来描述
金字塔中所有关键点,都得到特征描述,返回结果
下面就可以看代码了。
对于SIFT算法, OpenCV中直接也是一个函数搞定。
img = cv2.imread('img/test_1.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# opencv版本高于3.4.3, 这个sift算法使用变成cv2.SIFT_create(), 在这之前的版本是cv2.xfeatures2d.SIFT_create()
# SIFT检测器
sift = cv2.SIFT_create()
# 找出图像中的关键点
kp = sift.detect(gray, None)
# 在图中画出关键点
img = cv2.drawKeypoints(gray, kp, img)
# 计算关键点对应的SIFT特征向量
kp, des = sift.compute(gray, kp)
# 上面的两步,也可以用下面的一步
sift = cv2.SIFT_create()
kp1, des1 = sift.detectAndCompute(gray, None) # 这里还能一步到位,直接算出关键点以及关键向量
拿到图像中的关键点,以及也能用向量来描述这个关键点的特征了,那么给定两张图像之后,怎么看出这两张图片中哪些关键点比较相似呢?这就是看关键点向量之间的差异了,也是特征匹配在做的事情。
暴力匹配,两个图像中的关键点的特征向量,一个个的计算差异, 即两层for循环了。 这里直接看怎么用:
首先, 读入两张图片,然后拿到各自的关键点以及关键点向量。
img1 = cv2.imread('img/box.png', 0)
img2 = cv2.imread('img/box_in_scene.png', 0)
# sift算法,得到每张图的关键点以及关键向量
sift = cv2.SIFT_create()
kp1, des1 = sift.detectAndCompute(img1, None) # 这里还能一步到位,直接算出关键点以及关键向量
kp2, des2 = sift.detectAndCompute(img2, None)
下面进行特征匹配:
1对1的匹配: 即图片A中的一个向量匹配图片B中的一个向量
# crossCheck表示两个特征点要互相匹配,例如A中的第i个特征点与B中的第j个特征点最近的,并且B中的第j个特征点到A中的第i个特征点也是最近的
# NORM_L2: 归一化数组的欧几里得距离, 如果其他特征计算方法需要考虑不同的匹配计算方式
bf = cv2.BFMatcher(crossCheck=True)
# 1对1的匹配
matches = bf.match(des1, des2)
matches = sorted(matches, key=lambda x: x.distance)
如果这里指定属性crossCheck为False, 得到的结果是604,如果为True,得到的结果是206,所以我基于这个,盲猜下暴力匹配以及这个属性的意义。
暴力匹配的话,就是对于A图像中的每个观测点的向量, 我遍历一遍B图像中每个观测点的向量,然后求欧几里得距离,拿到最小的。这样对于A图像中每个观测点,就得到了B图像里面的最佳匹配。
然后再对于B图像中的每个观测点,也同样用上面的方式走一遍,这样对于B图像中的每个观测点,也得到了A图像中的最佳匹配。
此时,如果是:
bf.match(des1, des2)
: 返回的就是A图像中的每个观测点的最佳匹配,个数是A中关键点的个数bf.match(des2, des1)
: 返回的是B图像中每个观测点的最佳匹配, 个数是B中关键点的个数bf.match(des1, des2)
和bf.match(des2, des1)
都是260, 表示的其实是A和B中,关键点互相匹配的那些,比如A中的第j个观测点,最近点是B中的i,那么B中的i,也要对应A中的j,即 VS
。img3 = cv2.drawMatches(img1, kp1, img2, kp2, matches[:10], None, flags=2)
K对最佳匹配: 对于一个关键点, 找K个最相似的向量。
bf = cv2.BFMatcher()
matches = bf.knnMatch(des1, des2, k=2) # 相当于对于A中的每个关键点,不是找某一个最相似,而是K相似
这里可以根据近邻之间的相似比例对关键点筛选
good = []
for m, n in matches:
if m.distance < 0.75 * n.distance:
good.append([m])
也可以可视化一下:
img3 = cv2.drawMatchesKnn(img1, kp1, img2, kp2, good, None, flags=2)
结果如下:
这里会发现一个问题,对于上面的匹配结果,大部分结果还可以,但有某些匹配错误的点,这种情况怎么弥补呢? 可以使用RANSAC(Random sample consensus)随机抽样一致算法。 这个东西可以简单看下原理。
这个算法也是一个拟合算法, 和最小二乘对比如下:
思路是这样: 先选择初始样本点进行拟合, 给定一个容忍的范围,不断进行迭代。
每一次拟合之后, 容差范围内都有对应的数据点数, 找出数据点个数最多的情况,就是最终的拟合结果。所以这种算法在拟合的时候,先随机抽样初始点,然后进行拟合的时候,会考虑容忍范围(能拟合的数据点个数)
那么,这东西干啥用呢? 下面全景拼接的Demo会用到。
全景拼接大家肯定都玩过,相机里就有这个功能, 这个的原理大概是这样, 假设有两张图片要进行拼接,大概流程如下:
通过SIFT算法拿到两张图片的关键点以及关键向量
根据关键向量做特征匹配
根据特征匹配点,对图片进行一些仿射变换,比如平移,选择,缩放等,这里是保证能无缝衔接上,如果不做处理,那肯定拼接不是。
找到变换矩阵,对某张图片先进行变换,然后进行拼接即可。
这么说比较抽象,下面通过一个Demo把这个过程带起来。
代码如下:
image_left = cv2.imread('img/left_01.png')
image_right = cv2.imread('img/right_01.png')
这两张图片如下:
下面拿到关键点和特征向量
def detectAndDescribe(image):
# 将彩色图片转成灰度图
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# SIFT生成器
destriptor = cv2.SIFT_create()
(kps, features) = destriptor.detectAndCompute(image, None)
# 结果转成numpy数组
kps = np.float32([kp.pt for kp in kps])
return (kps, features)
# 检测A, B图片的SIFT特征关键点,得到关键点的表示向量
(kps_left, features_left) = detectAndDescribe(image_left) # kpsA (关键点个数, 坐标) features(关键点个数,向量)
(kps_right, features_right) = detectAndDescribe(image_right)
这里依然是写个函数:
def matchKeyPoints(kpsA, kpsB, featuresA, featuresB, ratio=0.75, reprojThresh=4.0):
# 建立暴力匹配器
matcher = cv2.BFMatcher()
# KNN检测来自两张图片的SIFT特征匹配对
rawMatches = matcher.knnMatch(featuresA, featuresB, 2)
matches = []
for m in rawMatches:
# 当最近距离跟次近距离的比值小于ratio时,保留此配对
# (, ) 表示对于featuresA中每个观测点,得到的最近的来自B中的两个关键点向量
if len(m) == 2 and m[0].distance < m[1].distance * ratio:
# 存储两个点在featuresA, featuresB中的索引值
matches.append([m[0].trainIdx, m[0].queryIdx]) # 这里怎么感觉只用了m[0]也就是最近的那个向量啊,应该没用到次向量
# 这个m[0].trainIdx表示的时该向量在B中的索引位置, m[0].queryIdx表示的时A中的当前关键点的向量索引
# 当筛选后的匹配对大于4时,可以拿来计算视角变换矩阵
if len(matches) > 4:
# 获取匹配对的点坐标
ptsA = np.float32([kpsA[i] for (_, i) in matches])
ptsB = np.float32([kpsB[i] for (i, _) in matches])
# 计算视角变换矩阵 这里就是采样,然后解方程得到变换矩阵的过程
(H, status) = cv2.findHomography(ptsA, ptsB, cv2.RANSAC, reprojThresh)
return (matches, H, status)
# 匹配结果小于4时,返回None
return None
主要逻辑是从图片B中给图片A中的关键点拿最近的K个匹配向量,然后基于规则筛选, 保存好匹配好的关键点的两个索引值,通过索引值取到匹配点的坐标值,有了多于4对的坐标值,就能得到透视变换矩阵。 这里返回的主要就是那个变换矩阵。
# 匹配两张图片的所有特征点,返回匹配结果 注意,这里是变换right这张图像,所以应该是从left找与right中匹配的点,然后去计算right的变换矩阵
M = matchKeyPoints(kps_right, kps_left, features_right, features_left)
if not M:
# 提取匹配结果
(matches, H, status) = M
这里要一定要注意好,到底是对哪张图片做变换,比如给图片B做变换,那么就从A中找与B中特征点匹配的特征向量,求的是让图片B变换的透视矩阵。 这里是对right做变换,所以从left中给right的关键点找匹配点,给right计算透视矩阵,接下来,变换right
# 图片right进行视角变换, result是变换后的图片
result = cv2.warpPerspective(image_right, H, (image_left.shape[1] + image_right.shape[1], image_right.shape[0]))
cv_imshow('result', result)
# 将图片A传入result图片最左端
result[0:image_right.shape[0], 0:image_right.shape[1]] = image_left
结果如下:
这里还可以在图像上画出匹配的向量:
def drawMatches(imageA, imageB, kpsA, kpsB, matches, status):
# 初始化可视化图片,将A、B图左右连接到一起
(hA, wA) = imageA.shape[:2]
(hB, wB) = imageB.shape[:2]
vis = np.zeros((max(hA, hB), wA + wB, 3), dtype="uint8")
vis[0:hA, 0:wA] = imageA
vis[0:hB, wA:] = imageB
# 联合遍历,画出匹配对
for ((trainIdx, queryIdx), s) in zip(matches, status):
# 当点对匹配成功时,画到可视化图上
if s == 1:
# 画出匹配对
ptA = (int(kpsA[queryIdx][0]), int(kpsA[queryIdx][1]))
ptB = (int(kpsB[trainIdx][0]) + wA, int(kpsB[trainIdx][1]))
cv2.line(vis, ptA, ptB, (0, 255, 0), 1)
# 返回可视化结果
return vis
vis = drawMatches(image_left, image_right, kps_left, kps_right, matches, status)
这篇文章主要是整理了在图像处理中重要且常用的找特征点的两个算法Harris和SIFT算法,包括算法的数学原理以及如何使用,然后是整理了下特征匹配与一致性采样算法,这俩东西其实为了后面的透视变换矩阵服务。 最后通过一个全景图拼接的Demo感受了下算法的魅力。
当然这只是冰山下面的一小角哈,因为后面要做一个停车场车位识别的一个项目, 会用到这里面的这些知识。另外就是这些方法依然是普适性的算法,所以也想沉淀下,方便后面回看。
参考: