概述:实验楼 OpenCV 图像处理基础入门

0x01 实验一 基础知识

概述:本节实验将会给大家介绍计算机视觉的基本概念和应用、OpenCV 和其他软件包的安装,以及图像的基础知识。

知识点

  • 计算机视觉概念和应用
  • 图像处理工具
  • 像素
  • 图像的通道和属性

计算机视觉简介

计算机视觉是一门致力于教会计算机”看“的科学,其目的是让计算机理解图片的内容。通过眼睛看世界对于一个视力正常的人来说是一种与生俱来的本能,看图片并理解图片内容对人类来说是易如反掌,但是对于计算机来说,理解图片内容是非常困难的事情。当我们看到下面左边的图片时,我们可以很自然地理解图片的内容是一张桌子上面摆放着插满植物的玻璃瓶和一些书本;但是相同的图片在计算机“眼中”却是以右图中矩阵的形式呈现,这样就很难理解矩阵中哪些部分是杯子哪些部分是书本。长期以来计算机视觉领域都在致力于研究让计算机理解矩阵形式的图片中的内容,包括矩阵中存在什么,其大小、位置、形状等特征。

1-1

计算机视觉在生产生活中已经得到广泛地应用,例如广泛应用于机场和车站的人脸识别系统、手机相机的美颜功能、停车场的车牌识别、无人驾驶、AR 和 VR、药品研发、医疗影像检测等。经过 40 多年发展,计算机视觉领域已经细分出多个热门研究方向:物体检测、运动跟踪、语义分割、视觉问答、姿势体态识别等。计算机视觉的发展经历了从偏重计算和数学方法、90 年代传统的人工设计特征结合机器学习的分类器、到 2012 年以后以深度学习为主的视觉研发方法,计算机视觉正在以井喷式地发展趋势应用于我们的生活。

在后面的章节将循序渐进地带着大家入门计算机视觉,在课程的最后我们将通过实例让大家对所学过的方法进行一个回顾,并且课程不会涉及过多的传统视觉算法,例如 FAST、SIFT、SURF、Harris 等算法,因为深度学习的快速发展及其在视觉领域出色的表现,现在很难看到相关的传统算法的论文发表,传统算法逐渐退出了视觉领域的舞台。本课程介绍的内容是视觉领域中常常用到的基本图像处理方法,这些方法在图像预处理和对数据集进行处理时会经常用到,例如想要获得理想的深度学习模型,对数据集适当地处理就是重要的一环,所以掌握这些基本方法有助于在视觉项目中获得较好的结果。

OpenCV 和相关工具的安装

在进行图像相关的代码编写前,让我们先简单介绍一下各个软件包的作用和安装方法以及安装过程会遇到的问题:

OpenCV 是一个基于 C++ 编写的轻量级、高效的、开源的跨平台计算机视觉库,可运行在多种操作系统上:Windows、Mac OS、Linux、Android。由于其具有友好的可读性和较高的运行效率,故获得大量开发者的青睐,同时其还提供 Python、Ruby 等语言的接口方便开发者调用。本教程将使用 Python 语言对 OpenCV 的库函数进行调用。

NumPy 是一个支持处理多维度大型矩阵的 Python 科学计算包。在对图像进行处理时经常会用到 NumPy,OpenCV 中读取存储图片都是以 NumPy 形式完成的。利用 NumPy 我们可以轻松地以多维数据的形式呈现图片,并对图片进行重组、计算、数值分析等操作。想要深入的学习可以访问 NumPy 官方中文文档

Matplotlib 是一个 Python 2D 绘图库。其可以方便地生成直方图、条形图、散点图等。

本实验将在实验楼 WebIDE 环境下使用 Python 3 编写代码。我们使用 pip 来安装上述各类库,pip 是 Python 包安装和管理工具。只需要在终端中执行下面命令即可安装。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5 matplotlib==3.0.3

图像的基础知识

像素

每一张图片都是由一组像素构成的,像素是构成图片的最小单位。可以把一张图片看成是由许多小方格组成,那么每一个小方格就是像素。如果我们有一张宽为 100 高为 100 的图片,那么这个图片共有 100*100 = 10000 个像素。如下图,其中右边的图片中每一个方格代表一个像素。

1-2

大部分像素有 2 种表示形态:灰度和彩色。在灰度图像中,每一个像素都具有一个在 0 到 255 之间的值,这些不同的值称为像素值,也可称为像素的强度。其中 0 表示黑色而 255 表示白色,不同的灰度表示不同的明暗变化,越接近 0 的值在图像上表现的越暗,越接近 255 的值在图像上表现的越明亮。

彩色像素通常用 RGB 颜色空间表示,RGB 颜色空间以 红(Red)、绿(Green)、蓝(Blue)三种基本颜色为基础,进行不同程度的叠加从而产生丰富的颜色,也通常称为三通道图片。这三种颜色每一个都用一个 0 到 255 的值表示,通常使用 8 位无符号整数来表示这些颜色的强度。所以一个彩色像素值表示为 (red, green, blue)。例如红色可以表示为 (255, 0, 0),白色表示为 (255, 255, 255)

像素坐标

像素坐标用于表示一个像素所处在图片中的位置,我们用 (y, x) 来表示一个像素在图片中的位置,其中 y 表示行,x 表示列。下图是一张数字 4 的图片,图中每个方格代表一个像素点,这是一张宽为 10 高为 10 的图片,所以这里总共有 10*10 = 100 个像素,我们定义图中左上角顶点的位置为起始点,表示为 (0, 0)。因为第一个位置我们用 0 表示,所以第一行的第 6 个像素表示为 (0, 5)。同样的 (7, 5) 表示像素在第 8 行第 6 列。 知道了像素的位置后,我们就可以对指定像素进行相关的操作了。在这里需要注意的是我们设置的起始位置是 0 而不是 1,记住这一点可以在后面对像素的操作中避免很多问题。

1-3

图像的基本操作

载入、保存图片

知道了图片的一些基本知识,现在我们来利用 Python 和 OpenCV 对图片进行一些简单的操作。在操作之前我们先下载实验中我们将要用到的图片,在终端中输入如下命令会下载一个压缩文件到当前目录下。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

我们使用 unzip images.zip 命令解压,使用 ls images/ 命令查看解压后的文件。

1-4

解压完图片后,让我们学习从磁盘中载入一张图片。在实验过程中我们将会使用 IPython 交互式系统进行代码的编写,首先在终端中输入 ipython,如果一切正常的话我们将看到下面的画面。

1-5

首先导入我们需要使用到的包 cv2,cv2 是 OpenCV 数据库,其中包含了我们需要用到的函数。使用下面命令将导入 OpenCV 数据库。

import cv2

导入了数据库后,我们要调用里面的读取图片的函数,使用 cv2.imread 函数从磁盘中读取图片文件,括号中是我们需要读取图片的地址,这里请填写你自己的图片所在的目录和文件名。注意这里如果只是输入图片名的话,图片和这个脚本需要在同一目录下,否则函数会找不到图片导致读取失败。

image = cv2.imread("images/cup.jpg")

我们可以使用 type(image) 来看一下这个 image数据类型,如下图我们可以看到这个函数它返回一个 NumPy 数组用于表示图片。

我们使用 cv2.imwrite 保存图片,第一个参数 “new.jpg" 是我们要保存图片的地址,第二个参数 iamge 是我们要保存的图片。执行这条语句会输出一个 True,说明我们成功保存了图片。至此我们已经完成了图片的载入、显示、保存操作。

cv2.imwrite("new.jpg", image)

如果没有意外的话,我们使用 !ls 命令可以查看到当前目录下多了一个 new.jpg 文件,这就是我们刚才保存的图片。输入 quit() 退出 IPython 环境。

1-6

我们也可以点击 WebIDE 左侧边框栏的文件夹图标(图中被红色方框包围的图标)就能看到我们保存的 new.jpg 图片,点击 new.jpg 文件,图片就会在 WebIDE 中显示。

1-7

修改像素值

现在我们已经学会读取、显示、保存图片了,下面的例子中我们将对图片进行一些简单的操作。 点击左上方的 File-New File,创建一个名为 args.py 的 Python 文件,在该文件中编写如下代码。

import cv2
import argparse
 
ap = argparse.ArgumentParser()
ap.add_argument("--image", type = str, help = "image path")
args = ap.parse_args()
 
image = cv2.imread(args.image)

我们在 args.py 导入了一个新的模块 argparse,这个库用于帮我们解析命令行,让我们可以直接在命令行中向程序传入参数并让程序运行,之前我们使用 cv2.imread 读取图片时是将图片路径作为参数传入该函数,这样如果我们每次传入不同的图片路径就需要不断的更改传入函数的参数,使用 argparse 可以不用修改函数代码,直接将图片的路径作为命令传入函数 。

argparse.ArgumentParser 创建一个 ap 对象。使用 add_argument 函数添加命令行参数,这里我们只需要一个 --image 的参数,这个参数是我们要读取的图片路径。最后我们将会解析命令,然后将他们存储起来。

我们使用 args.image 将解析的图片路径传入 cv2.imread 函数中。

在 IPython 中运行下面这条命令后,图片将被 cv2.imread 函数读取。

%run args.py --image images/cup.jpg

值得注意的是在 --image 后面跟着的是图片的路径。

下图 In[2] 我们使用 type(image) 函数来测试下图片是否被读取,可以看到图片是以 NumPy 数组形式输出的。

接下来将学习获取像素值并对其进行修改。图片在计算机中是由许多数字组成的矩阵,要想获得指定的像素值,只需要提供该像素值在矩阵中的 (y, x) 坐标。下面的代码我们提供的坐标值为 (0, 0) 即图片的左上角顶点的坐标,然后获取其对应的 (b, g, r) 值。

(b, g, r) = image[0, 0]

这里需要注意的是通常我们认为的通道顺序是 Red,Green,Blue,但是 OpenCV 是按照相反的顺序存储的,即 Blue,Green,Red 顺序。下面我们将每个通道的像素值输出看一下。

print("coordinate (0, 0): Red = {:d}, Green = {:d}, Blue = {:d}".format(r, g, b))

从下图 In[4] 的输出可以看出红色通道的值为 172,绿色为 180, 蓝色为 182

使用下面的代码修改像素值,这里直接将坐标 (0, 0) 的值赋值为 (0, 0, 255),这个元组表示红色。因为上面我们提到 OpenCV 中 RGB 存储的顺序是反的,所以红色是 255,绿色是 0,蓝色是 0。

image[0, 0] = (0, 0, 255)

修改像素值很简单,下面代码先获取 (0, 0) 坐标的各个通道的值,然后使用 print 函数将值在终端中打印出来。

(b, g, r) = image[0, 0]
print("coordinate (0, 0): Red = {:d}, Green = {:d}, Blue = {:d}".format(r, g, b))

下图 In[7] 的输出可以看到坐标为 (0, 0) 的像素值已经被修改了。

1-8

