OpenCV:最流行的图像处理库

发布时间 2023-07-03 10:24:45作者: tomato-haha

https://www.cnblogs.com/traditional/p/11193524.html


楔子

关于 Python 的图像处理,我们之前介绍一个第三方库叫 PIL,现在我们来介绍另一个库 OpenCV。从功能和性能上来讲,OpenCV 要比 PIL 强大很多,而且 OpenCV 还可以处理视频。

那么下面我们就来介绍一下 OpenCV 的用法,首先是安装,直接 pip install opencv-python 即可。当然啦,由于包比较大,建议指定国内的镜像。

图像的读取、显示和保存

我们说图片本质上是一个数组,在 Python 里面就是 Numpy 的数组。之前介绍 PIL 时,将读片读取进来之后得到的是一个 Image 对象,我们通过 np.asarray 可以转化为 Numpy 的数组;在 OpenCV 里面也是同理,只不过读取之后得到的直接就是 Numpy 的数组。

所以我们使用 PIL 和 OpenCV,本质上都是在对数组做操作,如果你对图片的原理很懂的话,那么你也可以直接操作数组。

下面就来看看怎么读取一张图片。

import cv2

# 读取图片使用 cv2.imread
im = cv2.imread('bg.png')
# 会得到 Numpy 的数组
print(im.__class__)  # <class 'numpy.ndarray'>

# 调用 cv2.imshow 来对图片进行显示
# 该函数会创建一个窗口,所以需要接收两个参数
# 第一个参数是窗口的名字,第二个参数是数组
cv2.imshow("girl", im)

# 然后指定窗口显示多长时间,调用 cv2.waitKey 函数
# 值大于 0,显示指定的毫秒数后退出
# 值小于 0,当用户按下键盘的任意键之后退出
# 值等于 0,永不退出
# 我们一般指定 0 即可,然后通过点击窗口右上角来退出
cv2.waitKey(0)

过程还是很简单的,我们看一下执行的效果。

执行之后 Python 进程会一直卡在 waitKey 函数这里,当我们单机窗口右上角关闭窗口,该函数就执行完毕了,然后进程退出。当然,如果我们调用 cv2.waitKey 的函数指定了大于 0 的值,比如 1000,那么会自动在 1 秒后退出。另外 cv2.waitKey 这个函数不能忽略,如果没有这个函数,窗口会一闪而过,也就是瞬间退出。

怎么样,图片的读取和显示还是比较简单的,然后我们再来说说 cv.imread 这个函数。这个函数接收两个参数,第一个参数是要读取的图片的路径,第二个参数表示按照什么模式来读取。

import cv2

# cv2.IMREAD_GRAYSCALE:按照灰度图来读取
# cv2.IMREAD_COLOR:默认参数,按照彩色图来读取,但会忽略alpha通道
# cv2.IMREAD_UNCHANGED:读取完整图片,不忽略 alpha 通道(如果存在的话)
im = cv2.imread('bg.png', cv2.IMREAD_GRAYSCALE)  # 按照灰度图来读取
cv2.imshow("girl", im)
cv2.waitKey(0)

此时得到的就是一张灰度图,我们看一下:

当然了,图片的读取模式不止上面三种,但是常用的就是这三种。

然后我们说图片读取进来之后,得到的就是一个数组,那么问题来了,我们自己创建一个数组是不是也可以显示呢?答案是肯定的,我们来试一下。

import numpy as np
import cv2

# 创建一个三维数组,对应的就是一张宽高均为 200 像素的图片,每个像素均是 3 个通道、即彩色图片
# 然后将图片设置成黑色,也就是将数组元素都设置成 0
im = np.empty((200, 200, 3), dtype="uint8")
im[...] = 0
cv2.imshow("girl", im)
cv2.waitKey(0)

# 窗口退出之后,销毁窗口数据
# 当然这里在窗口退出之后整个代码就执行完毕了,因此销不销毁都无所谓了
cv2.destroyAllWindows()

我们看一下显示的内容:

因为图片在读取进来之后得到的就是一个数组,cv2.imshow 也是将数组显示成图片,所以我们自己创建一个数组同样是可以显示的,区别无非就是:一个是通过读取图片得到的数组,一个是我们手动创建的数组。

那么问题来了,窗口显示的图片全是黑色比较单调,我们希望将左上角和右下角相连,画一条绿色的线,该怎么做呢?

import numpy as np
import cv2

im = np.empty((200, 200, 3), dtype="uint8")
im[...] = 0
# 显然将 (0, 0)、(1, 1)、(2, 2)、...、(199, 199) 的像素点都设置成 (0, 255, 0) 即可
im[list(range(0, 200)), list(range(0, 200))] = (0, 255, 0)
cv2.imshow("girl", im)
cv2.waitKey(0)
cv2.destroyAllWindows()

所以使用 OpenCV 一定要理解图片的本质,完全可以把它看成是一个三维数组,第一个维度表示图片的高度,第二个维度表示图片的宽度。如果一张图片是 1920 x 1080 格式,那么转成数组之后,数组的第一个维度的长度就是 1080、第二个维度的长度就是 1920。

import cv2

im = cv2.imread("bg.png")
# 获取高度为 150、宽度为 300 的像素点
print(im[150, 300])  # [207 212 233]

通过前两个维度,我们便可获取指定的像素点,如果是灰度图,那么每个像素点就是一个 uint8 整数,此时的图片读取进来是一个二维数组;如果是彩色图片,那么每个像素点就是一个长度为 3 的 uint8 数组,因为是 3 通道,并且由于每个像素点也是一个数组,那么图片读进来就变成了一个三维数组。所以我们上面沿着左上角到右下角画一条绿色的线,就是将经过的像素点的 3 通道的值设置成 (0,255,0) 即可。

彩色图片也有可能是 4 通道,那么每个像素点对应的数组长度就是 4,因为会包含一个透明度。但我们上面的读取方式,会忽略 alpha 通道。

另外关于彩色图片的 3 通道,肯定所有人都知道,不就是 RGB 嘛。但是 OpenCV 在读取和显示的之后,采用的不是 RGB、而是 BGR。我们借助 PIL 对比一下就知道了。

from PIL import Image
import numpy as np
import cv2

# 采用 PIL 读取之后需要手动转成数组
im1 = np.asarray(Image.open(r"レム.jpg"))
# 采用 OpenCV 读取之后得到的就是数组
im2 = cv2.imread(r"レム.jpg")

# 显然 PIL 和 OpenCV 读取之后,每一个像素点都是长度为 3 的数组
# 但对于 PIL 而言,里面的三个值分别表示每个像素点的 R 通道、G 通道、B 通道
# 而对于 OpenCV 而言,里面的三个值分别表示每个像素点的 B 通道、G 通道、R 通道
print(im1[100, 100])  # [248 229 222]
print(im2[100, 100])  # [222 229 248]

# 我们看到结果是相反的,如果将 im1 或 im2 的每一个像素点对应数组进行翻转
# 那么两个结果显然就变成一样的了
print(np.all(im1[:, :, :: -1] == im2))  # True

所以,PIL 读取的图片不能直接交给 OpenCV 显示,必须要将 RGB 转成 BGR 才可以。这样吧,我们来测试一下,不转成 BGR 会有什么后果。

from PIL import Image
import numpy as np
import cv2

#im = cv2.imread(r"bg.png")
im = np.asarray(Image.open("bg.png"))
# 不转成 BGR 直接显示
cv2.imshow("girl", im)
cv2.waitKey(0)
cv2.destroyAllWindows()

我们看到整个图片的色调就全变了,就是因为数组表达的图片是 RGB 通道,但是 OpenCV 是按照 BGR 通道来解析的。为此 OpenCV 专门提供了一些函数,用于图片的转换,比如 RBG 转 BGR、彩色图转灰度图等等,我们后面再说。当然啦,还是之前那句话,这些转换本质上都是对数组进行操作,返回的也是数组,因此如果你懂得背后的原理,那么你完全可以通过操作数组来实现,而无需借助于 OpenCV。

最后我们看一下图片的保存。

import cv2

# 读取图片,并假装对数组进行了一些操作
im = cv2.imread(r"bg.png")

# 保存图片,第一个参数:文件路径;第二个参数:数组
# 图片的格式取决于后缀名
cv2.imwrite("new_bg.png", im)

此时整个图片就保存好了,这时候可能有人问了,那保存的图像质量怎么办?所以 imwrite 函数还有第三个参数:

  • cv2.imwrite("new_bg.jpg", im, [cv2.IMWRITE_JPEG_QUALITY, 95]):如果保存格式为 jpg,那么可以指定图片质量,范围是 0 到 100,值越高图片质量就越高,默认是 95
  • cv2.imwrite("new_bg.png", im, [cv2.IMWRITE_PNG_COMPRESSION, 3]):如果保存格式为 png,那么可以指定压缩比,范围是 0 到 9,值越高压缩比越高、生成的图片就越小,默认是 3。另外,png 属于无损压缩,所以它没有指定图片质量这一参数

然后我们来测试一下,将生成的图片改成 jpg 格式,然后将质量设置为 1,看看是什么效果。

已经不忍直视了,因为 jpg 是属于有损压缩。

图像的种类与转换

这里我们再聊一聊图片方面的一些知识,我们说图像是由一个个的像素点构成的。如果一张图片上的像素点越多,那么图片看起来就越精美,因此 500万像素和 100万像素的手机拍出来的照片感觉是不一样的。而根据像素值的不同,我们可以将图片分为三种:

  • 二值图像:每个像素点存的值要么是 0 要么是 1
  • 灰度图像:每个像素点存的值是 0 到 255
  • RGB 图像:每个像素点存的是一个包含三个 uint8 的数组,比如 (255, 0, 0) 就表现为红色。当然 (200, 0, 0) 也是红色,只不过程度要低一些。而任何一种颜色都可以由不同程度的红色、绿色、蓝色混合得到,所有的像素组合起来就形成了我们看到的图像

比如我们希望一张图片的色调变的暖一些,那么可以适当减少 B 通道的值。

import cv2

im = cv2.imread(r"bg.png")
# 我们说 OpenCV 是按 BGR 读取的,所以 B 通道对应像素数组的第一个值
# 这里我们减少 60,故意减的多一些
im[:, :, 0] -= 60

cv2.imshow("girl", im)
cv2.waitKey(0)

图片变的暖了起来,但是却出现了一些不合适的蓝色,比如女孩的睫毛,这是怎么回事?其实很好想,我们的目的是减少 B 通道的值,但我们减少的是 60,因此那些 B 通道小于 60 的值在减 60 之后就变成了负数。比如 59,减 60 之后变成了 -1。而数组的类型是 uint8,不能保存负数,于是会发生环绕,变成了 255,结果 B 通道的值反而达到了最高,因此上面的蓝色就是这么出现的。

为了避免这种情况,我们可以事先做一些处理,比如将 B 通道小于 60 的设置为 60。再或者我们先将数组的类型改成 int16,这样既支持负数也避免了溢出(我们减去某个值还可以加上某个值),然后运算完之后将小于 0 的值设置成 0,大于 255 的值设置成 255。

如果将所有通道的值都一起相加,那么表现出的效果就是图片会变亮;相减的话,图片会变暗。

灰度图

彩色图片我们说完了,再来说说灰度图,灰度图的特点是每个像素都是一个标量,而不是一个数组。那么肯定有人想到了,我把一张彩色图片的三个通道分离开,只获取一个通道不就行了?

import cv2

im = cv2.imread(r"bg.png")
# 获取三个通道,顺序是 BGR
im_b = im[:, :, 0]
im_g = im[:, :, 1]
im_r = im[:, :, 2]
# 窗口的名字不能重复,否则会发生覆盖
cv2.imshow("girl1", im_b)
cv2.imshow("girl2", im_g)
cv2.imshow("girl3", im_r)
cv2.waitKey(0)

