原文:
annas-archive.org/md5/8dae50c67273e660f09fe74447ef722d译者:飞龙
第五章:使用 OpenCV 进行计算摄影
本章的目标是建立在前面章节中关于摄影和图像处理所涵盖的内容之上,并深入探讨 OpenCV 提供的算法。我们将专注于处理数码摄影和构建能够让您利用 OpenCV 力量的工具,甚至考虑将其作为您编辑照片的首选工具。
在本章中,我们将介绍以下概念:
-
规划应用
-
理解 8 位问题
-
使用伽玛校正
-
理解高动态范围成像(HDRI)
-
理解全景拼接
-
改进全景拼接
学习数码摄影的基础知识和高动态成像的概念不仅可以帮助您更好地理解计算摄影,还可以使您成为一名更好的摄影师。由于我们将详细探讨这些主题,您还将了解编写新算法需要付出多少努力。
通过本章的学习,您将了解如何直接从数码相机处理(RAW)图像,如何使用 OpenCV 的计算摄影工具,以及如何使用低级 OpenCV API 构建全景拼接算法。
我们有很多主题要介绍,所以让我们挽起袖子开始吧。
开始学习
您可以在我们的 GitHub 仓库中找到本章中展示的代码,网址为github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter5。
我们还将使用rawpy和exifreadPython 包来读取 RAW 图像和读取图像元数据。对于完整的需求列表,您可以参考书中 Git 仓库中的requirements.txt文件。
规划应用
我们有几个概念需要熟悉。为了构建您的图像处理工具箱,我们将把我们要熟悉的概念开发成使用 OpenCV 解决实际问题的 Python 脚本。
我们将使用 OpenCV 实现以下脚本,以便您在需要处理照片时可以使用它们:
-
gamma_correct.py:这是一个脚本,它对输入图像应用伽玛校正,并显示结果图像。 -
hdr.py:这是一个脚本,它以图像为输入,并生成一个高动态范围(HDR)图像作为输出。 -
panorama.py:这是一个脚本,它以多个图像为输入,并生成一个比单个图像更大的拼接图像。
我们首先讨论数码摄影的工作原理以及为什么我们不需要进行后期处理就无法拍摄完美的照片。让我们从图像的 8 位问题开始。
理解 8 位问题
我们习惯看到的典型联合图像专家小组(JPEG)图像,是通过将每个像素编码为 24 位来工作的——每个RGB(红色、绿色、蓝色)颜色组件一个 8 位数字,这给我们一个在 0-255 范围内的整数。这只是一个数字,255,*但这足够信息吗?*为了理解这一点,让我们尝试了解这些数字是如何记录的以及这些数字代表什么。
大多数当前的数码相机使用拜耳滤波器,或等效的,它使用相同的原则。拜耳滤波器是一个不同颜色传感器的阵列,放置在一个类似于以下图所示的网格上:
图片来源—https://en.wikipedia.org/wiki/Bayer_filter#/media/File:Bayer_pattern_on_sensor.svg (CC SA 3.0)
在前面的图中,每个传感器测量进入它的光的强度,四个传感器一组代表一个单独的像素。这四个传感器的数据被组合起来,为我们提供 R、G 和 B 的三个值。
不同的相机可能会有红色、绿色和蓝色像素的不同布局,但最终,它们都在使用小的传感器,将它们接收到的辐射量离散化到 0-255 范围内的单个值,其中 0 表示完全没有辐射,255 表示传感器可以记录的最亮辐射。
可检测的亮度范围被称为动态范围或亮度范围。最小可注册的辐射量(即,1)与最高辐射量(即,255)之间的比率称为对比度比。
正如我们所说,JPEG 文件具有255:1的对比度比。大多数当前的 LCD 显示器已经超过了这个比例,对比度比高达1,000:1。我打赌你正在等待你眼睛的对比度比。我不确定你,但大多数人类可以看到高达15,000:1。
因此,我们可以看到比我们最好的显示器显示的还要多,比简单的 JPEG 文件存储的还要多。不要过于绝望,因为最新的数码相机已经迎头赶上,现在可以捕捉到高达28,000:1的强度比(真正昂贵的那些)。
小的动态范围是当你拍照时,如果背景有太阳,你要么看到太阳,周围的一切都是白色,没有任何细节,要么前景中的所有东西都极其黑暗的原因。这里是一个示例截图:
图片来源—https://github.com/mamikonyana/winter-hills (CC SA 4.0)
因此,问题是我们要么显示过亮的东西,要么显示过暗的东西。在我们继续前进之前,让我们看看如何读取超过 8 位的文件并将数据导入 OpenCV。
了解 RAW 图像
由于本章是关于计算摄影的,一些阅读本章的人可能是摄影爱好者,喜欢使用相机支持的 RAW 格式拍照——无论是尼康电子格式(NEF)还是佳能原始版本 2(CR2)。
原始文件通常比 JPEG 文件捕获更多的信息(通常每像素更多位),如果你要进行大量的后期处理,这些文件处理起来会更加方便,因为它们将产生更高品质的最终图像。
因此,让我们看看如何使用 Python 打开 CR2 文件并将其加载到 OpenCV 中。为此,我们将使用一个名为rawpy的 Python 库。为了方便,我们将编写一个名为load_image的函数,它可以处理 RAW 图像和常规 JPEG 文件,这样我们就可以抽象这部分内容,专注于本章剩余部分更有趣的事情:
- 首先,我们处理导入(如承诺的那样,只是额外的一个小库):
import rawpy
import cv2
- 我们定义了一个函数,添加了一个可选的
bps参数,这将让我们控制我们想要图像具有多少精度,也就是说,我们想要检查我们是否想要完整的 16 位,或者 8 位就足够了:
def load_image(path, bps=16):
- 然后,如果文件具有
.CR2扩展名,我们使用rawpy打开文件并提取图像,而不尝试进行任何后期处理,因为我们想用 OpenCV 来做:
if path.suffix == '.CR2':
with rawpy.imread(str(path)) as raw:
data = raw.postprocess(no_auto_bright=True,
gamma=(1, 1),
output_bps=bps)
- 由于佳能(佳能公司——一家光学产品公司)和 OpenCV 使用不同的颜色顺序,我们切换到BGR(蓝色、绿色和红色),这是 OpenCV 中的默认顺序,我们
返回生成的图像:
return cv2.cvtColor(data, cv2.COLOR_RGB2BGR)
对于任何不是.CR2的文件,我们使用 OpenCV:
else:
return cv2.imread(str(path))
现在我们知道了如何将所有我们的图像放入 OpenCV,是时候开始使用我们最明亮的算法之一了。
由于我的相机具有 14 位动态范围,我们将使用用我的相机捕获的图像:
def load_14bit_gray(path):
img = load_image(path, bps=16)
return (cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) / 4).astype(np.uint16)
一旦我们知道了如何加载我们的图片,让我们尝试看看我们如何在屏幕上最佳地显示它们。
使用伽玛校正
*为什么每个人还在使用只能区分 255 个不同级别的 JPEG 文件呢?*这难道意味着它只能捕捉到 1:255 的动态范围吗? 事实上,人们使用了一些巧妙的技巧。
如我们之前提到的,相机传感器捕获的值是线性的,也就是说,4 表示它比 1 有 4 倍的光线,80 比 10 有 8 倍的光线。但是 JPEG 文件格式必须使用线性刻度吗?事实并非如此。 因此,如果我们愿意牺牲两个值之间的差异,例如,100 和 101,我们就可以在那里放入另一个值。
为了更好地理解这一点,让我们来看看 RAW 图像灰度像素值的直方图。以下是生成该直方图的代码——只需加载图像,将其转换为灰度,然后使用pyplot显示直方图:
images = [load_14bit_gray(p) for p in args.images]
fig, axes = plt.subplots(2, len(images), sharey=False)
for i, gray in enumerate(images):
axes[0, i].imshow(gray, cmap='gray', vmax=2**14)
axes[1, i].hist(gray.flatten(), bins=256)
这是直方图的结果:
我们有两张图片:左边的是一张正常的图片,你可以看到一些云,但几乎看不到前景中的任何东西,而右边的一张则试图捕捉树中的细节,因此烧毁了所有的云。有没有办法将它们结合起来?
如果我们仔细观察直方图,我们会看到在右侧直方图上可以看见烧毁的部分,因为存在值为 16,000 的数据被编码为 255,即白色像素。但在左侧图片中,没有白色像素。我们将 14 位值编码为 8 位值的方式非常基础:我们只是将值除以64 (=2⁶),因此我们失去了 2,500 和 2,501 以及 2,502 之间的区别;相反,我们只有 39(255 个中的 39 个)因为 8 位格式中的值必须是整数。
这就是伽玛校正发挥作用的地方。我们不会简单地显示记录的值作为强度,我们将进行一些校正,使图像更具有视觉吸引力。
我们将使用非线性函数来尝试强调我们认为更重要的一部分:
让我们尝试可视化这个公式对于两个不同值——γ = 0.3和γ = 3:
如您所见,小的伽玛值强调较低的值;0-50的像素值映射到0-150的像素值(超过一半的可供值)。对于较高的伽玛值,情况相反——200-250的值映射到100-250的值(超过一半的可供值)。因此,如果你想使你的照片更亮,你应该选择γ < 1的伽玛值,这通常被称为伽玛压缩。如果你想使你的照片变暗以显示更多细节,你应该选择γ > 1的伽玛值,这被称为伽玛扩展。
我们可以不用整数来表示I,而是从一个浮点数开始,得到 O,然后将该数字转换为整数以丢失更少的信息。让我们编写一些 Python 代码来实现伽玛校正:
- 首先,让我们编写一个函数来应用我们的公式。因为我们使用 14 位数字,所以我们需要将其更改为以下形式:
因此,相关的代码如下:
@functools.lru_cache(maxsize=None)
def gamma_transform(x, gamma, bps=14):
return np.clip(pow(x / 2**bps, gamma) * 255.0, 0, 255)
在这里,我们使用了@functools.lru_cache装饰器来确保我们不会两次计算相同的内容。
- 然后,我们只需遍历所有像素并应用我们的转换函数:
def apply_gamma(img, gamma, bps=14):
corrected = img.copy()
for i, j in itertools.product(range(corrected.shape[0]),
range(corrected.shape[1])):
corrected[i, j] = gamma_transform(corrected[i, j], gamma, bps=bps)
return corrected
现在,让我们看看如何使用这个方法来显示新图像与常规转换的 8 位图像并排。我们将为此编写一个脚本:
- 首先,让我们配置一个
parser来加载图像并允许设置gamma值:
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('raw_image', type=Path,
help='Location of a .CR2 file.')
parser.add_argument('--gamma', type=float, default=0.3)
args = parser.parse_args()
- 将
gray图像加载为14bit图像:
gray = load_14bit_gray(args.raw_image)
- 使用线性变换来获取输出值作为范围
[0-255]内的整数:
normal = np.clip(gray / 64, 0, 255).astype(np.uint8)
- 使用我们之前编写的
apply_gamma函数来获取伽玛校正的图像:
corrected = apply_gamma(gray, args.gamma)
- 然后,将这两张图像及其直方图一起绘制出来:
fig, axes = plt.subplots(2, 2, sharey=False)
for i, img in enumerate([normal, corrected]):
axes[0, i].imshow(img, cmap='gray', vmax=255)
axes[1, i].hist(img.flatten(), bins=256)
- 最后,
显示图像:
plt.show()
我们现在已经绘制了直方图,接下来我们将看看以下两张图像及其直方图中所阐述的神奇之处:
看看右上角的图片——你几乎可以看到一切!而我们才刚刚开始。
结果表明,伽玛补偿在黑白图像上效果很好,但它不能做所有的事情!它要么可以校正亮度,我们就会失去大部分的颜色信息,要么它可以校正颜色信息,我们就会失去亮度信息。因此,我们必须找到一个新最好的朋友——那就是,HDRI。
理解高动态范围成像
高动态范围成像(HDR)是一种技术,可以产生比通过显示介质显示或使用单次拍摄用相机捕获的图像具有更大亮度动态范围(即对比度比)的图像。创建此类图像有两种主要方法——使用特殊的图像传感器,例如过采样二进制图像传感器,或者我们在这里将重点关注的,通过组合多个标准动态范围(SDR)图像来生成一个组合 HDR 图像。
HDR 成像使用的是每个通道超过 8 位(通常是 32 位浮点值)的图像,这使得动态范围更广。正如我们所知,场景的动态范围是其最亮和最暗部分之间的对比度比。
让我们更仔细地看看我们能看到的某些事物的亮度值。以下图表显示了我们可以轻松看到的值,从黑暗的天空(大约10^(-4) cd/m²)到日落时的太阳(10⁵ cd/m²):
我们可以看到的不仅仅是这些值。因为有些人可以调整他们的眼睛适应甚至更暗的地方,所以当太阳不在地平线上,而是在更高的天空时,我们肯定可以看到太阳,可能高达10⁸ cd/m²,但这个范围已经相当大了,所以我们现在就坚持这个范围。为了比较,一个普通的 8 位图像对比度比为256:1,人眼一次可以看到大约百万到 1 的对比度,而 14 位 RAW 格式显示2¹⁴:1。
显示媒体也有局限性;例如,典型的 IPS 显示器对比度比约为1,000:1,而 VA 显示器对比度可能高达6,000:1。因此,让我们将这些值放在这个频谱上,看看它们是如何比较的:
现在,这看起来我们看不到多少,这是真的,因为我们需要时间来适应不同的光照条件。同样的情况也适用于相机。但仅仅一眼,我们的裸眼就能看到比最好的相机还能看到的东西更多。那么我们该如何解决这个问题呢?
正如我们所说的,技巧是快速连续拍摄多张照片,大多数相机都能轻松实现这一点。如果我们连续拍摄互补的照片,只需五张 JPEG 图片就能覆盖相当大的光谱范围:
这看起来有点太简单了,但记住,拍摄五张照片相当容易。但是,我们谈论的是一张包含所有动态范围的图片,而不是五张单独的图片。HDR 图像有两个主要问题:
-
我们如何将多张图像合并成一张图像?
-
我们如何显示一个比我们的显示媒体动态范围更高的图像?
然而,在我们能够合并这些图像之前,让我们更仔细地看看我们如何可以改变相机的曝光,即其对光线的敏感度。
探索改变曝光的方法
正如我们在本章前面讨论的,现代单镜头反光数码相机(DSLR)以及其他数码相机,都有一个固定的传感器阵列(通常放置为拜耳滤镜),它只是测量相机的光强度。
我敢打赌,你见过同一个相机用来捕捉美丽的夜景,其中水面看起来像丝质云朵,以及体育摄影师拍摄的运动员全伸展的照片。那么他们如何使用同一个相机在如此不同的设置中并获得我们在屏幕上看到的结果呢?
在测量曝光时,很难测量被捕获的亮度。相对于以 10 的幂次测量亮度来说,测量相对速度要容易得多,这可能相当难以调整。我们以 2 的幂次来测量速度;我们称之为档位。
这个技巧是,尽管相机受到限制,但它必须能够捕捉每张图片的有限亮度范围。这个范围本身可以在亮度光谱上移动。为了克服这一点,让我们研究相机的快门速度、光圈和 ISO 速度参数。
快门速度
快门速度并不是快门的速度,而是在拍照时相机快门开启的时间长度。因此,这是相机内部数字传感器暴露于光线以收集信息的时间长度。这是所有相机控制中最直观的一个,因为我们能感觉到它的发生。
快门速度通常以秒的分数来衡量。例如,1/60 是最快的速度,如果我们手持相机拍照时摇晃相机,它不会在照片中引入模糊。所以如果你要使用自己的照片,确保不要这样做,或者准备一个三脚架。
光圈
光圈是光学镜头中光线通过的孔的直径。以下图片展示了设置为不同光圈值的开口示例:
图片来源—https://en.wikipedia.org/wiki/Aperture#/media/File:Lenses_with_different_apertures.jpg (CC SA 4.0)
光圈通常使用f 数来衡量。f 数是系统焦距与开口直径(入射光瞳)的比值。我们不会关心镜头的焦距;我们唯一需要知道的是,只有变焦镜头才有可变焦距,因此如果我们不改变镜头的放大倍数,焦距将保持不变。所以我们可以通过平方 f 数的倒数来测量入射光瞳的面积:
此外,我们知道面积越大,我们图片中的光线就越多。因此,如果我们增加 f 数,这将对应于入射光瞳大小的减小,我们的图片会变暗,使我们能够在下午拍照。
ISO 感光度
ISO 感光度是相机中使用的传感器的灵敏度。它使用数字来衡量数字传感器的灵敏度,这些数字对应于计算机出现之前使用的化学胶片。
ISO 感光度以两个数字来衡量;例如,100/21°,其中第一个数字是算术尺度上的速度,第二个数字是对数尺度上的数字。由于这些数字有一一对应的关系,通常省略第二个数字,我们简单地写成ISO 100。ISO 100 比 ISO 200 对光线的敏感度低两倍,据说这种差异是1 挡。
用 2 的幂次方来表示比用 10 的幂次方表示更容易,因此摄影师提出了挡位的概念。一挡是两倍不同,两挡是四倍不同,以此类推。因此,n挡是2^n倍不同。这种类比已经变得如此普遍,以至于人们开始使用分数和实数来表示挡位。
现在我们已经了解了如何控制曝光,让我们来看看可以将多张不同曝光的图片组合成一张图片的算法。
使用多曝光图片生成 HDR 图像
现在,一旦我们知道了如何获取更多的图片,我们就可以拍摄多张几乎没有重叠动态范围的图片。让我们先看看最流行的 HDR 算法,该算法最早由 Paul E Debevec 和 Jitendra Malik 于 2008 年发表。
结果表明,如果你想得到好的结果,你需要有重叠的图片,以确保你有一个好的精度,因为照片中存在噪声。通常,图片之间的差异为 1、2 或最多 3 挡。如果我们拍摄五张 8 位图片,差异为 3 挡,我们将覆盖人眼一百万到一的敏感度比:
现在,让我们更详细地看看 Debevec HDR 算法是如何工作的。
首先,让我们假设相机看到的记录值是场景辐照度的某个函数。我们之前提到这应该是线性的,但现实生活中没有任何东西是完全线性的。让记录值矩阵为Z,辐照度矩阵为X;我们有以下内容:
这里,我们也将Δt作为曝光时间的度量,函数f被称为我们相机的响应函数。我们还假设如果我们加倍曝光并减半辐照度,我们将得到相同的输出,反之亦然。这应该适用于所有图像,而E的值不应该从一张图片到另一张图片改变;只有Z的记录值和曝光时间Δt可以改变。如果我们应用逆响应函数(** f^(-1) **)并取两边的对数,那么我们得到对于所有我们有的图片(i):
现在的技巧是提出一个可以计算***f^(-1)***的算法,这正是 Debevec 等人所做的事情。
当然,我们的像素值不会完全遵循这个规则,我们不得不拟合一个近似解,但让我们更详细地看看这些值是什么。
在我们继续前进之前,让我们看看如何在下一节中从图片文件中恢复**Δt[i]**值。
从图像中提取曝光强度
假设我们之前讨论的所有相机参数都遵循互易原理,让我们尝试提出一个函数——exposure_strength——它返回一个等同于曝光时间的时长:
- 首先,让我们为 ISO 速度和光圈设置一个参考值:
def exposure_strength(path, iso_ref=100, f_stop_ref=6.375):
- 然后,让我们使用
exifreadPython 包,它使得读取与图像关联的元数据变得容易。大多数现代相机以这种标准格式记录元数据:
with open(path, 'rb') as infile:
tags = exifread.process_file(infile)
- 然后,让我们提取
f_stop值,看看参考的入射光瞳面积大多少:
[f_stop] = tags['EXIF ApertureValue'].values
rel_aperture_area = 1 / (f_stop.num / f_stop.den / f_stop_ref) ** 2
- 然后,让我们看看 ISO 设置更加敏感多少:
[iso_speed] = tags['EXIF ISOSpeedRatings'].values
iso_multiplier = iso_speed / iso_ref
- 最后,让我们将所有值与快门速度结合,并返回
exposure_time:
[exposure_time] = tags['EXIF ExposureTime'].values
exposure_time_float = exposure_time.num / exposure_time.den
return rel_aperture_area * exposure_time_float * iso_multipli
这里是用于本演示的图片值示例,取自Frozen River图片集:
| 照片 | 光圈 | ISO 速度 | 快门速度 |
|---|---|---|---|
| AM5D5669.CR2 | 6 3/8 | 100 | 1/60 |
| AM5D5670.CR2 | 6 3/8 | 100 | 1/250 |
| AM5D5671.CR2 | 6 3/8 | 100 | 1/160 |
| AM5D5672.CR2 | 6 3/8 | 100 | 1/100 |
| AM5D5673.CR2 | 6 3/8 | 100 | 1/40 |
| AM5D5674.CR2 | 6 3/8 | 160 | 1/40 |
| AM5D5676.CR2 | 6 3/8 | 250 | 1/40 |
这是使用exposure_strength函数对这些图片进行时间估计的输出:
0.016666666666666666, 0.004, 0.00625, 0.01, 0.025, 0.04, 0.0625
现在,一旦我们有了曝光时间,让我们看看如何使用它来获取相机响应函数。
估计相机响应函数
让我们在Y轴上绘制 ![,在x轴上绘制Z[i]:
我们试图找到一个f^(-1),更重要的是,所有图片的 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv4-py-bp/img/d04631b2-e5aa-4874-b7b3-06192cc9d677.png。这样,当我们把**log(E)**加到曝光的对数上时,我们将有所有像素在同一个函数上。你可以在下面的屏幕截图中看到 Debevec 算法的结果:
Debevec 算法估计了f^(-1),它大约通过所有像素,以及 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ocv4-py-bp/img/d04631b2-e5aa-4874-b7b3-06192cc9d677.png。E矩阵是我们恢复的 HDR 图像矩阵。
现在让我们看看如何使用 OpenCV 来实现这一点。
使用 OpenCV 编写 HDR 脚本
脚本的第一个步骤将是使用 Python 内置的argparse模块设置脚本参数:
import argparse
if __name__ == '__main__':
parser = argparse.ArgumentParser()
img_group = parser.add_mutually_exclusive_group(required=True)
img_group.add_argument('--image-dir', type=Path)
img_group.add_argument('--images', type=Path, nargs='+')
args = parser.parse_args()
if args.image_dir:
args.images = sorted(args.image_dir.iterdir())
正如你所见,我们设置了两个互斥的参数—--image-dir,一个包含图像的目录,以及 --images,一个我们将要使用的图像列表。我们确保将所有图像的列表填充到args.images中,这样脚本的其他部分就不必担心用户选择了哪个选项。
在我们有了所有的命令行参数之后,接下来的步骤如下:
- 将所有
images读入内存:
images = [load_image(p, bps=8) for p in args.images]
- 使用
exposure_strength读取元数据和估计曝光时间:
times = [exposure_strength(p)[0] for p in args.images]
times_array = np.array(times, dtype=np.float32)
- 计算相机响应函数—
crf_debevec:
cal_debevec = cv2.createCalibrateDebevec(int samples=200)
crf_debevec = cal_debevec.process(images, times=times_array)
- 使用相机响应函数来计算 HDR 图像:
merge_debevec = cv2.createMergeDebevec()
hdr_debevec = merge_debevec.process(images, times=times_array.copy(),
response=crf_debevec)
注意到 HDR 图像是float32类型,而不是uint8,因为它包含了所有曝光图像的全动态范围。
现在我们有了 HDR 图像,我们已经来到了下一个重要部分。让我们看看我们如何使用我们的 8 位图像表示来显示 HDR 图像。
显示 HDR 图像
显示 HDR 图像很棘手。正如我们所说,HDR 比相机有更多的值,所以我们需要找出一种方法来显示它。幸运的是,OpenCV 在这里帮助我们,而且,正如你现在可能已经猜到的,我们可以使用伽玛校正将所有不同的值映射到范围0到255的较小值域中。这个过程被称为色调映射。
OpenCV 有一个方法可以做到这一点,它接受gamma作为参数:
tonemap = cv2.createTonemap(gamma=2.2)
res_debevec = tonemap.process(hdr_debevec)
现在我们必须将所有值clip成整数:
res_8bit = np.clip(res_debevec * 255, 0, 255).astype('uint8')
之后,我们可以使用pyplot显示我们的结果 HDR 图像:
plt.imshow(res_8bit)
plt.show()
这会产生以下令人惊叹的图像:
现在,让我们看看如何扩展相机的视野—可能到 360 度!
理解全景拼接
计算摄影中另一个非常有趣的话题是全景拼接。我相信你们大多数人手机上都有全景功能。本节将专注于全景拼接背后的思想,而不仅仅是调用一个单独的函数,我们将通过所有创建全景所需的步骤。
编写脚本参数和过滤图像
我们想要编写一个脚本,该脚本将接受一系列图像并生成一张单独的全景图。因此,让我们为我们的脚本设置ArgumentParser:
def parse_args():
parser = argparse.ArgumentParser()
img_group = parser.add_mutually_exclusive_group(required=True)
img_group.add_argument('--image-dir', type=Path)
img_group.add_argument('--images', type=Path, nargs='+')
args = parser.parse_args()
if args.image_dir:
args.images = sorted(args.image_dir.iterdir())
return args
在这里,我们创建了一个ArgumentParser的实例并添加了参数,以便传递图像目录或图像列表。然后,我们确保如果传递了图像目录,我们获取所有图像,而不是传递图像列表。
现在,正如你可以想象的那样,下一步是使用特征提取器并查看图像共享的共同特征。这非常类似于前两个章节,即第三章,通过特征匹配和透视变换寻找物体和第四章,使用运动结构进行 3D 场景重建。我们还将编写一个函数来过滤具有共同特征的图像,这样脚本就更加灵活。让我们一步一步地通过这个函数:
- 创建
SURF特征提取器并计算所有图像的所有特征:
def largest_connected_subset(images):
finder = cv2.xfeatures2d_SURF.create()
all_img_features = [cv2.detail.computeImageFeatures2(finder, img)
for img in images]
- 创建一个
matcher类,该类将图像与其最接近的邻居匹配,这些邻居共享最多的特征:
matcher = cv2.detail.BestOf2NearestMatcher_create(False, 0.6)
pair_matches = matcher.apply2(all_img_features)
matcher.collectGarbage()
- 过滤图像并确保我们至少有两个共享特征的图像,这样我们就可以继续算法:
_conn_indices = cv2.detail.leaveBiggestComponent(all_img_features, pair_matches, 0.4)
conn_indices = [i for [i] in _conn_indices]
if len(conn_indices) < 2:
raise RuntimeError("Need 2 or more connected images.")
conn_features = np.array([all_img_features[i] for i in conn_indices])
conn_images = [images[i] for i in conn_indices]
- 再次运行
matcher以检查我们是否删除了任何图像,并返回我们将来需要的变量:
if len(conn_images) < len(images):
pair_matches = matcher.apply2(conn_features)
matcher.collectGarbage()
return conn_images, conn_features, pair_matches
在我们过滤了图像并有了所有特征之后,我们继续下一步,即设置空白画布进行全景拼接。
确定相对位置和最终图片大小
一旦我们分离了所有连接的图片并知道了所有特征,就到了确定合并全景的大小并创建空白画布以开始添加图片的时候了。首先,我们需要找到图片的参数。
寻找相机参数
为了能够合并图像,我们需要计算所有图像的透视矩阵,然后使用这些矩阵调整图像,以便它们可以合并在一起。我们将编写一个函数来完成这项工作:
- 首先,我们将创建
HomographyBasedEstimator()函数:
def find_camera_parameters(features, pair_matches):
estimator = cv2.detail_HomographyBasedEstimator()
- 一旦我们有了
estimator,用于提取所有相机参数,我们就使用来自不同图像的匹配features:
success, cameras = estimator.apply(features, pair_matches, None)
if not success:
raise RuntimeError("Homography estimation failed.")
- 我们确保
R矩阵具有正确的类型:
for cam in cameras:
cam.R = cam.R.astype(np.float32)
- 然后,我们
返回所有参数:
return cameras
可以使用细化器(例如,cv2.detail_BundleAdjusterRay)来改进这些参数,但现在我们保持简单。
创建全景图的画布
现在是时候创建画布了。为此,我们根据所需的旋转方案创建一个warper对象。为了简单起见,让我们假设一个平面模型:
warper = cv2.PyRotationWarper('plane', 1)
然后,我们遍历所有连接的图像,并获取每张图像中的所有感兴趣区域:
stitch_sizes, stitch_corners = [], []
for i, img in enumerate(conn_images):
sz = img.shape[1], img.shape[0]
K = cameras[i].K().astype(np.float32)
roi = warper.warpRoi(sz, K, cameras[i].R)
stitch_corners.append(roi[0:2])
stitch_sizes.append(roi[2:4])
最后,我们根据所有感兴趣区域估计最终的canvas_size:
canvas_size = cv2.detail.resultRoi(corners=stitch_corners, sizes=stitch_sizes)
现在,让我们看看如何使用画布大小来混合所有图像。
合并图像
首先,我们创建一个MultiBandBlender对象,这将帮助我们合并图像。blender不会仅仅从一张或另一张图像中选取值,而是会在可用的值之间进行插值:
blender = cv2.detail_MultiBandBlender()
blend_width = np.sqrt(canvas_size[2] * canvas_size[3]) * 5 / 100
blender.setNumBands((np.log(blend_width) / np.log(2.) - 1.).astype(np.int))
blender.prepare(canvas_size)
然后,对于每个连接的图像,我们执行以下操作:
- 我们
warp图像并获取corner位置:
for i, img in enumerate(conn_images):
K = cameras[i].K().astype(np.float32)
corner, image_wp = warper.warp(img, K, cameras[i].R,
cv2.INTER_LINEAR, cv2.BORDER_REFLECT)
- 然后,计算画布上图像的
mask:
mask = 255 * np.ones((img.shape[0], img.shape[1]), np.uint8)
_, mask_wp = warper.warp(mask, K, cameras[i].R,
cv2.INTER_NEAREST, cv2.BORDER_CONSTANT)
- 之后,将值转换为
np.int16并将其feed到blender中:
image_warped_s = image_wp.astype(np.int16)
blender.feed(cv2.UMat(image_warped_s), mask_wp, stitch_corners[i])
- 之后,我们在
blender上使用blend函数来获取最终的result,并保存它:
result, result_mask = blender.blend(None, None)
cv2.imwrite('result.jpg', result)
我们还可以将图像缩小到 600 像素宽并显示:
zoomx = 600.0 / result.shape[1]
dst = cv2.normalize(src=result, dst=None, alpha=255.,
norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U)
dst = cv2.resize(dst, dsize=None, fx=zoomx, fy=zoomx)
cv2.imshow('panorama', dst)
cv2.waitKey()
当我们使用来自github.com/mamikonyana/yosemite-panorama的图像时,我们最终得到了这张精彩的全景图片:
你可以看到它并不完美,白平衡需要从一张图片到另一张图片进行校正,但这是一个很好的开始。在下一节中,我们将致力于改进拼接输出。
改进全景拼接
你可以玩弄我们已有的脚本,添加或删除某些功能(例如,你可以添加一个白平衡补偿器,以确保从一张图片到另一张图片的过渡更加平滑),或者调整其他参数来学习。
但要知道——当你需要快速全景时,OpenCV 还有一个方便的Stitcher类,它已经完成了我们讨论的大部分工作:
images = [load_image(p, bps=8) for p in args.images]
stitcher = cv2.Stitcher_create()
(status, stitched) = stitcher.stitch(images)
这段代码片段可能比将你的照片上传到全景服务以获得好图片要快得多——所以享受创建全景吧!
不要忘记添加一些代码来裁剪全景,以免出现黑色像素!
摘要
在本章中,我们学习了如何使用有限能力的相机拍摄简单的图像——无论是有限的动态范围还是有限的视野,然后使用 OpenCV 将多张图像合并成一张比原始图像更好的单张图像。
我们留下了三个你可以在此基础上构建的脚本。最重要的是,panorama.py中仍然缺少很多功能,还有很多其他的 HDR 技术。最好的是,可以同时进行 HDR 和全景拼接。想象一下,在日落时分从山顶四处张望,那将是多么美妙!
这是关于相机摄影的最后一章。本书的其余部分将专注于视频监控并将机器学习技术应用于图像处理任务。
在下一章中,我们将专注于跟踪场景中视觉上显著和移动的物体。这将帮助你了解如何处理非静态场景。我们还将探讨如何让算法快速关注场景中的重点,这是一种已知可以加速目标检测、目标识别、目标跟踪和内容感知图像编辑的技术。
进一步阅读
在计算摄影学中还有许多其他主题可以探索:
-
特别值得一看的是汤姆·梅滕斯等人开发的曝光融合技术。汤姆·梅滕斯、简·考茨和弗兰克·范·里特撰写的《曝光融合》文章,发表于《计算机图形学与应用》,2007 年,太平洋图形学 2007,第 15 届太平洋会议论文集,第 382-390 页,IEEE,2007 年。
-
由保罗·E·德贝维克和吉滕德拉·马利克撰写的《从照片中恢复高动态范围辐射图》文章,收录于 ACM SIGGRAPH 2008 课程中,2008 年,第 31 页,ACM,2008 年。
归属
Frozen River 照片集可在github.com/mamikonyana/frozen-river找到,并经过 CC-BY-SA-4.0 许可验证。
第六章:跟踪视觉显著性物体
本章的目标是在视频序列中同时跟踪多个视觉显著性物体。我们不会自己标记视频中的感兴趣物体,而是让算法决定视频帧中哪些区域值得跟踪。
我们之前已经学习了如何在严格控制的情况下检测简单的感兴趣物体(如人手)以及如何从相机运动中推断视觉场景的几何特征。在本章中,我们将探讨通过观察大量帧的图像统计信息我们可以了解视觉场景的哪些内容。
在本章中,我们将涵盖以下主题:
-
规划应用
-
设置应用
-
映射视觉显著性
-
理解均值漂移跟踪
-
了解 OpenCV 跟踪 API
-
整合所有内容
通过分析自然图像的傅里叶频谱,我们将构建一个显著性图,它允许我们将图像中某些统计上有趣的区域标记为(潜在的或实际的)原型物体。然后我们将所有原型物体的位置输入到一个均值漂移跟踪器中,这将使我们能够跟踪物体从一个帧移动到下一个帧的位置。
开始使用
本章使用OpenCV 4.1.0,以及额外的包NumPy(www.numpy.org)、wxPython 2.8(www.wxpython.org/download.php)和matplotlib(www.matplotlib.org/downloads.html)。尽管本章中提出的部分算法已被添加到OpenCV 3.0.0版本的可选显著性模块中,但目前还没有 Python API,因此我们将编写自己的代码。
本章的代码可以在书的 GitHub 仓库中找到,仓库地址为github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter6。
理解视觉显著性
视觉显著性是来自认知心理学的一个术语,试图描述某些物体或项目的视觉质量,使其能够立即吸引我们的注意力。我们的大脑不断引导我们的目光向视觉场景中的重要区域,并在一段时间内跟踪它们,使我们能够快速扫描周围环境中的有趣物体和事件,同时忽略不那么重要的部分。
下面是一个常规 RGB 图像及其转换为显著性图的示例,其中统计上有趣的突出区域显得明亮,而其他区域则显得暗淡:
傅里叶分析将使我们能够对自然图像统计有一个一般性的了解,这将帮助我们构建一个关于一般图像背景外观的模型。通过将背景模型与特定图像帧进行比较和对比,我们可以定位图像中突出其周围环境的子区域(如图中所示的前一个屏幕截图)。理想情况下,这些子区域对应于当我们观察图像时,往往会立即吸引我们注意力的图像块。
传统模型可能会尝试将特定的特征与每个目标关联起来(类似于我们在第三章中介绍的特征匹配方法,通过特征匹配和透视变换查找对象),这将把问题转化为检测特定类别对象的问题。然而,这些模型需要手动标记和训练。但如果要跟踪的特征或对象的数量是未知的呢?
相反,我们将尝试模仿大脑的工作方式,即调整我们的算法以适应自然图像的统计特性,这样我们就可以立即定位视觉场景中“吸引我们的注意力”的图案或子区域(即偏离这些统计规律的模式)并将它们标记出来进行进一步检查。结果是这样一个算法,它可以适用于场景中任何数量的原型对象,例如跟踪足球场上的所有球员。请参考以下一系列屏幕截图以查看其效果:
正如我们在这四个屏幕截图中所看到的,一旦定位到图像中所有潜在的“有趣”的块,我们可以使用一种简单而有效的方法——对象 均值漂移跟踪——来跟踪它们在多个帧中的运动。由于场景中可能存在多个可能随时间改变外观的原型对象,我们需要能够区分它们并跟踪所有这些对象。
规划应用程序
要构建应用程序,我们需要结合之前讨论的两个主要功能——显著性图和对象跟踪**。**最终的应用程序将把视频序列的每个 RGB 帧转换为显著性图,提取所有有趣的原始对象,并将它们输入到均值漂移跟踪算法中。为此,我们需要以下组件:
-
main: 这是主函数例程(在chapter6.py中),用于启动应用程序。 -
saliency.py: 这是一个模块,用于从 RGB 彩色图像生成显著性图和原型对象图。它包括以下功能:-
get_saliency_map: 这是一个函数,用于将 RGB 彩色图像转换为显著性图。 -
get_proto_objects_map: 这是一个函数,用于将显著性图转换为包含所有原型对象的二值掩码。 -
plot_power_density: 这是一个函数,用于显示 RGB 彩色图像的二维功率密度,这有助于理解傅里叶变换。 -
plot_power_spectrum:这是一个用于显示 RGB 颜色图像的径向平均功率谱的函数,有助于理解自然图像统计信息。 -
MultiObjectTracker:这是一个使用均值漂移跟踪在视频中跟踪多个对象的类。它包括以下公共方法:-
MultiObjectTracker.advance_frame:这是一个用于更新新帧跟踪信息的方法,它使用当前帧的显著性图上的均值漂移算法来更新从前一帧到当前帧的框的位置。 -
MultiObjectTracker.draw_good_boxes:这是一个用于展示当前帧跟踪结果的方法。
-
-
在以下章节中,我们将详细讨论这些步骤。
设置应用程序
为了运行我们的应用程序,我们需要执行main函数,该函数读取视频流的一帧,生成显著性图,提取原对象的定位,并从一帧跟踪到下一帧。
让我们在下一节学习main函数的常规操作。
实现主函数
主要流程由chapter6.py中的main函数处理,该函数实例化跟踪器(MultipleObjectTracker)并打开显示场地上足球运动员数量的视频文件:
import cv2
from os import path
from saliency import get_saliency_map, get_proto_objects_map
from tracking import MultipleObjectsTracker
def main(video_file='soccer.avi', roi=((140, 100), (500, 600))):
if not path.isfile(video_file):
print(f'File "{video_file}" does not exist.')
raise SystemExit
# open video file
video = cv2.VideoCapture(video_file)
# initialize tracker
mot = MultipleObjectsTracker()
函数将逐帧读取视频并提取一些有意义的感兴趣区域(用于说明目的):
while True:
success, img = video.read()
if success:
if roi:
# grab some meaningful ROI
img = img[roi[0][0]:roi[1][0],
roi[0][1]:roi[1][1]]
然后,感兴趣区域将被传递到一个函数,该函数将生成该区域的显著性图。然后,基于显著性图生成有趣的原对象,最后将它们与感兴趣区域一起输入到跟踪器中。跟踪器的输出是带有边界框的标注输入区域,如前一组截图所示:
saliency = get_saliency_map(img, use_numpy_fft=False,
gauss_kernel=(3, 3))
objects = get_proto_objects_map(saliency, use_otsu=False)
cv2.imshow('tracker', mot.advance_frame(img, objects))
应用程序将运行到视频文件结束或用户按下q键为止:
if cv2.waitKey(100) & 0xFF == ord('q'):
break
在下一节中,我们将了解MultiObjectTracker类。
理解MultiObjectTracker类
跟踪器类的构造函数很简单。它所做的只是设置均值漂移跟踪的终止条件,并存储后续计算步骤中要考虑的最小轮廓面积(min_area)和按对象大小归一化的最小平均速度(min_speed_per_pix)的条件:
def __init__(self, min_object_area: int = 400,
min_speed_per_pix: float = 0.02):
self.object_boxes = []
self.min_object_area = min_object_area
self.min_speed_per_pix = min_speed_per_pix
self.num_frame_tracked = 0
# Setup the termination criteria, either 100 iteration or move by at
# least 1 pt
self.term_crit = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,
5, 1)
从那时起,用户可以调用advance_frame方法向跟踪器提供新的帧。
然而,在我们充分利用所有这些功能之前,我们需要了解图像统计信息以及如何生成显著性图。
映射视觉显著性
如本章前面所述,视觉显著性试图描述某些物体或项目的视觉质量,使它们能够吸引我们的即时注意力。我们的大脑不断引导我们的目光向视觉场景中的重要区域,就像在视觉世界的不同子区域上打闪光灯一样,使我们能够快速扫描周围环境中的有趣物体和事件,同时忽略不那么重要的部分。
人们认为,这是一种进化策略,用来应对在视觉丰富的环境中生活所带来的持续信息过载。例如,如果你在丛林中随意散步,你希望在欣赏你面前蝴蝶翅膀上复杂的颜色图案之前,就能注意到你左边灌木丛中的攻击性老虎。因此,视觉显著的物体具有从其周围跳出的显著特性,就像以下截图中的目标条形:
识别使这些目标跳出的视觉质量可能并不总是微不足道的。如果你在彩色图像中查看左侧图像,你可能会立即注意到图像中唯一的红色条形。然而,如果你以灰度查看这张图像,目标条形可能有点难以找到(它是从上往下数的第四条,从左往右数的第五条)。
与颜色显著性类似,在右侧的图像中有一个视觉显著的条形。尽管左侧图像中的目标条形具有独特的颜色,而右侧图像中的目标条形具有独特的方向,但我们把这两个特征结合起来,突然独特的目标条形就不再那么突出:
在前面的显示中,又有一条独特的条形,与其他所有条形都不同。然而,由于干扰物品的设计方式,几乎没有显著性来引导你找到目标条形。相反,你发现自己似乎在随机扫描图像,寻找有趣的东西。(提示:目标是图像中唯一的红色且几乎垂直的条形,从上往下数的第二行,从左往右数的第三列。)
你可能会问,这与计算机视觉有什么关系?实际上,关系很大。人工视觉系统像我们一样,会遭受信息过载的问题,只不过它们对世界的了解甚至比我们还少。 如果我们能从生物学中提取一些见解,并用它们来教我们的算法关于世界的一些知识呢?
想象一下你车上的仪表盘摄像头,它会自动聚焦于最相关的交通标志。想象一下作为野生动物观察站一部分的监控摄像头,它会自动检测和跟踪著名害羞的鸭嘴兽的出现,但会忽略其他一切。我们如何教会算法什么是重要的,什么不是?我们如何让那只鸭嘴兽“跳出”来?
因此,我们进入了傅里叶分析域。
学习傅里叶分析
要找到图像的视觉显著子区域,我们需要查看其频谱。到目前为止,我们一直在空间域处理我们的图像和视频帧,即通过分析像素或研究图像强度在不同图像子区域中的变化。然而,图像也可以在频域中表示,即通过分析像素频率或研究像素在图像中出现的频率和周期性。
通过应用傅里叶变换,可以将图像从空间域转换到频域。在频域中,我们不再以图像坐标(x,y)为思考单位。相反,我们的目标是找到图像的频谱。傅里叶的激进想法基本上可以归结为以下问题——如果任何信号或图像可以被转换成一系列圆形路径(也称为谐波),会怎样?
例如,想想彩虹。*美丽,不是吗?*在彩虹中,由许多不同颜色或光谱部分组成的白光被分散到其频谱中。在这里,当光线穿过雨滴(类似于白光穿过玻璃棱镜)时,太阳光的颜色频谱被暴露出来。傅里叶变换的目标就是要做到同样的事情——恢复阳光中包含的所有不同频谱部分。
对于任意图像,也可以实现类似的效果。与彩虹不同,彩虹中的频率对应于电磁频率,而在图像中,我们考虑的是空间频率,即像素值的空间周期性。在一个监狱牢房的图像中,你可以将空间频率视为(两个相邻监狱栏杆之间的)距离的倒数。
从这种视角转变中获得的见解非常强大。不深入细节,我们只需指出,傅里叶频谱既包含幅度也包含相位。幅度描述了图像中不同频率的数量/数量,而相位则讨论这些频率的空间位置。下面的截图显示了左边的自然图像和右边的相应的傅里叶幅度频谱(灰度版本的频谱):
右侧的幅度频谱告诉我们,在左侧图像的灰度版本中,哪些频率成分是最突出的(明亮)的。频谱被调整,使得图像的中心对应于x和y方向上的零频率。你越靠近图像的边缘,频率就越高。这个特定的频谱告诉我们,左侧的图像中有许多低频成分(集中在图像的中心附近)。
在 OpenCV 中,这个转换可以通过离散傅里叶变换(DFT)来实现。让我们构建一个执行这个任务的函数。它包括以下步骤:
- 首先,如果需要,将图像转换为灰度图。该函数接受灰度和 RGB 彩色图像,因此我们需要确保我们在单通道图像上操作:
def calc_magnitude_spectrum(img: np.ndarray):
if len(img.shape) > 2:
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
- ****我们调整图像到最佳尺寸。结果发现,DFT 的性能取决于图像大小。对于是 2 的倍数的图像大小,它通常运行得最快。因此,通常一个好的做法是在图像周围填充 0:
rows, cols = img.shape
nrows = cv2.getOptimalDFTSize(rows)
ncols = cv2.getOptimalDFTSize(cols)
frame = cv2.copyMakeBorder(img, 0, ncols-cols, 0, nrows-rows,
cv2.BORDER_CONSTANT, value=0)
- 然后我们应用 DFT。这是一个 NumPy 中的单个函数调用。结果是复数的二维矩阵:
img_dft = np.fft.fft2(img)
- 然后,将实部和虚部值转换为幅度。一个复数有一个实部和虚部(虚数)部分。为了提取幅度,我们取绝对值:
magn = np.abs(img_dft)
- 然后我们切换到对数尺度。结果发现,傅里叶系数的动态范围通常太大,无法在屏幕上显示。我们有一些低值和高值的变化,我们无法这样观察。因此,高值将全部显示为白色点,低值则显示为黑色点。
为了使用灰度值进行可视化,我们可以将我们的线性尺度转换为对数尺度:
log_magn = np.log10(magn)
- 然后我们进行象限平移,以便将频谱中心对准图像。这使得视觉检查幅度
频谱更容易:
spectrum = np.fft.fftshift(log_magn)
- 我们
返回结果以进行绘图:
return spectrum/np.max(spectrum)*255
结果可以用pyplot绘制。
现在我们已经了解了图像的傅里叶频谱以及如何计算它,让我们在下一节分析自然场景的统计信息。
理解自然场景的统计信息
人类的大脑很久以前就找到了如何专注于视觉上显著对象的方法。我们生活的自然世界有一些统计规律性,这使得它独特地自然,而不是棋盘图案或随机的公司标志。最常见的一种统计规律性可能是1/f定律。它表明自然图像集合的幅度遵循1/f分布(如下面的截图所示)。这有时也被称为尺度不变性。
一个二维图像的一维功率谱(作为频率的函数)可以用以下plot_power_spectrum函数进行可视化。我们可以使用与之前使用的幅度谱相似的配方,但我们必须确保我们正确地将二维频谱折叠到单个轴上:
- 定义函数并在必要时将图像转换为灰度图(这与之前相同):
def plot_power_spectrum(frame: np.ndarray, use_numpy_fft=True) -> None:
if len(frame.shape) > 2:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
- 将图像扩展到其最佳尺寸(这与之前相同):
rows, cols = frame.shape
nrows = cv2.getOptimalDFTSize(rows)
ncols = cv2.getOptimalDFTSize(cols)
frame = cv2.copyMakeBorder(frame, 0, ncols-cols, 0,
nrows-rows, cv2.BORDER_CONSTANT, value = 0)
- 然后我们应用 DFT 并得到对数频谱。这里我们给用户一个选项(通过
use_numpy_fft标志)来选择使用 NumPy 的或 OpenCV 的傅里叶工具:
if use_numpy_fft:
img_dft = np.fft.fft2(frame)
spectrum = np.log10(np.real(np.abs(img_dft))**2)
else:
img_dft = cv2.dft(np.float32(frame), flags=cv2.DFT_COMPLEX_OUTPUT)
spectrum = np.log10(img_dft[:, :, 0]**2 + img_dft[:, :, 1]**2)
- 我们接下来进行径向平均。这是比较棘手的部分。简单地沿 x 或 y 方向平均二维频谱是错误的。我们感兴趣的是作为频率函数的频谱,与精确的取向无关。这有时也被称为径向平均功率谱(RAPS)。
这可以通过从图像中心开始,向所有可能的(径向)方向求和所有频率的幅度来实现,从某个频率 r 到 r+dr。我们使用 NumPy 的直方图函数的 binning 功能来求和数字,并将它们累积在 histo 变量中:
L = max(frame.shape)
freqs = np.fft.fftfreq(L)[:L/2]
dists = np.sqrt(np.fft.fftfreq(frame.shape[0])
[:,np.newaxis]**2 + np.fft.fftfreq
(frame.shape[1])**2)
dcount = np.histogram(dists.ravel(), bins=freqs)[0]
histo, bins = np.histogram(dists.ravel(), bins=freqs,
weights=spectrum.ravel())
- 我们接下来绘制结果,最后,我们可以绘制
histo中的累积数字,但不要忘记用 bin 大小(dcount)进行归一化:
centers = (bins[:-1] + bins[1:]) / 2
plt.plot(centers, histo/dcount)
plt.xlabel('frequency')
plt.ylabel('log-spectrum')
plt.show()
结果是一个与频率成反比的函数。如果你想绝对确定 1/f 属性,你可以对所有的 x 值取 np.log10,并确保曲线以大致线性的方式下降。在线性 x 轴和对数 y 轴上,图表看起来如下截图所示:
这个特性非常显著。它表明,如果我们平均所有自然场景的图像的频谱(当然忽略所有使用花哨图像滤镜拍摄的图像),我们会得到一个看起来非常像前面图像的曲线。
但是,回到平静小船在 Limmat 河上的图像,单张图像又如何呢? 我们刚刚看了这张图像的功率谱,并见证了 1/f 属性。我们如何利用我们对自然图像统计的了解来告诉算法不要盯着左边的树看,而是专注于在水中缓缓行驶的船呢? 以下照片描绘了 Limmat 河上的一个场景:
这是我们真正意识到显著性真正含义的地方。
让我们看看如何在下一节中用频谱残差方法生成显著性图。
使用频谱残差方法生成显著性图
我们在图像中需要注意的事情不是遵循 1/f 法则的图像块,而是突出在平滑曲线之外的图像块,换句话说,是统计异常。这些异常被称为图像的频谱残差,对应于图像中可能有趣的块(或原对象)。显示这些统计异常为亮点的地图称为显著性图。
这里描述的频谱残差方法基于 Xiaodi Hou 和 Liqing Zhang 于 2007 年发表的原科学出版物文章《显著性检测:频谱残差方法》(Saliency Detection: A Spectral Residual Approach),IEEE Transactions on Computer Vision and Pattern Recognition (CVPR),第 1-8 页,DOI:10.1109/CVPR.2007.383267。
单个通道的显著性图可以通过_get_channel_sal_magn函数使用以下过程生成。为了基于频谱残差方法生成显著性图,我们需要分别处理输入图像的每个通道(对于灰度输入图像是单个通道,对于 RGB 输入图像是三个单独的通道):
- 通过再次使用 NumPy 的
fft模块或 OpenCV 功能来计算图像的(幅度和相位)傅里叶频谱:
def _calc_channel_sal_magn(channel: np.ndarray,
use_numpy_fft: bool = True) -> np.ndarray:
if use_numpy_fft:
img_dft = np.fft.fft2(channel)
magnitude, angle = cv2.cartToPolar(np.real(img_dft),
np.imag(img_dft))
else:
img_dft = cv2.dft(np.float32(channel),
flags=cv2.DFT_COMPLEX_OUTPUT)
magnitude, angle = cv2.cartToPolar(img_dft[:, :, 0],
img_dft[:, :, 1])
- 计算傅里叶频谱的对数幅度。我们将幅度下限裁剪到
1e-9,以防止在计算对数时除以 0:
log_ampl = np.log10(magnitude.clip(min=1e-9))
- 通过与局部平均滤波器卷积来近似典型自然图像的平均光谱:
log_ampl_blur = cv2.blur(log_amlp, (3, 3))
- 计算频谱残差。频谱残差主要包含场景的非平凡(或意外)部分:
residual = np.exp(log_ampl - log_ampl_blur)
- 通过使用逆傅里叶变换来计算显著性图,再次通过 NumPy 中的
fft模块或 OpenCV:
if use_numpy_fft:
real_part, imag_part = cv2.polarToCart(residual, angle)
img_combined = np.fft.ifft2(real_part + 1j * imag_part)
magnitude, _ = cv2.cartToPolar(np.real(img_combined),
np.imag(img_combined))
else:
img_dft[:, :, 0], img_dft[:, :, 1] =%MCEPASTEBIN% cv2.polarToCart(residual,
angle)
img_combined = cv2.idft(img_dft)
magnitude, _ = cv2.cartToPolar(img_combined[:, :, 0],
img_combined[:, :, 1])
return magnitude
单通道显著性图(幅度)由get_saliency_map使用,对于输入图像的所有通道重复此过程。如果输入图像是灰度的,我们基本上就完成了:
def get_saliency_map(frame: np.ndarray,
small_shape: Tuple[int] = (64, 64),
gauss_kernel: Tuple[int] = (5, 5),
use_numpy_fft: bool = True) -> np.ndarray:
frame_small = cv2.resize(frame, small_shape)
if len(frame.shape) == 2:
# single channelsmall_shape[1::-1]
sal = _calc_channel_sal_magn(frame, use_numpy_fft)
然而,如果输入图像具有多个通道,例如 RGB 彩色图像,我们需要分别考虑每个通道:
else:
sal = np.zeros_like(frame_small).astype(np.float32)
for c in range(frame_small.shape[2]):
small = frame_small[:, :, c]
sal[:, :, c] = _calc_channel_sal_magn(small, use_numpy_fft)
多通道图像的整体显著性由平均整体通道确定:
sal = np.mean(sal, 2)
最后,我们需要应用一些后处理,例如可选的模糊阶段,以使结果看起来更平滑:
if gauss_kernel is not None:
sal = cv2.GaussianBlur(sal, gauss_kernel, sigmaX=8, sigmaY=0)
此外,我们还需要将sal中的值平方,以突出显示作者在原始论文中概述的高显著性区域。为了显示图像,我们将它缩放回原始分辨率并归一化值,使得最大值为 1。
接下来,将sal中的值归一化,使得最大值为 1,然后平方以突出显示作者在原始论文中概述的高显著性区域,最后将其缩放回原始分辨率以显示图像:
sal = sal**2
sal = np.float32(sal)/np.max(sal)
sal = cv2.resize(sal, self.frame_orig.shape[1::-1])
sal /= np.max(sal)
return cv2.resize(sal ** 2, frame.shape[1::-1])
生成的显著性图看起来如下:
现在,我们可以清楚地看到水中的船(在左下角),它看起来是图像中最显著的子区域之一。还有其他显著的区域,例如右边的格罗斯穆斯特(你猜到这个城市了吗?)。
顺便说一句,这两个区域是图像中最显著的,这似乎是明显的、无可争议的证据,表明算法意识到苏黎世市中心教堂塔楼的数量是荒谬的,有效地阻止了它们被标记为"显著的"。
在下一节中,我们将看到如何检测场景中的原型对象。
在场景中检测原型对象
在某种意义上,显著度图已经是一个原型对象的显式表示,因为它只包含图像的有趣部分。因此,现在我们已经完成了所有艰苦的工作,剩下的工作就是将显著度图进行阈值处理,以获得原型对象图。
这里唯一要考虑的开放参数是阈值。设置阈值过低会导致将许多区域标记为原型对象,包括可能不包含任何有趣内容的区域(误报)。另一方面,设置阈值过高会忽略图像中的大多数显著区域,并可能使我们没有任何原型对象。
原始光谱残差论文的作者选择仅将那些显著度大于图像平均显著度三倍的图像区域标记为原型对象。我们给用户提供了选择,要么实现这个阈值,要么通过将输入标志use_otsu设置为True来使用Otsu 阈值:
def get_proto_objects_map(saliency: np.ndarray, use_otsu=True) -> np.ndarray:
然后,我们将显著度转换为uint8精度,以便可以传递给cv2.threshold,设置阈值参数,最后应用阈值并返回原型对象:
saliency = np.uint8(saliency * 255)
if use_otsu:
thresh_type = cv2.THRESH_OTSU
# For threshold value, simply pass zero.
thresh_value = 0
else:
thresh_type = cv2.THRESH_BINARY
thresh_value = np.mean(saliency) * 3
_, img_objects = cv2.threshold(saliency,
thresh_value, 255, thresh_type)
return img_objects
结果原型对象掩码看起来如下:
原型对象掩码随后作为跟踪算法的输入,我们将在下一节中看到。
理解平均漂移跟踪
到目前为止,我们使用了之前讨论过的显著性检测器来找到原型对象的边界框。我们可以简单地将算法应用于视频序列的每一帧,并得到对象位置的不错概念。然而,丢失的是对应信息。
想象一个繁忙场景的视频序列,比如城市中心或体育场的场景。尽管显著度图可以突出显示记录视频每一帧中的所有原型对象,但算法将无法在上一帧的原型对象和当前帧的原型对象之间建立对应关系。
此外,原型对象映射可能包含一些误报,我们需要一种方法来选择最可能对应于真实世界对象的框。以下例子中可以注意到这些误报:
注意,从原型对象映射中提取的边界框在前面的例子中至少犯了三个错误——它没有突出显示一个球员(左上角),将两个球员合并到同一个边界框中,并突出显示了一些额外的可能不是有趣(尽管视觉上显著)的对象。为了改进这些结果并保持对应关系,我们想要利用跟踪算法。
为了解决对应问题,我们可以使用之前学过的方法,例如特征匹配和光流,但在这个情况下,我们将使用平均漂移算法进行跟踪。
均值漂移是一种简单但非常有效的追踪任意对象的技巧。均值漂移背后的直觉是将感兴趣区域(例如,我们想要追踪的对象的边界框)中的像素视为从描述目标的最佳概率密度函数中采样的。
例如,考虑以下图像:
在这里,小的灰色点代表概率分布的样本。假设点越近,它们彼此越相似。直观地说,均值漂移试图做的是找到这个景观中最密集的区域,并在其周围画一个圆。算法可能最初将圆的中心放在景观中完全不密集的区域(虚线圆)。随着时间的推移,它将逐渐移动到最密集的区域(实线圆)并锚定在那里。
如果我们设计景观使其比点更有意义,我们可以使用均值漂移追踪来找到场景中的感兴趣对象。例如,如果我们为每个点分配一个值,表示对象的颜色直方图与相同大小的图像邻域的颜色直方图之间的对应关系,我们就可以在生成的点上使用均值漂移来追踪对象。通常与均值漂移追踪相关的是后一种方法。在我们的情况下,我们将简单地使用显著性图本身。
均值漂移有许多应用(如聚类或寻找概率密度函数的模态),但它也非常适合目标追踪。在 OpenCV 中,该算法在cv2.meanShift中实现,接受一个二维数组(例如,一个灰度图像,如显著性图)和窗口(在我们的情况下,我们使用对象的边界框)作为输入。它根据均值漂移算法返回窗口的新位置,如下所示:
-
它固定窗口位置。
-
它计算窗口内数据的平均值。
-
它将窗口移动到平均值并重复,直到收敛。我们可以通过指定终止条件来控制迭代方法的长度和精度。
接下来,让我们看看算法是如何追踪并在视觉上映射(使用边界框)场上的球员的。
自动追踪足球场上的所有球员
我们的目标是将显著性检测器与均值漂移追踪相结合,以自动追踪足球场上的所有球员。显著性检测器识别出的原型对象将作为均值漂移追踪器的输入。具体来说,我们将关注来自 Alfheim 数据集的视频序列,该数据集可以从home.ifi.uio.no/paalh/dataset/alfheim/免费获取。
将两个算法(显著性图和均值漂移跟踪)结合的原因是为了在不同帧之间保持对象之间的对应信息,以及去除一些误报并提高检测对象的准确性。
之前介绍过的MultiObjectTracker类及其advance_frame方法完成了这项艰苦的工作。每当有新帧到达时,就会调用advance_frame方法,并接受原型对象和显著性作为输入:
def advance_frame(self,
frame: np.ndarray,
proto_objects_map: np.ndarray,
saliency: np.ndarray) -> np.ndarray:
以下步骤包含在本方法中:
- 从
proto_objects_map创建轮廓,并找到面积大于min_object_area的所有轮廓的边界矩形。后者是使用均值漂移算法进行跟踪的候选边界框:
object_contours, _ = cv2.findContours(proto_objects_map, 1, 2)
object_boxes = [cv2.boundingRect(contour)
for contour in object_contours
if cv2.contourArea(contour) > self.min_object_area]
- 候选框可能不是在整个帧中跟踪的最佳选择。例如,在这种情况下,如果两个玩家彼此靠近,它们将导致一个单一的对象框。我们需要某种方法来选择最佳的框。我们可以考虑一些算法,该算法将分析从前一帧跟踪的框与从显著性获得的框结合起来,并推断出最可能的框。
但在这里我们将以简单的方式进行——如果显著性图中的框数量没有增加,则使用当前帧的显著性图跟踪从前一帧到当前帧的框,这些框被保存为objcect_boxes:
if len(self.object_boxes) >= len(object_boxes):
# Continue tracking with meanshift if number of salient objects
# didn't increase
object_boxes = [cv2.meanShift(saliency, box, self.term_crit)[1]
for box in self.object_boxes]
self.num_frame_tracked += 1
- 如果它确实增加了,我们将重置跟踪信息,即对象被跟踪的帧数以及对象初始中心的计算:
else:
# Otherwise restart tracking
self.num_frame_tracked = 0
self.object_initial_centers = [
(x + w / 2, y + h / 2) for (x, y, w, h) in object_boxes]
- 最后,保存框并将在帧上绘制跟踪信息:
self.object_boxes = object_boxes
return self.draw_good_boxes(copy.deepcopy(frame))
我们对移动的框感兴趣。为此,我们计算每个框从跟踪开始时的初始位置的位移。我们假设在帧上出现更大的对象应该移动得更快,因此我们在框宽度上归一化位移:
def draw_good_boxes(self, frame: np.ndarray) -> np.ndarray:
# Find total displacement length for each object
# and normalize by object size
displacements = [((x + w / 2 - cx)**2 + (y + w / 2 - cy)**2)**0.5 / w
for (x, y, w, h), (cx, cy)
in zip(self.object_boxes, self.object_initial_centers)]
接下来,我们绘制框及其数量,这些框的平均每帧位移(或速度)大于我们在跟踪器初始化时指定的值。为了不在跟踪的第一帧上除以 0,我们添加了一个小的数值:
for (x, y, w, h), displacement, i in zip(
self.object_boxes, displacements, itertools.count()):
# Draw only those which have some avarage speed
if displacement / (self.num_frame_tracked + 0.01) > self.min_speed_per_pix:
cv2.rectangle(frame, (x, y), (x + w, y + h),
(0, 255, 0), 2)
cv2.putText(frame, str(i), (x, y),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255))
return frame
现在你已经理解了如何使用均值漂移算法实现跟踪。这只是众多跟踪方法中的一种。当感兴趣的对象直接朝向相机快速改变大小时,均值漂移跟踪可能会特别失败。
对于此类情况,OpenCV 有一个不同的算法,cv2.CamShift,它还考虑了旋转和尺寸的变化,其中CAMShift代表连续自适应均值漂移。此外,OpenCV 提供了一系列可用的跟踪器,可以直接使用,被称为OpenCV 跟踪 API。让我们在下一节中了解它们。
了解 OpenCV 跟踪 API
我们已经将均值漂移算法应用于显著性图以跟踪显著对象。当然,世界上并非所有对象都是显著的,因此我们不能使用那种方法来跟踪任何对象。如前所述,我们还可以结合使用 HSV 直方图和均值漂移算法来跟踪对象。后者不需要显著性图——如果选择了区域,那种方法将尝试在后续帧中跟踪所选对象。
在本节中,我们将创建一个脚本,该脚本能够使用 OpenCV 中可用的跟踪算法在整个视频中跟踪一个对象。所有这些算法都有相同的 API,并统称为 OpenCV 跟踪 API。这些算法跟踪单个对象——一旦向算法提供了初始边界框,它将尝试在整个后续帧中维持该框的新位置。当然,也可以通过为每个对象创建一个新的跟踪器来跟踪场景中的多个对象。
首先,我们导入我们将使用的库并定义我们的常量:
import argparse
import time
import cv2
import numpy as np
# Define Constants
FONT = cv2.FONT_HERSHEY_SIMPLEX
GREEN = (20, 200, 20)
RED = (20, 20, 255)
OpenCV 目前有八个内置跟踪器。我们定义了一个所有跟踪器构造函数的映射:
trackers = {
'BOOSTING': cv2.TrackerBoosting_create,
'MIL': cv2.TrackerMIL_create,
'KCF': cv2.TrackerKCF_create,
'TLD': cv2.TrackerTLD_create,
'MEDIANFLOW': cv2.TrackerMedianFlow_create,
'GOTURN': cv2.TrackerGOTURN_create,
'MOSSE': cv2.TrackerMOSSE_create,
'CSRT': cv2.TrackerCSRT_create
}
我们的脚本将能够接受跟踪器的名称和视频的路径作为参数。为了实现这一点,我们创建参数,设置它们的默认值,并使用之前导入的argparse模块解析它们:
# Parse arguments
parser = argparse.ArgumentParser(description='Tracking API demo.')
parser.add_argument(
'--tracker',
default="KCF",
help=f"One of {trackers.keys()}")
parser.add_argument(
'--video',
help="Video file to use",
default="videos/test.mp4")
args = parser.parse_args()
然后,我们确保存在这样的跟踪器,并尝试从指定的视频中读取第一帧。
现在我们已经设置了脚本并可以接受参数,下一步要做的是实例化跟踪器:
- 首先,使脚本不区分大小写并检查传递的跟踪器是否存在是一个好主意:
tracker_name = args.tracker.upper()
assert tracker_name in trackers, f"Tracker should be one of {trackers.keys()}"
- 打开视频并读取第一
帧。然后,如果无法读取视频,则中断脚本:
video = cv2.VideoCapture(args.video)
assert video.isOpened(), "Could not open video"
ok, frame = video.read()
assert ok, "Video file is not readable"
- 选择一个感兴趣的区域(使用边界框)以在整个视频中跟踪。OpenCV 为此提供了一个基于用户界面的实现:
bbox = cv2.selectROI(frame, False)
一旦调用此方法,将出现一个界面,您可以在其中选择一个框。一旦按下Enter键,就会返回所选框的坐标。
- 使用第一帧和选定的边界框启动跟踪器:
tracker = trackers[tracker_name]()
tracker.init(frame, bbox)
现在我们有一个跟踪器的实例,它已经使用第一帧和选定的感兴趣边界框启动。我们使用下一帧更新跟踪器以找到对象在边界框中的新位置。我们还使用time模块估计所选跟踪算法的每秒帧数(FPS):
for ok, frame in iter(video.read, (False, None)):
# Time in seconds
start_time = time.time()
# Update tracker
ok, bbox = tracker.update(frame)
# Calcurlate FPS
fps = 1 / (time.time() - start_time)
所有计算都在这一点上完成。现在我们展示每个迭代的计算结果:
if ok:
# Draw bounding box
x, y, w, h = np.array(bbox, dtype=np.int)
cv2.rectangle(frame, (x, y), (x + w, y + w), GREEN, 2, 1)
else:
# Tracking failure
cv2.putText(frame, "Tracking failed", (100, 80), FONT, 0.7, RED, 2)
cv2.putText(frame, f"{tracker_name} Tracker",
(100, 20), FONT, 0.7, GREEN, 2)
cv2.putText(frame, f"FPS : {fps:.0f}", (100, 50), FONT, 0.7, GREEN, 2)
cv2.imshow("Tracking", frame)
# Exit if ESC pressed
if cv2.waitKey(1) & 0xff == 27:
break
如果算法返回了边界框,我们在帧上绘制该框,否则,我们说明跟踪失败,这意味着所选算法未能找到当前帧中的对象。此外,我们在帧上键入跟踪器的名称和当前 FPS。
你可以用不同的算法在不同的视频上运行这个脚本,以查看算法的行为,特别是它们如何处理遮挡、快速移动的物体以及外观变化很大的物体。尝试了算法之后,你也可能对阅读算法的原始论文感兴趣,以了解实现细节。
为了使用这些算法跟踪多个物体,OpenCV 有一个方便的包装类,它结合了多个跟踪器实例并同时更新它们。为了使用它,首先,我们创建该类的实例:
multiTracker = cv2.MultiTracker_create()
接下来,对于每个感兴趣的边界框,创建一个新的跟踪器(在本例中为 MIL 跟踪器)并将其添加到multiTracker对象中:
for bbox in bboxes:
multiTracker.add(cv2.TrackerMIL_create(), frame, bbox)
最后,通过用新帧更新multiTracker对象,我们获得了边界框的新位置:
success, boxes = multiTracker.update(frame)
作为练习,你可能想要用本章介绍的一种跟踪器替换应用程序中用于跟踪显著物体的 mean-shift 跟踪。为了做到这一点,你可以使用multiTracker与其中一个跟踪器一起更新原型物体的边界框位置。
整合所有内容
您可以在以下一组屏幕截图中看到我们应用程序的结果:
在整个视频序列中,算法能够通过使用 mean-shift 跟踪识别球员的位置,并逐帧成功跟踪他们。
摘要
在本章中,我们探索了一种标记视觉场景中可能有趣的物体方法,即使它们的形状和数量未知。我们使用傅里叶分析探索了自然图像统计,并实现了一种从自然场景中提取视觉显著区域的方法。此外,我们将显著性检测器的输出与跟踪算法相结合,以跟踪足球比赛视频序列中未知形状和数量的多个物体。
我们介绍了 OpenCV 中可用的其他更复杂的跟踪算法,你可以用它们替换应用程序中的 mean-shift 跟踪,甚至创建自己的应用程序。当然,也可以用之前研究过的技术,如特征匹配或光流,来替换 mean-shift 跟踪器。
在下一章中,我们将进入迷人的机器学习领域,这将使我们能够构建更强大的物体描述符。具体来说,我们将专注于图像中街道标志的检测(位置)和识别(内容)。这将使我们能够训练一个分类器,它可以用于您汽车仪表盘上的摄像头,并使我们熟悉机器学习和物体识别的重要概念。
数据集归属
“足球视频和球员位置数据集,” S. A. Pettersen, D. Johansen, H. Johansen, V. Berg-Johansen, V. R. Gaddam, A. Mortensen, R. Langseth, C. Griwodz, H. K. Stensland, 和 P. Halvorsen,在 2014 年 3 月新加坡国际多媒体系统会议(MMSys)论文集中,第 18-23 页。
第七章:学习识别交通标志
我们之前研究了如何通过关键点和特征来描述对象,以及如何在两个不同图像中找到同一物理对象的对应点。然而,我们之前的方法在识别现实世界中的对象并将它们分配到概念类别方面相当有限。例如,在第二章,使用 Kinect 深度传感器进行手势识别中,图像中所需的对象是一只手,并且它必须放置在屏幕中央。如果我们可以去除这些限制会更好吗?
本章的目标是训练一个多类****分类器来识别交通标志。在本章中,我们将涵盖以下概念:
-
规划应用
-
监督学习概念的概述
-
理解德国交通标志识别基准数据集(GTSRB)
-
了解数据集特征提取
-
了解支持向量机(SVMs)
-
整合所有内容
-
使用神经网络提高结果
在本章中,你将学习如何将机器学习模型应用于现实世界的问题。你将学习如何使用现有的数据集来训练模型。你还将学习如何使用 SVMs 进行多类分类,以及如何使用 OpenCV 提供的机器学习算法进行训练、测试和改进,以实现现实世界任务。
我们将训练一个 SVM 来识别各种交通标志。尽管 SVMs 是二元分类器(也就是说,它们最多可以学习两个类别——正面和负面,动物和非动物等),但它们可以被扩展用于多类分类。为了实现良好的分类性能,我们将探索多个颜色空间,以及方向梯度直方图(HOG)特征。最终结果将是一个能够从数据集中区分 40 多种不同标志的分类器,具有非常高的准确性。
学习机器学习的基础对于未来当你想要使你的视觉相关应用更加智能时将非常有用。本章将教你机器学习的基础知识,后续章节将在此基础上展开。
开始学习
GTSRB 数据集可以从benchmark.ini.rub.de/?section=gtsrb&subsection=dataset(见数据集归属部分以获取归属详情)免费获取。
你可以在我们的 GitHub 仓库中找到本章中展示的代码:github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter7。
规划应用
为了得到这样一个多类分类器(可以区分数据集中超过 40 个不同的标志),我们需要执行以下步骤:
-
预处理数据集:我们需要一种方法来加载我们的数据集,提取感兴趣的区域,并将数据分为适当的训练集和测试集。
-
提取特征:原始像素值可能不是数据最有信息量的表示。我们需要一种方法从数据中提取有意义的特征,例如基于不同颜色空间和 HOG 的特征。
-
训练分类器:我们将使用一种一对多策略在训练数据上训练多类分类器。
-
评估分类器:我们将通过计算不同的性能指标来评估训练的集成分类器的质量,例如准确率、精确度和召回率。
我们将在接下来的章节中详细讨论所有这些步骤。
最终的应用程序将解析数据集,训练集成分类器,评估其分类性能,并可视化结果。这需要以下组件:
-
main:主函数例程(在chapter7.py中)是启动应用程序所必需的。 -
datasets.gtsrb:这是一个解析 GTSRB 数据集的脚本。此脚本包含以下函数:-
load_data:此函数用于加载 GTSRB 数据集,提取所需特征,并将数据分为训练集和测试集。 -
*_featurize,hog_featurize:这些函数被传递给load_data以从数据集中提取所需特征。以下是一些示例函数:-
gray_featurize:这是一个基于灰度像素值创建特征的函数。 -
surf_featurize:这是一个基于加速鲁棒特征(SURF)创建特征的函数。
-
-
分类性能将基于准确率、精确率和召回率进行判断。以下章节将详细解释所有这些术语。
监督学习概念的概述
机器学习的一个重要子领域是监督学习。在监督学习中,我们试图从一组标记数据中学习——也就是说,每个数据样本都有一个期望的目标值或真实输出值。这些目标值可能对应于函数的连续输出(例如y = sin(x)中的y),或者对应于更抽象和离散的类别(例如猫或狗)。
监督学习算法使用已经标记的训练数据,对其进行分析,并从特征到标签产生一个推断函数,该函数可以用于映射新的示例。理想情况下,推断算法将很好地泛化,并为新数据给出正确的目标值。
我们将监督学习任务分为两类:
-
如果我们处理的是连续输出(例如,降雨的概率),这个过程被称为回归。
-
如果我们处理的是离散输出(例如,动物的物种),这个过程被称为分类。
在本章中,我们专注于对 GTSRB 数据集图像进行标签的分类问题,我们将使用一种称为 SVM 的算法来推断图像与其标签之间的映射函数。
让我们先了解机器学习是如何赋予机器像人类一样学习的能力的。这里有一个提示——我们训练它们。
训练过程
例如,我们可能想要学习猫和狗的外观。为了使这个任务成为监督学习任务,首先,我们必须将其作为一个具有分类答案或实值答案的问题来提出。
这里有一些示例问题:
-
给定的图片中展示了哪种动物?
-
图片中有没有猫?
-
图片中有没有狗?
之后,我们必须收集一个与其对应正确答案的示例图片——训练数据。
然后,我们必须选择一个学习算法(学习者)并开始以某种方式调整其参数(学习算法),以便当学习者面对训练数据中的数据时,可以给出正确的答案。
我们重复这个过程,直到我们对学习者的性能或分数(可能是准确率、精确率或某些其他成本函数)满意为止。如果我们不满意,我们将改变学习者的参数,以随着时间的推移提高分数。
这个过程在以下截图中有概述:
从之前的截图可以看出,训练数据由一组特征表示。对于现实生活中的分类任务,这些特征很少是图像的原始像素值,因为这些往往不能很好地代表数据。通常,寻找最能描述数据的特征的过程是整个学习任务(也称为特征选择或特征工程)的一个基本部分。
这就是为什么在考虑设置分类器之前,深入研究你正在处理的训练集的统计和外观总是一个好主意。
如你所知,有一个完整的学习者、成本函数和学习算法的动物园。这些构成了学习过程的核心。学习者(例如,线性分类器或 SVM)定义了如何将输入特征转换为评分函数(例如,均方误差),而学习算法(例如,梯度下降)定义了学习者的参数如何随时间变化。
在分类任务中的训练过程也可以被视为寻找一个合适的决策边界,这是一个将训练集最好地分成两个子集的线,每个类别一个。例如,考虑只有两个特征(x 和 y 值)以及相应的类别标签(正类(+),或负类(–))的训练样本。
在训练过程的开始,分类器试图画一条线来区分所有正样本和所有负样本。随着训练的进行,分类器看到了越来越多的数据样本。这些样本被用来更新决策边界,如下面的截图所示:
与这个简单的插图相比,SVM 试图在高维空间中找到最优的决策边界,因此决策边界可能比直线更复杂。
我们现在继续了解测试过程。
测试过程
为了使训练好的分类器具有任何实际价值,我们需要知道它在应用于从未见过的数据样本(也称为泛化)时的表现。为了坚持我们之前展示的例子,我们想知道当我们向它展示一只猫或狗的以前未见过的图片时,分类器预测的是哪个类别。
更普遍地说,我们想知道在以下截图中的问号符号对应哪个类别,基于我们在训练阶段学习到的决策边界:
从前面的截图,你可以看到这是一个棘手的问题。如果问号的位置更偏向左边,我们就可以确定相应的类别标签是+。
然而,在这种情况下,有几种方式可以绘制决策边界,使得所有的加号都在它的左边,所有的减号都在它的右边,如下面的截图所示:
因此,问号的标签取决于在训练期间推导出的确切决策边界。如果前面截图中的问号实际上是减号,那么只有一个决策边界(最左边的)会得到正确的答案。一个常见的问题是训练可能导致一个在训练集上工作得太好的决策边界(也称为过拟合),但在应用于未见数据时犯了很多错误。
在那种情况下,学习者很可能会在决策边界上印刻了特定于训练集的细节,而不是揭示关于数据的一般属性,这些属性也可能适用于未见过的数据。
减少过拟合影响的一种常见技术被称为正则化。
简而言之:问题总是回到找到最佳分割边界,这个边界不仅分割了训练集,也分割了测试集。这就是为什么分类器最重要的指标是其泛化性能(即它在训练阶段未见过的数据上的分类效果)。
为了将我们的分类器应用于交通标志识别,我们需要一个合适的数据集。一个好的选择可能是 GTSRB 数据集。让我们接下来了解一下它。
理解 GTSRB 数据集
GTSRB 数据集包含超过 50,000 张属于 43 个类别的交通标志图片。
该数据集在 2011 年国际神经网络联合会议(IJCNN)期间被专业人士用于分类挑战。GTSRB 数据集非常适合我们的目的,因为它规模大、组织有序、开源且已标注。
尽管实际的交通标志不一定是一个正方形,也不一定位于每个图像的中心,但数据集附带了一个标注文件,指定了每个标志的边界框。
在进行任何类型的机器学习之前,通常一个好的想法是了解数据集、其质量和其挑战。一些好的想法包括手动浏览数据,了解其一些特征,阅读数据描述(如果页面上有)以了解哪些模型可能最适合,等等。
在这里,我们展示了data/gtsrb.py中的一个片段,该片段加载并绘制了训练数据集的随机 15 个样本,并重复 100 次,这样您就可以浏览数据:
if __name__ == '__main__':
train_data, train_labels = load_training_data(labels=None)
np.random.seed(75)
for _ in range(100):
indices = np.arange(len(train_data))
np.random.shuffle(indices)
for r in range(3):
for c in range(5):
i = 5 * r + c
ax = plt.subplot(3, 5, 1 + i)
sample = train_data[indices[i]]
ax.imshow(cv2.resize(sample, (32, 32)), cmap=cm.Greys_r)
ax.axis('off')
plt.tight_layout()
plt.show()
np.random.seed(np.random.randint(len(indices)))
另一个不错的策略是绘制每个 43 个类别中的 15 个样本,看看图像如何随给定类别变化。以下截图显示了该数据集的一些示例:
即使从这个小的数据样本来看,也立即清楚这是一个对任何类型的分类器都具有挑战性的数据集。标志的外观会根据观察角度(方向)、观察距离(模糊度)和光照条件(阴影和亮点)发生剧烈变化。
对于其中一些标志——例如第三行的第二个标志——即使是人类(至少对我来说),也很难立即说出正确的类别标签。我们作为机器学习的追求者真是件好事!
让我们现在学习如何解析数据集,以便将其转换为适合 SVM 用于训练的格式。
解析数据集
GTSRB 数据集包含 21 个文件,我们可以下载。我们选择使用原始数据以使其更具教育意义,并下载官方训练数据——图像和标注 (GTSRB_Final_Training_Images.zip) 用于训练,以及用于IJCNN 2011 比赛的官方训练数据集——图像和标注 (GTSRB-Training_fixed.zip) 用于评分。
以下截图显示了数据集的文件:
我们选择分别下载训练数据和测试数据,而不是从其中一个数据集中构建自己的训练/测试数据,因为在探索数据后,通常会有 30 张来自不同距离的相同标志的图像看起来非常相似。将这些 30 张图像放入不同的数据集中将扭曲问题,并导致结果极好,尽管我们的模型可能无法很好地泛化。
以下代码是一个从哥本哈根大学数据档案下载数据的函数:
ARCHIVE_PATH = 'https://sid.erda.dk/public/archives/daaeac0d7ce1152aea9b61d9f1e19370/'
def _download(filename, *, md5sum=None):
write_path = Path(__file__).parent / filename
if write_path.exists() and _md5sum_matches(write_path, md5sum):
return write_path
response = requests.get(f'{ARCHIVE_PATH}/{filename}')
response.raise_for_status()
with open(write_path, 'wb') as outfile:
outfile.write(response.content)
return write_path
之前的代码接受一个文件名(您可以从之前的屏幕截图中看到文件及其名称),并检查该文件是否已存在(如果提供了md5sum,则检查是否匹配),这样可以节省大量带宽和时间,无需反复下载文件。然后,它下载文件并将其存储在包含代码的同一目录中。
标注格式可以在benchmark.ini.rub.de/?section=gtsrb&subsection=dataset#Annotationformat查看。
下载文件后,我们编写一个函数,使用与数据一起提供的标注格式解压缩并提取数据,如下所示:
- 首先,我们打开下载的
.zip文件(这可能是指训练数据或测试数据),我们遍历所有文件,只打开包含对应类别中每个图像目标信息的.csv文件。这在上面的代码中显示如下:
def _load_data(filepath, labels):
data, targets = [], []
with ZipFile(filepath) as data_zip:
for path in data_zip.namelist():
if not path.endswith('.csv'):
continue
# Only iterate over annotations files
...
- 然后,我们检查图像的标签是否在我们感兴趣的
labels数组中。然后,我们创建一个csv.reader,我们将使用它来遍历.csv文件内容,如下所示:
....
# Only iterate over annotations files
*dir_path, csv_filename = path.split('/')
label_str = dir_path[-1]
if labels is not None and int(label_str) not in labels:
continue
with data_zip.open(path, 'r') as csvfile:
reader = csv.DictReader(TextIOWrapper(csvfile), delimiter=';')
for img_info in reader:
...
- 文件的每一行都包含一个数据样本的标注。因此,我们提取图像路径,读取数据,并将其转换为 NumPy 数组。通常,这些样本中的对象并不是完美切割的,而是嵌入在其周围环境中。我们使用存档中提供的边界框信息来切割图像,每个标签使用一个
.csv文件。在下面的代码中,我们将符号添加到data中,并将标签添加到targets中:
img_path = '/'.join([*dir_path, img_info['Filename']])
raw_data = data_zip.read(img_path)
img = cv2.imdecode(np.frombuffer(raw_data, np.uint8), 1)
x1, y1 = np.int(img_info['Roi.X1']),
np.int(img_info['Roi.Y1'])
x2, y2 = np.int(img_info['Roi.X2']),
np.int(img_info['Roi.Y2'])
data.append(img[y1: y2, x1: x2])
targets.append(np.int(img_info['ClassId']))
通常,执行某种形式的特征提取是可取的,因为原始图像数据很少是数据的最佳描述。我们将把这个任务推迟到另一个函数,我们将在稍后详细讨论。
如前一小节所述,将我们用于训练分类器的样本与用于测试的样本分开至关重要。为此,以下代码片段显示我们有两个不同的函数,用于下载训练数据和测试数据并将它们加载到内存中:
def load_training_data(labels):
filepath = _download('GTSRB-Training_fixed.zip',
md5sum='513f3c79a4c5141765e10e952eaa2478')
return _load_data(filepath, labels)
def load_test_data(labels):
filepath = _download('GTSRB_Online-Test-Images-Sorted.zip',
md5sum='b7bba7dad2a4dc4bc54d6ba2716d163b')
return _load_data(filepath, labels)
现在我们知道了如何将图像转换为 NumPy 矩阵,我们可以继续到更有趣的部分,即我们可以将数据输入到 SVM 中并对其进行训练以进行预测。所以,让我们继续到下一节,该节涵盖了特征提取。
学习数据集特征提取
很可能,原始像素值不是表示数据的最佳方式,正如我们在第三章,通过特征匹配和透视变换寻找对象中已经意识到的,我们需要从数据中推导出一个可测量的属性,这个属性对分类更有信息量。
然而,通常不清楚哪些特征会表现最好。相反,通常需要尝试不同的特征,这些特征是实践者认为合适的。毕竟,特征的选择可能强烈依赖于要分析的特定数据集或要执行的特定分类任务。
例如,如果你必须区分停车标志和警告标志,那么最显著的特征可能是标志的形状或颜色方案。然而,如果你必须区分两个警告标志,那么颜色和形状将完全帮不上忙,你需要想出更复杂一些的特征。
为了展示特征选择如何影响分类性能,我们将关注以下内容:
- 一些简单的颜色变换(例如灰度;红色、绿色、蓝色(RGB);以及色调、饱和度、亮度(HSV)):基于灰度图像的分类将为我们提供分类器的基准性能。RGB 可能会因为某些交通标志独特的颜色方案而提供略好的性能。
预期 HSV 会有更好的性能。这是因为它比 RGB 更稳健地表示颜色。交通标志通常具有非常明亮、饱和的颜色,这些颜色(理想情况下)与周围环境非常不同。
-
SURF:到现在为止,这应该对你来说非常熟悉了。我们之前已经将 SURF 识别为从图像中提取有意义特征的一种高效且稳健的方法。那么,我们能否利用这种技术在分类任务中占得先机?
-
HOG:这是本章要考虑的最先进的特征描述符。该技术沿着图像上密集排列的网格计算梯度方向的出现次数,非常适合与 SVMs 一起使用。
特征提取是通过 data/process.py 文件中的函数完成的,我们将调用不同的函数来构建和比较不同的特征。
这里有一个很好的蓝图,如果你遵循它,将能够轻松地编写自己的特征化函数,并使用我们的代码,比较你的 your_featurize 函数是否能产生更好的结果:
def your_featurize(data: List[np.ndarry], **kwargs) -> np.ndarray:
...
_featurize 函数接收一个图像列表并返回一个矩阵(作为二维 np.ndarray),其中每一行代表一个新的样本,每一列代表一个特征。
对于以下大多数特征,我们将使用 OpenCV 中(已经合适的)默认参数。然而,这些值并不是一成不变的,即使在现实世界的分类任务中,也经常需要在一个称为超参数探索的过程中,在特征提取和特征学习参数的可能值范围内进行搜索。
现在我们知道了我们在做什么,让我们看看一些基于前几节概念并添加了一些新概念的特性化函数。
理解常见的预处理
在我们查看我们得到的结果之前,让我们花时间看看在机器学习任务之前几乎总是应用于任何数据的两种最常见的前处理形式——即,均值减法和归一化。
均值减法是最常见的预处理形式(有时也称为零中心化或去均值),其中计算数据集中所有样本的每个特征维度的平均值。然后将这个特征维度的平均值从数据集中的每个样本中减去。你可以将这个过程想象为将数据的云中心化在原点。
归一化是指对数据维度进行缩放,使它们大致具有相同的尺度。这可以通过将每个维度除以其标准差(一旦它已经被零中心化)或缩放每个维度使其位于[-1, 1]的范围内来实现。
只有在你有理由相信不同的输入特征有不同的尺度或单位时,才适用这一步骤。在图像的情况下,像素的相对尺度已经大致相等(并且在[0, 255]的范围内),因此执行这个额外的预处理步骤并不是严格必要的。
带着这两个概念,让我们来看看我们的特征提取器。
了解灰度特征
最容易提取的特征可能是每个像素的灰度值。通常,灰度值并不非常能说明它们所描述的数据,但在这里我们将包括它们以供说明之用(即,为了达到基线性能)。
对于输入集中的每个图像,我们将执行以下步骤:
- 将所有图像调整到相同的大小(通常是更小的尺寸)。我们使用
scale_size=(32, 32)来确保我们不会使图像太小。同时,我们希望我们的数据足够小,以便在我们的个人电脑上处理。我们可以通过以下代码来实现:
resized_images = (cv2.resize(x, scale_size) for x in data)
- 将图像转换为灰度(值仍在 0-255 范围内),如下所示:
gray_data = (cv2.cvtColor(x, cv2.COLOR_BGR2GRAY) for x in resized_images)
- 将每个图像转换为具有(0, 1)范围内的像素值并展平,因此对于每个图像,我们有一个大小为
1024的向量,如下所示:
scaled_data = (np.array(x).astype(np.float32).flatten() / 255 for x in gray_data)
- 从展平向量的平均像素值中减去,如下所示:
return np.vstack([x - x.mean() for x in scaled_data])
我们使用返回的矩阵作为机器学习算法的训练数据。
现在,让我们看看另一个例子——如果我们也使用颜色中的信息会怎样?
理解色彩空间
或者,你可能发现颜色包含一些原始灰度值无法捕捉的信息。交通标志通常有独特的色彩方案,这可能表明它试图传达的信息(例如,红色表示停车标志和禁止行为;绿色表示信息标志;等等)。我们可以选择使用 RGB 图像作为输入,但在我们的情况下,我们不必做任何事情,因为数据集已经是 RGB 的。
然而,即使是 RGB 可能也不够有信息量。例如,在晴朗的白天,一个停车标志可能非常明亮和清晰,但在雨天或雾天,其颜色可能看起来要暗淡得多。更好的选择可能是 HSV 颜色空间,它使用色调、饱和度和亮度(或亮度)来表示颜色。
在这个颜色空间中,交通标志的最显著特征可能是色调(对颜色或色相的更感知相关的描述),它提供了区分不同标志类型颜色方案的能力。然而,饱和度和亮度可能同样重要,因为交通标志倾向于使用相对明亮和饱和的颜色,这些颜色在自然场景中通常不会出现(即,它们的周围)。
在 OpenCV 中,将图像转换为 HSV 颜色空间只需要一个cv2.cvtColor调用,如下面的代码所示:
hsv_data = (cv2.cvtColor(x, cv2.COLOR_BGR2HSV) for x in resized_images)
因此,总结一下,特征化几乎与灰度特征相同。对于每张图像,我们执行以下四个步骤:
-
将所有图像调整到相同(通常是较小的)大小。
-
将图像转换为 HSV(值在 0-255 范围内)。
-
将每个图像转换为具有(0,1)范围内的像素值,并将其展平。
-
从展平向量的平均像素值中减去。
现在,让我们尝试看一个使用 SURF 的更复杂的特征提取器的例子。
使用 SURF 描述符
但是等等!在第三章“通过特征匹配和透视变换查找对象”中,你了解到 SURF 描述符是描述图像独立于尺度或旋转的最佳和最鲁棒的方法之一。我们能否利用这项技术在分类任务中占得优势?
很高兴你问了!为了使这起作用,我们需要调整 SURF,使其为每张图像返回固定数量的特征。默认情况下,SURF 描述符仅应用于图像中的一小部分有趣的关键点,这些关键点的数量可能因图像而异。这对于我们的当前目的来说是不合适的,因为我们想要在每个数据样本中找到固定数量的特征值。
相反,我们需要将 SURF 应用于图像上铺设的固定密集网格,为此我们创建了一个包含所有像素的关键点数组,如下面的代码块所示:
def surf_featurize(data, *, scale_size=(16, 16)):
all_kp = [cv2.KeyPoint(float(x), float(y), 1)
for x, y in itertools.product(range(scale_size[0]),
range(scale_size[1]))]
然后,我们可以为网格上的每个点获得 SURF 描述符,并将该数据样本附加到我们的特征矩阵中。我们像之前一样,使用hessianThreshold值为400初始化 SURF,如下所示:
surf = cv2.xfeatures2d_SURF.create(hessianThreshold=400)
通过以下代码可以获得关键点和描述符:
kp_des = (surf.compute(x, kp) for x in data)
因为surf.compute有两个输出参数,所以kp_des实际上将是关键点和描述符的连接。kp_des数组中的第二个元素是我们关心的描述符。
我们从每个数据样本中选择前num_surf_features个,并将其作为图像的特征返回,如下所示:
return np.array([d.flatten()[:num_surf_features]
for _, d in kp_des]).astype(np.float32)
现在,让我们来看一个在社区中非常流行的概念——HOG。
映射 HOG 描述符
需要考虑的最后一种特征描述符是 HOG。之前的研究表明,HOG 特征与 SVMs 结合使用时效果非常好,尤其是在应用于行人识别等任务时。
HOG 特征背后的基本思想是,图像中对象的局部形状和外观可以通过边缘方向的分布来描述。图像被分成小的连通区域,在这些区域内,编译了梯度方向(或边缘方向)的直方图。
下面的截图显示了图片中的一个区域的直方图。角度不是方向性的;这就是为什么范围是(-180,180):
如您所见,它在水平方向上有许多边缘方向(+180 度和-180 度左右的角),因此这似乎是一个很好的特征,尤其是在我们处理箭头和线条时。
然后,通过连接不同的直方图来组装描述符。为了提高性能,局部直方图可以进行对比度归一化,这有助于提高对光照和阴影变化的鲁棒性。您可以看到为什么这种预处理可能非常适合在不同视角和光照条件下识别交通标志。
通过cv2.HOGDescriptor在 OpenCV 中可以方便地访问 HOG 描述符,它接受检测窗口大小(32 x 32)、块大小(16 x 16)、单元格大小(8 x 8)和单元格步长(8 x 8)作为输入参数。对于这些单元格中的每一个,HOG 描述符然后使用九个桶计算 HOG,如下所示:
def hog_featurize(data, *, scale_size=(32, 32)):
block_size = (scale_size[0] // 2, scale_size[1] // 2)
block_stride = (scale_size[0] // 4, scale_size[1] // 4)
cell_size = block_stride
hog = cv2.HOGDescriptor(scale_size, block_size, block_stride,
cell_size, 9)
resized_images = (cv2.resize(x, scale_size) for x in data)
return np.array([hog.compute(x).flatten() for x in resized_images])
将 HOG 描述符应用于每个数据样本就像调用hog.compute一样简单。
在提取了我们想要的全部特征之后,我们为每张图像返回一个扁平化的列表。
现在,我们终于准备好在预处理后的数据集上训练分类器了。所以,让我们继续到 SVM。
学习 SVMs
SVM 是一种用于二元分类(和回归)的学习器,它试图通过最大化两个类别之间的间隔来分离来自两个不同类别标签的示例。
让我们回到正负数据样本的例子,每个样本恰好有两个特征(x 和 y)和两个可能的决策边界,如下所示:
这两个决策边界都能完成任务。它们将所有正负样本分割开来,没有错误分类。然而,其中一个看起来直观上更好。我们如何量化“更好”,从而学习“最佳”参数设置?
这就是 SVMs 发挥作用的地方。SVMs 也被称为最大间隔分类器,因为它们可以用来做到这一点——定义决策边界,使得两个云团(+和-)尽可能远;也就是说,尽可能远离决策边界。
对于前面的例子,SVM 会在类别边缘(以下截图中的虚线)上的数据点找到两条平行线,然后将通过边缘中心的线作为决策边界(以下截图中的粗黑线):
结果表明,为了找到最大间隔,只需要考虑位于类别边缘的数据点。这些点有时也被称为支持向量。
除了执行线性分类(即决策边界是直线的情况)之外,SVM 还可以使用所谓的核技巧执行非线性分类,隐式地将它们的输入映射到高维特征空间。
现在,让我们看看我们如何将这个二元分类器转换成一个更适合我们试图解决的 43 个类别分类问题的多类分类器。
使用 SVM 进行多类分类
与一些分类算法(如神经网络)自然适用于使用多个类别不同,SVM 本质上是二元分类器。然而,它们可以被转换成多类分类器。
在这里,我们将考虑两种不同的策略:
- 一对多:
一对多策略涉及为每个类别训练一个单独的分类器,该类别的样本作为正样本,所有其他样本作为负样本。
对于k个类别,这种策略因此需要训练k个不同的 SVM。在测试期间,所有分类器可以通过预测一个未见样本属于其类别来表示一个+1的投票。
最后,一个未见样本被集成分类器归类为获得最多投票的类别。通常,这种策略会与置信度分数结合使用,而不是预测标签,这样最终可以选取置信度分数最高的类别。
- 一对一:
一对一策略涉及为每个类别对训练一个单独的分类器,第一个类别的样本作为正样本,第二个类别的样本作为负样本。对于k个类别,这种策略需要训练k*(k-1)/2个分类器。
然而,分类器必须解决一个显著更简单的问题,因此在考虑使用哪种策略时存在权衡。在测试期间,所有分类器可以为第一个或第二个类别表达一个+1的投票。最后,一个未见样本被集成分类器归类为获得最多投票的类别。
通常,除非你真的想深入研究算法并从你的模型中榨取最后一丝性能,否则你不需要编写自己的分类算法。幸运的是,OpenCV 已经内置了一个良好的机器学习工具包,我们将在本章中使用。OpenCV 使用一对多方法,我们将重点关注这种方法。
现在,让我们动手实践,看看我们如何使用 OpenCV 编写代码并获取一些实际结果。
训练 SVM
我们将把训练方法写入一个单独的函数中;如果我们以后想更改我们的训练方法,这是一个好的实践。首先,我们定义函数的签名,如下所示:
def train(training_features: np.ndarray, training_labels: np.ndarray):
因此,我们想要一个函数,它接受两个参数——training_features 和 training_labels——以及与每个特征对应的正确答案。因此,第一个参数将是一个二维 NumPy 数组的矩阵形式,第二个参数将是一个一维 NumPy 数组。
然后,函数将返回一个对象,该对象应该有一个 predict 方法,该方法接受新的未见数据并将其标记。所以,让我们开始,看看我们如何使用 OpenCV 训练 SVM。
我们将我们的函数命名为 train_one_vs_all_SVM,并执行以下操作:
- 使用
cv2.ml.SVM_create实例化 SVM 类,它使用一对一策略创建一个多类 SVM,如下所示:
def train_one_vs_all_SVM(X_train, y_train):
svm = cv2.ml.SVM_create()
- 设置学习器的超参数。这些被称为 超参数,因为这些参数超出了学习器的控制范围(与学习器在学习过程中更改的参数相对)。可以使用以下代码完成:
svm.setKernel(cv2.ml.SVM_LINEAR)
svm.setType(cv2.ml.SVM_C_SVC)
svm.setC(2.67)
svm.setGamma(5.383)
- 在 SVM 实例上调用
train方法,OpenCV 会负责训练(使用 GTSRB 数据集,在普通笔记本电脑上这可能需要几分钟),如下所示:
svm.train(X_train, cv2.ml.ROW_SAMPLE, y_train)
return svm
OpenCV 会处理其余部分。在底层,SVM 训练使用 拉格朗日乘数来优化一些导致最大边缘决策边界的约束。
优化过程通常是在满足某些终止条件时进行的,这些条件可以通过 SVM 的可选参数指定。
现在我们已经了解了 SVM 的训练过程,让我们来看看如何测试它。
测试 SVM
评估分类器有许多方法,但最常见的是,我们通常只对准确率指标感兴趣——也就是说,测试集中有多少数据样本被正确分类。
为了得到这个指标,我们需要从 SVM 中获取预测结果——同样,OpenCV 为我们提供了 predict 方法,该方法接受一个特征矩阵并返回一个预测标签数组。因此,我们需要按照以下步骤进行:
- 因此,我们首先需要对我们的测试数据进行特征化:
x_train = featurize(train_data)
- 然后,我们将特征化后的数据输入到分类器中,并获取预测标签,如下所示:
y_predict = model.predict(x_test)
- 之后,我们可以尝试运行以下代码来查看分类器正确标记了多少个标签:
num_correct = sum(y_predict == y_test)
现在,我们准备计算所期望的性能指标,这些指标将在后面的章节中详细描述。为了本章的目的,我们选择计算准确率、精确率和召回率。
scikit-learn 机器学习包(可在 scikit-learn.org 找到)直接支持三个指标——准确率、精确率和召回率(以及其他指标),并且还附带了许多其他有用的工具。出于教育目的(以及最小化软件依赖),我们将自己推导这三个指标。
准确率
计算最直接的指标可能是准确率。这个指标简单地计算预测正确的测试样本数量,并以总测试样本数的分数形式返回,如下面的代码块所示:
def accuracy(y_predicted, y_true):
return sum(y_predicted == y_true) / len(y_true)
之前的代码显示,我们通过调用 model.predict(x_test) 提取了 y_predicted。这很简单,但为了使代码可重用,我们将它放在一个接受 predicted 和 true 标签的函数中。现在,我们将继续实现一些更复杂的、有助于衡量分类器性能的指标。
混淆矩阵
混淆矩阵是一个大小为 (num_classes, num_classes) 的二维矩阵,其中行对应于预测的类别标签,列对应于实际的类别标签。然后,[r,c] 矩阵元素包含预测为标签 r 但实际上具有标签 c 的样本数量。通过访问混淆矩阵,我们可以计算精确率和召回率。
现在,让我们实现一种非常简单的方式来计算混淆矩阵。类似于准确率,我们创建一个具有相同参数的函数,这样就可以通过以下步骤轻松重用:
- 假设我们的标签是非负整数,我们可以通过取最高整数并加
1来确定num_classes,以考虑零,如下所示:
def confusion_matrix(y_predicted, y_true):
num_classes = max(max(y_predicted), max(y_true)) + 1
...
- 接下来,我们实例化一个空的矩阵,我们将在这里填充计数,如下所示:
conf_matrix = np.zeros((num_classes, num_classes))
- 接下来,我们遍历所有数据,对于每个数据点,我们取预测值
r和实际值c,然后在矩阵中增加相应的值。虽然有许多更快的方法来实现这一点,但没有什么比逐个计数更简单了。我们使用以下代码来完成这项工作:
for r, c in zip(y_predicted, y_true):
conf_matrix[r, c] += 1
- 在我们处理完训练集中的所有数据后,我们可以返回我们的混淆矩阵,如下所示:
return conf_matrix
- 这是我们的 GTSRB 数据集测试数据的混淆矩阵:
如您所见,大多数值都在对角线上。这意味着乍一看,我们的分类器表现相当不错。
- 从混淆矩阵中计算准确率也很容易。我们只需取对角线上的元素数量,然后除以总元素数量,如下所示:
cm = confusion_matrix(y_predicted, y_true)
accuracy = cm.trace() / cm.sum() # 0.95 in this case.
注意,每个类别中的元素数量都不同。每个类别对准确率的贡献不同,我们的下一个指标将专注于每个类别的性能。
精确率
在二元分类中,精度是一个有用的指标,用于衡量检索到的实例中有多少是相关的(也称为阳性预测值)。在分类任务中,真阳性的数量被定义为正确标记为属于正类别的项目数量。
精度被定义为真阳性数量除以总阳性数量。换句话说,在测试集中,一个分类器认为包含猫的所有图片中,精度是实际包含猫的图片的比例。
注意,在这里,我们有一个正标签;因此,精度是每个类别的值。我们通常谈论一个类别的精度,或者猫的精度等等。
正确样本的总数也可以通过真阳性和假阳性的总和来计算,后者是指被错误标记为属于特定类别的样本数量。这就是混淆矩阵派上用场的地方,因为它将允许我们通过以下步骤快速计算出假阳性和真阳性的数量:
- 因此,在这种情况下,我们必须更改我们的函数参数,并添加正类标签,如下所示:
def precision(y_predicted, y_true, positive_label):
...
- 让我们使用我们的混淆矩阵,并计算真阳性的数量,这将是在
[positive_label, positive_label]位置的元素,如下所示:
cm = confusion_matrix(y_predicted, y_true)
true_positives = cm[positive_label, positive_label]
- 现在,让我们计算真阳性和假阳性的数量,这将等于
positive_label行上所有元素的总和,因为该行表示预测的类别标签,如下所示:
total_positives = sum(cm[positive_label])
- 最后,返回真阳性与所有正性的比率,如下所示:
return true_positives / total_positives
根据不同的类别,我们得到非常不同的精度值。以下是所有 43 个类别的精度分数直方图:
精度较低的类别是 30,这意味着很多其他标志被错误地认为是以下截图中的标志:
在这种情况下,我们在结冰的道路上驾驶时格外小心是可以的,但可能我们错过了某些重要的事情。因此,让我们看看不同类别的召回值。
召回
召回与精度相似,因为它衡量的是检索到的相关实例的比例(而不是检索到的实例中有多少是相关的)。因此,它将告诉我们对于给定的正类(给定的标志),我们不会注意到它的概率。
在分类任务中,假阴性的数量是指那些没有被标记为属于正类别的项目,但实际上应该被标记的项目数量。
召回是真阳性数量除以真阳性和假阴性总数。换句话说,在世界上所有猫的图片中,召回是正确识别为猫的图片的比例。
这里是如何使用真实标签和预测标签来计算给定正标签的召回率的:
- 再次,我们与精度有相同的签名,并且以相同的方式检索真实正例,如下所示:
def recall(y_predicted, y_true, positive_label):
cm = confusion_matrix(y_predicted, y_true)
true_positives = cm[positive_label, positive_label]
现在,请注意,真实正例和假负例的总和是给定数据类中的点总数。
- 因此,我们只需计算该类别的元素数量,这意味着我们求混淆矩阵中
positive_label列的和,如下所示:
class_members = sum(cm[:, positive_label])
- 然后,我们像精度函数一样返回比率,如下所示:
return true_positives / class_members
现在,让我们看看以下截图所示的所有 43 个交通标志类别的召回值分布:
召回值分布得更广,第 21 个类别的值为 0.66。让我们检查哪个类别的值为 21:
现在,这并不像在覆盖着雪花/冰的道路上驾驶那样有害,但非常重要,不要错过路上的危险弯道。错过这个标志可能会产生不良后果。
下一个部分将演示运行我们的应用程序所需的main()函数例程。
将所有这些放在一起
要运行我们的应用程序,我们需要执行主函数例程(在chapter6.py中)。这加载数据,训练分类器,评估其性能,并可视化结果:
- 首先,我们需要导入所有相关模块并设置
main函数,如下所示:
import cv2
import numpy as np
import matplotlib.pyplot as plt
from data.gtsrb import load_training_data
from data.gtsrb import load_test_data
from data.process import grayscale_featurize, hog_featurize
- 然后,目标是比较不同特征提取方法的分类性能。这包括使用不同特征提取方法运行任务。因此,我们首先加载数据,并重复对每个特征化函数进行过程,如下所示:
def main(labels):
train_data, train_labels = load_training_data(labels)
test_data, test_labels = load_test_data(labels)
y_train, y_test = np.array(train_labels), np.array(test_labels)
accuracies = {}
for featurize in [hog_featurize, grayscale_featurize, hsv_featurize,
surf_featurize]:
...
对于每个featurize函数,我们执行以下步骤:
-
Featurize数据,以便我们有一个特征矩阵,如下所示:
x_train = featurize(train_data)
-
- 使用我们的
train_one_vs_all_SVM方法训练模型,如下所示:
- 使用我们的
model = train_one_vs_all_SVM(x_train, y_train)
-
- 通过对测试数据进行特征化并将结果传递给
predict方法(我们必须单独对测试数据进行特征化以确保没有信息泄露),为训练数据预测测试标签,如下所示:
- 通过对测试数据进行特征化并将结果传递给
x_test = featurize(test_data)
res = model.predict(x_test)
y_predict = res[1].flatten()
-
- 我们使用
accuracy函数对预测标签和真实标签进行评分,并将分数存储在字典中,以便在所有featurize函数的结果出来后进行绘图,如下所示:
- 我们使用
accuracies[featurize.__name__] = accuracy(y_predict, y_test)
- 现在,是时候绘制结果了,为此,我们选择了
matplotlib的bar图功能。我们还确保相应地缩放条形图,以便直观地理解差异的规模。由于准确度是一个介于0和1之间的数字,我们将y轴限制在[0, 1],如下所示:
plt.bar(accuracies.keys(), accuracies.values())
plt.ylim([0, 1])
- 我们通过在水平轴上旋转标签、添加
grid和title来为绘图添加一些漂亮的格式化,如下所示:
plt.axes().xaxis.set_tick_params(rotation=20)
plt.grid()
plt.title('Test accuracy for different featurize functions')
plt.show()
- 并且在执行
plt.show()的最后一行之后,以下截图所示的绘图在单独的窗口中弹出:
因此,我们看到hog_featurize在这个数据集上是一个赢家,但我们离完美的结果还远着呢——略高于 95%。要了解可能得到多好的结果,你可以快速进行一次谷歌搜索,你会找到很多实现 99%+精度的论文。所以,尽管我们没有得到最前沿的结果,但我们使用现成的分类器和简单的featurize函数做得相当不错。
另一个有趣的事实是,尽管我们认为具有鲜艳颜色的交通标志应该使用 hsv_featurize(它比灰度特征更重要),但事实并非如此。
因此,一个很好的经验法则是你应该对你的数据进行实验,以发展更好的直觉,了解哪些特征对你的数据有效,哪些无效。
说到实验,让我们用一个神经网络来提高我们获得的结果的效率。
使用神经网络提高结果
让我们快速展示一下,如果我们使用一些花哨的深度神经网络(DNNs),我们可能会达到多好的水平,并给你一个关于本书未来章节内容的预览。
如果我们使用以下“不太深”的神经网络,在我的笔记本电脑上训练大约需要 2 分钟(而 SVM 的训练只需要 1 分钟),我们得到的准确率大约为 0.964!
这里是训练方法的一个片段(你应该能够将其插入到前面的代码中,并调整一些参数以查看你能否在以后做到):
def train_tf_model(X_train, y_train):
model = tf.keras.models.Sequential([
tf.keras.layers.Conv2D(20, (8, 8),
input_shape=list(UNIFORM_SIZE) + [3],
activation='relu'),
tf.keras.layers.MaxPooling2D(pool_size=(4, 4), strides=4),
tf.keras.layers.Dropout(0.15),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.15),
tf.keras.layers.Dense(43, activation='softmax')
])
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
model.fit(x_train, np.array(train_labels), epochs=10)
return model
代码使用了 TensorFlow 的高级 Keras API(我们将在接下来的章节中看到更多),并创建了一个具有以下结构的神经网络:
-
卷积层带有最大池化,后面跟着一个 dropout——它只在训练期间存在。
-
隐藏密集层后面跟着一个 dropout——它只在训练期间存在。
-
最终密集层输出最终结果;它应该识别输入数据属于哪个类别(在 43 个类别中)。
注意,我们只有一个卷积层,这与 HOG 特征化非常相似。如果我们增加更多的卷积层,性能会显著提高,但让我们把这一点留到下一章去探索。
摘要
在本章中,我们训练了一个多类分类器来识别 GTSRB 数据库中的交通标志。我们讨论了监督学习的基础,探讨了特征提取的复杂性,并简要介绍了深度神经网络(DNNs)。
使用本章中采用的方法,你应该能够将现实生活中的问题表述为机器学习模型,使用你的 Python 技能从互联网上下载一个标记的样本数据集,编写将图像转换为特征向量的特征化函数,并使用 OpenCV 来训练现成的机器学习模型,帮助你解决现实生活中的问题。
值得注意的是,我们在过程中省略了一些细节,例如尝试微调学习算法的超参数(因为它们超出了本书的范围)。我们只关注准确率分数,并没有通过尝试结合所有不同特征集进行很多特征工程。
在这个功能设置和对其底层方法论的充分理解下,你现在可以分类整个 GTSRB 数据集,以获得高于 0.97 的准确率!0.99 呢?这绝对值得查看他们的网站,在那里你可以找到各种分类器的分类结果。也许你的方法很快就会被添加到列表中。
在下一章中,我们将更深入地探讨机器学习的领域。具体来说,我们将专注于使用卷积神经网络(CNNs)来识别人类面部表情。这一次,我们将结合分类器与一个目标检测框架,这将使我们能够在图像中找到人脸,然后专注于识别该人脸中包含的情感表情。
数据集归属
J. Stallkamp, M. Schlipsing, J. Salmen, and C. Igel, The German Traffic Sign Recognition Benchmark—A multiclass classification competition, in Proceedings of the IEEE International Joint Conference on Neural Networks, 2011, pages 1453–1460.
第八章:学习识别面部表情
我们之前已经熟悉了对象检测和对象识别的概念。在本章中,我们将开发一个能够同时执行这两项任务的应用程序。该应用程序将能够检测网络摄像头实时流中的每一帧捕获到的您的面部,识别您的面部表情,并在图形用户界面(GUI)上对其进行标记。
本章的目标是开发一个结合面部检测和面部识别的应用程序,重点关注识别检测到的面部表情。阅读本章后,您将能够在自己的不同应用程序中使用面部检测和识别。
在本章中,我们将涵盖以下主题:
-
计划应用程序
-
学习面部检测
-
收集机器学习任务的数据
-
理解面部表情识别
-
整合所有内容
我们将简要介绍 OpenCV 附带的两项经典算法——Haar cascade 分类器和MLPs。前者可以快速检测(或定位,并回答问题“在哪里?”)图像中各种大小和方向的物体,而后者可以用来识别它们(或识别,并回答问题“是什么?”)。
学习 MLPs 也是学习当今最流行算法之一——深度神经网络(DNNs)的第一步。当我们没有大量数据时,我们将使用 PCA 来加速并提高算法的准确性,以改善我们模型的准确性。
我们将自行收集训练数据,以向您展示这个过程是如何进行的,以便您能够为没有现成数据的学习任务训练机器学习模型。不幸的是,没有合适的数据仍然是当今机器学习广泛采用的最大障碍之一。
现在,在我们动手之前,让我们先看看如何开始。
开始
您可以在我们的 GitHub 仓库中找到本章中展示的代码,网址为github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter8。
除了这些,您应该从官方 OpenCV 仓库下载Haar cascade文件,网址为github.com/opencv/opencv/blob/master/data/haarcascades/,或者从您的机器安装目录复制它们到项目仓库中。
计划应用程序
可靠地识别面部和面部表情是人工智能(AI)的一个具有挑战性的任务,然而人类能够轻松地完成这些任务。为了使我们的任务可行,我们将限制自己只关注以下有限的情绪表达:
-
Neutral
-
Happy
-
Sad
-
Surprised
-
Angry
-
Disgusted
今天的最先进模型范围从适合卷积神经网络(CNNs)的 3D 可变形人脸模型,到深度学习算法。当然,这些方法比我们的方法要复杂得多。
然而,多层感知器(MLP)是一种经典的算法,它帮助改变了机器学习的领域,因此出于教育目的,我们将坚持使用 OpenCV 附带的一系列算法。
要达到这样的应用,我们需要解决以下两个挑战:
-
人脸检测:我们将使用 Viola 和 Jones 的流行 Haar 级联分类器,OpenCV 为此提供了一系列预训练的示例。我们将利用人脸级联和眼级联从帧到帧可靠地检测和校准面部区域。
-
面部表情识别:我们将训练一个 MLP 来识别之前列出的六种不同的情感表达,在每一个检测到的人脸中。这种方法的成功将关键取决于我们组装的训练集,以及我们对每个样本选择的预处理。
为了提高我们自录制的训练集质量,我们将确保所有数据样本都通过仿射变换进行对齐,并通过应用主成分分析(PCA)来降低特征空间的维度。这种表示有时也被称为特征脸。
我们将在一个端到端的单一应用中结合前面提到的算法,该应用将使用预训练模型在每个视频直播捕获的每一帧中为检测到的人脸标注相应的面部表情标签。最终结果可能看起来像以下截图,捕捉了我第一次运行代码时的样本反应:
最终的应用将包括一个集成的端到端流程的主脚本——即从人脸检测到面部表情识别,以及一些辅助函数来帮助实现这一过程。
因此,最终产品将需要位于书籍 GitHub 仓库的chapter8/目录中的几个组件,如下所示:
-
chapter8.py: 这是本章的主要脚本和入口点,我们将用它来进行数据收集和演示。它将具有以下布局:-
chapter8.FacialExpressionRecognizerLayout: 这是一个基于wx_gui.BaseLayout的自定义布局,它将在每个视频帧中检测面部,并使用预训练模型预测相应的类别标签。 -
chapter8.DataCollectorLayout: 这是一个基于wx_gui.BaseLayout的自定义布局,它将收集图像帧,检测其中的面部,使用用户选择的表情标签进行标记,并将帧保存到data/目录中。
-
-
wx_gui.py: 这是链接到我们在第一章,“滤镜乐趣”中开发的wxpythonGUI 文件。 -
detectors.FaceDetector:这是一个将包含基于 Haar 级联的面部检测所有代码的类。它将有两个以下方法:-
detect_face:这个方法用于检测灰度图像中的面部。可选地,图像会被缩小以提高可靠性。检测成功后,该方法返回提取的头区域。 -
align_head:这个方法通过仿射变换预处理提取的头区域,使得最终的面部看起来居中且垂直。
-
-
params/:这是一个包含我们用于本书的默认 Haar 级联的目录。 -
data/:我们将在这里编写所有存储和处理自定义数据的代码。代码被分为以下文件:-
store.py:这是一个包含所有将数据写入磁盘和从磁盘将数据加载到计算机内存中的辅助函数的文件。 -
process.py:这是一个包含所有预处理数据的代码的文件,以便在保存之前。它还将包含从原始数据构建特征的代码。
-
在接下来的章节中,我们将详细讨论这些组件。首先,让我们看看面部检测算法。
学习关于面部检测
OpenCV 预装了一系列复杂的通用对象检测分类器。这些分类器都具有非常相似的 API,一旦你知道你在寻找什么,它们就很容易使用。可能最广为人知的检测器是用于面部检测的基于 Haar 特征的级联检测器,它最初由 Paul Viola 和 Michael Jones 在 2001 年发表的论文《使用简单特征增强级联的快速对象检测》中提出。
基于 Haar 特征检测器是一种在大量正负标签样本上训练的机器学习算法。在我们的应用中,我们将使用 OpenCV 附带的一个预训练分类器(你可以在“入门”部分找到链接)。但首先,让我们更详细地看看这个分类器是如何工作的。
学习关于基于 Haar 级联分类器
每本关于 OpenCV 的书至少应该提到 Viola-Jones 面部检测器。这个级联分类器在 2001 年发明,它颠覆了计算机视觉领域,因为它最终允许实时面部检测和面部识别。
这个分类器基于Haar-like 特征(类似于Haar 基函数),它们在图像的小区域内求和像素强度,同时捕捉相邻图像区域之间的差异。
下面的截图可视化了四个矩形特征。可视化旨在计算在某个位置应用的特征的值。你应该将暗灰色矩形中的所有像素值加起来,然后从这个值中减去白色矩形中所有像素值的总和:
在前面的截图上,第一行显示了两个边缘特征(即你可以用它们检测边缘)的示例,要么是垂直方向的(左上角)要么是 45 度角方向的(右上角)。第二行显示了线特征(左下角)和中心环绕特征(右下角)。
应用这些过滤器在所有可能的位置,允许算法捕捉到人类面部的一些细节,例如,眼睛区域通常比脸颊周围的区域要暗。
因此,一个常见的 Haar 特征将是一个暗色的矩形(代表眼睛区域)在亮色的矩形(代表脸颊区域)之上。将这个特征与一组旋转和略微复杂的小波结合,Viola 和 Jones 得到了一个强大的人脸特征描述符。在额外的智慧行为中,这些人想出了一个有效的方法来计算这些特征,这使得第一次能够实时检测到人脸。
最终分类器是多个小型弱分类器的加权总和,每个分类器的二进制分类器都基于之前描述的单一特征。最难的部分是确定哪些特征组合有助于检测不同类型的对象。幸运的是,OpenCV 包含了一系列这样的分类器。让我们在下一节中看看其中的一些。
理解预训练的级联分类器
更好的是,这种方法不仅适用于人脸,还适用于眼睛、嘴巴、全身、公司标志;你说的都对。在下表中,展示了一些可以在 OpenCV 安装路径下的data文件夹中找到的预训练分类器:
| 级联分类器类型 | XML 文件名 |
|---|---|
| 面部检测器(默认) | haarcascade_frontalface_default.xml |
| 面部检测器(快速 Haar) | haarcascade_frontalface_alt2.xml |
| 眼睛检测器 | haarcascade_eye.xml |
| 嘴巴检测器 | haarcascade_mcs_mouth.xml |
| 鼻子检测器 | haarcascade_mcs_nose.xml |
| 全身检测器 | haarcascade_fullbody.xml |
在本章中,我们只使用haarcascade_frontalface_default.xml和haarcascade_eye.xml。
如果你戴着眼镜,请确保使用haarcascade_eye_tree_eyeglasses.xml进行眼睛检测。
我们首先将探讨如何使用级联分类器。
使用预训练的级联分类器
可以使用以下代码加载并应用于图像(灰度)的级联分类器:
import cv2
gray_img = cv2.cvtColor(cv2.imread('example.png'), cv2.COLOR_RGB2GRAY)
cascade_clf = cv2.CascadeClassifier('haarcascade_frontalface_default.xml')
faces = cascade_clf.detectMultiScale(gray_img,
scaleFactor=1.1,
minNeighbors=3,
flags=cv2.CASCADE_SCALE_IMAGE)
从前面的代码中,detectMultiScale函数附带了一些选项:
-
minFeatureSize是考虑的最小人脸大小——例如,20 x 20 像素。 -
searchScaleFactor是我们重置图像(尺度金字塔)的量。例如,1.1的值将逐渐减小输入图像的大小 10%,使得具有更大值的脸(图像)更容易被找到。 -
minNeighbors是每个候选矩形必须保留的邻居数量。通常,我们选择3或5。 -
flags是一个选项对象,用于调整算法——例如,是否寻找所有面部或仅寻找最大的面部(cv2.cv.CASCADE_FIND_BIGGEST_OBJECT)。
如果检测成功,函数将返回一个包含检测到的面部区域坐标的边界框列表(faces),如下所示:
for (x, y, w, h) in faces:
# draw bounding box on frame
cv2.rectangle(frame, (x, y), (x + w, y + h), (100, 255, 0),
thickness=2)
在之前的代码中,我们遍历返回的面部,并为每个面部添加一个厚度为2像素的矩形轮廓。
如果你预训练的面部级联没有检测到任何东西,一个常见的原因通常是预训练级联文件的路径找不到。在这种情况下,CascadeClassifier将静默失败。因此,始终检查返回的分类器casc = cv2.CascadeClassifier(filename)是否为空,通过检查casc.empty()。
如果你在这张Lenna.png图片上运行代码,你应该得到以下结果:
图片来源——Conor Lawless 的 Lenna.png,许可协议为 CC BY 2.0
从前面的截图来看,在左侧,你可以看到原始图像,在右侧是传递给 OpenCV 的图像以及检测到的面部的矩形轮廓。
现在,让我们尝试将这个检测器封装成一个类,使其适用于我们的应用。
理解FaceDetector类
本章所有相关的面部检测代码都可以在detectors模块中的FaceDetector类中找到。在实例化时,这个类加载了两个不同的级联分类器,这些分类器用于预处理——即face_cascade分类器和eye_cascade分类器,如下所示:
import cv2
import numpy as np
class FaceDetector:
def __init__(self, *,
face_cascade='params/haarcascade_frontalface_default.xml',
eye_cascade='params/haarcascade_lefteye_2splits.xml',
scale_factor=4):
由于我们的预处理需要一个有效的面部级联,我们确保文件可以被加载。如果不能,我们将抛出一个ValueError异常,这样程序将终止并通知用户出了什么问题,如下面的代码块所示:
# load pre-trained cascades
self.face_clf = cv2.CascadeClassifier(face_cascade)
if self.face_clf.empty():
raise ValueError(f'Could not load face cascade
"{face_cascade}"')
我们对眼睛分类器也做同样的事情,如下所示:
self.eye_clf = cv2.CascadeClassifier(eye_cascade)
if self.eye_clf.empty():
raise ValueError(
f'Could not load eye cascade "{eye_cascade}"')
面部检测在低分辨率灰度图像上效果最佳。这就是为什么我们也存储一个缩放因子(scale_factor),以便在必要时操作输入图像的缩放版本,如下所示:
self.scale_factor = scale_factor
现在我们已经设置了类的初始化,让我们看看检测面部的算法。
在灰度图像中检测面部
现在,我们将之前章节中学到的内容放入一个方法中,该方法将接受一个图像并返回图像中的最大面部。我们返回最大面部是为了简化事情,因为我们知道在我们的应用中,将只有一个用户坐在摄像头前。作为一个挑战,你可以尝试将其扩展以处理多个面部!
我们将检测最大面部的方法称为detect_face。让我们一步一步地来看:
- 如同上一节所述,首先,我们将输入的 RGB 图像转换为灰度图,并通过运行以下代码按
scale_factor进行缩放:
def detect_face(self, rgb_img, *, outline=True):
frameCasc = cv2.cvtColor(cv2.resize(rgb_img, (0, 0),
fx=1.0 /
self.scale_factor,
fy=1.0 /
self.scale_factor),
cv2.COLOR_RGB2GRAY)
- 然后,我们在灰度图像中检测人脸,如下所示:
faces = self.face_clf.detectMultiScale(
frameCasc,
scaleFactor=1.1,
minNeighbors=3,
flags=cv2.CASCADE_SCALE_IMAGE) * self.scale_factor
- 我们遍历检测到的人脸,如果将
outline=True关键字参数传递给detect_face,则会绘制轮廓。OpenCV 返回给我们头部左上角的x, y坐标以及头部宽度和高度w, h。因此,为了构建轮廓,我们只需计算轮廓的底部和右侧坐标,然后调用cv2.rectangle函数,如下所示:
for (x, y, w, h) in faces:
if outline:
cv2.rectangle(rgb_img, (x, y), (x + w, y + h),
(100, 255, 0), thickness=2)
- 我们从原始 RGB 图像中裁剪出头部。如果我们想要对头部进行更多处理(例如,识别面部表情),可以运行以下代码:
head = cv2.cvtColor(rgb_img[y:y + h, x:x + w],
cv2.COLOR_RGB2GRAY)
-
我们返回以下 4 元组:
-
一个布尔值,用于检查检测是否成功
-
添加了人脸轮廓的原图像(如果需要)
-
根据需要裁剪的头像
-
原图像中头部位置的坐标
-
-
在成功的情况下,我们返回以下内容:
return True, rgb_img, head, (x, y)
在失败的情况下,我们返回没有找到头部的信息,并且对于任何不确定的事项,如这样返回None:
return False, rgb_img, None, (None, None)
现在,让我们看看在检测到人脸之后会发生什么,以便为机器学习算法做好准备。
预处理检测到的人脸
在检测到人脸之后,我们可能想要在对其进行分类之前先预处理提取的头像区域。尽管人脸级联检测相当准确,但对于识别来说,所有的人脸都必须是竖直且居中于图像中。
这是我们要达成的目标:
图片来源——由 Conor Lawless 提供的 Lenna.png,许可协议为 CC BY 2.0
如前一个截图所示,由于这不是护照照片,模型中的头部略微向一侧倾斜,同时看向肩膀。人脸区域,如图像级联提取的,显示在前一个截图的中间缩略图中。
为了补偿检测到的盒子中头部朝向和位置,我们旨在旋转、移动和缩放面部,以便所有数据样本都能完美对齐。这是FaceDetector类中的align_head方法的工作,如下面的代码块所示:
def align_head(self, head):
desired_eye_x = 0.25
desired_eye_y = 0.2
desired_img_width = desired_img_height = 200
在前面的代码中,我们硬编码了一些用于对齐头部的参数。我们希望所有眼睛都在最终图像顶部下方 25%,并且距离左右边缘各 20%,此函数将返回一个头部处理后的图像,其固定大小为 200 x 200 像素。
处理流程的第一步是检测图像中眼睛的位置,之后我们将使用这些位置来构建必要的转换。
检测眼睛
幸运的是,OpenCV 自带了一些可以检测睁眼和闭眼的眼睛级联,例如haarcascade_eye.xml。这允许我们计算连接两个眼睛中心的线与地平线之间的角度,以便我们可以相应地旋转面部。
此外,添加眼睛检测器将降低我们数据集中出现假阳性的风险,只有当头部和眼睛都成功检测到时,我们才能添加数据样本。
在FaceDetector构造函数中从文件加载眼睛级联后,它被应用于输入图像(head),如下所示:
try:
eye_centers = self.eye_centers(head)
except RuntimeError:
return False, head
如果我们失败,级联分类器找不到眼睛,OpenCV 将抛出一个RuntimeError。在这里,我们正在捕获它并返回一个(False, head)元组,表示我们未能对齐头部。
接下来,我们尝试对分类器找到的眼睛的引用进行排序。我们将left_eye设置为具有较低第一坐标的眼睛——即左侧的眼睛,如下所示:
if eye_centers[0][0] < eye_centers[0][1]:
left_eye, right_eye = eye_centers
else:
right_eye, left_eye = eye_centers
现在我们已经找到了两个眼睛的位置,我们想要弄清楚我们想要进行什么样的转换,以便将眼睛放置在硬编码的位置——即图像两侧的 25%和顶部以下的 25%。
转换面部
转换面部是一个标准过程,可以通过使用cv2.warpAffine(回忆第三章,通过特征匹配和透视变换查找对象)来实现。我们将遵循以下步骤来完成此转换:
- 首先,我们计算连接两个眼睛的线与水平线之间的角度(以度为单位),如下所示:
eye_angle_deg = 180 / np.pi * np.arctan2(right_eye[1]
- left_eye[1],
right_eye[0]
- left_eye[0])
- 然后,我们推导出一个缩放因子,将两个眼睛之间的距离缩放到图像宽度的 50%,如下所示:
eye_dist = np.linalg.norm(left_eye - right_eye)
eye_size_scale = (1.0 - desired_eye_x * 2) *
desired_img_width / eye_dist
- 现在我们有了两个参数(
eye_angle_deg和eye_size_scale),我们可以现在提出一个合适的旋转矩阵,将我们的图像转换,如下所示:
eye_midpoint = (left_eye + right_eye) / 2
rot_mat = cv2.getRotationMatrix2D(tuple(eye_midpoint),
eye_angle_deg,
eye_size_scale)
- 接下来,我们将确保眼睛的中心将在图像中居中,如下所示:
rot_mat[0, 2] += desired_img_width * 0.5 - eye_midpoint[0]
rot_mat[1, 2] += desired_eye_y * desired_img_height -
eye_midpoint[1]
- 最后,我们得到了一个垂直缩放的图像,看起来像前一个截图中的第三张图像(命名为训练图像),如下所示:
res = cv2.warpAffine(head, rot_mat, (desired_img_width,
desired_img_width))
return True, res
在这一步之后,我们知道如何从未处理图像中提取整齐、裁剪和旋转的图像。现在,是时候看看如何使用这些图像来识别面部表情了。
收集数据
面部表情识别管道封装在chapter8.py中。此文件包含一个交互式 GUI,在两种模式(训练和测试)下运行,如前所述。
我们的应用程序被分成几个部分,如下所述:
- 使用以下命令从命令行以
collect模式运行应用程序:
$ python chapter8.py collect
之前的命令将在数据收集模式下弹出一个 GUI,以组装一个训练集,
通过python train_classifier.py在训练集上训练一个 MLP 分类器。因为这个步骤可能需要很长时间,所以这个过程在自己的脚本中执行。训练成功后,将训练好的权重存储在文件中,以便我们可以在下一步加载预训练的 MLP。
- 然后,再次以以下方式在
demo模式下运行 GUI,我们将能够看到在真实数据上面部识别的效果如何:
$ python chapter8.py demo
在这种模式下,你将有一个 GUI 来实时对实时视频流中的面部表情进行分类。这一步涉及到加载几个预训练的级联分类器以及我们的预训练 MLP 分类器。然后,这些分类器将被应用于每个捕获的视频帧。
现在,让我们看看如何构建一个用于收集训练数据的应用程序。
组装训练数据集
在我们能够训练一个 MLP 之前,我们需要组装一个合适的训练集。这是因为你的脸可能还不是任何现有数据集的一部分(国家安全局(NSA)的私人收藏不算),因此我们将不得不自己组装。这可以通过回到前几章中的 GUI 应用程序来完成,该应用程序可以访问网络摄像头,并处理视频流的每一帧。
我们将继承wx_gui.BaseLayout并调整用户界面(UI)以满足我们的喜好。我们将有两个类用于两种不同的模式。
GUI 将向用户展示以下六种情感表达之一的选项——即中性、快乐、悲伤、惊讶、愤怒和厌恶。点击按钮后,应用将捕捉到检测到的面部区域并添加到文件中的数据收集。
这些样本然后可以从文件中加载并用于在train_classifier.py中训练机器学习分类器,如步骤 2(之前给出)所述。
运行应用程序
正如我们在前几章中看到的那样,使用wxpython GUI,为了运行这个应用(chapter8.py),我们需要使用cv2.VideoCapture设置屏幕捕获,并将句柄传递给FaceLayout类。我们可以通过以下步骤来完成:
- 首先,我们创建一个
run_layout函数,它将适用于任何BaseLayout子类,如下所示:
def run_layout(layout_cls, **kwargs):
# open webcam
capture = cv2.VideoCapture(0)
# opening the channel ourselves, if it failed to open.
if not(capture.isOpened()):
capture.open()
capture.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
# start graphical user interface
app = wx.App()
layout = layout_cls(capture, **kwargs)
layout.Center()
layout.Show()
app.MainLoop()
如您所见,代码与之前章节中使用的wxpython代码非常相似。我们打开网络摄像头,设置分辨率,初始化布局,并启动应用程序的主循环。当你需要多次使用相同的函数时,这种类型的优化是好的。
- 接下来,我们设置一个参数解析器,它将确定需要运行哪两个布局之一,并使用适当的参数运行它。
为了在两种模式下都使用run_layout函数,我们使用argparse模块在我们的脚本中添加一个命令行参数,如下所示:
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['collect', 'demo'])
parser.add_argument('--classifier')
args = parser.parse_args()
我们使用了 Python 附带的argparse模块来设置参数解析器,并添加了具有collect和demo选项的参数。我们还添加了一个可选的--classifier参数,我们将在demo模式下使用它。
- 现在,我们使用用户传递的所有参数,以适当的参数调用
run_layout函数,如下所示:
if args.mode == 'collect':
run_layout(DataCollectorLayout, title='Collect Data')
elif args.mode == 'demo':
assert args.svm is not None, 'you have to provide --svm'
run_layout(FacialExpressionRecognizerLayout,
title='Facial Expression Recognizer',
classifier_path=args.classifier)
如前述代码所示,我们已设置在demo模式下传递额外的classifier_path参数。我们将在本章后面的部分讨论FacialExpresssionRecognizerLayout时看到它是如何被使用的。
现在我们已经建立了如何运行我们的应用程序,让我们构建 GUI 元素。
实现数据收集 GUI
与前几章的一些内容类似,该应用程序的 GUI 是通用BaseLayout的定制版本,如下所示:
import wx
from wx_gui import BaseLayout
class DataCollectorLayout(BaseLayout):
我们通过调用父类的构造函数开始构建 GUI,以确保它被正确初始化,如下所示:
def __init__(self, *args,
training_data='data/cropped_faces.csv',
**kwargs):
super().__init__(*args, **kwargs)
注意,我们在之前的代码中添加了一些额外的参数。这些参数是我们类中所有额外的属性,而父类中没有的属性。
在我们继续添加 UI 组件之前,我们还初始化了一个FaceDetector实例和用于存储数据的文件引用,如下所示:
self.face_detector = FaceDetector(
face_cascade='params/haarcascade_frontalface_default.xml',
eye_cascade='params/haarcascade_eye.xml')
self.training_data = training_data
注意,我们正在使用硬编码的级联 XML 文件。您可以随意尝试这些文件。
现在,让我们看看我们如何使用wxpython构建 UI。
增强基本布局
布局的创建再次推迟到名为augment_layout的方法中。我们尽可能保持布局简单。我们创建一个用于获取视频帧的面板,并在其下方绘制一排按钮。
然后点击六个单选按钮之一,以指示您要记录哪种面部表情,然后将头部放在边界框内,并点击Take Snapshot按钮。
因此,让我们看看如何构建六个按钮,并将它们正确地放置在wx.Panel对象上。相应的代码如下所示:
def augment_layout(self):
pnl2 = wx.Panel(self, -1)
self.neutral = wx.RadioButton(pnl2, -1, 'neutral', (10, 10),
style=wx.RB_GROUP)
self.happy = wx.RadioButton(pnl2, -1, 'happy')
self.sad = wx.RadioButton(pnl2, -1, 'sad')
self.surprised = wx.RadioButton(pnl2, -1, 'surprised')
self.angry = wx.RadioButton(pnl2, -1, 'angry')
self.disgusted = wx.RadioButton(pnl2, -1, 'disgusted')
hbox2 = wx.BoxSizer(wx.HORIZONTAL)
hbox2.Add(self.neutral, 1)
hbox2.Add(self.happy, 1)
hbox2.Add(self.sad, 1)
hbox2.Add(self.surprised, 1)
hbox2.Add(self.angry, 1)
hbox2.Add(self.disgusted, 1)
pnl2.SetSizer(hbox2)
您可以看到,尽管代码量很大,但我们所写的大部分内容都是重复的。我们为每种情绪创建一个RadioButton,并将按钮添加到pnl2面板中。
Take Snapshot按钮放置在单选按钮下方,并将绑定到_on_snapshot方法,如下所示:
# create horizontal layout with single snapshot button
pnl3 = wx.Panel(self, -1)
self.snapshot = wx.Button(pnl3, -1, 'Take Snapshot')
self.Bind(wx.EVT_BUTTON, self._on_snapshot, self.snapshot)
hbox3 = wx.BoxSizer(wx.HORIZONTAL)
hbox3.Add(self.snapshot, 1)
pnl3.SetSizer(hbox3)
如注释所示,我们创建了一个新的面板,并添加了一个带有Take Snapshot标签的常规按钮。重要的是,我们将按钮的点击事件绑定到self._on_snapshot方法,这样我们点击Take Snapshot按钮后,将处理捕获的每一张图片。
布局将如下截图所示:
要使这些更改生效,需要将创建的面板添加到现有面板的列表中,如下所示:
# arrange all horizontal layouts vertically
self.panels_vertical.Add(pnl2, flag=wx.EXPAND | wx.BOTTOM,
border=1)
self.panels_vertical.Add(pnl3, flag=wx.EXPAND | wx.BOTTOM,
border=1)
其余的可视化管道由BaseLayout类处理。
现在,让我们看看我们是如何在视频捕获中一旦人脸出现就使用process_frame方法添加边界框的。
处理当前帧
process_frame方法被调用在所有图像上,我们希望在视频流中出现人脸时显示一个围绕人脸的帧。如下所示:
def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
_, frame, self.head, _ = self.face_detector.detect_face(frame_rgb)
return frame
我们刚刚调用了在布局类的构造函数中创建的self.face_detector对象的FaceDetector.detect_face方法。记得从上一节中,它使用 Haar 级联在当前帧的降尺度灰度版本中检测人脸。
因此,如果我们识别出人脸,我们就添加一个帧;就是这样。现在,让我们看看我们是如何在_on_snapshot方法中存储训练图像的。
存储数据
当用户点击“拍摄快照”按钮,并调用_on_snapshot事件监听器方法时,我们将存储数据,如下所示:
def _on_snapshot(self, evt):
"""Takes a snapshot of the current frame
This method takes a snapshot of the current frame, preprocesses
it to extract the head region, and upon success adds the data
sample to the training set.
"""
让我们看看这个方法内部的代码,如下所示:
- 首先,我们通过找出哪个单选按钮被选中来确定图像的标签,如下所示:
if self.neutral.GetValue():
label = 'neutral'
elif self.happy.GetValue():
label = 'happy'
elif self.sad.GetValue():
label = 'sad'
elif self.surprised.GetValue():
label = 'surprised'
elif self.angry.GetValue():
label = 'angry'
elif self.disgusted.GetValue():
label = 'disgusted'
如你所见,一旦我们意识到每个单选按钮都有一个GetValue()方法,它仅在它被选中时返回True,这个过程就非常直接。
- 接下来,我们需要查看当前帧中检测到的面部区域(由
detect_head存储在self.head中)并将其与其他所有收集到的帧对齐。也就是说,我们希望所有的人脸都是直立的,眼睛是对齐的。
否则,如果我们不对齐数据样本,我们面临的风险是分类器会将眼睛与鼻子进行比较。因为这个计算可能很昂贵,所以我们不在process_frame方法的每一帧上应用它,而是在_on_snapshot方法中仅在对快照进行操作时应用,如下所示:
if self.head is None:
print("No face detected")
else:
success, aligned_head =
self.face_detector.align_head(self.head)
由于这发生在调用process_frame之后,我们已经有权限访问self.head,它存储了当前帧中头部的图像。
- 接下来,如果我们已经成功对齐了头部(也就是说,如果我们已经找到了眼睛),我们将存储数据。否则,我们将通过向终端发送一个
print命令来通知用户,如下所示:
if success:
save_datum(self.training_data, label, aligned_head)
print(f"Saved {label} training datum.")
else:
print("Could not align head (eye detection
failed?)")
实际的保存是在save_datum函数中完成的,我们将其抽象出来,因为它不是 UI 的一部分。此外,如果你想要向文件中添加不同的数据集,这会很有用,如下所示:
def save_datum(path, label, img):
with open(path, 'a', newline='') as outfile:
writer = csv.writer(outfile)
writer.writerow([label, img.tolist()])
如前所述的代码所示,我们使用.csv文件来存储数据,其中每个图像都是一个newline。所以,如果你想回去删除一个图像(也许你忘记梳理头发),你只需要用文本编辑器打开.csv文件并删除那一行。
现在,让我们转向更有趣的部分,找出我们如何使用我们收集的数据来训练一个机器学习模型以检测情感。
理解面部情感识别
在本节中,我们将训练一个 MLP 来识别图片中的面部情感。
我们之前已经指出,找到最能描述数据的特征往往是整个学习任务的一个重要部分。我们还探讨了常见的预处理方法,如均值减法和归一化。
这里,我们将探讨一个在人脸识别中有着悠久传统的额外方法——那就是 PCA。我们希望即使我们没有收集成千上万的训练图片,PCA 也能帮助我们获得良好的结果。
处理数据集
类似于第七章,学习识别交通标志,我们在data/emotions.py中编写了一个新的数据集解析器,该解析器将解析我们自行组装的训练集。
我们定义了一个load_data函数,该函数将加载训练数据并返回一个包含收集到的数据及其对应标签的元组,如下所示:
def load_collected_data(path):
data, targets = [], []
with open(path, 'r', newline='') as infile:
reader = csv.reader(infile)
for label, sample in reader:
targets.append(label)
data.append(json.loads(sample))
return data, targets
这段代码,类似于所有处理代码,是自包含的,并位于data/process.py文件中,类似于第七章,学习识别交通标志。
本章中的特征化函数将是pca_featurize函数,它将对所有样本执行 PCA。但与第七章,学习识别交通标志不同,我们的特征化函数考虑了整个数据集的特征,而不是单独对每张图像进行操作。
现在,它将返回一个包含训练数据和应用于测试数据所需的所有参数的训练数据元组,如下所示:
def pca_featurize(data) -> (np.ndarray, List)
现在,让我们弄清楚 PCA 是什么,以及为什么我们需要它。
学习 PCA
PCA 是一种降维技术,在处理高维数据时非常有用。从某种意义上说,你可以将图像视为高维空间中的一个点。如果我们通过连接所有行或所有列将高度为m和宽度为n的 2D 图像展平,我们得到一个长度为m x n的(特征)向量。这个向量中第 i 个元素的值是图像中第 i 个像素的灰度值。
为了描述所有可能的具有这些精确尺寸的 2D 灰度图像,我们需要一个m x n维度的向量空间,其中包含*256^(m x n)*个向量。哇!
当考虑到这些数字时,一个有趣的问题浮现在脑海中——是否可能存在一个更小、更紧凑的向量空间(使用小于 m x n 的特征)来同样好地描述所有这些图像? 因为毕竟,我们之前已经意识到灰度值并不是内容的最有信息量的度量。
这就是主成分分析(PCA)介入的地方。考虑一个数据集,我们从其中提取了恰好两个特征。这些特征可能是某些x和y位置的像素的灰度值,但它们也可能比这更复杂。如果我们沿着这两个特征轴绘制数据集,数据可能被映射到某个多元高斯分布中,如下面的截图所示:
PCA 所做的是将所有数据点旋转,直到数据映射到解释数据大部分扩散的两个轴(两个内嵌向量)上。PCA 认为这两个轴是最有信息的,因为如果你沿着它们走,你可以看到大部分数据点分离。用更技术性的术语来说,PCA 旨在通过正交线性变换将数据转换到一个新的坐标系中。
新的坐标系被选择得使得如果你将数据投影到这些新轴上,第一个坐标(称为第一个主成分)观察到最大的方差。在前面的截图中,画的小向量对应于协方差矩阵的特征向量,它们的尾部被移动到分布的均值处。
如果我们之前已经计算了一组基向量(top_vecs)和均值(center),那么转换数据将非常直接,正如前一段所述——我们从每个数据点中减去中心,然后将这些向量乘以主成分,如下所示:
def _pca_featurize(data, center, top_vecs):
return np.array([np.dot(top_vecs, np.array(datum).flatten() - center)
for datum in data]).astype(np.float32)
注意,前面的代码将对任何数量的top_vecs都有效;因此,如果我们只提供num_components数量的顶级向量,它将降低数据的维度到num_components。
现在,让我们构建一个pca_featurize函数,它只接受数据,并返回转换以及复制转换所需的参数列表——即center和top_vecs——这样我们就可以在测试数据上应用_pcea_featurize,如下所示:
def pca_featurize(training_data) -> (np.ndarray, List)
幸运的是,有人已经想出了如何在 Python 中完成所有这些操作。在 OpenCV 中,执行 PCA 就像调用cv2.PCACompute一样简单,但我们必须传递正确的参数,而不是重新格式化我们从 OpenCV 得到的内容。以下是步骤:
- 首先,我们将
training_data转换为一个 NumPy 2D 数组,如下所示:
x_arr = np.array(training_data).reshape((len(training_data), -1)).astype(np.float32)
- 然后,我们调用
cv2.PCACompute,它计算数据的中心,以及主成分,如下所示:
mean, eigvecs = cv2.PCACompute(x_arr, mean=None)
- 我们可以通过运行以下代码来限制自己只使用
num_components中最有信息量的成分:
# Take only first num_components eigenvectors.
top_vecs = eigvecs[:num_components]
PCA 的美丽之处在于,根据定义,第一个主成分解释了数据的大部分方差。换句话说,第一个主成分是数据中最有信息量的。这意味着我们不需要保留所有成分来得到数据的良好表示。
- 我们还将
mean转换为创建一个新的center变量,它是一个表示数据中心的 1D 向量,如下所示:
center = mean.flatten()
- 最后,我们返回由
_pca_featurize函数处理过的训练数据,以及传递给_pca_featurize函数的必要参数,以便复制相同的转换,这样测试数据就可以以与训练数据完全相同的方式被特征化,如下所示:
args = (center, top_vecs)
return _pca_featurize(training_data, *args), args
现在我们知道了如何清理和特征化我们的数据,是时候看看我们用来学习识别面部情绪的训练方法了。
理解 MLP
MLP 已经存在了一段时间。MLP 是用于将一组输入数据转换为输出数据的人工神经网络(ANNs)。
MLP 的核心是一个感知器,它类似于(但过于简化)生物神经元。通过在多个层中组合大量感知器,MLP 能够对其输入数据进行非线性决策。此外,MLP 可以通过反向传播进行训练,这使得它们对于监督学习非常有兴趣。
以下部分解释了感知器的概念。
理解感知器
感知器是一种二分类器,由 Frank Rosenblatt 在 20 世纪 50 年代发明。感知器计算其输入的加权总和,如果这个总和超过阈值,它输出一个1;否则,它输出一个0。
在某种意义上,感知器正在整合证据,其传入信号表示某些对象实例的存在(或不存在),如果这种证据足够强烈,感知器就会活跃(或沉默)。这与研究人员认为生物神经元在大脑中(或可以用来做)做的事情(或可以用来做)松散相关,因此有ANN这个术语。
感知器的一个草图在以下屏幕截图中有展示:
在这里,感知器计算所有输入(x[i])的加权(w[i])总和,加上一个偏置项(b)。这个输入被送入一个非线性激活函数(θ),它决定了感知器的输出(y)。在原始算法中,激活函数是Heaviside 阶跃函数。
在现代 ANN 的实现中,激活函数可以是任何从 Sigmoid 到双曲正切函数的范围。Heaviside 阶跃函数和 Sigmoid 函数在以下屏幕截图中有展示:
根据激活函数的不同,这些网络可能能够执行分类或回归。传统上,只有当节点使用 Heaviside 阶跃函数时,人们才谈论 MLP。
了解深度架构
一旦你搞清楚了感知器的工作原理,将多个感知器组合成更大的网络就很有意义了。MLP(多层感知器)通常至少包含三个层,其中第一层为数据集的每个输入特征都有一个节点(或神经元),而最后一层为每个类别标签都有一个节点。
在第一层和最后一层之间的层被称为隐藏层。以下截图展示了这种前馈神经网络的例子:
在前馈神经网络中,输入层的一些或所有节点连接到隐藏层的所有节点,隐藏层的一些或所有节点连接到输出层的一些或所有节点。你通常会选择输入层的节点数等于数据集中的特征数,以便每个节点代表一个特征。
类似地,输出层的节点数通常等于数据集中的类别数,因此当输入样本为类别c时,输出层的第c个节点是活跃的,而其他所有节点都是沉默的。
当然,也可以有多个隐藏层。通常,事先并不清楚网络的理想大小应该是多少。
通常,当你向网络中添加更多神经元时,你会看到训练集上的误差率下降,如下面的截图所示(较细的,红色曲线):
这是因为模型的表达能力或复杂性(也称为Vapnik-Chervonenkis或VC 维度)随着神经网络规模的增加而增加。然而,对于前面截图中所显示的测试集上的误差率(较粗的,蓝色曲线)来说,情况并非如此。
相反,你会发现,随着模型复杂性的增加,测试误差会达到其最小值,向网络中添加更多神经元也不再能提高测试数据的性能。因此,你希望将神经网络的规模保持在前面截图中所标记的“最佳范围”,这是网络实现最佳泛化性能的地方。
你可以这样想——一个弱复杂度模型(在图表的左侧)可能太小,无法真正理解它试图学习的数据集,因此训练集和测试集上的误差率都很大。这通常被称为欠拟合。
另一方面,图表右侧的模型可能过于复杂,以至于开始记住训练数据中每个样本的具体细节,而没有注意到使样本与众不同的通用属性。因此,当模型需要预测它以前从未见过的数据时,它将失败,从而在测试集上产生很大的误差率。这通常被称为过拟合。
相反,你想要的是一个既不过拟合也不欠拟合的模型。通常,这只能通过试错来实现;也就是说,将网络大小视为一个需要根据要执行的确切任务进行调整和微调的超参数。
MLP 通过调整其权重来学习,当展示一个类c的输入样本时,输出层的第c个节点是活跃的,而其他所有节点都是沉默的。MLP 通过反向传播方法进行训练,这是一种计算网络中任何突触权重或神经元偏置相对于损失函数的偏导数的算法。这些偏导数可以用来更新网络中的权重和偏置,以逐步减少整体损失。
通过向网络展示训练样本并观察网络的输出,可以获得一个损失函数。通过观察哪些输出节点是活跃的,哪些是休眠的,我们可以计算最后一层的输出与通过损失函数提供的真实标签之间的相对误差。
然后,我们对网络中的所有权重进行修正,以便随着时间的推移误差逐渐减小。结果发现,隐藏层的误差取决于输出层,输入层的误差取决于隐藏层和输出层的误差。因此,从某种意义上说,误差会反向传播通过网络。在 OpenCV 中,通过在训练参数中指定cv2.ANN_MLP_TRAIN_PARAMS_BACKPROP来使用反向传播。
梯度下降有两种常见的类型——即随机梯度下降和批量学习。
在随机梯度下降中,我们在展示每个训练示例后更新权重,而在批量学习中,我们以批量的形式展示训练示例,并且只在每个批量展示后更新权重。在这两种情况下,我们必须确保我们只对每个样本进行轻微的权重调整(通过调整学习率),以便网络随着时间的推移逐渐收敛到一个稳定的解。
现在,在学习了 MLP 的理论之后,让我们动手用 OpenCV 来实现它。
设计用于面部表情识别的 MLP
类似于第七章,学习识别交通标志,我们将使用 OpenCV 提供的机器学习类,即ml.ANN_MLP。以下是创建和配置 OpenCV 中 MLP 的步骤:
- 实例化一个空的
ANN_MLP对象,如下所示:
mlp = cv2.ml.ANN_MLP_create()
- 设置网络架构——第一层等于数据的维度,最后一层等于所需的输出大小
6(用于可能的情绪数量),如下所示:
mlp.setLayerSizes(np.array([20, 10, 6], dtype=np.uint8)
- 我们将训练算法设置为反向传播,并使用对称的 sigmoid 函数作为激活函数,正如我们在前面的章节中讨论的那样,通过运行以下代码:
mlp.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1)
mlp.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM)
- 最后,我们将终止条件设置为在
30次迭代后或当损失达到小于0.000001的值时,如下所示,我们就可以准备训练 MLP 了:
mlp.setTermCriteria((cv2.TERM_CRITERIA_COUNT |
cv2.TERM_CRITERIA_EPS, 30, 0.000001 ))
为了训练 MLP,我们需要训练数据。我们还想了解我们的分类器做得如何,因此我们需要将收集到的数据分为训练集和测试集。
分割数据最好的方式是确保训练集和测试集中没有几乎相同的图像——例如,用户双击了“捕获快照”按钮,我们有两个相隔毫秒的图像,因此几乎是相同的。不幸的是,这是一个繁琐且手动的过程,超出了本书的范围。
我们定义函数的签名,如下。我们想要得到一个大小为n的数组的索引,我们想要指定训练数据与所有数据的比例:
def train_test_split(n, train_portion=0.8):
带着签名,让我们一步一步地回顾train_test_split函数,如下所示:
- 首先,我们创建一个
indices列表并对其进行shuffle,如下所示:
indices = np.arange(n)
np.random.shuffle(indices)
- 然后,我们计算
N训练数据集中需要有多少个训练点,如下所示:
N = int(n * train_portion)
- 之后,我们为训练数据的前
N个索引创建一个选择器,并为剩余的indices创建一个选择器,用于测试数据,如下所示:
return indices[:N], indices[N:]
现在我们有一个模型类和一个训练数据生成器,让我们看看如何训练 MLP。
训练 MLP
OpenCV 提供了所有的训练和预测方法,因此我们需要弄清楚如何格式化我们的数据以符合 OpenCV 的要求。
首先,我们将数据分为训练/测试,并对训练数据进行特征化,如下所示:
train, test = train_test_split(len(data), 0.8)
x_train, pca_args = pca_featurize(np.array(data)[train])
这里,pca_args是我们如果想要特征化任何未来的数据(例如,演示中的实时帧)需要存储的参数。
因为cv2.ANN_MLP模块的train方法不允许整数值的类别标签,我们需要首先将y_train转换为 one-hot 编码,只包含 0 和 1,然后可以将其输入到train方法中,如下所示:
encoded_targets, index_to_label = one_hot_encode(targets)
y_train = encoded_targets[train]
mlp.train(x_train, cv2.ml.ROW_SAMPLE, y_train)
one-hot 编码在train_classifiers.py中的one_hot_encode函数中处理,如下所示:
- 首先,我们确定数据中有多少个点,如下所示:
def one_hot_encode(all_labels) -> (np.ndarray, Callable):
unique_lebels = list(sorted(set(all_labels)))
all_labels中的每个c类标签都需要转换为一个长度为len(unique_labels)的 0 和 1 的向量,其中所有条目都是 0,除了c^(th),它是一个 1。我们通过分配一个全 0 的向量来准备这个操作,如下所示:
y = np.zeros((len(all_labels), len(unique_lebels))).astype(np.float32)
- 然后,我们创建字典,将列的索引映射到标签,反之亦然,如下所示:
index_to_label = dict(enumerate(unique_lebels))
label_to_index = {v: k for k, v in index_to_label.items()
- 这些索引处的向量元素需要设置为
1,如下所示:
for i, label in enumerate(all_labels):
y[i, label_to_index[label]] = 1
- 我们还返回
index_to_label,这样我们就能从预测向量中恢复标签,如下所示:
return y, index_to_label
现在我们继续测试我们刚刚训练的 MLP。
测试 MLP
类似于第七章,学习识别交通标志,我们将评估我们的分类器在准确率、精确率和召回率方面的性能。
为了重用我们之前的代码,我们只需要计算 y_hat 并通过以下方式将 y_true 一起传递给度量函数:
- 首先,我们使用存储在特征化训练数据时的
pca_args和_pca_featurize函数,对测试数据进行特征化,如下所示:
x_test = _pca_featurize(np.array(data)[test], *pca_args)
- 然后,我们预测新的标签,如下所示:
_, predicted = mlp.predict(x_test)
y_hat = np.array([index_to_label[np.argmax(y)] for y
in predicte
- 最后,我们使用存储的测试索引提取真实的测试标签,如下所示:
y_true = np.array(targets)[test]
剩下的唯一要传递给函数的是 y_hat 和 y_true,以计算我们分类器的准确率。
我需要 84 张图片(每种情绪 10-15 张)才能达到 0.92 的训练准确率,并拥有足够好的分类器向我的朋友们展示我的软件。你能打败它吗?
现在,让我们看看如何运行训练脚本,并以演示应用程序能够使用的方式保存输出。
运行脚本
可以使用 train_classifier.py 脚本来训练和测试 MLP 分类器,该脚本执行以下操作:
- 脚本首先将
--data命令行选项设置为保存数据的位置,将--save设置为我们想要保存训练模型的目录位置(此参数是可选的),如下所示:
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--data', required=True)
parser.add_argument('--save', type=Path)
args = parser.parse_args()
- 然后,我们加载保存的数据,并按照上一节中描述的训练过程进行,如下所示:
data, targets = load_collected_data(args.data)
mlp = cv2.ml.ANN_MLP_create()
...
mlp.train(...
- 最后,我们通过运行以下代码检查用户是否希望我们保存训练好的模型:
if args.save:
print('args.save')
x_all, pca_args = pca_featurize(np.array(data))
mlp.train(x_all, cv2.ml.ROW_SAMPLE, encoded_targets)
mlp.save(str(args.save / 'mlp.xml'))
pickle_dump(index_to_label, args.save / 'index_to_label')
pickle_dump(pca_args, args.save / 'pca_args')
之前的代码保存了训练好的模型,index_to_label 字典,以便在演示中显示可读的标签,以及 pca_args,以便在演示中特征化实时摄像头帧。
保存的 mlp.xml 文件包含网络配置和学习的权重。OpenCV 知道如何加载它。所以,让我们看看演示应用程序的样子。
将所有这些放在一起
为了运行我们的应用程序,我们需要执行主函数例程(chapter8.py),该例程加载预训练的级联分类器和预训练的 MLP,并将它们应用于网络摄像头的实时流中的每一帧。
然而,这次,我们不会收集更多的训练样本,而是以不同的选项启动程序,如下所示:
$ python chapter8.py demo --classifier data/clf1
这将启动应用程序,并使用新的 FacialExpressionRecognizerLayout 布局,它是 BasicLayout 的子类,没有任何额外的 UI 元素。让我们首先看看构造函数,如下所示:
- 它读取并初始化由训练脚本存储的所有数据,如下所示:
class FacialExpressionRecognizerLayout(BaseLayout):
def __init__(self, *args,
clf_path=None,
**kwargs):
super().__init__(*args, **kwargs)
- 使用
ANN_MLP_load加载预训练的分类器,如下所示:
self.clf = cv2.ml.ANN_MLP_load(str(clf_path / 'mlp.xml'))
- 它加载我们想要从训练中传递的 Python 变量,如下所示:
self.index_to_label = pickle_load(clf_path
/ 'index_to_label')
self.pca_args = pickle_load(clf_path / 'pca_args')
- 它初始化一个
FaceDetector类,以便能够进行人脸识别,如下所示:
self.face_detector = FaceDetector(
face_cascade='params/
haarcascade_frontalface_default.xml',
eye_cascade='params/haarcascade_lefteye_2splits.xml')
一旦我们从训练中获得了所有这些部件,我们就可以继续编写代码来为面部添加标签。在这个演示中,我们没有使用任何额外的按钮;因此,我们唯一要实现的方法是process_frame,它首先尝试在实时流中检测人脸并在其上方放置标签,我们将按以下步骤进行:
- 首先,我们尝试通过运行以下代码来检测视频流中是否存在人脸:
def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
success, frame, self.head, (x, y) =
self.face_detector.detect_face(frame_rgb)
- 如果没有人脸,我们不做任何操作,并返回一个未处理的
frame,如下所示:
if not success:
return frame
- 一旦检测到人脸,我们尝试将人脸对齐(与收集训练数据时相同),如下所示:
success, head = self.face_detector.align_head(self.head)
if not success:
return frame
- 如果我们成功,我们将使用 MLP 对头部进行特征化并预测标签,如下所示:
_, output = self.clf.predict(self.featruize_head(head))
label = self.index_to_label[np.argmax(output)]
- 最后,我们通过运行以下代码将带有标签的文本放在人脸的边界框上,并将其显示给用户:
cv2.putText(frame, label, (x, y - 20),
cv2.FONT_HERSHEY_COMPLEX, 1, (0, 255, 0), 2)
return frame
在前面的方法中,我们使用了featurize_head,这是一个方便的函数来调用_pca_featurize,如下面的代码块所示:
def featurize_head(self, head):
return _pca_featurize(head[None], *self.pca_args)
最终结果如下所示:
尽管分类器只训练了大约 100 个训练样本,但它能够可靠地检测直播流中每一帧的我的各种面部表情,无论我的脸在那一刻看起来多么扭曲。
这表明之前训练的神经网络既没有欠拟合也没有过拟合数据,因为它能够预测正确的类别标签,即使是对于新的数据样本。
摘要
这本书的这一章真正总结了我们的经验,并使我们能够将各种技能结合起来,最终开发出一个端到端的应用程序,该应用程序包括物体检测和物体识别。我们熟悉了 OpenCV 提供的各种预训练的级联分类器,我们收集并创建了我们的训练数据集,学习了 MLP,并将它们训练来识别面部表情(至少是我的面部表情)。
分类器无疑受益于我是数据集中唯一的主题这一事实,但,凭借你在整本书中学到的所有知识和经验,现在是时候克服这些限制了。
在学习本章的技术之后,你可以从一些较小的东西开始,并在你(室内和室外,白天和夜晚,夏天和冬天)的图像上训练分类器。或者,你可以看看Kaggle 的面部表情识别挑战,那里有很多你可以玩的数据。
如果你对机器学习感兴趣,你可能已经知道有许多可用的库,例如Pylearn、scikit-learn和PyTorch。
在下一章中,你将开始你的深度学习之旅,并亲手操作深度卷积神经网络。你将熟悉多个深度学习概念,并使用迁移学习创建和训练自己的分类和定位网络。为了完成这项任务,你将使用Keras中可用的预训练分类卷积神经网络。在整个章节中,你将广泛使用Keras和TensorFlow,它们是当时最受欢迎的深度学习框架之一。
进一步阅读
-
Kaggle 面部表情识别挑战:
www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge. -
Pylearn:
github.com/lisa-lab/pylearn2. -
scikit-learn:
scikit-learn.org. -
pycaffe:
caffe.berkeleyvision.org. -
Theano:
deeplearning.net/software/theano. -
Torch:
torch.ch. -
UC Irvine 机器学习库:
archive.ics.uci.edu/ml.
贡献
Lenna.png—图像 Lenna 可在 www.flickr.com/photos/15489034@N00/3388463896 由 Conor Lawless 提供,授权为 CC 2.0 Generic。
991

被折叠的 条评论
为什么被折叠?



