OpenCV入门学习笔记之常用的图像处理操作

1. 写在前面

由于后面的工作会偏cv一些,所以这段时间抓住最后毕业的小尾巴抽出时间来想开一条cv的自学线路,和当时入门推荐差不多,cv这里也是简单的梳理了目前的一些方向以及各个方向常用的一些知识,然后各个方向进行学习和突破。 当然作为初学者,我依然还是从经典的模型开始,因为我发现,读论文学模型,然后做相关项目是比较快速的入门方式,但是呢, 这个过程中,我突然发现,对于cv来讲,基础的图像预处理操作也是非常重要的一大块,虽然做一些重大项目还是以大规模的深度学习模型为主,但是,如何让模型能更好的学习到图像的特征,图像的相关特征工程,也是需要非常多的技术的,我觉得这是和推荐或者结构化数据不太一样的地方,图像数据有时候本身很复杂,同一张图片在不同的清晰度,颜色,亮度,轮廓等不同情况下,可能最后的模型识别效果会相差很大,所以对图像进行预处理操作,做细致的特征工程也很重要,但图像的特征工程方面,还需要一些针对图像的专门预处理的方式,比如图像的平滑,阈值,增强,形态学,边缘检测,轮廓检测,模板匹配,滤波等,而这些技术如果能使用的好,就能很好把图像的特征给表现出来,能对后面模型的识别起到很大的作用,甚至可能模型都不用很复杂。这就是我要学习这块的原因。

OpenCV是一个专门针对图像处理的计算机视觉的一个工具包,里面包含了大量的图像预处理操作,这次我从OpenCV开始学习,跟着唐宇迪老师的OpenCV入门教程学习的,这个教程是先讲图像的一些基本操作,比如变换,阈值,平滑,形态学,算子,边缘检测,金字塔,轮廓,模板匹配以及傅里叶变换等,然后再通过几个实践项目来把前面知识融合起来,这正符合我的学习习惯。所以觉得还不错,目前到了项目实战部分,但由于前面的这些知识太多,好多都忘了,于是,就想先通过一篇文章,把之前学习到的这些东西总结一下,然后再通过后面的项目把知识融会一下。

主要内容

  • 图像的基本操作(数据读取,通道分割,边界填充,图像融合,视频读取, 阈值操作)
  • 图像的平滑与滤波(均值滤波,中值滤波,高斯滤波,低高通滤波与傅里叶)
  • 图像的形态学操作(腐蚀,膨胀,开闭操作,黑帽顶帽等)
  • 图像的算子操作(Sobel算子,Scharr算子,Laplacian算子)
  • Canny边缘检测算法
  • 图像金字塔(高斯金字塔,拉普拉斯金字塔)
  • 图像轮廓检测(绘制,特征提取,近似等)
  • 图像模板匹配
  • 图像直方图与均衡化

Ok, let’s go!

2. 图像的基本操作

2.1 图像读取

首先是图像的读取操作, opencv提供了cv2.imread函数, 帮助我们读取一张图片,主要有两种读取方式:

  • cv2.IMREAD_COLOR: 默认的,读取彩色图像
  • cv2.IMREAD_GRAYSCALE: 读取灰度图像

下面是代码示例:

def cv_show(name, img, wait_key=0):
    # 图像的展示, 也可以创建多个窗口
    cv2.imshow(name, img)
    # 展示时间, 毫秒级, 0表示任意键终止
    cv2.waitKey(wait_key)
    cv2.destroyAllWindows()

img=cv2.imread('img/cat.jpg')

# 读取灰度图
# 单通道的图一定是灰度图, 而三通道的图也可以有灰度模式,即R,G,B三个通道的亮度一致,即RGB三通道的值改成一样? 那么改成啥呢?
# 这里其实是要做灰度转换的, 涉及到灰度转换算法了
img_gray = cv2.imread("img/cat.jpg", cv2.IMREAD_GRAYSCALE)

img.shape, img.dtype等

# ndarry的形式,所以支持切片处理, 拿到部分像素值
img_part = img_gray[0:200, 0:200]
cv_show('cat', img_part)

这里读取的img其实就是numpy数组,所以np数组的一些属性这里的img都会有,并且既然是numpy数组,也能够进行基本的四则运算来改变图像的像素值大小。

2.2 颜色通道

cv2.split函数把图片的三个通道划分开

b, g, r = cv2.split(img)
# 三个通道也可以融合起来
img_merge = cv2.merge((b, g, r))

# 只保留某个通道
# 只保留某个通道, 这里写个函数
def get_channel_img(img, stay_channel=0):
    # B, G, R 是 0, 1, 2
    cur_img = img.copy()
    for i in range(3):
        if i != stay_channel:
            cur_img[:, :, i] = 0
    return cur_img
blue_channel_img = get_channel_img(img, 0)
green_channel_img = get_channel_img(img, 1)
red_channel_img = get_channel_img(img, 2)

这里补充点灰度图,单通道,三通道的知识:

  • 单通道: 维度数2, 或者第三维是1, 也称灰度图,理解成黑白图
  • 图像每个像素点只能有一个值表示颜色, 值在0-255, 这样的图是单通道值,或者灰度图, 0是全黑, 255全白
  • 三通道图是每个像素点3个值表示颜色, 比如GRB, 如果某个位置三个通道值相同, 图片也是黑白色,这种是彩色图的灰度模式
  • 判断一张灰度图是单通道还是三通道黑白颜色的图, 看图片属性的位深度, 单通道的是8, 三通道黑白是24

如果想将单通道转成三通道,可以用下面的函数:

# 单通道转成三通道
r2img = cv2.cvtColor(img_gray, cv2.COLOR_GRAY2RGB)
cv_show('img', r2img)

2.3 边界填充

这里用的cv2.copyMakeBorder函数。这里就类似于图像的padding操作, 不过是在预处理过程中,我们自定义方式进行的padding,具体使用示例如下:

# 上下左右分别填充的大小
top_pad, bottom_pad, left_pad, right_pad = 50, 50, 50, 50

# 有不同的填充方式
def img_pad(img, top_pad, bottom_pad, left_pad, right_pad, mode=cv2.BORDER_REPLICATE, value=0):
    if mode == cv2.BORDER_CONSTANT:
        pad_img = cv2.copyMakeBorder(img, top_pad, bottom_pad, left_pad, right_pad, borderType=mode, value=value)
    else:
        pad_img = cv2.copyMakeBorder(img, top_pad, bottom_pad, left_pad, right_pad, borderType=mode)
    return pad_img

replicate = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_REPLICATE)
reflect = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_REFLECT)
reflect101 = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_REFLECT_101)
wrap = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_WRAP)
constant = img_pad(img, top_pad, bottom_pad, left_pad, right_pad, cv2.BORDER_CONSTANT, value=0)

这里面的几个属性:

  • cv2.BORDER_REPLICATE: 复制法, 复制最边缘像素
  • cv2.BORDER_REFLECE: 反射法,对感兴趣的图像中的像素在两边进行复制, 比如原图abcdefg, 那么两边填充之后,fedcba|abcdefg|gfe
  • cv2.BORDER_REFLECT_101: 反射法, 以最边缘像素为轴, 对称 gfedcb|abcdefg|fedcba
  • cv2.BORDER_WRAP: 外包装法 cdefg|abcdefg|abcdef
  • cv2.BORDER_CONSTANT: 常数值填充

2.4 图像融合

图像融合的需求是两张图片融合成一张,本质上还是像素之间的操作,但前提: shape必须一致,并且我们还可以给不同图片加权,可以用cv2.add_weight函数。

