OpenCV-Python实战(32)——基于深度学习的物体分类与定位(学习笔记)

前段时间遇到一个小问题,后来发现这是个挺常见的坑,顺手整理一篇笔记。

OpenCV-Python实战(32)——基于深度学的物体分类与定位

4. 使用卷积神经网络进行分类 5. 使用卷积神经网络进行定位 小结系列链接

0. 前言

咱们已经研究了一系列算法和方式,学会了如何借助计算机视觉解决实际问题。近年来,随着图形处理单元 (Graphical Processing Unit, GPU) 等设备提供的硬件计算能力的显著提升,许多利用这种能力并在计算机视觉任务中取得最先进成果的算法应运而生。通常,这些算法基于神经网络,使算法的创建者能够从数据中提取出大量有意义的信息。
与此同时,与经典方式相比,这些信息往往难以解释。从这个角度来看,也能说咱们正在接近人工智能——即咱们给计算机提供一种方式,它自己会弄清楚如何完成其余的干活,我们在本节中将学深度学习模型。
计算机视觉中的一些经典问题包括对象目标检测和定位。让我们在本节中探讨如何借助深度学习模型对物体进行分类和定位。本节的目标是学习重要的深度学习概念,如迁移学习,以及如何应用它们来构建自己的物体分类器和定位器。
我们将从准备训练数据集开始。然后,我们将继续学习如何使用预训练模型来创建新的分类器。一旦理解了如何做到这一点,我们将进一步构建更复杂的架构来执行定位。
在本节中,我们将使用 Oxford-IIIT-Pet 数据集。最后,我们将运行应用程序,该应用将使用我们训练好的定位器网络进行推理。尽管该网络仅使用宠物头部的边框进行训练,但我们会看到它也能很好地用于定位人类头部的位置。后者展示了我们模型的泛化能力。
学习这些深度学习概念并观察它们的实际应用,对于将来使用深度学习模型构建自己的应用程序或开始研究全新的深度学习架构挺有用。

1. 规划应用程序

最终的应用程序将包含准备数据集、训练模型以及使用摄像头输入进行模型推理的模块。这将需要以下组件:

  • main.py:用于启动应用程序并实时定位(宠物)头部的主脚本
  • data.py:用于下载和准备训练数据集的模块
  • classification.py:用于训练分类器网络的脚本
  • localization.py:用于训练和保存定位网络的脚本
准备好训练数据集后,我们将完成以下步骤来构建应用程序:

1. 首先,使用迁移学习训练一个分类网络
2. 其次,再次使用迁移学习训练一个目标定位网络
3. 创建并训练好定位网络后,我们将运行 main.py 脚本以实时定位头部

让我们首先学习如何准备将运行我们应用程序的推理脚本。该脚本将连接到计算机摄像头,使用我们将创建的定位模型在视频流的每一帧中找到头部位置,然后实时显示结果。

2. 准备推理脚本

我们的推理脚本相当简单。它首先准备一个绘图函数,然后加载模型并连接到摄像头。接着,它将循环处理来自视频流的帧。在循环中,对于流的每一帧,它将使用导入的模型进行推理,并使用绘图函数来显示结果。让我们通过以下步骤创建一个完整的脚本:

(1) 首先,导入所需的模块:

import numpy as np
import cv2
import tensorflow.keras as K

在这段代码中,除了导入 NumPyOpenCV,我们还导入了 Keras。我们将在这个脚本中使用 Keras 进行预测;此外,我们将在本节中使用它来创建和训练我们的模型。

(2) 然后,定义一个在帧上绘制定位边界框的函数:

def draw_box(frame: np.ndarray, box: np.ndarray) -> np.ndarray:
    h, w = frame.shape[0:2]
    pts = (box.reshape((2, 2)) * np.array([w, h])).astype(np.int)
    cv2.rectangle(frame, tuple(pts[0]), tuple(pts[1]), (0, 255, 0), 2)
    return frame

上述 draw_box 函数接受一个帧和一个边框两个角点的归一化坐标(以包含四个数字的数组形式)。该函数首先将一维的边框数组重塑为二维数组,里面第一维索引表示角点,第二维表示 xy 坐标。然后,通过将归一化坐标乘以由图像宽度和高度组成的数组,将其转换为图像坐标,并在同一行中将结果转换为整数值。最后,使用 cv2.rectangle 函数以绿色绘制边界框,并返回该帧。

(3) 然后,导入我们将在本节中准备的模型,并连接到摄像头:

model = K.models.load_model("localization.h5")

cap = cv2.VideoCapture(0)

model 将存储在一个二进制文件中,通过 Keras 的一个便捷函数导入。

(4) 之后,我们遍历来自摄像头的每一帧,将每帧调整到标准尺寸(即我们将创建的模型的默认图像尺寸),并将帧转换为 RGB (红、绿、蓝)颜色空间,因为我们将在 RGB 图像上训练模型:

for _, frame in iter(cap.read, (False, None)):
    input = cv2.resize(frame, (224, 224))
    input = cv2.cvtColor(input, cv2.COLOR_BGR2RGB)

(5) 在同一个循环中,我们对图像进行归一化,并在帧的形状上加一,因为模型接受批图像作为输入。然后,将结果传递给模型进行推理:

box, = model.predict(input[None] / 255)

(6) 通过使用之前定义的函数绘制预测出的边界框、显示结果,然后设置终止条件,来继续循环:

draw_box(frame, box)
    cv2.imshow("res", frame)
    if(cv2.waitKey(1) == 27):
        break