现在我们已经学会了修改单个像素值,并且我们可以使用切片的方法获取并修改图片中指定的一块区域的像素值。下面的代码表示从图片的左上角顶点开始取一块 150*300 像素的区域。这里我们需要提供 4 个整数:要截取图片的起始位置、图片的高和宽。我们从图片的左上角顶点开始,所以 (0, 0) 是起始位置。我们提供的截取图片的高是 150 宽是 300,所以 0:1500:300 分别表示从起始点开始获取 150 行和 300 列像素值,然后使用 cv2.imwrite 保存获取到的部分图片,最后输出的 True 说明已经保存了图片。

block = image[0:150, 0:300]
cv2.imwrite("Block.jpg", block)

上面的代码会在当前目录下保存名为 Block.jpg 的图片,下面是截取图片的部分区域的结果。

1-9

下一步同样用切片的方法将获取的区域的像素值全部更改为红色,首先我们将 (0, 0, 255) 的值赋值给从 (0, 0) 起始,范围为 150*300 的区域,然后保存修改后的图片。

image[0:150, 0:300] = (0, 0, 255)
cv2.imwrite("Red_block.jpg", image)

同样上面代码会在当前目录下保存名为 Red_block.jpg 图片,从下图可以看到我们已经修改了图片中的部分区域的像素值。

1-10

拆分和合并图像通道

上一节中我们学习了获得彩色像素值的方法,这一节我们会学习将一张 RGB 彩色图片的三个通道拆分为单通道。在实际应用中有时候我们需要分别处理图像的 R,G,B 通道,这种情况下我们可以使用 cv2.split 函数来实现这个功能。首先通过下面的命令在 IPython 中运行已经在前面的实验编辑过的 args.py 文件。

%run args.py --image images/cup.jpg

然后使用 cv2.split 函数将三通道图片分别拆分为三个单独通道,这个函数只有一个参数,image 是我们要输入函数的图片,然后函数会按照 (b, g, r) 的顺序输出每个通道。

(b, g, r) = cv2.split(image)

下面代码使用三次 cv2.imwrite 函数分别将 rgb 保存为三张单通道图片。

cv2.imwrite("Red.jpg", r)
cv2.imwrite("Green.jpg", g)
cv2.imwrite("Blue.jpg", b)

下图为保存的结果,三张不同通道的图片区别不是很大,但是我们还是可以看出一些细微的差别。在原图中杯子旁边的书本上的两个扇形呈现棕黄色,在蓝色通道中其呈现的强度相较于绿色和红色通道中同样的位置更暗,表明这些区域中很少有蓝色。在绿色通道中这些区域显得更明亮些,说明其中包含的绿色比蓝色通道的同一区域更多。最后红色通道中这个区域最亮,表明这个区域中包含的红色比其他两个通道的都多。

1-11

我们可以使用 cv2.merged 函数将分开的通道合并,只需要按照 BGR 的顺序将列表 [b, g, r] 传入函数,这样函数会将三个单独通道合并为一张三通道图片,合并后的图片和原图是一样的。

sum = cv2.merge([b, g, r])
cv2.imwrite("sum.jpg", sum)

访问图片属性

一张图像具有多个属性:行数、列数、通道数、图像数据类型、像素数等。这一节我们将通过几个简单的函数来一一访问这些属性。首先通过下面的命令在 IPython 中运行已经在前面的实验编辑过的 args.py 文件。

%run args.py --image images/cup.jpg

我们可以通过 shape 方法获取图片的一些属性, 在 IPython 中输入下面代码将会获得图片的宽、高以及通道数。

image.shape

下图中 In[2] 可以看到 image.shape 返回的是一组包含图片属性的元组。第一个元素 333 是图片的高,表示图片有多少行像素。第二个元素 500 是图片的宽,表示图片有多少列像素。最后一个元素 3 是图片的通道数,我们使用的是 RGB 彩色图片,所以一共有三个通道。

我们使用不同的下标索引来访问各个元素。下图中 In[3] 使用 image.shape[0] 获取图片的高,In[4] 使用 image.shape[1] 获取图片的宽,In[5] 使用 image.shape[2] 获取图片的通道数。

接下来我们通过两行简单的代码来获得图片的像素值总数和图像的数据类型,如下图 In[6] 我们使用 image.size 获取图片的像素值的个数,这个值是 333*500*3 的结果。

In[7] 使用 image.dtype 获取像素值的数据类型,可以看到像素值的数据类型是 uint8 表示 8 位无符号整数。因为像素值的范围是 0 到 255,而 uint8 也是在这一范围。

1-12

知道图片的数据类型可以帮助我们在调试时避免很多问题,因为 OpenCV-Python 代码的大量错误是由无效的数据类型导致的。

0x02 绘图

概述:本节将教会大家如何使用 OpenCV 进行绘图、图像填充操作,并且将文字添加到图片中。

知识点

  • 图形的绘制
  • 图形的填充
  • 在图像中添加文字

基本绘图方法

OpenCV 中的绘图操作比较简单,但是绘图在计算机视觉应用中常常能够遇到。下图是两个应用绘图的例子。

2-1

很多图片编辑软件都有绘图的功能,我们可以利用这些软件在图片上随意绘制不同形状的图案。在人体姿势体态识别应用中,我们需要绘制出人体的基本体态,这个时候我们会用简单的圆形或椭圆形和一些线段绘制出人体基本的姿态。在物体检测领域我们需要绘制一些图形来标记出物体的位置,同时需要用文字标示出被检测物体是什么。在物体追踪的应用中我们需要绘制出物体运动的轨迹,在检测物体轮廓的应用中我们需要贴合物体边缘绘制出物体的基本轮廓等等。接下来的实验我们会利用 OpenCV 学习绘制一些基本图形。

画线

在开始实验之前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5

OpenCV 中的 cv2.line 函数用于画线操作。在画线之前我们要先创建一块区域用于绘制我们的线段。首先我们进入 IPython 环境并输入下面代码,第一行是导入 OpenCV 模块,第二行导入 NumPy 模块。

import cv2
import numpy as np

下面一行代码使用 np.zeros 创建一个所有像素值都是 0 的图片,我们将在这个图片上绘制图片。(500, 500, 3) 表示图片的高和宽都是 500 且有 3 个通道对应 RGB 空间的 Red,Green,Blue 通道。np.uint8 表示每个元素都是范围在 0 ~ 255 之间的无符号整数。

region = np.zeros((500, 500, 3), dtype = np.uint8)

下面的代码我们使用 cv2.line 函数来绘制线段,要绘制线段我们需要知道线段的起始位置和结束位置。

line = cv2.line(region, (50, 50), (450, 450), (0, 0, 255), 5)

cv2.line 的第一个参数 region 是我们刚才创建的图片。第二个参数 (50, 50) 表示线段起点位置是在第 50 行和第 50 列。第三个参数 (450, 450) 表示我们的线段的结束点在第 450 行和第 450 列。第四个参数 (0, 0, 255) 表示我们要绘制的线段的颜色是红色。最后一个参数表示我们绘制的线段的粗细,这里我们用 5 这个数字表示绘制的线段的厚度将占 5 个像素。

cv2.imwrite("line.jpg", line)

最后使用 cv2.imwrite 将绘制的图片保存,下图是绘制结果。

2-2

矩形

画矩形的方法和画线的方法类似,这里我们使用 cv2.rectangle 函数来绘制矩形。第一个参数 region 是刚才已经被绘制线段的图片。第二参数 (50, 50) 表示我们设置这个正方形的起始顶点是在 50 行和 50 列。第三个参数 (150, 150) 表示结束顶点是在第 150 行和第 150 列。第四个参数 (0, 255, 0) 表示设置这个矩形框的颜色为绿色。第五个参数 5 表示设置矩形框的粗细占 5 个像素。

rectangle = cv2.rectangle(region, (50,50), (150, 150), (0, 255, 0), 5)
cv2.imwrite("rectangle.jpg", rectangle)

我们将图片保存然后看下结果,下图中的绿色矩形就是我们绘制的。

2-3

下面我们再绘制一个矩形,这个矩形的起始点是 (200, 170) 结束点是 (300, 470) ,使用 (255, 0, 0) 蓝色绘制,第五个参数 -1 表示我们要用颜色将整个矩形填充,负数表示不单单绘制图形框,并且要将整个图形填充颜色。

rectangle = cv2.rectangle(region, (200, 170), (300, 470), (255, 0, 0), -1) cv2.imwrite("rectangle.jpg", rectangle)

保存绘制的图片,下图是绘制结果,蓝色的矩形是我们刚刚绘制的。

2-4

圆形和椭圆

我们使用 cv2.circle 来绘制圆形。第一个参数 region 是我们将要在上面作画的图片。第二个参数 (100, 100) 表示中心点坐标。第三个参数 100 表示圆形的半径。第四个参数 (255, 0, 0) 表示用蓝色绘制圆形。第五个参数 -1 表示将整个圆形填充颜色。

circle = cv2.circle(region, (100, 100), 100, (255, 0, 0), -1)
cv2.imwrite("circle.jpg", circle)

保存图片,下图中的蓝色圆形为绘画结果。

2-5

我们用下面的代码绘制第二个圆,圆心坐标为 (250, 250),半径为 200,颜色和轮廓粗细分别是红色和 5

cv2.circle(region, (250, 250), 200, (0, 0, 255), 5)
cv2.imwrite("circle.jpg", circle)

保存图片,下图中红色的圆圈即是我们绘制的结果。

2-6

我们调用 cv2.ellipse 函数绘制椭圆。第一个参数 region 是要在上面绘制椭圆的图片。第二个参数 (200, 200) 是椭圆中心的坐标。第三个参数 (150, 50) 表示椭圆的轴长,其中 150 表示长轴,50 表示短轴。第四个参数 0 表示椭圆处于一个水平的状态,第四个参数表示沿顺时针方向椭圆的旋转角度 。第五个参数 0 表示沿顺时针方向绘制椭圆的起始角度,第六个参数 180 表示沿顺时针方向绘制椭圆的结束角度 ,所以第五、第六参数表示我们将绘制半个椭圆。第七个参数 (0, 255, 0) 表示绘制椭圆的颜色为绿色。第八个参数 -1 表示用绿色将这半个椭圆填充满。

ellipse = cv2.ellipse(region, (200, 200), (150, 50), 0, 0, 180, (0, 255, 0), -1)
cv2.imwrite("ellipse.jpg", ellipse)

保存图片,可看到下图中绿色的半个椭圆是我们绘画的结果。

2-7

下面我们绘制第二个椭圆,这个椭圆的中心坐标是 (300, 300),轴长为 (200, 100),旋转角度为 45 表示椭圆沿顺时针方向旋转 45 度,起始和结束点为 0360 表示绘制一个完整的椭圆,椭圆的颜色为白色并将其填充满。

ellipse = cv2.ellipse(region, (300, 300), (200, 100), 45, 0, 360, (255, 255, 255), -1)
cv2.imwrite("ellipse.jpg", ellipse)

保存图片,下图中白色的椭圆是我们绘制的结果。

2-8

多边形

