ibelieveican0 2020-07-05
HOG特征:方向梯度直方图(Histogram of Oriented Gradient,HOG)特征是一种进行物体检测时的特征描述子,它是一种用于表征图像局部梯度方向和梯度强度分布特性的描述符。
特征描述子:计算机不能直接识别图像,所以特征描述子实际上就是图像的数字表示,但它抽取了有用的信息,且丢掉了不相关的信息。通常特征描述子会把一个\(W \times H \times 3\)的图像转换成一个一维的、长度为\(N\)的向量表示。
首先单独说HOG特征的用处:计算图像梯度后,把图片变成只有边缘的图像,如下图所示。在一些颜色信息显然不起作用的图像处理任务中,我们就可以借助HOG特征将颜色信息剔除,留下边缘信息做进一步的处理。
HOG特征能够很好地反映人体或汽车的轮廓,而且对整体光照、亮度等不敏感。
现在比较流行HOG和SVM组合使用,在行人检测、车辆检测、跟踪方面有比较广泛的运用。
传统的SVM可以利用训练数据生成非常精确的二分类器,也广泛用于解决一些计算机视觉方面的任务。因此两者结合之后,在检测方面具有良好的性能和鲁棒性。
具体两者是怎么结合的,在之后会详细进行介绍。
在介绍HOG特征之前,我们应该先对图像梯度有所了解。
图像梯度计算的是图像变化的速度,对于图像的边缘部分,其灰度值变化较大,梯度值也较大;相反,对于图像中比较平滑的部分,其灰度值变化较小,相应的梯度值也较小。一般情况下,图像梯度计算的是图像的边缘信息。
严格地说,图像梯度计算需要求导数,但是图像梯度一般通过计算像素值的差来计算梯度的近似值。
比如下面这张图像边界示意图所示:
针对左图,通过垂直方向的线条A和线条B的位置,可以计算图像水平方向的边界:
针对右图,通过水平方向的线条A和线条B的位置,可以计算图像垂直方向的边界:
根据大学数学基础可以知道,图像的梯度也有自己的方向,比如上面这张图包含的垂直、水平方向。
所以如果我们现在要计算某个像素点的方向梯度,可以先计算它在垂直和水平方向的梯度,进而得到它的最终梯度值。
上图为HOG特征算法的基本流程,其具体过程如下:
下面,将给出算法流程中的每个步骤,结合实例进行具体介绍:
由于颜色信息作用不大,通常转化为灰度图。 对于彩色图像,将RGB分量转化成灰度图像,其转化公式为:
其实图像灰度化是可选操作,因为灰度图像和彩色图像都可以用于计算梯度图。
对于彩色图像而言,先对三通道颜色值分别计算梯度,然后取梯度值最大的那个作为该像素的梯度。
所以,首先我们读取图片,并将图片进行灰度处理。(由于原图太大了,原图缩小成了原来的20%)
import cv2 as cv imgpath = ‘../img/cv_5.jpeg‘ # 图片路径 img = cv.imread(imgpath) scale_percent = 20 # 缩小成20% width = int(img.shape[1] * scale_percent / 100) height = int(img.shape[0] * scale_percent / 100) dim = (width, height) img = cv.resize(img, dim, interpolation=cv.INTER_LINEAR) gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY) cv.imshow(‘img‘, img) cv.waitKey(0) cv.destroyAllWindows()
Gamma变换就是用来图像增强,其提升了暗部细节,简单来说就是通过非线性变换,让图像从暴光强度的线性响应变得更接近人眼感受的响应,即将漂白(相机曝光)或过暗(曝光不足)的图片,进行矫正。
其输出图像灰度值与输入图像灰度值呈指数关系:
这个指数即为Gamma。
注意这个\(V_{in}\) 的取值范围为0~1。(之前以为是0-255,纠结了好久)
经过Gamma变换后的输入和输出图像灰度值关系如图1所示:
横坐标是输入灰度值,纵坐标是输出灰度值,蓝色曲线是gamma值小于1时的输入输出关系,红色曲线是gamma值大于1时的输入输出关系。
可以观察到,当gamma值小于1时(蓝色曲线),图像的整体亮度值得到提升,同时低灰度处的对比度得到增加,更利于分辩低灰度值时的图像细节。
?? : 所以可以总结如下:
\(\gamma > 1\),较亮的区域灰度被拉伸,较暗的区域灰度被压缩的更暗,图像整体变暗;
$ \gamma<1$,较亮的区域灰度被压缩,较暗的区域灰度被拉伸的较亮,图像整体变亮;
所以,在HOG特征计算中,当图像光照不均匀时,可以通过Gamma校正,将图像整体亮度提高或降低。
通常我们取\(\gamma = 0.5\)。
所以上述的灰度图进行Gamma校正后,图片在亮度上明显发生了变化——亮度提升:
img = np.power(np.float32(gray) / 255.0, 1/2)
先分别计算每个像素点的横坐标和纵坐标方向上的梯度值,并据此结果计算出最终梯度方向值。
求导操作不仅能够捕获轮廓,人影和一些纹理信息,还能进一步弱化光照的影响。
图像中像素点(x,y)的梯度为:
式中的\(G_x(x,y), G_y(x, y)\)分别为图像在水平方向和垂直方向上的梯度值,而\(H(x,y)\)表示的是位置\((x, y)\)上的像素值。
在这里我们可以使用Sobel算子来计算图像在水平方向和垂直方向上的偏导数近似值,滤波核处理图像的速度会加快。下图为Sobel算子的示例。
??: 之前问过助教,据说Sobel算子对X轴、Y轴实际上是不做要求的,而是注重于计算水平或者是垂直方向的梯度值。所以左侧的\(3 \times 3\)矩阵,是计算水平方向的梯度(理论上可以理解为是X轴),而右侧的计算的是垂直方向上的梯度。
现计算水平方向偏导数的近似值:
将Sobel算子与原始图像img进行卷积操作,可以计算水平方向上的像素值变化情况。例如,当Sobel算子的大小为\(3 \times 3\)时,水平方向偏导数\(G_x\)的计算方式为
上式中,img是原始图像,假设其中有9个像素点,如下图所示:
如果要计算像素点P5的水平方向偏导数\(P5_x\),则需要利用Sobel算子及P5邻域点,所使用的公式为
即用像素点\(P_5\)右侧像素点的像素值减去其左侧像素点的像素值,这符合公式(4)的计算。
其中,中间像素点P4和P6距离像素点P5比较近,因此它俩占的权重会更高一些,值为2,其他权重差值为1。
那么,我们使用cv.Sobel()
方法求得水平方向的梯度:
grad_x = cv.Sobel(img, cv.CV_32F, 1, 0, ksize = 3) # cv.CV_32F 可以防止因为相减后的值为负数造成的影响 # 1, 0 表示计算的是水平方向梯度; 0, 1表示垂直 # ksize是Sobel核的大小
关于该方法的具体参数含义将另行介绍,此处不做过多说明。
得到图像如下:
那么同样的,计算垂直方向上的梯度时,可以得到结果:
从上面的图像中可以看到x轴方向的梯度主要凸显了垂直方向的线条,y轴方向的梯度凸显了水平方向的梯度,梯度幅值凸显了像素值有剧烈变化的地方。
(??:图像的原点是图片的左上角,x轴是水平的,y轴是垂直的)
图像的梯度去掉了很多不必要的信息(比如不变的背景色),加重了轮廓。换句话说,你可以从梯度的图像中还是可以轻而易举的发现有个人。
最后将两方向梯度进行平方和计算后再开方,得到最后梯度结果,并另外计算其梯度方向,公式如下:
然后使用cv.cartToPolar()
来计算合梯度的幅值和方向(角度)。
# 计算合梯度的幅值和方向 grad_xy, angle = cv.cartToPolar(grad_x, grad_y, angleInDegrees=True) cv.imshow(grad_xy)
可以发现方向梯度结合后,得到图像为:
也可以使用其他梯度算子来替换Sobel算子,比如大部分博客写的是:水平边缘算子\([-1, 0, 1]\) ;垂直边缘算子\([-1, 0, 1]^T\)。
首先明白几个在HOG特征求取过程中需要用到的单位,根据下图具体解释:
我们使用一个滑动窗口(window)按照从左到右、从上至下的顺序对给定的待检测图片(img)进行处理。而window需要包含你要检测的整个目标的一个窗口。
假如现在要检测行人,你就需要用这个window把行人给框住。因为window是整个HOG计算的最顶层,也就是说我们每次计算HOG特征,计算的并不是整幅图像的,而是一个window范围内的HOG特征。
其实window可以是任意尺寸的(arbitrary的),这里使用官方推荐的 64 x 128。
设定block是window中的一个滑框。
window的长和宽最好是block长宽的整数倍, 这里依旧使用官方推荐的16 x 16。
设定最小单位cell,它是block的下一级了,其中cell是不可滑动的。
cell的单位依旧是官方推荐的8 x 8。
所以,在一个滑动窗口中,最小单位是Cell,4个Cell组成了一个Block。
设定直方图的区间数为9,将0-180度分成9等份,称为9个bins,分别是0,20,40...160。
?? :角度的范围介于0到180度之间,而不是0到360度, 这被称为“无符号”梯度,因为两个完全相反的方向被认为是相同的。
那么,我们再对一张图像阐述详细处理过程吧:
以预先设定的Cell和Block来划分window,那么整个window最后就被划分为\(8 \times 16\)个\(8 \times 8\)的Cell单元,并为每个Cell计算梯度直方图。在计算Cell的梯度过程中,总共包含了\(8 \times 8 \times 2 = 128\)个值,因为每个像素包括梯度的大小和方向。
那么,我们先来看看每个8*8的cell的梯度都是什么样子:
中间这个图的箭头是梯度的方向,长度是梯度的大小,可以发现箭头的指向方向是像素强度都变化方向,幅值是强度变化的大小。
右边的梯度方向矩阵中可以看到角度是0-180度,不是0-360度,这种被称之为"无符号"梯度("unsigned" gradients)因为一个梯度和它的负数是用同一个数字表示的,也就是说一个梯度的箭头以及它旋转180度之后的箭头方向被认为是一样的。那为什么不用0-360度的表示呢?在事件中发现unsigned gradients比signed gradients在行人检测任务中效果更好。一些HOG的实现中可以让你指定signed gradients。
下一步就是为这些8*8的网格创建直方图,直方图包含了9个bin来对应0,20,40,...160这些角度。
下面这张图解释了这个过程。我们用了上一张图里面的那个网格的梯度幅值和方向。根据方向选择用哪个bin, 根据副值来确定这个bin的大小。先来看蓝色圈圈出来的像素点,它的角度是80,副值是2,所以它在第五个bin里面加了2,再来看红色的圈圈出来的像素点,它的角度是10,副值是4,因为角度10介于0-20度的中间(正好一半),所以把幅值一分为二地放到0和20两个bin里面去。
这里有个细节要注意,如果一个角度大于160度,也就是在160-180度之间,我们知道这里角度0,180度是一样的,所以在下面这个例子里,像素的角度为165度的时候,要把幅值按照比例放到0和160的bin里面去。
把这8*8的cell里面所有的像素点都分别加到这9个bin里面去,就构建了一个9-bin的直方图,上面的网格对应的直方图如下:
疑问:为什么我们要分Cell呢?
答:这是因为如果对一整张梯度图逐像素计算,其中的有效特征是非常稀疏的,不但运算量大,而且会受到一些噪声干扰。于是我们就使用局部特征描述符来表示一个更紧凑的特征,计算这种局部cell上的梯度直方图更具鲁棒性。
上面的步骤中,我们创建了基于图片的梯度直方图,但是一个图片的梯度对于整张图片的光线会很敏感。如果你把所有的像素点都除以2,那么梯度的幅值也会减半,那么直方图里面的值也会减半,所以这样并不能消除光线的影响。
所以理想情况下,我们希望我们的特征描述子可以和光线变换无关,所以我们就想让我们的直方图归一化从而不受光线变化影响,能够进一步地对光照、阴影和边缘进行压缩。
我们知道,block是由多个cell所组成的,典型的组合方式是 2x2 个 cell 组成成一个 block,每个 cell 上面都有一个 9 维的表示直方图大小的向量,那么一个block的拼接向量上就有 2x2x9 = 36维的向量。
疑问:为什么我们要分Block呢?
答:这是因为,虽然我们已经为图像的8×8单元创建了HOG特征,但是图像的梯度对整体光照很敏感。这意味着对于特定的图像,图像的某些部分与其他部分相比会非常明亮。
?? 由于图像中光照情况和背景的变化多样,梯度值的变化范围会比较大,因而良好的特征标准化对于检测率的提高相当重要。
?? 相邻block之间是有重叠的,这样有效的利用了相邻像素信息,对检测结果有很大的帮助。
规范化的方法有多种可选:
先考虑对向量用L2归一化的步骤是:
再把\(V\)中每一个元素除以146.64得到\([0.87,0.43,0.22]\),得到最后结果。
所以,经过上述步骤,我们成功将4个Cell的直方图进行拼接,形成了一个Block归一化后的直方图。
最后一步就是将检测窗口中所有重叠的块进行HOG特征的收集,那么为了计算这整个window的特征向量,需要把36*1的向量全部合并组成一个巨大的向量。向量的大小可以这么计算:
再将最终的特征向量供分类器使用。
# coding:utf-8 """ @Author : sonata @time : 2020-07-04 11:34 @File : HOG.py @Software: PyCharm @Role : task04 HOG特征描述算子 """ import cv2 as cv import numpy as np imgpath = ‘../img/cv_5.jpeg‘ img = cv.imread(imgpath) hog = cv.HOGDescriptor() hog.setSVMDetector(cv.HOGDescriptor_getDefaultPeopleDetector()) (rects, weights) = hog.detectMultiScale(img, winStride=(2, 4), padding=(8, 8), scale=1.2, useMeanshiftGrouping=False) for (x, y, w, h) in rects: cv.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) cv.imwrite("image", img) cv.imshow(‘image‘, img) cv.waitKey(0) cv.destroyAllWindows()
可以得到最后结果如下图所示:
这是我第二次参加Datawhale的组队学习活动,这次任务结束后,CV下的学习也就彻底结束了。
这16天的学习让我感到充实,并且又探索出了新的学习方法。这次加入的队伍也依旧优秀,每天能在群里唠唠嗑,感觉真的很好~
希望16期的组队活动能够如约而至,而到了那时,我能比现在更进步一点点!