现在我们已经准备好了推理脚本,让我们在下一小节中从准备数据集开始,踏上创建模型的旅程。

3. 准备数据集

如前所述,在本节中,我们将使用 Oxford-IIIT-Pet 数据集。将数据集的准备封装在一个单独的 data.py 脚本中也能在整节中重复使用。与任何其他脚本一样,首先,我们必须导入所有必需的模块:

from glob import glob
import os

from itertools import count
from collections import defaultdict, namedtuple

import cv2
import numpy as np
import tensorflow as tf
import xml.etree.ElementTree as ET

为了准备使用我们的数据集,我们将首先下载数据集并将其解析到内存中。然后,从解析后的数据中,我们将创建一个 TensorFlow 数据集,这使我们能够以方便的方式处理数据集,并在后台准备数据,从而使数据准备过程不会中断神经网络的训练过程。这样一来,让我们在下一小节中继续下载并解析数据集。

3.1 下载并解析数据集

我们首先从官方网站下载数据集,然后将其解析为合适的格式。在此阶段,我们将暂时不加载图像,因为图像会占用大量内存。我们通过以下步骤来涵盖这个过程:

(1) 定义我们希望存储宠物数据集的位置,并使用 Keras 中的便捷函数 get_file 下载它:

DATASET_DIR = "dataset"
for type in ("annotations", "images"):
    tf.keras.utils.get_file(
        type,
        f"https://www.robots.ox.ac.uk/~vgg/data/pets/data/{type}.tar.gz",
        untar=True,
        cache_dir=".",
        cache_subdir=DATASET_DIR)

由于我们的数据集存放在一个压缩包中,我们通过传递 untar=True 来解压它。我们还将 cache_dir 指向当前目录。一旦文件被保存,后续执行 get_file 函数将不会有任何操作。

(2) 下载并解压数据集后,让我们为数据集和标注文件夹定义常量,并设置我们想要将图像调整到的目标尺寸:

IMAGE_SIZE = 224
IMAGE_ROOT = os.path.join(DATASET_DIR, "images/images")
XML_ROOT = os.path.join(DATASET_DIR, "annotations/annotations")

尺寸 224 通常是图像分类网络训练的默认尺寸。这样一来,最好保持该大小以提高准确性。

(3) 该数据集的标注以 XML 格式包含图像信息。在解析 XML 之前,让我们先定义我们想要获取哪些数据:

Data = namedtuple("Data", "image,box,size,type,breed")

namedtuplePython 中标准元组的扩展,允许我们通过名称来引用元组中的元素。我们定义的名称对应于我们感兴趣的数据元素。即:图像本身、宠物的头部边界框、图像尺寸、类型(猫或狗)以及品种(共有 37 个品种)。

(4) 在标注中,品种和类型是字符串;我们需要的是与品种相对应的数字。为此,我们定义两个字典:

types = defaultdict(count().__next__)
breeds = defaultdict(count().__next__)

defaultdict 是一个为未定义的键返回默认值的字典。在这里,当请求时,它会返回从零开始的下一个数字。

(5) 接下来,我们定义一个函数,给定一个 XML 文件的路径,它将返回一个数据实例:

def parse_xml(path: str) -> Data:

上述定义的函数包含以下步骤:

(5.1) 打开并解析 XML 文件:

with open(path) as f:
        xml_string = f.read()
    root = ET.fromstring(xml_string)

使用 ElementTree 模块解析 XML 文件的内容,该模块以便于导航的格式表示 XML

(5.2) 然后,获取对应图像的名称并提取品种名称:

img_name = root.find("./filename").text
    breed_name = img_name[:img_name.rindex("_")]

(5.3) 之后,使用之前定义的 breeds 字典将品种转换为数字,该字典会为每个未定义的键分配下一个数字:

breed_id = breeds[breed_name]

(5.4) 类似地,获取类型的 ID

type_id = types[root.find("./object/name").text]

(5.5) 然后,提取边界框并对其进行归一化:

box = np.array([int(root.find(f"./object/bndbox/{tag}").text)
                    for tag in "xmin,ymin,xmax,ymax".split(",")])
    size = np.array([int(root.find(f"./size/{tag}").text)
                     for tag in "width,height".split(",")])
    normed_box = (box.reshape((2, 2)) / size).reshape((4))

(5.6) 将结果作为 Data 的实例返回:

return Data(img_name, normed_box, size, type_id, breed_id)

(6) 现在我们已经下载了数据集并准备好了解析器,接下来继续解析数据集:

xml_paths = glob(os.path.join(XML_ROOT, "xmls", "*.xml"))
xml_paths.sort()
parsed = np.array([parse_xml(path) for path in xml_paths], dtype=object)

解析完数据集后,打印出可用的品种和类型:

print(f"{len(types)} TYPES:", *types.keys(), sep=", ")
print(f"{len(breeds)} BREEDS:", *breeds.keys(), sep=", ")

上述代码片段输出两种类型,即猫和狗,以及它们的品种:

在本节稍后部分,我们需要将数据集划分为训练集和测试集。为了进行良好的划分,我们理应从数据集中随机选取数据元素,以使训练集和测试集中包含成比例的品种数量。
对数据集进行打乱操作:

np.random.seed(1)
np.random.shuffle(parsed)