绘制多边形需要用到 cv2.polylines 函数。绘制一个多边形需要知道多边形各个顶点的坐标,下面的代码我们取五对值表示多边形有 5 个顶点。首先我们使用 np.array 将列表[[[20,50]], [[100,100]], [[200,300]], [[300,400]], [[250,450] 转化为 NumPy 数组,列表以这样的形式转化,表示转化后的 NumPy 数组是一个三维数组,其第一维由 5 个二维数组组成分别对应 5 个顶点;每个二维数组是由一个一维数组组成,表示每一个顶点的坐标;每个一维数组由两个元素组成,对应顶点的 x,y 坐标值;所以 pts 数组是(顶点数 * 1 * 2)的形式。

pts = np.array([20,50, 100,100, 200,300, 300,400, 250,450], np.int32)

下面的代码我们将绘制一个多边形。第一个参数 region 表示要被用于绘图的图片。第二个参数 [pts] 是多边形的顶点,这个参数必须是用列表存储的 NumPy 数组。第三个参数 True 表示各个顶点的连线闭合组成一个多边形,False 表示只用线段连接各个顶点得到一条折线。第四个参数 (255, 255, 0) 表示多边形的颜色是浅蓝色,第五个参数表示多边形框的粗细是 5

polylines = cv2.polylines(region, [pts], True, (255, 255, 0), 5)
cv2.imwrite("polylines.jpg", polylines)

保存图片,下图中浅蓝色多边形即为绘制结果。

2-9

下面我们再创建一个 NumPy 数组,同样的这个数组表示 5 个顶点。

pts = np.array([10, 60, 290, 250, 300, 350, 470, 390], np.int32)

然后再次调用 cv2.polylines 函数,我们用上面创建的新的数组作为第二个参数,第三个参数我们使用 False 表示只用线段连接各个顶点得到一条折线,其他参数不变。

polylines = cv2.polylines(region, [pts], False, (255, 255, 0), 5)
cv2.imwrite("polylines.jpg", polylines)

保存图片,下图中浅蓝色折线是我们绘制的结果。

2-10

添加文字

添加文字只需要调用 cv2.putText 函数即可。第一个参数 region 是需要添加文字的图片,第二个参数 Vision 是添加的文字内容,第三个参数 (100, 250) 表示这个坐标位于文字的左下角(即文字放置的位置)。第四个参数 cv2.FONT_HERSHEY_SIMPLEX 是字体类型,第五个参数 3 是字体的大小,第六个参数 (0, 255, 0) 是字体的颜色,第七个参数 10 是文字线条的粗细,第八个参数 cv2.LINE_AA 是线条的类型。

words = cv2.putText(region, "Vision", (100, 250), cv2.FONT_HERSHEY_SIMPLEX, 3, (0, 255, 0), 10, cv2.LINE_AA)
cv2.imwrite("words.jpg", words)

保存图片,下图中文字是我们添加的结果。

2-11

0x03 图像的处理

本节实验我们将学习图像处理的基本操作,例如缩放、平移、旋转等基本转换。

知识点

  • 图像的缩放
  • 图像的平移
  • 图像的旋转
  • 图像的翻转

基本的图像变换方法

图像的变换是运用某些方法将图像从一个图像空间转换到另一个图像空间,同时改变图像中的像素值。图像的变换在视觉领域是比较常见的操作,在处理图片的时候我们经常能够用到旋转、平移、翻转等变换方法。下图是图像变换的例子。

3-1

图像的缩放处理是常见的变换方法,在修图软件中常常会用到。在图像拼接时我们常常会将图片旋转、平移、翻转,在画面数字编辑和游戏制作过程中会经常使用这些方法对图像进行处理。深度学习领域我们常常会用到这些图像处理方法来处理数据集。下面我们将介绍一些常见的变换。

尺寸调整

在进行实验之前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5

然后使用下面命令下载本次实验所需图片,并使用 unzip images.zip 命令解压压缩包。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

有时候我们需要将尺寸过大的图片调整到一个相对合适的大小,反之较小尺寸的图片也需要扩增其尺寸。我们可以使用 cv2.resize 函数来实现尺寸调整。首先点击左上方的 File-New File,创建 args.py 脚本。

import cv2
import argparse
 
ap = argparse.ArgumentParser()
ap.add_argument("--image", type = str, help = "image path")
args = ap.parse_args()
 
image = cv2.imread(args.image)

保存 args.py 脚本,然后进入 IPython 环境,使用下面的命令读取 images 目录下的 cup.jpg 图片。

%run args.py --image images/cup.jpg

接下来我们将使用 cv2.resize 函数来对图片的尺寸进行缩放,该函数的第一个参数 image 是要被缩放的图片。第二个参数 (0, 0) 是一个元组,表示输出图片的尺寸,元组内的第一个元素表示图片的宽,第二个表示图片的高,后面会解释为什么这里将此参数设置为 (0, 0)。第三参数 fx = 0.5 表示沿图片的宽的缩放系数,第四参数 fy = 0.5 表示沿图片的高的缩放系数。第五个参数 interpolation = cv2.INTER_AREA) 是插值方法,表明图片是用哪种方法被缩放的,cv2.resize 默认的插值方法是 cv2.INTER_LINEAR,大家也可以尝试使用其他方法: cv2.INTER_NEARESTcv2.INTER_CUBICcv2.INTER_LANCZ0S4。我们也可以尝试定义输出图片尺寸来对图片进行缩放。

resized = cv2.resize(image, (0, 0), fx=0.5, fy=0.5, interpolation = cv2.INTER_AREA)
cv2.imwrite("resized.jpg",resized)

我们分别使用 image.shaperesized.shape 方法来看一下缩放后的图片大小。下图中 out[4] 表示原始的输入图片,out[5] 表示缩放后的图片的尺寸,通过 out[5] 我们看到图片的高和宽被缩小了 0.5 倍。

我们可以使用另一种方法对图片进行缩放,我们可以事先定义缩放后的图片的尺寸。首先我们先通过 shape 方法获取原始图片的高和宽。

height, width = image.shape[:2]

下面代码中我们用 w 表示输出图片的宽,并将其赋值为 200 个像素。为了让输出图片保持纵横比避免图片被过度拉伸我们要计算缩放比例:缩放比例 = 定义的宽度 / 原图的宽度, 即代码中的 200 / width。输出图片的高就等于原图的高乘以缩放比例,即代码中的 height * (200 / width)。这里要注意的是一定要用 int 函数把计算结果转换成整型,因为输出图片的尺寸要是整型。

w = 200
h = int(height * (200 / width))

我们可以在 IPython 中输入 h 查看高是多少,下图中 Out[9] 的输出是 133 表示缩放后图片的高为 133

下面我们使用 cv2.resize 函数对图片进行缩放,这次我们的参数只有三个,第一个参数 image 是传入函数的原图片,第二参数 (w, h) 是输出图片的尺寸,第三个参数 interpolation = cv2.INTER_AREA 是插值方法。当然我们也可以先定义输出图片的高,根据输出图片的高计算缩放比例。最后我们使用 cv2.imwrite 保存图片。

resized = cv2.resize(image, (w, h), interpolation = cv2.INTER_AREA)
cv2.imwrite("resized.jpg",resized)

我们用 resized.shape 来看一下缩放后图片的尺寸,如下图 Out[12]

3-2

现在我们来解决前面提到的为什么要将第二个参数,即输出尺寸设置为 (0, 0) 的问题,当将此参数设置为 (0, 0) 时,图片的输出尺寸由后面两个缩放系数 fxfy 分别与函数的输入图片的宽和高相乘得出,当此参数不等于 (0, 0) 时,输出图片的尺寸等于我们自己设定的值。

最后我们来看一下图片缩放的效果,左上角是使用缩放系数缩放的 0.5 倍的图片。左下角是事先定义尺寸的宽为 200 高为 133 的图片。右边的大图是我们的原始图片。

3-3

平移

图片的平移是让图片中所有的像素点沿着坐标轴移动相同的距离,视觉上看起来就是图片在进行上、下、左、右移动。在对图片进行平移前,我们点击左上方 File-Open 选择 args.py 文件,在第一行添加下面代码。

import numpy as np

我们导入 NumPy 模块,接下来的操作我们要用到该模块。保存修改后我们使用下面命令运行 args.py 脚本。

%run args.py --image images/cup.jpg

接下来我们使用 shape 方法获取图片的高和宽。

height, width = image.shape[:2]

对图片进行平移时,我们要先确定图片向哪个方向移动、移动多少。所以下面一行代码表示我们定义的图片平移规则。

M = np.float32([[1, 0, 100], [0, 1, 50]])

上面的代码表示我们用 np.float32 创建一个 2*3 矩阵 M,列表 [[1, 0, 100], [0, 1, 50]] 定义了我们的图片会向哪个方向移动和移动多少。列表的第一个元素 [1, 0, 100] 中的 1, 0 表示图片将沿着水平方向移动,100 表示图片在水平方向将会向右移动 100 个像素。第二个元素 [0, 1, 50] 中的 0, 1 表示图片将会沿着垂直方向移动,50 表示图片在垂直方向上将会向下移动 50 个像素。

translation = cv2.warpAffine(image, M, (width, height))
cv2.imwrite("translation1.jpg", translation)

上面的代码表示我们使用 cv2.warpAffine 函数对图片进行平移,第一个参数 image 是要被平移的图片。第二个参数 M 是我们刚才创建的矩阵 M。第三个参数 (width, height) 是输出的图片尺寸,这里我们输出的尺寸和输入的原始图片尺寸一样,width 表示图片的宽,height 表示图片的高。最后我们保存图片。如下图中左图是原始图片,右图是平移后的结果。

3-4

同样的,通过下面的代码我们重新定义图片的平移规则。

M = np.float32([[1, 0, -150], [0, 1, -80]])

上面的代码表示我们创建了一个 2*3 矩阵。这次我们让图片在水平方向上向左移动 150 个像素,在垂直方向上往上移动 80 个像素。

translation = cv2.warpAffine(image, M, (width, height))
cv2.imwrite("translation2.jpg", translation)

然后通过 cv2.warpAffine 函数让图片完成移动操作。使用 cv2.imwrite 函数保存图片后让我们来看看操作结果,左图是输入的原始图片,右图是平移后结果。

3-5

旋转

图片的旋转是通过将图片的所有像素点按照相同的方向和角度,围绕一个定点旋转实现的。可以通过结合 cv2.getRotationMatrix2Dcv2.warpAffine 实现。要进行旋转操作我们首先需要构造一个旋转矩阵,我们使用 cv2.getRotationMatrix2D 来构造一个这样的矩阵。

M = cv2.getRotationMatrix2D((50, 50), 30, 1)

函数的第一个参数 (50, 50) 表示图片中的像素点将要围绕坐标为 (50, 50) 的点旋转。第二个参数 30 表示图片逆时针旋转 30 度。最后一个参数 1 表示图片的缩放系数,这里我们设置为 1 表示图片将保持原有尺寸。

rotation = cv2.warpAffine(image, M, (width, height))
cv2.imwrite("rotation1.jpg", rotation)

上面代码表示使用 cv2.warpAffine 对图片进行旋转,第一个参数 image 是要被旋转的图片,第二个参数 M 是我们创建的旋转矩阵,第三个参数 (width, height) 表示输出的图片尺寸。最后保存图片。

如下图所示,左图是输入的原始图片,右图是旋转结果。

3-6

下面代码表示我们创建一个新的旋转矩阵,这次我们将让图片中的像素点围绕坐标为 (50, 50) 的点顺时针旋转 60 度,且将图片缩放 0.5 倍。

M = cv2.getRotationMatrix2D((50, 50), -60, 0.5)

同前面的步骤一样,下面代码我们使用 cv2.warpAffine 函数对图片进行旋转,然后保存图片。

rotation = cv2.warpAffine(image, M, (width, height))
cv2.imwrite("rotation2.jpg", rotation)

执行结果如下图,下图左图是输入的原始图片,右图是旋转结果。

3-7

翻转

图片的翻转是指让图片沿着水平方向或垂直方向进行翻转,我们可以使用 cv2.flip 函数实现。

turn = cv2.flip(image, 1)
cv2.imwrite("turn1.jpg", turn)

从上面的代码看,图片的翻转代码很简单。函数的第一个参数 image 是要被翻转的图片。第二个参数 1 表示图片沿着水平方向翻转。下图是翻转结果,左图是输入的原始图片,右图是沿着水平方向翻转的结果。

3-8

下面我们尝试让图片沿其他方向翻转,更改函数第二参数为 0 表示图片沿垂直方向翻转,然后保存图片。

turn = cv2.flip(image, 0)
cv2.imwrite("turn2.jpg", turn)

如下图,左图是原始图片,右图是图片沿垂直方向翻转的结果。

3-9

现在我们再次修改函数的第二个参数为 -1 表示这次我们让图片沿水平和垂直两个方向翻转,然后保存图片。

turn = cv2.flip(image, -1)
cv2.imwrite("turn3.jpg", turn)

如下图,左图是原始图片,右图是图片沿水平和垂直两个方向翻转的结果。

3-10

图像的运算和颜色空间

本节实验将介绍一些图像的算术运算和按位运算,同时还将介绍关于图像的掩膜,最后我们将了解一些关于颜色空间的知识。

知识点

  • 图像的算术运算
  • 图像的按位运算
  • 掩膜
  • 颜色空间

运算和颜色空间简介

图像的算术运算和按位运算在图像处理中有很多应用,例如图像的算术运算在图像增强中普遍应用,其可对图片进行增加亮度、减少亮度、增强对比度等操作以满足特定的图像处理需求,还可以通过将两幅图片进行算术运算以达到图像融合的效果。图像的按位运算在图像分割、目标检测和识别、模式识别中得到广泛的应用,例如通过按位运算操作,可以提取图片中真正感兴趣的区域而忽略其他区域,在图像分割中可以用按位运算将前景物体从图片中分割开来。下图是图像运算的一个例子。

4-1

颜色空间是对色彩的说明,RGB 颜色空间是在处理图像时常常遇到的,但是除了 RGB 颜色空间人们还创建了其他很多的颜色空间,每种颜色空间都有各自的优缺点,有时候我们需要将图片从一种颜色空间转换到另一种颜色空间进行处理。下面我们将通过几个例子来学习这些方法。

算数运算

图像的算术运算比较简单,使用 cv2.addcv2.subtractcv2.multiplycv2.divide 即可实现加、减、乘、除运算。

在进行实验之前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5

我们用下面命令下载需要用到的图片,然后使用 unzip images.zip 解压压缩包。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

点击左上角 File-New File 创建并编辑 args.py 文件。首先导入要使用到的模块,本节实验我们会用到 cv2argparsenumpy 模块。

import cv2
import argparse
import numpy as np

然后创建一个 argparse.ArgumentParser 对象用于添加命令行参数。

ap = argparse.ArgumentParser()

使用 add_argument 添加命令行参数,这里和之前学习的有点不同,这个函数多了一个 action = "append" 参数,这个参数将允许我们多次使用 --image 命令读取多张图片。 然后 parse_args 将命令解析并存储到列表中。

ap.add_argument("--image", action = "append", type = str, help = "image2 path")
args = ap.parse_args()

接下来我们就可以使用从命令行获取到的图片路径读取图片了。首先我们用 if 语句判断是否获取到图片,如果我们获取到了图片路径,则 args.image 为真并执行后面的代码。然后我们再次用 if 语句判断是否获取到了多张图片路径,本次实验我们将用到两张图片,所以这里会获得 image[0]image[1] 两张图片。如果只获取一张图片路径,则将只读取 args.image[0] 这一张图片。

if args.image:
    if len(args.image) > 1:
        image1 = cv2.imread(args.image[0])
        image2 = cv2.imread(args.image[1])
    else:
        image = cv2.imread(args.image[0])

保存脚本后在 IPython 环境中执行下面命令以读取图片。我们将会读取下载的其中两张图片,分别是 images/cup.jpgimages/pug.jpg

%run args.py --image images/cup.jpg --image images/pug.jpg

对单张图片的所有像素进行运算需要我们创建一个和图片尺寸、通道数一样的矩阵,然后我们再用这个矩阵同图片进行算术运算。首先通过 image1.shape 我们获取图片矩阵的维数。

M = image1.shape

现在我们用 np.ones 创建一个所有元素都是 1 的矩阵 x

x = np.ones(M, dtype = "uint8")

这个函数的第一个参数 M 是我们获取到的图片的维度,将告诉函数创建一个通道数、宽、高都和原图片一样的矩阵,第二个参数 dtype = "uint8" 表示每个元素数据类型都是 8 位无符号整型(因为像素值的范围是 0 到 255,而 uint8 的范围也是 0 到 255)。

将矩阵 x 乘以一个整数 150 使得矩阵中的每个元素都是 150

x = x * 150

我们使用 cv2.add 函数将图片和矩阵进行相加,也就是对图片中的每个像素值进行相加运算。该函数的第一个参数 image1 是我们的原始图片,第二个参数 x 是我们创建的矩阵。这里要确保图片的尺寸和通道数与创建矩阵的维数是一样的,否则在执行脚本时会报错。最后我们保存运算后的图片。

sums = cv2.add(image1, x)
cv2.imwrite("sums.jpg", sums)

下图左边是原始图片,右边是图片中每个像素都相加 150 的结果,因为像素值变得更大,数值接近 255 所以图片看起来更亮。

4-2

图片的减法和图片的加法类似,通过 cv2.subtract 实现图片的减法,函数的两个参数 image1x 分别是原始图片和创建的矩阵。

sub = cv2.subtract(image1, x)
cv2.imwrite("sub.jpg", sub)

下图左边是原始图片,右边是图片中每个像素值都减 150 的结果,因为像素值变得更小,数值接近 0,所以图片看起来很暗。

4-3

我们通过 cv2.multiply 对图片进行相乘操作,函数的两个参数 image1x 分别是原始图片和创建的矩阵。

mul = cv2.multiply(image1, x)
cv2.imwrite("mul.jpg", mul)

下图左边是原始图片,右边是图片中每个像素值都乘 150 的结果,可以看到相乘的结果是一张白色的图片,因为每个像素值相乘后都变为 255

4-4

最后下面是使用 cv2.divide 函数进行相除运算,函数的两个参数 image1x 分别是原始图片和创建的矩阵。

div = cv2.divide(image1, x)
cv2.imwrite("div.jpg", div)

下图左边是原始图片,右边是图片中每个像素值都除以 150 的结果。因为每个像素值都与 150 相除所以像素值都变得很小、接近 0,所以整张图片几乎是黑色看不到任何内容。

4-5

我们可以看一下像素值的变化。下图中 image1[100, 200] 表示原始图片中第 100 行、第 200 列的像素值为 [180, 185, 176]sums[100, 200] 表示加法运算后的值为 [255, 255, 255]sub[100, 200] 表示减法运算后的值为 [30, 35, 26]mul[100, 200] 表示乘法运算后的像素值为 [255, 255, 255]div[100, 200] 表示除法运算后的像素值。

4-6

上图中运算后的结果与单纯的十进制算术运算结果不同。这是因为我们用 8 位无符号整数表示 0 到 255,OpenCV 的算术运算函数是饱和运算,当运算结果大于 255 时函数输出 255,当运算结果小于 0 时函数输出 0。这里需要同 NumPy 的算术运算区分开,NumPy 的运算是取模运算,即当运算结果大于 255 时则最后得到的是运算结果对 256 取模。

下面我们来对比下 OpenCV 和 NumPy 算术运算的区别。 首先我们使用 np.uint8 构建两个 8 位无符号整数的 NumPy 数组:x150y160

x, y = np.uint8([150]), np.uint8([160])

首先用 OpenCV 做加、减运算,下图 Out[20]cv2.add 计算 xy 的结果为 255Out[21]cv2.subtract 计算相减的结果为 0。因为 OpenCV 是进行饱和运算的, 所以当运算结果大于 255 时函数输出为 255,运算值小于 0 时函数输出为 0

我们再使用 NumPy 进行加、减运算,下图 Out[22] 计算 x + y 的结果为 54Out[23] 计算相减的结果为 246。因为在 NumPy 中对大于 255 的值进行取模运算,所以这里用 x + y 的值 310256 进行取模得到 54,同样的 x - y 的值 -10256 进行取模得到 246

4-7

对两张照片进行算术运算的操作和上面基本一样,只需为运算函数提供两张尺寸和通道数相同的图片即可。下面一行代码我们使用前面学习过的 cv2.resize 函数将第二张图片的宽和高调整到和第一张图片相同的大小。

image2 = cv2.resize(image2, (image1.shape[1], image1.shape[0]), interpolation=cv2.INTER_CUBIC)

同前面的实验一样,我们使用 cv2.add 函数对两张图片进行加运算,第一个参数 image1 和第二个参数 image2 分别是我们读取的图片。

sums = cv2.add(image1, image2)
cv2.imwrite("sums.jpg", sums)

下图是执行加运算的结果。左边和中间的图片是要进行加运算的两张图片,右边的图片是进行加运算的结果。

4-8

同样的,使用 cv2.subtract 对两张图片进行减运算。

sub = cv2.subtract(image1, image2)
cv2.imwrite("sub.jpg", sub)

下图中最右边的图片是两张图片执行减运算的结果。

4-9

使用 cv2.multiply 对两张图片进行乘运算。

mul = cv2.multiply(image1, image2)
cv2.imwrite("mul.jpg", mul)

下图中最右边的图片是两张图片执行乘运算的结果。因为乘法运算时很多像素值大于 255 所以很多像素值被函数设置为 255

4-10

使用 cv2.divide 对两张图片进行除法运算。

div = cv2.divide(image1, image2)
cv2.imwrite("div.jpg", div)

下图中最右边的图片是两张图片执行乘运算的结果。因为两张图片相除使像素值变得接近 0 所以整张图片趋近于黑色。

4-11

按位运算

OpenCV 的位运算是对像素值的二进制位进行的运算,有 4 种按位运算: andorxornotand 运算是当两个像素值都大于 0 时为真,or 运算是当两个像素值有一个大于 0 时为真,xor 运算是当两个像素值有一个大于 0 但不同时都大于 0 则为真。not 则是一个取反的运算。

首先我们在 IPython 中运行下面命令读取两张图片。

%run args.py --image images/cup.jpg --image images/rectangle.jpg

让我们来看下按位运算作用于像素值的例子,在 Ipython 中创建两个 NumPy 数组 ab 表示两个像素值。

a = np.array([20, 40, 167], dtype=np.uint8)
b = np.array([80, 48, 240], dtype=np.uint8)

然后我们使用 cv2.bitwise_and 函数对两个像素值进行 and 运算。函数的第一个参数是我们创建的数组 a,第二个参数是我们创建的数组 b

cv2.bitwise_and(a, b)

下图 Out[4] 是我们进行 and 操作的结果。前面我们提到过 OpenCV 的位运算是对像素值的二进制位进行的运算,a 中的 20 的二进制是 00010100b 中的 80 的二进制是 01010000,对两个数进行 and 计算得到 00010000 其对应十进制就是 16。同理 a 中的 40b 中的 48and 运算结果是 32a 中的167b 中的 240and 运算结果是 160

我们使用 cv2.bitwise_or 函数对两个像素值进行 or 运算,函数的两个参数分别是 ab

cv2.bitwise_or(a, b)

下图 Out[5] 是进行 or 操作的结果。a 中的 40 的二进制是 00101000b 中的 48 的二进制是 00110000,对两个数进行 or 运算得到 00111000 其对应十进制就是 56。剩下的两个值同理可得。

我们使用 cv2.bitwise_xor 函数对 ab 两个像素值进行 xor 运算。

cv2.bitwise_xor(a, b)

下图 Out[6] 是进行 xor 运算的结果。a 中的 167 的二进制是 10100111b 中的 240 的二进制是 11110000,对两个数进行 xor 运算得到 01010111 其对应十进制就是 87。剩下的两个值同理可得。

我们使用 cv2.bitwise_not 对数组 a 进行取反运算。

cv2.bitwise_not(a)

下图 Out[7] 是进行 not 运算的结果。a 中的 20 的二进制是 00010100,则执行 not 运算后的二进制值是 11101011 也就是十进制的 235。剩下的两个值同理可得。

4-12

了解两个像素值的运算后我们再来看看图片的按位运算。两张图片的按位运算就是将两张尺寸通道数相同的图片中的每个像素值分别进行 andorxornot 运算。首先我们通过 cv2.resize 函数将两张图片调整到相同大小。

image2 = cv2.resize(image2, (image1.shape[1], image1.shape[0]), interpolation=cv2.INTER_CUBIC)

接下来我们使用 cv2.bitwise_and 对两张图片进行 and 运算,函数的第一个参数 image1 是我们读取的 images/cup.jpg 图片,第二个参数 image2 是我们读取的 images/rectangle.jpg 图片。

i_and = cv2.bitwise_and(image1, image2)
cv2.imwrite("i_and.jpg", i_and)

下面我们来看看两张图片 and 操作的结果。下图左图是 image1/cup.jpg, 中间的图片是 image1/rectangle.jpg ,右边的图片是两张图片 and 运算后的结果。可以看到中间图片的黑色的区域起到了遮挡的作用。

4-13

下面我们使用 cv2.bitwise_or 对两张图片进行 or 运算。

i_or = cv2.bitwise_or(image1, image2)
cv2.imwrite("i_or.jpg", i_or)

下面是进行 or 操作的结果。可以看到在 or 运算中白色区域充当了遮挡作用。

4-14

下面我们使用 cv2.bitwise_xor 对两张图片进行 xor 运算。

i_xor = cv2.bitwise_xor(image1, image2)
cv2.imwrite("i_xor.jpg", i_xor)

下图是执行 xor 的运算结果,因为 xor 运算是当两个像素值有一个大于 0 但不同时都大于 0 则为真,导致右边图片中对应中间图片的白色区域产生变化。

4-15

最后我们使用 cv2.bitwise_not 对两张图片进行 not 运算。该函数的参数只有一个,这里我们使用 image1 作为参数。

i_not = cv2.bitwise_not(image1)
cv2.imwrite("i_not.jpg", i_not)

下图是执行 not 的运算结果。 not 函数是一个取反的运算,如下图所示,最右边的图片的像素值的二进制形式都被取反了。

4-16

掩膜

掩膜(mask)是图像按位计算的一种应用,其目的是只显示出图片中我们感兴趣的区域并遮盖其他部分。现在我们来看一下掩膜的具体操作。掩膜的应用比较简单,下面代码中我们用 np.zeros 创建一张黑色图片,因为这张图片的尺寸要和进行掩膜操作的原图片的尺寸一样,所以我们使用 shape 方法获取 image1 的宽和高,然后将这两个值作为参数传递给函数。然后将我们感兴趣的区域的像素值赋值为 255,这里我们选取的区域 mask[100:300, 200:300] 也就是感兴趣区域为白色。

mask = np.zeros(image1.shape[:2], np.uint8)
mask[100:300, 200:300] = 255

下图就是我的 mask 图片。

4-17

使用 cv2.bitwise_and 函数进行掩膜操作,函数第一个和第二个参数相同,都是我们的原始图片 image1 ,第三个参数 mask 就是我们刚才创建的掩膜图片。该函数会把原图片中对应于掩膜图片的白色区域显示出来,其他区域会被黑色遮盖。下图左边是我们的原图片,右边是提取出我们感兴趣的区域。

imagemask = cv2.bitwise_and(image1, image1, mask = mask)
cv2.imwrite("imagemask.jpg", imagemask)

下图是进行掩膜操作的结果,可以看到我们已经成功的使用黑色区域覆盖掉了不感兴趣的区域。

4-18

颜色空间

除了以前我们了解的 RGB 颜色空间,还有很多其他的颜色空间,例如 HSV,LAB 等,颜色空间是一种描述色彩的模型,其作用是在不同的标准下对不同颜色的说明。OpenCV 中有上百种颜色空间转换方法,这里我们将简单介绍常用的颜色空间转换方法。

我们用 cv2.cvtColor 方法将图片转换为灰度图。函数的第一个参数 image1 是要被转换为灰度图的图片。第二个参数 cv2.COLOR_BGR2GRAY 表示颜色空间转换类型,这里表示将图片从 RGB 彩色图片转换为灰度图。

grayimage = cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY)
cv2.imwrite("grayimage.jpg", grayimage)

