教程内容来源于:面向初学者的OpenCV-Python教程,内容见大佬的博客:http://codec.wang/#/opencv/

大佬的博客已经提供了很好的学习平台,但是对于全文搜索的适配性一般,有一些知识点细节无法通过全文搜索来找到,这也是博主转载教程的原因。我将所有教程的Markdo文档集合成了一个文件,并生成本文,借助浏览器的页面搜索,你可以在此页面搜索到教程里的任何内容。面向中文领域,关于Opencv的入门学习,这是能找到的最好的一系列教程了,如果你觉得还不错,请务必给原作者一个Star,有条件的同学可以捐赠支持原作者。↓↓

入门篇

01: 简介与安装

相信大部分人知道的OpenCV都是用C++来开发的,那为什么我推荐使用Python呢?

本教程翻译自OpenCV官方英文教程,我按照使用度和难易度翻译,重新编写了大量原创内容,将不常用和较难的部分写成番外篇,浅显易懂,很easy的辣。每节的源码、图片和练习题答案均可在引用处找到噢(⊙o⊙)

Python照样快!

众所周知,虽然Python语法简洁、编写高效,但相比C/C++运行慢很多。然而Python还有个重要的特性:它是一门胶水语言!Python可以很容易地扩展C/C++。OpenCV-Python就是用Python包装了C++的实现,背后实际就是C++的代码在跑,运行速度非常接近原生。

比如我分别用Python和C++实现读入图片和调整图片的亮度对比度,结果如下:

可以看到某些情况下Python的运行速度甚至好于C++,代码行数也直接少一半多!

另外,图像是矩阵数据,OpenCV-Python原生支持Numpy,相当于Python中的Matlab,为矩阵运算、科学计算提供了极大的便利性。

人工智能浪潮

近些年,人工智能AI相关技术的快速发展大家有目共睹。在编程语言方面,更多人希望的是具备高效开发效率、跨平台、高度扩展性的语言,尤其是一些AI巨头优先推出支持Python语言的深度学习框架,如Facebook的PyTorch、Google的Tensorflow等,可以说Python是名副其实的“网红语言”了。

TIOBE编程语言排行榜也可以看到,Python发展迅猛,已经逼近C++的份额。这个排行榜每月更新,就不截图了,我编写时的TOP5:Java/C/C++/Python/C#。

人生苦短,我用Python

  • 如果你搞科研用,果断放弃C++(Matlab?出门左拐)
  • 如果你是快速原型开发,验证方案,果断放弃C++
  • 如果你懒的配置OpenCV环境,果断放弃C++
  • 如果你的程序是在支持Python的较高硬件环境下运行,果断放弃C++
  • 如果你担心Python写不了界面,那是你的问题o_o ....
  • 除非你的程序是MFC或已经用C++编写其他模块或是嵌入式设备,那就用C++吧

"人生苦短,我用Python!!!"

安装

本教程编写时使用的相关版本是:OpenCV 4.x,Python 3.x。

opencv-python

只需终端下的一条指令:

pip install opencv-python

pip是Python的包管理器,如果你还没安装Python,强烈推荐安装Anaconda,它包含了大量的科学计算包,不用后期一个个安装。

Anaconda安装

进入Anaconda官网,下载最新版本的安装文件,速度比较慢的话,可以去清华开源镜像站

  • Windows版是exe文件,双击直接安装,安装时记得勾选 Add Anaconda to my PATH environment variable,添加到环境变量。
  • Linux版是sh文件,执行bash Anaconda3-xx.sh,Linux版也会提示添加到环境变量,记得输yes就行。
  • MAC版是pkg文件,同样直接双击安装即可。

安装测试

Python的版本可以在终端中输入python --version来查看。对于OpenCV,打开Python的开发环境,输入import cv2,运行没有报错说明一切正常。要查看OpenCV的版本,可以:

print(cv2.__version__)

编辑器我习惯用Visual Studio Code,也可以用PyCharm/Atom/Jupyter Notebook(Anaconda自带)。

常见问题

  1. pip识别不了:pip的目录没有添加到环境变量中,添加到用户(或系统)变量的path中。
  2. 下载速度很慢:可到此处下载离线版。终端输入pip install 文件名安装。

学习软件

为了便于学习OpenCV,我编写了一款教学软件LearnOpenCVEdu,目前只开发了一部分功能,欢迎Star支持:smiley:。

经验之谈:虽然我推荐大家使用OpenCV-Python进行图像处理,但想要深入理解OpenCV,C++是必须的,尤其是OpenCV源码

引用

网络资料

书籍

名校视觉研究所/课程

02: 基本元素-图片

学习如何加载图片,显示并保存图片。图片等可到文末引用处下载。

目标

  • 加载图片,显示图片,保存图片
  • OpenCV函数:cv2.imread(), cv2.imshow(), cv2.imwrite()

教程

大部分人可能都知道电脑上的彩色图是以RGB(红-绿-蓝,Red-Green-Blue)颜色模式显示的,但OpenCV中彩色图是以B-G-R通道顺序存储的,灰度图只有一个通道。

图像坐标的起始点是在左上角,所以行对应的是y,列对应的是x:

加载图片

使用cv2.imread()来读入一张图片:

import cv2

# 加载灰度图
img = cv2.imread('lena.jpg', 0)
  • 参数1:图片的文件名
    • 如果图片放在当前文件夹下,直接写文件名就行,如'lena.jpg'
    • 否则需要给出绝对路径,如'D:\OpenCVSamples\lena.jpg'
  • 参数2:读入方式,省略即采用默认值
    • cv2.IMREAD_COLOR:彩色图,默认值(1)
    • cv2.IMREAD_GRAYSCALE:灰度图(0)
    • cv2.IMREAD_UNCHANGED:包含透明通道的彩色图(-1)

经验之谈:路径中不能有中文噢,并且没有加载成功的话是不会报错的,print(img)的结果为None,后面处理才会报错,算是个小坑。

显示图片

使用cv2.imshow()显示图片,窗口会自适应图片的大小:

cv2.imshow('lena', img)
cv2.waitKey(0)

参数1是窗口的名字,参数2是要显示的图片。不同窗口之间用窗口名区分,所以窗口名相同就表示是同一个窗口,显示结果如下:

cv2.waitKey()是让程序暂停的意思,参数是等待时间(毫秒ms)。时间一到,会继续执行接下来的程序,传入0的话表示一直等待。等待期间也可以获取用户的按键输入:k = cv2.waitKey(0)([练习1]())。

我们也可以先用cv2.namedWindow()创建一个窗口,之后再显示图片:

# 先定义窗口,后显示图片
cv2.namedWindow('lena2', cv2.WINDOW_NORMAL)
cv2.imshow('lena2', img)
cv2.waitKey(0)

参数1依旧是窗口的名字,参数2默认是cv2.WINDOW_AUTOSIZE,表示窗口大小自适应图片,也可以设置为cv2.WINDOW_NORMAL,表示窗口大小可调整。图片比较大的时候,可以考虑用后者。

保存图片

使用cv2.imwrite()保存图片,参数1是包含后缀名的文件名:

cv2.imwrite('lena_gray.jpg', img)

Nice,是不是很简单呐,再接再厉噢(●'◡'●)

小结

  • cv2.imread()读入图片、cv2.imshow()显示图片、cv2.imwrite()保存图片。

练习

  1. 打开lena.jpg并显示,如果按下's',就保存图片为'lena_save.bmp',否则就结束程序。
  2. Matplotlib是Python中常用的一个绘图库,请学习番外篇:无损保存和Matplotlib使用

接口文档

引用

03: 打开摄像头

学习打开摄像头捕获照片、播放本地视频、录制视频等。图片/视频等可到文末引用处下载。

目标

  • 打开摄像头并捕获照片
  • 播放本地视频,录制视频
  • OpenCV函数:cv2.VideoCapture(), cv2.VideoWriter()

教程

打开摄像头

要使用摄像头,需要使用cv2.VideoCapture(0)创建VideoCapture对象,参数0指的是摄像头的编号,如果你电脑上有两个摄像头的话,访问第2个摄像头就可以传入1,依此类推。

## 打开摄像头并灰度化显示
import cv2

capture = cv2.VideoCapture(0)

while(True):
    # 获取一帧
    ret, frame = capture.read()
    # 将这帧转换为灰度图
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

    cv2.imshow('frame', gray)
    if cv2.waitKey(1) == ord('q'):
        break

capture.read()函数返回的第1个参数ret(return value缩写)是一个布尔值,表示当前这一帧是否获取正确。cv2.cvtColor()用来转换颜色,这里将彩色图转成灰度图。

另外,通过cap.get(propId)可以获取摄像头的一些属性,比如捕获的分辨率,亮度和对比度等。propId是从0~18的数字,代表不同的属性,完整的属性列表可以参考:VideoCaptureProperties。也可以使用cap.set(propId,value)来修改属性值。比如说,我们在while之前添加下面的代码:

## 获取捕获的分辨率
## propId可以直接写数字,也可以用OpenCV的符号表示
width, height = capture.get(3), capture.get(4)
print(width, height)

## 以原分辨率的一倍来捕获
capture.set(cv2.CAP_PROP_FRAME_WIDTH, width * 2)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, height * 2)

经验之谈:某些摄像头设定分辨率等参数时会无效,因为它有固定的分辨率大小支持,一般可在摄像头的资料页中找到。

播放本地视频

跟打开摄像头一样,如果把摄像头的编号换成视频的路径就可以播放本地视频了。回想一下cv2.waitKey(),它的参数表示暂停时间,所以这个值越大,视频播放速度越慢,反之,播放速度越快,通常设置为25或30。

## 播放本地视频
capture = cv2.VideoCapture('demo_video.mp4')

while(capture.isOpened()):
    ret, frame = capture.read()
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

    cv2.imshow('frame', gray)
    if cv2.waitKey(30) == ord('q'):
        break

录制视频

之前我们保存图片用的是cv2.imwrite(),要保存视频,我们需要创建一个VideoWriter的对象,需要给它传入四个参数:

  • 输出的文件名,如'output.avi'
  • 编码方式FourCC
  • 帧率FPS
  • 要保存的分辨率大小

FourCC是用来指定视频编码方式的四字节码,所有的编码可参考Video Codecs。如MJPG编码可以这样写: cv2.VideoWriter_fourcc(*'MJPG')cv2.VideoWriter_fourcc('M','J','P','G')

capture = cv2.VideoCapture(0)

## 定义编码方式并创建VideoWriter对象
fourcc = cv2.VideoWriter_fourcc(*'MJPG')
outfile = cv2.VideoWriter('output.avi', fourcc, 25., (640, 480))

while(capture.isOpened()):
    ret, frame = capture.read()

    if ret:
        outfile.write(frame)  # 写入文件
        cv2.imshow('frame', frame)
        if cv2.waitKey(1) == ord('q'):
            break
    else:
        break

小结

  • 使用cv2.VideoCapture()创建视频对象,然后在循环中一帧帧显示图像。参数传入数字时,代表打开摄像头,传入本地视频路径时,表示播放本地视频。
  • cap.get(propId)获取视频属性,cap.set(propId,value)设置视频属性。
  • cv2.VideoWriter()创建视频写入对象,用来录制/保存视频。

练习

  1. 请先阅读番外篇:滑动条,然后实现一个可以拖动滑块播放视频的功能。(提示:需要用到 cv2.CAP_PROP_FRAME_COUNTcv2.CAP_PROP_POS_FRAMES两个属性)。

接口文档

引用

04: 图像基本操作

学习获取和修改像素点的值,ROI感兴趣区域,通道分离合并等基本操作。图片等可到文末引用处下载。

目标

  • 访问和修改图片像素点的值
  • 获取图片的宽、高、通道数等属性
  • 了解感兴趣区域ROI
  • 分离和合并图像通道

教程

获取和修改像素点值

我们先读入一张图片:

import cv2

img = cv2.imread('lena.jpg')

通过行列的坐标来获取某像素点的值,对于彩色图,结果是B,G,R三个值的列表,对于灰度图或单通道图,只有一个值:

px = img[100, 90]
print(px)  # [103 98 197]

## 只获取蓝色blue通道的值
px_blue = img[100, 90, 0]
print(px_blue)  # 103

还记得吗?行对应y,列对应x,所以其实是img[y, x],需要注意噢(●ˇ∀ˇ●)。容易混淆的话,可以只记行和列,行在前,列在后。

修改像素的值也是同样的方式:

img[100, 90] = [255, 255, 255]
print(img[100, 90])  # [255 255 255]

经验之谈:还有一种性能更好的方式,获取:img.item(100,100,0),修改:img.itemset((100,100,0),255),但这种方式只能B,G,R逐一进行。

注意:这步操作只是内存中的img像素点值变了,因为没有保存,所以原图并没有更改。

图片属性

img.shape获取图像的形状,图片是彩色的话,返回一个包含行数(高度)、列数(宽度)和通道数的元组,灰度图只返回行数和列数:

print(img.shape)  # (263, 247, 3)
## 形状中包括行数、列数和通道数
height, width, channels = img.shape
## img是灰度图的话:height, width = img.shape

img.dtype获取图像数据类型:

print(img.dtype)  # uint8

经验之谈:多数错误是因为数据类型不对导致的,所以健壮的代码应该对这个属性加以判断。

img.size获取图像总像素数:

print(img.size)  # 263*247*3=194883

ROI

ROI:Region of Interest,感兴趣区域。什么意思呢?比如我们要检测眼睛,因为眼睛肯定在脸上,所以我们感兴趣的只有脸这部分,其他都不care,所以可以单独把脸截取出来,这样就可以大大节省计算量,提高运行速度。

截取ROI非常简单,指定图片的范围即可(后面我们学了特征后,就可以自动截取辣,(ง •_•)ง):

## 截取脸部ROI
face = img[100:200, 115:188]
cv2.imshow('face', face)
cv2.waitKey(0)

通道分割与合并

彩色图的BGR三个通道是可以分开单独访问的,也可以将单独的三个通道合并成一副图像。分别使用cv2.split()cv2.merge()

b, g, r = cv2.split(img)
img = cv2.merge((b, g, r))

split()函数比较耗时,更高效的方式是用numpy中的索引,如提取B通道:

b = img[:, :, 0]
cv2.imshow('blue', b)
cv2.waitKey(0)

小结

  • img[y,x]获取/设置像素点值,img.shape:图片的形状(行数、列数、通道数),img.dtype:图像的数据类型。
  • img[y1:y2,x1:x2]进行ROI截取,cv2.split()/cv2.merge()通道分割/合并。更推荐的获取单通道方式:b = img[:, :, 0]

练习

  1. 打开lena.jpg,将帽子部分(高:25-120,宽:50-220)的红色通道截取出来并显示。

接口文档

引用

05: 颜色空间转换

学习如何进行图片的颜色空间转换,视频中追踪特定颜色的物体。图片等可到文末引用处下载。

目标

  • 颜色空间转换,如BGR↔Gray,BGR↔HSV等
  • 追踪视频中特定颜色的物体
  • OpenCV函数:cv2.cvtColor(), cv2.inRange()

教程

颜色空间转换

import cv2

img = cv2.imread('lena.jpg')
## 转换为灰度图
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

cv2.imshow('img', img)
cv2.imshow('gray', img_gray)
cv2.waitKey(0)

cv2.cvtColor()用来进行颜色模型转换,参数1是要转换的图片,参数2是转换模式, COLOR_BGR2GRAY表示BGR→Gray,可用下面的代码显示所有的转换模式:

flags = [i for i in dir(cv2) if i.startswith('COLOR_')]
print(flags)

经验之谈:颜色转换其实是数学运算,如灰度化最常用的是:gray=R*0.299+G*0.587+B*0.114

视频中特定颜色物体追踪

HSV是一个常用于颜色识别的模型,相比BGR更易区分颜色,转换模式用COLOR_BGR2HSV表示。

经验之谈:OpenCV中色调H范围为[0,179],饱和度S是[0,255],明度V是[0,255]。虽然H的理论数值是0°~360°,但8位图像像素点的最大值是255,所以OpenCV中除以了2,某些软件可能使用不同的尺度表示,所以同其他软件混用时,记得归一化。

现在,我们实现一个使用HSV来只显示视频中蓝色物体的例子,步骤如下:

  1. 捕获视频中的一帧
  2. 从BGR转换到HSV
  3. 提取蓝色范围的物体
  4. 只显示蓝色物体

import numpy as np

capture = cv2.VideoCapture(0)

## 蓝色的范围,不同光照条件下不一样,可灵活调整
lower_blue = np.array([100, 110, 110])
upper_blue = np.array([130, 255, 255])

while(True):
    # 1.捕获视频中的一帧
    ret, frame = capture.read()

    # 2.从BGR转换到HSV
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

    # 3.inRange():介于lower/upper之间的为白色,其余黑色
    mask = cv2.inRange(hsv, lower_blue, upper_blue)

    # 4.只保留原图中的蓝色部分
    res = cv2.bitwise_and(frame, frame, mask=mask)

    cv2.imshow('frame', frame)
    cv2.imshow('mask', mask)
    cv2.imshow('res', res)

    if cv2.waitKey(1) == ord('q'):
        break

其中,bitwise_and()函数暂时不用管,后面会讲到。那蓝色的HSV值的上下限lower和upper范围是怎么得到的呢?其实很简单,我们先把标准蓝色的BGR值用cvtColor()转换下:

blue = np.uint8([[[255, 0, 0]]])
hsv_blue = cv2.cvtColor(blue, cv2.COLOR_BGR2HSV)
print(hsv_blue)  # [[[120 255 255]]]

结果是[120, 255, 255],所以,我们把蓝色的范围调整成了上面代码那样。