上述代码首先设置一个随机种子,这是为了每次执行代码时都能获得相同的结果。seed 方法接受一个参数,即指定随机序列的数字。
一旦设置了种子方法,在使用随机数的函数中就会得到相同的随机数序列,这样的数字称为伪随机数。这意味着,尽管它们看起来是随机的,但实际上是预定义的。在以上代码中,我们使用 shuffle 方法,它会打乱解析后的数组中元素的顺序。
现在我们已经将数据集解析成了一个 NumPy 数组,接下来从中创建一个 TensorFlow 数据集。

3.2 创建 TensorFlow 数据集

我们将使用 TensorFlow 数据集适配器来训练模型。当然,我们也能从数据集中创建一个 NumPy 数组,但将所有图像保留在内存中将需要大量内存。
相比之下,数据集适配器允许我们在需要时才将数据加载到内存中。此外,数据在后台加载和准备,这样就不会成为我们训练过程中的瓶颈。我们按如下方式转换解析后的数组:

ds = tuple(np.array(list(i)) for i in np.transpose(parsed))
ds_slices = tf.data.Dataset.from_tensor_slices(ds)

从上述代码片段中,from_tensor_slices 创建了一个数据集,其元素是给定张量的切片。在我们的例子中,张量是标签的 NumPy 数组(边界框、品种、图像位置等)。
在底层,这与 Pythonzip 函数概念类似。现在,我们打印数据集中的一个元素来查看其形式:

for el in ds_slices.take(1):
    print(el)

输出结果如下所示:

这是一个 TensorFlow 张量,包含我们从单个 XML 文件中解析出的所有信息。有了数据集后,我们可以检查所有的边界框是否正确:

for el in ds_slices:
    b = el[1].numpy()
    if(np.any((b > 1) | (b < 0)) or np.any(b[2:] < b[:2])):
        print(f"Invalid box found {b}")

由于我们已经对边界框进行了归一化,它们理应在 [0,1] 范围内。此外,我们还要确保边界框第一个点的坐标小于或等于第二个点的坐标。
现在,我们定义一个函数来转换我们的数据元素,以便将其输入到神经网络中:

def prepare(image, box, size, type, breed):
    image = tf.io.read_file(IMAGE_ROOT + "/" + image)
    image = tf.image.decode_png(image, channels=3)
    image = tf.image.resize(image, (IMAGE_SIZE, IMAGE_SIZE))
    image /= 255
    return Data(image, box, size, tf.one_hot(
        type, len(types)), tf.one_hot(breed, len(breeds)))

该函数首先加载对应的图像,将其调整到标准尺寸,并归一化到 [0,1] 范围。然后,使用 tf.one_hot 方法从类型和品种创建独热向量,并将结果作为 Data 的实例返回。
接下来,用该函数映射数据集:

ds = ds_slices.map(prepare).prefetch(128)

我们还调用了 prefetch 方法,该方法确保预取一定量的数据,以便我们的网络不必等待从硬盘加载数据。
如果我们直接运行数据准备脚本,通常需要展示一些数据样本。首先,我们创建一个函数,当给定一个数据样本时,它生成一个示例图像:

if __name__ == "__main__":
    def illustrate(sample):
        breed_num = np.argmax(sample.breed)
        for breed, num in breeds.items():
            if num == breed_num:
                break
        image = sample.image.numpy()
        pt1, pt2 = (sample.box.numpy().reshape(
            (2, 2)) * IMAGE_SIZE).astype(np.int32)
        cv2.rectangle(image, tuple(pt1), tuple(pt2), (0, 1, 0))
        cv2.putText(image, breed, (10, 10),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 1, 0))
        return image

该函数将品种的独热向量转换回数字,在 breeds 字典中找到品种名称,并连同品种名称一起绘制边界框。
现在,我们将几个这样的示例图像拼接起来,并显示结果图像:

samples_image = np.concatenate([illustrate(sample)
                                    for sample in ds.take(3)], axis=1)
    cv2.imshow("samples", samples_image)
    cv2.waitKey(0)

结果如下图所示:

上图展示了宠物以及它们头部周围的预期边界框。需要留意的是,尽管我们在脚本中使用了随机数来打乱数据集,但仍会得到与上图相同的结果,这就是伪随机数的作用。
现在我们已经准备好数据集,接下来我们将继续创建和训练分类器。我们将构建两个分类器——一个用于宠物类型,另一个用于品种。

4. 使用卷积神经网络进行分类

首先,要开始分类,我们必须导入所需的模块:

import tensorflow.keras as K
from data import ds

我们必须导入准备好的数据集以及将用于构建分类器的 Keras。在构建分类器之前,我们先了解卷积神经网络 (Convolutional Neural Network, CNN),因为我们将使用它们来构建分类器。

4.1 卷积神经网络