下图是我们转换的结果。

4-19

同样的,我们用 cv2.cvtColor 方法将图片转换为 HSV 图片。函数的第一个参数 image1 是要被转换为 HSV 图的图片。第二个参数 cv2.COLOR_BGR2HSV 表示颜色空间转换类型。这里表示将图片从 RGB 彩色图片转换为 HSV 图片。

hsvimage = cv2.cvtColor(image1, cv2.COLOR_BGR2HSV)
cv2.imwrite("HSVimage.jpg", hsvimage)

下图是转换结果。

4-20

0x05 图像阈值

本节实验我们将了解到什么是阈值,并且学习一些常用的阈值方法。

知识点

  • 简单阈值
  • 自适应阈值
  • Otsu 阈值方法

常见的阈值方法

阈值法是一种图像分割的方法,阈值法因其简单、稳定、计算量小的特征使其在传统算法中得到广泛应用。阈值法是将灰度图像进行二值化以达到分割图像,获得特定感兴趣区域的方法。阈值法的通常做法是将灰度图转换为二值图,使得图片中的像素值只有 0 和 255 这两种值,这样图片就被分为两个不同像素值的区域。下图是使用阈值法处理的图片,可以看出通过阈值法我们排除了很多图片的信息,保留了部分关键的信息。

5-1

