在过去几个月中,我一直在实验室中研究提升目标检测的方法。在这之中我获得的最大启发就是意识到:学习目标检测的最佳方法就是自己动手实现这些算法,而这正是本教程引导你去做的。 在本教程中,我们将使用 PyTorch 实现基于 YOLO v3 的目标检测器,后者是一种快速的目标检测算法。 本教程使用的代码需要运行在 Python 3.5 和 PyTorch 0.3 版本之上。你可以在以下链接中找到所有代码: https://ift.tt/2HEZllp 本教程包含五个部分: 1. YOLO 的工作原理 2. 创建 YOLO 网络层级 3. 实现网络的前向传播 4. objectness 置信度阈值和非极大值抑制 5. 设计输入和输出管道 所需背景知识 在学习本教程之前,你需要了解: 什么是 YOLO?YOLO 是 You Only Look Once 的缩写。它是一种使用深度卷积神经网络学得的特征来检测对象的目标检测器。在我们上手写代码之前,我们必须先了解 YOLO 的工作原理。 全卷积神经网络 YOLO 仅使用卷积层,这就使其成为全卷积神经网络(FCN)。它拥有 75 个卷积层,还有跳过连接和上采样层。它不使用任何形式的池化,使用步幅为 2 的卷积层对特征图进行下采样。这有助于防止通常由池化导致的低级特征丢失。 作为 FCN,YOLO 对于输入图像的大小并不敏感。然而,在实践中,我们可能想要持续不变的输入大小,因为各种问题只有在我们实现算法时才会浮现出来。 这其中的一个重要问题是:如果我们希望按批次处理图像(批量图像由 GPU 并行处理,这样可以提升速度),我们就需要固定所有图像的高度和宽度。这就需要将多个图像整合进一个大的批次(将许多 PyTorch 张量合并成一个)。 YOLO 通过被步幅对图像进行上采样。例如,如果网络的步幅是 32,则大小为 416×416 的输入图像将产生 13×13 的输出。通常,网络层中的任意步幅都指层的输入除以输入。 解释输出 典型地(对于所有目标检测器都是这种情况),卷积层所学习的特征会被传递到分类器/回归器,从而进行预测(边界框的坐标、类别标签等)。 在 YOLO 中,预测是通过卷积层完成的(它是一个全卷积神经网络,请记住!)其核心尺寸为: 1×1×(B×(5+C)) 现在,首先要注意的是我们的输出是一个特征图。由于我们使用了 1×1 的卷积,所以预测图的大小恰好是之前特征图的大小。在 YOLO v3(及其更新的版本)上,预测图就是每个可以预测固定数量边界框的单元格。 虽然形容特征图中单元的正确术语应该是「神经元」,但本文中为了更为直观,我们将其称为单元格(cell)。
深度方面,特征图中有 (B x (5 + C))* *个条目。B 代表每个单元可以预测的边界框数量。根据 YOLO 的论文,这些 B 边界框中的每一个都可能专门用于检测某种对象。每个边界框都有 5+C 个属性,分别描述每个边界框的中心坐标、维度、objectness 分数和 C 类置信度。YOLO v3 在每个单元中预测 3 个边界框。 如果对象的中心位于单元格的感受野内,你会希望特征图的每个单元格都可以通过其中一个边界框预测对象。(感受野是输入图像对于单元格可见的区域。) 这与 YOLO 是如何训练的有关,只有一个边界框负责检测任意给定对象。首先,我们必须确定这个边界框属于哪个单元格。 因此,我们需要切分输入图像,把它拆成维度等于最终特征图的网格。 让我们思考下面一个例子,其中输入图像大小是 416×416,网络的步幅是 32。如之前所述,特征图的维度会是 13×13。随后,我们将输入图像分为 13×13 个网格。 输入图像中包含了真值对象框中心的网格会作为负责预测对象的单元格。在图像中,它是被标记为红色的单元格,其中包含了真值框的中心(被标记为黄色)。 现在,红色单元格是网格中第七行的第七个。我们现在使特征图中第七行第七个单元格(特征图中的对应单元格)作为检测狗的单元。 现在,这个单元格可以预测三个边界框。哪个将会分配给狗的真值标签?为了理解这一点,我们必须理解锚点的概念。 请注意,我们在这里讨论的单元格是预测特征图上的单元格,我们将输入图像分隔成网格,以确定预测特征图的哪个单元格负责预测对象。 锚点框(Anchor Box) 预测边界框的宽度和高度看起来非常合理,但在实践中,训练会带来不稳定的梯度。所以,现在大部分目标检测器都是预测对数空间(log-space)变换,或者预测与预训练默认边界框(即锚点)之间的偏移。 然后,这些变换被应用到锚点框来获得预测。YOLO v3 有三个锚点,所以每个单元格会预测 3 个边界框。 回到前面的问题,负责检测狗的边界框的锚点有最高的 IoU,且有真值框。 预测 下面的公式描述了网络输出是如何转换,以获得边界框预测结果的。 中心坐标 注意:我们使用 sigmoid 函数进行中心坐标预测。这使得输出值在 0 和 1 之间。原因如下: 正常情况下,YOLO 不会预测边界框中心的确切坐标。它预测: 与预测目标的网格单元左上角相关的偏移; 使用特征图单元的维度(1)进行归一化的偏移。 以我们的图像为例。如果中心的预测是 (0.4, 0.7),则中心在 13 x 13 特征图上的坐标是 (6.4, 6.7)(红色单元的左上角坐标是 (6,6))。 但是,如果预测到的 x,y 坐标大于 1,比如 (1.2, 0.7)。那么中心坐标是 (7.2, 6.7)。注意该中心在红色单元右侧的单元中,或第 7 行的第 8 个单元。这打破了 YOLO 背后的理论,因为如果我们假设红色框负责预测目标狗,那么狗的中心必须在红色单元中,不应该在它旁边的网格单元中。 因此,为了解决这个问题,我们对输出执行 sigmoid 函数,将输出压缩到区间 0 到 1 之间,有效确保中心处于执行预测的网格单元中。 边界框的维度 我们对输出执行对数空间变换,然后乘锚点,来预测边界框的维度。 检测器输出在最终预测之前的变换过程,图源:https://ift.tt/1MxJmDa 得出的预测 bw 和 bh 使用图像的高和宽进行归一化。即,如果包含目标(狗)的框的预测 bx 和 by 是 (0.3, 0.8),那么 13 x 13 特征图的实际宽和高是 (13 x 0.3, 13 x 0.8)。 Objectness 分数 Object 分数表示目标在边界框内的概率。红色网格和相邻网格的 Object 分数应该接近 1,而角落处的网格的 Object 分数可能接近 0。 objectness 分数的计算也使用 sigmoid 函数,因此它可以被理解为概率。 类别置信度 类别置信度表示检测到的对象属于某个类别的概率(如狗、猫、香蕉、汽车等)。在 v3 之前,YOLO 需要对类别分数执行 softmax 函数操作。 但是,YOLO v3 舍弃了这种设计,作者选择使用 sigmoid 函数。因为对类别分数执行 softmax 操作的前提是类别是互斥的。简言之,如果对象属于一个类别,那么必须确保其不属于另一个类别。这在我们设置检测器的 COCO 数据集上是正确的。但是,当出现类别「女性」(Women)和「人」(Person)时,该假设不可行。这就是作者选择不使用 Softmax 激活函数的原因。 在不同尺度上的预测 YOLO v3 在 3 个不同尺度上进行预测。检测层用于在三个不同大小的特征图上执行预测,特征图步幅分别是 32、16、8。这意味着,当输入图像大小是 416 x 416 时,我们在尺度 13 x 13、26 x 26 和 52 x 52 上执行检测。 该网络在第一个检测层之前对输入图像执行下采样,检测层使用步幅为 32 的层的特征图执行检测。随后在执行因子为 2 的上采样后,并与前一个层的特征图(特征图大小相同)拼接。另一个检测在步幅为 16 的层中执行。重复同样的上采样步骤,最后一个检测在步幅为 8 的层中执行。 在每个尺度上,每个单元使用 3 个锚点预测 3 个边界框,锚点的总数为 9(不同尺度的锚点不同)。 作者称这帮助 YOLO v3 在检测较小目标时取得更好的性能,而这正是 YOLO 之前版本经常被抱怨的地方。上采样可以帮助该网络学习细粒度特征,帮助检测较小目标。 输出处理 对于大小为 416 x 416 的图像,YOLO 预测 ((52 x 52) + (26 x 26) + 13 x 13)) x 3 = 10647 个边界框。但是,我们的示例中只有一个对象——一只狗。那么我们怎么才能将检测次数从 10647 减少到 1 呢? 目标置信度阈值:首先,我们根据它们的 objectness 分数过滤边界框。通常,分数低于阈值的边界框会被忽略。 非极大值抑制:非极大值抑制(NMS)可解决对同一个图像的多次检测的问题。例如,红色网格单元的 3 个边界框可以检测一个框,或者临近网格可检测相同对象。 实现 YOLO 只能检测出属于训练所用数据集中类别的对象。我们的检测器将使用官方权重文件,这些权重通过在 COCO 数据集上训练网络而获得,因此我们可以检测 80 个对象类别。 该教程的第一部分到此结束。这部分详细讲解了 YOLO 算法。如果你想深度了解 YOLO 的工作原理、训练过程和与其他检测器的性能规避,可阅读原始论文: 1. YOLO V1: You Only Look Once: Unified, Real-Time Object Detection (https://ift.tt/2wyfhRy) 2. YOLO V2: YOLO9000: Better, Faster, Stronger (https://ift.tt/2vjsYjP) 3. YOLO V3: An Incremental Improvement (https://ift.tt/2pKub1g) 4. Convolutional Neural Networks (https://ift.tt/1vUDG8E) 5. Bounding Box Regression (Appendix C) (https://ift.tt/2zNYHy9) 6. IoU (https://www.youtube.com/watch?v=DNEm4fJ-rto) 7. Non maximum suppresion (https://www.youtube.com/watch?v=A46HZGR5fMw) 8. PyTorch Official Tutorial (https://ift.tt/2or6HPQ) 第二部分:创建 YOLO 网络层级以下是从头实现 YOLO v3 检测器的第二部分教程,我们将基于前面所述的基本概念使用 PyTorch 实现 YOLO 的层级,即创建整个模型的基本构建块。 这一部分要求读者已经基本了解 YOLO 的运行方式和原理,以及关于 PyTorch 的基本知识,例如如何通过 nn.Module、nn.Sequential 和 torch.nn.parameter 等类来构建自定义的神经网络架构。 开始旅程 首先创建一个存放检测器代码的文件夹,然后再创建 Python 文件 darknet.py。Darknet 是构建 YOLO 底层架构的环境,这个文件将包含实现 YOLO 网络的所有代码。同样我们还需要补充一个名为 util.py 的文件,它会包含多种需要调用的函数。在将所有这些文件保存在检测器文件夹下后,我们就能使用 git 追踪它们的改变。 配置文件 官方代码(authored in C)使用一个配置文件来构建网络,即 cfg 文件一块块地描述了网络架构。如果你使用过 caffe 后端,那么它就相当于描述网络的.protxt 文件。 我们将使用官方的 cfg 文件构建网络,它是由 YOLO 的作者发布的。我们可以在以下地址下载,并将其放在检测器目录下的 cfg 文件夹下。 配置文件下载:https://ift.tt/2vF3iBU 当然,如果你使用 Linux,那么就可以先 cd 到检测器网络的目录,然后运行以下命令行。 mkdir cfg cd cfg wget https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3.cfg
如果你打开配置文件,你将看到如下一些网络架构: [convolutional] batch_normalize=1 filters=64 size=3 stride=2 pad=1 activation=leaky [convolutional] batch_normalize=1 filters=32 size=1 stride=1 pad=1 activation=leaky [convolutional] batch_normalize=1 filters=64 size=3 stride=1 pad=1 activation=leaky [shortcut] from=-3 activation=linear
我们看到上面有四块配置,其中 3 个描述了卷积层,最后描述了 ResNet 中常用的捷径层或跳过连接。下面是 YOLO 中使用的 5 种层级: 1. 卷积层 [convolutional] batch_normalize=1 filters=64 size=3 stride=1 pad=1 activation=leaky
2. 跳过连接 [shortcut] from=-3 activation=linear
跳过连接与残差网络中使用的结构相似,参数 from 为-3 表示捷径层的输出会通过将之前层的和之前第三个层的输出的特征图与模块的输入相加而得出。 3.上采样 [upsample] stride=2
通过参数 stride 在前面层级中双线性上采样特征图。 4.路由层(Route) [route] layers = -4 [route] layers = -1, 61
路由层需要一些解释,它的参数 layers 有一个或两个值。当只有一个值时,它输出这一层通过该值索引的特征图。在我们的实验中设置为了-4,所以层级将输出路由层之前第四个层的特征图。 当层级有两个值时,它将返回由这两个值索引的拼接特征图。在我们的实验中为-1 和 61,因此该层级将输出从前一层级(-1)到第 61 层的特征图,并将它们按深度拼接。 5.YOLO [yolo] mask = 0,1,2 anchors = 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326 classes=80 num=9 jitter=.3 ignore_thresh = .5 truth_thresh = 1 random=1
YOLO 层级对应于上文所描述的检测层级。参数 anchors 定义了 9 组锚点,但是它们只是由 mask 标签使用的属性所索引的锚点。这里,mask 的值为 0、1、2 表示了第一个、第二个和第三个使用的锚点。而掩码表示检测层中的每一个单元预测三个框。总而言之,我们检测层的规模为 3,并装配总共 9 个锚点。 Net [net] # Testing batch=1 subdivisions=1 # Training # batch=64 # subdivisions=16 width= 320 height = 320 channels=3 momentum=0.9 decay=0.0005 angle=0 saturation = 1.5 exposure = 1.5 hue=.1
配置文件中存在另一种块 net,不过我不认为它是层,因为它只描述网络输入和训练参数的相关信息,并未用于 YOLO 的前向传播。但是,它为我们提供了网络输入大小等信息,可用于调整前向传播中的锚点。 解析配置文件 在开始之前,我们先在 darknet.py 文件顶部添加必要的导入项。 from __future__ import division import torch import torch.nn as nn import torch.nn.functional as F from torch.autograd import Variable import numpy as np
我们定义一个函数 parse_cfg,该函数使用配置文件的路径作为输入。 def parse_cfg(cfgfile): """ Takes a configuration file Returns a list of blocks. Each blocks describes a block in the neural network to be built. Block is represented as a dictionary in the list """
这里的思路是解析 cfg,将每个块存储为词典。这些块的属性和值都以键值对的形式存储在词典中。解析过程中,我们将这些词典(由代码中的变量 block 表示)添加到列表 blocks 中。我们的函数将返回该 block。 我们首先将配置文件内容保存在字符串列表中。下面的代码对该列表执行预处理: file = open(cfgfile, 'r') lines = file.read().split('\n') # store the lines in a list lines = [x for x in lines if len(x) > 0] # get read of the empty lines lines = [x for x in lines if x[0] != '#'] # get rid of comments lines = [x.rstrip().lstrip() for x in lines] # get rid of fringe whitespaces
然后,我们遍历预处理后的列表,得到块。 block = {} blocks = [] for line in lines: if line[0] == "[": # This marks the start of a new block if len(block) != 0: # If block is not empty, implies it is storing values of previous block. blocks.append(block) # add it the blocks list block = {} # re-init the block block["type"] = line[1:-1].rstrip() else: key,value = line.split("=") block[key.rstrip()] = value.lstrip() blocks.append(block) return blocks
创建构建块 现在我们将使用上面 parse_cfg 返回的列表来构建 PyTorch 模块,作为配置文件中的构建块。 列表中有 5 种类型的层。PyTorch 为 convolutional 和 upsample 提供预置层。我们将通过扩展 nn.Module 类为其余层写自己的模块。 create_modules 函数使用 parse_cfg 函数返回的 blocks 列表: def create_modules(blocks): net_info = blocks[0] #Captures the information about the input and pre-processing module_list = nn.ModuleList() prev_filters = 3 output_filters = []
在迭代该列表之前,我们先定义变量 net_info,来存储该网络的信息。 nn.ModuleList 我们的函数将会返回一个 nn.ModuleList。这个类几乎等同于一个包含 nn.Module 对象的普通列表。然而,当添加 nn.ModuleList 作为 nn.Module 对象的一个成员时(即当我们添加模块到我们的网络时),所有 nn.ModuleList 内部的 nn.Module 对象(模块)的 parameter 也被添加作为 nn.Module 对象(即我们的网络,添加 nn.ModuleList 作为其成员)的 parameter。 当我们定义一个新的卷积层时,我们必须定义它的卷积核维度。虽然卷积核的高度和宽度由 cfg 文件提供,但卷积核的深度是由上一层的卷积核数量(或特征图深度)决定的。这意味着我们需要持续追踪被应用卷积层的卷积核数量。我们使用变量 prev_filter 来做这件事。我们将其初始化为 3,因为图像有对应 RGB 通道的 3 个通道。 路由层(route layer)从前面层得到特征图(可能是拼接的)。如果在路由层之后有一个卷积层,那么卷积核将被应用到前面层的特征图上,精确来说是路由层得到的特征图。因此,我们不仅需要追踪前一层的卷积核数量,还需要追踪之前每个层。随着不断地迭代,我们将每个模块的输出卷积核数量添加到 output_filters 列表上。 现在,我们的思路是迭代模块的列表,并为每个模块创建一个 PyTorch 模块。 for index, x in enumerate(blocks[1:]): module = nn.Sequential() #check the type of block #create a new module for the block #append to module_list
nn.Sequential 类被用于按顺序地执行 nn.Module 对象的一个数字。如果你查看 cfg 文件,你会发现,一个模块可能包含多于一个层。例如,一个 convolutional 类型的模块有一个批量归一化层、一个 leaky ReLU 激活层以及一个卷积层。我们使用 nn.Sequential 将这些层串联起来,得到 add_module 函数。例如,以下展示了我们如何创建卷积层和上采样层的例子: if (x["type"] == "convolutional"): #Get the info about the layer activation = x["activation"] try: batch_normalize = int(x["batch_normalize"]) bias = False except: batch_normalize = 0 bias = True filters= int(x["filters"]) padding = int(x["pad"]) kernel_size = int(x["size"]) stride = int(x["stride"]) if padding: pad = (kernel_size - 1) // 2 else: pad = 0 #Add the convolutional layer conv = nn.Conv2d(prev_filters, filters, kernel_size, stride, pad, bias = bias) module.add_module("conv_{0}".format(index), conv) #Add the Batch Norm Layer if batch_normalize: bn = nn.BatchNorm2d(filters) module.add_module("batch_norm_{0}".format(index), bn) #Check the activation. #It is either Linear or a Leaky ReLU for YOLO if activation == "leaky": activn = nn.LeakyReLU(0.1, inplace = True) module.add_module("leaky_{0}".format(index), activn) #If it's an upsampling layer #We use Bilinear2dUpsampling elif (x["type"] == "upsample"): stride = int(x["stride"]) upsample = nn.Upsample(scale_factor = 2, mode = "bilinear") module.add_module("upsample_{}".format(index), upsample)
路由层/捷径层 接下来,我们来写创建路由层(Route Layer)和捷径层(Shortcut Layer)的代码: #If it is a route layer elif (x["type"] == "route"): x["layers"] = x["layers"].split(',') #Start of a route start = int(x["layers"][0])
「每周精要」 NO. 848 2024/09/21 头条 HEADLINE JavaScript 之父联手近万名开发者集体讨伐 Oracle:给 JavaScript 一条活路吧! 精选 SELECTED C++ 发布革命性提案 "借鉴"Rust...
|
没有评论:
发表评论