其实 OpenCV 也提供了相应的方法去获取灰度图像,并且它提供的方式要更加的好,我们一会会说。原因就是我们通过分离通道的方式获取灰度图,那么就意味着要丢失两个通道的信息,而 OpenCV 采用的是加权运算的方式,可以最大程度的保留图像的信息。

import cv2

im = cv2.imread(r"bg.png")
# 调用 cvtColor 进行图像转换
# 第一个参数是图像对应的数组
# 第二个参数表示将图像转成什么格式
im2 = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
# 第二个参数的种类有很多,以 cv2.COLOR_ 开头的都是
# 可以借助于 PyCharm 的智能提示进行查看
# 然后名称也很固定,都是 cv2.COLOR_XXX2XXX 的形式
# 因为我们这里的 im 是 BGR 格式,要转成灰度图
# 所以调用 COLOR_BGR2GRAY 将 BGR 图像转成 GRAY 图像、也就是灰度图
# 还有 COLOR_BGR2RGB 表示将 BGR 图像转成 RGB 图像等等,支持的转换非常多

cv2.imshow("girl", im2)
cv2.waitKey(0)

可以自己试着对比一下,看看分离通道生成灰度图和调用 cv2.cvtColor 生成的灰度图有什么区别。

二值图像

二值图像更加简单,顾名思义,每个像素值只有两种选择。

我们看到图像变得非常单调,要么黑要么白。而生成二值图像,我们需要先得到灰度图像,根据灰度图像再生成二值图像。

那么二值图像怎么生成呢?cv2 提供了如下方法:

import cv2

im = cv2.imread(r"bg.png")
# 要二值化图像,要先进行灰度化处理
gray = cv2.cvtColor(im, cv2.COLOR_RGB2GRAY)

# 进行二值化,需要调用 cv2.threshold 函数,里面接收 4 个参数
"""
参数一:原图
参数二:分类的阈值
参数三:最大值
参数四:阈值计算方式,我们主要看这个参数
"""
# 当像素超过阈值(127)时,该像素变为 255,否则的话变为 0
ret1, thresh1 = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
# 当像素超过阈值(127)时,该像素变为 0,否则的话变为 255
ret2, thresh2 = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)
# 当像素超过阈值(127)时,该像素被设置为阈值,否则的话保持不变
ret3, thresh3 = cv2.threshold(gray, 127, 255, cv2.THRESH_TRUNC)
# 当像素超过阈值(127)时,该像素保持不变,否则的话被设置为 0
ret4, thresh4 = cv2.threshold(gray, 127, 255, cv2.THRESH_TOZERO)
# 当像素超过阈值(127)时,该像素被设置为 0,否则的话保持不变
ret5, thresh5 = cv2.threshold(gray, 127, 255, cv2.THRESH_TOZERO_INV)

# 返回的 ret 表示阈值,thresh 表示二值化之后的数组
# 显示的时候就是二值图
cv2.imshow("girl", gray)
cv2.imshow("girl1", thresh1)
cv2.imshow("girl2", thresh2)
cv2.imshow("girl3", thresh3)
cv2.imshow("girl4", thresh4)
cv2.imshow("girl5", thresh5)
cv2.waitKey(0)

我们来对比一下区别:

过程还是很简单的,那么问题来了,你能不能不借助 cv2,而是使用 Numpy 来实现呢?我们试一下:

import numpy as np
import cv2

im = cv2.imread(r"bg.png")
gray = cv2.cvtColor(im, cv2.COLOR_RGB2GRAY)

# 当像素超过阈值(127)时,该像素变为 255,否则的话变为 0
ret1, thresh1 = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
print(np.all(thresh1 == np.where(gray > 127, 255, 0)))  # True

# 当像素超过阈值(127)时,该像素变为 0,否则的话变为 255
ret2, thresh2 = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)
print(np.all(thresh2 == np.where(gray > 127, 0, 255)))  # True

# 当像素超过阈值(127)时,该像素被设置为阈值,否则的话保持不变
ret3, thresh3 = cv2.threshold(gray, 127, 255, cv2.THRESH_TRUNC)
print(np.all(thresh3 == np.where(gray > 127, 127, gray)))  # True

# 当像素超过阈值(127)时,该像素保持不变,否则的话被设置为 0
ret4, thresh4 = cv2.threshold(gray, 127, 255, cv2.THRESH_TOZERO)
print(np.all(thresh4 == np.where(gray > 127, gray, 0)))  # True

# 当像素超过阈值(127)时,该像素被设置为 0,否则的话保持不变
ret5, thresh5 = cv2.threshold(gray, 127, 255, cv2.THRESH_TOZERO_INV)
print(np.all(thresh5 == np.where(gray > 127, 0, gray)))  # True

正如之前说的,只要理解背后原理,完全可以通过数组本身实现。

最后来说一说图像的属性,比如我们想获取一张图片的宽、高该怎么做呢?好吧,显然这个问题非常简单。

import cv2

im = cv2.imread(r"bg.png")
print(im.shape)  # (440, 780, 3)
# 显示的数组是三维,证明是彩色图片
# 如果是二维,证明是灰度图片
# 然后图片的宽是 780 个像素,高是 440 个像素,并且是 3 通道

# 将 alpha 通道也读进来,此时就是 4 通道
im = cv2.imread(r"bg.png", cv2.IMREAD_UNCHANGED)
print(im.shape)  # (440, 780, 4)

# 计算像素点的个数,总共有 343200 个像素点
print(im.shape[0] * im.shape[1])  # 343200

图像的 ROI

ROI(region of interest),表示感兴趣的区域,我们可以通过各种算子和函数来获取一张图片的 ROI,并用方框、圆、不规则多边形等方式将其勾勒出来。

import cv2

im = cv2.imread(r"bg.png")
# 我们随便找一个区域吧,然后将这个区域涂成蓝色
im[100: 200, 200: 400] = (255, 0, 0)
cv2.imshow("girl", im)
cv2.waitKey(0)

我们这里是随便找了一个区域,我们还可以通过图像识别的方式找到感兴趣的区域,然后进行相应的处理。比如自动识别人脸,然后给它带一个帽子之类的。

当然啦,我们也可以读取一张图片,将它放在另外一张图片里面,这也都是可以的。

通道的拆分与合并

拆分通道我们之前已经见过了,但是 OpenCV 也给我们提供了专门的函数用于拆分。

import numpy as np
import cv2

im = cv2.imread(r"bg.png")
# 拆分通道
b, g, r = cv2.split(im)
print(np.all(b == im[:, :, 0]))  # True
print(np.all(g == im[:, :, 1]))  # True
print(np.all(r == im[:, :, 2]))  # True

然后是合并通道,这里我们按照 B、R、G 的方式合并。

import numpy as np
import cv2

im = cv2.imread(r"bg.png")
b, g, r = cv2.split(im)
# 合并通道
im = cv2.merge([b, r, g])
# 当然我们也可以使用数组的方式
im2 = np.array([b, r, g])
print(im2.shape)  # (3, 440, 780)
# 但是维度不一样,正确的维度应该是 (440, 780, 3)
# 所以我们将第一个维度和第二个维度交换,再将第二个维度和第三个维度交换
print(np.all(im == im2.swapaxes(0, 1).swapaxes(1, 2)))  # True
# 或者还有一种更简单的做法,将第一个维度(索引为 0)移动到第三个维度后面
# 第三个维度的索引为 2,但是要移动到它的后面,所以是 3
print(np.all(im == np.rollaxis(im2, 0, 3)))  # True
# 或者还有更更简单的做法,直接进行转置
# 转置之后要将原来的维度从 (0, 1, 2) 变成了 (1, 2, 0)
print(np.all(im == np.transpose(im2, (1, 2, 0))))  # True
cv2.imshow("girl", im)
cv2.waitKey(0)

所以虽然这是关于 OpenCV 的文章,但是我们也介绍了不少关于 Numpy 的内容,因为操作这些图片本质上就是操作 Numpy 的数组。并且通过这些操作,我们也能看出 Numpy 的伟大之处,真的感谢社区诞生了这么个工具,速度以及支持的操作都是无与伦比的。

图像的相加与融合

我们将两张图片的像素进行相加,看看会有什么结果。

import cv2

im = cv2.imread(r"bg.png")
cv2.imshow("girl", cv2.add(im, im))
cv2.waitKey(0)

图像变得亮了,因为每一个像素点的值变成了原来的 2 倍。并且 cv2.add 函数在相加之后,还会额外做一步处理,就是当超过了 255 时设置成 255。如果是 Numpy 的话,那么超过之后会发生截断,比如 256 会变成 0、257 会变成 1。

import cv2

im = cv2.imread(r"bg.png")
cv2.imshow("girl", im + im)
cv2.waitKey(0)

我们看到此时图片比较花,如果我们希望和 cv2.add 函数具有一样效果的话,该怎么做呢?

import numpy as np
import cv2

im = cv2.imread(r"bg.png")
# 转成 uint16 进行相加即可,然后将大于 255 的值设置成 255
im2 = im.astype("uint16") + im.astype("uint16")
im2[im2 > 255] = 255
# 最后再转成 uin8
im2 = im2.astype("uint8")
print(np.all(cv2.add(im, im) == im2))  # True

所以只要懂得了图像转化背后的原理,完全可以自己使用数组来实现。

图像相加有一个硬性要求,就是图像的大小必须一致,因为图像是要转成数组来操作的,它们的 shape 要是一样的。

然后是图像的融合,那么融合和之前的加法有什么区别呢?我们验证一下就知道了。

这是我们要处理的两张图片,显然看看加法的效果:

import cv2

im1 = cv2.imread(r"satori.png")
im2 = cv2.imread(r"koishi.png")
cv2.imshow("girl", cv2.add(im1, im2))
cv2.waitKey(0)

显然我们的目的是将两张图片融合在一起,为了能够同时展现两张图片的信息,简单的相加肯定是无法办到的。应该给每张图片一个权重,举个栗子:

import cv2

im1 = cv2.imread(r"satori.png")
im2 = cv2.imread(r"koishi.png")
# 等价于 im1 * 0.4 + im2 * 0.6 + 10
cv2.imshow("girl", cv2.addWeighted(im1, 0.4, im2, 0.6, 10))
# cv2.add(im1, im2) 就类似于 im1 * 1 + im2 * 1
# 应该让两者权重加起来不超过 1,这样才能比较好的展现融合的效果
# 如果想哪一个更突出,那么就将哪一个的权重设置的高一些
cv2.waitKey(0)

此时的融合效果才算是我们想要的。另外,图像融合同样要求两张图片的尺寸一样。

图像的缩放和翻转

图像的缩放我们可以使用 resize 函数,比如我们缩小一张图像,然后贴在另一张图像上。

import cv2

im1 = cv2.imread(r"satori.png")
im2 = cv2.imread(r"koishi.png")