阈值法可以帮助我们去除图像中多余的信息,让我们只关注我们感兴趣的信息,同时阈值法可以大大减少数据量和简化分析过程。对灰度图进行阈值化常用的方法有简单阈值法、自适应阈值法、Otsu 阈值法。下面我们将一一介绍这三种方法。

简单阈值

简单阈值法同其名字一样方法简单直接,人为的选定一个阈值 T ,对于灰度图片中所有小于 T 的像素值设置为 0,将图片中所有大于 T 的像素值设置为 255。也可将大于阈值的像素值设置为 0,小于阈值的设置为 255。

在进行实际练习前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5

我们通过下面命令下载本节实验需要的图片,然后使用 unzip images.zip 解压文件包。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

运行下面命令获取 args.py 脚本。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/args.py

然后在 IPthon 环境中运行 args.py 脚本载入图片。

%run args.py --image images/lego.jpg

下面的代码将读取的图片转换为灰度图,因为进行二值化需要在灰度图片上进行。

grayimage = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imwrite("gray.jpg", grayimage)

下面左图是原始图片,右图是转换后的灰度图片。

5-2

然后我们使用 cv2.threshold 函数对图片进行二值化,该函数会返回 2 个输出,第一个 T 是我们设置的阈值,第二个 thresh 是二值化后的图片。

T, thresh = cv2.threshold(grayimage, 200, 255, cv2.THRESH_BINARY)
cv2.imwrite("thresh1.jpg", thresh)

函数的第一个参数 image 需要输入单通道图片,这里我们输入上面得到的灰度图。第二个参数 200 是我们要设置的阈值,第三个参数 255 表示当图像中的像素值大于 200 那么该像素值将被替换成 255,否则像素值将会被设置为 0。第四个参数是设置阈值的方法,这里使用的是 cv2.THRESH_BINARY,该方法将会把所有大于第二个参数的像素值设置为第三个参数也就是 255

下图是进行阈值化处理的结果。

5-3

我们还可以通过将第四个参数改为 cv2.THRESH_BINARY_INV 进行相反的操作。

T, thresh = cv2.threshold(grayimage, 200, 255, cv2.THRESH_BINARY_INV)
cv2.imwrite("thresh2.jpg", thresh)

上面的代码表示将所有小于第二个参数 200 的像素值替换为第三个参数也就是 255,否则将被设置为 0。下图中右边的图片就是操作结果。

5-4

自适应阈值

简单阈值的方法是对整张图片的所有像素值采用一个阈值进行二值化,这种方法不仅需要我们尝试多次不同的阈值以达到较好到二值化效果,而且该方法在处理稍复杂的背景环境和像素值的强度分布较广的图片时效果并不好,然而自适应阈值法可以帮助我们优化简单阈值法的弊端。自适应阈值是基于一小片区域的像素值来确定阈值的,所以处理一张图片可以获得多个阈值。

我们使用 cv2.adaptiveThreshold 进行自适应阈值。

thresh = cv2.adaptiveThreshold(grayimage, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 9, 3)
cv2.imwrite("thresh3.jpg", thresh)

函数的第一个参数 grayimage 是要二值化的的灰度图。第二个参数 255 是像素的最大值,作用同 cv2.threshold 第三个参数一样。第三个参数 cv2.ADAPTIVE_THRESH_MEAN_C 是计算阈值的方法,该方法会计算像素邻近区域的平均值然后减去一个常数 C,这个常数是整数且是该函数的第六个参数。第四个参数 cv2.THRESH_BINARY 是前面讲过的设置阈值的方法。第五个参数 9 是用来计算阈值的区域的大小,该参数必须是奇数,这里我们设置其值为 9 表示我们将计算 9*9 区域内的阈值。第六个参数 3 就是前面提到的常数 C