在《图像滤波》一节中,我们学习了滤波器和卷积。具体来说,学习了如何使用滤波器创建铅笔素描图像。在铅笔素描中,可以看到图像中值发生急剧变化的点,它们比那些变化平滑的点更暗。
从这个角度来看,我们应用的滤波器可以被视为边缘检测的滤波器。换句话说,滤波器充当特征检测器,里面特征是边缘。或者,可以组合一个不同的滤波器,它在角点上被激活,或者在颜色值没有变化时被激活。
我们使用的滤波器作用于单通道图像且是二维的;但我们可以将滤波器扩展到第三维,然后将其应用于多通道图像。例如,如果一个单通道滤波器的大小为 3×3,那么对应的三通道(例如 RGB )滤波器的大小将为 3×3×3,里面最后一个值是滤波器的深度。
这样的滤波器已经可以用于更复杂的特征。例如,可以设想一个与绿色相关的滤波器,同时通过在滤波器的相应元素中设置零来忽略红色和蓝色中的值。
设定好一组滤波器后,可以将它们应用于原始图像,然后将它们堆叠成一个新的多通道图像。例如,如果我们在图像上应用 100 个滤波器,我们将得到 100 个单通道图像,堆叠后将得到一个具有 100 个通道的图像。也就是说,我们构建了一个接受 3 个通道并输出 100 个通道的网络层。
接下来,我们可以组合深度为 100 的新滤波器,并作用于组合后的 100 通道图像。这些滤波器也可以在更复杂的特征上被激活。例如,如果前几层中存在在边缘上被激活的滤波器,我们可以组合一个在边缘交叉点上被激活的滤波器。
经过一系列网络层后,我们可能会看到被激活的滤波器,例如在人的鼻子、头部、车辆的车轮等上面,这实际上就是卷积网络的干活原理。那么问题来了:我们如何组合这些滤波器?答案是它们由网络学习得到。
我们提供数据,网络学习需要哪些滤波器才能做出良好的预测。与卷积滤波器相比,另一个不同之处在于,除了可学习的滤波器参数外,还有一个可学习的值,即添加到滤波器输出的常数项。
除此之外,在每一层的卷积滤波器之后,通常会对滤波器的输出应用一个非线性函数,称为激活函数。由于非线性的存在,网络能够表示更广泛的函数类别,从而有相对更高的机会构建出好的模型。
既然我们对卷积神经网络的干活原理有了一定的了解,接下来我们开始构建一个分类器。在构建本节网络时,将看到卷积层是如何构建和使用的。如前所述,我们为新模型使用预训练模型,或者换句话说,我们使用迁移学习。

4.2 迁移学习

通常,CNN 拥有数百万个参数,我们估算一下这些参数从何而来。
假设我们有一个 10 层的网络,每层有 100 个大小为 3×3 的滤波器。这些数字相当低,而性能良好的网络通常有几十层,每层有数百个滤波器。以我们的情况为例,每个滤波器的深度为 100。这样一来,每个滤波器有 3×3×100 = 900 个参数(不包括偏置,偏置的数量为 100),这意味着每层有 900×100 个参数,因此整个网络大约有 900000 个参数。从头开始学习这么多参数而不发生过拟合,需要相当大的标注数据集。
我们已经了解到,网络的各层充当特征提取器。除此之外,自然图像有很多共同点。因此,我们可以使用在大型数据集上训练过的网络的特征提取器来在另一个不同的、较小的数据集上获得良好性能,这种技术称为迁移学习。
选择一个预训练模型作为我们的基础模型,这在 Keras 中只需一行代码:

base_model = K.applications.MobileNetV2(input_shape=(224,224, 3), include_top=False)

这里,我们使用了 MobileNetV2 预训练网络,这是一个鲁棒且轻量级的网络。当然,我们也可以使用其他可用的模型,这些模型可以在 Keras 网站上找到,或者简单地用 dir(keras.applications) 列出。
我们通过传入 include_top=False,获取了去掉顶部分类层的网络版本,因为我们打算在它之上构建一个新的分类器。不过,网络仍然包含所有在 ImageNet 上训练过的其他层。ImageNet 是一个包含数百万张图像的数据集,每张图像都被标注为该数据集中 1000 个类别之一。
查看基础模型的输出形状:

print(base_model.output.shape)

结果如下:

(None, 7, 7, 1280)

第一个数字是未定义的,表示批大小,或者说输入图像的数量。假设我们同时向网络传递一组 10 张图像;那么这里的输出形状将是 (10,7,7,1280),张量的第一个维度将对应输入图像的编号。
接下来两个索引是输出形状的尺寸,最后一个是通道数。在原始模型中,这个输出表示输入图像的特征,这些特征随后用于分类 ImageNet 数据集中的图像。
因此,这些特征是对所有图像相当好的表征,网络可以基于它们对 ImageNet 中的图像进行分类。接下来,我们尝试利用这些特征来区分宠物的种类和品种。为此,我们首先在下一节中准备一个分类器。

4.3 宠物类型和品种分类器

由于我们将直接使用这些特征,因此首先冻结网络各层的权重,使其在训练过程中不会更新:

for layer in base_model.layers:
    layer.trainable = False

通常,激活图中的每个位置指示该位置是否存在对应类型的特征。由于我们处理的是网络的最后几层,可以假设激活图上的不同位置包含相似信息,并通过平均激活图来降低特征的维度:

x = K.layers.GlobalAveragePooling2D()(base_model.output)

该操作称为 AveragePooling2D——我们在特征张量的两个维度上对张量取平均池化。可以通过打印该操作的输入和输出形状来查看结果:

print(base_model.output.shape, x.shape)

输出如下所示:

(None, 7, 7, 1280) (None, 1280)

现在每张图像只剩下 1280 个特征,我们直接添加分类层,并准备数据集以便在种类或品种上进行训练:

is_breeds = True
if is_breeds:
    out = K.layers.Dense(37,activation="softmax")(x)
    inp_ds = ds.map(lambda d: (d.image,d.breed))
else:
    out = K.layers.Dense(2,activation="softmax")(x)
    inp_ds = ds.map(lambda d: (d.image,d.type))