img_dog = cv2.imread('img/dog.jpg')
# 把狗和猫的成分按照比例融合在一起
# img + img_dog  这个直接相加不行 ,因为shape不一样

# 转成一样的先
img_dog = cv2.resize(img_dog, (666, 548))  # 这里注意下后面参数, 这里的是先指定宽, 再指定高
img_dog.shape
# resize 还能这么玩  cv2.resize(img, (0, 0), fx=4, fy=1)  这个是不知道具体大小,而是让x,y轴按照倍数伸长或者缩短

# 进行融合
res = cv2.addWeighted(img, 0.4, img_dog, 0.6, 0)  # aplha * img + beta * img_dog + b

2.5 视频读取

OpenCV可以读取视频的,使用cv2.VideoCaptuer函数, 视频是很多帧的图片组成,所以读取视频,本质上还是需要拿到每一帧图像,对图像进行处理。

vc = cv2.VideoCapture("img/test.mp4")  # 从网上现下载了一个视频

# 检测是否打开正确
if vc.isOpened():
    open, frame = vc.read()
else:
    open = False

# 读取完视频后,将视频进行拆分成每一帧进行操作
while open:
    ret, frame = vc.read()
    # 如果当前没有帧,停掉
    if frame is None:
        break
    if ret == True:
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        cv2.imshow("result", gray)
        if cv2.waitKey(10) & 0xFF == 27:
            break
vc.release()
cv2.destroyAllWindows()

2.6 图像的阈值操作

阈值操作,就是对某张图片,根据给定的阈值进行像素改变,比如大于某个阈值或者小于某个阈值的像素点,我们给他改成多少。 这里使用的cv2.threshold函数, 里面4个参数:

ret, dst = cv2.threshold(src, thresh, maxval, type)

- src: 输入图像,只能是单通道图像,通常来说是灰度图
- dst: 输出图
- thresh: 阈值, 大于多少的或者小于多少的进行处理, 一般是127
- maxval: 当像素值超过了阈值(或者小于阈值的,根据type来决定)所赋予的值, 一般255
- type: 二值化操作类型,包括下面五种类型:
    - cv2.THRESH_BINARY: 超过阈值部分取maxval,否则取0
    - cv2.THRESH_BINAEY_INV: 上面的相反
    - cv2.THRESH_TRUNC: 大于阈值部分设置为阈值,否则不变
    - cv2.THRESH_TOZERO: 大于阈值部分的不改变,否则设为0
    - cv2.THRESH_TOZERO_INV: 上面这个反转

这里可以看一个应用示例, 感受下效果:

# 数据读取 opencv读取的格式是BGR, 不是RGB,所以图片展示的时候,最好是使用opencv自带的工具函数
import cv2
import matplotlib.pyplot as plt
img_gray = cv2.imread("img/cat.jpg", cv2.IMREAD_GRAYSCALE)

ret, thresh1 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_BINARY)  # 像素大于127的替换成255, 小于127的换成0 255是最亮,白
ret, thresh2 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_BINARY_INV)  # 像素大于127的换成0, 大于127的换成255
ret, thresh3 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_TRUNC) # 大于127的换成255, 其余不变, 这个相当于截断
ret, thresh4 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_TOZERO)  # 大于127的不改变,小于127的变成0
ret, thresh5 = cv2.threshold(img_gray, 127, 255, cv2.THRESH_TOZERO_INV)   # 大于127的变成0, 小于127的不改变

titles = ['original image', 'binary', 'binary_inv', 'trunc', 'tozero', 'tozero_inv']
images = [img, thresh1, thresh2, thresh3, thresh4, thresh5]

for i in range(6):
    plt.subplot(2, 3, i+1), plt.imshow(images[i], 'gray')
    plt.title(titles[i])
    plt.xticks([]), plt.yticks([])
plt.show()

结果如下:
OpenCV入门学习笔记之常用的图像处理操作_第1张图片

3. 图像的平滑处理

图像的滤波目的一般有两个:

  • 抽出对象的特征作为图像识别的特征模式
  • 适应图像处理要求,消除图像数字化时混入的噪声

对滤波处理的要求也有两个:

  1. 不能损坏图像的轮廓及边缘等重要信息
  2. 要使图像清晰视觉效果好

平滑滤波是低频增强的空间滤波技术,目的主要是模糊或者是消除图像的噪音。

空间域的平滑滤波一般采用简单平均法进行,就是求邻近像元点的平均亮度值。邻域的大小与平滑的效果直接相关,邻域越大平滑的效果越好,但邻域过大,平滑会使边缘信息损失的越大,从而使输出的图像变得模糊,因此需合理选择邻域的大小。

举一个滤波在我们生活中的应用:美颜的磨皮功能。如果将我们脸上坑坑洼洼比作是噪声的话,那么滤波算法就是来取出这些噪声,使我们自拍的皮肤看起来很光滑。

常用的滤波方法: 均值滤波,中值滤波和高斯滤波。

3.1 均值滤波

均值滤波是周围相似点的均值代替中心点的值,边缘部分保持不变,比如下面这个例子:

OpenCV入门学习笔记之常用的图像处理操作_第2张图片
cv2.blur函数可以实现均值滤波,代码示例:

# 均值滤波
# 简单的平均卷积操作
blur = cv2.blur(img, (3, 3))  # 3*3是卷积核的大小

cv_imshow('blur', blur)  # 真实实现的时候,其实这个卷积核的参数是[[1,1,1], [1,1,1], [1,1,1]] * 1/9 用这样的卷积核卷上面图片

缺陷:均值滤波本身存在着固有的缺陷,即它不能很好地保护图像细节,在图像去噪的同时也破坏了图像的细节部分,从而使图像变得模糊,不能很好地去除噪声点。特别是椒盐噪声。

3.2 中值滤波

中值滤波就是中心点和周围一圈的像素点从小到大排序,用中值替换中心点的值,这样的好处就是不会破坏图像的细节部分,能够很好的处理椒盐噪声,因为中心点最终的取值必定是周围那一圈以及它自己的某个值,比较连贯。 而均值滤波的话是所有值的平均,一旦这些点的距离相差很大,一平均,就把原图给破坏掉了。 这也就是为啥均值滤波之后图像变模糊的原因。 但是均值滤波在处理高斯噪声上有用。

OpenCV入门学习笔记之常用的图像处理操作_第3张图片
cv2.medianBlur函数实现中值滤波:

# 中值滤波
img_median = cv2.medianBlur(img, 5)

均值滤波 VS 中值滤波:

  • 两者都可以起到平滑图像,过滤噪声的功能
  • 均值滤波采用线性的方法,平均整个窗口范围内的像素值,均值滤波本身存在固有的缺陷,即不能很好的保护图像细节, 在图像去噪的同时也破坏了图像的细节部分,从而使得图像变得模糊,不能很好去除噪声点。 均值滤波对高斯噪声表现较好,椒盐噪声表现差
  • 中值滤波采用非线性的方法,它在平滑脉冲噪声方面非常有效,同时它可以保护图像尖锐的边缘,选择适当的点来替代污染点的值,所以处理效果好,对椒盐噪声表现较好,对高斯噪声表现较差

3.3 高斯滤波

高斯滤波,就是卷积核参数符合高斯分布, 类比均值滤波比较好理解, 均值滤波的话,是周围点和中心点的均值,所谓求均值,就是所有像素的权重都是一样的。 这样,相当于周围点对中心点的贡献程度一样了,不是很合理。因为从像素上来看,理应周围像素点的数值和中心点越近,权重越大。 所以高斯分布是这样,根据像素点离中心点的权重去分配不同的权重参数。