# 我们将 im1 缩放为原来的一半,第二个参数表示缩放之后的尺寸
# 比如缩放为宽 300、高 200,那么第二个参数就是 (300, 200)
# 这是按照目前的习惯来设计的,因为我们平时在说的时候也是先说宽度、再说高度
# 注意:但是对于数组而言就不同了,我们说数组的第一个维度表示的是高度,第二个维度表示的是宽度
# 因为这里的缩放尺寸要求宽在前、高在后,所以是 im1.shape[1] 在前、im1.shape[0] 在后
# 这一点比较容易乱,要理清楚
im1 = cv2.resize(im1, (im1.shape[1] // 2, im1.shape[0] // 2))
# 贴在 im2 上面,就从 (100, 100) 这个位置开始贴吧
im2[100: 100 + im1.shape[0], 100: 100 + im1.shape[1]] = im1
cv2.imshow("girl", im2)
cv2.waitKey(0)

缩放还是比较简单的,只是里面的第二个参数容易让人混淆,所以 resize 里面还提供了额外的参数:

import cv2

im1 = cv2.imread(r"satori.png")
im2 = cv2.imread(r"koishi.png")

# 第二个参数是默认参数,我们必须要传
# 但是我们不用,所以指定一个 None 即可
# 然后是 fx 和 fy 表示在水平、垂直方向上缩小多少倍
im1 = cv2.resize(im1, None, fx=0.5, fy=0.3)
im2[100: 100 + im1.shape[0], 100: 100 + im1.shape[1]] = im1
cv2.imshow("girl", im2)
cv2.waitKey(0)

因此在垂直方向上缩放的比例高一些,所以图片变得扁了。

接下来是图片的翻转,翻转一般有水平翻转、垂直翻转、水平垂直翻转。

import cv2

im = cv2.imread(r"bg.png")
# 垂直翻转,第二个参数为 0
im1 = cv2.flip(im, 0)
# 水平翻转,第二个参数大于 0
im2 = cv2.flip(im, 1)
# 水平垂直翻转,第二个参数小于 0
im3 = cv2.flip(im, -1)
cv2.imshow("girl", im)
cv2.imshow("girl", im1)
cv2.imshow("girl", im2)
cv2.imshow("girl", im3)
cv2.waitKey(0)

我们上面的翻转都是 90 度翻转,问题来了,我们想实现任意角度的旋转该怎么做呢?

import cv2

im = cv2.imread(r"bg.png")
# 参数一:旋转的中心,注意:使用 (宽度, 高度) 来表示的
# 参数二:旋转的角度
# 参数三:缩放比例
# 得到旋转用的 rotate
rotate = cv2.getRotationMatrix2D((im.shape[1] / 2, im.shape[0] / 2), 45, 2)

# 参数一:原始图像
# 参数二:上一步计算得到的旋转参数 rotate
# 参数三:旋转后图片的宽度和高度,这里和原图保持一致
im2 = cv2.warpAffine(im, rotate, ((im.shape[1], im.shape[0])))
cv2.imshow("girl1", im)
cv2.imshow("girl2", im2)
cv2.waitKey(0)

图像的平滑处理(滤波)

滤波分为多种,有均值滤波、方框滤波、高斯滤波、中值滤波,我们分别介绍。

但在介绍之前我们先来解释一下这些滤波是做什么的?其实从标题也能看出来,为的是使图像变得更平滑,或者更直接点,可以理解为就是对图像进行模糊处理。

均值滤波

首先是中值滤波,先来看看它的原理是什么?

原理其实很好理解,任何一点的像素值都设置成包含自身在内的周围 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.734em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.698em, 1002.73em, 2.646em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-2"><span class="mi" id="MathJax-Span-3" style="font-family: MathJax_Math; font-style: italic;">N<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.085em;">NN∗N<script type="math/tex" id="MathJax-Element-1">N*N</script> 个像素值的均值。比如图中的绿色部分,所有的值加起来求平均,得到的就是中间值为 226 这个像素点(在某一通道上)的新像素值。至于其它的像素点也是同理,但是这个 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 0.882em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.698em, 1000.88em, 2.646em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-7"><span class="mi" id="MathJax-Span-8" style="font-family: MathJax_Math; font-style: italic;">N<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.085em;">N<script type="math/tex" id="MathJax-Element-2">N</script> 是可以动态变化的,<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 0.882em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.698em, 1000.88em, 2.646em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-10"><span class="mi" id="MathJax-Span-11" style="font-family: MathJax_Math; font-style: italic;">N<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.085em;">N<script type="math/tex" id="MathJax-Element-3">N</script> 的大小决定了均值滤波效果的好坏。

我们说每个像素值等于周围 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.734em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.698em, 1002.73em, 2.646em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-13"><span class="mi" id="MathJax-Span-14" style="font-family: MathJax_Math; font-style: italic;">N<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.085em;">NN∗N<script type="math/tex" id="MathJax-Element-4">N*N</script> 个像素值的平均值,那么我们也可以这么理解:

想象成有一个 shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.998em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.9em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-18"><span class="mo" id="MathJax-Span-19" style="font-family: MathJax_Main;">(N,N)(N,N)<script type="math/tex" id="MathJax-Element-5">(N, N)</script> 的数组,每个元素都是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.984em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.516em, 1001.98em, 3.021em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-25"><span class="texatom" id="MathJax-Span-26"><span class="mrow" id="MathJax-Span-27"><span class="mfrac" id="MathJax-Span-28"><span style="display: inline-block; position: relative; width: 1.729em; height: 0px; margin-right: 0.12em; margin-left: 0.12em;"><span style="position: absolute; clip: rect(3.409em, 1000.3em, 4.145em, -1000em); top: -4.406em; left: 50%; margin-left: -0.177em;"><span class="mn" id="MathJax-Span-29" style="font-size: 70.7%; font-family: MathJax_Main;">1NN1N∗N<script type="math/tex" id="MathJax-Element-6">{\frac 1 {N*N}}</script>,这个数组的中心位置在原始的数组上面从左往右、从上往下依次滑动,重叠部分的每个元素各自相乘、再整体相加,然后将结果作为该位置的新的元素值。正如上图显示的那样,此时数组的中心位置滑到了 226,那么重叠的部分各自相乘再整体相加之后,其结果就是 226 这个位置的新的元素值。当然图中只计算了一个点的新元素值,至于其它点也是同理。

这个 shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.998em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.9em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-35"><span class="mo" id="MathJax-Span-36" style="font-family: MathJax_Main;">(N,N)(N,N)<script type="math/tex" id="MathJax-Element-7">(N, N)</script> 的数组就叫做"卷积核",针对原始图像内的每个像素点,逐个采用核进行处理,即可得到结果图像。卷积核的维度和原始数组的维度是相同的,比如我们上图是一个二维数组(灰度图、或者彩色图的一个通道),那么卷积核也是二维数组。如果是三维数组(彩色图),那么卷积核也是三维<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 3.924em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1003.83em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-42"><span class="mo" id="MathJax-Span-43" style="font-family: MathJax_Main;">(N,N,3)(N,N,3)<script type="math/tex" id="MathJax-Element-8">(N, N, 3)</script>的,值也都是一样的,每个通道也都是按照上面的规则进行运算。

以上是均值滤波的思想,说白了就是取周围多个元素的平均值来作为该值。接下来我们来看看如何通过 OpenCV 来实现均值滤波:

import cv2

im = cv2.imread(r"bg.png")
# 调用 blur 函数即可实现均值滤波
# 第二个参数就是我们定义的卷积核,我们之前一直说 shape 为 (N, N) 的数组
# 但其实核的 shape 可以是不同的,比如我们定义成 (3, 5)
# 那么核对应的数组就是 5 行 3 列,每个元素都是 1/15
# 注意:我们这里给 blur 函数传递的 shape 指的是 (宽度, 高度)
# 对应成数组的话第一个维度的长度是 shape[1]、第二个才是 shape[0]
# 因此 cv2 的这一个地方就比较让人讨厌,容易让人混乱
im2 = cv2.blur(im, (3, 5))
cv2.imshow("girl", im2)
cv2.waitKey(0)

我们看到以上就是均值滤波对应的模糊效果。这里再多提一句 blur 函数的第二个参数,核的维度可以不同,shape 不一定非要是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.998em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.9em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-51"><span class="mo" id="MathJax-Span-52" style="font-family: MathJax_Main;">(N,N)(N,N)<script type="math/tex" id="MathJax-Element-9">(N, N)</script>,也可以是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 3.175em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1003.08em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-58"><span class="mo" id="MathJax-Span-59" style="font-family: MathJax_Main;">(M,N)(M,N)<script type="math/tex" id="MathJax-Element-10">(M, N)</script>。

如果是灰度图,那么 shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-65"><span class="mo" id="MathJax-Span-66" style="font-family: MathJax_Main;">(3,5)(3,5)<script type="math/tex" id="MathJax-Element-11">(3, 5)</script> 的核就等价于:

<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 9.656em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(2.366em, 1009.34em, 9.48em, -1000em); top: -6.173em; left: 0em;"><span class="mrow" id="MathJax-Span-72"><span class="mrow" id="MathJax-Span-73"><span class="mo" id="MathJax-Span-74" style="vertical-align: 3.675em;"><span style="display: inline-block; position: relative; width: 0.667em; height: 0px;"><span style="position: absolute; font-family: MathJax_Size4; top: -2.858em; left: 0em;">⎡<span style="display: inline-block; width: 0px; height: 4.012em;">1/151/151/151/151/151/151/151/151/151/151/151/151/151/151/15[1/151/151/151/151/151/151/151/151/151/151/151/151/151/151/15]<script type="math/tex" id="MathJax-Element-12">\left[ \begin{matrix} 1/15 & 1/15 & 1/15\\ 1/15 & 1/15 & 1/15  \\ 1/15 & 1/15 & 1/15 \\ 1/15 & 1/15 & 1/15\\ 1/15 & 1/15 & 1/15 \end{matrix} \right]</script>

如果是彩色图,那么 shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-183"><span class="mo" id="MathJax-Span-184" style="font-family: MathJax_Main;">(3,5)(3,5)<script type="math/tex" id="MathJax-Element-13">(3, 5)</script> 的核就等价于将上面每一个数组中 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.984em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1001.93em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-190"><span class="mn" id="MathJax-Span-191" style="font-family: MathJax_Main;">1/151/15<script type="math/tex" id="MathJax-Element-14">1/15</script> 都换成 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 7.451em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1007.33em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-197"><span class="mo" id="MathJax-Span-198" style="font-family: MathJax_Main;">[1/15,1/15,1/15][1/15,1/15,1/15]<script type="math/tex" id="MathJax-Element-15">[1/15, 1/15, 1/15]</script>。然后每个通道会单独进行运算,所以对于每个通道而言,对应卷积核相当于还是上面那个数组。当然了,我们上面指定的 shape 是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-218"><span class="mo" id="MathJax-Span-219" style="font-family: MathJax_Main;">(3,5)(3,5)<script type="math/tex" id="MathJax-Element-16">(3, 5)</script>,你也可以指定不同的 shape 看看效果如何。

总之结论是核越大,图像越模糊。因为核的维度是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-225"><span class="mo" id="MathJax-Span-226" style="font-family: MathJax_Main;">(1,1)(1,1)<script type="math/tex" id="MathJax-Element-17">(1, 1)</script> 时,每个元素处理之后还是它本身;如果核越大,那么选取周围的像素点就越多,取平均值之后和原始的像素点的差异就越大,图像就越模糊。

如果是彩色图,那么 shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-232"><span class="mo" id="MathJax-Span-233" style="font-family: MathJax_Main;">(3,5)(3,5)<script type="math/tex" id="MathJax-Element-18">(3, 5)</script> 的核就等价于将上面每一个数组中 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.984em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1001.93em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-239"><span class="mn" id="MathJax-Span-240" style="font-family: MathJax_Main;">1/151/15<script type="math/tex" id="MathJax-Element-19">1/15</script> 都换成 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 7.451em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1007.33em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-246"><span class="mo" id="MathJax-Span-247" style="font-family: MathJax_Main;">[1/15,1/15,1/15][1/15,1/15,1/15]<script type="math/tex" id="MathJax-Element-20">[1/15, 1/15, 1/15]</script>。然后每个通道会单独进行运算,所以对于每个通道而言,对应卷积核相当于还是上面那个数组。

方框滤波

方框滤波和均值滤波比较相似,我们直接来看 cv2 提供的函数。

import cv2

im = cv2.imread(r"bg.png")
# 方框滤波采用 boxFilter 函数
# 第一个参数是原始图像
# 第二个参数表示图像的深度,设置为 -1 表示生成的图像和原始图像的深度一样
# 第三个参数表示卷积核的 shape,注意:这里仍是宽度在前、高度在后
im2 = cv2.boxFilter(im, -1, (3, 5))
cv2.imshow("girl", im2)
cv2.waitKey(0)

这里就不显示生成的图像了,和上面采用均值滤波得到图像是一样的,但是该函数可以指定生成的图像的深度。这里需要解释一下,什么是图像的深度。

首先介绍一下像素深度,像素深度指的是存储每个像素所需要的 bit 数,假定存储每个图像所需要的像素需要 8 bit,那么图像的像素深度就是 8 bit。至于图像的深度则是实际使用的 bit 数,比如每个像素需要 16 bit 来存储,但是实际只用了 15 个 bit,那么图像的像素深度就是 16 bit、图像的深度就是 15 bit。

我们平常简单的 RGB 图像,每个像素点有 3 个分量,一个分量用 8 个 bit,那么像素深度就是 24 bit,当然图片深度也是 24 bit。

因为图像的深度表示实际使用的 bit 数,所以图像的深度越大,能表达的色彩种类就越丰富。但我们一般都设置成 -1,表示和原始图像的深度一致。那么问题来了,这不就和 blur 函数一模一样了吗?所以 boxFilter 函数还有一个 normalize 参数,它表示是否对图片进行归一化处理。

当进行归一化处理时,shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-267"><span class="mo" id="MathJax-Span-268" style="font-family: MathJax_Main;">(3,5)(3,5)<script type="math/tex" id="MathJax-Element-21">(3, 5)</script> 的核就是:

<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 9.656em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(2.366em, 1009.34em, 9.48em, -1000em); top: -6.173em; left: 0em;"><span class="mrow" id="MathJax-Span-274"><span class="mrow" id="MathJax-Span-275"><span class="mo" id="MathJax-Span-276" style="vertical-align: 3.675em;"><span style="display: inline-block; position: relative; width: 0.667em; height: 0px;"><span style="position: absolute; font-family: MathJax_Size4; top: -2.858em; left: 0em;">⎡<span style="display: inline-block; width: 0px; height: 4.012em;">1/151/151/151/151/151/151/151/151/151/151/151/151/151/151/15[1/151/151/151/151/151/151/151/151/151/151/151/151/151/151/15]<script type="math/tex" id="MathJax-Element-22">\left[ \begin{matrix} 1/15 & 1/15 & 1/15\\ 1/15 & 1/15 & 1/15 \\ 1/15 & 1/15 & 1/15 \\ 1/15 & 1/15 & 1/15\\ 1/15 & 1/15 & 1/15 \end{matrix} \right]</script>

当不进行归一化处理时,shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-385"><span class="mo" id="MathJax-Span-386" style="font-family: MathJax_Main;">(3,5)(3,5)<script type="math/tex" id="MathJax-Element-23">(3, 5)</script> 的核就是:

<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 5.159em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(2.402em, 1004.84em, 9.267em, -1000em); top: -6.085em; left: 0em;"><span class="mrow" id="MathJax-Span-392"><span class="mrow" id="MathJax-Span-393"><span class="mo" id="MathJax-Span-394" style="vertical-align: 3.55em;"><span style="display: inline-block; position: relative; width: 0.667em; height: 0px;"><span style="position: absolute; font-family: MathJax_Size4; top: -2.858em; left: 0em;">⎡<span style="display: inline-block; width: 0px; height: 4.012em;">111111111111111[111111111111111]<script type="math/tex" id="MathJax-Element-24">\left[ \begin{matrix} 1 & 1 & 1\\ 1 & 1 & 1 \\ 1 & 1 & 1 \\ 1 & 1 & 1\\ 1 & 1 & 1 \end{matrix} \right]</script>

所以当进行归一化处理时,核(每一个通道)里面的所有元素加起来总和等于 1;不进行归一化,那么核里面的每个元素就是 1,因此新的元素相当于是周围 25 个元素直接相加,那么此时很容易出现溢出。另外当不指定第 normalize 参数、并将图像深度设置为 -1 时,方框滤波和均值滤波是等价的。

im = cv2.imread(r"bg.png")
# 不进行归一化
im2 = cv2.boxFilter(im, -1, (3, 5), normalize=False)
cv2.imshow("girl", im2)
cv2.waitKey(0)

此时变成了白色,因为所有元素都等于 255。当然,如果将核的规模改的小一点,比如 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-443"><span class="mo" id="MathJax-Span-444" style="font-family: MathJax_Main;">(2,2)(2,2)<script type="math/tex" id="MathJax-Element-25">(2, 2)</script>,那么此时就只有 4 个元素相加,因此会存在相加之后小于 255 的像素,所以还是可以看到图像的,我们将核改成 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-450"><span class="mo" id="MathJax-Span-451" style="font-family: MathJax_Main;">(2,2)(2,2)<script type="math/tex" id="MathJax-Element-26">(2, 2)</script> 来试一下。

虽然大部分都是白色,但依稀能看出写痕迹。

高斯滤波

再来看看高斯滤波,在这之前还是先回忆一下均值滤波。我们说对于均值滤波而言,核数组中的每一个元素都是一样的,我们以 shape 为 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.205em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1002.11em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-457"><span class="mo" id="MathJax-Span-458" style="font-family: MathJax_Main;">(3,3)(3,3)<script type="math/tex" id="MathJax-Element-27">(3, 3)</script> 的核数组为例。

但是这样做没有考虑到权重,如果离像素点越近,它的权重是不是也应该越大呢。如果你了解机器学习中的 KNN 算法,那么应该很熟悉这一点,KNN 算法中是选择离某个未知点最近的 N 个点,然后根据 N 个点中出现次数最多的样本值来作为未知点的样本值。但是这样做没有考虑到权重,假设我们选择了离未知点最近的 3 个点,其中一个点的样本值为 A,另外两个为 B,但是样本值为 B 的点距离未知点比样本值为 A 的点要远,那么我们也不能轻易的就说未知点的样本值就是 B,应该将权重也算进去。

说了这么多,其实主要是想说,高斯滤波和均值滤波的原理是一样的,只不过高斯滤波中每一个点的值不一样,距离要求的点越近,那么值就越大。当然啦,数组中所有元素加起来仍然等于 1。

然后来看看 OpenCV 给我们提供的高斯滤波函数:

import cv2

im = cv2.imread(r"bg.png")
"""
第一个参数(src):原始图像
第二个参数(ksize):核的 shape,依旧是宽度在前、高度在后,当然这里叫 size,我个人习惯称 shape
          shape 为 (3,5) 的核,对应的数组的 shape 应该是 (5, 3)
          当然啦,对于高斯滤波、或者说高斯模糊而言,我们不需要关心 shape
          因为高斯滤波要求核的前两个维度必须一致,
第三个参数(sigmaX):x 轴方向上的公差,用来控制权重的,每一个点会从中心点开始、按照指定方差依次递减
          一般我们指定为 0 即可,会根据核的大小自动计算出一个公差
          计算方式为:0.3 * ((kernel_size - 1) * 0.5 - 1) + 0.8
第五个参数(sigmaY):y 轴方向上的公差,这是个默认值,不传的话和 x 轴保持一致
"""
im2 = cv2.GaussianBlur(im, (3, 3), 0)
cv2.imshow("girl", im2)
cv2.waitKey(0)

以上就是高斯滤波,还是比较简单的。

中值滤波

最后来看一下中值滤波,它就更简单了,还是选择一个核,将核上面的值进行排序,然后位于中间位置的值(中位数)作为新的元素值。

最后来看一下中值滤波,它就更简单了,还是选择一个核,将核上面的值进行排序,然后位于中间位置的值(中位数)作为新的元素值。

import cv2

im = cv2.imread(r"bg.png")
# 第一个参数:原始图像
# 第二个参数:核的大小,并且大小必须一致
#           和前面三个滤波不同,这里的大小不再是元组,而是一个奇数,注意是奇数
im2 = cv2.medianBlur(im, 5)
cv2.imshow("girl", im2)
cv2.waitKey(0)

以上便是中值滤波的效果,当然我们这里的核比较大,看起来更模糊一些。如果指定成 3 的话,那么效果还是很不错的,特别是图片有很多微小的噪点,使用中值滤波的效果比上面三个滤波要更好一些。因为我们选择的是中值,不是平均、也不是加权平均,所以这些噪点不会对新图像造成影响。

图像腐蚀与图像膨胀

图像腐蚀与图像膨胀适用于二值图像,我们先来看看图像腐蚀。

图像腐蚀

它的原理我们借用一张图来解释。

首先是图像的左边是原始图像,然后还是有一个核在上面游走,如果与核重叠的所有像素都是白色,那么核中心的位置依旧是白色;但只要有一个不是白色,那么核中心的位置就替换成黑色。因此在经过该处理之后,白色的区域肯定会变小,所以相当于起到了腐蚀的效果。

可以想象一下,当核的中心位置刚踏入白色区域时,肯定有一部分还是在白色区域外面的,所以中心位置会被替换成黑色。只有当白色区域将整个核全部包起来时,核中心的位置才依旧是白色。因此不难想出,核的尺寸越大,那么白色区域就被腐蚀的越严重。

import numpy as np
import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:核,这里需要我们手动创建,我们直接直接调用 np.ones 即可
# 参数三:腐蚀多少次,不指定默认是 1 次
im2 = cv2.erode(im, np.ones((5, 5), dtype="uint8"))
cv2.imshow("girl", im2)
cv2.waitKey(0)

图像腐蚀应该作用在二值图像上,但也可以应用于彩色图像。

我们来换一个二值图像:

import numpy as np
import cv2

# 按照灰度图像读取
im = cv2.imread(r"threshold.png", cv2.IMREAD_GRAYSCALE)
_, thresh = cv2.threshold(im, 0, 255, cv2.THRESH_BINARY)
# 参数一:原始图像
# 参数二:核,这里需要我们手动创建,我们直接直接调用 np.ones 即可
# 参数三:腐蚀多少次,不指定默认是 1 次
im2 = cv2.erode(thresh, np.ones((5, 5), dtype="uint8"))
cv2.imshow("x", thresh)
cv2.imshow("y", im2)
cv2.waitKey(0)

我们看到周围的箭头就被腐蚀掉了,当然我们这里腐蚀的不是很完美,你也可以增大腐蚀次数,看看效果。

图像膨胀

说完了图像腐蚀,我们再来看看图像膨胀。我们说图像腐蚀是,只要核里面有一个元素是黑色,那么核中心的位置就会是黑色,正如上图显示的那样,白色的箭头(相当于噪声)会被去掉。但是也有可能腐蚀过重,导致我们不希望被腐蚀的区域也被腐蚀掉了。

所以便有了图像膨胀,它的原理和图像腐蚀类似,只不过功能相反,它是核里面只要有一个元素是白色,核中心的位置就会是白色。因此采用相同大小的核,进行一次图像腐蚀、再进行一次图像膨胀,即可有效取出图中的噪声,并且不丢失有效区域的信息。

import numpy as np
import cv2

im = cv2.imread(r"threshold.png", cv2.IMREAD_GRAYSCALE)
_, thresh = cv2.threshold(im, 0, 255, cv2.THRESH_BINARY)
# 参数一:原始图像
# 参数二:核,这里需要我们手动创建,我们直接直接调用 np.ones 即可
# 参数三:膨胀多少次,不指定默认是 1 次
im2 = cv2.dilate(thresh, np.ones((5, 5), dtype="uint8"))
cv2.imshow("x", thresh)
cv2.imshow("y", im2)
cv2.waitKey(0)

这里我们不腐蚀,直接进行膨胀看看有什么效果。

可以看到,白色箭头变粗了,以上就是图像膨胀的效果。

图像的开运算、闭运算和梯度运算

前面介绍了图像腐蚀和图像膨胀,而对图像先进行一次腐蚀操作、再进行一次膨胀操作,这两步合起来被称为图像的开运算。

import numpy as np
import cv2

im = cv2.imread(r"threshold.png", cv2.IMREAD_GRAYSCALE)
_, thresh = cv2.threshold(im, 0, 255, cv2.THRESH_BINARY)
# 参数一:原始图像
# 参数二:固定参数,cv2.MORPH_OPEN,表示开运算
# 参数三:核,仍然需要手动创建
im2 = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, np.ones((10, 10), dtype="uint8"))
cv2.imshow("x", thresh)
cv2.imshow("y", im2)
cv2.waitKey(0)

我们看到此时白色圆圈周围的箭头就被去掉了,因此将腐蚀操作和膨胀操作结合起来就称之为开运算。

那么闭运算你肯定要想到是什么了,先进行膨胀操作、再进行腐蚀操作,两步结合起来就叫做闭运算,那么闭运算一般作用在什么场景呢?还是以上图为例,假设圆圈的外面没有箭头,而是内部有很多的小黑点,我们要把内部的小黑点给去掉,该怎么做呢?

很简单,先进行图像膨胀,里面白色部分膨胀之后会将黑色的小点覆盖掉,然后再进行图像腐蚀即可,所以两步结合起来叫做图像的闭运算。

import numpy as np
import cv2

im = cv2.imread(r"threshold2.png", cv2.IMREAD_GRAYSCALE)
_, thresh = cv2.threshold(im, 0, 255, cv2.THRESH_BINARY)
# 参数一:原始图像
# 参数二:固定参数,cv2.MORPH_CLOSE,表示闭运算
# 参数三:核,仍然需要手动创建
im2 = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, np.ones((15, 15), dtype="uint8"))
cv2.imshow("x", thresh)
cv2.imshow("y", im2)
cv2.waitKey(0)