下图中左边是输入的灰度图,右边是执行的结果,可以看到相较于简单阈值法,自适应阈值法提取出的物体轮廓效果更好。

5-5

我们也可以将第三个参数设置为 cv2.ADAPTIVE_THRESH_GAUSSIAN_C 表示用领域值的高斯加权总和减去常数 C

thresh = cv2.adaptiveThreshold(grayimage, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 9, 3)
cv2.imwrite("thresh4.jpg", thresh)

下图中右边是处理结果,可以看到 cv2.ADAPTIVE_THRESH_GAUSSIAN_C 方法获取的边缘更细一些。

5-6

Otsu 阈值

Otsu 方法是另一种自动计算阈值的方法,是一种寻找一个阈值将图片分为前景和背景的方法,其优点是不受图像的亮度和对比度的影响。

我们使用 cv2.threshold 实现 Otsu 方法,只需要将函数第四个参数设置为 cv2.THRESH_OTSU 就可实现。

T, thresh = cv2.threshold(grayimage, 0, 255, cv2.THRESH_OTSU)

当第四个参数设置为 Otsu 方法时,第二个参数就会无效。我们可以在 IPython 环境中输入 T 看一下这个值,如下图可以看到 Otsu 方法获得的阈值与函数的第二个参数不同。

5-7

cv2.imwrite("otsu1.jpg", thresh)

最后通过 cv2.imwrite 保存图片,下图中的右边图片是处理的结果。

5-8

我们还可以在 cv2.threshold 函数的第四个参数 cv2.THRESH_OTSU 后面添加设置阈值的方法来呈现不同的处理结果。

T, thresh = cv2.threshold(grayimage, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV)
cv2.imwrite("otsu2.jpg", thresh)

上面 cv2.threshold 的第四个参数是以 cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV 呈现的,表示函数将用 Otsu 方法二值化图片,且小于阈值 T 的像素值将被设置为 255。下图中的右边图片是得到的结果。

5-9

0x06 平滑

在图像处理和计算机视觉领域我们常常能遇到图像平滑,例如图像二值化和边缘检测等。本节实验我们将了解到图像平滑的基本知识以及一些基本方法。

知识点

  • 均值滤波
  • 高斯滤波
  • 中位滤波
  • 双边滤波

常见的平滑方法

图像平滑也可称为滤波,是一种用于减少图像噪声且会让图像变得模糊的方法。图像平滑常常在图像预处理时使用,其主要目的是减少图像中的噪声,使得后续的处理操作能够获得更好的效果和可靠性。在日常生活中最常见的图像平滑应该就是修图了,如果你是一个喜欢用修图软件处理自拍照片的人,那么你很可能就会用到图像平滑的技术,因为图像平滑的方法的其中一个作用就是美颜,使图片中的皮肤看起来更光滑白嫩。

6-1

如上图,图中的每个数字表示像素值,红色方框是一个 3*3 矩阵或者称之为卷积核。图像平滑实际上是使用一个 m*k 的矩阵在图片上移动并与像素值进行相应计算来实现的。本节实验我们将分别学习均值滤波、高斯滤波、中位滤波、双边滤波 4 种图像平滑方法。

均值滤波

均值滤波就是线性滤波,是指用一个 m*m 的模板(卷积核)在图像上滑动,其中 m 是正整数且是奇数,当模板在滑动时,处于模板中心的像素值将会被其周围的像素值的平均值所替代。

在进行实验之前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5

我们通过下面命令下载本节实验需要的图片,然后使用 unzip images.zip 解压文件包。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

运行下面命令获取 args.py 脚本。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/args.py

我们使用 cv2.blur 函数来实现该功能,首先在 IPython 环境中运行下面命令载入两张图片。

%run args.py --image images/cup.jpg --image images/noise.jpg

使用 cv2.blur 函数对图像进行均值滤波。

blur1 = cv2.blur(image1, (5, 5))
cv2.imwrite("blur1.jpg", blur1)

其中第一个参数 image 是要进行平滑的图片,第二个参数 (5, 5) 表示使用 5*5 卷积核对图片进行处理。

下图中的左边图片是我们的原始图片,右边图片是被 5*5 卷积核处理后的图片,相较于左图右图变得模糊了,这是因为图像中的每个像素值都被周围的像素的均值所替代。

6-2

下面我们将函数的第二个参数改为 (9, 9),即使用 9*9 的卷积核对图片进行处理。

blur2 = cv2.blur(image1, (9, 9))
cv2.imwrite("blur2.jpg", blur2)

下图中左边和中间的图片分别是原始图片和使用 5*5 卷积核处理的图片,最右边是使用 9*9 的卷积进行平滑的图片,通过对比可以看得出随着卷积核的尺寸变大图片中的边缘信息就越模糊。

6-3

高斯滤波

高斯滤波是为了克服平滑处理导致的图像边缘信息丢失的问题。高斯滤波比较类似均值滤波,但是高斯滤波在计算均值时使用了权重。卷积核的中心像素值是由相邻像素的加权平均得到的,越靠近中心像素的像素值的权重越大。

gaussian1 = cv2.GaussianBlur(image1, (5, 5), 0)
cv2.imwrite("gaussian1.jpg", gaussian1)

上面代码中我们通过 cv2.GaussianBlur 函数来实现高斯滤波。第一个参数 image 是要进行高斯平滑的图片,第二个参数 (5, 5) 是高斯卷积核的大小,第三个参数 0x 轴方向的高斯卷积核的标准差,这里设置为 0 表示让 OpenCV 根据卷积核大小自动计算。

6-4

上图左边图片是原始图片,右边的图片是进行高斯滤波的结果,可以看到虽然图片变的模糊了,但是图片中边缘还是清晰可见的。

下面我们将第二个参数换成 (9, 9),表示我们将用 9*9 的卷积核对图片进行平滑处理。

gaussian2 = cv2.GaussianBlur(image1, (9, 9), 0)
cv2.imwrite("gaussian2.jpg", gaussian2)

下图左边的图是原图,中间的图片是使用 5*5 高斯卷积核处理的图片,右边是使用 9*9 高斯卷积核处理的图片,可以看到虽然随着卷积核尺寸增加图片越来越模糊,但是图片中的边缘还是清晰可见。

6-5

中位滤波

中位滤波首先需要设定一个 m*m 的卷积核,当卷积核在图片上滑动时将卷积核中的每个值进行排序,取排序后的中间值作为卷积核中心的像素值。中位滤波对去除图片中的椒盐噪声有较好的效果。椒盐噪声就是图片中存在许多白色和黑色的点,其会给图像处理带来一定的困难,所以我们要使用一些方法来消除其造成的影响。下图是一张含有椒盐噪声的图片。

6-6

中位滤波之所以对椒盐噪声有效是因为卷积核中心的像素值取的是图片中的像素值而不是计算得来的值。我们使用 cv2.medianBlur 函数对图片进行中位滤波。

median1 = cv2.medianBlur(image2, 5)
cv2.imwrite("median1.jpg", median1)

第一个参数 image 是要被处理的图片,第二个参数 5 是卷积核的尺寸,这里我们还是使用 5*5 的卷积核对图片进行处理。 下图中的左边图片是我们原始的图片,右边图片是我们处理后的图片。可看到经过 5*5 卷积核后图片中椒盐噪声被滤除掉了,物体的边缘还是比较清晰的,比较好的保留了边缘信息,但是图片还是会在一定程度上变得模糊。

6-7

下面我们尝试用 9*9 的卷积核对图片进行平滑,只需要将第二个参数替换为 9 即可。

median2 = cv2.medianBlur(image2, 9)
cv2.imwrite("median2.jpg", median2)

下图中最左边图片是原始图片。中间的图片是使用 5*5 卷积核处理的结果 。最右边的图片是我们使用卷积核 9*9 的卷积核对图片进行处理的结果,椒盐噪声虽然被去除掉了,但是由于使用了更大的卷积核导致图片变得更加模糊,物体的边缘虽然可以辨别但是相较中间的图片已经变得更模糊了。

6-8

双边滤波

双边滤波是一种使用两个高斯函数,在减少噪声同时也可以保留边缘信息的平滑方法。双边滤波使用两个权重,距离卷积核的中心点越近的像素权重越大,相邻区域内像素的强度与中心像素的强度越相似,其所获得的权重就越大。虽然双边滤波可以保持边缘信息,但是比之前的滤波方法慢很多。

我们使用 cv2.bilateralFilter 实现双边滤波。

bilateral1 = cv2.bilateralFilter(image1, 9, 45, 45)
cv2.imwrite("bilateral1.jpg", bilateral1)

函数第一个参数 image 是要被处理的图片,第二个参数 9 是在进行滤波过程中每个像素其相邻区域像素的直径,第三个参数 45 是颜色标准差,这个值越大表示更多的相邻区域内颜色在进行滤波时会被考虑。第四个参数 45 是坐标空间的标准差,这个值越大表示距离中心像素较远的像素会影响计算。

最后使用 cv2.imwrite 保存图片,下图中左边图片是我的原始图片,右边图片是使用双边滤波后的图片,可以看到两张图片似乎没有明显的变换。

6-9

下面我们将参数设置大一些,我们将第二参数设置为 11,将第三个参数设置为 65,将第四个参数设置为 65

bilateral2 = cv2.bilateralFilter(image1, 11, 65, 65)
cv2.imwrite("bilateral2.jpg", bilateral2)

使用 cv2.imwrite 保存图片,下图中左图是原始图片,右图是执行结果,虽然整张图片看不出明显的变换,但是在处理后的图片中,杯子右侧的书本封面变得比原图更柔和。

6-10

同样的,我们将第二个参数设为 65 ,将第三个参数设置为 100,将第四个参数设置为 100

bilateral3 = cv2.bilateralFilter(image1, 65, 100, 100)
cv2.imwrite("bilateral3.jpg", bilateral3)

最后保存图片,下图中左图是原始图片,右图是执行结果,当把函数第二个参数设置比较大时,虽然图片中的边缘仍然比较清晰,但是整张图片看上去已经变得很柔和了,看上去很像手绘的画一样。

6-11

0x07 梯度和边缘

边缘检测是一种在图像处理中常常能够用到的方法。本节实验我们将了解图像中的梯度,并学习一些边缘检测的算法包括 Sobel、Laplacian、Canny。

知识点

  • Sobel 算子
  • Laplacian 算子
  • Canny 算子

边缘检测

边缘是图像中的一种重要信息,边缘将图片分成不同的区域,每个区域代表不同的物体。边缘有一个重要的特征:在边缘两侧区域的像素灰度值差异较大,而边缘上的像素灰度值差异不明显。

8-1

如上图,通过肉眼我们可以识别出图片中哪些部位是边缘,例如在图中红色矩形内的区域存在两个亮度和颜色不同的子区域,颜色较浅较亮的区域与颜色黑暗的区域的灰度值差异较大,在中间的边缘处发生了“突变”,而边缘上的灰度值变化却不是很大,整体呈现一种缓和变化的情况。利用这种“突变”的特征我们可以对边缘进行检测。