# 高斯滤波
# 高斯滤波的卷积核里面的参数满足高斯分布,相当于给周围的像素值根据距离中心点的远近加一个权重
aussian = cv2.GaussianBlur(img, (3, 3), 1)
cv_imshow('aussian', aussian)

看下三种滤波对于处理椒盐噪声图片的效果:
OpenCV入门学习笔记之常用的图像处理操作_第4张图片

下面记录一点思考:

  • 上面的滤波器其实就是卷积核, 核上的参数不同,代表着不同的算法
  • 均值滤波周围点的贡献率相同, 而高斯滤波核的参数,与距离中心像素点的距离相关, 越近则权重越大,相当于给周围的像素点加权了
  • 均值滤波不适用于消除椒盐噪声,我是这样理解的, 椒盐噪声的话就是那些白点, 像素值255, 而均值滤波的话,是取周围像素的均值,虽然能平滑这些白色点了,但是像普通的那些点,或者不是噪声的尖锐边缘,用这种方式也一下子平滑掉了, 所以会使得图像变得模糊, 而中值滤波的话, 白色点那里取的是中值, 而其他点也是取中值, 而取中值的意思是必定取它周围那一圈里面的,这样在视觉效果上看更加平滑些,所以从效果上比较清晰

既然上面整理到了简单的滤波操作,为了知识的连贯性,这里再整理一点知识,把后面学习的低高通滤波也拿过来。

3.4 低高通滤波与傅里叶变换

傅里叶变换我们知道是将图像从时域转换到频域的一种非常强大的武器, 时域中的图像数据就是我们看到的一个个像素点组成的图片,而频域中,我们是能够得到灰度分量的频率大小的。可能图像看起来不是很好理解,拿一段语言来描述最贴切:假设我们录了一段语言,里面各种声音混杂,从时域的角度,这就是按照时间序组成的一段音频,那么我们有办法把这段音频的噪声去掉,只保留重要的音频信息吗? 其实这个在时域中非常难做到,而转到频域里面,就会发现这些声音都是一条条频率不同的声音线组成,通过频率就能非常轻松的过滤出某些我们想要的声音。所以通过傅里叶变换操作,我们能非常容易的拿出图像或者声音中我们需要的某些灰度分量了。
OpenCV入门学习笔记之常用的图像处理操作_第5张图片
那么回到图像里面, 同样会存在高频或者低频的灰度分量:

  • 高频: 变化剧烈的灰度分量, 例如图片里面的边界信息, 相当于从一个对象到了另一个对象,像素值变换幅度非常大
  • 低频:变换缓慢的灰度分量,比如一片大海,像素值变换幅度非常小

根据频率进行滤波,主要分为两种:

  • 低通滤波器: 只保留低频, 会使得图像模糊, 高频没了, 使得边界没了
  • 高通滤波器: 只保留高频, 会使得图像细节增强, 即边界锐化的感觉

在opencv中的函数:

  • opencv中主要是cv2.dft()cv2.idft(), 输入图像需要先转成np.float32格式, cv2.dft就是把时域转成了频域,但是为了显示,还需要进行逆变换,即cv2.idft()
  • 得到的结果中频率为0的部分会在左上角, 通常要转换到中心位置, 可以通过shift变换来实现。
  • cv2.dft()返回的结果是双通道的(实部,虚部), 通常还需要转换成图像格式才能展示(0,255)

下面总结下低通滤波和高通滤波的过程:

  1. 原始图像 -> cv2.ift -> 得到频域图像dft -> 低频信息移到中间(np.fft.fftshift) -> 这个结果是双通道(实部+虚部) -> 转成图像格式cv2.magnitude
  2. 找中心点 -> 制作mask掩码矩阵 -> 频域图像dft* mask -> 得到过滤之后的频域图像fshift
  3. 过滤掉高频信息的频域图像fshift -> 低频信息移动到原来位置(np.fft.ifftshift) -> 傅里叶逆变换cv2.idft -> 转成图像格式cv2.magnitude

这就是整个的处理逻辑, 高通滤波和低通滤波只是保留的频率不一样,所以区别在于制作的mask矩阵上,但整体逻辑是一样的, 下面我把高通滤波和低通滤波操作封装成了一个函数:

# 下面是代码总结
def dft_idft(img, threshold=30, mode='low'):
    img_float = np.float32(img)  # 转成float
    # 时域 -> 频域
    dft = cv2.dft(img_float, flags=cv2.DFT_COMPLEX_OUTPUT)
    # 把低频值转到中间位置
    dft_shift = np.fft.fftshift(dft)  # 低频值转到中心位置
    
    # 掩码矩阵
    row, col = img.shape
    c_row, c_col = int(row/2), int(col/2)
    if mode == 'low':
        mask = np.zeros((row, col, 2), np.uint8)
        mask[c_row-threshold:c_row+threshold, c_col-threshold:c_row+threshold] = 1
    elif mode == 'high':
        mask = np.ones((row, col, 2), np.uint8)
        mask[c_row-threshold:c_row+threshold, c_col-threshold:c_row+threshold] = 0
    else:
        print("参数错误")
        return
        
    # 下面就是用这个滤波器对频域的那个图像做操作
    fshift = dft_shift * mask
    f_ishift = np.fft.ifftshift(fshift)   # 之前是低频信息shift到了中间位置,而这个操作是从中间位置变到原来位置
    img_back = cv2.idft(f_ishift)  # 逆变换
    img_back = cv2.magnitude(img_back[:, :, 0], img_back[:, :, 1])
    
    return img_back

# 低通滤波和高通滤波
img_low = dft_idft(img, mode='low')
img_high = dft_idft(img, mode='high')

我们可视化下看看效果:
OpenCV入门学习笔记之常用的图像处理操作_第6张图片
高频滤波和低频滤波的作用就是对原始图像在频域的角度上进行了相关的频率信息过滤,在频域上,图像其实是有层次性的,所以通过将时域的图像转到频域中,我们就很容易得到图像的层次信息,而这样就非常重要过滤高频或者低频信息

4. 图像的形态学操作

所谓形态学操作,我理解,对图像本身进行的一些预处理,比如让图像里面的线条变粗或者变细,重点突出图像的某些部分, 去掉图像中的毛刺,处理一些缺陷等。

图像形态学中常用的两个操作是腐蚀和膨胀,腐蚀一般用于处理毛刺问题,能够让线条或者图形变细。 而膨胀一般是填补一些缺陷, 能够让线条变粗。

4.1 腐蚀操作

腐蚀操作一般处理图像的毛刺,其原理如下:

我们事先定义了一个kernel,是一个 3 ∗ 3 3*3 33的全1的卷积核, 指定这样的kernel之后, 就开始对输入的图像进行卷积操作, 对于当前的 3 ∗ 3 3*3 33区域, 卷积核会这样检测:

  • 如果当前 3 ∗ 3 3 * 3 33的图像区域全白色, 那么当前 3 ∗ 3 3*3 33区域中心点这个像素值不变
  • 如果当前 3 ∗ 3 3 * 3 33的图像区域是白色和黑色都有, 那么当前 3 ∗ 3 3 * 3 33区域中心点就要被腐蚀掉,即变成0