由于里面的点比较大,所以我们这里的核也设置的大一些。如果图像里面的黑点有大有小,那么当核设置的不够大时,可能造成较小的黑点被消除、但较大的黑点仍被保留的情况出现。对于当前来说,将大小设置为 15 可以有效消除,这就便是图像的闭运算,经过一次膨胀和一次腐蚀,即可消除有效区域内部的噪点。

最后来看看什么是梯度运算,先对图像进行一次膨胀运算,再进行一次腐蚀运算,然后让膨胀运算的结果减去腐蚀运算的结果,相当于对图像进行了梯度运算。

那么梯度运算的意义何在呢?可以想一下, 先膨胀再腐蚀,两者相减,那么不就相当于获取图像的轮廓吗?

# 函数还是一样的, 但是第二个参数变成了 cv2.MORPH_GRADIENT
im2 = cv2.morphologyEx(im, cv2.MORPH_GRADIENT, np.ones((15, 15), dtype="uint8"))

具体过程不演示了,可以自己测试一下。

图像的礼帽运算和黑帽运算

先来看看图像的礼帽运算,由于英文是 Top Hat,所以也被翻译成顶帽。它是做什么的呢?首先它等于原始图像减去经过开运算的图像,而经过开运算的图像相当于把外部的噪声给去掉了,所以原始图像减去经过开运算的图像得到的就是外部的噪声图像。

