zoukankan      html  css  js  c++  java
  • 【Datawhale】计算机视觉下 —— HOG特征描述算子

    前言

    概念介绍

    HOG特征:方向梯度直方图(Histogram of Oriented Gradient,HOG)特征是一种进行物体检测时的特征描述子,它是一种用于表征图像局部梯度方向和梯度强度分布特性的描述符。

    特征描述子:计算机不能直接识别图像,所以特征描述子实际上就是图像的数字表示,但它抽取了有用的信息,且丢掉了不相关的信息。通常特征描述子会把一个(W imes H imes 3)的图像转换成一个一维的、长度为(N)的向量表示。

    适用场景

    首先单独说HOG特征的用处:计算图像梯度后,把图片变成只有边缘的图像,如下图所示。在一些颜色信息显然不起作用的图像处理任务中,我们就可以借助HOG特征将颜色信息剔除,留下边缘信息做进一步的处理。

    HOG特征能够很好地反映人体或汽车的轮廓,而且对整体光照、亮度等不敏感。

    现在比较流行HOG和SVM组合使用,在行人检测、车辆检测、跟踪方面有比较广泛的运用。

    传统的SVM可以利用训练数据生成非常精确的二分类器,也广泛用于解决一些计算机视觉方面的任务。因此两者结合之后,在检测方面具有良好的性能和鲁棒性。

    具体两者是怎么结合的,在之后会详细进行介绍。

    图像梯度

    在介绍HOG特征之前,我们应该先对图像梯度有所了解。

    图像梯度计算的是图像变化的速度,对于图像的边缘部分,其灰度值变化较大,梯度值也较大;相反,对于图像中比较平滑的部分,其灰度值变化较小,相应的梯度值也较小。一般情况下,图像梯度计算的是图像的边缘信息。

    严格地说,图像梯度计算需要求导数,但是图像梯度一般通过计算像素值的差来计算梯度的近似值。

    比如下面这张图像边界示意图所示:

    针对左图,通过垂直方向的线条A和线条B的位置,可以计算图像水平方向的边界:

    • 对于线条A和线条B,它们的右侧像素值和左侧像素值的差值不为0,所以它们属于边界。
    • 对于其余位置的线条而言,它们的左右两侧的像素值差值为0,所以不是边界。

    针对右图,通过水平方向的线条A和线条B的位置,可以计算图像垂直方向的边界:

    • 对于线条A和线条B,它们的上下两侧的像素差值为零,因此是边界。
    • 对于其他位置的线条而言,它们的上下两侧的像素值差值为0,所以不是边界。

    根据大学数学基础可以知道,图像的梯度也有自己的方向,比如上面这张图包含的垂直、水平方向。

    所以如果我们现在要计算某个像素点的方向梯度,可以先计算它在垂直和水平方向的梯度,进而得到它的最终梯度值。

    HOG特征算法

    img

    上图为HOG特征算法的基本流程,其具体过程如下:

    1. 输入待处理图像,进行标准化操作,包括图像缩放、图像灰度化、Gamma校正。
    2. 预处理完毕后,便可以计算每个像素点的图像梯度。
    3. 确定图像分割单位Cell大小,为每个细胞单元构建梯度方向直方图。
    4. 把细胞单元组合成大的块(block),块内归一化梯度直方图。
    5. 收集图像的HOG特征,得到最终的特征向量,并进行之后的分类使用。

    下面,将给出算法流程中的每个步骤,结合实例进行具体介绍:

    图像灰度化

    由于颜色信息作用不大,通常转化为灰度图。 对于彩色图像,将RGB分量转化成灰度图像,其转化公式为:

    [gray = 0.3 * R + 0.59 * G + 0.11 * B ]

    其实图像灰度化是可选操作,因为灰度图像和彩色图像都可以用于计算梯度图。

    对于彩色图像而言,先对三通道颜色值分别计算梯度,然后取梯度值最大的那个作为该像素的梯度

    所以,首先我们读取图片,并将图片进行灰度处理。(由于原图太大了,原图缩小成了原来的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_{out} = CV_{in}^gamma ]

    这个指数即为Gamma。

    注意这个(V_{in}) 的取值范围为0~1。(之前以为是0-255,纠结了好久)

    经过Gamma变换后的输入和输出图像灰度值关系如图1所示:

    横坐标是输入灰度值,纵坐标是输出灰度值,蓝色曲线是gamma值小于1时的输入输出关系,红色曲线是gamma值大于1时的输入输出关系。

    可以观察到,当gamma值小于1时(蓝色曲线),图像的整体亮度值得到提升,同时低灰度处的对比度得到增加,更利于分辩低灰度值时的图像细节。

    在这里插入图片描述

    ⚠️ : 所以可以总结如下:
    (gamma > 1),较亮的区域灰度被拉伸,较暗的区域灰度被压缩的更暗,图像整体变暗;
    $ gamma<1$,较亮的区域灰度被压缩,较暗的区域灰度被拉伸的较亮,图像整体变亮;

    所以,在HOG特征计算中,当图像光照不均匀时,可以通过Gamma校正,将图像整体亮度提高或降低。

    [Y(x, y) = I(x, y) ^ gamma ]

    通常我们取(gamma = 0.5)

    所以上述的灰度图进行Gamma校正后,图片在亮度上明显发生了变化——亮度提升:

    img = np.power(np.float32(gray) / 255.0, 1/2)
    

    计算每个像素点的梯度值

    先分别计算每个像素点的横坐标和纵坐标方向上的梯度值,并据此结果计算出最终梯度方向值。

    求导操作不仅能够捕获轮廓,人影和一些纹理信息,还能进一步弱化光照的影响。

    图像中像素点(x,y)的梯度为:

    [G_x(x, y) = H(x+1, y) - H(x-1, y) \ G_y(x, y) = H(x, y + 1) - H(x, y-1) ]

    式中的(G_x(x,y), G_y(x, y))分别为图像在水平方向和垂直方向上的梯度值,而(H(x,y))表示的是位置((x, y))上的像素值。

    在这里我们可以使用Sobel算子来计算图像在水平方向和垂直方向上的偏导数近似值,滤波核处理图像的速度会加快。下图为Sobel算子的示例。

    ⚠️: 之前问过助教,据说Sobel算子对X轴、Y轴实际上是不做要求的,而是注重于计算水平或者是垂直方向的梯度值。所以左侧的(3 imes 3)矩阵,是计算水平方向的梯度(理论上可以理解为是X轴),而右侧的计算的是垂直方向上的梯度。

    现计算水平方向偏导数的近似值:

    将Sobel算子与原始图像img进行卷积操作,可以计算水平方向上的像素值变化情况。例如,当Sobel算子的大小为(3 imes 3)时,水平方向偏导数(G_x)的计算方式为

    [G_x = egin{bmatrix} -1 & 0 & 1 \ -2 & 0 & 2 \ -1 & 0 & 1 end{bmatrix} imes img ]

    上式中,img是原始图像,假设其中有9个像素点,如下图所示:

    如果要计算像素点P5的水平方向偏导数(P5_x),则需要利用Sobel算子及P5邻域点,所使用的公式为

    [P5_x = (P3 - P1) + 2 imes (P6 - P4) + (P9 - P7) ]

    即用像素点(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轴是垂直的)

    图像的梯度去掉了很多不必要的信息(比如不变的背景色),加重了轮廓。换句话说,你可以从梯度的图像中还是可以轻而易举的发现有个人。

    最后将两方向梯度进行平方和计算后再开方,得到最后梯度结果,并另外计算其梯度方向,公式如下:

    [G(x,y) = sqrt{G_x(x, y)^2 + G_y(x, y)^2} \ alpha(x, y) = arctan(frac{G_y(x,y)}{G_x(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度, 这被称为“无符号”梯度,因为两个完全相反的方向被认为是相同的。

    img

    那么,我们再对一张图像阐述详细处理过程吧:

    以预先设定的Cell和Block来划分window,那么整个window最后就被划分为(8 imes 16)(8 imes 8)的Cell单元,并为每个Cell计算梯度直方图。在计算Cell的梯度过程中,总共包含了(8 imes 8 imes 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上的梯度直方图更具鲁棒性。

    Block归一化

    上面的步骤中,我们创建了基于图片的梯度直方图,但是一个图片的梯度对于整张图片的光线会很敏感。如果你把所有的像素点都除以2,那么梯度的幅值也会减半,那么直方图里面的值也会减半,所以这样并不能消除光线的影响。

    所以理想情况下,我们希望我们的特征描述子可以和光线变换无关,所以我们就想让我们的直方图归一化从而不受光线变化影响,能够进一步地对光照、阴影和边缘进行压缩。

    我们知道,block是由多个cell所组成的,典型的组合方式是 2x2 个 cell 组成成一个 block,每个 cell 上面都有一个 9 维的表示直方图大小的向量,那么一个block的拼接向量上就有 2x2x9 = 36维的向量。

    疑问:为什么我们要分Block呢?

    答:这是因为,虽然我们已经为图像的8×8单元创建了HOG特征,但是图像的梯度对整体光照很敏感。这意味着对于特定的图像,图像的某些部分与其他部分相比会非常明亮。

    ⚠️ 由于图像中光照情况和背景的变化多样,梯度值的变化范围会比较大,因而良好的特征标准化对于检测率的提高相当重要。

    ⚠️ 相邻block之间是有重叠的,这样有效的利用了相邻像素信息,对检测结果有很大的帮助。

    规范化的方法有多种可选:

    先考虑对向量用L2归一化的步骤是:

    [V = [128, 64, 32] \ [(128^2) + (64^2) + (32^2) ]^{0.5}=146.64 ]

    再把(V)中每一个元素除以146.64得到([0.87,0.43,0.22]),得到最后结果。

    所以,经过上述步骤,我们成功将4个Cell的直方图进行拼接,形成了一个Block归一化后的直方图。

    计算HOG特征向量

    最后一步就是将检测窗口中所有重叠的块进行HOG特征的收集,那么为了计算这整个window的特征向量,需要把36*1的向量全部合并组成一个巨大的向量。向量的大小可以这么计算:

    1. 我们有多少个(16 imes 16) 的块?水平7个,垂直15个,总共有(7 imes 15=105)次移动。
    2. 每个(16 imes 16) 的块代表了(36 imes 1) 的向量。所以把他们放在一起也就是$ 36 imes 105=3780$维向量。

    再将最终的特征向量供分类器使用。

    openCV实现与可视化

    # 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期的组队活动能够如约而至,而到了那时,我能比现在更进步一点点!

    参考资料

    1. https://blog.csdn.net/hujingshuang/article/details/47337707
    2. https://blog.csdn.net/coming_is_winter/article/details/72850511
    3. https://blog.csdn.net/Pierce_KK/article/details/89501308
    4. https://blog.csdn.net/zhazhiqiang/article/details/21047207
    5. https://www.cnblogs.com/tornadomeet/archive/2012/08/15/2640754.html
    6. https://blog.csdn.net/zouxy09/article/details/7929348
    7. https://blog.csdn.net/krais_wk/article/details/81119237
    8. https://www.jianshu.com/p/395f0582c5f7
  • 相关阅读:
    windows 2012 r2怎么进入本地组策略
    ESXI | ESXI6.7如何在网页端添加用户并且赋予不同的权限
    exsi 6.7u2 不能向winows虚拟机发送ctrl+alt+del
    正确安装Windows server 2012 r2的方法
    gitkraken生成ssh keys并连接git
    GitKraken 快速配置 SSH Key
    寒假学习进度六
    寒假学习进度五——活动之间的跳转以及数据的传递
    寒假学习进度四(解决Android studio的com.android.support.v4.view.ViewPager报错问题)
    寒假学习进度三——安卓的一些基本组件
  • 原文地址:https://www.cnblogs.com/recoverableTi/p/13246058.html
Copyright © 2011-2022 走看看