经验之谈:Lab颜色空间也经常用来做颜色识别,有兴趣的同学可以了解下。

小结

  • cv2.cvtColor()函数用来进行颜色空间转换,常用BGR↔Gray,BGR↔HSV。
  • HSV颜色模型常用于颜色识别。要想知道某种颜色在HSV下的值,可以将它的BGR值用cvtColor()转换得到。

练习

  1. 尝试在视频中同时提取红色、蓝色、绿色的物体。(效果如下)

接口文档

引用

06: 阈值分割

学习使用不同的阈值方法"二值化"图像。图片等可到文末引用处下载。

目标

  • 使用固定阈值、自适应阈值和Otsu阈值法"二值化"图像
  • OpenCV函数:cv2.threshold(), cv2.adaptiveThreshold()

教程

固定阈值分割

固定阈值分割很直接,一句话说就是像素点值大于阈值变成一类值,小于阈值变成另一类值。

import cv2

## 灰度图读入
img = cv2.imread('gradient.jpg', 0)

## 阈值分割
ret, th = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
cv2.imshow('thresh', th)
cv2.waitKey(0)

cv2.threshold()用来实现阈值分割,ret是return value缩写,代表当前的阈值,暂时不用理会。函数有4个参数:

  • 参数1:要处理的原图,一般是灰度图
  • 参数2:设定的阈值
  • 参数3:对于THRESH_BINARYTHRESH_BINARY_INV阈值方法所选用的最大阈值,一般为255
  • 参数4:阈值的方式,主要有5种,详情:ThresholdTypes

下面结合代码理解下这5种阈值方式:

import matplotlib.pyplot as plt

## 应用5种不同的阈值方法
ret, th1 = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
ret, th2 = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)
ret, th3 = cv2.threshold(img, 127, 255, cv2.THRESH_TRUNC)
ret, th4 = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO)
ret, th5 = cv2.threshold(img, 127, 255, cv2.THRESH_TOZERO_INV)

titles = ['Original', 'BINARY', 'BINARY_INV', 'TRUNC', 'TOZERO', 'TOZERO_INV']
images = [img, th1, th2, th3, th4, th5]

## 使用Matplotlib显示
for i in range(6):
    plt.subplot(2, 3, i + 1)
    plt.imshow(images[i], 'gray')
    plt.title(titles[i], fontsize=8)
    plt.xticks([]), plt.yticks([])  # 隐藏坐标轴

plt.show()

经验之谈:很多人误以为阈值分割就是二值化。从上图中可以发现,两者并不等同,阈值分割结果是两类值,而不是两个值,所以教程开头我把二值化加了引号。

自适应阈值

看得出来固定阈值是在整幅图片上应用一个阈值进行分割,它并不适用于明暗分布不均的图片cv2.adaptiveThreshold()自适应阈值会每次取图片的一小部分计算阈值,这样图片不同区域的阈值就不尽相同。它有5个参数,其实很好理解,先看下效果:

## 自适应阈值对比固定阈值
img = cv2.imread('sudoku.jpg', 0)

## 固定阈值
ret, th1 = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
## 自适应阈值
th2 = cv2.adaptiveThreshold(
    img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 11, 4)
th3 = cv2.adaptiveThreshold(
    img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 17, 6)

titles = ['Original', 'Global(v = 127)', 'Adaptive Mean', 'Adaptive Gaussian']
images = [img, th1, th2, th3]

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

  • 参数1:要处理的原图
  • 参数2:最大阈值,一般为255
  • 参数3:小区域阈值的计算方式
    • ADAPTIVE_THRESH_MEAN_C:小区域内取均值
    • ADAPTIVE_THRESH_GAUSSIAN_C:小区域内加权求和,权重是个高斯核
  • 参数4:阈值方法,只能使用THRESH_BINARYTHRESH_BINARY_INV,具体见前面所讲的阈值方法
  • 参数5:小区域的面积,如11就是11*11的小块
  • 参数6:最终阈值等于小区域计算出的阈值再减去此值

如果你没看懂上面的参数也不要紧,暂时会用就行,当然我建议你调整下参数看看不同的结果。

Otsu阈值

在前面固定阈值中,我们是随便选了一个阈值如127,那如何知道我们选的这个阈值效果好不好呢?答案是:不断尝试,所以这种方法在很多文献中都被称为经验阈值。Otsu阈值法就提供了一种自动高效的二值化方法,不过我们直方图还没学,这里暂时略过。

好吧,我知道我激起了你的兴趣,~ o( ̄▽ ̄)o,有能力的童鞋可以看下[练习题]()。

小结

  • cv2.threshold()用来进行固定阈值分割。固定阈值不适用于光线不均匀的图片,所以用 cv2.adaptiveThreshold()进行自适应阈值分割。
  • 二值化跟阈值分割并不等同。针对不同的图片,可以采用不同的阈值方法。

练习

  1. Otsu阈值是一种高效的二值化算法,请阅读番外篇:Otsu阈值法

接口文档

引用

07: 图像几何变换

学习如何旋转、平移、缩放和翻转图片。图片等可到文末引用处下载。

目标

  • 实现旋转、平移和缩放图片
  • OpenCV函数:cv2.resize(), cv2.flip(), cv2.warpAffine()

教程

图像的几何变换从原理上看主要包括两种:基于2×3矩阵的仿射变换(平移、缩放、旋转和翻转等)、基于3×3矩阵的透视变换,感兴趣的小伙伴可参考番外篇:仿射变换与透视变换

缩放图片

缩放就是调整图片的大小,使用cv2.resize()函数实现缩放。可以按照比例缩放,也可以按照指定的大小缩放:

import cv2

img = cv2.imread('drawing.jpg')

## 按照指定的宽度、高度缩放图片
res = cv2.resize(img, (132, 150))
## 按照比例缩放,如x,y轴均放大一倍
res2 = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_LINEAR)

cv2.imshow('shrink', res), cv2.imshow('zoom', res2)
cv2.waitKey(0)

我们也可以指定缩放方法interpolation,更专业点叫插值方法,默认是INTER_LINEAR,全部可以参考:InterpolationFlags

翻转图片

镜像翻转图片,可以用cv2.flip()函数:

dst = cv2.flip(img, 1)

其中,参数2 = 0:垂直翻转(沿x轴),参数2 > 0: 水平翻转(沿y轴),参数2 < 0: 水平垂直翻转。

平移图片

要平移图片,我们需要定义下面这样一个矩阵,tx,ty是向x和y方向平移的距离:

$$
M = \left[
\begin{matrix}
1 & 0 & t_x \newline
0 & 1 & t_y
\end{matrix}
\right]
$$

平移是用仿射变换函数cv2.warpAffine()实现的:

## 平移图片
import numpy as np

rows, cols = img.shape[:2]

## 定义平移矩阵,需要是numpy的float32类型
## x轴平移100,y轴平移50
M = np.float32([[1, 0, 100], [0, 1, 50]])
## 用仿射变换实现平移
dst = cv2.warpAffine(img, M, (cols, rows))

cv2.imshow('shift', dst)
cv2.waitKey(0)

旋转图片

旋转同平移一样,也是用仿射变换实现的,因此也需要定义一个变换矩阵。OpenCV直接提供了 cv2.getRotationMatrix2D()函数来生成这个矩阵,该函数有三个参数:

  • 参数1:图片的旋转中心
  • 参数2:旋转角度(正:逆时针,负:顺时针)
  • 参数3:缩放比例,0.5表示缩小一半
## 45°旋转图片并缩小一半
M = cv2.getRotationMatrix2D((cols / 2, rows / 2), 45, 0.5)
dst = cv2.warpAffine(img, M, (cols, rows))

cv2.imshow('rotation', dst)
cv2.waitKey(0)

小结

  • cv2.resize()缩放图片,可以按指定大小缩放,也可以按比例缩放。
  • cv2.flip()翻转图片,可以指定水平/垂直/水平垂直翻转三种方式。
  • 平移/旋转是靠仿射变换cv2.warpAffine()实现的。

接口文档

引用

08: 绘图功能

学习画线、圆和矩形等多种几何形状,给图片添加文字。图片等可到文末引用处下载。

目标

  • 绘制各种几何形状、添加文字
  • OpenCV函数:cv2.line(), cv2.circle(), cv2.rectangle(), cv2.ellipse(), cv2.putText()

教程

参数说明

绘制形状的函数有一些共同的参数,提前在此说明一下:

  • img:要绘制形状的图片
  • color:绘制的颜色
    • 彩色图就传入BGR的一组值,如蓝色就是(255,0,0)
    • 灰度图,传入一个灰度值就行
  • thickness:线宽,默认为1;对于矩形/圆之类的封闭形状而言,传入-1表示填充形状

需要导入的模块和显示图片的通用代码:

import cv2
import numpy as np
import matplotlib.pyplot as plt

cv2.imshow('img', img)
cv2.waitKey(0)

上图就是本教程绘制的最终效果,下面一步步来看:

画线

画直线只需指定起点和终点的坐标就行:

## 创建一副黑色的图片
img = np.zeros((512, 512, 3), np.uint8)
## 画一条线宽为5的蓝色直线,参数2:起点,参数3:终点
cv2.line(img, (0, 0), (512, 512), (255, 0, 0), 5)

经验之谈:所有绘图函数均会直接影响原图片,这点要注意。

画矩形

画矩形需要知道左上角和右下角的坐标:

## 画一个绿色边框的矩形,参数2:左上角坐标,参数3:右下角坐标
cv2.rectangle(img, (384, 0), (510, 128), (0, 255, 0), 3)

画圆

画圆需要指定圆心和半径,注意下面的例子中线宽=-1代表填充:

## 画一个填充红色的圆,参数2:圆心坐标,参数3:半径
cv2.circle(img, (447, 63), 63, (0, 0, 255), -1)

画椭圆

画椭圆需要的参数比较多,请对照后面的代码理解这几个参数:

  • 参数2:椭圆中心(x,y)
  • 参数3:x/y轴的长度
  • 参数4:angle---椭圆的旋转角度
  • 参数5:startAngle---椭圆的起始角度
  • 参数6:endAngle---椭圆的结束角度

经验之谈:OpenCV中原点在左上角,所以这里的角度是以顺时针方向计算的。

## 在图中心画一个填充的半圆
cv2.ellipse(img, (256, 256), (100, 50), 0, 0, 180, (255, 0, 0), -1)

画多边形

画多边形需要指定一系列多边形的顶点坐标,相当于从第一个点到第二个点画直线,再从第二个点到第三个点画直线....

OpenCV中需要先将多边形的顶点坐标需要变成顶点数×1×2维的矩阵,再来绘制:

## 定义四个顶点坐标
pts = np.array([[10, 5],  [50, 10], [70, 20], [20, 30]], np.int32)
## 顶点个数:4,矩阵变成4*1*2维
pts = pts.reshape((-1, 1, 2))
cv2.polylines(img, [pts], True, (0, 255, 255))

cv2.polylines()的参数3如果是False的话,多边形就不闭合。

经验之谈:如果需要绘制多条直线,使用cv2.polylines()要比cv2.line()高效很多,例如:

## 使用cv2.polylines()画多条直线
line1 = np.array([[100, 20],  [300, 20]], np.int32).reshape((-1, 1, 2))
line2 = np.array([[100, 60],  [300, 60]], np.int32).reshape((-1, 1, 2))
line3 = np.array([[100, 100],  [300, 100]], np.int32).reshape((-1, 1, 2))
cv2.polylines(img, [line1, line2, line3], True, (0, 255, 255))

添加文字

使用cv2.putText()添加文字,它的参数也比较多,同样请对照后面的代码理解这几个参数:

  • 参数2:要添加的文本
  • 参数3:文字的起始坐标(左下角为起点)
  • 参数4:字体
  • 参数5:文字大小(缩放比例)
## 添加文字
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(img, 'ex2tron', (10, 500), font,
            4, (255, 255, 255), 2, lineType=cv2.LINE_AA)

字体可参考:HersheyFonts。另外,这里有个线型lineType参数,LINE_AA表示抗锯齿线型,具体可见LineTypes

小结

  • cv2.line()画直线,cv2.circle()画圆,cv2.rectangle()画矩形,cv2.ellipse()画椭圆,cv2.polylines()画多边形,cv2.putText()添加文字。
  • 画多条直线时,cv2.polylines()要比cv2.line()高效很多。

练习

  1. 你能用已学的绘图功能画出OpenCV的logo吗?(提示:椭圆和圆)

接口文档

引用

挑战任务: 画动态时钟

挑战任务:使用OpenCV绘制一个随系统时间动态变化的时钟。

挑战内容

完成如下图所展示的动态时钟,时钟需随系统时间变化,中间显示当前日期。

其实本次任务涉及的OpenCV知识并不多,但有助于提升大家的编程实践能力。

挑战题不会做也木有关系,但请务必在自行尝试后,再看下面的解答噢,不然...我也没办法( ̄▽ ̄)"

挑战解答

方案

本次挑战任务旨在提升大家的动手实践能力,解决实际问题,所以我们得先有个解题思路和方案。观察下常见的时钟表盘:

整个表盘其实只有3根表针在动,所以可以先画出静态表盘,然后获取系统当前时间,根据时间实时动态绘制3根表针就解决了。

绘制表盘

表盘上只有60条分/秒刻线和12条小时刻线,当然还有表盘的外部轮廓圆,也就是重点在如何画72根线。先把简单的圆画出来:

import cv2
import math
import datetime
import numpy as np

margin = 5  # 上下左右边距
radius = 220  # 圆的半径
center = (center_x, center_y) = (225, 225)  # 圆心

## 1. 新建一个画板并填充成白色
img = np.zeros((450, 450, 3), np.uint8)
img[:] = (255, 255, 255)

## 2. 画出圆盘
cv2.circle(img, center, radius, (0, 0, 0), thickness=5)

前面我们使用OpenCV画直线的时候,需知道直线的起点和终点坐标,那么画72根线就变成了获取72组坐标。

在平面坐标系下,已知半径和角度的话,A点的坐标可以表示为:

$$
\begin{matrix}
x=r\times \cos\alpha \newline
y=r\times \sin\alpha
\end{matrix}
$$

先只考虑将坐标系原点移动到左上角,角度依然是平面坐标系中的逆时针计算,那么新坐标是:

$$
\begin{matrix}
x=r+r\times \cos\alpha \newline
y=r+r\times \sin\alpha
\end{matrix}
$$

对于60条分/秒刻线,刻线间的夹角是360°/60=6°,对于小时刻线,角度是360°/12=30°,这样就得到了72组起点坐标,那怎么得到终点坐标呢?其实同样的原理,用一个同心的小圆来计算得到B点:

通过A/B两点就可以画出直线:

pt1 = []

## 3. 画出60条秒和分钟的刻线
for i in range(60):
    # 最外部圆,计算A点
    x1 = center_x+(radius-margin)*math.cos(i*6*np.pi/180.0)
    y1 = center_y+(radius-margin)*math.sin(i*6*np.pi/180.0)
    pt1.append((int(x1), int(y1)))

    # 同心小圆,计算B点
    x2 = center_x+(radius-15)*math.cos(i*6*np.pi/180.0)
    y2 = center_y+(radius-15)*math.sin(i*6*np.pi/180.0)

    cv2.line(img, pt1[i], (int(x2), int(y2)), (0, 0, 0), thickness=2)

## 4. 画出12条小时的刻线
for i in range(12):
    # 12条小时刻线应该更长一点
    x = center_x+(radius-25)*math.cos(i*30*np.pi/180.0)
    y = center_y+(radius-25)*math.sin(i*30*np.pi/180.0)
    # 这里用到了前面的pt1
    cv2.line(img, pt1[i*5], (int(x), int(y)), (0, 0, 0), thickness=5)

## 到这里基本的表盘图就已经画出来了

角度换算

接下来算是一个小难点,首先时钟的起始坐标在正常二维坐标系的90°方向,其次时钟跟图像一样,都是顺时针计算角度的,所以三者需要统一下:

因为角度是完全对称的,顺逆时针没有影响,所以平面坐标系完全不用理会,放在这里只是便于大家理解。对于时钟坐标和图像坐标,时钟0的0°对应图像的270°,时钟15的90°对应图像的360°,时钟30的180°对应图像的450°(360°+90°)...

所以两者之间的关系便是:

计算角度 = 时钟角度+270°
计算角度 = 计算角度 if 计算角度<=360° else 计算角度-360°

同步时间

Python中如何获取当前时间和添加日期文字都比较简单,看代码就行,我就不解释了。代码中角度计算我换了一种方式,其实是一样的,看你能不能看懂(●ˇ∀ˇ●):

while(1):
    # 不断拷贝表盘图,才能更新绘制,不然会重叠在一起
    temp = np.copy(img)

    # 5. 获取系统时间,画出动态的时-分-秒三条刻线
    now_time = datetime.datetime.now()
    hour, minute, second = now_time.hour, now_time.minute, now_time.second

    # 画秒刻线
    # OpenCV中的角度是顺时针计算的,所以需要转换下
    sec_angle = second*6+270 if second <= 15 else (second-15)*6
    sec_x = center_x+(radius-margin)*math.cos(sec_angle*np.pi/180.0)
    sec_y = center_y+(radius-margin)*math.sin(sec_angle*np.pi/180.0)
    cv2.line(temp, center, (int(sec_x), int(sec_y)), (203, 222, 166), 2)

    # 画分刻线
    min_angle = minute*6+270 if minute <= 15 else (minute-15)*6
    min_x = center_x+(radius-35)*math.cos(min_angle*np.pi/180.0)
    min_y = center_y+(radius-35)*math.sin(min_angle*np.pi/180.0)
    cv2.line(temp, center, (int(min_x), int(min_y)), (186, 199, 137), 8)

    # 画时刻线
    hour_angle = hour*30+270 if hour <= 3 else (hour-3)*30
    hour_x = center_x+(radius-65)*math.cos(hour_angle*np.pi/180.0)
    hour_y = center_y+(radius-65)*math.sin(hour_angle*np.pi/180.0)
    cv2.line(temp, center, (int(hour_x), int(hour_y)), (169, 198, 26), 15)

    # 6. 添加当前日期文字
    font = cv2.FONT_HERSHEY_SIMPLEX
    time_str = now_time.strftime("%d/%m/%Y")
    cv2.putText(img, time_str, (135, 275), font, 1, (0, 0, 0), 2)

    cv2.imshow('clocking', temp)
    if cv2.waitKey(1) == 27:  # 按下ESC键退出
        break