import numpy as np
import cv2

im = cv2.imread(r"threshold.png")
_, thresh = cv2.threshold(im, 0, 255, cv2.THRESH_BINARY)
# 参数一:原始图像
# 参数二:固定参数,cv2.MORPH_TOPHAT,表示礼帽运算
# 参数三:核,仍然需要手动创建
im2 = cv2.morphologyEx(im, cv2.MORPH_TOPHAT, np.ones((15, 15), dtype="uint8"))
cv2.imshow("x", thresh)
cv2.imshow("y", im2)
cv2.waitKey(0)

我们看到此时只保留了噪声图像。

礼帽运算大致就是这样,那么黑帽运算是做什么的呢?首先黑帽运算等于经过闭运算的图像减去原始图像,所以它也是保存噪声数据,只不过礼帽运算保存的噪声是在有效区域外面的,而黑帽运算保存的噪声是在有效区域里面。

import numpy as np
import cv2

im = cv2.imread(r"threshold2.png")
_, thresh = cv2.threshold(im, 0, 255, cv2.THRESH_BINARY)
# 参数一:原始图像
# 参数二:固定参数,cv2.MORPH_BLACKHAT,表示黑帽运算
# 参数三:核,仍然需要手动创建
im2 = cv2.morphologyEx(im, cv2.MORPH_BLACKHAT, np.ones((15, 15), dtype="uint8"))
cv2.imshow("x", thresh)
cv2.imshow("y", im2)
cv2.waitKey(0)

并且获取的噪声,呈现的点都是白色。

图像梯度

首先解释一下什么是图像梯度,图像梯度指的就是像素值变化的剧烈程度。比如一张图像由红色区域和蓝色区域组成,那么每个区域内部的像素点的梯度都是 0,因为周围像素点的颜色都是一样的,没有变化;但是在红色区域和蓝色区域的交界位置,梯度就很大了,因为像素值发生了改变。

所以图像梯度还是很好理解的,求像素值的变化程度,就是在求图像梯度。而求梯度可以通过以下算子来实现,我们分别介绍。

Sobel 算子

来看一张图:

假设图像上存在 P1、P2、... 、P9 共九个像素点,现在我们要求 P5 在 x 轴方向上的梯度,那么就可以这么做:

<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 20.459em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1020.36em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-464"><span class="mi" id="MathJax-Span-465" style="font-family: MathJax_Math; font-style: italic;">P<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.109em;">5x=(P3P1)+2(P6P4)+(P9P7)P5x=(P3−P1)+2∗(P6−P4)+(P9−P7)<script type="math/tex" id="MathJax-Element-28">P5_{x} = (P3 - P1) + 2 * (P6 - P4) + (P9 - P7)</script>,也就是也就是左右各选择一个像素点,然后作差。但是光有 P4 和 P6 还不够,还要将 P1、P3 和 P7、P9 考虑进去,但是它们离 P5 稍微远一些,所以权重是 1。

同理 y 轴方向上的梯度,你肯定知道该怎么求了,<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 20.414em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1020.32em, 2.94em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-498"><span class="mi" id="MathJax-Span-499" style="font-family: MathJax_Math; font-style: italic;">P<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.109em;">5y=(P7P1)+2(P8P2)+(P9P3)P5y=(P7−P1)+2∗(P8−P2)+(P9−P3)<script type="math/tex" id="MathJax-Element-29">P5_{y} = (P7 - P1) + 2 * (P8 - P2) + (P9 - P3)</script>,当然卷积核要变成下面这个样子。

而求梯度主要是判断某个像素点是不是处于边界位置,如果 x 方向上周围的像素点都一样,那么 x 方向上该点的梯度就是 0。如果不为 0,但是梯度很小,那么说明图像颜色虽然变化了,但是变化的很小,就类似于渐变;但如果梯度很大,那么就说明该点处于边界位置,它两侧的像素点差异就比较大了。

当然啦,我们上面说的都是某一个方向上的梯度,有可能某一点在 x 轴方向上的梯度很大,但是 y 轴方向上的梯度很小。所以我们还要将该点在两个方向上的维度综合起来,整体求梯度,计算规则也很简单:整体梯度值等于两个方向上的梯度值的平方和开根号,就类似于勾股定理那样。但是这样计算的话比较麻烦,所以后来又进行了简化,直接将两个方向上的梯度值的绝对值之和作为该点整体的梯度值,这就是 Sobel 算子。

因此:<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 10.053em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1009.93em, 2.94em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-532"><span class="mi" id="MathJax-Span-533" style="font-family: MathJax_Math; font-style: italic;">P<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.109em;">5Sobel=|P5x|+|P5y|P5Sobel=|P5x|+|P5y|<script type="math/tex" id="MathJax-Element-30">P5_{Sobel} =|P5_{x}|+ |P5_{y}|</script>

而 OpenCV 也提供了 Sobel 算子对应的函数,我们可以直接调用。

import cv2

im = cv2.imread(r"bg.png")
dy = cv2.Sobel(
    im,
    cv2.CV_64F,
    0,
    1
)

里面的参数需要解释一下,首先第一个参数表示原始图像这没有什么好说的。然后我们来看第二个参数,它表示生成图像的深度,这个参数我们上面也见过,当指定为 -1 时,生成图像的深度和原始图像保持一致。但是在这里我们不能指定为 -1,因为我们在计算 x 方向梯度的时候,会使用右边的像素值减去左边的像素值;在计算 y 方向梯度的时候,会使用下边的像素值减去上边的像素值。

但是这样问题来了,如果出现了负数该怎么办?直接说结论,负数会被截断为 0,因此就会造成左边界。所以我们要让生成图像可以存储负值,这里我们指定 cv2.CV_64F 即可。第三个参数表示是否在 x 方向上求梯度,为 1 表示求、为 0 表示不求;第四个参数表示是否在 y 轴上求梯度,为 1 表示求、为 0 表示不求。

import cv2

im = cv2.imread(r"bg.png")
dx = cv2.convertScaleAbs(cv2.Sobel(im, cv2.CV_64F, 0, 1))
dy = cv2.convertScaleAbs(cv2.Sobel(im, cv2.CV_64F, 1, 0))

以上就分别求得了两个方向上的梯度,如果第三、第四个参数都指定为 1,那么表示整体求梯度。不过我们一般不这么做,而是分别求梯度,求完了之后再单独计算,这样提取的效果更好一些。然后还有一点,就是计算完梯度之后又调用了 cv2.convertScaleAbs,因为虽然可以显示负值了,但是在生成图像的时候负数还是会被截断为 0,所以还要再调用一下 cv2.convertScaleAbs 将负数转换为它的绝对值。

另外 Sobel 函数里面还有一个默认参数叫 ksize 表示核的大小,需要是 1、3、5 ... 等奇数。不指定的话,核数组就是我们上图中展示的那样。

我们实际演示一下:

import cv2

im = cv2.imread(r"bg.png")
# 计算两个方向的梯度之后,将负数转成绝对值
dx = cv2.convertScaleAbs(cv2.Sobel(im, cv2.CV_64F, 0, 1))
dy = cv2.convertScaleAbs(cv2.Sobel(im, cv2.CV_64F, 1, 0))
# 但是我们还不能直接相加,因为可能出现溢出
# 因此将每个梯度乘上0.5,然后进行相加,效果会比较好
# 而 cv2 也提供了相应的函数帮助我们实现,以下就等价于 dx * 0.5 + dy * 0.5 + 0
# 第 5 个参数是 gamma,表示修正值,传递 0 表示不需要修正
im2 = cv2.addWeighted(dx, 0.5, dy, 0.5, 0)
cv2.imshow("girl", im2)
cv2.waitKey(0)

以上就计算出了图像的边界,当然我们这里使用的是彩色图像,如果使用二值图像,那么会更直观。

Scharr 算子

再来看一个新的算子, Scharr 算子。既然要介绍新的算子,那就证明 Sobel 算子肯定有不完美的地方。确实如此,默认 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.94em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.716em, 1001.9em, 2.668em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-570"><span class="mn" id="MathJax-Span-571" style="font-family: MathJax_Main;">333∗3<script type="math/tex" id="MathJax-Element-31">3 * 3</script> 的 Sobel 算子,可能不太精确,而 Scharr 算子的效果要更好。