为了检测到边缘,我们需要找到一种方法确定哪些区域是存在灰度值的“突变”的,使用梯度可以找到灰度值变化明显的地方,梯度变化的幅度可以用来检测边缘。OpenCV 中为我们提供了几种通过梯度获取图片中边缘的方法,下面我们就来一一了解。

Sobel 算子

我们可以使用 Sobel 算子来获得图像的梯度信息,在 OpenCV 中我们可以使用 cv2.Sobel 函数来计算水平方向和垂直方向的梯度。

在进行实验之前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5

我们通过下面命令下载本节实验需要的图片,然后使用 unzip images.zip 解压文件包。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

运行下面命令获取 args.py 脚本。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/args.py

我们进入 IPython 环境,运行下面命令,执行 args.py 脚本并读取图片。

%run args.py --image images/cup.jpg

接下来让我们将彩色图片转换为灰度图片(这里是为了方便理解,我们选择使用灰度图计算梯度)然后保存灰度图片。

image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imwrite("grayimage.jpg", image)

我们首先使用 Sobel 算子计算水平方向的梯度。第一个参数 image 是输入函数的图片。第二个参数 cv2.CV_64F 表示输出图片的数据类型为 64 位浮点型。第三个参数 1 表示水平方向的导数,第四个参数 0 表示垂直方向的导数,当第三和第四个参数为 1, 0 时,表示计算水平方向的梯度,当两个参数为 0, 1 时表示计算垂直方向的梯度。

X = cv2.Sobel(image, cv2.CV_64F, 1, 0)

输出图片的数据类型使用 64 位浮点型的原因是:从黑色像素值向白色像素值过渡时计算的梯度是正,相反从白色像素值向黑色像素值过渡计算的梯度是负;在之前的试验中我们讲过像素值用 8 位无符号整数表示,所以其无法表示负数,前面的实验我们也讲过在对像素值计算时 OpenCV 会将小于 0 的值设为 0,所以如果我们不用浮点类型的话,我们会丢失从白色向黑色过渡的边缘。

然后我们再计算垂直方向的梯度。这次我们只需要将函数的第三个参数设置为 0,将第四个参数设置为 1

Y = cv2.Sobel(image, cv2.CV_64F, 0, 1)

最后分别保存水平和垂直方向的边缘图片。

cv2.imwrite("X.jpg", X)
cv2.imwrite("Y.jpg", Y)

下图中最左边图片是我们的灰度图片,中间的图片是获取的水平方向上的梯度,最右边图片是获取的垂直方向上的梯度。

7-1

直接将获取的 XY 保存为图片会丢失部分边缘,因为上面提到计算得到的梯度值有正负之分,如果直接将其保存为图片,那么 OpenCV 会将小于 0 的值设置为 0(详见实验 4 中的图像计算的部分)导致丢失值为负数的梯度。为了避免这种情况,我们需要进行类型转换。

X = np.uint8(np.absolute(X))
Y = np.uint8(np.absolute(Y))

上面的代码我们先使用 np.absolute 函数对 XY 取绝对值,然后再使用 np.uint8 将结果转换为 8 位无符号整数类型,这样做的目的就是为了确保我们不会丢失从白色像素值向黑色像素值过渡的边缘。最后保存梯度图片。

cv2.imwrite("X.jpg", X)
cv2.imwrite("Y.jpg", Y)

下图中的左边是转换后的水平方向上的梯度,右边是转换后的垂直方向上的梯度。之前丢失的梯度因为使用数据类型转换被重新获得,所以图片中的边缘信息更多了。

7-2

接下来我们要将水平方向和垂直方向上的梯度图合并以获得最终的边缘检测结果。这里我们使用之前实验讲过的按位 or 运算,我们使用 cv2.bitwise_or 函数将两个方向上的梯度图合并。

edge = cv2.bitwise_or(X, Y)
cv2.imwrite("edge.jpg", edge)

下图是将两个方向上的梯度合并的结果。

7-3

Laplacian 算子

与 Sobel 算子不同,Laplacian 算子只产生一个结果即与水平和垂直方向无关。因为这个算子对噪声比较敏感,所以当图片中存在噪声时最好先对图片进行降噪处理。我们使用 cv2.Laplacian 函数对图片进行边缘检测。

edge1 = cv2.Laplacian(image, cv2.CV_64F)

该函数的第一个参数 image 是输入函数的图片,第二个参数 cv2.CV_64F 是输出图片的数据类型。

edge1 = np.uint8(np.absolute(edge1))
cv2.imwrite("edge1.jpg", edge1)

然后我们将 edge1 进行数据类型转换并保存。下面就是我们进行 Laplacian 方法处理的结果。

7-4

Canny 算子

Canny 边缘检测是一种比较流行的算法,其在进行边缘检测过程中包含多个步骤:对图片进行降噪处理、计算水平和垂直方向的梯度、剔除干扰的边缘、使用双阈值确定哪些像素在边缘上。我们使用 cv2.Canny 对图片使用 Canny 算法。

edge2 = cv2.Canny(image, 20, 140)
cv2.imwrite("edge2.jpg", edge2)

第一个参数 image 是要被处理的图片,第二个参数 20 和第三个参数 140 是要设置的阈值。小于 20 的梯度值将被划分为非边缘,大于 140 的梯度值将被划分为边缘;介于 20140 中间的值,如果该值与大于 140 的值相邻接,那么判定其为边缘。

下图为运行结果,可以看到由于其多阶段的处理方法使得 Canny 算子具有更好的效果。

7-5

0x08 直方图

本节实验我们将学习图像直方图的一些基本知识,同时我们会通过几个简单的例子来练习如何绘制直方图以及其简单的应用。

知识点

  • 灰度直方图
  • 彩色直方图
  • 二维直方图
  • 直方图均匀化

绘制直方图

图像的直方图是一种通过简单的统计分析手段对像素值的分布情况进行可视化的方法。通过绘制图像直方图我们可以直观的了解图像的一些特征。 一个简单的图像直方图由 x 轴和 y 轴构成,x 轴表示像素值的范围,y 轴表示像素值出现的频率。由于直方图的简单、直观、计算量小等特点使其在图像处理中得到广泛的应用。

在进行实验之前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5 matplotlib==3.0.3

我们通过下面命令下载本节实验需要的图片,然后使用 unzip images.zip 解压文件包。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

运行下面命令获取 args.py 脚本。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/args.py

在 OpenCV 中使用 cv2.calcHist 函数计算直方图。首先在 IPython 环境中我们执行下面命令读取图片。

%run args.py --image images/cup.jpg

然后导入 matplotlib 模块用于绘制直方图。

from matplotlib import pyplot as plt

下面的代码表示我们先将图片转换为灰度图,然后使用 cv2.calcHist 计算直方图。

image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cv2.imwrite("gray_image.png", image)

下图是我们保存的灰度图片。

8-1

fig = cv2.calcHist([image], [0], None, [256], [0,256])

cv2.calcHist 函数的第一个参数 image 是要绘制直方图的图片,这里要将图片以 [image] 的形式传入函数。第二个参数 [0] 是图片通道的索引,[0] 表示蓝色通道,[1] 表示绿色通道 ,[2] 表示红色通道,这里我们计算的是单通道图片的直方图,所以这里输入的通道索引为 [0]。第三个参数 None 表示是否使用掩膜,这里我们设置为 None 表示计算整张图片的直方图。第四个参数 [256] 表示我们要将直方图分成 256 份。第五个参数 [0, 256] 表示直方图统计的像素值的范围在 0 到 255。

最后我们使用 matplotlib 绘制直方图并保存。我们使用 plt.figure 创建用于绘图的画布,使用 plt.plot 绘制直方图。

plt.figure()
plt.plot(fig)

最后我们使用 plt.savefig 保存直方图。

plt.savefig("histogram.png")

下图中左边是灰度图,右边是绘制的直方图。直方图的横轴表示该直方图在水平方向上被分成了 256 份,分别表示 0 到 255 的像素值。纵轴表示每个像素值出现的次数。

下图中的左图为灰度图,右图是绘制的直方图。

8-2

如果我们只对图片中部分区域感兴趣,只想计算感兴趣区域的直方图,可以利用学过的掩膜方法来实现。首先我们需要创建一个掩膜,使用 np.zeros 创建一个和原始图片一样大小的图片。

mask = np.zeros(image.shape[:2], dtype = "uint8")

然后在 mask 上用 cv2.circle 画一个圆用于提取图片中感兴趣的区域。

mask = cv2.circle(mask, (150,150), 100, 255, -1)
cv2.imwrite("mask.png", mask)

下图中的左图是灰度图,右图就是我们创建的掩膜。

8-3

下面我们用按位 and 操作提取出我们关注的区域并覆盖掉不感兴趣的区域。

mask_area = cv2.bitwise_and(image, image, mask = mask)
cv2.imwrite("mask_area.png", mask_area)

下图中的左图是灰度图,右图就是使用掩膜后得到的感兴趣的区域。

8-4

现在我们使用前面创建的掩膜 mask 替换 cv2.calcHist 函数中的第三个参数 None,然后函数就会根据掩膜提取出来的区域进行直方图的计算。

figmask = cv2.calcHist([image], [0], mask, [256], [0,256])

最后我们利用 matplotlib 绘制直方图并保存。

plt.figure()
plt.plot(figmask)
plt.savefig("histogram_mask.png")

下图就是执行的结果,左边的图片是我们利用掩膜提取出来的感兴趣区域,右边的图片是绘制的直方图。

8-5

彩色直方图

前面实验我们学习了绘制单通道图片的直方图,现在我们来学习绘制 RGB 图片的 3 个通道的直方图。首先我们输入下面命令读取图片。

%run args.py --image images/cup.jpg

彩色直方图的绘制和灰度图的绘制相似,这次我们对 RGB 图片的每个通道都进行直方图的计算。首先使用 plt.figure 创建一个画布用于绘制我们的直方图。

plt.figure()

然后我们创建一个列表,这个列表中的字符依次表示蓝色、绿色、红色,这个列表将用于定义绘制直方图曲线的颜色。

channel = ["b","g","r"]

接着我们使用一个 for 循环来分别绘制图片的三个通道的直方图。我们将 cv2.calcHist 函数的第二个参数先后设置为 [0][1][2] 来计算每个通道的直方图。然后使用 plt.plot 绘制每个通道的直方图并将直方图的曲线定义为相应通道的颜色。

for i in range(3):
    fig = cv2.calcHist([image], [i], None, [256], [0,256])
    plt.plot(fig, color = channel[i])
    plt.xlim([0,256])

最后使用 plt.savefig 保存绘制后的图片。

plt.savefig("color.png")

如下图,左边是 RGB 彩色图片,右边图片是绘制的彩色直方图,可以看到我们分别绘制了红色、绿色、蓝色三个通道的直方图。

8-6

以上的实验是对单通道中的像素值进行统计并以直方图的形式呈现出来,其中绘制的图像中横轴表示 0 到 255 像素值,纵轴表示该像素值在该单通道图片中的个数。

二维直方图

前面的实验我们绘制直方图时只考虑一个因素,即单通道中的像素值,如果我们要考虑两个因素,例如同时考虑一个像素的蓝色、绿色通道的像素值时该如何绘制直方图。我们可以使用 cv2.calcHist 函数绘制二维直方图。