OpenCV入门学习笔记之常用的图像处理操作_第7张图片
看上面这两个中心点,右边的中心点会被腐蚀掉, 并且这个中心点之外的点也都会被腐蚀掉, 因为 3 ∗ 3 3 * 3 33的区域肯定白色和黑色都有。 这样,迭代一次,就能把一些中心点变成黑色, 再迭代一次, 又会有一些点被腐蚀掉。 所以上面腐蚀操作虽然会去掉毛刺,但笔也变细了,而iteration就是控制迭代次数的, 迭代次数越多, 白色被腐蚀的越厉害。 当然,这个腐蚀程度也和卷积核大小有关, 5 ∗ 5 5 * 5 55的估计迭代1次就很细了, 这个也非常好理解,毕竟中心点周围的区域大了, 而只有这里面黑白兼有,这个点就要被腐蚀掉。 所以大的卷积核使得中心点被腐蚀性的概率会更大。

应用示例:

img = cv2.imread('img/dige.png')
cv_imshow('img', img)

kernel = np.ones((3, 3), np.uint8)
erosion_img = cv2.erode(img, kernel, iterations = 1)

cv_imshow('erosion_img', erosion_img)

效果如下:
在这里插入图片描述
这里卷积核越大,或者迭代次数越多, 都会使得右边的线条变细。

4.2 膨胀操作

上面经过腐蚀操作,虽然能把图像里面有的毛刺去掉,但是,线条也会随着腐蚀越多越细, 那么我们能不能让它变得更粗一些呢? 使得图片线条更加丰满? 这就是膨胀做的事情了。

这个原理,也非常好理解:

  • 同样是那个3*3的卷积核卷,只不过对当前中心点处理不一样了:
    • 若当前中心点周围这个区域内黑白都有, 就会把当前值变成白色的
    • 若当前中心点这个区域全是黑色, 不变

迭代次数和卷积核越大,越有利于膨胀操作, 越来越胖

# 下面尝试把上面腐蚀的图片弄成胖一点的
kernel = np.ones((3, 3), np.uint8)
dige_dilate = cv2.dilate(erosion_img, kernel, iterations=1)
cv_imshow('dilate_img', dige_dilate)

# 对线条起了加粗的效果

看下效果:
在这里插入图片描述
通过查资料发现:

  • 腐蚀算法: 用 n ∗ n n*n nn的卷积核扫描每个元素, 用卷积核与其覆盖的二值图像做"与"操作,如果都是1, 那么结果中心元素像素值是1, 否则是0
  • 膨胀算法: 猜一下, n ∗ n n*n nn的卷积核扫描每个元素, 用卷积核与其覆盖的二值图像做或操作, 如果有1, 那么中心元素变成1,如果都是0, 那么才是0

但,全0的卷积核发现并不会改变图像。

4.3 开闭运算

类似于一个管道把腐蚀和膨胀两个操作连接到了一起:

  • 开运算cv2.MORPH_OPEN: 先腐蚀,后膨胀
  • 闭运算cv2.MORPH_CLOSE: 先膨胀,后腐蚀

代码如下:

img = cv2.imread('img/dige.png')
cv_imshow('img', img)

# 开运算: 先腐蚀,再膨胀
kernel = np.ones((5, 5), np.uint8)
opening = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel)
cv_imshow('opening', opening)

这个也能达到去毛刺的效果,就是先腐蚀,再膨胀嘛, 并且这个结果和之前的线条粗细上还差不多。

# 闭运算: 先膨胀后腐蚀
closing = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)
cv_imshow('closing', closing)

这个并没有去掉毛刺, 想想就能明白, 先膨胀,相当于毛刺也变粗了,再腐蚀,怎么腐蚀的掉?

但开闭运算的用处不仅如此, 我看好多项目里面开闭运算都非常有用,能通过这种运算达到重点突出图片中某些部分的效果。

4.4 梯度运算

cv2.MORPH_GRADIENT这个就是膨胀-腐蚀,容易提取到边缘信息。

# 梯度 = 膨胀 - 腐蚀
pie = cv2.imread('img/pie.png')
kernel = np.ones((5, 5), np.uint8)
dilate = cv2.dilate(pie, kernel, iterations=5)
erosion = cv2.erode(pie, kernel, iterations=5)

res = np.hstack((dilate, erosion))
cv_imshow('res', res)

cv_imshow('subtract', dilate-erosion)

gradient = cv2.morphologyEx(pie, cv2.MORPH_GRADIENT, kernel, iterations=5)
cv_imshow('gradient', gradient)

这个梯度运算和直接膨胀-腐蚀效果是一样的结果, 背后实现,我猜其实就是膨胀先执行iterations次,然后腐蚀执行iterations次,然后前面结果减去后面的结果。

4.5 礼帽和黑帽

  • 礼帽操作cv2.MORPH_TOPHAT: 原始输入 - 开运算结果,即原始输入-(先腐蚀,后膨胀)
  • 黑帽操作cv2.MORPH_BLACKHAT: 闭运算结果-原始输入,即(先膨胀,后腐蚀)-原始输入

代码如下:

# 礼帽
img = cv2.imread('img/dige.png')
tophat = cv2.morphologyEx(img, cv2.MORPH_TOPHAT, kernel)
cv_imshow('tophat', tophat)

# 黑帽
img = cv2.imread('img/dige.png')
blackhat = cv2.morphologyEx(img, cv2.MORPH_BLACKHAT, kernel)
cv_imshow('blackhat', blackhat)

这里一开始并没弄明白干啥用的? 参考了这篇文章,有些理解了。

顶帽运算: 取出亮度高的地方

  • 开运算可以消除暗背景下的高亮区域
  • 如果用原图减去开运算结果,就可以得到灰度图中的区域

黑帽运算: 取出亮度低的地方

  • 闭运算可以删除亮区域背景下的暗区域,闭运算减去原图可以得到原图像中灰度较暗的区域

一般用的时候,原始图像转灰度图像,然后进行二值化,然后, 再通过开闭运算, 礼帽和黑帽等操作,就能拿到图像中的想要的区域来了。 所以后面这些组合操作很重要,也很实用。比如车牌号识别,信用卡数字识别等,都会用到这些技术,后面会重点整理。

5. 图像的算子操作

图像的算子操作其实可以帮助我们去找图像的轮廓信息,主要有Sobel算子,Scharr算子以及Laplacian算子,区别在于卷积核参数不一样。

5.1 Sobel算子

Sobel算子, 这感觉依然是两个卷积核进行操作, 原理如下:
在这里插入图片描述
这个东西其实找的是图像的轮廓信息,或者边缘信息,依赖于上面的两个卷积核, 一个是水平方向的,一个是垂直方向的, 实际计算的时候是这样, 3 ∗ 3 3 * 3 33的卷积核覆盖到一个图像区域, 中间点的取值,就是水平的这个卷积核与当前图像卷积结果或者垂直方向的卷积核与当前图像卷积结果。这个就看是从水平上算还是垂直方向上算了。 当然, 这个算子也是保证最终结果是0-255,如果超了,就会进行截断操作。

靠中心越近权重越大,所以这里用了2或者-2表示

  • 只要中心点两边的颜色不一样,这个点这里就有梯度, 就不为0, 中心点两边的亮度相差越大, 使得这个边界就会越明显。
  • 如果中心点两边的颜色一样, 这个点就没有梯度,上面运行就是0, 变成黑色
  • 找边缘的时候, 尽量的先找水平,然后找垂直或者先找垂直然后找水平,别一块找,因为一块找有可能会出现抵消, 导致效果不清晰。-- 这算个小经验

具体函数如下:

dst = cv2.Sobel(src, ddepth, dx, dy, ksize)

ddepth: 图像的深度
dx和dx分别表示水平和垂直方向, 是算x方向还是y方向
ksize是Sobel算子的大小, 一般是3*3