当然啦,背后的原理统统都是一样的,无非就是核数组里面的值不同,我们来看一下。

我们看到两个算子只有这一点不同,也就是值不同,但是 Scharr 算子的表现效果更好,所以我们一般都选择它。

im = cv2.imread(r"bg.png")
# 在使用上 cv2.Sobel 和 cv.Scharr 是类似的
# 区别就是:使用 Scharr 算子我们一次只能计算一个方向上的梯度
# 也就是 dx 和 dy 必须一个为 1、一个为 0,算完了之后再手动相加
# 然后该函数没有 ksize 参数,另外 Sabel 函数中的 ksize 如果指定为 1,那么会改良使用这里的 Scharr 算子
dx = cv2.convertScaleAbs(cv2.Scharr(im, cv2.CV_64F, 0, 1))
dy = cv2.convertScaleAbs(cv2.Scharr(im, cv2.CV_64F, 1, 0))
im2 = cv2.addWeighted(dx, 0.5, dy, 0.5, 0)
cv2.imshow("girl", im2)
cv2.waitKey(0)

以上是两个梯度组合的结果,我们也可以查看某一个梯度,比如将上面的 dx、dy 显示成图像,看看效果。

Laplacian 算子

最后来看一下 Laplacian 算子,也就是拉普拉斯算子。我们上面说的 Sobel 算子和 Scharr 算子都是用右列减去左列、下一行减去上一行,两者无非是系数不一样,Sobel 算子是 1、2、1,Scharr 算子是 3、10、3。而拉普拉斯算子类似于二阶 Sobel 导数,它使用的卷积核如下。

而 P5 这一点的梯度值等于 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 13.36em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1013.31em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-575"><span class="mo" id="MathJax-Span-576" style="font-family: MathJax_Main;">(P2+P4+P6+P8)4P5(P2+P4+P6+P8)−4∗P5<script type="math/tex" id="MathJax-Element-32">(P2 + P4 + P6 + P8) - 4 * P5</script>,所以相比前两个算子,拉普拉斯算子在同一个方向上计算了两次,所以它是二阶 Sobel。

import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:图像深度,因为可能出现负数,所以指定为 CV_64F
# 当然在显示的时候还是会将负数进行截断、变成 0,所以还要将负数变成绝对值
im2 = cv2.convertScaleAbs(cv2.Laplacian(im, cv2.CV_64F))
cv2.imshow("girl", im2)
cv2.waitKey(0)

Laplacian 里面还有一个默认参数 ksize,用于指定核大小。

Canny 边缘检测

下面来介绍 Canny 边缘检测,它可以用来描述图像的轮廓,总共分为以下四步。

第一步:去噪

边缘检测容易受到噪声的影响,因此在进行边缘检测之前要先进行去噪,一般采用高斯滤波来去除噪声。高斯滤波应该没有忘记吧,对周围像素计算加权平均值,但是较近的像素具有较大的权重值。

第二步:梯度

对平滑后的图像采用 Sobel 算子计算梯度和方向,计算梯度很简单,我们计算两个方向上的梯度之后,将平方和相加开根号即可。但是这样比较麻烦,所以换了一种做法,直接将两个梯度的绝对值(乘上 0.5)相加作为整体梯度。

然后是方向,梯度的方向我们之前没有说,但是相信你也能猜出来。这个方向我们可以理解为角度,这个角度的正切值要等于 y 方向的梯度除以 x 方向的梯度。换句话说,角度就等于 y 方向的梯度除以 x 方向的梯度的反正切值。

另外梯度的方向一般总是与边界垂直,所以它被归为四类:垂直、水平和两个对角线。

第三步:非极大值抑制

在获取了梯度和方向后,遍历图像,去除所有不是边界的点,也就是非极大值抑制。实现方法就是逐个遍历像素点,判断当前像素点是否是周围像素点中具有相同方向梯度的最大值,如果是最大值则保留、不是则抛弃。

A、B、C 三点具有相同的方向,梯度方向垂直于边缘,然后判断点 A 是否为三个点的局部最大值,如果是则保留该点;否则,该点将被抑制(归零)。

第四步:滞后阈值

最后是滞后阈值,在第三步处理完之后,我们还可以再指定两个阈值:minVal 和 maxVal。如果梯度值小于 minVal 直接丢弃,梯度值大于 maxVal 说明是边界则保留。然后对于那些梯度值大于 minVal 小于 maxVal 的梯度值,如果与边界相连则保留,与边界不相连则丢弃。

也就是说滞后阈值负责将小于极小值或者没有连接到边界的值给踢掉,比如上面的 B、D 在经过第三步处理之后也是边界,因为计算的就是梯度。但在滞后阈值处理时,我们将只有大于极大值的点才称为边界,而 D 比极小值小、B 和边界不相连,所以经过滞后阈值处理后,它们就不是边界了。

所以很容易得出,如果在滞后阈值处理时,两个值指定的越大,那么就会有越多能被称为边界的值被忽略掉。相反,两个值指定的越小,那么就会有更多的细节被保留。有一些点,它可以是边界也可以不是,至于到底是不是,则取决于这两个极值的大小,极值大就不是,极值小就是。因此不难看出,滞后阈值相当于是对边界的二次筛查。

当然啦,以上都是原理部分,可以不用知道,OpenCV 提供了现成的函数,我们可以直接拿来用。

import cv2

im = cv2.imread(r"bg.png")
# 原始图像、极小值、极大值
im2 = cv2.Canny(im, 64, 128)
# 将极值调的小一些,也就是筛查的范围更宽松一些,因此保留的细节也会更多一些
im3 = cv2.Canny(im, 32, 64)
cv2.imshow("girl1", im2)
cv2.imshow("girl2", im3)
cv2.waitKey(0)

不难看出,右侧的边缘细节保留的更多。

图形绘制:线段、矩形、圆、椭圆、多边形、文字等等

图像绘制比较简单,其实在最开始我们就绘制过了,只要找到对应的像素点,然后改变像素值即可。但是这需要我们自己计算,比如绘制一条线段,我们需要自己找到线段上的每一个像素点,比较麻烦,而 OpenCV 提供了一些函数可以方便我们实现。

以下涉及到的坐标,都是按照 (宽度, 高度) 的方式来表示的。对应数组的话,就是第二个维度在前、第一个维度在后。

绘制线段

线段绘制在 cv2 中通过 line 这个函数来实现。

import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:起点坐标
# 参数三:终点坐标
# 参数四:线条颜色
# 参数五:线条粗细
cv2.line(im, (100, 100), (300, 300), (0, 255, 0), 3)
cv2.imshow("girl", im)
cv2.waitKey(0)

注意:绘图函数没有返回值,直接是在原始图像上进行操作的。

绘制矩形

绘制矩形使用 rectangle 函数。

import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:矩形的左上顶点
# 参数三:矩形的右下顶点
# 参数四:线条颜色
# 参数五:线条粗细,如果指定为 -1,那么表示对绘制出的矩形进行填充
cv2.rectangle(im, (100, 100), (300, 300), (0, 255, 0), 3)
cv2.imshow("girl", im)
cv2.waitKey(0)

比如我们通过人脸识别技术,找到脸部所在区域,那么就可以通过绘制矩形的方式将其框起来。

绘制圆形

绘制圆形使用 circle 函数。

import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:圆心
# 参数三:半径
# 参数四:线条颜色
# 参数五:线条粗细,如果指定为 -1,那么表示对绘制出的圆形进行填充
cv2.circle(im, (150, 150), 100, (255, 255, 0), 3)
cv2.imshow("girl", im)
cv2.waitKey(0)

绘制椭圆

绘制椭圆使用 ellipse 函数。

import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:椭圆圆心
# 参数三:椭圆的长轴和短轴,使用元组表示,长轴在前
# 参数四:椭圆的旋转方向,正数为顺时针,负数为逆时针
# 参数五:椭圆的起始角度,0 ~ 360
# 参数六:椭圆的结束角度,0 ~ 360
# 参数七:线条颜色
# 参数八:线条粗细,如果指定为 -1,那么表示对绘制出的椭圆进行填充
cv2.ellipse(im, (150, 150), (120, 60), 1, 15, 300, (255, 111, 123), -1)
cv2.imshow("girl", im)
cv2.waitKey(0)

由于该函数参数有点多,可能有人不清楚,这些参数是怎么控制椭圆形状的,我们来解释一下。

  • 参数二:椭圆圆心这个应该无需解释,它就表示椭圆的中心位置
  • 参数三:椭圆的长轴和短轴,注意:长轴和短轴可以想象成圆的半径,但是长轴指的是水平方向上的轴,短轴指的是垂直方向上的轴。但并不是说长轴就长、短轴就短,只不过我们这里给给长轴和短轴赋的值分别是 120、60,所以椭圆看起来是扁平的。如果我们赋的值是 60、120,那么椭圆就变成了细高的形状
  • 参数四:椭圆是按照顺时针绘制还是逆时针绘制
  • 参数五:绘制的起始角度,因为是顺时针绘制,所以先顺时针转了 15 度之后才开始绘制
  • 参数六:绘制的结束角度,因为顺时针转了 300 之后就停止绘制了,所以图像就是上面的那个样子
  • 参数七和参数八:线条颜色无需解释,然后是粗细,指定 -1 表示对区域进行填充。如果是正整数,那么就是线条宽度了

但是我们看到这个椭圆的边绘制的不是很好,有锯齿,因此我们还可以指定第九个参数 lineType,表示线段类型,我们指定 cv2.LINE_AA 即可,这个绘制出来的图像的边就会好看许多。当然不止绘制椭圆有 lineType 这个参数,绘制其它图形的函数里面都有这个参数,并且都是指定线条粗细参数的下一个参数。

绘制多边形

绘制多边形使用 polylines 函数。

import numpy as np
import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:多边形的坐标组成的数组,并且这个数组比较奇葩,是一个三维的,[[ [宽, 高], [宽, 高], [宽, 高], ...  ]]
# 参数三:是否将这些第一个点和最后一个点连起来
# 参数五:线条颜色
# 参数六:线条粗细,如果指定为 -1,那么表示对绘制出的多边形进行填充
cv2.polylines(im,
              np.array([[[207,63],[171,122],[189,188],[210,240],[242,254],[269,231],[291,153],[268,86],[247,59]]]),
              True, (170, 212, 131), 3)
cv2.imshow("girl", im)
cv2.waitKey(0)

绘制文字

然后是在图像上显示文字。

import cv2

im = cv2.imread(r"bg.png")
# 参数一:原始图像
# 参数二:绘制的文字内容
# 参数三:在什么位置开始绘制
# 参数五:字体
# 参数六:文字大小,等于字体的基础大小乘上传入的值,比如传入 1.5 表示放大 1.5 倍
# 参数七:文字颜色
# 参数八:文字的线条粗细
# 参数九:线条类型,可取值为 cv2.LINE_4、cv2.LINE_8 分别表示 8 邻接连接线、4 邻接连接线
#        但是更推荐使用 cv2.LINE_AA,它是反锯齿连接线,背后采用了高斯滤波
cv2.putText(im,
            "komeji satotri",
            (200, 300),
            cv2.FONT_HERSHEY_SIMPLEX,
            1.8,
            (223, 222, 156),
            3,
            cv2.LINE_AA)
cv2.imshow("girl", im)
cv2.waitKey(0)

以上就是图形绘制相关的内容。

图像金字塔