本此挑战旨在锻炼一步步解决实际问题的思路(虽然有点数学知识( ̄▽ ̄)"),大家再接再厉噢!

引用

挑战任务: PyQt5编写GUI界面

拓展挑战:编写GUI图像处理应用程序。

挑战内容

前面我们学习的OpenCV内容都是运行在命令行中的,没有界面,所以本次的拓展挑战内容便是:

了解Python编写GUI界面的方法,使用PyQt5编写如下的图像处理应用程序,实现打开摄像头、捕获图片、读取本地图片、灰度化和Otsu自动阈值分割的功能。

挑战题不会做也木有关系,但请务必在自行尝试后,再看下面的解答噢,不然...我也没办法( ̄▽ ̄)"

挑战解答

简介

目前我们学的内容都是跑在命令行中的,并没有界面,那么"脚本语言"Python如何搭建GUI界面呢?

其实Python支持多种图形界面库,如Tk(Tkinter)wxPythonPyQt等,虽然Python自带Tkinter,无需额外安装包,但我更推荐使用PyQt,一是因为它完全基于Qt,跨平台,功能强大,有助于了解Qt的语法,二是Qt提供了Designer设计工具,界面设计上可以拖控件搞定,非常方便,大大节省时间。

大家感兴趣的话,除去官网,下面是一些可参考的资源:

安装

pip install pyqt5

下载速度慢的话,可以到PyPI上下载离线版安装。另外我推荐使用Qt Designer来设计界面,如果你装的是Anaconda的话,就已经自带了designer.exe,例如我的是在:D:\ProgramData\Anaconda3\Library\bin\,如果是普通的Python环境,则需要自行安装:

pip install pyqt5-tools

安装完成后,designer.exe应该在Python安装目录下:xxx\Lib\site-packages\pyqt5_tools\。

可以使用下面的代码生成一个简单的界面:

import sys
from PyQt5.QtWidgets import QApplication, QWidget

if __name__ == '__main__':
    app = QApplication(sys.argv)

    window = QWidget()
    window.setWindowTitle('Hello World!')
    window.show()

    sys.exit(app.exec_())

界面设计

根据我们的挑战内容,解决思路是使用Qt Designer来设计界面,使用Python完成代码逻辑。打开designer.exe,会弹出创建新窗体的窗口,我们直接点击“create”:

界面的左侧是Qt的常用控件"Widget Box",右侧有一个控件属性窗口"Property Editor",其余暂时用不到。本例中我们只用到了"Push Button"控件和"Label"控件:最上面的三个Label控件用于显示图片,可以在属性窗口调整它的大小,我们统一调整到150×150:

另外,控件上显示的文字"text"属性和控件的名字"objectName"属性需要修改,便于显示和代码调用。可以按照下面我推荐的命名:

控件 显示内容text 控件名objectName
PushButton 打开摄像头 btnOpenCamera
PushButton 捕获图片 btnCapture
PushButton 打开图片 btnReadImage
PushButton 灰度化 btnGray
PushButton 阈值分割(Otsu) btnThreshold
Label 摄像头 labelCamera
Label 捕获图 labelCapture
Label 结果图 labelResult

这样大致界面就出来了,很简单:

按钮事件

如果你之前有过一些GUI开发经验,比如MFC,WinForm等,就知道GUI是通过事件驱动的,什么意思呢?比如前面我们已经设计好了界面,接下来就需要实现"打开摄像头"到"阈值分割"这5个按钮的功能,也就是给每个按钮指定一个"函数",逻辑代码写在这个函数里面。这种函数就称为事件,Qt中称为槽连接。

点击Designer工具栏的"Edit Signals/Slots"按钮,进入槽函数编辑界面,点击旁边的"Edit Widgets"可以恢复正常视图:

然后点击按钮并拖动,当产生类似于电路中的接地符号时释放鼠标,参看下面动图:

在弹出的配置窗口中,可以看到左侧是按钮的常用事件,我们选择点击事件"clicked()",然后添加一个名为"btnOpenCamera_Clicked()"的槽函数:

重复上面的步骤,给五个按钮添加五个槽函数,最终结果如下:

到此,我们就完成了界面设计的所有工作,按下Ctrl+S保存当前窗口为.ui文件。.ui文件其实是按照XML格式标记的内容,可以用文本编辑器将.ui文件打开看看。

ui文件转py代码

因为我们是用Designer工具设计出的界面,并不是用Python代码敲出来的,所以要想真正运行,需要使用pyuic5将ui文件转成py文件。pyuic5.exe默认在%\Scripts\下,比如我的是在:D:\ProgramData\Anaconda3\Scripts\。

打开cmd命令行,切换到ui文件的保存目录。Windows下有个小技巧,可以在目录的地址栏输入cmd,一步切换到当前目录:

然后执行这条指令:

pyuic5 -o mainForm.py using_pyqt_create_ui.ui

如果出现pyuic5不是内部命令的错误,说明pyuic5的路径没有在环境变量里,添加下就好了。执行正常的话,就会生成mainForm.py文件,里面应该包含一个名为"Ui_MainWindow"的类。

编写逻辑代码

经验之谈:mainForm.py文件是根据ui文件生成的,也就是说重新生成会覆盖掉。所以为了使界面与逻辑分离,我们需要新建一个逻辑文件。

在同一工作目录下新建一个"mainEntry.py"的文件,存放逻辑代码。代码中的每部分我都写得比较独立,没有封装成函数,便于理解。代码看上去很长,但很简单,可以每个模块单独看,有几个需要注意的地方我做了注释:

import sys
import cv2

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import QFileDialog, QMainWindow

from mainForm import Ui_MainWindow

class PyQtMainEntry(QMainWindow, Ui_MainWindow):
    def __init__(self):
        super().__init__()
        self.setupUi(self)

        self.camera = cv2.VideoCapture(0)
        self.is_camera_opened = False  # 摄像头有没有打开标记

        # 定时器:30ms捕获一帧
        self._timer = QtCore.QTimer(self)
        self._timer.timeout.connect(self._queryFrame)
        self._timer.setInterval(30)

    def btnOpenCamera_Clicked(self):
        '''
        打开和关闭摄像头
        '''
        self.is_camera_opened = ~self.is_camera_opened
        if self.is_camera_opened:
            self.btnOpenCamera.setText("关闭摄像头")
            self._timer.start()
        else:
            self.btnOpenCamera.setText("打开摄像头")
            self._timer.stop()

    def btnCapture_Clicked(self):
        '''
        捕获图片
        '''
        # 摄像头未打开,不执行任何操作
        if not self.is_camera_opened:
            return

        self.captured = self.frame

        # 后面这几行代码几乎都一样,可以尝试封装成一个函数
        rows, cols, channels = self.captured.shape
        bytesPerLine = channels * cols
        # Qt显示图片时,需要先转换成QImgage类型
        QImg = QImage(self.captured.data, cols, rows, bytesPerLine, QImage.Format_RGB888)
        self.labelCapture.setPixmap(QPixmap.fromImage(QImg).scaled(
            self.labelCapture.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))

    def btnReadImage_Clicked(self):
        '''
        从本地读取图片
        '''
        # 打开文件选取对话框
        filename,  _ = QFileDialog.getOpenFileName(self, '打开图片')
        if filename:
            self.captured = cv2.imread(str(filename))
            # OpenCV图像以BGR通道存储,显示时需要从BGR转到RGB
            self.captured = cv2.cvtColor(self.captured, cv2.COLOR_BGR2RGB)

            rows, cols, channels = self.captured.shape
            bytesPerLine = channels * cols
            QImg = QImage(self.captured.data, cols, rows, bytesPerLine, QImage.Format_RGB888)
            self.labelCapture.setPixmap(QPixmap.fromImage(QImg).scaled(
                self.labelCapture.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))

    def btnGray_Clicked(self):
        '''
        灰度化
        '''
        # 如果没有捕获图片,则不执行操作
        if not hasattr(self, "captured"):
            return

        self.cpatured = cv2.cvtColor(self.captured, cv2.COLOR_RGB2GRAY)

        rows, columns = self.cpatured.shape
        bytesPerLine = columns
        # 灰度图是单通道,所以需要用Format_Indexed8
        QImg = QImage(self.cpatured.data, columns, rows, bytesPerLine, QImage.Format_Indexed8)
        self.labelResult.setPixmap(QPixmap.fromImage(QImg).scaled(
            self.labelResult.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))

    def btnThreshold_Clicked(self):
        '''
        Otsu自动阈值分割
        '''
        if not hasattr(self, "captured"):
            return

        _, self.cpatured = cv2.threshold(
            self.cpatured, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

        rows, columns = self.cpatured.shape
        bytesPerLine = columns
        # 阈值分割图也是单通道,也需要用Format_Indexed8
        QImg = QImage(self.cpatured.data, columns, rows, bytesPerLine, QImage.Format_Indexed8)
        self.labelResult.setPixmap(QPixmap.fromImage(QImg).scaled(
            self.labelResult.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))

    @QtCore.pyqtSlot()
    def _queryFrame(self):
        '''
        循环捕获图片
        '''
        ret, self.frame = self.camera.read()

        img_rows, img_cols, channels = self.frame.shape
        bytesPerLine = channels * img_cols

        cv2.cvtColor(self.frame, cv2.COLOR_BGR2RGB, self.frame)
        QImg = QImage(self.frame.data, img_cols, img_rows, bytesPerLine, QImage.Format_RGB888)
        self.labelCamera.setPixmap(QPixmap.fromImage(QImg).scaled(
            self.labelCamera.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    window = PyQtMainEntry()
    window.show()
    sys.exit(app.exec_())

本文只是抛砖引玉,介绍了PyQt5的简单使用,想要深入学习,可以参考本文开头的参考资料噢(●ˇ∀ˇ●)

引用

番外篇: 代码性能优化

学习如何评估和优化代码性能。(本节还没更新完…………)

完成一项任务很重要,高效地完成更重要。图像处理是对矩阵的操作,数据量巨大。如果代码写的不好,性能差距将很大,所以这节我们来了解下如何评估和提升代码性能。

评估代码运行时间

import cv2

start = cv2.getTickCount()
## 这里写测试代码...
end = cv2.getTickCount()
print((end - start) / cv2.getTickFrequency())

这段代码就是用来测量程序运行时间的(单位:s),其中cv2.getTickCount()函数得到电脑启动以来的时钟周期数,cv2.getTickFrequency()返回你电脑的主频,前后相减再除以主频就是你代码的运行时间(这样解释并不完全准确,但能理解就行)。另外,也可以用Python中的time模块计时:

import time

start = time.clock()
## 这里写测试代码...
end = time.clock()
print(end - start)

经验之谈:如果你使用的是IPythonJupyter Notebook开发环境,性能分析将会非常方便,详情请参考:Timing and Profiling in IPython

优化原则

  • 数据元素少时用Python语法,数据元素多时用Numpy:
x = 10
z = np.uint8([10])

## 尝试比较下面三句话各自的运行时间
y = x * x * x   # (1.6410249677846285e-06)
y = x**3        # (2.461537451676943e-06)
y = z * z * z   # 最慢 (3.1179474387907945e-05)

所以Numpy的运行速度并不一定比Python本身语法快,元素数量较少时,请用Python本身格式。

  • 尽量避免使用循环,尤其嵌套循环,因为极其慢!!!
  • 优先使用OpenCV/Numpy中封装好的函数
  • 尽量将数据向量化,变成Numpy的数据格式
  • 尽量避免数组的复制操作

接口文档

引用

番外篇: 无损保存和Matplotlib

了解常用图片格式和OpenCV高质量保存图片的方式,学习如何使用Matplotlib显示OpenCV图像。

无损保存

事实上,我们日常看到的大部分图片都是压缩过的,那么都有哪些常见的图片格式呢?

常用图片格式

  • bmp
    • 全称:Bitmap
    • 不压缩
  • jpg
    • 全称:Joint Photographic Experts Group
    • 有损压缩方式
  • png
    • 全称:Portable Network Graphics
    • 无损压缩方式

简单来说,同一个文件保存成不同的格式后,文件大小上bmp肯定是最大的,而png和jpg,不同的压缩比结果会有所不同。可以用画图工具新建一副100×100的图像,分别保存成这三种格式来验证:

高质量保存

用cv2.imwrite()保存图片时,可以传入第三个参数(请参考[接口文档]()),用于控制保存质量:

  • cv2.IMWRITE_JPEG_QUALITY:jpg质量控制,取值0~100,值越大,质量越好,默认为95
  • cv2.IMWRITE_PNG_COMPRESSION:png质量控制,取值0~9,值越大,压缩比越高,默认为1

还有诸如CV_IMWRITE_WEBP_QUALITY的参量,不常用,请参考:ImwriteFlags

举例来说,原图lena.jpg的分辨率是350×350,大小49.7KB。我们把它转成不同格式看下:

import cv2

new_img = cv2.imread('lena.jpg')

## bmp
cv2.imwrite('img_bmp.bmp',new_img) # 文件大小:359KB

## jpg 默认95%质量
cv2.imwrite('img_jpg95.jpg',new_img) # 文件大小:52.3KB
## jpg 20%质量
cv2.imwrite('img_jpg20.jpg',new_img,[int(cv2.IMWRITE_JPEG_QUALITY),20]) # 文件大小:8.01KB
## jpg 100%质量
cv2.imwrite('img_jpg100.jpg',new_img,[int(cv2.IMWRITE_JPEG_QUALITY),100]) # 文件大小:82.5KB

## png 默认1压缩比
cv2.imwrite('img_png1.png',new_img) # 文件大小:240KB
## png 9压缩比
cv2.imwrite('img_png9.png',new_img,[int(cv2.IMWRITE_PNG_COMPRESSION),9]) # 文件大小:207KB

可以看到:

  • bmp文件是最大的,没有任何压缩(1个像素点1byte,3通道的彩色图总大小:350×350×3/1024 ≈ 359 KB)
  • jpg/png本身就有压缩的,所以就算是100%的质量保存,体积也比bmp小很多
  • jpg的容量优势很明显,这也是它为什么如此流行的原因

思考:为什么原图49.7KB,保存成bmp或其他格式反而大了呢?

这是个很有趣的问题,很多童鞋都问过我。这里需要明确的是保存新格式时,容量大小跟原图的容量没有直接关系,而是取决于原图的分辨率大小和原图本身的内容(压缩方式),所以lena.jpg保存成不压缩的bmp格式时,容量大小就是固定的350×350×3/1024 ≈ 359 KB;另外,容量变大不代表画质提升噢,不然就逆天了~~~

Matplotlib

Matplotlib是Python的一个很常用的绘图库,有兴趣的可以去官网学习更多内容。

显示灰度图

import cv2
import matplotlib.pyplot as plt

img = cv2.imread('lena.jpg', 0)

## 灰度图显示,cmap(color map)设置为gray
plt.imshow(img, cmap='gray')
plt.show()

结果如下:

显示彩色图

OpenCV中的图像是以BGR的通道顺序存储的,但Matplotlib是以RGB模式显示的,所以直接在Matplotlib中显示OpenCV图像会出现问题,因此需要转换一下:

import cv2
import matplotlib.pyplot as plt

img = cv2.imread('lena.jpg')
img2 = img[:, :, ::-1]
## 或使用
## img2 = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

## 显示不正确的图
plt.subplot(121),plt.imshow(img) 

## 显示正确的图
plt.subplot(122)
plt.xticks([]), plt.yticks([]) # 隐藏x和y轴
plt.imshow(img2)

plt.show()

img[:,:,0]表示图片的蓝色通道,img[:,:,::-1]就表示BGR翻转,变成RGB,说明一下:

熟悉Python的童鞋应该知道,对一个字符串s翻转可以这样写:s[::-1],'abc'变成'cba',-1表示逆序。图片是二维的,所以完整地复制一副图像就是:

img2 = img[:,:] # 写全就是:img2 = img[0:height,0:width]

而图片是有三个通道,相当于一个长度为3的字符串,所以通道翻转与图片复制组合起来便是img[:,:,::-1]

结果如下:

加载和保存图片

不使用OpenCV,Matplotlib也可以加载和保存图片:

import matplotlib.image as pli

img = pli.imread('lena.jpg')
plt.imshow(img)

## 保存图片,需放在show()函数之前
plt.savefig('lena2.jpg')
plt.show()

接口文档

引用

番外篇: 滑动条

学习使用滑动条动态调整参数。图片等可到文末引用处下载。

滑动条的使用

首先我们需要创建一个滑动条,如cv2.createTrackbar('R','image',0,255,call_back),其中

  • 参数1:滑动条的名称
  • 参数2:所在窗口的名称
  • 参数3:当前的值
  • 参数4:最大值
  • 参数5:回调函数名称,回调函数默认有一个表示当前值的参数

创建好之后,可以在回调函数中获取滑动条的值,也可以用:cv2.getTrackbarPos()得到,其中,参数1是滑动条的名称,参数2是窗口的名称。

RGB调色板

下面我们实现一个RGB的调色板,理解下滑动条的用法:

import cv2
import numpy as np

## 回调函数,x表示滑块的位置,本例暂不使用
def nothing(x):
    pass

img = np.zeros((300, 512, 3), np.uint8)
cv2.namedWindow('image')

## 创建RGB三个滑动条
cv2.createTrackbar('R', 'image', 0, 255, nothing)
cv2.createTrackbar('G', 'image', 0, 255, nothing)
cv2.createTrackbar('B', 'image', 0, 255, nothing)

while(True):
    cv2.imshow('image', img)
    if cv2.waitKey(1) == 27:
        break

    # 获取滑块的值
    r = cv2.getTrackbarPos('R', 'image')
    g = cv2.getTrackbarPos('G', 'image')
    b = cv2.getTrackbarPos('B', 'image')
    # 设定img的颜色
    img[:] = [b, g, r]

小结

  • cv2.createTrackbar()用来创建滑动条,可以在回调函数中或使用cv2.getTrackbarPos()得到滑块的位置

接口文档

引用

番外篇: Otsu阈值法

大部分图像处理任务都需要先进行二值化操作,阈值的选取很关键,Otsu阈值法会自动计算阈值。

Otsu阈值法(日本人大津展之提出的,也可称大津算法)非常适用于双峰图片,啥意思呢?

Otsu N. A threshold selection method from gray-level histograms[J]. IEEE transactions on systems, man, and cybernetics, 1979, 9(1): 62-66.

什么是双峰图片?

双峰图片就是指图片的灰度直方图上有两个峰值,直方图就是每个值(0~255)的像素点个数统计,后面会详细介绍。

Otsu算法假设这副图片由前景色和背景色组成,通过统计学方法(最大类间方差)选取一个阈值,将前景和背景尽可能分开,我们先来看下代码,然后详细说明下算法原理。

代码示例

下面这段代码对比了使用固定阈值和Otsu阈值后的不同结果:

另外,对含噪点的图像,先进行滤波操作效果会更好。

import cv2
from matplotlib import pyplot as plt

img = cv2.imread('noisy.jpg', 0)

## 固定阈值法
ret1, th1 = cv2.threshold(img, 100, 255, cv2.THRESH_BINARY)

## Otsu阈值法
ret2, th2 = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

## 先进行高斯滤波,再使用Otsu阈值法
blur = cv2.GaussianBlur(img, (5, 5), 0)
ret3, th3 = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

下面我们用Matplotlib把原图、直方图和阈值图都显示出来:

images = [img, 0, th1, img, 0, th2, blur, 0, th3]
titles = ['Original', 'Histogram', 'Global(v=100)',
          'Original', 'Histogram', "Otsu's",
          'Gaussian filtered Image', 'Histogram', "Otsu's"]

for i in range(3):
    # 绘制原图
    plt.subplot(3, 3, i * 3 + 1)
    plt.imshow(images[i * 3], 'gray')
    plt.title(titles[i * 3], fontsize=8)
    plt.xticks([]), plt.yticks([])

    # 绘制直方图plt.hist,ravel函数将数组降成一维
    plt.subplot(3, 3, i * 3 + 2)
    plt.hist(images[i * 3].ravel(), 256)
    plt.title(titles[i * 3 + 1], fontsize=8)
    plt.xticks([]), plt.yticks([])

    # 绘制阈值图
    plt.subplot(3, 3, i * 3 + 3)
    plt.imshow(images[i * 3 + 2], 'gray')
    plt.title(titles[i * 3 + 2], fontsize=8)
    plt.xticks([]), plt.yticks([])
plt.show()

可以看到,Otsu阈值明显优于固定阈值,省去了不断尝试阈值判断效果好坏的过程。其中,绘制直方图时,使用了numpy中的ravel()函数,它会将原矩阵压缩成一维数组,便于画直方图。

Otsu算法详解

Otsu阈值法将整幅图分为前景(目标)和背景,以下是一些符号规定:

  • $ T $:分割阈值
  • $ N_0 $:前景像素点数
  • $ N_1 $:背景像素点数
  • $ \omega_0 $:前景的像素点数占整幅图像的比例
  • $ \omega_1 $:背景的像素点数占整幅图像的比例
  • $ \mu_0 $:前景的平均像素值
  • $ \mu_1 $:背景的平均像素值
  • $ \mu $:整幅图的平均像素值
  • $ rows\times cols $:图像的行数和列数

结合下图会更容易理解一些,有一副大小为4×4的图片,假设阈值T为1,那么:

其实很好理解,$ N_0+N_1 $就是总的像素点个数,也就是行数乘列数:

$$
N_0+N_1=rows\times cols
$$

$ \omega_0 $和$ \omega_1 $是前/背景所占的比例,也就是:

$$
\omega_0=\frac{N_0}{rows\times cols}
$$

$$
\omega_1=\frac{N_1}{rows\times cols}
$$

$$
\omega_0+\omega_1=1 \tag{1}
$$

整幅图的平均像素值就是:

$$
\mu=\omega_0\times \mu_0+\omega_1\times \mu_1 \tag{2}
$$

此时,我们定义一个前景$ \mu_0 $与背景$ \mu_1 $的方差$ g $:

$$
g=\omega_0(\mu_0-\mu)^2+\omega_1(\mu_1-\mu)^2 \tag{3}
$$

将前述的1/2/3公式整合在一起,便是:

$$
g=\omega_0\omega_1(\mu_0-\mu_1)^2
$$

$ g $就是前景与背景两类之间的方差,这个值越大,说明前景和背景的差别也就越大,效果越好。Otsu算法便是遍历阈值T,使得$ g $最大,所以又称为最大类间方差法。基本上双峰图片的阈值T在两峰之间的谷底。

接口文档

引用

番外篇: 仿射变换与透视变换

常见的2D图像变换从原理上讲主要包括基于2×3矩阵的仿射变换和基于3×3矩阵透视变换

仿射变换

基本的图像变换就是二维坐标的变换:从一种二维坐标(x,y)到另一种二维坐标(u,v)的线性变换:

$$
\begin{matrix}
u=a_1x+b_1y+c_1 \newline
v=a_2x+b_2y+c_2
\end{matrix}
$$

如果写成矩阵的形式,就是:

$$
\left[
\begin{matrix}
u \newline
v
\end{matrix}
\right] = \left[
\begin{matrix}
a_1 & b_1 \newline
a_2 & b_2
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y
\end{matrix}
\right]+\left[
\begin{matrix}
c_1 \newline
c_2
\end{matrix}
\right]
$$

作如下定义:

$$
R=\left[
\begin{matrix}
a_1 & b_1 \newline
a_2 & b_2
\end{matrix}
\right], t=\left[
\begin{matrix}
c_1 \newline
c_2
\end{matrix}
\right],T=\left[
\begin{matrix}
R & t
\end{matrix}
\right]
$$

矩阵T(2×3)就称为仿射变换的变换矩阵,R为线性变换矩阵,t为平移矩阵,简单来说,仿射变换就是线性变换+平移。变换后直线依然是直线,平行线依然是平行线,直线间的相对位置关系不变,因此非共线的三个对应点便可确定唯一的一个仿射变换,线性变换4个自由度+平移2个自由度→仿射变换自由度为6

来看下OpenCV中如何实现仿射变换:

import cv2
import numpy as np
import matplotlib.pyplot as plt

img = cv2.imread('drawing.jpg')
rows, cols = img.shape[:2]

## 变换前的三个点
pts1 = np.float32([[50, 65], [150, 65], [210, 210]])
## 变换后的三个点
pts2 = np.float32([[50, 100], [150, 65], [100, 250]])

## 生成变换矩阵
M = cv2.getAffineTransform(pts1, pts2)
dst = cv2.warpAffine(img, M, (cols, rows))

plt.subplot(121), plt.imshow(img), plt.title('input')
plt.subplot(122), plt.imshow(dst), plt.title('output')
plt.show()

三个点我已经在图中标记了出来。用cv2.getAffineTransform()生成变换矩阵,接下来再用cv2.warpAffine()实现变换。

思考:三个点我标记的是红色,为什么Matplotlib显示出来是下面这种颜色?([练习]())

其实平移、旋转、缩放和翻转等变换就是对应了不同的仿射变换矩阵,下面分别来看下。

平移

平移就是x和y方向上的直接移动,可以上下/左右移动,自由度为2,变换矩阵可以表示为:

$$
\left[
\begin{matrix}
u \newline
v
\end{matrix}
\right] = \left[
\begin{matrix}
1 & 0 \newline
0 & 1
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y
\end{matrix}
\right]+\left[
\begin{matrix}
t_x \newline
t_y
\end{matrix}
\right]
$$

旋转

旋转是坐标轴方向饶原点旋转一定的角度θ,自由度为1,不包含平移,如顺时针旋转可以表示为:

$$
\left[
\begin{matrix}
u \newline
v
\end{matrix}
\right] = \left[
\begin{matrix}
\cos \theta & -\sin \theta \newline
\sin \theta & \cos \theta
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y
\end{matrix}
\right]+\left[
\begin{matrix}
0 \newline
0
\end{matrix}
\right]
$$

思考:如果不是绕原点,而是可变点,自由度是多少呢?(请看下文刚体变换)

翻转

翻转是x或y某个方向或全部方向上取反,自由度为2,比如这里以垂直翻转为例:

$$
\left[
\begin{matrix}
u \newline
v
\end{matrix}
\right] = \left[
\begin{matrix}
1 & 0 \newline
0 & -1
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y
\end{matrix}
\right]+\left[
\begin{matrix}
0 \newline
0
\end{matrix}
\right]
$$

刚体变换

旋转+平移也称刚体变换(Rigid Transform),就是说如果图像变换前后两点间的距离仍然保持不变,那么这种变化就称为刚体变换。刚体变换包括了平移、旋转和翻转,自由度为3。变换矩阵可以表示为:

$$
\left[
\begin{matrix}
u \newline
v
\end{matrix}
\right] = \left[
\begin{matrix}
\cos \theta & -\sin \theta \newline
\sin \theta & \cos \theta
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y
\end{matrix}
\right]+\left[
\begin{matrix}
t_x \newline
t_y
\end{matrix}
\right]
$$

由于只是旋转和平移,刚体变换保持了直线间的长度不变,所以也称欧式变换(变化前后保持欧氏距离)。

缩放

缩放是x和y方向的尺度(倍数)变换,在有些资料上非等比例的缩放也称为拉伸/挤压,等比例缩放自由度为1,非等比例缩放自由度为2,矩阵可以表示为:

$$
\left[
\begin{matrix}
u \newline
v
\end{matrix}
\right] = \left[
\begin{matrix}
s_x & 0 \newline
0 & s_y
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y
\end{matrix}
\right]+\left[
\begin{matrix}
0 \newline
0
\end{matrix}
\right]
$$

相似变换

相似变换又称缩放旋转,相似变换包含了旋转、等比例缩放和平移等变换,自由度为4。在OpenCV中,旋转就是用相似变换实现的:

若缩放比例为scale,旋转角度为θ,旋转中心是$ (center_x,center_y) $,则仿射变换可以表示为:

$$
\left[
\begin{matrix}
u \newline
v
\end{matrix}
\right] = \left[
\begin{matrix}
\alpha & \beta \newline
-\beta & \alpha
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y
\end{matrix}
\right]+\left[
\begin{matrix}
(1-\alpha)center_x-\beta center_y \newline
\beta center_x+(1-\alpha)center_y
\end{matrix}
\right]
$$

其中,

$$
\alpha=scale \cdot \cos \theta,\beta=scale \cdot \sin \theta
$$

相似变换相比刚体变换加了缩放,所以并不会保持欧氏距离不变,但直线间的夹角依然不变。

经验之谈:OpenCV中默认按照逆时针旋转噢~

总结一下(原图[#计算机视觉:算法与应用p39]):

变换 矩阵 自由度 保持性质
平移 [I, t](2×3) 2 方向/长度/夹角/平行性/直线性
刚体 [R, t](2×3) 3 长度/夹角/平行性/直线性
相似 [sR, t](2×3) 4 夹角/平行性/直线性
仿射 [T](2×3) 6 平行性/直线性
透视 [T](3×3) 8 直线性

透视变换

前面仿射变换后依然是平行四边形,并不能做到任意的变换。

透视变换(Perspective Transformation)是将二维的图片投影到一个三维视平面上,然后再转换到二维坐标下,所以也称为投影映射(Projective Mapping)。简单来说就是二维→三维→二维的一个过程。

$$
\begin{matrix}
X=a_1 x + b_1 y + c_1 \newline
Y=a_2 x + b_2 y + c_2 \newline
Z=a_3 x + b_3 y + c_3
\end{matrix}
$$

这次我写成齐次矩阵的形式:

$$
\left[
\begin{matrix}
X \newline
Y \newline
Z
\end{matrix}
\right] = \left[
\begin{matrix}
a_1 & b_1 & c_1 \newline
a_2 & b_2 & c_2 \newline
a_3 & b_3 & c_3
\end{matrix}
\right] \left[
\begin{matrix}
x \newline
y \newline
1
\end{matrix}
\right]
$$

其中,$ \left[ \begin{matrix} a_1 & b_1 \newline a_2 & b_2 \newline \end{matrix} \right] $表示线性变换,$ \left[ \begin{matrix} a_3 & b_3 \end{matrix} \right] $产生透视变换,其余表示平移变换,因此仿射变换是透视变换的子集。接下来再通过除以Z轴转换成二维坐标:

$$
x^{’}=\frac{X}{Z}=\frac{a_1x+b_1y+c_1}{a_3x+b_3y+c_3 }
$$

$$
y^{’}=\frac{Y}{Z}=\frac{a_2x+b_2y+c_2}{a_3x+b_3y+c_3 }
$$

透视变换相比仿射变换更加灵活,变换后会产生一个新的四边形,但不一定是平行四边形,所以需要非共线的四个点才能唯一确定,原图中的直线变换后依然是直线。因为四边形包括了所有的平行四边形,所以透视变换包括了所有的仿射变换。

OpenCV中首先根据变换前后的四个点用cv2.getPerspectiveTransform()生成3×3的变换矩阵,然后再用cv2.warpPerspective()进行透视变换。实战演练一下:

img = cv2.imread('card.jpg')

## 原图中卡片的四个角点
pts1 = np.float32([[148, 80], [437, 114], [94, 247], [423, 288]])
## 变换后分别在左上、右上、左下、右下四个点
pts2 = np.float32([[0, 0], [320, 0], [0, 178], [320, 178]])

## 生成透视变换矩阵
M = cv2.getPerspectiveTransform(pts1, pts2)
## 进行透视变换,参数3是目标图像大小
dst = cv2.warpPerspective(img, M, (320, 178))

plt.subplot(121), plt.imshow(img[:, :, ::-1]), plt.title('input')
plt.subplot(122), plt.imshow(dst[:, :, ::-1]), plt.title('output')
plt.show()

代码中有个img[:, :, ::-1]还记得吗?忘记的话,请看[练习]()。

当然,我们后面学习了特征提取之后,就可以自动识别角点了。透视变换是一项很酷的功能。比如我们经常会用手机去拍身份证和文件,无论你怎么拍,貌似都拍不正或者有边框。如果你使用过手机上面一些扫描类软件,比如"扫描全能王","Office Lens",它们能很好地矫正图片,这些软件就是应用透视变换实现的。

练习

  1. 请复习:无损保存和Matplotlib使用

引用

番外篇: 鼠标绘图

学习如何用鼠标实时绘图。图片等可到文末引用处下载。

目标

  • 捕获鼠标事件
  • OpenCV函数:cv2.setMouseCallback()

教程

知道鼠标在哪儿

OpenCV中,我们需要创建一个鼠标的回调函数来获取鼠标当前的位置、当前的事件如左键按下/左键释放或是右键单击等等,然后执行相应的功能。

使用cv2.setMouseCallback()来创建鼠标的回调函数,比如我们在左键单击的时候,打印出当前鼠标的位置:

import cv2
import numpy as np

## 鼠标的回调函数
def mouse_event(event, x, y, flags, param):
    # 通过event判断具体是什么事件,这里是左键按下
    if event == cv2.EVENT_LBUTTONDOWN:
        print((x, y))

img = np.zeros((512, 512, 3), np.uint8)
cv2.namedWindow('image')
## 定义鼠标的回调函数
cv2.setMouseCallback('image', mouse_event)

while(True):
    cv2.imshow('image', img)
    # 按下ESC键退出
    if cv2.waitKey(20) == 27:
        break

上面的代码先定义鼠标的回调函数mouse_event(),然后在回调函数中判断是否是左键单击事件 EVENT_LBUTTONDOWN,是的话就打印出坐标。需要注意的是,回调函数的参数格式是固定的,不要随意更改。

那除了左键单击之外,还有哪些事件呢?可以用下面的代码打印出来:

## 获取所有的事件
events = [i for i in dir(cv2) if 'EVENT' in i]
print(events)

综合实例

现在我们来实现一个综合的例子,这个实例会帮助你理解图像交互的一些思想:

在图像上用鼠标画图,可以画圆或矩形,按m键在两种模式下切换。左键按下时开始画图,移动到哪儿画到哪儿,左键释放时结束画图。听上去很复杂,是吗?一步步来看:

  • 用鼠标画图:需要定义鼠标的回调函数mouse_event
  • 画圆或矩形:需要定义一个画图的模式mode
  • 左键单击、移动、释放:需要捕获三个不同的事件
  • 开始画图,结束画图:需要定义一个画图的标记位drawing

好,开始coding吧:

import cv2
import numpy as np

drawing = False  # 是否开始画图
mode = True  # True:画矩形,False:画圆
start = (-1, -1)

def mouse_event(event, x, y, flags, param):
    global start, drawing, mode

    # 左键按下:开始画图
    if event == cv2.EVENT_LBUTTONDOWN:
        drawing = True
        start = (x, y)
    # 鼠标移动,画图
    elif event == cv2.EVENT_MOUSEMOVE:
        if drawing:
            if mode:
                cv2.rectangle(img, start, (x, y), (0, 255, 0), 1)
            else:
                cv2.circle(img, (x, y), 5, (0, 0, 255), -1)
    # 左键释放:结束画图
    elif event == cv2.EVENT_LBUTTONUP:
        drawing = False
        if mode:
            cv2.rectangle(img, start, (x, y), (0, 255, 0), 1)
        else:
            cv2.circle(img, (x, y), 5, (0, 0, 255), -1)

img = np.zeros((512, 512, 3), np.uint8)
cv2.namedWindow('image')
cv2.setMouseCallback('image', mouse_event)

while(True):
    cv2.imshow('image', img)
    # 按下m切换模式
    if cv2.waitKey(1) == ord('m'):
        mode = not mode
    elif cv2.waitKey(1) == 27:
        break

效果应该如下图所示:

小结

  • 要用鼠标绘图,需要用cv2.setMouseCallback()定义回调函数,然后在回调函数中根据不同的event事件,执行不同的功能。

练习

1.(选做)实现用鼠标画矩形,跟实例差不多,但只实时画一个,类似下面动图:

2.(选做)做一个在白色面板上绘图的简单程序,可用滑动条调整颜色和笔刷大小。

引用

基础篇

## 09: 图像混合

![](https://jsdelivr.kanochan.net/gh/kanodaisuki/kanochan_net/typora_images/202204020939104.jpeg)

学习图片间的数学运算,图像混合。图片等可到文末引用处下载。

### 目标

* 图片间的数学运算,如相加、按位运算等
* OpenCV函数:`cv2.add()`, `cv2.addWeighted()`, `cv2.bitwise_and()`

### 教程

> 首先恭喜你已经完成了入门篇的学习噢,接下来我们学习一些OpenCV的基础内容,加油\(ง •\_•\)ง

#### 图片相加

要叠加两张图片,可以用`cv2.add()`函数,相加两幅图片的形状(高度/宽度/通道数)必须相同。numpy中可以直接用res = img + img1相加,但这两者的结果并不相同:

```python
x = np.uint8([250])
y = np.uint8([10])
print(cv2.add(x, y))  # 250+10 = 260 => 255
print(x + y)  # 250+10 = 260 % 256 = 4

如果是二值化图片(只有0和255两种值),两者结果是一样的(用numpy的方式更简便一些)。

09图像混合

图像混合`cv2.addWeighted()`也是一种图片相加的操作,只不过两幅图片的权重不一样,γ相当于一个修正值:

$$
dst = \alpha\times img1+\beta\times img2 + \gamma
$$

img1 = cv2.imread('lena_small.jpg')
img2 = cv2.imread('opencv-logo-white.png')
res = cv2.addWeighted(img1, 0.6, img2, 0.4, 0)

经验之谈:α和β都等于1时,就相当于图片相加。

按位操作

按位操作包括按位与/或/非/异或操作,有什么用途呢?比如说我们要实现下图的效果:

如果将两幅图片直接相加会改变图片的颜色,如果用图像混合,则会改变图片的透明度,所以我们需要用按位操作。首先来了解一下掩膜(mask)的概念:掩膜是用一副二值化图片对另外一幅图片进行局部的遮挡,看下图就一目了然了:

所以我们的思路就是把原图中要放logo的区域抠出来,再把logo放进去就行了:

img1 = cv2.imread('lena.jpg')
img2 = cv2.imread('opencv-logo-white.png')

## 把logo放在左上角,所以我们只关心这一块区域
rows, cols = img2.shape[:2]
roi = img1[:rows, :cols]

## 创建掩膜
img2gray = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
ret, mask = cv2.threshold(img2gray, 10, 255, cv2.THRESH_BINARY)
mask_inv = cv2.bitwise_not(mask)

## 保留除logo外的背景
img1_bg = cv2.bitwise_and(roi, roi, mask=mask_inv)
dst = cv2.add(img1_bg, img2)  # 进行融合
img1[:rows, :cols] = dst  # 融合后放在原图上

经验之谈:掩膜的概念在图像混合/叠加的场景下使用较多,可以多多练习噢!

小结

  • `cv2.add()`用来叠加两幅图片,`cv2.addWeighted()`也是叠加两幅图片,但两幅图片的权重不一样。
  • `cv2.bitwise_and()`, `cv2.bitwise_not()`, `cv2.bitwise_or()`, `cv2.bitwise_xor()`分别执行按位与/或/非/异或运算。掩膜就是用来对图片进行全局或局部的遮挡。

接口文档

引用

10: 平滑图像

学习模糊/平滑图像,消除噪点。图片等可到文末引用处下载。

目标

  • 模糊/平滑图片来消除图片噪声
  • OpenCV函数:`cv2.blur()`, `cv2.GaussianBlur()`, `cv2.medianBlur()`, `cv2.bilateralFilter()`

教程

滤波与模糊

推荐大家先阅读:番外篇:卷积基础(图片边框),有助于理解卷积和滤波的概念。

关于滤波和模糊,很多人分不清,我来给大家理理(虽说如此,我后面也会混着用,,ԾㅂԾ,,):

  • 它们都属于卷积,不同滤波方法之间只是卷积核不同(对线性滤波而言)
  • 低通滤波器是模糊,高通滤波器是锐化

低通滤波器就是允许低频信号通过,在图像中边缘和噪点都相当于高频部分,所以低通滤波器用于去除噪点、平滑和模糊图像。高通滤波器则反之,用来增强图像边缘,进行锐化处理。

常见噪声有椒盐噪声高斯噪声,椒盐噪声可以理解为斑点,随机出现在图像中的黑点或白点;高斯噪声可以理解为拍摄图片时由于光照等原因造成的噪声。

均值滤波

均值滤波是一种最简单的滤波处理,它取的是卷积核区域内元素的均值,用`cv2.blur()`实现,如3×3的卷积核:

$$
kernel = \frac{1}{9}\left[
\begin{matrix}
1 & 1 & 1 \newline
1 & 1 & 1 \newline
1 & 1 & 1
\end{matrix}
\right]
$$

img = cv2.imread('lena.jpg')
blur = cv2.blur(img, (3, 3))  # 均值模糊

所有的滤波函数都有一个可选参数borderType,这个参数就是番外篇:卷积基础(图片边框)中所说的边框填充方式。

方框滤波

方框滤波跟均值滤波很像,如3×3的滤波核如下:

$$
k = a\left[
\begin{matrix}
1 & 1 & 1 \newline
1 & 1 & 1 \newline
1 & 1 & 1
\end{matrix}
\right]
$$

用`cv2.boxFilter()`函数实现,当可选参数normalize为True的时候,方框滤波就是均值滤波,上式中的a就等于1/9;normalize为False的时候,a=1,相当于求区域内的像素和。

## 前面的均值滤波也可以用方框滤波实现:normalize=True
blur = cv2.boxFilter(img, -1, (3, 3), normalize=True)

高斯滤波

前面两种滤波方式,卷积核内的每个值都一样,也就是说图像区域中每个像素的权重也就一样。高斯滤波的卷积核权重并不相同:中间像素点权重最高,越远离中心的像素权重越小,来,数学时间( ╯□╰ ),还记得标准正态分布的曲线吗?

显然这种处理元素间权值的方式更加合理一些。图像是2维的,所以我们需要使用2维的高斯函数,比如OpenCV中默认的3×3的高斯卷积核(具体原理和卷积核生成方式请参考文末的[番外小篇]()):

$$
k = \left[
\begin{matrix}
0.0625 & 0.125 & 0.0625 \newline
0.125 & 0.25 & 0.125 \newline
0.0625 & 0.125 & 0.0625
\end{matrix}
\right]
$$

OpenCV中对应函数为`cv2.GaussianBlur(src,ksize,sigmaX)`:

img = cv2.imread('gaussian_noise.bmp')
## 均值滤波vs高斯滤波
blur = cv2.blur(img, (5, 5))  # 均值滤波
gaussian = cv2.GaussianBlur(img, (5, 5), 1)  # 高斯滤波

参数3 σx值越大,模糊效果越明显。高斯滤波相比均值滤波效率要慢,但可以有效消除高斯噪声,能保留更多的图像细节,所以经常被称为最有用的滤波器。均值滤波与高斯滤波的对比结果如下(均值滤波丢失的细节更多):

中值滤波

中值又叫中位数,是所有数排序后取中间的值。中值滤波就是用区域内的中值来代替本像素值,所以那种孤立的斑点,如0或255很容易消除掉,适用于去除椒盐噪声和斑点噪声。中值是一种非线性操作,效率相比前面几种线性滤波要慢。

比如下面这张斑点噪声图,用中值滤波显然更好:

img = cv2.imread('salt_noise.bmp', 0)
## 均值滤波vs中值滤波
blur = cv2.blur(img, (5, 5))  # 均值滤波
median = cv2.medianBlur(img, 5)  # 中值滤波

双边滤波

模糊操作基本都会损失掉图像细节信息,尤其前面介绍的线性滤波器,图像的边缘信息很难保留下来。然而,边缘(edge)信息是图像中很重要的一个特征,所以这才有了双边滤波。用`cv2.bilateralFilter()`函数实现:

img = cv2.imread('lena.jpg')
## 双边滤波vs高斯滤波
gau = cv2.GaussianBlur(img, (5, 5), 0)  # 高斯滤波
blur = cv2.bilateralFilter(img, 9, 75, 75)  # 双边滤波

可以看到,双边滤波明显保留了更多边缘信息。

番外小篇:高斯滤波卷积核

要解释高斯滤波卷积核是如何生成的,需要先复习下概率论的知识(What??又是数学( ╯□╰ ))

一维的高斯函数/正态分布$ X\sim N(\mu, \sigma^2) $:

$$
G(x)=\frac{1}{\sqrt{2\pi}\sigma}exp(-\frac{(x-\mu)^2}{2\sigma^2})
$$

当$ \mu=0, \sigma^2=1 $时,称为标准正态分布$ X\sim N(0, 1) $:

$$
G(x)=\frac{1}{\sqrt{2\pi}}exp(-\frac{x^2}{2})
$$

二维X/Y相互独立的高斯函数:

$$
G(x,y)=\frac{1}{2\pi\sigma_x\sigma_y}exp(-\frac{(x-\mu_x)^2+(y-\mu_y)^2}{2\sigma_x\sigma_y})=G(x)G(y)
$$

由上可知,二维高斯函数具有可分离性,所以OpenCV分两步计算二维高斯卷积,先水平再垂直,每个方向上都是一维的卷积。OpenCV中这个一维卷积的计算公式类似于上面的一维高斯函数:

$$
G(i)=\alpha *exp(-\frac{(i-\frac{ksize-1}{2})^2}{2\sigma^2})
$$

其中i=0…ksize-1,α是一个常数,也称为缩放因子,它使得\(\sum{G(i)}=1\)

比如我们可以用`cv2.getGaussianKernel(ksize,sigma)`来生成一维卷积核:

  • sigma<=0时,`sigma=0.3((ksize-1)0.5 - 1) + 0.8`
  • sigma>0时,sigma=sigma
print(cv2.getGaussianKernel(3, 0))
## 结果:[[0.25][0.5][0.25]]

生成之后,先进行三次的水平卷积:

$$
I×\left[
\begin{matrix}
0.25 & 0.5 & 0.25 \newline
0.25 & 0.5 & 0.25 \newline
0.25 & 0.5 & 0.25
\end{matrix}
\right]
$$

然后再进行垂直的三次卷积:

$$
I×\left[
\begin{matrix}
0.25 & 0.5 & 0.25 \newline
0.25 & 0.5 & 0.25 \newline
0.25 & 0.5 & 0.25
\end{matrix}
\right]×\left[
\begin{matrix}
0.25 & 0.25 & 0.25 \newline
0.5 & 0.5 & 0.5 \newline
0.25 & 0.25 & 0.25
\end{matrix}
\right] =I×\left[
\begin{matrix}
0.0625 & 0.125 & 0.0625 \newline
0.125 & 0.25 & 0.125 \newline
0.0625 & 0.125 & 0.0625
\end{matrix}
\right]
$$

这就是OpenCV中高斯卷积核的生成方式。其实,OpenCV源码中对小于7×7的核是直接计算好放在数组里面的,这样计算速度会快一点,感兴趣的可以看下源码:getGaussianKernel()

上面矩阵也可以写成:

$$
\frac{1}{16}\left[
\begin{matrix}
1& 2 & 1 \newline
2 & 4 & 2 \newline
1 & 2 & 1
\end{matrix}
\right]
$$

小结

  • 在不知道用什么滤波器好的时候,优先高斯滤波`cv2.GaussianBlur()`,然后均值滤波`cv2.blur()`。
  • 斑点和椒盐噪声优先使用中值滤波`cv2.medianBlur()`。
  • 要去除噪点的同时尽可能保留更多的边缘信息,使用双边滤波`cv2.bilateralFilter()`。
  • 线性滤波方式:均值滤波、方框滤波、高斯滤波(速度相对快)。
  • 非线性滤波方式:中值滤波、双边滤波(速度相对慢)。

接口文档

引用

11: 边缘检测

学习使用Canny获取图像的边缘。图片等可到文末引用处下载。

Canny J . A Computational Approach To Edge Detection[J]. IEEE Transactions on Pattern Analysis and Machine Intelligence, 1986, PAMI-8(6):679-698.

目标

  • Canny边缘检测的简单概念
  • OpenCV函数:`cv2.Canny()`

教程

Canny边缘检测方法常被誉为边缘检测的最优方法,废话不多说,先看个例子:

import cv2
import numpy as np

img = cv2.imread('handwriting.jpg', 0)
edges = cv2.Canny(img, 30, 70)  # canny边缘检测

cv2.imshow('canny', np.hstack((img, edges)))
cv2.waitKey(0)

`cv2.Canny()`进行边缘检测,参数2、3表示最低、高阈值,下面来解释下具体原理。

经验之谈:之前我们用低通滤波的方式模糊了图片,那反过来,想得到物体的边缘,就需要用到高通滤波。推荐先阅读:番外篇:图像梯度

Canny边缘检测

Canny边缘提取的具体步骤如下:

1,使用5×5高斯滤波消除噪声:

边缘检测本身属于锐化操作,对噪点比较敏感,所以需要进行平滑处理。高斯滤波的具体内容参考前一篇:平滑图像

$$
K=\frac{1}{256}\left[
\begin{matrix}
1 & 4 & 6 & 4 & 1 \newline
4 & 16 & 24 & 16 & 4 \newline
6 & 24 & 36 & 24 & 6 \newline
4 & 16 & 24 & 16 & 4 \newline
1 & 4 & 6 & 4 & 1
\end{matrix}
\right]
$$

2,计算图像梯度的方向:

首先使用Sobel算子计算两个方向上的梯度$ G_x $和$ G_y $,然后算出梯度的方向:

$$
\theta=\arctan(\frac{G_y}{G_x})
$$

保留这四个方向的梯度:0°/45°/90°/135°,有什么用呢?我们接着看。

3,取局部极大值:

梯度其实已经表示了轮廓,但为了进一步筛选,可以在上面的四个角度方向上再取局部极大值:

比如,A点在45°方向上大于B/C点,那就保留它,把B/C设置为0。

4,滞后阈值:

经过前面三步,就只剩下0和可能的边缘梯度值了,为了最终确定下来,需要设定高低阈值:

  • 像素点的值大于最高阈值,那肯定是边缘(上图A)
  • 同理像素值小于最低阈值,那肯定不是边缘
  • 像素值介于两者之间,如果与高于最高阈值的点连接,也算边缘,所以上图中C算,B不算

Canny推荐的高低阈值比在2:1到3:1之间。

先阈值分割后检测

其实很多情况下,阈值分割后再检测边缘,效果会更好:

_, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
edges = cv2.Canny(thresh, 30, 70)

cv2.imshow('canny', np.hstack((img, thresh, edges)))
cv2.waitKey(0)

代码中我用了番外篇:Otsu阈值法中的自动阈值分割,如果你不太了解,大可以使用传统的方法,不过如果是下面这种图片,推荐用Otsu阈值法。另外Python中某个值不用的话,就写个下划线'_'。

练习

  1. (选做)如果你不太理解高低阈值的效果,创建两个滑动条来调节它们的值看看:

小结

  • Canny是用的最多的边缘检测算法,用`cv2.Canny()`实现。

接口文档

引用

12: 腐蚀与膨胀

学习常用形态学操作:腐蚀膨胀,开运算和闭运算。图片等可到文末引用处下载。

目标

  • 了解形态学操作的概念
  • 学习膨胀、腐蚀、开运算和闭运算等形态学操作
  • OpenCV函数:`cv2.erode()`, `cv2.dilate()`, `cv2.morphologyEx()`

教程

啥叫形态学操作

形态学操作其实就是改变物体的形状,比如腐蚀就是"变瘦",膨胀就是"变胖",看下图就明白了:

经验之谈:形态学操作一般作用于二值化图,来连接相邻的元素或分离成独立的元素。腐蚀和膨胀是针对图片中的白色部分!

腐蚀

腐蚀的效果是把图片"变瘦",其原理是在原图的小区域内取局部最小值。因为是二值化图,只有0和255,所以小区域内有一个是0该像素点就为0:

这样原图中边缘地方就会变成0,达到了瘦身目的(小胖福利(●ˇ∀ˇ●))

OpenCV中用`cv2.erode()`函数进行腐蚀,只需要指定核的大小就行:

import cv2
import numpy as np

img = cv2.imread('j.bmp', 0)
kernel = np.ones((5, 5), np.uint8)
erosion = cv2.erode(img, kernel)  # 腐蚀

这个核也叫结构元素,因为形态学操作其实也是应用卷积来实现的。结构元素可以是矩形/椭圆/十字形,可以用`cv2.getStructuringElement()`来生成不同形状的结构元素,比如:

kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))  # 矩形结构
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))  # 椭圆结构
kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (5, 5))  # 十字形结构

膨胀

膨胀与腐蚀相反,取的是局部最大值,效果是把图片"变胖":

dilation = cv2.dilate(img, kernel)  # 膨胀

开/闭运算

先腐蚀后膨胀叫开运算(因为先腐蚀会分开物体,这样容易记住),其作用是:分离物体,消除小区域。这类形态学操作用`cv2.morphologyEx()`函数实现:

kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))  # 定义结构元素

img = cv2.imread('j_noise_out.bmp', 0)
opening = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel)  # 开运算

闭运算则相反:先膨胀后腐蚀(先膨胀会使白色的部分扩张,以至于消除/"闭合"物体里面的小黑洞,所以叫闭运算)

img = cv2.imread('j_noise_in.bmp', 0)
closing = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)  # 闭运算

经验之谈:很多人对开闭运算的作用不是很清楚(好吧,其实是比较容易混◑﹏◐),但看上图↑,不用怕:如果我们的目标物体外面有很多无关的小区域,就用开运算去除掉;如果物体内部有很多小黑洞,就用闭运算填充掉。

接下来的3种形态学操作并不常用,大家有兴趣可以看看(因为较短,没有做成番外篇):

其他形态学操作

  • 形态学梯度:膨胀图减去腐蚀图,`dilation - erosion`,这样会得到物体的轮廓:
img = cv2.imread('school.bmp', 0)
gradient = cv2.morphologyEx(img, cv2.MORPH_GRADIENT, kernel)

  • 顶帽:原图减去开运算后的图:`src - opening`
tophat = cv2.morphologyEx(img, cv2.MORPH_TOPHAT, kernel)
  • 黑帽:闭运算后的图减去原图:`closing - src`
blackhat = cv2.morphologyEx(img, cv2.MORPH_BLACKHAT, kernel)

小结

  • 形态学操作就是改变物体的形状,如腐蚀使物体"变瘦",膨胀使物体"变胖"。
  • 先腐蚀后膨胀会分离物体,所以叫开运算,常用来去除小区域物体。
  • 先膨胀后腐蚀会消除物体内的小洞,所以叫闭运算。开/闭理解了之后很容易记忆噢(⊙o⊙)。

接口文档

引用

13: 轮廓

学习如何寻找并绘制轮廓。图片等可到文末引用处下载。

目标

  • 了解轮廓概念
  • 寻找并绘制轮廓
  • OpenCV函数:`cv2.findContours()`, `cv2.drawContours()`

教程

啥叫轮廓

轮廓是一系列相连的点组成的曲线,代表了物体的基本外形。

谈起轮廓不免想到边缘,它们确实很像。简单的说,轮廓是连续的,边缘并不全都连续(下图)。其实边缘主要是作为图像的特征使用,比如可以用边缘特征可以区分脸和手,而轮廓主要用来分析物体的形态,比如物体的周长和面积等,可以说边缘包括轮廓。

寻找轮廓的操作一般用于二值化图,所以通常会使用阈值分割或Canny边缘检测先得到二值图。

经验之谈:寻找轮廓是针对白色物体的,一定要保证物体是白色,而背景是黑色,不然很多人在寻找轮廓时会找到图片最外面的一个框

寻找轮廓

使用`cv2.findContours()`寻找轮廓:

import cv2

img = cv2.imread('handwriting.jpg')
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

## 寻找二值化图中的轮廓
image, contours, hierarchy = cv2.findContours(
    thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
print(len(contours))  # 结果应该为2
  • 参数2:轮廓的查找方式,一般使用cv2.RETR_TREE,表示提取所有的轮廓并建立轮廓间的层级。更多请参考:RetrievalModes
  • 参数3:轮廓的近似方法。比如对于一条直线,我们可以存储该直线的所有像素点,也可以只存储起点和终点。使用cv2.CHAIN_APPROX_SIMPLE就表示用尽可能少的像素点表示轮廓。更多请参考:ContourApproximationModes
  • 简便起见,这两个参数也可以直接用真值3和2表示。

函数有3个返回值,image还是原来的二值化图片,hierarchy是轮廓间的层级关系(番外篇:轮廓层级),这两个暂时不用理会。我们主要看contours,它就是找到的轮廓了,以数组形式存储,记录了每条轮廓的所有像素点的坐标(x,y)。

绘制轮廓

轮廓找出来后,为了方便观看,可以像前面图中那样用红色画出来:`cv2.drawContours()`

cv2.drawContours(img, contours, -1, (0, 0, 255), 2)

其中参数2就是得到的contours,参数3表示要绘制哪一条轮廓,-1表示绘制所有轮廓,参数4是颜色(B/G/R通道,所以(0,0,255)表示红色),参数5是线宽,之前在绘制图形中介绍过。

经验之谈:很多人画图时明明用了彩色,但没有效果,请检查你是在哪个图上画,画在灰度图和二值图上显然是没有彩色的(⊙o⊙)。

一般情况下,我们会首先获得要操作的轮廓,再进行轮廓绘制及分析:

cnt = contours[1]
cv2.drawContours(img, [cnt], 0, (0, 0, 255), 2)

小结

  • 轮廓特征非常有用,使用`cv2.findContours()`寻找轮廓,`cv2.drawContours()`绘制轮廓。

接口文档

引用

14: 轮廓特征

学习计算轮廓特征,如面积、周长、最小外接矩形等。图片等可到文末引用处下载。

目标

  • 计算物体的周长、面积、质心、最小外接矩形等
  • OpenCV函数:`cv2.contourArea()`, `cv2.arcLength()`, `cv2.approxPolyDP()` 等

教程

在计算轮廓特征之前,我们先用上一节的代码把轮廓找到:

import cv2
import numpy as np

img = cv2.imread('handwriting.jpg', 0)
_, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
image, contours, hierarchy = cv2.findContours(thresh, 3, 2)

## 以数字3的轮廓为例
cnt = contours[0]

为了便于绘制,我们创建出两幅彩色图,并把轮廓画在第一幅图上:

img_color1 = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
img_color2 = np.copy(img_color1)
cv2.drawContours(img_color1, [cnt], 0, (0, 0, 255), 2)

轮廓面积

area = cv2.contourArea(cnt)  # 4386.5

注意轮廓特征计算的结果并不等同于像素点的个数,而是根据几何方法算出来的,所以有小数。

如果统计二值图中像素点个数,应尽量避免循环,可以使用`cv2.countNonZero()`,更加高效。

轮廓周长

perimeter = cv2.arcLength(cnt, True)  # 585.7

参数2表示轮廓是否封闭,显然我们的轮廓是封闭的,所以是True。

图像矩

矩可以理解为图像的各类几何特征,详情请参考:[Image Moments]

M = cv2.moments(cnt)

M中包含了很多轮廓的特征信息,比如M['m00']表示轮廓面积,与前面`cv2.contourArea()`计算结果是一样的。质心也可以用它来算:

cx, cy = M['m10'] / M['m00'], M['m01'] / M['m00']  # (205, 281)

外接矩形

形状的外接矩形有两种,如下图,绿色的叫外接矩形,表示不考虑旋转并且能包含整个轮廓的矩形。蓝色的叫最小外接矩,考虑了旋转:

x, y, w, h = cv2.boundingRect(cnt)  # 外接矩形
cv2.rectangle(img_color1, (x, y), (x + w, y + h), (0, 255, 0), 2)
rect = cv2.minAreaRect(cnt)  # 最小外接矩形
box = np.int0(cv2.boxPoints(rect))  # 矩形的四个角点取整
cv2.drawContours(img_color1, [box], 0, (255, 0, 0), 2)

其中np.int0(x)是把x取整的操作,比如377.93就会变成377,也可以用x.astype(np.int)。

最小外接圆

外接圆跟外接矩形一样,找到一个能包围物体的最小圆:

(x, y), radius = cv2.minEnclosingCircle(cnt)
(x, y, radius) = np.int0((x, y, radius))  # 圆心和半径取整
cv2.circle(img_color2, (x, y), radius, (0, 0, 255), 2)

拟合椭圆

我们可以用得到的轮廓拟合出一个椭圆:

ellipse = cv2.fitEllipse(cnt)
cv2.ellipse(img_color2, ellipse, (255, 255, 0), 2)

形状匹配

`cv2.matchShapes()`可以检测两个形状之间的相似度,返回值越小,越相似。先读入下面这张图片:

img = cv2.imread('shapes.jpg', 0)
_, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
image, contours, hierarchy = cv2.findContours(thresh, 3, 2)
img_color = cv2.cvtColor(thresh, cv2.COLOR_GRAY2BGR)  # 用于绘制的彩色图

图中有3条轮廓,我们用A/B/C表示:

cnt_a, cnt_b, cnt_c = contours[0], contours[1], contours[2]
print(cv2.matchShapes(cnt_b, cnt_b, 1, 0.0))  # 0.0
print(cv2.matchShapes(cnt_b, cnt_c, 1, 0.0))  # 2.17e-05
print(cv2.matchShapes(cnt_b, cnt_a, 1, 0.0))  # 0.418

可以看到BC相似程度比AB高很多,并且图形的旋转或缩放并没有影响。其中,参数3是匹配方法,详情可参考:ShapeMatchModes,参数4是OpenCV的预留参数,暂时没有实现,可以不用理会。

形状匹配是通过图像的Hu矩来实现的(`cv2.HuMoments()`),大家如果感兴趣,可以参考:Hu-Moments

练习

  1. 前面我们是对图片中的数字3进行轮廓特征计算的,大家换成数字1看看。
  2. (选做)用形状匹配比较两个字母或数字(这相当于很简单的一个OCR噢)。

小结

常用的轮廓特征:

  • `cv2.contourArea()`算面积,`cv2.arcLength()`算周长,`cv2.boundingRect()`算外接矩。
  • `cv2.minAreaRect()`算最小外接矩,`cv2.minEnclosingCircle()`算最小外接圆。
  • `cv2.matchShapes()`进行形状匹配。

接口文档

引用

15: 直方图

学习计算并绘制直方图,直方图均衡化等。图片等可到文末引用处下载。

目标

  • 计算并绘制直方图
  • (自适应)直方图均衡化
  • OpenCV函数:`cv2.calcHist()`, `cv2.equalizeHist()`

教程

啥叫直方图

简单来说,直方图就是图像中每个像素值的个数统计,比如说一副灰度图中像素值为0的有多少个,1的有多少个……:

在计算直方图之前,有几个术语先来了解一下:

  • dims: 要计算的通道数,对于灰度图dims=1,普通彩色图dims=3
  • range: 要计算的像素值范围,一般为[0,256)
  • bins: 子区段数目,如果我们统计0`~`255每个像素值,bins=256;如果划分区间,比如0`~`15, 16`~`31…240`~`255这样16个区间,bins=16

计算直方图

OpenCV和Numpy中都提供了计算直方图的函数,我们对比下它们的性能。

OpenCV中直方图计算

使用`cv2.calcHist(images, channels, mask, histSize, ranges)`计算,其中:

  • 参数1:要计算的原图,以方括号的传入,如:[img]
  • 参数2:类似前面提到的dims,灰度图写[0]就行,彩色图B/G/R分别传入[0]/[1]/[2]
  • 参数3:要计算的区域,计算整幅图的话,写None
  • 参数4:前面提到的bins
  • 参数5:前面提到的range
import cv2
import numpy as np
import matplotlib.pyplot as plt

img = cv2.imread('hist.jpg', 0)
hist = cv2.calcHist([img], [0], None, [256], [0, 256])  # 性能:0.025288 s
Numpy中直方图计算

也可用Numpy的函数计算,其中ravel()函数将二维矩阵展平变成一维数组,之前有提到过:

hist, bins = np.histogram(img.ravel(), 256, [0, 256])  # 性能:0.020628 s

经验之谈:Numpy中还有一种更高效的方式:(还记得怎么评估性能吗:番外篇:代码性能优化

hist = np.bincount(img.ravel(), minlength=256)  # 性能:0.003163 s

计算出直方图之后,怎么把它画出来呢?

绘制直方图

其实Matplotlib自带了一个计算并绘制直方图的功能,不需要用到上面的函数:

plt.hist(img.ravel(), 256, [0, 256])
plt.show()

当然,也可以用前面计算出来的结果绘制:

plt.plot(hist)
plt.show()

从直方图上可以看到图片的大部分区域集中在150偏白的附近,这其实并不是很好的效果,下面我们来看看如何改善它。

使用OpenCV的画线功能也可以画直方图,不过太麻烦了,有兴趣的可以看下官方示例:hist.py

直方图均衡化

一副效果好的图像通常在直方图上的分布比较均匀,直方图均衡化就是用来改善图像的全局亮度和对比度。其实从观感上就可以发现,前面那幅图对比度不高,偏灰白。对均衡化算法感兴趣的同学可参考:维基百科:直方图均衡化

equ = cv2.equalizeHist(img)

OpenCV中用`cv2.equalizeHist()`实现均衡化。我们把两张图片并排显示,对比一下:

cv2.imshow('equalization', np.hstack((img, equ)))  # 并排显示
cv2.waitKey(0)

可以看到均衡化后图片的亮度和对比度效果明显好于原图。

自适应均衡化

不难看出来,直方图均衡化是应用于整幅图片的,会有什么问题呢?看下图:

很明显,因为全局调整亮度和对比度的原因,脸部太亮,大部分细节都丢失了。

自适应均衡化就是用来解决这一问题的:它在每一个小区域内(默认8×8)进行直方图均衡化。当然,如果有噪点的话,噪点会被放大,需要对小区域内的对比度进行了限制,所以这个算法全称叫:对比度受限的自适应直方图均衡化CLAHE(Contrast Limited Adaptive Histogram Equalization)。

## 自适应均衡化,参数可选
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
cl1 = clahe.apply(img)

练习

  1. `cv2.calcHist()`函数中的参数3是指要计算的区域(mask:目标区域白色,其余黑色),编写一个只计算图片左上角200×200区域直方图的程序。

小结

  • 直方图是一种分析图像的手段。
  • `cv2.calcHist()`和`numpy.bincount()`均可用来计算直方图,使用Matplotlib绘制直方图。
  • 均衡化用来使图像的直方图分布更加均匀,提升亮度和对比度。

引用

16: 模板匹配

学习使用模板匹配在图像中寻找物体。图片等可到文末引用处下载。

目标

  • 使用模板匹配在图像中寻找物体
  • OpenCV函数:`cv2.matchTemplate()`, `cv2.minMaxLoc()`

教程

模板匹配

模板匹配就是用来在大图中找小图,也就是说在一副图像中寻找另外一张模板图像的位置:

用`cv2.matchTemplate()`实现模板匹配。首先我们来读入图片和模板:

import cv2
import numpy as np
from matplotlib import pyplot as plt

img = cv2.imread('lena.jpg', 0)
template = cv2.imread('face.jpg', 0)
h, w = template.shape[:2]  # rows->h, cols->w

匹配函数返回的是一副灰度图,最白的地方表示最大的匹配。使用`cv2.minMaxLoc()`函数可以得到最大匹配值的坐标,以这个点为左上角角点,模板的宽和高画矩形就是匹配的位置了:

## 相关系数匹配方法:cv2.TM_CCOEFF
res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)

left_top = max_loc  # 左上角
right_bottom = (left_top[0] + w, left_top[1] + h)  # 右下角
cv2.rectangle(img, left_top, right_bottom, 255, 2)  # 画出矩形位置

原理

这部分可看可不看,不太理解也没关系,还记得前面的方法吗?不懂得就划掉(✿◕‿◕✿)

模板匹配的原理其实很简单,就是不断地在原图中移动模板图像去比较,有6种不同的比较方法,详情可参考:TemplateMatchModes

  • 平方差匹配CV_TM_SQDIFF:用两者的平方差来匹配,最好的匹配值为0
  • 归一化平方差匹配CV_TM_SQDIFF_NORMED
  • 相关匹配CV_TM_CCORR:用两者的乘积匹配,数值越大表明匹配程度越好
  • 归一化相关匹配CV_TM_CCORR_NORMED
  • 相关系数匹配CV_TM_CCOEFF:用两者的相关系数匹配,1表示完美的匹配,-1表示最差的匹配
  • 归一化相关系数匹配CV_TM_CCOEFF_NORMED

归一化的意思就是将值统一到0~1,这些方法的对比代码可到[源码处]()查看。模板匹配也是应用卷积来实现的:假设原图大小为W×H,模板图大小为w×h,那么生成图大小是(W-w+1)×(H-h+1),生成图中的每个像素值表示原图与模板的匹配程度。

匹配多个物体

前面我们是找最大匹配的点,所以只能匹配一次。我们可以设定一个匹配阈值来匹配多次:

## 1.读入原图和模板
img_rgb = cv2.imread('mario.jpg')
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY)
template = cv2.imread('mario_coin.jpg', 0)
h, w = template.shape[:2]

## 2.标准相关模板匹配
res = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED)
threshold = 0.8 

## 3.这边是Python/Numpy的知识,后面解释
loc = np.where(res >= threshold)  # 匹配程度大于%80的坐标y,x
for pt in zip(*loc[::-1]):  # *号表示可选参数
    right_bottom = (pt[0] + w, pt[1] + h)
    cv2.rectangle(img_rgb, pt, right_bottom, (0, 0, 255), 2)

第3步有几个Python/Numpy的重要知识,来大致看下:

  • np.where()在这里返回res中值大于0.8的所有坐标,如:
x = np.arange(9.).reshape(3, 3)
print(np.where(x > 5))
## 结果(先y坐标,再x坐标):(array([2, 2, 2]), array([0, 1, 2]))

  • zip()函数,功能强大到难以解释,举个简单例子就知道了:
x = [1, 2, 3]
y = [4, 5, 6]
print(list(zip(x, y)))  # [(1, 4), (2, 5), (3, 6)]

这样大家就能理解前面代码的用法了吧:因为loc是先y坐标再x坐标,所以用loc[::-1]翻转一下,然后再用zip函数拼接在一起。

练习

  1. 之前我们有学过形状匹配,不论形状旋转/缩放都可以匹配到。思考一下,图片旋转或缩放的话模板匹配还有作用吗?

小结

  • 模板匹配用来在大图中找小图。
  • `cv2.matchTemplate()`用来进行模板匹配。

引用

17: 霍夫变换

学习使用霍夫变换识别出图像中的直线和圆。图片等可到文末引用处下载。

目标

  • 理解霍夫变换的实现
  • 分别使用霍夫线变换和圆变换检测图像中的直线和圆
  • OpenCV函数:`cv2.HoughLines()`, `cv2.HoughLinesP()`, `cv2.HoughCircles()`

教程

理解霍夫变换

霍夫变换常用来在图像中提取直线和圆等几何形状,我来做个简易的解释:

学过几何的都知道,直线可以分别用直角坐标系和极坐标系来表示:

那么经过某个点(x0,y0)的所有直线都可以用这个式子来表示:

$$
r_\theta=x_0\cdot\cos \theta+y_0\cdot\sin \theta
$$

也就是说每一个(r,θ)都表示一条经过(x0,y0)直线,那么同一条直线上的点必然会有同样的(r,θ)。如果将某个点所有的(r,θ)绘制成下面的曲线,那么同一条直线上的点的(r,θ)曲线会相交于一点:

OpenCV中首先计算(r,θ) 累加数,累加数超过一定值后就认为在同一直线上。

霍夫直线变换

OpenCV中用`cv2.HoughLines()`在二值图上实现霍夫变换,函数返回的是一组直线的(r,θ)数据:

import cv2
import numpy as np

## 1.加载图片,转为二值图
img = cv2.imread('shapes.jpg')
drawing = np.zeros(img.shape[:], dtype=np.uint8)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150)

## 2.霍夫直线变换
lines = cv2.HoughLines(edges, 0.8, np.pi / 180, 90)

函数中:

  • 参数1:要检测的二值图(一般是阈值分割或边缘检测后的图)
  • 参数2:距离r的精度,值越大,考虑越多的线
  • 参数3:角度θ的精度,值越小,考虑越多的线
  • 参数4:累加数阈值,值越小,考虑越多的线
## 3.将检测的线画出来(注意是极坐标噢)
for line in lines:
    rho, theta = line[0]
    a = np.cos(theta)
    b = np.sin(theta)
    x0 = a * rho
    y0 = b * rho
    x1 = int(x0 + 1000 * (-b))
    y1 = int(y0 + 1000 * (a))
    x2 = int(x0 - 1000 * (-b))
    y2 = int(y0 - 1000 * (a))

    cv2.line(drawing, (x1, y1), (x2, y2), (0, 0, 255))

统计概率霍夫直线变换

前面的方法又称为标准霍夫变换,它会计算图像中的每一个点,计算量比较大,另外它得到的是整一条线(r和θ),并不知道原图中直线的端点。所以提出了统计概率霍夫直线变换(Probabilistic Hough Transform),是一种改进的霍夫变换:

drawing = np.zeros(img.shape[:], dtype=np.uint8)
## 3.统计概率霍夫线变换
lines = cv2.HoughLinesP(edges, 0.8, np.pi / 180, 90,
                        minLineLength=50, maxLineGap=10)

前面几个参数跟之前的一样,有两个可选参数:

  • `minLineLength`:最短长度阈值,比这个长度短的线会被排除
  • `maxLineGap`:同一直线两点之间的最大距离
## 3.将检测的线画出来
for line in lines:
    x1, y1, x2, y2 = line[0]
    cv2.line(drawing, (x1, y1), (x2, y2), (0, 255, 0), 1, lineType=cv2.LINE_AA)

`cv2.LINE_AA`在之前绘图功能中讲解过,表示抗锯齿线型。

霍夫圆变换

霍夫圆变换跟直线变换类似,只不过线是用(r,θ)表示,圆是用(x_center,y_center,r)来表示,从二维变成了三维,数据量变大了很多;所以一般使用霍夫梯度法减少计算量,对该算法感兴趣的同学可参考:Circle Hough Transform

drawing = np.zeros(img.shape[:], dtype=np.uint8)
## 2.霍夫圆变换
circles = cv2.HoughCircles(edges, cv2.HOUGH_GRADIENT, 1, 20, param2=30)
circles = np.int0(np.around(circles))

其中,

  • 参数2:变换方法,一般使用霍夫梯度法,详情:HoughModes
  • 参数3 dp=1:表示霍夫梯度法中累加器图像的分辨率与原图一致
  • 参数4:两个不同圆圆心的最短距离
  • 参数5:param2跟霍夫直线变换中的累加数阈值一样
## 将检测的圆画出来
for i in circles[0, :]:
    cv2.circle(drawing, (i[0], i[1]), i[2], (0, 255, 0), 2)  # 画出外圆
    cv2.circle(drawing, (i[0], i[1]), 2, (0, 0, 255), 3)  # 画出圆心

小结

  • 霍夫变换用来提取图像中的直线和圆等几何形状。
  • 霍夫直线变换:`cv2.HoughLines()`(整条直线), `cv2.HoughLinesP()`。
  • 霍夫圆变换:`cv2.HoughCircles()`。

引用

挑战任务: 车道检测

挑战任务:实际公路的车道线检测。图片等可到文末引用处下载。

挑战内容

1. 在所提供的公路图片上检测出车道线并标记:

2. 在所提供的公路视频上检测出车道线并标记:

本次挑战内容来自Udacity自动驾驶纳米学位课程,素材中车道保持不变,车道线清晰明确,易于检测,是车道检测的基础版本,网上也有很多针对复杂场景的高级实现,感兴趣的童鞋可以自行了解。

挑战题不会做也木有关系,但请务必在自行尝试后,再看下面的解答噢,不然...我也没办法( ̄▽ ̄)"

挑战解答

方案

要检测出当前车道,就是要检测出左右两条车道直线。由于无人车一直保持在当前车道,那么无人车上的相机拍摄的视频中,车道线的位置应该基本固定在某一个范围内:

如果我们手动把这部分ROI区域抠出来,就会排除掉大部分干扰。接下来检测直线肯定是用霍夫变换,但ROI区域内的边缘直线信息还是很多,考虑到只有左右两条车道线,一条斜率为正,一条为负,可将所有的线分为两组,每组再通过均值或最小二乘法拟合的方式确定唯一一条线就可以完成检测。总体步骤如下:

  1. 灰度化
  2. 高斯模糊
  3. Canny边缘检测
  4. 不规则ROI区域截取
  5. 霍夫直线检测
  6. 车道计算

对于视频来说,只要一幅图能检查出来,合成下就可以了,问题不大。

图像预处理

灰度化和滤波操作是大部分图像处理的必要步骤。灰度化不必多说,因为不是基于色彩信息识别的任务,所以没有必要用彩色图,可以大大减少计算量。而滤波会削弱图像噪点,排除干扰信息。另外,根据前面学习的知识,边缘提取是基于图像梯度的,梯度对噪声很敏感,所以平滑滤波操作必不可少。

这次的代码我们分模块来写,规范一点。其中`process_an_image()`是主要的图像处理流程:

import cv2
import numpy as np

## 高斯滤波核大小
blur_ksize = 5
## Canny边缘检测高低阈值
canny_lth = 50
canny_hth = 150

def process_an_image(img):
    # 1. 灰度化、滤波和Canny
    gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
    blur_gray = cv2.GaussianBlur(gray, (blur_ksize, blur_ksize), 1)
    edges = cv2.Canny(blur_gray, canny_lth, canny_hth)

if __name__ == "__main__":
    img = cv2.imread('test_pictures/lane.jpg')
    result = process_an_image(img)
    cv2.imshow("lane", np.hstack((img, result)))
    cv2.waitKey(0)

ROI截取

按照前面描述的方案,只需保留边缘图中的红线部分区域用于后续的霍夫直线检测,其余都是无用的信息:

如何实现呢?还记得图像混合中的这张图吗?

我们可以创建一个梯形的mask掩膜,然后与边缘检测结果图混合运算,掩膜中白色的部分保留,黑色的部分舍弃。梯形的四个坐标需要手动标记:

def process_an_image(img):
    # 1. 灰度化、滤波和Canny

    # 2. 标记四个坐标点用于ROI截取
    rows, cols = edges.shape
    points = np.array([[(0, rows), (460, 325), (520, 325), (cols, rows)]])
    # [[[0 540], [460 325], [520 325], [960 540]]]
    roi_edges = roi_mask(edges, points)

def roi_mask(img, corner_points):
    # 创建掩膜
    mask = np.zeros_like(img)
    cv2.fillPoly(mask, corner_points, 255)

    masked_img = cv2.bitwise_and(img, mask)
    return masked_img

这样,结果图"roi_edges"应该是:

霍夫直线提取

为了方便后续计算直线的斜率,我们使用统计概率霍夫直线变换(因为它能直接得到直线的起点和终点坐标)。霍夫变换的参数比较多,可以放在代码开头,便于修改:

## 霍夫变换参数
rho = 1
theta = np.pi / 180
threshold = 15
min_line_len = 40
max_line_gap = 20

def process_an_image(img):
    # 1. 灰度化、滤波和Canny

    # 2. 标记四个坐标点用于ROI截取

    # 3. 霍夫直线提取
    drawing, lines = hough_lines(roi_edges, rho, theta, threshold, min_line_len, max_line_gap)

def hough_lines(img, rho, theta, threshold, min_line_len, max_line_gap):
    # 统计概率霍夫直线变换
    lines = cv2.HoughLinesP(img, rho, theta, threshold, minLineLength=min_line_len, maxLineGap=max_line_gap)

    # 新建一副空白画布
    drawing = np.zeros((img.shape[0], img.shape[1], 3), dtype=np.uint8)
    # draw_lines(drawing, lines)     # 画出直线检测结果

    return drawing, lines

def draw_lines(img, lines, color=[0, 0, 255], thickness=1):
    for line in lines:
        for x1, y1, x2, y2 in line:
            cv2.line(img, (x1, y1), (x2, y2), color, thickness)

`draw_lines()`是用来画直线检测的结果,后面我们会接着处理直线,所以这里注释掉了,可以取消注释看下效果:

对本例的这张测试图来说,如果打印出直线的条数`print(len(lines))`,应该是有16条。

车道计算

这部分应该算是本次挑战任务的核心内容了:前面通过霍夫变换得到了多条直线的起点和终点,我们的目的是通过某种算法只得到左右两条车道线。

第一步、根据斜率正负划分某条线是左车道还是右车道。

$$
斜率=\frac{y_2-y_1}{x_2-x_1}(\leq0:左,>0:右)
$$

经验之谈:再次强调,斜率计算是在图像坐标系下,所以斜率正负/左右跟平面坐标有区别。

第二步、迭代计算各直线斜率与斜率均值的差,排除掉差值过大的异常数据。

注意这里迭代的含义,意思是第一次计算完斜率均值并排除掉异常值后,再在剩余的斜率中取均值,继续排除……这样迭代下去。

第三步、最小二乘法拟合左右车道线。

经过第二步的筛选,就只剩下可能的左右车道线了,这样只需从多条直线中拟合出一条就行。拟合方法有很多种,最常用的便是最小二乘法,它通过最小化误差的平方和来寻找数据的最佳匹配函数。

具体来说,假设目前可能的左车道线有6条,也就是12个坐标点,包括12个x和12个y,我们的目的是拟合出这样一条直线:

$$
f(x_i) = ax_i+b
$$

使得误差平方和最小:

$$
E=\sum(f(x_i)-y_i)^2
$$

Python中可以直接使用`np.polyfit()`进行最小二乘法拟合。

def process_an_image(img):
    # 1. 灰度化、滤波和Canny

    # 2. 标记四个坐标点用于ROI截取

    # 3. 霍夫直线提取

    # 4. 车道拟合计算
    draw_lanes(drawing, lines)

    # 5. 最终将结果合在原图上
    result = cv2.addWeighted(img, 0.9, drawing, 0.2, 0)

    return result

def draw_lanes(img, lines, color=[255, 0, 0], thickness=8):
    # a. 划分左右车道
    left_lines, right_lines = [], []
    for line in lines:
        for x1, y1, x2, y2 in line:
            k = (y2 - y1) / (x2 - x1)
            if k < 0:
                left_lines.append(line)
            else:
                right_lines.append(line)

    if (len(left_lines) <= 0 or len(right_lines) <= 0):
        return

    # b. 清理异常数据
    clean_lines(left_lines, 0.1)
    clean_lines(right_lines, 0.1)

    # c. 得到左右车道线点的集合,拟合直线
    left_points = [(x1, y1) for line in left_lines for x1, y1, x2, y2 in line]
    left_points = left_points + [(x2, y2) for line in left_lines for x1, y1, x2, y2 in line]
    right_points = [(x1, y1) for line in right_lines for x1, y1, x2, y2 in line]
    right_points = right_points + [(x2, y2) for line in right_lines for x1, y1, x2, y2 in line]

    left_results = least_squares_fit(left_points, 325, img.shape[0])
    right_results = least_squares_fit(right_points, 325, img.shape[0])

    # 注意这里点的顺序
    vtxs = np.array([[left_results[1], left_results[0], right_results[0], right_results[1]]])
    # d. 填充车道区域
    cv2.fillPoly(img, vtxs, (0, 255, 0))

    # 或者只画车道线
    # cv2.line(img, left_results[0], left_results[1], (0, 255, 0), thickness)
    # cv2.line(img, right_results[0], right_results[1], (0, 255, 0), thickness)

def clean_lines(lines, threshold):
    # 迭代计算斜率均值,排除掉与差值差异较大的数据
    slope = [(y2 - y1) / (x2 - x1) for line in lines for x1, y1, x2, y2 in line]
    while len(lines) > 0:
        mean = np.mean(slope)
        diff = [abs(s - mean) for s in slope]
        idx = np.argmax(diff)
        if diff[idx] > threshold:
            slope.pop(idx)
            lines.pop(idx)
        else:
            break

def least_squares_fit(point_list, ymin, ymax):
    # 最小二乘法拟合
    x = [p[0] for p in point_list]
    y = [p[1] for p in point_list]

    # polyfit第三个参数为拟合多项式的阶数,所以1代表线性
    fit = np.polyfit(y, x, 1)
    fit_fn = np.poly1d(fit)  # 获取拟合的结果

    xmin = int(fit_fn(ymin))
    xmax = int(fit_fn(ymax))

    return [(xmin, ymin), (xmax, ymax)]

这段代码比较多,请每个步骤单独来看。最后得到的是左右两条车道线的起点和终点坐标,可以选择画出车道线,这里我直接填充了整个区域:

视频处理

搞定了一张图,视频也就没什么问题了,关键就是视频帧的提取和合成,为此,我们要用到Python的视频编辑包moviepy

pip install moviepy

另外还需要ffmpeg,首次运行moviepy时会自动下载,也可手动下载。

只需在开头导入moviepy,然后将主函数改掉就可以了,其余代码不需要更改:

## 开头导入moviepy
from moviepy.editor import VideoFileClip

## 主函数更改为:
if __name__ == "__main__":
    output = 'test_videos/output.mp4'
    clip = VideoFileClip("test_videos/cv2_white_lane.mp4")
    out_clip = clip.fl_image(process_an_image)
    out_clip.write_videofile(output, audio=False)

本文实现了车道检测的基础版本,如果你感兴趣的话,可以自行搜索或参考引用部分了解更多。

引用

番外篇: 亮度与对比度

学习如何调整图片的亮度和对比度。图片等可到文末引用处下载。

亮度与对比度

亮度调整是将图像像素的强度整体变大/变小,对比度调整指的是图像暗处的像素强度变低,亮出的变高,从而拓宽某个区域内的显示精度。

OpenCV中亮度和对比度应用这个公式来计算:g</span>(x)=αf</span>(x)+β</span></span>,其中:α(>0)、β常称为增益与偏置值,分别控制图片的对比度和亮度。

经验之谈:此处对α/β控制对比度和亮度有争议,具体请参考:OpenCV关于对比度和亮度的误解

import cv2
import numpy as np

img = cv2.imread('lena.jpg')
## 此处需注意,请参考后面的解释
res = np.uint8(np.clip((1.5 * img + 10), 0, 255))
tmp = np.hstack((img, res))  # 两张图片横向合并(便于对比显示)

cv2.imshow('image', tmp)
cv2.waitKey(0)

还记得图像混合那一节中numpy对数据溢出的取模处理吗?`250+10 = 260 => 260%256=4`,它并不适用于我们的图像处理,所以用np.clip()函数将数据限定:`a<0 => a=0, a>255 => a=255`。

练习

  1. 创建两个滑动条分别调整对比度和亮度(对比度范围:0~0.3,亮度:0~100)。提示:因为滑动条没有小数,所以可以设置为0~300,然后乘以0.01。
  2. 亮度/对比度用C++实现也很有趣,推荐阅读:OpenCV改变图像亮度和对比度以及优化

引用

番外篇: 卷积基础-图片边框

了解卷积/滤波的基础知识,给图片添加边框。

卷积的概念其实很好理解,下面我就给大家做个最简单的解释,绝对轻松加愉快的辣o( ̄▽ ̄)o

卷积

什么是二维卷积呢?看下面一张图就一目了然:

卷积就是循环对图像跟一个核逐个元素相乘再求和得到另外一副图像的操作,比如结果图中第一个元素5是怎么算的呢?原图中3×3的区域与3×3的核逐个元素相乘再相加:

$$
5=1\times1+2\times0+1\times0+0\times0+1\times0+1\times0+3\times0+0\times0+2\times2
$$

算完之后,整个框再往右移一步继续计算,横向计算完后,再往下移一步继续计算……网上有一副很经典的动态图,方便我们理解卷积:

padding

不难发现,前面我们用3×3的核对一副6×6的图像进行卷积,得到的是4×4的图,图片缩小了!那怎么办呢?我们可以把原图扩充一圈,再卷积,这个操作叫填充padding

事实上,原图为n×n,卷积核为f×f,最终结果图大小为(n-f+1) × (n-f+1)。

那么扩展的这一层应该填充什么值呢?OpenCV中有好几种填充方式,都使用`cv2.copyMakeBorder()`函数实现,一起来看看。

添加边框

`cv2.copyMakeBorder()`用来给图片添加边框,它有下面几个参数:

  • src:要处理的原图
  • top, bottom, left, right:上下左右要扩展的像素数
  • borderType:边框类型,这个就是需要关注的填充方式,详情请参考:BorderTypes

其中默认方式和固定值方式最常用,我们详细说明一下:

固定值填充

顾名思义,`cv2.BORDER_CONSTANT`这种方式就是边框都填充成一个固定的值,比如下面的程序都填充0:

img = cv2.imread('6_by_6.bmp', 0)
print(img)

## 固定值边框,统一都填充0也称为zero padding
cons = cv2.copyMakeBorder(img, 1, 1, 1, 1, cv2.BORDER_CONSTANT, value=0)
print(cons)

默认边框类型

默认边框`cv2.BORDER_DEFAULT`其实是取镜像对称的像素填充,比较拗口,一步步解释:

default = cv2.copyMakeBorder(img, 1, 1, 1, 1, cv2.BORDER_DEFAULT)
print(default)

首先进行上下填充,填充成与原图像边界对称的值,如下图:

同理再进行左右两边的填充,最后把四个顶点补充上就好了:

经验之谈:一般情况下默认方式更加合理,因为边界的像素值更加接近。具体应视场合而定。

OpenCV进行卷积

OpenCV中用`cv2.filter2D()`实现卷积操作,比如我们的核是下面这样(3×3区域像素的和除以10):

$$
M = \frac{1}{10}\left[
\begin{matrix}
1 & 1 & 1 \newline
1 & 1 & 1 \newline
1 & 1 & 1
\end{matrix}
\right] \tag{3}
$$

img = cv2.imread('lena.jpg')
## 定义卷积核
kernel = np.ones((3, 3), np.float32) / 10
## 卷积操作,-1表示通道数与原图相同
dst = cv2.filter2D(img, -1, kernel)

可以看到这个核对图像进行了模糊处理,这是卷积的众多功能之一。当然卷积还有很多知识没有学到,后面我们再继续深入。

练习

  1. 尝试给"lena.jpg"添加几种不同的边框类型,对比下效果。

引用

番外篇: 图像梯度

了解图像梯度和边缘检测的相关概念。图片等可到文末引用处下载。

还记得前面平滑图像中提到的滤波与模糊的区别吗?我们说低通滤波器是模糊,高通滤波器是锐化,这节我们就来看看高通滤波器。

图像梯度

如果你还记得高数中用一阶导数来求极值的话,就很容易理解了:把图片想象成连续函数,因为边缘部分的像素值是与旁边像素明显有区别的,所以对图片局部求极值,就可以得到整幅图片的边缘信息了。不过图片是二维的离散函数,导数就变成了差分,这个差分就称为图像的梯度。

当然,大部分人应该是早忘记高数了( ̄▽ ̄)",所以看不懂的话,就把上面的解释划掉,我们重新从卷积的角度来看看。

垂直边缘提取

滤波是应用卷积来实现的,卷积的关键就是卷积核,我们来考察下面这个卷积核:

$$
k1 = \left[
\begin{matrix}
-1 & 0 & 1 \newline
-2 & 0 & 2 \newline
-1 & 0 & 1
\end{matrix}
\right]
$$

这个核是用来提取图片中的垂直边缘的,怎么做到的呢?看下图:

当前列左右两侧的元素进行差分,由于边缘的值明显小于(或大于)周边像素,所以边缘的差分结果会明显不同,这样就提取出了垂直边缘。同理,把上面那个矩阵转置一下,就是提取水平边缘。这种差分操作就称为图像的梯度计算:

$$
k2 = \left[
\begin{matrix}
-1 & -2 & -1 \newline
0 & 0 & 0 \newline
1 & 2 & 1
\end{matrix}
\right]
$$

还记得滤波函数`cv2.filter2D()`吗?(番外篇:卷积基础)我们来手动实现上面的功能:

img = cv2.imread('sudoku.jpg', 0)

## 自己进行垂直边缘提取
kernel = np.array([[-1, 0, 1],
                   [-2, 0, 2],
                   [-1, 0, 1]], dtype=np.float32)
dst_v = cv2.filter2D(img, -1, kernel)
## 自己进行水平边缘提取
dst_h = cv2.filter2D(img, -1, kernel.T)
## 横向并排对比显示
cv2.imshow('edge', np.hstack((img, dst_v, dst_h)))
cv2.waitKey(0)

Sobel算子

上面的这种差分方法就叫Sobel算子,它先在垂直方向计算梯度

$$
G_x=k_1×src
$$

,再在水平方向计算梯度

$$
G_y=k_2×src
$$

,最后求出总梯度:

$$
G=\sqrt{Gx^2+Gy^2}
$$

我们可以把前面的代码用Sobel算子更简单地实现:

sobelx = cv2.Sobel(img, -1, 1, 0, ksize=3)  # 只计算x方向
sobely = cv2.Sobel(img, -1, 0, 1, ksize=3)  # 只计算y方向

经验之谈:很多人疑问,Sobel算子的卷积核这几个值是怎么来的呢?事实上,并没有规定,你可以用你自己的。

比如,最初只利用领域间的原始差值来检测边缘的Prewitt算子

$$
K = \left[
\begin{matrix}
-1 & 0 & 1 \newline
-1 & 0 & 1 \newline
-1 & 0 & 1
\end{matrix}
\right]
$$

还有比Sobel更好用的Scharr算子,大家可以了解下:

$$
K = \left[
\begin{matrix}
-3 & 0 & 3 \newline
-10 & 0 & 10 \newline
-3 & 0 & 3
\end{matrix}
\right]
$$

这些算法都是一阶边缘检测的代表,网上也有算子之间的对比资料,有兴趣的可参考文末引用。

Laplacian算子

高数中用一阶导数求极值,在这些极值的地方,二阶导数为0,所以也可以通过求二阶导计算梯度:

$$
dst=\frac{\partial^2 f}{\partial x^2}+\frac{\partial^2 f}{\partial y^2}
$$

一维的一阶和二阶差分公式分别为:

$$
\frac{\partial f}{\partial x}=f(x+1)-f(x)
$$

$$
\frac{\partial^2 f}{\partial x^2}=f(x+1)+f(x-1)-2f(x)
$$

提取前面的系数,那么一维的Laplacian滤波核是:

$$
K=\left[
\begin{matrix}
1 & -2 & 1
\end{matrix}
\right]
$$

而对于二维函数f(x,y),两个方向的二阶差分分别是:

$$
\frac{\partial^2 f}{\partial x^2}=f(x+1,y)+f(x-1,y)-2f(x,y)
$$

$$
\frac{\partial^2 f}{\partial y^2}=f(x,y+1)+f(x,y-1)-2f(x,y)
$$

合在一起就是:

$$
\triangledown^2 f(x,y)=f(x+1,y)+f(x-1,y)+f(x,y+1)+f(x,y-1)-4f(x,y)
$$

同样提取前面的系数,那么二维的Laplacian滤波核就是:

$$
K = \left[
\begin{matrix}
0 & 1 & 0 \newline
1 & -4 & 1 \newline
0 & 1 & 0
\end{matrix}
\right]
$$

这就是Laplacian算子的图像卷积模板,有些资料中在此基础上考虑斜对角情况,将卷积核拓展为:

$$
K = \left[
\begin{matrix}
1 & 1 & 1 \newline
1 & -8 & 1 \newline
1 & 1 & 1
\end{matrix}
\right]
$$

OpenCV中直接使用`cv2.Laplacian()`函数:

laplacian = cv2.Laplacian(img, -1)  # 使用Laplacian算子

Laplacian算子是二阶边缘检测的典型代表,一/二阶边缘检测各有优缺点,大家可自行了解。

练习

  1. (选做)同志们有空补补高数姿势(知识)呗!(✿◕‿◕✿)

引用

番外篇: 轮廓层级

了解轮廓间的层级关系。图片等可到文末引用处[文末引用出]()下载。

前面我们使用`cv2.findContours()`寻找轮廓时,参数3表示轮廓的寻找方式(RetrievalModes),当时我们传入的是cv2.RETR_TREE,它表示什么意思呢?另外,函数返回值hierarchy有什么用途呢?下面我们就来研究下这两个问题。

理解轮廓层级

很多情况下,图像中的形状之间是有关联的,比如说下图:

图中总共有8条轮廓,2和2a分别表示外层和里层的轮廓,3和3a也是一样。从图中看得出来:

  • 轮廓0/1/2是最外层的轮廓,我们可以说它们处于同一轮廓等级:0级
  • 轮廓2a是轮廓2的子轮廓,反过来说2是2a的父轮廓,轮廓2a算一个等级:1级
  • 同样3是2a的子轮廓,轮廓3处于一个等级:2级
  • 类似的,3a是3的子轮廓,等等…………

这里面OpenCV关注的就是两个概念:同一轮廓等级和轮廓间的子属关系。

OpenCV中轮廓等级的表示

如果我们打印出`cv2.findContours()`函数的返回值hierarchy,会发现它是一个包含4个值的数组:[Next, Previous, First Child, Parent]

  • Next:与当前轮廓处于同一层级的下一条轮廓

举例来说,前面图中跟0处于同一层级的下一条轮廓是1,所以Next=1;同理,对轮廓1来说,Next=2;那么对于轮廓2呢?没有与它同一层级的下一条轮廓了,此时Next=-1。

  • Previous:与当前轮廓处于同一层级的上一条轮廓

跟前面一样,对于轮廓1来说,Previous=0;对于轮廓2,Previous=1;对于轮廓1,没有上一条轮廓了,所以Previous=-1。

  • First Child:当前轮廓的第一条子轮廓

比如对于轮廓2,第一条子轮廓就是轮廓2a,所以First Child=2a;对轮廓3a,First Child=4。

  • Parent:当前轮廓的父轮廓

比如2a的父轮廓是2,Parent=2;轮廓2没有父轮廓,所以Parent=-1。

下面我们通过代码验证一下:

import cv2

## 1.读入图片
img = cv2.imread('hierarchy.jpg')
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

## 2.寻找轮廓
image, contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, 2)

## 3.绘制轮廓
print(len(contours),hierarchy)  # 8条
cv2.drawContours(img, contours, -1, (0, 0, 255), 2)

经验之谈:OpenCV中找到的轮廓序号跟前面讲的不同噢,如下图:

现在既然我们了解了层级的概念,那么类似cv2.RETR_TREE的轮廓寻找方式又是啥意思呢?

轮廓寻找方式

OpenCV中有四种轮廓寻找方式RetrievalModes,下面分别来看下:

1. RETR_LIST

这是最简单的一种寻找方式,它不建立轮廓间的子属关系,也就是所有轮廓都属于同一层级。这样,hierarchy中的后两个值[First Child, Parent]都为-1。比如同样的图,我们使用cv2.RETR_LIST来寻找轮廓:

_, _, hierarchy = cv2.findContours(thresh, cv2.RETR_LIST, 2)
print(hierarchy)
## 结果如下
[[[ 1 -1 -1 -1]
  [ 2  0 -1 -1]
  [ 3  1 -1 -1]
  [ 4  2 -1 -1]
  [ 5  3 -1 -1]
  [ 6  4 -1 -1]
  [ 7  5 -1 -1]
  [-1  6 -1 -1]]]

因为没有从属关系,所以轮廓0的下一条是1,1的下一条是2……

经验之谈:如果你不需要轮廓层级信息的话,cv2.RETR_LIST更推荐使用,因为性能更好。

2. RETR_TREE

cv2.RETR_TREE就是之前我们一直在使用的方式,它会完整建立轮廓的层级从属关系,前面已经详细说明过了。

3. RETR_EXTERNAL

这种方式只寻找最高层级的轮廓,也就是它只会找到前面我们所说的3条0级轮廓:

_, contours, hierarchy = cv2.findContours(thresh, cv2.RETR_EXTERNAL, 2)
print(len(contours), hierarchy, sep='\n')
## 结果如下
3
[[[ 1 -1 -1 -1]
  [ 2  0 -1 -1]
  [-1  1 -1 -1]]]

4. RETR_CCOMP

相比之下cv2.RETR_CCOMP比较难理解,但其实也很简单:它把所有的轮廓只分为2个层级,不是外层的就是里层的。结合代码和图片,我们来理解下:

_, contours, hierarchy = cv2.findContours(thresh, cv2.RETR_CCOMP, 2)
print(hierarchy)
## 结果如下
[[[ 1 -1 -1 -1]
  [ 2  0 -1 -1]
  [ 4  1  3 -1]
  [-1 -1 -1  2]
  [ 6  2  5 -1]
  [-1 -1 -1  4]
  [ 7  4 -1 -1]
  [-1  6 -1 -1]]]

注意:使用这个参数找到的轮廓序号与之前不同。

图中括号里面1代表外层轮廓,2代表里层轮廓。比如说对于轮廓2,Next就是4,Previous是1,它有里层的轮廓3,所以First Child=3,但因为只有两个层级,它本身就是外层轮廓,所以Parent=-1。大家可以针对其他的轮廓自己验证一下。

练习

  1. 如下图,找到3个圆环的内环,然后填充成(180,215,215)这种颜色:

引用

番外篇: 凸包及更多轮廓特征

计算凸包及更多轮廓特征。图片等可到文末引用处下载。

多边形逼近

前面我们学习过最小外接矩和最小外接圆,那么可以用一个最小的多边形包围物体吗?当然可以:

import cv2
import numpy as np

## 1.先找到轮廓
img = cv2.imread('unregular.jpg', 0)
_, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
image, contours, hierarchy = cv2.findContours(thresh, 3, 2)
cnt = contours[0]

## 2.进行多边形逼近,得到多边形的角点
approx = cv2.approxPolyDP(cnt, 3, True)

## 3.画出多边形
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
cv2.polylines(image, [approx], True, (0, 255, 0), 2)

其中`cv2.approxPolyDP()`的参数2(epsilon)是一个距离值,表示多边形的轮廓接近实际轮廓的程度,值越小,越精确;参数3表示是否闭合。

凸包

凸包跟多边形逼近很像,只不过它是物体最外层的"凸"多边形:集合A内连接任意两个点的直线都在A的内部,则称集合A是凸形的。如下图,红色的部分为手掌的凸包,双箭头部分表示凸缺陷(Convexity Defects),凸缺陷常用来进行手势识别等:

## 1.先找到轮廓
img = cv2.imread('convex.jpg', 0)
_, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
image, contours, hierarchy = cv2.findContours(thresh, 3, 2)
cnt = contours[0]

## 2.寻找凸包,得到凸包的角点
hull = cv2.convexHull(cnt)

## 3.绘制凸包
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
cv2.polylines(image, [hull], True, (0, 255, 0), 2)

其中函数`cv2.convexHull()`有个可选参数returnPoints,默认是True,代表返回角点的x/y坐标;如果为False的话,表示返回轮廓中是凸包角点的索引,比如说:

print(hull[0])  # [[362 184]](坐标)
hull2 = cv2.convexHull(cnt, returnPoints=False)
print(hull2[0])  # [510](cnt中的索引)
print(cnt[510])  # [[362 184]]

当使用`cv2.convexityDefects()`计算凸包缺陷时,returnPoints需为False,详情可参考:Convexity Defects

另外可以用下面的语句来判断轮廓是否是凸形的:

print(cv2.isContourConvex(hull))  # True

点到轮廓距离

`cv2.pointPolygonTest()`函数计算点到轮廓的最短距离(也就是垂线),又称多边形测试:

dist = cv2.pointPolygonTest(cnt, (100, 100), True)  # -3.53

其中参数3为True时表示计算距离值:点在轮廓外面值为负,点在轮廓上值为0,点在轮廓里面值为正;参数3为False时,只返回-1/0/1表示点相对轮廓的位置,不计算距离。

更多轮廓特征,如当量直径、平均强度等,我目前也没用到过,以后用到再写吧,感兴趣的可以参看:Contour PropertiesContours Hierarchy

引用