这里直接说常规使用方法, 对于一张图片, 先通过imread的灰度方式读入进来,然后先求水平上的梯度,再求垂直方向上的梯度, 最后加权融合就能找出图片的梯度来。

lena = cv2.imread('img/lena.jpg', cv2.IMREAD_GRAYSCALE)

# 水平做sobel算子
lena_sobelx = cv2.Sobel(lena, cv2.CV_64F, 1, 0, ksize=3)
lena_sobelx = cv2.convertScaleAbs(lena_sobelx)   # 这里是为了如果出现负数不让他截断成0,而是变成它的绝对值
# cv_imshow('lena_x', lena_sobelx)

# 垂直做sobel计算
lena_sobely = cv2.Sobel(lena, cv2.CV_64F, 0, 1, ksize=3)
lena_sobely = cv2.convertScaleAbs(lena_sobely)
# cv_imshow('lena_y', lena_sobely)

# 把这俩合并
lena_margin = cv2.addWeighted(lena_sobelx, 1, lena_sobely, 1, 0)
cv_imshow('lena_margin', lena_margin)

这里一个小经验就是两个方向分开算,要比同时算,最终效果要好, 原因是因为,如果是同时计算的时候, 亮度上会有一些抵消。

# 如果是同时找
lena_test = cv2.Sobel(lena, cv2.CV_64F, 1, 1, ksize=5)
lena_test = cv2.convertScaleAbs(lena_test)
cv_imshow('lena', lena_test)

这个效果不好。对比结果如下:
OpenCV入门学习笔记之常用的图像处理操作_第8张图片
左边原始图像,中间是先找水平边缘,再找垂直边缘然后合并的结果,右边是同时找两个方向边缘的结果。

这里做了两个尝试:

  1. 改变ksize参数, 发现了一个问题,就是这个ksize越大, 最终得到的边界会越粗, 这个考虑了下,是因为,如果ksize越大,说明覆盖的区域会越大,这时候中心点两边的值区别就越可能相差的大,所以中心点像素不为0的概率就会变大,所以会越来越粗
  2. 尝试改变加和的时候, dx和dy的权重, 这个得到的结论就是这俩权重越大,最终的合并边界就会越发亮和明显,其实是显然的。
  3. 最后一个很重要的经验就是, 同时在dx和dy方向计算,不如先计算某一边再计算另一边最终效果来的好。这是因为会发生方向上的效果抵消。

5.2 Scharr算子

这个算子的卷积核长下面这样:
在这里插入图片描述
Scharr算子能够找出更加细致的边界,使得图像的纹理信息更加丰富。使用的方法和上面Sobel是一样的,无非就是函数换了下:

# 这里用scharr算子试一下
img = cv2.imread('img/lena.jpg', cv2.IMREAD_GRAYSCALE)
lena_scharr_x = cv2.Scharr(img, cv2.CV_64F, 1, 0)
lena_scharr_x = cv2.convertScaleAbs(lena_scharr_x)
lena_scharr_y = cv2.Scharr(img, cv2.CV_64F, 0, 1)
lena_scharr_y = cv2.convertScaleAbs(lena_scharr_y)
lena_scharr_xy = cv2.addWeighted(lena_scharr_x, 0.5, lena_scharr_y, 0.5, 0)
cv_imshow('lena_scharr_xy', lena_scharr_xy)

# 可以找出更细致的边界, 我猜的没错 对, 纹理细节这个词用的好

结果如下:
OpenCV入门学习笔记之常用的图像处理操作_第9张图片

5.3 Laplacian算子

卷积核长下面这样:
在这里插入图片描述
一般不会单独使用这个, 常和其他算子组合使用, 对噪声会更加敏感, 但噪音点可能不是边界,所以这个效果单独用不好

这个具体用的时候,会发现是中心点和紧挨着的上下左右四个邻居进行比较, 这里就没有x和y的概念了

laplacian = cv2.Laplacian(img, cv2.CV_64F)
laplacian = cv2.convertScaleAbs(laplacian)
cv_imshow('laplacian', laplacian)

最后对比下各种算子找边缘的效果:
在这里插入图片描述

6. Canny边缘检测算法

Canny边缘检测算法,用于检测图像的边缘信息。主要包括下面的流程:

  1. 使用高斯滤波器,以平滑图像, 滤除噪声 ---- 滤波器去噪
  2. 计算图像中每个像素点的梯度强度和方向 ---- 梯度的强度和方向
  3. 应用非极大值抑制(Non-Maximum Suppression), 以消除边缘检测带来的杂散响应 ---- 梯度小的像素点抑制掉
  4. 应用双阈值(Double-Threshold)检测来确定真实和潜在的边缘 ---- 双阈再检测
  5. 通过抑制孤立的弱边缘最终完成边缘检测

6.1 高斯滤波

这一步是对原始图像处理,去掉一些噪声,让其本身更加平滑, 在Canny算法中用的是高斯滤波器
在这里插入图片描述
其他滤波器,像均值滤波器, 中值滤波器等都比较常用,这是滤波器那里的相关知识。

6.2 梯度和方向

这里就是求各个中心点的梯度, Canny算法中用的是sobel算子
在这里插入图片描述

6.3 非极大值抑制

这里是为了去除一些梯度值很小的边缘信息, 以消除边缘信息带来的杂散效应。 这里介绍了两种方法:

6.3.1 Method1

OpenCV入门学习笔记之常用的图像处理操作_第10张图片
这里描述下这种方法是怎么做的, 这个属实有些复杂, 这里是判断像素C这个点要不要被抑制点,即判断C这个点是不是极大值,可以这么做:

  1. 首先,根据上面的求梯度的方法,计算C点的梯度, 有了梯度,就有了方向(和边界垂直), 这样就能画出C点处像素的梯度来
  2. C点的梯度就那条蓝色的线, 延伸一下,会和上下的边界有交点, 上面交到了dtmp1, 下面交到了dtmp2
  3. 下面算出dtmp1和dtmp2点的梯度来,用M表示梯度的话,然后比较M(dtmp1), M(dtmp2), M(C)的大小, 如果M(C)比那两个都大,那么说明C这个点的像素值是极大值像素点, 保留下这个点,否则, 把它干掉。 那么接下来的问题,就是M(dtmp1), M(dtmp2)如何计算呢?
    1. 这俩点的梯度其实是伪梯度,直接算没法算,只能通过与它相邻的两个点去估计, 比如算M(dtmp1)
      1. 首先, 我们能得到dtmp1两端点的像素值M(g1), M(g2)
      2. 然后, 我们通过这两点的像素值线性插出dtmp1的梯度值来, 怎么插呢? 线性插值公式如上面, 这里的w表示的距离权重,很好理解应该
    2. 对于dtmp2,同样按照上面的方式计算即可

6.3.2 Method2

在这里插入图片描述
这个方法比上面那个简单了, 这里会借助它周围的8个点:

  1. 首先, 把一个像素的梯度方向离散成周围8个方向, 然后看看它离这个方向进,就用那个方向代替
  2. 这个方向上,看看离着哪两个点近, 就有这两个点的梯度,和中心这个点的梯度进行比较
  3. 如果中心这个点的梯度比那两个点都大, 那么保留,否则,抑制掉

6.4 双阈值检测

这里是再自定义进行一波筛选
在这里插入图片描述
这里其实就是指定了一个最大值和一个最小值,然后看看中心点这个梯度值大小,根据右边的规则进行判断需要需要保留。 这里的连有边界的意思是看他是不是挨着一个边界, 如果它旁边那个点是边界了, 那么这个点也会保留下来,否则会去掉。

上面就是Canny算法各个流程的细节部分,在opencv中用起来其实很简单,只需要一个cv2.Canny函数即可。