在种类和品种上训练的区别仅在于输出神经元的数量和标签。对于品种,标签数量为 37;对于种类,标签数量为 2 (即猫或狗),这在代码中可以看到。全连接层 (Dense layer) 表示密集连接的神经元,这意味着该层中的每个神经元都连接到该层的全部 1280 个输入。
因此,每个神经元有 1280 + 1 个可学习参数,其中 1 对应偏置项。从数学上看,对于整个层,核的权重用一个大小为 (1280, 类别数) 的矩阵和一个高度为 1280 的列向量表示。
该层的线性部分可以写成如下形式:

(

a

x

+

b

)

(a*x+b)

(a∗x+b)
其中,x 是前一层的输出(在本节中是 1280 个平均后的特征),a 是权重矩阵,b 是偏置列向量。
此外,我们设置了 softmax 函数作为激活函数,这在分类任务中是一个很好的选择。softmax 函数的定义如下:

s

o

f

t

m

a

x

(

x

)

i

=

e

x

p

(

x

i

)

j

e

x

p

(

x

j

)

softmax(x)_i=\frac {exp(x_i)}{\sum_jexp(x_j)}

softmax(x)i​=∑j​exp(xj​)exp(xi​)​
其中,x 是激活的输入(线性部分的输出)。
可以看到,softmax 在所有输出上的总和为 1;因此,输出可以被视为对应类别的概率。
我们在数据集上定义的映射将把图像作为数据,品种或类型作为标签。
定义模型:

model = K.Model(inputs=base_model.input, outputs=out)

可以看到,网络的输入是我们的基础模型,输出是我们的分类器层。至此,我们已经成功构建了分类网络。既然我们已经准备好了分类器网络,在下一小节中我们将进行训练和评估。

4.4 训练和评估分类器

为了训练分类器,我们必须对其进行配置以用于训练。我们需要指定一个目标函数(损失函数)和一种训练方法。此外,我们可能还想指定一些指标来观察模型的性能。我们可以使用模型的 compile 方法来配置分类器:

model.compile(loss="categorical_crossentropy", optimizer="adam", metrics=["categorical_accuracy","top_k_categorical_accuracy"])

传递 categorical_accuracy 作为指标,它将显示数据集中哪一部分被正确分类。除此之外,我们还传递了另一个指标 top_k_categorical_accuracy,它显示数据集中哪一部分在网络的前 k 个预测中是正确的。
k 的默认值是 5,因此该指标显示数据集中哪一部分落在神经网络预测的最可能的五个类别中。我们还传递了 optimizer="adam",这强制模型使用 Adam 优化器作为训练算法。
在训练之前,我们还将数据集分割为训练集和测试集,以便观察网络在未见过的数据上的表现:

valid = inp_ds.take(1000)
train = inp_ds.skip(1000).shuffle(10**4)

这里,我们取数据集的前 1000 个元素用于测试目的,其余部分用于训练。
通过调用 shuffle 方法对训练部分进行打乱,这将确保在每个训练周期中数据具有不同的顺序。最后,我们通过调用数据集的 fit 方法来训练网络,然后在验证集上进行评估:

model.fit(train.batch(32), epochs=4)

fit 方法接收数据集本身,我们以 32 个样本为一批次传入,这意味着在训练过程的每一步,将使用数据集中的 32 张图像。我们还传递了轮次数(epochs),这意味着数据集将被迭代 4 次,直到训练过程停止。最后一个周期的输出如下所示:

Epoch 4/4
84/84 [==============================] - 13s 156ms/step - loss: 0.0834 -
categorical_accuracy: 0.9717 - top_k_categorical_accuracy: 1.0000

在训练集上,我们的分类准确率 (categorical accuracy) 超过了 97%。因此,我们在区分猫和狗方面表现得相当不错。当然,由于只有两个类别,top-K 准确率会是 99.96%。接下来,我们看看在验证集上的表现如何。
训练完成后,评估模型,会得到与测试集类似的结果:

model.evaluate(valid.batch(1))

输出如下:

1000/1000 [==============================] - 9s 9ms/step - loss: 0.0954 -
categorical_accuracy: 0.9730 - top_k_categorical_accuracy: 1.0000

我们获得了超过 97% 的分类准确率。因此,我们的模型没有过拟合,并且在测试集上表现良好。
如果我们在品种上进行训练,训练过程的输出如下所示:

Epoch 4/4
84/84 [==============================] - 13s 155ms/step - loss: 0.3272 -
categorical_accuracy: 0.9233 - top_k_categorical_accuracy:
0.9963

同时,测试的输出如下所示:

1000/1000 [==============================] - 11s 11ms/step - loss: 0.5646 -
categorical_accuracy: 0.8080 - top_k_categorical_accuracy: 0.9890

对于品种,我们得到的结果较差,这在意料之中,因为区分品种比仅仅判断是猫还是狗要困难得多。无论如何,该模型表现并不算太差。它的首次猜测有超过 80% 的正确率,而且我们大约有 99% 的把握觉得,如果给它 5 次尝试,它能够猜对品种。
在本节中,我们学习了如何利用预训练的分类网络来构建一个新的分类器。下一小节,我们将继续深度学习之旅,使用同样的基准模型创建一个目标定位网络。

5. 使用卷积神经网络进行定位

能够创建自己的定位器是直观理解目标检测网络工作原理的好方法。这是因为目标检测网络和定位网络之间唯一的概念区别在于:定位网络预测单个边界框,而目标检测网络预测多个边界框。同时,这也有助于理解如何构建完成其他回归任务的神经网络。
在本节中,我们将使用与前一小节相同的预训练分类器网络 MobileNetV2。但,这次我们将使用该网络进行目标定位,而不是分类。让我们以与前一节相同的方式导入所需的模块和基础模型——但这次,我们不会冻结基础模型的层:

import tensorflow.keras as K
from data import ds

base_model = K.applications.MobileNetV2(
    input_shape=(224, 224, 3), include_top=False)

一切就绪,让我们继续准备我们的定位器模型。

5.1 准备模型

首先,让我们思考如何利用基础模型的输出来构建一个定位器。如前所述,基础模型的输出张量形状为 (None, 7, 7, 1280)。该输出张量表示通过卷积网络获得的特征。我们可以假设一些空间信息被编码在空间索引 (7,7) 中。
让我们尝试使用几个卷积层来降低特征图的维度,并创建一个回归器,该回归器应预测数据集提供的宠物头部边界框的顶点坐标。
我们的卷积层将具有若干相同的选项:

conv_opts = dict(
    activation='relu',
    padding='same',
    kernel_regularizer="l2")

首先,它们都使用 ReLU 作为激活函数。ReLU 是一个简单的函数:当输入小于零时输出为零,当输入大于或等于零时输出等于输入。
padding='same' 指定我们不希望卷积操作减小特征图的尺寸。特征图将用零填充,以便特征图的尺寸不减小。这与 padding='valid' 相反,后者仅在特征图的边缘内部应用卷积核。
通常,可以对训练参数进行正则化、归一化,或两者兼行。后者通常能使训练得更轻松、更快速,并获得更好的泛化能力。正则化器允许我们在优化过程中对层参数施加惩罚,这些惩罚被纳入网络优化的损失函数中。
在本节中,我们使用了 L2 核正则化器,它对卷积核权重的欧几里得范数进行正则化。正则化是通过在损失函数(目标函数)中添加

λ

w

2

λ∥w∥^2

λ∥w∥2 项来实现的。其中,

λ

λ

λ 是一个小常数,

w

∥w∥

∥w∥ 是 L2 范数,等于该层参数的平方和的开平方根,这是最广泛使用的正则化项之一。现在,我们准备定义卷积层。第一层如下所示:

x = K.layers.Conv2D(256, (1, 1), **conv_opts)(base_model.output)

其中,第一个参数是输出通道数,也是卷积滤波器的数量,第二个参数描述卷积滤波器的大小。乍一看,单像素卷积核似乎意义不大,因为它无法编码特征图的上下文信息。但在这种情况下,它作为一种快速操作,可以将输入特征图的深度编码到更低的维度中,深度从 1280 减少到 256
第二层如下所示:

x = K.layers.Conv2D(256, (3, 3), strides=2, **conv_opts)(x)

在这里,除了我们使用的默认选项外,我们还使用了 strides (步长),它指定了在输入上移动的像素数。在《图像滤波》一节中,卷积操作是在每个位置应用的,这意味着滤波器一次移动一个像素,这等同于步长等于 1
strides 选项为 2 时,我们在每一步中将滤波器移动两个像素。该选项采用复数形式,因为我们可能希望在不同方向上有不同的步长,这可以通过传入一个数字元组来实现。应用步长值大于 1 是减小激活图尺寸而不丢失空间信息的一种方法。
当然,还有其他操作可以减小激活图的尺寸。例如,可以使用最大池化操作,这是现代卷积网络中最广泛使用的操作之一。后者取一个小窗口尺寸(例如 2×2),从该窗口中选择最大值,移动指定数量的像素(例如 2),并在整个激活图上重复该过程,最终激活图的尺寸将减少 2 倍。
与使用步长的方法相比,最大池化操作更适合我们对空间信息不太感兴趣的任务。例如,分类任务就是这样的任务,我们不太关心物体确切的位置,而只是关心它是什么。当我们简单地取窗口中的最大值而不考虑其在窗口中的位置时,最大池化中就会发生空间信息的丢失。
我们要做的最后一件事是将一个包含四个神经元的全连接层连接到卷积层,该层将被回归到边界框的两个顶点坐标(每个顶点的 (x, y)):

out = K.layers.Flatten()(x)
out = K.layers.Dense(4, activation="sigmoid")(out)

由于边界框的坐标是归一化的,因此最好使用一个值域在 (0,1) 范围内的激活函数,例如在本节中使用 sigmoid 函数。
所有需要的层都已准备就绪。现在,让我们用这些新层来定义模型,并进行编译以进行训练:

model = K.Model(inputs=base_model.input, outputs=out)
model.compile(
    loss="mean_squared_error",
    optimizer="adam",
    metrics=[
        K.metrics.RootMeanSquaredError(),
        "mae"])

我们使用均方误差 (Mean Squared Error, MSE) 作为损失函数,它是真实值与预测值之间平方差。在训练过程中,该值将被最小化;因此,模型理应在训练后能够预测边界框顶点坐标。
正如之前讨论的,我们添加到卷积层的正则化项也会被添加到损失中。后者由 Keras 自动完成。此外,我们使用均方根误差 (Root of MSE, RMSE) 以及平均绝对误差 (Mean Absolute Error, MAE,衡量误差的平均幅度)作为我们的指标。
现在,按照与上一小节相同的方式拆分数据集:

inp_ds = ds.map(lambda d: (d.image, d.box))
valid = inp_ds.take(1000)
train = inp_ds.skip(1000).shuffle(10000)

