安装OpenCV以及运行示例很有趣,但是在这一阶段,我们希望以
自己的方式来尝试一下。本章将介绍OpenCV的I/O功能,还将讨论项目
的概念,并开始对该项目进行面向对象设计,并在后续章节中继续对
该项目进行充实。
首先,我们来看一下I/O的功能和设计模式,我们将以制作三明治
的方式构建项目——由外而内。面包切片和涂抹,或者端点和黏合,
都是添加馅料和算法之前的工作。之所以选择这种方式是因为计算机
视觉通常是外向的——它专注于计算机之外的真实世界——我们希望
通过一个共同接口将所有的后续算法工作都应用于真实世界。
本章将介绍以下主题:
·从图像文件、视频文件、摄像头设备或内存中的原始数据字节
读取图像。
·将图像写入图像文件或视频文件。
·在NumPy数组中处理图像数据。
·在窗口中显示图像。
·处理键盘和鼠标输入。
·实现基于面向对象设计的应用程序。
2.1 技术需求
本章使用了Python、OpenCV以及NumPy。安装说明请参阅第1章。
本章的完整代码可以在本书GitHub库(网址为
https://github.com/PacktPublishing/Learning-OpenCV-4-
Computer-Vision-with-Python-Third-Edition)的chapter02文件中
找到。
2.2 基本I/O脚本
大多数计算机视觉(Computer Vision,CV)应用程序需要获取图
像作为输入。大多数计算机视觉应用程序还会生成图像作为输出。交
互式计算机视觉应用程序可能需要把摄像头作为输入源,还需要将窗
口作为输出目标。但是其他可能的源和目标包括图像文件、视频文件
以及原始字节。例如,如果把过程式图形合成到应用程序中,那么原
始字节可能通过网络连接进行传输,也可能由算法生成。我们来看看
每一种可能性。
2.2.1 读取/写入图像文件
OpenCV提供了imread函数来从文件加载图像,也提供了imwrite函
数来将图像写入文件。这些函数支持静态图像(非视频)的各种文件
格式。支持的格式各不相同——在OpenCV的自定义构建中可以添加或
删除某些格式——但是,通常BMP、PNG、JPEG和TIFF都是所支持的格
式。
我们来研究一下在OpenCV和NumPy中图像表示的解剖结构。一幅图
像就是一个多维数组,有列像素和行像素,每个像素都有一个值。对
于不同类型的图像数据,像素值可以使用不同的格式。例如,通过简
单地创建一个二维NumPy数组,可以从头开始创建一幅3×3的黑色正方
形图像:

如果将这幅图像打印到控制台,获得的结果如下所示:
这里,每个像素都用一个8位整数表示,这意味着每个像素的值都
在0~255的范围内,其中0表示黑色,255表示白色,中间的值表示灰
色。这是一幅灰度图像。
现在,我们使用cv2.cvtColor函数把这幅图像转换成蓝–绿–红
(Blue-Green-Red,BGR)格式:
我们来看图像是如何变化的:
如你所见,现在每个像素都用一个三元数组表示,每个整数分别
表示三个颜色通道(B、G和R)中的一个。HSV之类的其他常见颜色模
型的表示方法也类似,只是取值范围不同。例如,HSV颜色模型的色调
值的范围是0~180。
有关颜色模型的更多内容,请参阅第3章,尤其是3.2节。
通过查看shape属性,你可以查看图像的结构,shape属性返回
行、列和通道数(如果有多个通道的话)。
考虑如下示例:
上述代码将打印(5,3),表示我们有一幅5行3列的灰度图像。如
果将该图像转换成BGR格式,shape将是(5,3,3),表示每个像素有3
个通道。
图像可以从一种文件格式加载并保存为另一种格式。例如,把一
幅图像从PNG转换为JPEG:
OpenCV的Python模块命名为cv2,尽管我们使用的是OpenCV
4.x而非OpenCV 2.x。以前,OpenCV有两个Python模块:cv2和cv。cv封
装了用C实现的OpenCV的一个旧版本。目前,OpenCV只有cv2 Python
模块,该模块封装了用C++实现的OpenCV当前版本。
默认情况下,imread返回BGR格式的图像,即使该文件使用的是灰
度格式。BGR表示与红–绿–蓝(Red-Green-Blue,RGB)相同的颜色
模型,只是字节顺序相反。
我们还可以指定imread的模式,所支持的选项包括:
·cv2.IMREAD_COLOR:该模式是默认选项,提供3通道的BGR图
像,每个通道一个8位值(0~255)。
·cv2.IMREAD_GRAYSCALE:该模式提供8位灰度图像。
·cv2.IMREAD_ANYCOLOR:该模式提供每个通道8位的BGR图
像或者8位灰度图像,具体取决于文件中的元数据。
·cv2.IMREAD_UNCHANGED:该模式读取所有的图像数据,包
括作为第4通道的α或透明度通道(如果有的话)。
·cv2.IMREAD_ANYDEPTH:该模式加载原始位深度的灰度图
像。例如,如果文件以这种格式表示一幅图像,那么它提供每个通道
16位的一幅灰度图像。
·cv2.IMREAD_ANYDEPTH|cv2.IMREAD_COLOR:该组合模式
加载原始位深度的BGR彩色图像。
·cv2.IMREAD_REDUCED_GRAYSCALE_2:该模式加载的灰度
图像的分辨率是原始分辨率的1/2。例如,如果文件包括一幅640×480
的图像,那么它加载的是一幅320×240的图像。
·cv2.IMREAD_REDUCED_COLOR_2:该模式加载每个通道8位
的BGR彩色图像,分辨率是原始图像的1/2。
·cv2.IMREAD_REDUCED_GRAYSCALE_4:该模式加载灰度图
像,分辨率是原始图像的1/4。
·cv2.IMREAD_REDUCED_COLOR_4:该模式加载每个通道8位
的彩色图像,分辨率是原始图像的1/4。
·cv2.IMREAD_REDUCED_GRAYSCALE_8:该模式加载灰度图
像,分辨率是原始图像的1/8。
·cv2.IMREAD_REDUCED_COLOR_8:该模式加载每个通道8位
的彩色图像,分辨率为原始图像的1/8。
举个例子,我们将一个PNG文件加载为灰度图像(在此过程中会丢
失所有颜色信息),再将其保存为一个灰度PNG图像:
除非是绝对路径,否则图像的路径都是相对于工作目录(Python
脚本的运行路径)的,因此在前面的例子中,MyPic.png必须在工作目
录中,否则将找不到该图像。如果你希望避免对工作目录的假设,可
以使用绝对路径,比如Windows上的
C:\Users\Joe\Pictures\MyPic.png、Mac上
的/Users/Joe/Pictures/MyPic.png,或者Linux上
的/home/joe/pictures/MyPic.png。
imwrite()函数要求图像为BGR格式或者灰度格式,每个通道具有
输出格式可以支持的特定位数。例如,BMP文件格式要求每个通道8
位,而PNG允许每个通道8位或16位。
2.2.2 在图像和原始字节之间进行转换
从概念上讲,一个字节就是0~255范围内的一个整数。目前,在
实时图形应用程序中,像素通常由每个通道一个字节来表示,但是也
可以使用其他表示方式。
OpenCV图像是numpy.array类型的二维或者三维数组。8位灰度图
像是包含字节值的一个二维数组。24位的BGR图像是一个三维数组,也
包含字节值。我们可以通过使用类似于image[0,0]或者image[0,0,0]
的表达式来访问这些值。第一个索引是像素的y坐标或者行,0表示顶
部。第二个索引是像素的x坐标或者列,0表示最左边。第三个索引
(如果有的话)表示一个颜色通道。可以用下面的笛卡儿坐标系可视
化数组的三维空间(见图2-1)。
例如,在左上角为白色像素的8位灰度图像中,image[0,0]是
255。在左上角为蓝色像素的24位(每个通道8位)BGR图像中,
image[0,0]是[255,0,0]。
假设图像的每个通道有8位,我们可以将其强制转换为标准的
Python bytearray对象(一维的):
相反,假设bytearray以一种合适的顺序包含字节,我们对其进行
强制转换后再将其变维,可以得到一幅numpy.array类型的图像:
举个更完整的例子,我们将包含随机字节的bytearray转换为灰度
图像和BGR图像:
此处,我们使用Python的标准os.urandom函数生成随机的原始
字节,然后再将其转换成NumPy数组。请注意,也可以使用像
numpy.random.randint(0,256,120000).reshape(300,400)这样的语句直接(而
且更有效)生成随机NumPy数组。我们使用os.urandom的唯一原因是:
这有助于展示原始字节的转换。
运行这个脚本之后,在脚本目录中应该有一对随机生成的图像:
RandomGray.png和RandomColor.png。
图2-2是RandomGray.png的一个例子(你得到的结果很可能会有所
不同,因为这是随机生成的)。
类似地,图2-3是RandomColor.png的一个例子。
既然我们已经对数据如何形成图像有了一个更好的理解,那么就
可以开始对其执行基本操作了。
2.2.3 基于numpy.array访问图像数据
我们已经知道在OpenCV中加载图像最简单(也是最常见)的方法
是使用imread函数。我们还知道这将返回一幅图像,它实际上是一个
数组(是二维还是三维取决于传递给imread的参数)。
numpy.array类对数组操作进行极大的优化,它允许某些类型的批
量操作,而这些操作在普通Python列表中是不可用的。这些类型的
numpy.array都是OpenCV中特定于数组类型的操作,对于图像操作来说
很方便。但是,我们还是从一个基本的例子开始,逐步探讨图像操
作。假设你想操作BGR图像的(0,0)坐标处的像素,并将其转换成白
色像素:
如果将修改后的图像保存到文件后再查看该图像,你会在图像的
左上角看到一个白点。当然,这种修改并不是很有用,但是它显示了
某种修改的可能性。现在,我们利用numpy.array的功能在数组上执行
变换的速度比普通的Python列表要快得多。
假设你想更改某一特定像素的蓝色值,例如(150,120)坐标处的
像素。numpy.array类型提供了一个方便的方法item,它有三个参数:
x(或者left)位置、y(或者top)位置以及数组中(x,y)位置的索
引(请记住,在BGR图像中,某个特定位置处的数据是一个三元数组,
包含按照B、G和R顺序排列的值),并返回索引位置的值。另一个方法
itemset可以将某一特定像素的特定通道的值设置为指定的值。
itemset有两个参数:三元组(x、y和索引)以及新值。
在下面的例子中,我们将(150,120)处的蓝色通道值从其当前值
更改为255:
对于修改数组中的单个元素,itemset方法比我们在本节第一个例
子中看到的索引语法要快一些。
同样,修改数组的一个元素本身并没有太大意义,但是它确实打
开了一个充满可能性的世界。然而,就性能而言,这只适合于感兴趣
的小区域。当需要操作整个图像或者感兴趣的大区域时,建议使用
OpenCV的函数或者NumPy的数组切片。NumPy的数组切片允许指定索引
的范围。我们来考虑使用数组切片来操作颜色通道的一个例子。将一
幅图像的所有G(绿色)值都设置为0非常简单,如下面的代码所示:
这段代码执行了一个相当重要的操作,而且很容易理解。相关的
代码行是最后一行,它指示程序从所有行和列中获取所有像素,并把
绿色值(在三元BGR数组的一个索引处)设置为0。如果显示此图像,
你会注意到绿色完全消失了。
通过使用NumPy的数组切片访问原始像素,我们可以做一些有趣的
事情,其中之一是定义感兴趣区域(Region Of Interest,ROI)。一
旦定义了感兴趣区域,就可以执行一系列的操作了。例如,可以把这
个区域绑定到一个变量,定义第二个区域,将第一个区域的值赋给第
二个区域(从而将图像的一部分复制到图像的另一个位置):
确保两个区域在大小上一致很重要。如果大小不一致,NumPy会
(立刻)控诉这两个形状不匹配。
最后,我们可以访问numpy.array的属性,如下列代码所示:
这三个属性的定义如下:
·shape:描述数组形状的一个元组。对于图像,它(依次)包括
高度、宽度、通道数(如果是彩色图像的话)。shape元组的长度是确
定图像是灰度的还是彩色的一种有用方法。对于灰度图像,
len(shape)==2,对于彩色图像,len(shape)==3。
·size:数组中的元素数。对于灰度图像,这和像素数是一样的。
对于BGR图像,它是像素数的3倍,因为每个像素都由3个元素(B、G
和R)表示。
·dtype:数组元素的数据类型。对于每个通道8位的图像,数据类
型是numpy.uint8。
总之,强烈建议你在使用OpenCV时,了解NumPy的一般情况以及
numpy.array的特殊情况。这个类是Python中使用OpenCV进行所有图像
处理的基础。
2.2.4 读取/写入视频文件
OpenCV提供了VideoCapture和VideoWriter类,支持各种视频文件
格式。支持的格式取决于操作系统和OpenCV的构建配置,但是通常情
况下,假设支持AVI格式是安全的。通过它的read方法,VideoCapture
对象可以依次查询新的帧,直到到达视频文件的末尾。每一帧都是一
幅BGR格式的图像。
相反,图像可以传递给VideoWriter类的write方法,该方法将图
像添加到VideoWriter的文件中。我们来看一个例子,从一个AVI文件
读取帧,再用YUV编码将其写入另一个文件:
VideoWriter类的构造函数的参数值得特别注意。必须指定一个视
频文件的名称。具有此名称的所有之前存在的文件都将被覆盖。还必
须指定一个视频编解码器。可用的编解码器因系统而异。支持的选项
可能包括以下内容:
·0:这个选项表示未压缩的原始视频文件。文件扩展名应该
是.avi。
·cv2.VideoWriter_fourcc('I','4','2','0'):这个选项表示未压缩的YUV编
码,4:2:0色度抽样。这种编码是广泛兼容的,但是会产生大的文件。文
件扩展名应该是.avi。
·cv2.VideoWriter_fourcc('P','I','M','1'):这个选项是MPEG-1。文件扩
展名应该是.avi。
·cv2.VideoWriter_fourcc('X','V','I','D'):这个选项是一种相对较旧的
MPEG-4编码。如果想限制生成的视频大小,这是一个不错的选项。文
件扩展名应该是.avi。
·cv2.VideoWriter_fourcc('M','P','4','V'):这个选项是另一种相对较旧
的MPEG-4编码。如果想限制生成的视频大小,这是一个不错的选项。
文件扩展名应该是.mp4。
·cv2.VideoWriter_fourcc('X','2','6','4'):这个选项是一种相对较新的
MPEG-4编码。如果想限制生成的视频大小,这可能是最佳的选项。文
件扩展名应该是.mp4。
·cv2.VideoWriter_fourcc('T','H','E','O'):这个选项是Ogg Vorbis。文
件扩展名应该是.ogv。
·cv2.VideoWriter_fourcc('F','L','V','1'):这个选项表示Flash视频。文
件扩展名应该是.flv。
帧率和帧大小也必须指定。因为我们是从另一个视频复制的,所
以这些属性可以从VideoCapture类的get方法读取。
2.2.5 捕捉摄像头帧
摄像头帧流也可以用VideoCapture对象来表示。但是,对于摄像
头,我们通过传递摄像头设备索引(而不是视频文件名称)来构造
VideoCapture对象。我们来考虑下面这个例子,它从摄像头抓取10秒
的视频,并将其写入AVI文件。代码与2.2.4节的示例(从视频文件获
取的,而不是从摄像头中获取的)类似,更改的内容标记为粗体:
对于某些系统上的一些摄像头,
cameraCapture.get(cv2.CAP_PROP_FRAME_WIDTH)和
cameraCapture.get(cv2.CAP_PROP_FRAME_HEIGHT)可能会返回不准确
的结果。为了更加确定图像的实际大小,可以先抓取一帧,再用像
h,w=frame.shape[:2]这样的代码来获得图像的高度和宽度。有时,你可
能会遇到摄像头在开始产生大小稳定的好帧之前,产生一些大小不稳
定的坏帧的情况。如果你关心的是如何防范这种情况,在开始捕捉会
话时你可能想要读取并忽略一些帧。
可是,在大多数情况下,VideoCapture的get方法不会返回摄像头
帧率的准确值,通常会返回0。
http://docs.opencv.org/modules/highgui/doc/reading_and_writin
g_images_and_video.html上的官方文档警告如下:
当查询VideoCapture实例使用的后端不支持的属性时,返回值为0。
注意:
读/写属性涉及许多层。沿着这条链可能会发生一些意想不到的结
果[sic]。
VideoCapture->API Backend->Operating System->DeviceDriver-
>Device Hardware
返回值可能与设备实际使用的值不同,也可能使用设备相关规则
(例如,步长或者百分比)对其进行编码。有效的行为取决于[sic]设备
驱动程序和API后端。
要为摄像头创建合适的VideoWriter类,我们必须对帧率做一个假
设(就像前面代码中所做的那样),或者使用计时器测量帧率。后一
种方法更好,我们将在本章后面对其进行介绍。
当然,摄像头数量及其顺序取决于系统。可是,OpenCV不提供任
何查询摄像头数量或者摄像头属性的方法。如果用无效的索引构造
VideoCapture类,VideoCapture类将不会产生任何帧,它的read方法
将返回(False,None)。要避免试图从未正确打开的VideoCapture对象
检索帧,你可能想先调用VideoCapture.isOpened方法,返回一个布尔
值。
当我们需要同步一组摄像头或者多摄像头相机(如立体摄像机)
时,read方法是不合适的。我们可以改用grab和retrieve方法。对于
一组(两台)摄像机,可以使用类似于下面的代码:
2.2.6 在窗口中显示图像
OpenCV中一个最基本的操作是在窗口中显示图像。这可以通过
imshow函数实现。如果你有任何其他GUI框架背景,那么可能认为调用
imshow来显示图像就足够了。可是,在OpenCV中,只有当调用另一个
函数waitKey时,才会绘制(或者重新绘制)窗口。后一个函数抽取窗
口事件队列(允许处理各种事件,比如绘图),并且它返回用户在指
定的超时时间内输入的任何键的键码。在某种程度上,这个基本设计
简化了开发使用视频或网络摄像头输入的演示程序的任务,至少开发
人员可以手动控制新帧的获取和显示。
下面是一个非常简单的示例脚本,用于从文件中读取图像,并对
其进行显示:
imshow函数有两个参数:显示图像的窗口名称以及图像自己的名
称。我们将在2.2.7节中对waitKey进行更详细的介绍。
恰如其名,destroyAllWindows函数会注销由OpenCV创建的所有窗
口。
2.2.7 在窗口中显示摄像头帧
OpenCV允许使用namedWindow、imshow和destroyWindow函数来创
建、重新绘制和注销指定的窗口。此外,任何窗口都可以通过waitKey
函数捕获键盘输入,通过setMouseCallback函数捕获鼠标输入。我们
来看一个例子,展示从实时摄像头获取的帧:
waitKey的参数是等待键盘输入的毫秒数,默认情况下为0,这是
一个特殊的值,表示无穷大。返回值可以是-1(表示未按下任何
键),也可以是ASCII键码(如27表示Esc)。有关ASCII键码的列表,
请参阅http://www.asciitable.com/。另外,请注意Python提供了一
个标准函数ord,可以将字符转换成ASCII键码。例如,ord('a')返回
97。
同样,请注意,OpenCV的窗口函数和waitKey是相互依赖的。
OpenCV窗口只在调用waitKey时更新。相反,waitKey只在OpenCV窗口
有焦点时才捕捉输入。
传递给setMouseCallback的鼠标回调应有5个参数,如代码示例所
示。把回调的param参数设置为setMouseCallback的第3个可选参数,
默认情况下为0。回调的事件参数是以下操作之一:
·cv2.EVENT_MOUSEMOVE:这个事件指的是鼠标移动。
·cv2.EVENT_LBUTTONDOWN:这个事件指的是按下左键时,
左键向下。
·cv2.EVENT_RBUTTONDOWN:这个事件指的是按下右键时,
右键向下。
·cv2.EVENT_MBUTTONDOWN:这个事件指的是按下中间键
时,中间键向下。
·cv2.EVENT_LBUTTONUP:这个事件指的是释放左键时,左键
回到原位。
·cv2.EVENT_RBUTTONUP:这个事件指的是释放右键时,右键
回到原位。
·cv2.EVENT_MBUTTONUP:这个事件指的是释放中间键时,中
间键回到原位。
·cv2.EVENT_LBUTTONDBLCLK:这个事件指的是双击左键。
·cv2.EVENT_RBUTTONDBLCLK:这个事件指的是双击右键。
·cv2.EVENT_MBUTTONDBLCLK:这个事件指的是双击中间
键。
鼠标回调的flag参数可能是以下事件的一些按位组合:
·cv2.EVENT_FLAG_LBUTTON:这个事件指的是按下左键。
·cv2.EVENT_FLAG_RBUTTON:这个事件指的是按下右键。
·cv2.EVENT_FLAG_MBUTTON:这个事件指的是按下中间键。
·cv2.EVENT_FLAG_CTRLKEY:这个事件指的是按下Ctrl键。
·cv2.EVENT_FLAG_SHIFTKEY:这个事件指的是按下Shift键。
·cv2.EVENT_FLAG_ALTKEY:这个事件指的是按下Alt键。
可是,OpenCV不提供任何手动处理窗口事件的方法。例如,单击
窗口关闭按钮不能停止应用程序。因为OpenCV的事件处理和GUI功能有
限,许多开发人员更喜欢将其与其他应用程序框架集成。在本章的2.4
节中,我们将设计一个抽象层来帮助OpenCV与应用程序框架集成。
2.3 项目Cameo(人脸跟踪和图像处理)
通常,通过一种烹饪书式的方法研究OpenCV,这种方法涵盖了很
多算法,但是没有涉及高级应用程序开发。在某种程度上,这种方法
是可以理解的,因为OpenCV的潜在应用非常多样化。OpenCV广泛应用
于各种各样的应用,如照片/视频编辑器、运动控制游戏、机器人的人
工智能,或者我们记录参与者眼球运动的心理学实验等。在这些不同
的用例中,我们能真正研究一组有用的抽象吗?
本书的作者相信我们可以,而且越早开始抽象,学习效果越好。
我们将围绕单个应用程序构建许多OpenCV示例,但是在每个步骤中,
我们将设计一个可扩展且可重用的应用程序组件。
我们将开发一个交互式应用程序,对摄像头输入进行实时的人脸
跟踪和图像处理。这种类型的应用程序涵盖了OpenCV的各种功能,而
且创建一个高效且有效的实现对我们来说是一种挑战。
具体来说,我们的应用程序将实时合并人脸。给定2个摄像头输入
流(或者预录制的视频输入),应用程序将把一个流的人脸叠加到另
一个流的人脸上。将滤镜和畸变应用到这个混合的场景中,将会给人
一种统一的感觉。用户应该有进入另一个环境和角色参与现场表演的
体验。这种类型的用户体验在像迪士尼乐园这样的游乐园中很受欢
迎。
在这样的应用程序中,用户会立刻注意到缺陷,如低帧率或者跟
踪不准确等。为了达到最好的效果,我们将尝试使用传统成像和深度
成像的几种方法。
我们将应用程序命名为“Cameo”。Cameo(在珠宝中)是一个人
的小肖像,或者(在电影中)是由名人扮演的、非常短暂的一个角
色。
2.4 Cameo:面向对象的设计
可以用纯过程式风格编写Python应用程序。通常,这是通过小型
应用程序(例如前面讨论过的基本I/O脚本)实现的。但是,从现在开
始,我们将经常使用面向对象的风格,因为面向对象促进了模块化和
可扩展性。
从对OpenCV的I/O功能的概述中,我们知道不管源图像或者目标图
像是什么,所有图像都是相似的。不管获取的图像流是什么,或者将
其作为输出发送到哪里,我们都可以对这个流的每一帧应用相同的特
定于应用程序的逻辑。在使用多个I/O流的应用程序(例如Cameo)
中,I/O代码和应用程序代码的分离变得特别方便。
我们将创建的类命名为CaptureManager和WindowManager,作为
I/O流的高级接口。应用程序代码可以使用CaptureManager读取新帧,
也可以将每一帧分派给一个或多个输出,包括静态图像文件、视频文
件和窗口(通过WindowManager类)。WindowManager类允许应用程序
代码以面向对象风格处理窗口和事件。
CaptureManager和WindowManager都是可扩展的。我们可以实现不
依赖OpenCV的I/O。
2.4.1 基于managers.CaptureManager提取视频流
正如我们所看到的,OpenCV可以获取、显示和记录来自视频文件
或来自摄像头的图像流,但是在每种情况下都会有一些特殊考虑的事
项。CaptureManager类提取了一些差异并提供了一个更高级的接口,
将图像从获取流分发到一个或多个输出——静态图像文件、视频文
件,或者窗口。
CaptureManager对象是由VideoCapture对象初始化的,并拥有
enterFrame和exitFrame方法,通常应该在应用程序主循环的每次迭代
中调用这两个方法。在调用enterFrame和exitFrame之间,应用程序可
以(任意次)设置一个channel属性并获得一个frame属性。channel属
性初始为0,只有多摄像头相机使用其他值。frame属性是在调用
enterFrame时,对应于当前通道状态的一幅图像。
CaptureManager类还拥有可以在任何时候调用的writeImage、
startWriting Video和stopWritingVideo方法。实际的文件写入被推
迟到exitFrame。同样,在执行exitFrame方法期间,可以在窗口中显
示frame,这取决于应用程序代码将WindowManager类作为
CaptureManager构造函数的参数提供,还是通过设置
previewWindowManager属性提供。
如果应用程序代码操作frame,那么将在记录文件和窗口中体现这
些操作。CaptureManager类有一个构造函数参数和一个名为
shouldMirrorPreview的属性,如果想要在窗口中镜像(水平翻转)
frame,但不记录在文件中,那么此属性应该为True。通常,在面对摄
像头时,用户更喜欢镜像实时摄像头回传信号。
回想一下,VideoWriter对象需要一个帧率,但是OpenCV没有提供
任何可靠的方法来为摄像头获取准确的帧率。CaptureManager类通过
使用帧计数器和Python的标准time.time函数来解决此限制,如有必要
还会估计帧率。这种方法并非万无一失。取决于帧率的波动和依赖于
系统的time.time实现,估计的准确率在某些情况下可能仍然很糟糕。
但是,如果部署到未知的硬件,这也比只假设用户摄像头有某个特定
的帧率要好。
我们创建一个名为managers.py的文件,该文件将包含
CaptureManager实现。这个实现非常很长,所以我们将分成几个部分
介绍:
(1)首先,添加导入和构造函数,如下所示:
2)接下来,为CaptureManager的属性添加下面的getter和
setter方法:
请注意,大多数member变量是非公共的,由变量名称中下划线前
缀所示,如self._enteredFrame。这些非公共变量与当前帧的状态和
任何文件的写入操作相关。如前所述,应用程序代码只需要配置一些
内容,这些内容是作为构造函数参数和可设置的公共属性(摄像头通
道、窗口管理器以及镜像摄像头预览的选项)实现的。
本书假设读者对Python有一定的了解,但是如果你对这些@注释
(例如@property)感到困惑,请参考有关decorator的Python文档,
decorator是Python语言的内置特性,允许函数被另一个函数封装,通
常用来在应用程序的几个地方应用用户定义的行为。具体来说,可以
在
https://docs.python.org/3/reference/compound_stmts.html#gramm
ar-token-decorator查看相关文档。
Python没有强制使用非公共的成员变量的概念,但是在开发人
员想要将变量视为非公共的情况下,通常会看到单下划线前缀(_)或
者双下划线前缀(__)。单下划线前缀只是一种约定,表示应该将变
量视为受保护的(仅在类及其子类中访问)。双下划线前缀实际上会
导致Python解释器重命名变量,这样MyClass.__myVariable就变成了
MyClass._MyClass__myVariable。这被称为名称重整(非常恰当)。按照
惯例,应该将这样的变量视为私有的(只能在类内访问,不能在子类
中访问)。相同的前缀,具有相同的意义,可以应用于方法和变量。
(3)将enterFrame方法添加到managers.py:
请注意,enterFrame的实现只(同步地)抓取一帧,而来自通道
的实际检索被推迟到frame变量的后续读取。
(4)接下来,把exitFrame方法添加到managers.py:
exitFrame的实现从当前通道获取图像,估计帧率,通过窗口管理
器(如果有的话)显示图像,并完成将图像写入文件的所有挂起请
求。
(5)其他几种方法也适用于文件的写入。将下列名为
writeImage、startWriting Video和stopWritingVideo的公共方法的
实现添加到managers.py:
上述方法只更新了文件写入操作的参数,实际的写入操作被推迟
到exitFrame的下一次调用。
(6)在本节的前面,我们看到exitFrame调用了一个名为
_writeVideoFrame的辅助方法。把下面的_writeVideoFrame实现添加
到managers.py:
上述方法创建或添加视频文件的方式应该与之前的脚本相似(请
参考2.2.4节)。但是,在帧率未知的情况下,我们在捕获会话开始
时,跳过一些帧,这样就有时间构建帧率的估计。
我们对CaptureManager的实现就结束了。尽管CaptureManager的
实现依赖于VideoCapture,我们可以完成不使用OpenCV作为输入的其
他实现。例如,我们可以创建用套接字连接实例化的子类,将其字节
流解析为图像流。另外,我们还可以使用第三方摄像头库创建子类,
并提供与OpenCV不同的硬件支持。但是,对于Cameo,当前的实现就足
够了。
2.4.2 基于managers.WindowManager提取窗口和键盘
正如我们所见,OpenCV提供了一些函数用于创建、撤销窗口,显
示图像以及处理事件。这些函数不是窗口类的方法,因而要求将窗口
名称作为参数传递。因为这个接口不是面向对象的,所以与OpenCV的
一般风格不一致。而且,它不太可能与我们最终想要使用的(而不是
OpenCV的)其他窗口或者事件处理接口兼容。
为了面向对象和适应性,我们将这个功能抽象成具有
createWindow、destroy Window、show和processEvents方法的
WindowManager类。作为一个属性,WindowManager有一个名为
keypressCallback的函数,在响应按键时可以从processEvents调用
(如果不是None的话)。keypressCallback对象必须是一个接受单个
参数(尤其是ASCII键码)的函数。
我们将WindowManager的实现添加到managers.py。该实现首先定
义下列类声明和__init__方法:
该实现接着使用下面的方法来管理窗口及其事件的生命周期:
当前的实现只支持键盘事件,对于Cameo足够了。但是,我们也可
以修改Window Manager来支持鼠标事件。例如,类接口可以扩展为包
含mouseCallback属性(和可选的构造函数参数),但是其他方面保持
不变。使用OpenCV之外的事件框架,我们可以通过添加回调属性以同
样的方式支持其他事件类型。
2.4.3 基于cameo.Cameo应用所有内容
我们的应用程序由带有两个方法(run和onKeypress)的Cameo类
表示。在初始化时,Cameo对象创建了一个WindowManager对象(将
onKeypress作为一个回调),以及一个使用摄像头(具体来说,是一
个cv2.VideoCapture对象)和同一WindowManager对象的
CaptureManager对象。在调用run时,应用程序执行一个主循环,并在
这个主循环中处理帧和事件。
作为事件处理的结果,可能会调用onKeypress。空格键会产生一
个屏幕截图,选项卡(Tab)键会使屏幕播放(视频录制)开始/停
止,Esc键会使应用程序退出。
在与managers.py相同的目录中,创建一个名为cameo.py的文件,
并在此实现Cameo类:
(1)首先,实现下面的import语句和__init__方法:
(2)接下来,添加以下run()方法的实现:
(3)下面是为完成Cameo类实现的onKeypress()方法:
(4)最后,添加一个__main__块来实例化并运行Cameo,如下所
示:
在运行应用程序时,请注意实时摄像头回传信号是镜像的,而屏
幕截图和屏幕播放则不是镜像的。这是预期的行为,因为在初始化
CaptureManager类时,我们将True传给了shouldMirrorPreview。
图2-4是Cameo的一个屏幕截图,显示了一个窗口(标题为Cameo)
和来自摄像头的当前帧。
到目前为止,除了为预览而对帧进行镜像之外,我们没有对帧执
行任何操作。我们将在第3章开始添加更有趣的效果。
2.5 本章小结
现在,我们应该拥有了一个显示摄像头回传信号、监听键盘输入
并(在命令下)记录屏幕截图或屏幕播放的应用程序。我们打算通过
在每一帧的开始和结束之间插入一些图像滤波代码(见第3章)来扩展
应用程序。此外,除了OpenCV所支持的那些功能外,我们还准备集成
其他摄像头驱动程序或应用程序框架。
我们还掌握了把图像作为NumPy数组进行操作的知识。这将为下一
个主题——图像滤波器——奠定完美的基础。