lena = cv2.imread('img/lena.jpg', cv2.IMREAD_GRAYSCALE)

v1 = cv2.Canny(lena, 80, 150)
v2 = cv2.Canny(lena, 50, 100)

res_lena = np.hstack((lena, v1, v2))
cv_imshow('res', res_lena)

看下结果:

OpenCV入门学习笔记之常用的图像处理操作_第11张图片
这里的minval指定的如果比较小,会找到更加细致的边界, 纹理信息更多, 但可能会拿到很多误选择的边界
maxval指定的如果比较大, 会找到更加严格的边界,但可能会漏掉一些边界

  • minval和maxval都越小,那么检测到的信息就越丰富
  • maxval和maxval都越大, 检测到的信息就越稀疏, 很多边界可能检测不出来

7. 图像金字塔

图像金字塔, 把图像组合成像金字塔一样的形状。图像金字塔的作用,比如我们要对一张图像进行特征提取, 我们可以把图像制作成一个金字塔, 对于金字塔里面的每张图片都进行特征提取, 每张图片可能提取的特征是不一样的,这样就可能增加了图像特征的丰富性。
OpenCV入门学习笔记之常用的图像处理操作_第12张图片
图像金字塔是图像多尺度表达的一种,最主要用于图像分割,是一种以多分辨率来解释图像的有效但概念简单的结构,最早用于视觉和图像压缩。最底部是待处理图像的高分分辨率表示, 越高层,分辨率越低。

常见的两类金字塔:

  • 高斯金字塔: 用来向下/降采样, 主要的图像金字塔
  • 拉普拉斯金字塔: 用来从金字塔底层图像重建上层未采样图像, 可以对图像进行最大程度的还原, 配合高斯金字塔一起使用

两者的简要区别:高斯金字塔用来向下降采样图像,注意降采样其实是由金字塔底部向上,分辨率降低,它和我们理解的金字塔概念相反(注意);而拉普拉斯金字塔则用来从金字塔底层图像中向上采样重建一个图像。

7.1 高斯金字塔

7.1.1 向下采样方法(缩小)

将level0级别的图像转换为 level1,level2,level3,level4,图像分辨率不断降低的过程称为向下取样。
OpenCV入门学习笔记之常用的图像处理操作_第13张图片
从金字塔下往上,是一个向下采样,越采样越小, 主要过程有两步:

  1. 普通的卷积操作, 用的是上面这个卷积核, 我发现这里的卷积和卷积神经网络里面的那种卷积运算还不太一样吧, 这里怎么感觉是对图片的每个像素都用上面这个卷积核卷一遍,卷的过程就是覆盖区域元素相乘最后相加得到中心点的元素,这样最终的结果就是和原图片一样大小的了。
  2. 但是金字塔是越往上越小的,所以这里的第二步,就是将所有偶数行和列去掉,这样得到的结果图像只有原图的四分之一 , 这就是下采样的过程,

7.1.2 向上采样方法(放大)

将level4级别的图像转换为 level3,level2,level1,leve0,图像分辨率不断增大的过程称为向上取样
OpenCV入门学习笔记之常用的图像处理操作_第14张图片
金字塔上往下, 是一个向上采样, 越采样越大

它将图像在每个方向上扩大为原图像的2倍,新增的行和列均使用0来填充,并使用于“向下取样”相同的卷积核乘以4,再与放大后的图像进行卷积运算,以获得“新增像素”的新值

注意: 把图像下上采样,再下采样还原,或者是下采样再上采样还原,都会损失掉信息,虽然和原始图片一样大,但会比原始图像模糊,所以上采样和下采样都是非线性处理, 不可逆,会损失掉信息!

下面是具体实现:

img = cv2.imread('img/AM.png')     # (442, 340, 3)

# 先向上采样看看
up_sample = cv2.pyrUp(img)
cv_imshow('up', up_sample)
print(up_sample.shape)   # (884, 680, 3)

# 向下采样
up_down = cv2.pyrDown(img)
cv_imshow('down', up_down)
print(up_down.shape)   # (221, 170, 3)

# 先经历上采样再还原
cv_imshow('up_down', np.hstack((img, cv2.pyrDown(up_sample))))

可以看下最后这个的结果, 会有一些失真的, 因为下采样和上采样,都会进行一定的信息损失
OpenCV入门学习笔记之常用的图像处理操作_第15张图片

7.2 拉普拉斯金字塔

在这里插入图片描述
这个东西的公式计算就是上面这个, G i G_i Gi表示第 i i i层的图像,下面解释下流程:

  1. 原将原始图像, 经过一层层的降采样做一个图像金字塔, 这样每一层都有一种降采样的图片了,这个就是 G i G_i Gi
  2. 每一层图片的下一步运算,就是上面那个公式, 比如第一层, 用 G i − U P ( G i + 1 ) G_i-UP(G_i+1) GiUP(Gi+1)得到当前第 i i i层的图片

也就是说,拉普拉斯金字塔是通过源图像减去先缩小后再放大的图像的一系列图像构成的。保留的是残差!为图像还原做准备,实现非常简单。

down = cv2.pyrDown(img)
down_up = cv2.pyrUp(down)
l_1 = img - down_up
cv_imshow('l1', l_1)

下面,就是把高斯上采样, 下采样, 拉普拉斯金字塔的这个过程,都写成了函数的形式,可以一键把整个金字塔干出来。

# 高斯降采样金字塔
def Gaussian_Down(img, sample_times):
    Gaussian_Down_pyramid = []
    for i in range(sample_times):
        if i == 0:
            down = cv2.pyrDown(img)
        else:
            down = cv2.pyrDown(down)
        Gaussian_Down_pyramid.append(down)
    return Gaussian_Down_pyramid

def Gaussian_up(img, sample_times):
    Gaussian_up_pyramid = []
    for i in range(sample_times):
        if i == 0:
            up = cv2.pyrUp(img)
        else:
            up = cv2.pyrUp(up)
        Gaussian_up_pyramid.append(up)
    return up

def Laplace(img, sample_times):
    laplace_pyramid = []
    for i in range(sample_times):
        if i == 0:
            down = cv2.pyrDown(img)
            laplace_pyramid.append(img-cv2.pyrUp(cv2.pyrDown(img)))
        else:
            up_down = cv2.pyrUp(cv2.pyrDown(down))
            # 注意这里由于取整的关系,导致这俩还不一样大
            if down.shape != up_down.shape:
                laplace_pyramid.append(down - up_down[:down.shape[0], :down.shape[1], :])
            else:
                laplace_pyramid.append(down - up_down)
            down = cv2.pyrDown(down)
    return laplace_pyramid

8. 图像轮廓检测

根据我目前的理解,轮廓检测和边缘检测是不一样的概念,轮廓检测是只检测对象最外面的那个大轮廓,类似于用一个框圈起对象来,相当于锁定对象的位置,而边缘检测是检测对象内部的各种边界信息。所以不一样。

轮廓检测的作用是可以帮助我们做一些额外的数值特征, 比如图像轮廓的面积,周长, 这样就能反映出大小来了。

8.1 轮廓绘制

核心是下面这个函数:

cv2.findContours(img, mode, method) → contours, hierarchy(返回的轮廓列表及层级信息)

- mode: 轮廓检索模式
    - RETR_EXTERNAL: 只检索最外面的轮廓
    - RETR_LIST: 检索所有的轮廓,并将其保存到一条链表当中
    - RETR_CCOMP: 检索所有的轮廓, 并将他们组织成两层, 顶层是各部分的外部边界, 第二层是空洞的边界
    - RETR_TREE: 检索所有的轮廓, 并重构嵌套轮廓的整个层次(最常用), 多层字典?