剩下要做的就是训练我们的模型,就像我们在上一小节中所做的那样。然而,在继续训练之前,我们可能会有兴趣了解新层的训练究竟是如何完成的。在多层神经网络中,训练通常使用反向传播算法完成,我们在下一小节中进行介绍。

5.3 反向传播

当我们拥有网络的一些最优权重,使得网络能够对我们的数据做出良好预测时,神经网络就被觉得已经训练好了。那么,问题是我们如何达到这些最优权重?神经网络通常使用梯度下降算法进行训练,这可以是纯梯度下降算法,或者一些改进的优化方法,例如 Adam 优化器,它同样是基于梯度的计算。
在这些算法中,我们需要计算损失函数相对于所有权重的梯度。由于神经网络是一个复杂的函数,因此计算梯度并不简单。这正是反向传播算法发挥作用的地方,它允许我们轻松地计算复杂网络中的梯度,并理解梯度的形式。
假设我们有一个由 N 个顺序层组成的神经网络。一般来说,网络中的第i层是可以定义如下的函数:

f

i

=

f

i

(

w

i

,

f

i

1

)

f_i=f_i(w_i,f_{i-1})

fi​=fi​(wi​,fi−1​)
其中,

w

i

w_i

wi​ 是第

i

i

i 层的权重,

f

i

1

f_{i−1}

fi−1​ 是对应于前一层的函数。
我们可以将

f

0

f_0

f0​ 定义为网络的输入,这样该公式就适用于包括第一层在内的整个神经网络。我们也可以将

f

N

+

1

f_{N+1}

fN+1​ 定义为我们损失函数,这样该公式不仅定义了所有层,还定义了损失函数。当然,这种泛化排除了我们已经使用的权重正则化项。但,这是一个简单的项,只是累加到损失中,因此为了简化可以省略。
我们可以通过设置

i

=

N

+

1

i=N+1

i=N+1 并使用链式法则来计算损失函数的梯度如下:

f

i

w

1...

i

=

f

i

f

i

1

×

f

i

w

1...

i

1

+

f

i

w

i

\frac{\partial f_i}{\partial w_{1...i}}=\frac{\partial f_i}{\partial f_{i-1}}\times\frac{\partial f_i}{\partial w_{1...i-1}}+\frac{\partial f_i}{\partial w_i}

∂w1...i​∂fi​​=∂fi−1​∂fi​​×∂w1...i−1​∂fi​​+∂wi​∂fi​​
根据我们的定义,这个公式不仅对损失函数成立,而且对所有层都是通用的。在这个公式中,我们可以看到,某一层相对于前一层中所有权重(包括当前层)的偏导数,是用前一层的相同导数(即公式中的

f

i

w

1...

i

1

\frac{\partial f_i}{\partial w_{1...i-1}}

∂w1...i−1​∂fi​​ 项)以及仅使用当前层就可以计算的项(即

f

i

f

i

1

\frac{\partial f_i}{\partial f_{i-1}}

∂fi−1​∂fi​​ 和

f

i

w

i

\frac{\partial f_i}{\partial w_i}

∂wi​∂fi​​ )来表示的。
利用该公式,我们现在可以数值计算梯度。为此,我们首先定义一个表示误差信号的变量,并将其初始值设为 1。然后,我们从最后一层(在本节中是损失函数)开始,重复以下步骤,直到到达网络的输入:

1. 计算当前层相对于其权重的偏导数,并乘以误差信号。这将得到梯度中对应于当前层权重的部分
2. 计算相对于前一层的偏导数,乘以误差信号,然后用结果值更新误差信号
3. 如果尚未到达网络输入,则移动到前一层并重复上述步骤

一旦我们到达输入,我们就得到了损失相对于所有可学习权重的偏导数;因此,我们得到了损失函数的梯度。现在我们可以留意到,这就是某一层相对于前一层的偏导数,它在梯度计算过程中通过网络反向传播。
这是一个传播信号,它影响每一层对损失函数梯度的贡献。例如,如果在传播过程中的某处信号变为全零,那么之后所有剩余层对梯度的贡献也将为零。这种现象称为梯度消失问题。该算法可以推广到具有不同类型分支的无环网络。
为了训练我们的网络,剩下要做的就是在梯度的方向上更新我们的权重,并重复这个过程直到收敛。如果使用纯梯度下降算法,我们只需从权重中减去乘以某个小常数的梯度;但现在通常使用更先进的优化算法,例如 Adam 优化器。
纯梯度下降算法的问题在于:首先,我们需要为那个小常数找到某个最优值,以使权重的更新既不会太小(导致学习缓慢),也不会太大(导致不稳定)。另一个问题是,一旦我们找到了最优值,当网络开始收敛时,我们必须开始减小它。更重要的是,用不同的因子更新不同的权重通常是明智的,因为不同的权重可能离它们的最优值有不同的距离。
这些就是我们可能想要使用更先进的优化技术(如 Adam 优化器或 RMSProp )的部分原因,它们考虑了上述部分或全部问题,甚至一些未提及的问题。同时,在创建自己的网络时,应该留意到优化算法领域的研究仍在进行中,在某些情况下,现有的某个优化器可能比其他更好,但 Adam 优化器对许多任务来说应该是一个不错的选择。
我们可能还会注意到,在算法中,我们并没有确切说明如何计算一层中的偏导数。当然,它们可以通过改变值和测量响应来数值计算,就像用数值方法计算导数一样。问题在于这种计算会很繁重且容易出错。更好的方法是为使用的每个操作定义一个符号表示,然后再次使用像反向传播中的链式法则。
我们现在理解了完整梯度是如何计算的,实际上,大多数现代深度学习框架都会自动完成微分。通常不需要担心它具体是如何实现的,但
但现在,让我们在下一节中训练我们准备好的模型,看看它的表现如何。