再来说一说图像金字塔,首先我们不是说金字塔本身,而是值它的形状,假设我们给金字塔水平切上一刀,会得到一个断裂面。而切的位置越往上,那么得到的断裂面的面积就越小,可以想象一下金字塔的形状。而图像金字塔是什么呢?我们有一张图像 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.811em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-595"><span class="msubsup" id="MathJax-Span-596"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-597" style="font-family: MathJax_Math; font-style: italic;">G0G0<script type="math/tex" id="MathJax-Element-33">G_{0}</script>,然后对其采样,水平和垂直方向上各取一半的像素,得到新图像 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.796em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-602"><span class="msubsup" id="MathJax-Span-603"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-604" style="font-family: MathJax_Math; font-style: italic;">G1G1<script type="math/tex" id="MathJax-Element-34">G_{1}</script>,那么 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.796em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-609"><span class="msubsup" id="MathJax-Span-610"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-611" style="font-family: MathJax_Math; font-style: italic;">G1G1<script type="math/tex" id="MathJax-Element-35">G_{1}</script> 的分辨率就是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.811em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-616"><span class="msubsup" id="MathJax-Span-617"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-618" style="font-family: MathJax_Math; font-style: italic;">G0G0<script type="math/tex" id="MathJax-Element-36">G_{0}</script> 的 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 0.705em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.516em, 1000.71em, 3.017em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-623"><span class="texatom" id="MathJax-Span-624"><span class="mrow" id="MathJax-Span-625"><span class="mfrac" id="MathJax-Span-626"><span style="display: inline-block; position: relative; width: 0.474em; height: 0px; margin-right: 0.12em; margin-left: 0.12em;"><span style="position: absolute; clip: rect(3.409em, 1000.3em, 4.145em, -1000em); top: -4.406em; left: 50%; margin-left: -0.177em;"><span class="mn" id="MathJax-Span-627" style="font-size: 70.7%; font-family: MathJax_Main;">1414<script type="math/tex" id="MathJax-Element-37">{\frac 1 4}</script>。然后我们在 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.796em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-630"><span class="msubsup" id="MathJax-Span-631"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-632" style="font-family: MathJax_Math; font-style: italic;">G1G1<script type="math/tex" id="MathJax-Element-38">G_{1}</script> 的基础上按照相同的规则继续采样,得到 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.796em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-637"><span class="msubsup" id="MathJax-Span-638"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-639" style="font-family: MathJax_Math; font-style: italic;">G2G2<script type="math/tex" id="MathJax-Element-39">G_{2}</script>,然后是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.811em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-644"><span class="msubsup" id="MathJax-Span-645"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-646" style="font-family: MathJax_Math; font-style: italic;">G3G3<script type="math/tex" id="MathJax-Element-40">G_{3}</script>、<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.796em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-651"><span class="msubsup" id="MathJax-Span-652"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-653" style="font-family: MathJax_Math; font-style: italic;">G4G4<script type="math/tex" id="MathJax-Element-41">G_{4}</script>、......,那么这些图像从下往上是不是也构建成了一个金字塔的形状呢?

所以图像金字塔就是同一图像的不同分辨率的子图集合,最下层是原始图像,然后越往上分辨率越小。而这种每次采样,分辨率都减少的方式叫做向下采样,并且图像金字塔是从下往上构建的;既然有向下采样,那么就有向上采样,向上采样的话是每一次采样,分辨率都增加,此时图像金字塔是从上往下构建的。

显然向下采样,每采样一次,图像就会缩小为原来的四分之一;向上采样,每采样一次,图像就会扩大为原来的四倍。但是很明显,这个操作不是可逆的,向下采样 4 次,再向上采样 4 次,得到的图像在大小上虽然和原来是一样的,但是要模糊很多,因为很多像素信息在向下采样的时候丢失了,并且这些丢失的信息无法在向上采样的时候补回来,因为图像扩大的时候使用 0 进行填充的。下面就来说一说具体细节。

向下采样的具体步骤:

对图像 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.102em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.1em, 2.803em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-658"><span class="msubsup" id="MathJax-Span-659"><span style="display: inline-block; position: relative; width: 1.105em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-660" style="font-family: MathJax_Math; font-style: italic;">GiGi<script type="math/tex" id="MathJax-Element-42">G_{i}</script> 进行高斯卷积,或者说高斯滤波,然后删除所有的偶数行和偶数列,相当于面积变成了原来的 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 0.705em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.516em, 1000.71em, 3.017em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-665"><span class="texatom" id="MathJax-Span-666"><span class="mrow" id="MathJax-Span-667"><span class="mfrac" id="MathJax-Span-668"><span style="display: inline-block; position: relative; width: 0.474em; height: 0px; margin-right: 0.12em; margin-left: 0.12em;"><span style="position: absolute; clip: rect(3.409em, 1000.3em, 4.145em, -1000em); top: -4.406em; left: 50%; margin-left: -0.177em;"><span class="mn" id="MathJax-Span-669" style="font-size: 70.7%; font-family: MathJax_Main;">1414<script type="math/tex" id="MathJax-Element-43">{\frac 1 4}</script>。重点是高斯滤波,它的原理我们已经介绍过了,这里说一下卷积核,它的卷积核是一个 5 行 5 列的数组:

<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 11.243em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(2.402em, 1010.92em, 9.267em, -1000em); top: -6.085em; left: 0em;"><span class="mrow" id="MathJax-Span-672"><span class="texatom" id="MathJax-Span-673"><span class="mrow" id="MathJax-Span-674"><span class="mfrac" id="MathJax-Span-675"><span style="display: inline-block; position: relative; width: 1.181em; height: 0px; margin-right: 0.12em; margin-left: 0.12em;"><span style="position: absolute; clip: rect(3.409em, 1000.3em, 4.145em, -1000em); top: -4.406em; left: 50%; margin-left: -0.177em;"><span class="mn" id="MathJax-Span-676" style="font-size: 70.7%; font-family: MathJax_Main;">125614641416241646243624641624164146411256[1464141624164624362464162416414641]<script type="math/tex" id="MathJax-Element-44">{\frac 1 {256}}\left[ \begin{matrix} 1 & 4 & 6 & 4 & 1 \\ 4 & 16 & 24 & 16 &4 \\ 6 & 24 & 36 & 24 & 6 \\ 4 & 16 & 24 & 16 & 4\\  1 & 4 & 6 & 4 & 1 \end{matrix} \right]</script>

数组的所有元素加起来仍然是 1,但是为了直观,我们一般会写上面这种形式。这就是高斯滤波对应的核数组,当然原理就是我们之前说的那样,对周围像素计算加权平均值作为当前像素的值,并且较近的像素值具有更大的权重。

然后是删除掉偶数行和偶数列,如果原始图像是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 2.866em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.698em, 1002.87em, 2.646em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-758"><span class="mi" id="MathJax-Span-759" style="font-family: MathJax_Math; font-style: italic;">M<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.081em;">NM∗N<script type="math/tex" id="MathJax-Element-45">M * N</script>,那么新图像就是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 3.042em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.504em, 1003.04em, 3.009em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-763"><span class="texatom" id="MathJax-Span-764"><span class="mrow" id="MathJax-Span-765"><span class="mfrac" id="MathJax-Span-766"><span style="display: inline-block; position: relative; width: 0.863em; height: 0px; margin-right: 0.12em; margin-left: 0.12em;"><span style="position: absolute; clip: rect(3.397em, 1000.74em, 4.145em, -1000em); top: -4.406em; left: 50%; margin-left: -0.372em;"><span class="mi" id="MathJax-Span-767" style="font-size: 70.7%; font-family: MathJax_Math; font-style: italic;">M<span style="display: inline-block; overflow: hidden; height: 1px; width: 0.057em;">2N2M2∗N2<script type="math/tex" id="MathJax-Element-46">{\frac M 2} * {\frac N 2}</script>,每次向下采样,处理之后的图像都是处理之前的四分之一。以上操作,被称为 Octave。

显然这个过程是会丢失信息的。

向上采样的具体步骤:

向上采样的原理也是类似的,在每个方向上扩大为原来的二倍,新增的行和列用 0 来填充,所以此时图像金字塔是从上往下构建的。因此是向上采样还是向下采样,取决于图像是增大还是减小,如果是减小则向下采样、增大则向上采样。

向下采样是先卷积、然后扔掉一半的行和一半的列,向上采样则是先填充行和列为原来的两倍,然后再进行卷积,并且卷积核为向下采样使用的卷积核乘以 4。关于为什么要乘上 4,其实不难理解,我们看上图,在用 0 填充之后,每个元素都额外分配了 3 个 0。那么在卷积的时候,相当于要除以 4,因此卷积之后像素值整体变成了原来的四分之一,所以我们向上采样的时候要给卷积核乘上 4,保证得到的是一个正常的图像。

pyrDown 函数

在 OpenCV 中提供了 pyrDown 函数用于向下采样。

import cv2

im = cv2.imread(r"bg.png")
im2 = cv2.pyrDown(im)
im3 = cv2.pyrDown(im2)
cv2.imshow("girl", im)
cv2.imshow("girl2", im2)
cv2.imshow("girl3", im3)
cv2.waitKey(0)

可以看到函数用起来非常简单,只需要传递一个原始图像即可。

每次处理之后生成的图像,都是上一个图像的四分之一。

pyrUp 函数

在 OpenCV 中提供了 pyrUp 函数用于向上采样,这里我们用上面生成的 im3 进行向上采样两次,看看生成的图像和原始的图像有什么差异。其实可以很容易想到,大小是一样的,但是采样之后的图像会很模糊,因为向下采样丢失的像素无法在向上采样中补回来,因为补的都是 0。

import cv2

im = cv2.imread(r"bg.png")
im2 = cv2.pyrDown(im)
im3 = cv2.pyrDown(im2)
im4 = cv2.pyrUp(im3)
im5 = cv2.pyrUp(im4)
# 比较 im 和 im5 即可
cv2.imshow("girl1", im)
cv2.imshow("girl5", im5)
cv2.waitKey(0)

我们仍然只需要传递一个原始图像即可。

可以看到,清晰度降低了。

采样的可逆性研究

我们说先向下采样、后向上采样,图像的质量是会有损失的。但如果反过来呢?先向上采样,后向下采样,会有什么结果呢?

import cv2

im = cv2.imread(r"bg.png")
im2 = cv2.pyrUp(im)
im3 = cv2.pyrDown(im2)
cv2.imshow("girl1", im)
cv2.imshow("girl3", im3)
cv2.waitKey(0)

清晰度还是有所降低的,或者我们换一种方式来验证。

import cv2

im = cv2.imread(r"bg.png")
im2 = cv2.pyrUp(im)
im3 = cv2.pyrDown(im2)
cv2.imshow("girl", im3 - im)
cv2.waitKey(0)

以上就是丢失的信息。

拉普拉斯金字塔

我们上面介绍的金字塔实际上是高斯金字塔,下面来介绍拉普拉斯金字塔。拉普拉斯金字塔和高斯金字塔是类似的,也包含向上采样和向下采样,实际上拉普拉斯金字塔就是在高斯金字塔之上构建的,公式如下:

<span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 14.109em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1014.01em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-776"><span class="msubsup" id="MathJax-Span-777"><span style="display: inline-block; position: relative; width: 1em; height: 0px;"><span style="position: absolute; clip: rect(3.197em, 1000.65em, 4.145em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-778" style="font-family: MathJax_Math; font-style: italic;">Li=GipyrUp(pyrDown(Gi))Li=Gi−pyrUp(pyrDown(Gi))<script type="math/tex" id="MathJax-Element-47">L_{i} = G_{i} - pyrUp(pyrDown(G_{i}))</script>

先对原始图像使用高斯金字塔进行向下采样,再进行向上采样,最后用原始图像与之相减,得到的就是拉普拉斯金字塔图像。但需要注意的是里面的 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.102em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.1em, 2.803em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-811"><span class="msubsup" id="MathJax-Span-812"><span style="display: inline-block; position: relative; width: 1.105em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-813" style="font-family: MathJax_Math; font-style: italic;">GiGi<script type="math/tex" id="MathJax-Element-48">G_{i}</script>,它表示的永远是高斯金字塔图像。假设我们要获取 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.102em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.698em, 1001.1em, 2.811em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-818"><span class="msubsup" id="MathJax-Span-819"><span style="display: inline-block; position: relative; width: 1.11em; height: 0px;"><span style="position: absolute; clip: rect(3.197em, 1000.65em, 4.145em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-820" style="font-family: MathJax_Math; font-style: italic;">L5L5<script type="math/tex" id="MathJax-Element-49">L_{5}</script>,那么我们需要使用高斯金字塔得到 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.235em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.676em, 1001.24em, 2.811em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-825"><span class="msubsup" id="MathJax-Span-826"><span style="display: inline-block; position: relative; width: 1.215em; height: 0px;"><span style="position: absolute; clip: rect(3.175em, 1000.76em, 4.167em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-827" style="font-family: MathJax_Math; font-style: italic;">G5G5<script type="math/tex" id="MathJax-Element-50">G_{5}</script>,然后再减去 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 9.568em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1009.47em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-832"><span class="mi" id="MathJax-Span-833" style="font-family: MathJax_Math; font-style: italic;">pyrUp(pyrDown(G5))pyrUp(pyrDown(G5))<script type="math/tex" id="MathJax-Element-51">pyrUp(pyrDown(G_{5}))</script>,即可得到 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 1.102em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.698em, 1001.1em, 2.811em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-855"><span class="msubsup" id="MathJax-Span-856"><span style="display: inline-block; position: relative; width: 1.11em; height: 0px;"><span style="position: absolute; clip: rect(3.197em, 1000.65em, 4.145em, -1000em); top: -4.012em; left: 0em;"><span class="mi" id="MathJax-Span-857" style="font-family: MathJax_Math; font-style: italic;">L5L5<script type="math/tex" id="MathJax-Element-52">L_{5}</script>。

import cv2

im = cv2.imread(r"bg.png")
im_down = cv2.pyrDown(im)
im_up = cv2.pyrUp(im_down)
# 拉普拉斯的第一层
laplace1 = im - im_up
# 如果想得到拉普拉斯第二层该怎么办呢?
# 那么就以 im_down 作为原始图像,因为每一层都是基于高斯金字塔计算的
im_down2 = cv2.pyrDown(im_down)
im_up2 = cv2.pyrUp(im_down2)
laplace2 = im_down - im_up2
cv2.imshow("girl1", laplace1)
cv2.imshow("girl2", laplace2)
cv2.waitKey(0)

图像轮廓

下面介绍图像轮廓,估计可能会有人好奇,图像轮廓和上面介绍的图像边缘有什么区别呢?这两个是不是同一个东西呢?其实两者还是有区别的,我们先来解释一下边缘和轮廓之间的区别。

  • 边缘:边缘检测能够检测出边缘,但检测出的边缘不是连续的
  • 轮廓:将边缘连接为一个整体,构成轮廓

在 OpenCV 里面提供了两个函数:

  • cv2.findContours:查找图像轮廓,并返回
  • cv2.drawContours:将返回的轮廓信息绘制在图像上
import numpy as np
import cv2

im = cv2.imread(r"bg.png")
im_gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
# 查找轮廓的时候,必须使用二值图像
_, im_binary = cv2.threshold(im_gray, 127, 255, cv2.THRESH_BINARY)
# 然后寻找轮廓,参数含义如下
"""
参数一:处理后的二值图像
参数二:轮廓检索模式,有以下几种。
       cv2.RETR_EXTERNAL:只检测外轮廓
       cv2.RETR_LIST:检测的轮廓不建立等级关系
       cv2.RETR_CCOMP:建立两个等级的轮廓,上面的一层为外边界,里面的一层为内孔的边界信息。
                       如果内孔的内部还有一个连通物体,这个物体的边界也在顶层
       cv2.RETR_TREE:建立一个等级树结构的轮廓
       如果只检索外轮廓,那么用第一个参数;如果将所有轮廓都检索出来,那么一般选择最后一个即可
参数三:轮廓的近似方法,有以下几种。
       cv2.CHAIN_APPROX_NONE:存储所有的轮廓点,相邻的两个点的像素位置差不超过 1,
                              即 max(abs(x1 - x2), abs(y2 - y1)) == 1
       cv2.CHAIN_APPROX_SIMPLE:压缩水平方向,垂直方向,对角线方向上的元素
                                只保留该方向上的终点坐标,例如一个矩形轮廓只需要 4 个点来保存轮廓信息
       cv2.CHAIN_APPROX_TC89_L1:使用 teh-Chinl chain 近似算法                                                           
       cv2.CHAIN_APPROX_TC89_KCOS:使用 teh-Chinl chain 近似算法                                                           

返回值一:图像的轮廓
返回值二:图像的拓扑信息(轮廓层次),比如字轮廓是谁、父轮廓是谁等等                                         
"""
contours, hierarchy = cv2.findContours(im_binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

"""
参数一:原始图像,即上面的 im,为了避免原始图像破坏,我们最好拷贝一份,在拷贝之后的图像上做操作
参数二:需要绘制的边缘数组,即contours
参数三:需要绘制的边缘索引,如需全部绘制,则传入 -1
参数四:绘制的线条颜色,BGR 格式
参数五:绘制的线条粗细
参数六:lineType,线条类型,一般指定 cv2.LINE_AA 抗锯齿
"""
# 会返回新的数组,但是也会修改原始数组,所以我们拷贝了一份
im_ret = cv2.drawContours(np.array(im), contours, -1, (123, 213, 150), 1, cv2.LINE_AA)
cv2.imshow("girl1", im)
cv2.imshow("girl2", im_ret)
cv2.waitKey(0)

以上就是图像轮廓的查找与绘制,当然彩色图不是很明显,如果采样二值图,那么效果会非常明显。

这里多提一句,OpenCV 会将黑色当成背景色,白色当成前景色。

图像的直方图

直方图应该都见过,那么图像的直方图有啥意义呢?首先我们来解释一下图像直方图的横纵坐标分表代表啥?

  • 横坐标:图像中各个像素点的灰度级,比如一张灰度图,那么横坐标就是 0 ~ 255
  • 纵坐标:具有该灰度级的像素个数

假设下面这样一张灰度图:

横坐标就是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 4.85em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1004.73em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-862"><span class="mo" id="MathJax-Span-863" style="font-family: MathJax_Main;">[1,2,3,4,5][1,2,3,4,5]<script type="math/tex" id="MathJax-Element-53">[1, 2, 3, 4, 5]</script>,代表出现的灰度值;纵坐标就是 <span class="MathJax_Preview" style="color: inherit;"><span style="display: inline-block; position: relative; width: 4.85em; height: 0px; font-size: 126%;"><span style="position: absolute; clip: rect(1.631em, 1004.73em, 2.896em, -1000em); top: -2.513em; left: 0em;"><span class="mrow" id="MathJax-Span-875"><span class="mo" id="MathJax-Span-876" style="font-family: MathJax_Main;">[3,1,2,1,2][3,1,2,1,2]<script type="math/tex" id="MathJax-Element-54">[3, 1, 2, 1, 2]</script>,代表每个灰度值出现的次数。

说起直方图,还有归一化直方图,两者的区别就是归一化直方图的纵坐标表示的是灰度值出现的概率,也就是在原来的基础上再除以总次数即可。

然后我们来看看 OpenCV 如何统计直方图:

import cv2

im = cv2.imread(r"bg.png")
"""
参数一:原始图像,以数组的形式,比如 [im]
参数二:指定的通道,选择 B 通道就是 [0]、G 通道就是 [1]、R 通道就是 [2]
       如果是灰度图,直接写 [0] 即可,也是以数组的形式
参数三:mask,掩码图像。假设图像非常大,而我们只希望计算某一部分的直方图信息,那么就需要使用掩码
       光说的话比较抽象,后面会解释                                                           
参数四:BINS 的数量,可以理解为 x 轴坐标的数量,一般写 [256] 即可
参数五:像素值的范围,写 [0, 255] 即可
参数六:是否累计,默认值为 False,如果设置为 True,则直方图在开始分配时不会清零
       该参数允许从多个对象中计算单个直方图,或者用于实时更新直方图。
       当然这个参数用的少,不管它就行,一般情况下我们只计算单个图像的直方图
"""
# 会返回直方图信息,是一个长度为 256 的数组
hist = cv2.calcHist([im], [0], None, [256], [0, 255])
print(type(hist))  # <class 'numpy.ndarray'>
print(hist.shape)  # (256, 1)
print(hist)
"""
[[0.000e+00]
 [0.000e+00]
 [1.000e+00]
 [1.000e+00]
 [2.000e+00]
 [2.000e+01]
 [3.500e+01]
 ...
 ...
 [3.260e+03]
 [2.364e+03]
 [0.000e+00]]
"""

然后我们来绘制直方图,关于绘制图像 OpenCV 没有提供相应的函数,毕竟它不是一个绘图框架。至于绘图框架 Python 有很多,比如 matplotlib、pyecharts、plotly 等等,都可以绘制直方图,我个人最喜欢 plotly,我们就用它来绘制吧。

import numpy as np
import cv2
import plotly.graph_objs as go

im = cv2.imread(r"bg.png")
hist_b = cv2.calcHist([im], [0], None, [256], [0, 255])
hist_g = cv2.calcHist([im], [1], None, [256], [0, 255])
hist_r = cv2.calcHist([im], [2], None, [256], [0, 255])
# 这里我们以折线图的方式绘制
x = np.arange(0, 256)
hist_b_trace = go.Scatter(x=x, y=hist_b[..., 0], line={"width": 2, "color": "green"})
hist_g_trace = go.Scatter(x=x, y=hist_g[..., 0], line={"width": 2, "color": "cyan"})
hist_r_trace = go.Scatter(x=x, y=hist_r[..., 0], line={"width": 2, "color": "yellow"})

fig = go.Figure(data=[hist_b_trace, hist_g_trace, hist_r_trace],
                layout={"title": "直方图",
                        "template": "plotly_dark"})
fig.show()

绘制带掩码的直方图

在绘制直方图的时候还可以指定掩码,也就是我们只对图像的某一部分绘制直方图,那么具体怎么做呢?首先掩码也是一个图像,这个我们可以通过 numpy 去生成,首先掩码它的 shape 和原图像是相同的,值全部为 0,此时呈现黑色。然后你想计算原图像上的那一块的直方图,就把掩码对应部分的像素值改成 255 即可。

说白了就是掩码和原图像的 shape 是相同的,然后根据掩码中像素值为白色的部分,来计算原图像对应部分的直方图。

import numpy as np
import cv2

im = cv2.imread(r"bg.png")
# 生成掩码,此时元素值全部为 0
mask = np.zeros(im.shape[: 2], dtype="uint8")
# 将想要计算的区域指定为 255
mask[100: 300, 200: 400] = 255
hist_b = cv2.calcHist([im], [0], mask, [256], [0, 255])
hist_g = cv2.calcHist([im], [1], mask, [256], [0, 255])
hist_r = cv2.calcHist([im], [2], mask, [256], [0, 255])

然后进行绘制即可。

还有直方图均衡化,避免图像过亮或过暗。均衡化使用 cv2.equalizeHist 函数,接收一个原始图像、返回一个新的图像。