- method: 轮廓逼近方法:
    - CHAIN_APPROX_NONE: 以Freeman链码的方式输出轮廓, 所有其他方法输出多边形(顶点的序列)
    - CHAIN_APPROX_SIMPLE: 压缩水平的,垂直的和斜的部分, 也就是,函数只保留他们的终点部分

下面看如何用:

img = cv2.imread('img/car.png')

# 转成灰度, 这里为啥不直接读取的时候转?  因为有些人后序会用到彩色图,仅此而已
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)

# 下一步, 对上面的灰度图像进行二值处理
ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)  # 像素小于127的弄成0, 大于127的弄成255,这样变成二值图像

# 下面绘制轮廓
# 最新版opencv只返回两个值了 3.2之后, 不会返回原来的二值图像了,直接返回轮廓信息和层级信息
contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)

下面是轮廓在原图进行可视化:

# 下面把轮廓进行可视化
draw_img = img.copy()    # 这里要copy一下, 否则会改变原始图像, 下面那哥们貌似是原子操作
# drawContours(image, contours, contourIdx, color[, thickness[, lineType[, hierarchy[, maxLevel[, offset]]]]]) -> image
# 这里的contourIdx要绘制的轮廓线,负数表示所有, 正是只画对应位置的那个点
# color是轮廓线的颜色, (0,0,255)表示红色,因为opencv里面通道排列是bgr吧应该--> 对的
# 最后面那个2是线条的宽度,值越大, 线条越宽
res = cv2.drawContours(draw_img, contours, -1, (0, 255, 0), 2)
cv_imshow('res', res)

看下效果:
OpenCV入门学习笔记之常用的图像处理操作_第16张图片

8.2 轮廓特征

找轮廓, 是为了把这些信息弄成数值特征,方便后面计算, 这里可以找轮廓面积和轮廓周长等。

但是必须是具体的轮廓, 不能是上面的轮廓列表

def contours_feature(contour):
    features = []
    features.append(cv2.contourArea(contour))  # 轮廓面积
    features.append(cv2.arcLength(contours, True))  # 轮廓周长,后面那个是closed,表示这个曲线是否闭合
		return features

controus_feature(countours[0])

这里可以弄一个函数,来找某个具体轮廓的系列特征,然后进行返回,这些特征可以反映出具体对象的大小来。

8.3 轮廓近似

OpenCV入门学习笔记之常用的图像处理操作_第17张图片
左边轮廓这个如果找到太细了,就会出现一些毛毛刺刺的一些轮廓,这时候,我们可能会把这种毛刺的这种轮廓给去掉,就需要右边这样的近似结果, 右边两个是两种不同的近似结果,有点避免它过拟合的味道。

下面简单看下轮廓近似的原理:
OpenCV入门学习笔记之常用的图像处理操作_第18张图片比如,我AB这是一条曲线,我想找一条或者几条直线近似它, 可以这么做:

  1. 首先, A, B两点先连起来,成了一个拱形
  2. 然后,从AB的曲线上找点C, 向AB直线上做垂线, 与AB直线的交点假设与C点的距离d1<设定的阈值, 那么AB这条直线就是成立的,AB曲线直接用这条直线来近似
  3. 如果上面的d1>设定的阈值, 那么说明AB曲线用A到B的一条直线无法近似,看看两条行不
    1. 把AC, BC连起来
    2. AC之间的曲线看看能不能用AC之间的直线代替,方法还是和上面一样, AC上找D点,然后向AC做垂线,看距离d2是否小于设定阈值,如果小于则可以, 如果不小于,则不行,继续二分
    3. BC之间曲线也同理,这样二分下去

使用起来很简单:

# 读取图片 -> 转成灰度图 -> 二值化 -> 找轮廓
img = cv2.imread('img/contours2.png')

# 用上面的函数画一些轮廓
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
cnt = contours[0]

draw_img = img.copy()
res = cv2.drawContours(draw_img, [cnt], -1, (0, 0, 255), 2)
cv_imshow('res', res)


# 下面进行轮廓近似
epsilon = 0.1 * cv2.arcLength(cnt, True)   # 这里是指定的阈值,一般按照周长的百分比设置
# 这个阈值越大,那么画出来的轮廓就越粗糙,因为很容易直接一条直线代替曲线, 如果阈值越小,那么近似出来的轮廓细腻,因为d很容易大于阈值,用多条直线近似
approx = cv2.approxPolyDP(cnt, epsilon, True)

draw_img2 = img.copy()
res2 = cv2.drawContours(draw_img2, [approx], -1, (0, 0, 255), 2)
cv_imshow('res2', res2)

看下两个对比:
OpenCV入门学习笔记之常用的图像处理操作_第19张图片

8.3 轮廓小作用

基于图像的轮廓,还可以计算一些外接矩形,外接圆等等,然后基于这些外接矩形,外接圆再构建一些数值型的特征,比如再加一些额外的组合特征,像轮廓自身面积与边界矩形比,周长之比等

# 基于这个轮廓画外接矩形
x, y, w, h = cv2.boundingRect(cnt)  # 这里返回的是(x, y)是左上角的顶点坐标, w是宽, h是长
draw_img3 = res2.copy()
img_rec = cv2.rectangle(draw_img3, (x, y), (x+w, y+h), (0, 255, 0), 2)
cv_imshow('rec', img_rec)

有了外界矩形,就可以做一些额外的特征:

# 有了这样的一个矩形,我们就可以额外做一些特征
area = cv2.contourArea(cnt)
x, y, w, h = cv2.boundingRect(cnt)
rect_area = w * h
extent = float(area) / rect_area
print('轮廓面积与边界矩形比: ', extent)

还可以做外接圆:

# 还可以做外接圆
(x, y), radius = cv2.minEnclosingCircle(cnt) # 圆心位置和半径
center = (int(x), int(y))
radius = int(radius)

draw_img4 = draw_img3.copy()
res_circle = cv2.circle(draw_img4, center, radius, (255, 0, 0), 2)
cv_imshow('circle', res_circle)

结果如下:
OpenCV入门学习笔记之常用的图像处理操作_第20张图片
通过轮廓检测算法,是能够找到上面图像中各个图像的具体轮廓,这样就能对每个具体图像图形具体操作,如果再加上后面学习的模板匹配,就能很轻松的让计算机知道这些具体对象是啥了。 像车牌号检测,信用卡数字识别等,其实就是用的这样的原理,先锁定卡上的数字区域,然后再模板匹配。

所以这些内容很重要。

9. 图像模板匹配

模板匹配和卷积原理很像, 模板在原图像上从原点开始滑动,计算模板与(图像被模板覆盖的地方)的差别程度,这个差别程度的计算方法在opencv里面有6种, 然后将每次计算的结果放入一个矩阵里,作为结果输出。