5.4 训练模型

在实际训练之前,最好有一些方法来保存具有最佳权重的模型。为此,我们将使用 Keras 中的一个回调函数:

checkpoint = K.callbacks.ModelCheckpoint("localization.h5", monitor='val_root_mean_squared_error',
                                         save_best_only=True, verbose=1)

该回调函数将在每个训练周期后被调用;它会计算验证数据上预测的均方根误差指标,如果该指标有所改善,则将模型保存到 localization.h5 文件中。
现在,我们以与分类相同的方式训练我们的模型:

model.fit(
    train.batch(32),
    epochs=12,
    validation_data=valid.batch(1),
    callbacks=[checkpoint])

在训练过程中,将首先看到损失和指标在训练数据和验证数据上都逐渐下降。经过几个周期后,可能会看到验证数据上的指标开始上升。后者可能被觉得是过拟合的迹象,但经过更多周期后,可能会看到 validation_data 上的指标突然下降,这种现象是因为模型在优化过程中转换到了一个更好的最小指标。
以下是监测指标最低值的结果:

可以注意到,在这种情况下,第 8 个周期在验证数据上表现最佳。可以看到验证数据上的 RMSE 偏差约为 6%MAE 小于 6%。我们可以这样解释这个结果:给定验证数据中的一张图像,边界框的顶点坐标通常会偏移图像尺寸的 1/20,考虑到边界框的尺寸与图像尺寸相当,这算是不错的结果。
我们还可以尝试用冻结的基础模型层来训练模型。如果这样做,会注意到性能比不冻结的模型差得多。根据指标,它在验证数据集上的表现大约差一倍。根据这些数字,我们可以得出结论:基础模型的网络层能够在数据集上进行学习,从而使我们的模型在定位任务上表现得更好。
模型准备完成后,我们在下一小节中使用推理脚本来看看它的实际效果。
观察推理的实际效果
一旦我们运行推理脚本,它将连接到摄像头,并在每一帧上定位一个边界框,如下图所示:

尽管该模型是在宠物头部位置上训练的,但我们可以看到它也能够定位人的头部,这正是模型泛化能力的体现。
当我们创建自己的深度学习应用程序时,可能会注意到我们的特定应用缺乏数据。然而,如果将具体案例与其他可用的数据集联系起来,也许能够找到一些适用的数据集,尽管这些数据集有所不同,但它们可能允许我们成功训练模型。

小结

在本节中,我们使用 Oxford-IIIT-Pet 数据集创建并训练了分类和定位模型。我们学习了如何使用迁移学习来创建深度学习分类器和定位器。我们已经开始理解如何使用深度学习来解决现实世界的问题,已经理解了 CNN 的工作原理,并且知道如何使用基础模型创建新的 CNN。我们还介绍了用于计算梯度的反向传播算法。理解该算法将使我们能够在构建自定义模型架构时做出更明智的决策。

系列链接

OpenCV-Python实战(1)——OpenCV简介与图像处理基础
OpenCV-Python实战(2)——图像与视频文件的处理
OpenCV-Python实战(3)——OpenCV中绘制图形与文本
OpenCV-Python实战(4)——OpenCV常用图像处理技术
OpenCV-Python实战(5)——OpenCV图像运算
OpenCV-Python实战(6)——OpenCV中的色彩空间和色彩映射
OpenCV-Python实战(7)——直方图详解
OpenCV-Python实战(8)——直方图均衡化
OpenCV-Python实战(9)——OpenCV用于图像分割的阈值技术
OpenCV-Python实战(10)——OpenCV轮廓检测
OpenCV-Python实战(11)——OpenCV轮廓检测相关应用
OpenCV-Python实战(12)——一文详解AR增强现实
OpenCV-Python实战(13)——OpenCV与机器学习的碰撞
OpenCV-Python实战(14)——人脸检测详解
OpenCV-Python实战(15)——面部特征点检测详解
OpenCV-Python实战(16)——人脸追踪详解
OpenCV-Python实战(17)——人脸识别详解
OpenCV-Python实战(18)——深度学习简介与入门示例
OpenCV-Python实战(19)——OpenCV与深度学习的碰撞
OpenCV-Python实战(20)——OpenCV计算机视觉工程在Web端的部署
OpenCV-Python实战(21)——OpenCV人脸检测工程在Web端的部署
OpenCV-Python实战(22)——使用Keras和Flask在Web端部署图像识别应用
OpenCV-Python实战(23)——将OpenCV计算机视觉工程部署到云端
OpenCV-Python实战(24)——打造实时图像滤镜系统
OpenCV-Python实战(25)——基于深度传感器与凸性分析打造实时手势识别系统
OpenCV-Python实战(26)——复杂场景下的实时物体检测与跟踪
OpenCV-Python实战(27)——基于对极几何的3D场景重建
OpenCV-Python实战(28)——OpenCV计算摄影从HDR图像融合到全景拼接
OpenCV-Python实战(29)——基于视觉显著性的自动多目标跟踪系统
OpenCV-Python实战(30)——基于支持向量机的交通标志识别模型
OpenCV-Python实战(31)——实时面部情绪检测与识别系统


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论