fig = cv2.calcHist([image], [0, 1], None, [16, 16], [0, 256, 0, 256])

函数的第一个参数 image 是输入的彩色图片。第二个参数 [0, 1] 表示我们将计算蓝色和绿色通道的像素值的直方图。第三个参数 None 表示不使用掩膜。第四个参数 [16, 16] 表示我们将横轴和纵轴各分为 16 份,也就是像素值在 0 到 255 的范围内被分成 16 份。第五个参数 [0, 256, 0, 256] 表示两个通道的像素值的范围都在 0 到 255。

我们使用 plt.imshow 绘制直方图,并使用 plt.colorbar 为直方图添加颜色棒以便于观察。保存直方图。

plt.figure()
plt.colorbar(plt.imshow(fig,interpolation = 'nearest'))

最后使用 plt.savefig 保存直方图。

plt.savefig("2d_histogram.png")

下图中左图是我们的原始图片,右图是绘制的直方图。直方图的纵轴表示蓝色通道的像素值,横轴表示绿色通道的像素值。直方图右侧的颜色棒表示像素的数量,颜色越深表示像素的数量越少。从直方图中我们可以看到蓝色和绿色通道的像素在纵轴的 0 到 2 范围和横轴的 0 到 2 范围中数量最多,因为这块区域的颜色呈现黄色表示有很多像素在这个范围内。

8-7

均衡化

有时候我们拍摄的照片会出现曝光过度或者曝光不足导致图片的内容缺乏辨识度,这种情况我们可以通过均衡化的方法来缓解这种问题。首先我们先通过代码来了解如何通过均衡化来处理图片,然后通过输出的结果我们就可以直观的了解均衡化的效果了。

首先使用 cv2.split 函数将图片分离为三个单通道矩阵,创建用于绘制直方图的画布。

splits = cv2.split(image)
plt.figure()

我们使用一个 for 循环用于对图片的每一个通道进行均衡化处理。

for i in range(3):
    splits[i] = cv2.equalizeHist(splits[i])
    fig = cv2.calcHist([splits[i]], [0], None, [256], [0,256])
    plt.plot(fig, color = channel[i])
    plt.xlim([0,256])

我们使用 cv2.equalizeHist 对图片进行均衡化处理,函数只传递了一个参数 splits[i] 给该函数,该参数表示彩色图片的其中一个通道,注意该函数只接受单通道图片作为参数。接下来为均衡化处理后的图片绘制直方图,使用 plt.plot 将相应的通道颜色作为直方图曲线的颜色。 然后我们使用 plt.savefig 保存直方图。

plt.savefig("equalizeHist.png")

最后我们将均衡化处理的各个通道合并然后保存合并后的图片。

equalized = cv2.merge(splits)
cv2.imwrite("equalized.png", equalized)

下图是处理结果,其中左上角和右上角是用原始图片绘制的彩色直方图。从直方图可以看出像素值大部分集中在 0 到 50 和 150 到 200 之间,像素值分布比较不均衡。左下角和右下角是均衡化后的直方图,可以看到相对于原始图片,处理后的图片颜色更鲜艳,通过直方图可以看出均衡化后的像素值在 0 到 250 之间分布也相对比较均衡。

8-8

抠图

本节实验将结合前面几节实验所学的内容进行一个实例演练。在本节内容中我们将学会如何从一张图片中扣取出感兴趣的区域然后将这个区域放入另一张图片中。

知识点

  • 轮廓提取
  • 绘制轮廓
  • 提取感兴趣区域

轮廓的提取和分割

在前面的实验我们学习了按位运算和边缘检测,本节实验我们将利用这两个方法实现从图片中抠取特定区域,然后将其融合进一张新的图片中去。要想从一张图片中抠取特定的区域我们需要用到轮廓提取的方法,轮廓是描述物体外形的一段闭合的曲线,是物体的一个重要特征,轮廓特征在图像处理中广泛应用,在图像分割中我们利用轮廓特征将物体从背景中分割开。

在进行实验之前,我们先在终端中使用下面命令安装将要用到的模块。

sudo -H python3 -m pip install opencv-python==4.2.0.34 numpy==1.18.5

我们通过下面命令下载本节实验需要的图片,然后使用 unzip images.zip 解压文件包。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/images.zip

运行下面命令获取 args.py 脚本。

wget https://labfile.oss-internal.aliyuncs.com/courses/2480/args.py

下面我们将使用 OpenCV 来实现物体轮廓提取和图像融合。首先在 IPython 中运行下面命令读取两张图片。

%run args.py --image images/pepper.jpg --image images/cup.jpg

接下来使用边缘检测算子检测出图片中的边缘,首先我们将彩色图片转换为灰度图,然后使用 cv2.canny 函数检测出图片中的边缘,然后保存图片。

edge = cv2.Canny(cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY), 60, 150)
cv2.imwrite("edge.jpg", edge)

执行代码我们可以看到如下结果。左边图片是我们的原始图片,右边是进行边缘检测获得的结果。

9-1

下面的一行代码表示使用 cv2.findContours 来找出图片中的轮廓。

contours, h = cv2.findContours(edge, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

该函数的第一个参数 edgecv2.Canny 函数输出的边缘图,这个参数必须是二值图且函数会修改输入的图片。第二个参数 cv2.RETR_EXTERNAL 是检索轮廓的方式,表示只检测最外层的轮廓;可以尝试使用 cv2.RETR_LISTcv2.RETR_CCOMPcv2.RETR_TREE 参数。第三个参数 cv2.CHAIN_APPROX_SIMPLE 表示逼近轮廓的方法,或者可以称为存储轮廓的方法,这个方法将会对轮廓上的所有坐标点进行压缩,只保留水平、垂直、对角线方向上的端点坐标;可以尝试 cv2.CHAIN_APPROX_NONEcv2.CHAIN_APPROX_TC89_L1cv2.CHAIN_APPROX_TC89_KCOS 方法。

cv2.findContours 有两个返回值 contourshcontours 是一个列表,其中每一个元素对应检测到的轮廓,另一个 h 表示检测到的轮廓所对应的属性。

下面我们将会把物体的轮廓绘制出来,首先使用 copy 方法拷贝一个 image1 的副本,因为绘图操作会修改图片。

drawc = image1.copy()

然后使用 cv2.drawContours 函数进行绘图。

drawc = cv2.drawContours(drawc, contours, -1, (255, 0, 0), 2)
cv2.imwrite("contour.jpg", drawc)

这个函数的第一个参数 drawc 是输入的图片,表示我们要在这张图片上绘制出物体的轮廓。第二个参数 contours 是我们检测到的轮廓。第三个参数 -1 是检测到的轮廓的索引,表示要绘制出所有的轮廓;上面我们提到 contours 是一个列表,里面的每一个元素表示一个轮廓,如果我们想绘制第一个轮廓,那么第三个参数就是 0,如果想绘制第二个轮廓,那么第三个参数就是 1。第四个参数 (255, 0, 0) 表示轮廓的颜色是蓝色。第五个参数表示绘制的轮廓的粗细。执行上面一段代码我们可以得到下图结果。可以看到三个辣椒的轮廓都被检测到了。

9-2

分割物体

当我们获取到了物体的轮廓后,使用 np.zeros 创建一个尺寸和通道数同原始图片一样的黑色图片 mask

mask = np.zeros(image1.shape, dtype = "uint8")

然后使用 cv2.drawContoursmask 上绘制我们检测到的轮廓。

mask = cv2.drawContours(mask, contours, 1, (255,255,255), -1)
cv2.imwrite("mask.jpg", mask)

cv2.drawContours 的第三个参数 1 表示绘制检测到的第二个物体的轮廓,第四个参数 (255, 255, 255) 表示轮廓的颜色为白色,第五个参数 -1 表示将整个轮廓内的区域填充为白色。然后保存图片。

下图为在 mask 上绘制的物体轮廓,这里绘制了中间的辣椒轮廓。

9-3

然后使用按位运算对原始图片和 mask 进行 and 操作,这样我们就可以将中间的辣椒提取出来了。

divide = cv2.bitwise_and(mask, image1)
cv2.imwrite("divide.jpg", divide)

下图就是使用掩膜获得的结果。左图是原始图片,右图是提取出的物体。

9-4

裁剪和图片合并

现在我们已经提取出了一个辣椒,接下来我们要将这个辣椒合并到其他图片上。首先我们要从 divide 中裁剪出辣椒。我们使用 cv2.boundingRect 函数获得这个辣椒最小的外接矩形的坐标。

(x, y, w, h) = cv2.boundingRect(contours[1])

这个函数只有一个参数 contours[1] 表示这个辣椒的轮廓,然后这个函数将会返回辣椒最小的外接矩形的起始顶点坐标、矩形的宽和高。

下图是获得的矩形参数,这个矩形的顶点坐标是 (103, 147),宽是 131,高是 49

9-5

知道了坐标我们就可以使用切片方法裁剪出这个辣椒,下面第一行代码表示我们截取的部分是辣椒的最小外接矩形。

cut_pepper = divide[y:y + h, x:x + w]
cv2.imwrite("cutpepper.jpg", cut_pepper)

下图中右边的小图片是我们裁剪下来的图片。

9-6

下面的代码同样使用切片方法将 mask 上绘制的轮廓裁剪下来。

cut_mask = mask[y:y + h, x:x + w]
cv2.imwrite("cut_mask.jpg", cut_mask)

下图中右边的小图片是我们从 mask 上裁剪的轮廓。

9-7

然后使用按位 not 运算将被白色填充的轮廓区域变成黑色,将其他区域变成白色。

cut_not = cv2.bitwise_not(cut_mask)
cv2.imwrite("cut_not.jpg", cut_not)

下图中右边的图片是执行 not 操作的结果。

9-8

现在我们将裁剪出来的辣椒合并到其他图片中,首先我们在读取的第二张图片中使用切片方法选择一个尺寸和我们裁剪下来的辣椒图片的尺寸一样的区域。

cut_image2 = image2[50:50 + h, 50:50 + w]
cv2.imwrite("cut_image2.jpg", cut_image2)

下图中,左图为读取到的第二张图片,右图为从该图片中裁剪出的区域。

9-9

然后将裁剪出来的区域和 cut_not 进行按位 and 操作。

cut_image2 = cv2.bitwise_and(cut_not, cut_image2)
cv2.imwrite("cut_image2.jpg", cut_image2)

下图为 and 操作结果。

9-10

然后再将 cut_image2cut_pepper 进行按位 or 运算。

cut_image2 = cv2.bitwise_or(cut_pepper, cut_image2)
cv2.imwrite("cut_image2.jpg", cut_image2)

下图为两个区域进行按位 or 运算的结果。

9-11

最后将操作后的区域用切片方法替换原始图像的区域,最后保存图片。

image2[50:50 + h, 50:50 + w] = cut_image2[:,:]
cv2.imwrite("merge.jpg", image2)

下图中的左边图片是我们读取的图片,右边图片是我们的操作结果,可以看到在图片中我们成功的将辣椒合并到了读取的图片中。

9-12