假如原图是 A ∗ B A*B AB大小, 而模板是 a ∗ b a*b ab大小, 则输出结果的矩阵 ( A − a + 1 ) ( B − b + 1 ) (A-a+1)(B-b+1) (Aa+1)(Bb+1), 这个感觉和卷积神经网络中的卷积操作很像了。 主要步骤如下:

  1. 读入原始图像,一般是灰度图

  2. 读入模板图像,也是灰度图

  3. 模板匹配,这里直接使用模板匹配函数cv2.matchTemplate(img, template, mode), mode表示计算方式

    • TM_SQDIFF: 计算平方不同, 计算出来的值越小越相关
    • TM_CCORR: 计算相关性, 计算出来的值越大,越相关
    • TM_CCOEFF: 计算相关系数,计算出来的值越大,越相关
    • TM_SQDIFF_NORMED: 计算归一化平方不同,计算出来的值越接近0, 越相关
    • TM_CCORR_NORMED: 计算归一化相关性,计算出来的值越接近1, 越相关
    • TM_CCOEFF_NORMED: 计算归一化相关系数, 计算出来的值越接近1, 越相关

    具体计算公式可以去查文档。尽量使用下面归一化的结果

  4. 获取到匹配好的原始图像的起始位置cv2.minMaxLoc, 这里可以获取到最大值位置,也可以获取到最小值位置,但是由于上面计算方式的不同, 得先弄明白是最大的时候两个越相关,还是越小的时候, 两个越相关

下面一个示例带起来:

# 1. 读入原始图像,灰度图模式读入 0表示灰度图,还有就是cv2.IMREAD_SCALE
img = cv2.imread('img/cat.jpg', 0)  # <==> cv2.imread('img/cat.jpg', cv2.IMREAD_GRAYSCALE)

# 2. 读入模板
template = cv2.imread('img/template.jpg', 0)

# 3. 模板匹配
res = cv2.matchTemplate(img, template, cv2.TM_SQDIFF)  # 值越小越相似
res.shape  # (548-122+1)(666-181+1)


# 4. 获取匹配好图像的起始位置
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)

# 下面尝试把矩形框画出来
top_left = min_loc
bottom_right = (top_left[0]+template.shape[0], top_left[1]+template.shape[1])

# 画矩形
img2 = img.copy()
rect = cv2.rectangle(img2, top_left, bottom_right, 255, 2)

效果如下:
OpenCV入门学习笔记之常用的图像处理操作_第21张图片
假设图片里面不只是一个对象能匹配呢? 有没有办法把所有的模板图像都匹配出来, 那就需要自己最大或者最小位置的地方了,其实也简单:

img_rgb = cv2.imread('img/mario.jpg')
# 转成灰度图
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY)
template = cv2.imread('img/mario_coin.jpg', 0)

h, w = template.shape[:2]
res = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED)
threshold = 0.8  # 这里设置一个门限

# 取匹配程度大于百分之八十的坐标
loc = np.where(res >= threshold)
# 这个loc是横坐标一个数组,纵坐标一个数组的形式,所以下面得用可选参数的方式进行组合

for pt in zip(*loc[::-1]):
    bottom_right = (pt[0]+w, pt[1]+h)
    cv2.rectangle(img_rgb, pt, bottom_right, (0, 255, 0), 2)

cv_imshow('img_rgb', img_rgb)

效果如下:
OpenCV入门学习笔记之常用的图像处理操作_第22张图片

10. 图像直方图与均衡化

10.1 直方图

所谓直方图,就是通过直方图,统计一张图片中,各个像素点值出现的个数。
OpenCV入门学习笔记之常用的图像处理操作_第23张图片
函数介绍如下:

cv2.calcHist(images, channels, mask, histSize, ranges):

- images: 原图像格式为uint8或者float32, 当传入时用[]括起来, 一般时灰度图像居多。
- channels: 同样时中括号括起来, 指定彩色图片的哪个通道,如果时灰度图,它的值就是[0], 如果时彩色图, [0], [1], [2]表示BGR三个通道。
- mask: 掩膜图像, 统计整个图的时候,就时None,如果想统计图像的某一部分,那么可以制作一个掩膜矩阵
- histSize: bin的数目, 可以统计0-255,每个像素点,也可以按照范围, 比如0-1010-20, 等等的个数,这里指定
- ranges: 像素值范围,一般默认[0,256)

使用也非常简单:

img_bgr = cv2.imread('img/cat.jpg')
color = ('b', 'g', 'r')
for i, col in enumerate(color):
    histr = cv2.calcHist([img_bgr], [i], None, [256], [0, 256])
    plt.plot(histr, color=col)
    plt.xlim([0, 256])

其实这个东西,用plt.plot也能画出直方图来,毕竟图片数据是numpy数组, 通过可视化的方式,就能大致上知道图片的像素分布取值了。

mask掩码操作的使用, mask掩码矩阵传入之后,只会统计我们需要部分的直方图:

# 先创建mask
mask = np.zeros(img.shape[:2], np.uint8)

mask[100:300, 100:400] = 255
cv_imshow('mask', mask)  # 这样就创造除了一个掩码矩阵, 只有0和255组成, 白色的地方是要显示的,黑色的地方是被遮挡

masked_img = cv2.bitwise_and(img, img, mask=mask)  # 与操作  等价img & mask

# 下面对比一下
hist_full = cv2.calcHist([img], [0], None, [256], [0, 256])
hist_mask = cv2.calcHist([img], [0], mask, [256], [0, 256])

效果如下:
OpenCV入门学习笔记之常用的图像处理操作_第24张图片

10.2 均衡化

直方图的作用,就是可以对一些图像均衡化处理,使得图像的对比度拉高,更加清晰。 看下面这个图片:
OpenCV入门学习笔记之常用的图像处理操作_第25张图片
先简单介绍下原理:

OpenCV入门学习笔记之常用的图像处理操作_第26张图片
上面的左图假设是原始图像,右边是均衡化之后的结果,所谓均衡化,无非就是通过一种算法,把原始图像里面的像素值映射成别的值代替。让其更加均匀一些,那么怎么映射呢?

  1. 首先,先统计原始图像里面每个像素点出现的次数,然后归一化,计算出概率
  2. 从小到大排序,然后就算累计概率,也非常简单,从上往下,概率的累计值
  3. 用到的映射函数: 累计概率值*像素的取值范围(255-0)
  4. 把上面计算得到的数取整

具体使用:

img = cv2.imread('img/clahe.jpg', 0)  
plt.hist(img.ravel(), 256)

# 均衡化
equ = cv2.equalizeHist(img)
plt.hist(equ.ravel(), 256)
plt.show()

res = np.hstack((img, equ))
cv_imshow('res', res)  # 会发现均衡话之后,变得更加清晰了

效果如下:
OpenCV入门学习笔记之常用的图像处理操作_第27张图片
均衡话可能存在的问题,虽然有时候会增强图片的对比度,但是可能有些细节丢失掉,因为均衡化类似于全局求了个平均,有细节的地方会受到其他位置的干扰。 所以,改进就是分块进行均衡话, 比如把一张图片分成多个小块,然后在每个小块里面执行均衡化的操作。当然,这样可能带来的问题就是每个小块的边界可能很明显了, opencv提供了一些插值处理来解决这个问题。

# cv2提供了分块均衡化的函数
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))  # 自适应均衡化

res_clahe = clahe.apply(img)
res = np.hstack((img, equ, res_clahe))
cv_imshow('res', res)

最终的效果如下:

OpenCV入门学习笔记之常用的图像处理操作_第28张图片

11. 小总

这就是目前学习到的通过OpenCV库进行图像预处理操作的所有知识了,内容有些多,不适合顺序阅读,而是想着把这些东西整理到一块,后面用到的时候会很方便。 当然,目前对每一块的了解还处在皮毛状态,如果后面学习到更深入的知识,也会及时的在相应版块进行补充。 下面一张导图把知识拎起来:
OpenCV入门学习笔记之常用的图像处理操作_第29张图片
基础知识应该就到这里了,后面就是通过几个项目把上面所有内容带起来, Rush

参考:

  • opencv入门教程

你可能感兴趣的:(计算机视觉CV学习笔记,图像处理,OpenCV)