计算机视觉

图像增广

图像增广在对训练图像进行一系列的随机变化之后,生成相似但不同的训练样本,从而扩大了训练集的规模。 此外,应用图像增广的原因是,随机改变训练样本可以减少模型对某些属性的依赖,从而提高模型的泛化能力。 例如,我们可以以不同的方式裁剪图像,使感兴趣的对象出现在不同的位置,减少模型对于对象出现位置的依赖。 我们还可以调整亮度、颜色等因素来降低模型对颜色的敏感度。 可以说,图像增广技术对于AlexNet的成功是必不可少的

翻转和裁剪

使用transforms模块来创建RandomFlipLeftRight实例,这样就各有50%的几率使图像向左或向右翻转。

1
apply(img, torchvision.transforms.RandomHorizontalFlip())

../_images/output_image-augmentation_7d0887_42_0.svg

创建一个RandomFlipTopBottom实例,使图像各有50%的几率向上或向下翻转

1
apply(img, torchvision.transforms.RandomVerticalFlip())

../_images/output_image-augmentation_7d0887_54_0.svg

下面的代码将随机裁剪一个面积为原始面积10%到100%的区域,该区域的宽高比从0.5~2之间随机取值。 然后,区域的宽度和高度都被缩放到200像素。 在本节中(除非另有说明),$a$和$b$之间的随机数指的是在区间$[a,b]$中通过均匀采样获得的连续值。

1
2
3
shape_aug = torchvision.transforms.RandomResizedCrop(
(200, 200), scale=(0.1, 1), ratio=(0.5, 2))
apply(img, shape_aug)

../_images/output_image-augmentation_7d0887_66_0.svg


改变颜色

另一种增广方法是改变颜色。 我们可以改变图像颜色的四个方面:亮度、对比度、饱和度和色调。 在下面的示例中,我们随机更改图像的亮度,随机值为原始图像的50%(1−0.5)到150%(1+0.5)之间。

1
2
apply(img, torchvision.transforms.ColorJitter(
brightness=0.5, contrast=0, saturation=0, hue=0))

../_images/output_image-augmentation_7d0887_78_0.svg

同样,我们可以随机更改图像的色调。

1
2
apply(img, torchvision.transforms.ColorJitter(
brightness=0, contrast=0, saturation=0, hue=0.5))

../_images/output_image-augmentation_7d0887_90_0.svg


微调

假如我们想识别图片中不同类型的椅子,然后向用户推荐购买链接。 一种可能的方法是首先识别100把普通椅子,为每把椅子拍摄1000张不同角度的图像,然后在收集的图像数据集上训练一个分类模型。 尽管这个椅子数据集可能大于Fashion-MNIST数据集,但实例数量仍然不到ImageNet中的十分之一。 适合ImageNet的复杂模型可能会在这个椅子数据集上过拟合。 此外,由于训练样本数量有限,训练模型的准确性可能无法满足实际要求。

为了解决上述问题,一个显而易见的解决方案是收集更多的数据。 但是,收集和标记数据可能需要大量的时间和金钱。 例如,为了收集ImageNet数据集,研究人员花费了数百万美元的研究资金。 尽管目前的数据收集成本已大幅降低,但这一成本仍不能忽视。

另一种解决方案是应用迁移学习(transfer learning)将从源数据集学到的知识迁移到目标数据集。 例如,尽管ImageNet数据集中的大多数图像与椅子无关,但在此数据集上训练的模型可能会提取更通用的图像特征,这有助于识别边缘、纹理、形状和对象组合。 这些类似的特征也可能有效地识别椅子。

步骤

微调包括以下四个步骤。

  1. 在源数据集(例如ImageNet数据集)上预训练神经网络模型,即源模型
  2. 创建一个新的神经网络模型,即目标模型。这将复制源模型上的所有模型设计及其参数(输出层除外)。我们假定这些模型参数包含从源数据集中学到的知识,这些知识也将适用于目标数据集。我们还假设源模型的输出层与源数据集的标签密切相关;因此不在目标模型中使用该层。
  3. 向目标模型添加输出层,其输出数是目标数据集中的类别数。然后随机初始化该层的模型参数。
  4. 在目标数据集(如椅子数据集)上训练目标模型。输出层将从头开始进行训练,而所有其他层的参数将根据源模型的参数进行微调。../_images/finetune.svg

当目标数据集比源数据集小得多时,微调有助于提高模型的泛化能力。


热狗识别

让我们通过具体案例演示微调:热狗识别。 我们将在一个小型数据集上微调ResNet模型。该模型已在ImageNet数据集上进行了预训练。 这个小型数据集包含数千张包含热狗和不包含热狗的图像,我们将使用微调模型来识别图像中是否包含热狗。

1
2
3
4
5
6
%matplotlib inline
import os
import torch
import torchvision
from torch import nn
from d2l import torch as d2l

我们使用的热狗数据集来源于网络。 该数据集包含1400张热狗的“正类”图像,以及包含尽可能多的其他食物的“负类”图像。 含着两个类别的1000张图片用于训练,其余的则用于测试。

解压下载的数据集,我们获得了两个文件夹hotdog/trainhotdog/test。 这两个文件夹都有hotdog(有热狗)和not-hotdog(无热狗)两个子文件夹, 子文件夹内都包含相应类的图像

1
2
3
4
5
#@save
d2l.DATA_HUB['hotdog'] = (d2l.DATA_URL + 'hotdog.zip',
'fba480ffa8aa7e0febbb511d181409f899b9baa5')

data_dir = d2l.download_extract('hotdog')

我们创建两个实例来分别读取训练和测试数据集中的所有图像文件。

1
2
train_imgs = torchvision.datasets.ImageFolder(os.path.join(data_dir, 'train'))
test_imgs = torchvision.datasets.ImageFolder(os.path.join(data_dir, 'test'))

下面显示了前8个正类样本图片和最后8张负类样本图片。正如所看到的,图像的大小和纵横比各有不同

1
2
3
hotdogs = [train_imgs[i][0] for i in range(8)]
not_hotdogs = [train_imgs[-i - 1][0] for i in range(8)]
d2l.show_images(hotdogs + not_hotdogs, 2, 8, scale=1.4);

../_images/output_fine-tuning_368659_42_0.png

在训练期间,我们首先从图像中裁切随机大小和随机长宽比的区域,然后将该区域缩放为224×224输入图像。 在测试过程中,我们将图像的高度和宽度都缩放到256像素,然后裁剪中央224×224区域作为输入。 此外,对于RGB(红、绿和蓝)颜色通道,我们分别标准化每个通道。 具体而言,该通道的每个值减去该通道的平均值,然后将结果除以该通道的标准差。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 使用RGB通道的均值和标准差,以标准化每个通道
normalize = torchvision.transforms.Normalize(
[0.485, 0.456, 0.406], [0.229, 0.224, 0.225])

train_augs = torchvision.transforms.Compose([
torchvision.transforms.RandomResizedCrop(224),
torchvision.transforms.RandomHorizontalFlip(),
torchvision.transforms.ToTensor(),
normalize])

test_augs = torchvision.transforms.Compose([
torchvision.transforms.Resize([256, 256]),
torchvision.transforms.CenterCrop(224),
torchvision.transforms.ToTensor(),
normalize])

定义和初始化模型

我们使用在ImageNet数据集上预训练的ResNet-18作为源模型。 在这里,我们指定pretrained=True以自动下载预训练的模型参数。 如果首次使用此模型,则需要连接互联网才能下载。

1
pretrained_net = torchvision.models.resnet18(pretrained=True)

预训练的源模型实例包含许多特征层和一个输出层fc。 此划分的主要目的是促进对除输出层以外所有层的模型参数进行微调。 下面给出了源模型的成员变量fc

1
2
pretrained_net.fc
#Linear(in_features=512, out_features=1000, bias=True)

在ResNet的全局平均汇聚层后,全连接层转换为ImageNet数据集的1000个类输出。 之后,我们构建一个新的神经网络作为目标模型。 它的定义方式与预训练源模型的定义方式相同,只是最终层中的输出数量被设置为目标数据集中的类数(而不是1000个)。

在下面的代码中,目标模型finetune_net中成员变量features的参数被初始化为源模型相应层的模型参数。 由于模型参数是在ImageNet数据集上预训练的,并且足够好,因此通常只需要较小的学习率即可微调这些参数。

成员变量output的参数是随机初始化的,通常需要更高的学习率才能从头开始训练。 假设Trainer实例中的学习率为$\eta$,我们将成员变量output中参数的学习率设置为10$\eta$。


微调模型

首先,我们定义了一个训练函数train_fine_tuning,该函数使用微调,因此可以多次调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def train_fine_tuning(net, learning_rate, batch_size=128, num_epochs=5, param_group=True):
# 加载训练数据集。ImageFolder是一个通用的数据加载器,能够从文件夹中按目录结构自动标注图片。
# train_augs是训练数据的预处理和增强操作。
train_iter = torch.utils.data.DataLoader(torchvision.datasets.ImageFolder(
os.path.join(data_dir, 'train'), transform=train_augs),
batch_size=batch_size, shuffle=True)

# 加载测试数据集。与训练数据集相似,但是通常不进行数据增强。
test_iter = torch.utils.data.DataLoader(torchvision.datasets.ImageFolder(
os.path.join(data_dir, 'test'), transform=test_augs),
batch_size=batch_size)

# 尝试使用所有可用的GPU进行训练。
devices = d2l.try_all_gpus()

# 设置损失函数。这里使用交叉熵损失函数,适用于多分类问题。
loss = nn.CrossEntropyLoss(reduction="none")

# 根据param_group标志决定是否对输出层的模型参数使用不同的学习率。
if param_group:
# 筛选出除了全连接层外的所有参数,这些参数使用默认的学习率。
params_1x = [param for name, param in net.named_parameters()
if name not in ["fc.weight", "fc.bias"]]
# 为优化器设置两组参数:一组是除了全连接层的参数,另一组是全连接层的参数。
# 全连接层的参数使用十倍的学习率。
trainer = torch.optim.SGD([{'params': params_1x},
{'params': net.fc.parameters(),
'lr': learning_rate * 10}],
lr=learning_rate, weight_decay=0.001)
else:
# 如果不区分参数组,所有参数都使用相同的学习率。
trainer = torch.optim.SGD(net.parameters(), lr=learning_rate,
weight_decay=0.001)

# 调用d2l.train_ch13函数进行训练。这个函数封装了训练循环,包括前向传播、计算损失、反向传播和参数更新。
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)

我们使用较小的学习率,通过微调 预训练获得的模型参数。

1
train_fine_tuning(finetune_net, 5e-5)

../_images/output_fine-tuning_368659_108_1.svg

为了进行比较,我们定义了一个相同的模型,但是将其所有模型参数初始化为随机值。 由于整个模型需要从头开始训练,因此我们需要使用更大的学习率。

1
2
3
scratch_net = torchvision.models.resnet18()
scratch_net.fc = nn.Linear(scratch_net.fc.in_features, 2)
train_fine_tuning(scratch_net, 5e-4, param_group=False)

../_images/output_fine-tuning_368659_120_1.svg

意料之中,微调模型往往表现更好,因为它的初始参数值更有效。


目标检测和边界框

图像分类任务中,我们假设图像中只有一个主要物体对象,我们只关注如何识别其类别。 然而,很多时候图像里有多个我们感兴趣的目标,我们不仅想知道它们的类别,还想得到它们在图像中的具体位置。 在计算机视觉里,我们将这类任务称为目标检测(object detection)或目标识别(object recognition)。

目标检测在多个领域中被广泛使用。 例如,在无人驾驶里,我们需要通过识别拍摄到的视频图像里的车辆、行人、道路和障碍物的位置来规划行进线路。 机器人也常通过该任务来检测感兴趣的目标。安防领域则需要检测异常目标,如歹徒或者炸弹。

接下来的几节将介绍几种用于目标检测的深度学习方法。 我们将首先介绍目标的位置

1
2
3
%matplotlib inline
import torch
from d2l import torch as d2l

下面加载本节将使用的示例图像。可以看到图像左边是一只狗,右边是一只猫。 它们是这张图像里的两个主要目标。

1
2
3
d2l.set_figsize()
img = d2l.plt.imread('../img/catdog.jpg')
d2l.plt.imshow(img);

../_images/output_bounding-box_d6b70e_21_0.svg


边界框

在目标检测中,我们通常使用边界框(bounding box)来描述对象的空间位置。 边界框是矩形的,由矩形左上角的以及右下角的$x$和$y$坐标决定。 另一种常用的边界框表示方法是边界框中心的$(x,y)$轴坐标以及框的宽度和高度。

在这里,我们定义在这两种表示法之间进行转换的函数:box_corner_to_center从两角表示法转换为中心宽度表示法,而box_center_to_corner反之亦然。 输入参数boxes可以是长度为4的张量,也可以是形状为($n$,4)的二维张量,其中$n$是边界框的数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#@save
def box_corner_to_center(boxes):
"""从(左上,右下)转换到(中间,宽度,高度)"""
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
w = x2 - x1
h = y2 - y1
boxes = torch.stack((cx, cy, w, h), axis=-1)
return boxes

#@save
def box_center_to_corner(boxes):
"""从(中间,宽度,高度)转换到(左上,右下)"""
cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
x1 = cx - 0.5 * w
y1 = cy - 0.5 * h
x2 = cx + 0.5 * w
y2 = cy + 0.5 * h
boxes = torch.stack((x1, y1, x2, y2), axis=-1)
return boxes

我们将根据坐标信息定义图像中狗和猫的边界框。 图像中坐标的原点是图像的左上角,向右的方向为$x$轴的正方向,向下的方向为$y$轴的正方向。

我们可以将边界框在图中画出,以检查其是否准确。 画之前,我们定义一个辅助函数bbox_to_rect。 它将边界框表示成matplotlib的边界格式。

1
2
3
4
5
6
7
#@save
def bbox_to_rect(bbox, color):
# 将边界框(左上x,左上y,右下x,右下y)格式转换成matplotlib格式:
# ((左上x,左上y),宽,高)
return d2l.plt.Rectangle(
xy=(bbox[0], bbox[1]), width=bbox[2]-bbox[0], height=bbox[3]-bbox[1],
fill=False, edgecolor=color, linewidth=2)

在图像上添加边界框之后,我们可以看到两个物体的主要轮廓基本上在两个框内。

1
2
3
fig = d2l.plt.imshow(img)
fig.axes.add_patch(bbox_to_rect(dog_bbox, 'blue'))
fig.axes.add_patch(bbox_to_rect(cat_bbox, 'red'));

../_images/output_bounding-box_d6b70e_70_0.svg


锚框

目标检测算法通常会在输入图像中采样大量的区域,然后判断这些区域中是否包含我们感兴趣的目标,并调整区域边界从而更准确地预测目标的真实边界框。 不同的模型使用的区域采样方法可能不同。 这里我们介绍其中的一种方法:以每个像素为中心,生成多个缩放比和宽高比不同的边界框。 这些边界框被称为锚框(anchor box)

1
2
3
4
5
%matplotlib inline
import torch
from d2l import torch as d2l

torch.set_printoptions(2) # 精简输出精度

生成多个锚框

假设输入图像的高度为$h$,宽度为$w$。我们以图像的每个像素为中心生成不同形状的锚框:缩放比为$s\in (0, 1]$,宽高比为$r > 0$。那么锚框的宽度和高度分别是$hs\sqrt{r}$和$hs/\sqrt{r}$请注意,当中心位置给定时,已知宽和高的锚框是确定的。

要生成多个不同形状的锚框,让我们设置许多缩放比(scale)取值$s_1,\ldots, s_n$和许多宽高比(aspect ratio)取值$r_1,\ldots, r_m$。

当使用这些比例和长宽比的所有组合以每个像素为中心时,输入图像将总共有$whnm$个锚框。尽管这些锚框可能会覆盖所有真实边界框,但计算复杂性很容易过高。在实践中,我们只考虑包含$s_1$或$r_1$的组合

也就是说,

即第一个缩放比与所有宽高比的组合,这意味着对于第一个缩放比$s_1$,我们会生成与每个宽高比 ($r_1, \ldots, r_m$) 的组合,共计 ($m$) 个锚框,对于除了第一个缩放比之外的每个缩放比 ($s_2, \ldots, s_n$),我们仅考虑与第一个宽高比 (r_1) 的组合,这会产生额外的 ($n-1$) 个锚框。

以同一像素为中心的锚框的数量是$m+n-1$。对于整个输入图像,将共生成$wh(n+m-1)$个锚框。上述生成锚框的方法在下面的multibox_prior函数中实现。我们指定输入图像、尺寸列表和宽高比列表,然后此函数将返回所有的锚框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#@save
def multibox_prior(data, sizes, ratios):
"""生成以每个像素为中心具有不同形状的锚框"""
# 获取输入数据的高度和宽度
in_height, in_width = data.shape[-2:]
# 获取设备信息(CPU或GPU)、尺寸和宽高比的数量
device, num_sizes, num_ratios = data.device, len(sizes), len(ratios)
# 计算每个像素位置的锚框数量
boxes_per_pixel = (num_sizes + num_ratios - 1)
# 将尺寸和宽高比转换为张量,并确保它们在相同的设备上
size_tensor = torch.tensor(sizes, device=device)
ratio_tensor = torch.tensor(ratios, device=device)

# 为了将锚点移动到像素的中心,设置偏移量为0.5
offset_h, offset_w = 0.5, 0.5
# 计算在高度和宽度方向上的步长
steps_h = 1.0 / in_height
steps_w = 1.0 / in_width

# 生成所有中心点的坐标
center_h = (torch.arange(in_height, device=device) + offset_h) * steps_h
center_w = (torch.arange(in_width, device=device) + offset_w) * steps_w
# 使用meshgrid生成中心点的网格
shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij')
# 重塑为一维数组,为每个中心点准备
shift_y, shift_x = shift_y.reshape(-1), shift_x.reshape(-1)

# 生成锚框的宽度和高度
w = torch.cat((size_tensor * torch.sqrt(ratio_tensor[0]),
sizes[0] * torch.sqrt(ratio_tensor[1:]))) * in_height / in_width
h = torch.cat((size_tensor / torch.sqrt(ratio_tensor[0]),
sizes[0] / torch.sqrt(ratio_tensor[1:])))
# 除以2来获取半宽和半高,这些用于确定锚框的四个角
anchor_manipulations = torch.stack((-w, -h, w, h)).T.repeat(
in_height * in_width, 1) / 2

# 生成包含所有锚框中心的网格,每个中心点重复“boxes_per_pixel”次
out_grid = torch.stack([shift_x, shift_y, shift_x, shift_y],
dim=1).repeat_interleave(boxes_per_pixel, dim=0)
# 计算最终的锚框坐标(xmin, ymin, xmax, ymax)并返回
output = out_grid + anchor_manipulations
return output.unsqueeze(0)

锚框变量Y的形状是(批量大小,锚框的数量,4)。

1
2
3
4
5
6
7
8
9
10
11
12
img = d2l.plt.imread('../img/catdog.jpg')
h, w = img.shape[:2]

print(h, w)
X = torch.rand(size=(1, 3, h, w))
Y = multibox_prior(X, sizes=[0.75, 0.5, 0.25], ratios=[1, 2, 0.5])
Y.shape

"""
561 728
torch.Size([1, 2042040, 4])
"""

将锚框变量Y的形状更改为(图像高度,图像宽度,以同一像素为中心的锚框的数量,4)后,我们可以获得以指定像素的位置为中心的所有锚框。 在接下来的内容中,我们访问以(250,250)为中心的第一个锚框。 它有四个元素:锚框左上角的$(x,y)$轴坐标和右下角的$(x,y)$轴坐标。 输出中两个轴的坐标各分别除以了图像的宽度和高度。

为了显示以图像中以某个像素为中心的所有锚框,定义下面的show_bboxes函数来在图像上绘制多个边界框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#@save
def show_bboxes(axes, bboxes, labels=None, colors=None):
"""显示所有边界框"""
def _make_list(obj, default_values=None):
# 该辅助函数确保labels和colors参数被处理成列表形式。
# 如果传入的是None,则使用默认值;如果是单个对象而非列表或元组,则将其转换为列表。
if obj is None:
obj = default_values
elif not isinstance(obj, (list, tuple)):
obj = [obj]
return obj

labels = _make_list(labels) # 处理labels参数,确保其为列表形式。
colors = _make_list(colors, ['b', 'g', 'r', 'm', 'c']) # 处理colors参数,设置默认颜色为['b', 'g', 'r', 'm', 'c']。

# 遍历所有边界框,每个边界框根据其在列表中的位置选择颜色。
for i, bbox in enumerate(bboxes):
color = colors[i % len(colors)] # 循环使用colors列表中的颜色。
rect = d2l.bbox_to_rect(bbox.detach().numpy(), color)
# 将边界框坐标转换为matplotlib的Rectangle对象。
axes.add_patch(rect) # 在图像上添加边界框矩形。

# 如果提供了标签,为每个边界框添加标签文本。
if labels and len(labels) > i:
text_color = 'k' if color == 'w' else 'w' # 根据边界框颜色决定文本颜色,以保证文本可见性。
axes.text(rect.xy[0], rect.xy[1], labels[i],
va='center', ha='center', fontsize=9, color=text_color,
bbox=dict(facecolor=color, lw=0)) # 添加文本标签,位置位于边界框左上角。

正如从上面代码中所看到的,变量boxes中$x$轴和$y$轴的坐标值已分别除以图像的宽度和高度。 绘制锚框时,我们需要恢复它们原始的坐标值。 因此,在下面定义了变量bbox_scale。 现在可以绘制出图像中所有以(250,250)为中心的锚框了。 如下所示,缩放比为0.75且宽高比为1的蓝色锚框很好地围绕着图像中的狗。

1
2
3
4
5
6
d2l.set_figsize()
bbox_scale = torch.tensor((w, h, w, h))
fig = d2l.plt.imshow(img)
show_bboxes(fig.axes, boxes[250, 250, :, :] * bbox_scale,
['s=0.75, r=1', 's=0.5, r=1', 's=0.25, r=1', 's=0.75, r=2',
's=0.75, r=0.5'])

../_images/output_anchor_f592d1_66_0.svg


交并比

我们刚刚提到某个锚框“较好地”覆盖了图像中的狗。如果已知目标的真实边界框,那么这里的“好”该如何如何量化呢?直观地说,可以衡量锚框和真实边界框之间的相似性。杰卡德系数可以衡量两组之间的相似性。给定集合$\mathcal{A}$和$\mathcal{B}$,他们的杰卡德系数是他们交集的大小除以他们并集的大小:

事实上,我们可以将任何边界框的像素区域视为一组像素。通过这种方式,我们可以通过其像素集的杰卡德系数来测量两个边界框的相似性。

对于两个边界框,它们的杰卡德系数通常称为交并比(IoU),即两个边界框相交面积与相并面积之比,如图所示。交并比的取值范围在0和1之间:0表示两个边界框无重合像素,1表示两个边界框完全重合。

../_images/iou.svg

接下来部分将使用交并比来衡量锚框和真实边界框之间、以及不同锚框之间的相似度。给定两个锚框或边界框的列表,以下box_iou函数将在这两个列表中计算它们成对的交并比。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#@save
def box_iou(boxes1, boxes2):
"""计算两个锚框或边界框列表中成对的交并比"""
# 定义一个lambda函数来计算一个边界框列表的面积。
# 面积计算公式为(宽度) x (高度) = (x2 - x1) * (y2 - y1)
box_area = lambda boxes: ((boxes[:, 2] - boxes[:, 0]) *
(boxes[:, 3] - boxes[:, 1]))

# 计算两组边界框各自的面积。
areas1 = box_area(boxes1) # boxes1的每个边界框的面积。
areas2 = box_area(boxes2) # boxes2的每个边界框的面积。

# 计算所有边界框对之间的交集区域的左上角和右下角坐标。
# 使用广播机制比较boxes1和boxes2中每对边界框。
inter_upperlefts = torch.max(boxes1[:, None, :2], boxes2[:, :2]) # 交集区域的左上角。
inter_lowerrights = torch.min(boxes1[:, None, 2:], boxes2[:, 2:]) # 交集区域的右下角。

# 计算交集区域的尺寸。如果没有交集,尺寸会是负的,使用clamp将其设置为0。
inters = (inter_lowerrights - inter_upperlefts).clamp(min=0) # 交集区域的宽度和高度。

# 计算交集区域的面积。
inter_areas = inters[:, :, 0] * inters[:, :, 1] # 每对边界框交集区域的面积。

# 计算并集区域的面积。并集面积 = 边界框1的面积 + 边界框2的面积 - 交集面积。
union_areas = areas1[:, None] + areas2 - inter_areas # 每对边界框并集区域的面积。

# 计算交并比。交并比 = 交集面积 / 并集面积。
return inter_areas / union_areas


标注锚框

将真实边界框分配给锚框

在训练集中,我们将每个锚框视为一个训练样本。 为了训练目标检测模型,我们需要每个锚框的类别(class)和偏移量(offset)标签,其中前者是与锚框相关的对象的类别,后者是真实边界框相对于锚框的偏移量。 在预测时,我们为每个图像生成多个锚框,预测所有锚框的类别和偏移量,根据预测的偏移量调整它们的位置以获得预测的边界框,最后只输出符合特定条件的预测边界框。

目标检测训练集带有真实边界框 的位置及其包围物体类别的标签。 要标记任何生成的锚框,我们可以参考分配到的最接近此锚框的真实边界框的位置和类别标签。 下文将介绍一个算法,它能够把最接近的真实边界框分配给锚框。

给定图像,假设锚框是$A_1, A_2, \ldots, A_{n_a}$,真实边界框是$B_1, B_2, \ldots, B_{n_b}$,其中$n_a \geq n_b$。让我们定义一个矩阵$\mathbf{X} \in \mathbb{R}^{n_a \times n_b}$,其中第$i$行、第$j$列的元素$x_{ij}$是锚框$A_i$和真实边界框$B_j$的IoU

该算法包含以下步骤。

  1. 在矩阵$\mathbf{X}$中找到最大的元素,并将它的行索引和列索引分别表示为$i_1$和$j_1$。然后将真实边界框$B_{j_1}$分配给锚框$A_{i_1}$。这很直观,因为$A_{i_1}$和$B_{j_1}$是所有锚框和真实边界框配对中最相近的。在第一个分配完成后,丢弃矩阵中${i_1}^\mathrm{th}$行和${j_1}^\mathrm{th}$列中的所有元素。
  2. 在矩阵$\mathbf{X}$中找到剩余元素中最大的元素,并将它的行索引和列索引分别表示为$i_2$和$j_2$。我们将真实边界框$B_{j_2}$分配给锚框$A_{i_2}$,并丢弃矩阵中${i_2}^\mathrm{th}$行和${j_2}^\mathrm{th}$列中的所有元素。
  3. 此时,矩阵$\mathbf{X}$中两行和两列中的元素已被丢弃。我们继续,直到丢弃掉矩阵$\mathbf{X}$中$n_b$列中的所有元素。此时已经为这$n_b$个锚框各自分配了一个真实边界框。
  4. 只遍历剩下的$n_a - n_b$个锚框。例如,给定任何锚框$A_i$,在矩阵$\mathbf{X}$的第$i^\mathrm{th}$行中找到与$A_i$的IoU最大的真实边界框$B_j$,只有当此IoU大于预定义的阈值时,才将$B_j$分配给$A_i$。

../_images/anchor-label.svg

下面用一个具体的例子来说明上述算法。

如图左所示,假设矩阵$\mathbf{X}$中的最大值为$x_{23}$,我们将真实边界框$B_3$分配给锚框$A_2$。

然后,我们丢弃矩阵第2行和第3列中的所有元素,在剩余元素(阴影区域)中找到最大的$x_{71}$,然后将真实边界框$B_1$分配给锚框$A_7$。

接下来,如图中所示,丢弃矩阵第7行和第1列中的所有元素,在剩余元素(阴影区域)中找到最大的$x_{54}$,然后将真实边界框$B_4$分配给锚框$A_5$。

最后,如图右所示,丢弃矩阵第5行和第4列中的所有元素,在剩余元素(阴影区域)中找到最大的$x_{92}$,然后将真实边界框$B_2$分配给锚框$A_9$。

之后,我们只需要遍历剩余的锚框$A_1, A_3, A_4, A_6, A_8$,然后根据阈值确定是否为它们分配真实边界框。

此算法在下面的assign_anchor_to_bbox函数中实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#@save
def assign_anchor_to_bbox(ground_truth, anchors, device, iou_threshold=0.5):
"""将最接近的真实边界框分配给锚框"""
# 计算锚框和真实边界框的数量
num_anchors, num_gt_boxes = anchors.shape[0], ground_truth.shape[0]

# 计算所有锚框和真实边界框之间的IoU。jaccard变量存储了这些IoU值。
jaccard = box_iou(anchors, ground_truth)

# 初始化一个用于存储每个锚框分配到的真实边界框索引的张量。初始值设为-1,表示没有分配。
anchors_bbox_map = torch.full((num_anchors,), -1, dtype=torch.long,
device=device)

# 首先,为每个锚框找到IoU最大的真实边界框,如果最大IoU大于阈值,则进行分配。
max_ious, indices = torch.max(jaccard, dim=1)
anc_i = torch.nonzero(max_ious >= iou_threshold).reshape(-1) # 找到IoU大于阈值的锚框索引
box_j = indices[max_ious >= iou_threshold] # 对应的真实边界框索引
anchors_bbox_map[anc_i] = box_j # 分配

# 为了避免在后面的循环中修改jaccard矩阵,预先定义两个丢弃值向量
col_discard = torch.full((num_anchors,), -1)
row_discard = torch.full((num_gt_boxes,), -1)

# 然后,对于每个真实边界框,找到与其IoU最大的锚框,并进行强制分配,
# 即使这个IoU值小于阈值。这确保了每个真实边界框至少被一个锚框覆盖。
for _ in range(num_gt_boxes):
max_idx = torch.argmax(jaccard) # 找到IoU矩阵中的最大值索引
box_idx = (max_idx % num_gt_boxes).long() # 真实边界框的索引
anc_idx = (max_idx / num_gt_boxes).long() # 锚框的索引
anchors_bbox_map[anc_idx] = box_idx # 强制分配
jaccard[:, box_idx] = col_discard # 丢弃已分配的真实边界框
jaccard[anc_idx, :] = row_discard # 丢弃已分配的锚框

# 返回每个锚框分配到的真实边界框索引
return anchors_bbox_map


标记类别和偏移量

现在我们可以为每个锚框标记类别和偏移量了。假设一个锚框$A$被分配了一个真实边界框$B$。一方面,锚框$A$的类别将被标记为与$B$相同。另一方面,锚框$A$的偏移量将根据$B$和$A$中心坐标的相对位置以及这两个框的相对大小进行标记。鉴于数据集内不同的框的位置和大小不同,我们可以对那些相对位置和大小应用变换,使其获得分布更均匀且易于拟合的偏移量。这里介绍一种常见的变换。给定框$A$和$B$,中心坐标分别为$(x_a, y_a)$和$(x_b, y_b)$,宽度分别为$w_a$和$w_b$,高度分别为$h_a$和$h_b$,可以将$A$的偏移量标记为:

其中常量的默认值为 $\mu_x = \mu_y = \mu_w = \mu_h = 0, \sigma_x=\sigma_y=0.1$ , $\sigma_w=\sigma_h=0.2$。

这种转换在下面的 offset_boxes 函数中实现。

1
2
3
4
5
6
7
8
9
#@save
def offset_boxes(anchors, assigned_bb, eps=1e-6):
"""对锚框偏移量的转换"""
c_anc = d2l.box_corner_to_center(anchors)
c_assigned_bb = d2l.box_corner_to_center(assigned_bb)
offset_xy = 10 * (c_assigned_bb[:, :2] - c_anc[:, :2]) / c_anc[:, 2:]
offset_wh = 5 * torch.log(eps + c_assigned_bb[:, 2:] / c_anc[:, 2:])
offset = torch.cat([offset_xy, offset_wh], axis=1)
return offset

如果一个锚框没有被分配真实边界框,我们只需将锚框的类别标记为背景(background)。 背景类别的锚框通常被称为负类 锚框,其余的被称为正类 锚框。 我们使用真实边界框(labels参数)实现以下multibox_target函数,来标记锚框的类别和偏移量(anchors参数)。 此函数将背景类别的索引设置为零,然后将新类别的整数索引递增一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#@save
def multibox_target(anchors, labels):
"""使用真实边界框标记锚框"""
# 获取批量大小和调整锚框的形状
batch_size, anchors = labels.shape[0], anchors.squeeze(0)
# 初始化批量偏移量、掩码和类别标签列表
batch_offset, batch_mask, batch_class_labels = [], [], []
# 获取设备信息和锚框数量
device, num_anchors = anchors.device, anchors.shape[0]

# 对批量中的每个样本进行循环处理
for i in range(batch_size):
label = labels[i, :, :] # 获取当前样本的真实边界框和类别标签
# 将最接近的真实边界框分配给锚框
anchors_bbox_map = assign_anchor_to_bbox(
label[:, 1:], anchors, device)
# 根据分配结果生成边界框掩码,未分配的锚框掩码为0,分配了的为1
bbox_mask = ((anchors_bbox_map >= 0).float().unsqueeze(-1)).repeat(
1, 4)

# 初始化类别标签和分配给锚框的真实边界框坐标
class_labels = torch.zeros(num_anchors, dtype=torch.long,
device=device)
assigned_bb = torch.zeros((num_anchors, 4), dtype=torch.float32,
device=device)

# 根据分配的真实边界框更新锚框的类别标签和坐标
indices_true = torch.nonzero(anchors_bbox_map >= 0)
bb_idx = anchors_bbox_map[indices_true]
class_labels[indices_true] = label[bb_idx, 0].long() + 1 # 类别标签从1开始编号,0表示背景
assigned_bb[indices_true] = label[bb_idx, 1:] # 更新锚框的真实边界框坐标

# 计算锚框相对于其分配的真实边界框的偏移量,并应用掩码
offset = offset_boxes(anchors, assigned_bb) * bbox_mask
# 将结果添加到批量列表中
batch_offset.append(offset.reshape(-1))
batch_mask.append(bbox_mask.reshape(-1))
batch_class_labels.append(class_labels)

# 将批量数据堆叠成张量
bbox_offset = torch.stack(batch_offset)
bbox_mask = torch.stack(batch_mask)
class_labels = torch.stack(batch_class_labels)

# 返回处理后的批量偏移量、掩码和类别标签
return (bbox_offset, bbox_mask, class_labels)

下面通过一个具体的例子来说明锚框标签。

我们已经为加载图像中的狗和猫定义了真实边界框,其中第一个元素是类别(0代表狗,1代表猫),其余四个元素是左上角和右下角的$(x, y)$轴坐标(范围介于0和1之间)。我们还构建了五个锚框,用左上角和右下角的坐标进行标记:$A_0, \ldots, A_4$(索引从0开始)。然后我们在图像中绘制这些真实边界框和锚框

1
2
3
4
5
6
7
8
9
ground_truth = torch.tensor([[0, 0.1, 0.08, 0.52, 0.92],
[1, 0.55, 0.2, 0.9, 0.88]])
anchors = torch.tensor([[0, 0.1, 0.2, 0.3], [0.15, 0.2, 0.4, 0.4],
[0.63, 0.05, 0.88, 0.98], [0.66, 0.45, 0.8, 0.8],
[0.57, 0.3, 0.92, 0.9]])

fig = d2l.plt.imshow(img)
show_bboxes(fig.axes, ground_truth[:, 1:] * bbox_scale, ['dog', 'cat'], 'k')
show_bboxes(fig.axes, anchors * bbox_scale, ['0', '1', '2', '3', '4']);

../_images/output_anchor_f592d1_126_0.svg

使用上面定义的multibox_target函数,我们可以根据狗和猫的真实边界框,标注这些锚框的分类和偏移量。 在这个例子中,背景、狗和猫的类索引分别为0、1和2。 下面我们为锚框和真实边界框样本添加一个维度。

1
2
labels = multibox_target(anchors.unsqueeze(dim=0),
ground_truth.unsqueeze(dim=0))

让我们根据图像中的锚框和真实边界框的位置来分析下面返回的类别标签。首先,在所有的锚框和真实边界框配对中,锚框$A_4$与猫的真实边界框的IoU是最大的。因此,$A_4$的类别被标记为猫。

去除包含$A_4$或猫的真实边界框的配对,在剩下的配对中,锚框$A_1$和狗的真实边界框有最大的IoU。因此,$A_1$的类别被标记为狗。

接下来,我们需要遍历剩下的三个未标记的锚框:$A_0$、$A_2$和$A_3$。

对于$A_0$,与其拥有最大IoU的真实边界框的类别是狗,但IoU低于预定义的阈值(0.5),因此该类别被标记为背景;

对于$A_2$,与其拥有最大IoU的真实边界框的类别是猫,IoU超过阈值,所以类别被标记为猫;

对于$A_3$,与其拥有最大IoU的真实边界框的类别是猫,但值低于阈值,因此该类别被标记为背景

返回的结果中有三个元素,都是张量格式。第三个元素包含标记的输入锚框的类别。

1
2
labels[2]
#tensor([[0, 1, 2, 0, 2]])

返回的第二个元素是掩码(mask)变量,形状为(批量大小,锚框数的四倍)。 掩码变量中的元素与每个锚框的4个偏移量一一对应。 由于我们不关心对背景的检测,负类的偏移量不应影响目标函数。 通过元素乘法,掩码变量中的零将在计算目标函数之前过滤掉负类偏移量。

1
2
3
4
labels[1]
"""
tensor([[0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 0., 0., 0., 0., 1., 1.,
1., 1.]])

返回的第一个元素包含了为每个锚框标记的四个偏移值。 请注意,负类锚框的偏移量被标记为零。

1
2
3
4
5
6
7
8
labels[0]


"""
tensor([[-0.00e+00, -0.00e+00, -0.00e+00, -0.00e+00, 1.40e+00, 1.00e+01,
2.59e+00, 7.18e+00, -1.20e+00, 2.69e-01, 1.68e+00, -1.57e+00,
-0.00e+00, -0.00e+00, -0.00e+00, -0.00e+00, -5.71e-01, -1.00e+00,
4.17e-06, 6.26e-01]])

非极大值抑制预测边界框

在预测时,我们先为图像生成多个锚框,再为这些锚框一一预测类别和偏移量。 一个预测好的边界框 则根据其中某个带有预测偏移量的锚框而生成。 下面我们实现了offset_inverse函数,该函数将锚框和偏移量预测作为输入,并应用逆偏移变换来返回预测的边界框坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#@save
def offset_inverse(anchors, offset_preds):
"""根据带有预测偏移量的锚框来预测边界框"""
# 将锚框从角落表示(xmin, ymin, xmax, ymax)转换为中心表示(x_center, y_center, width, height)
anc = d2l.box_corner_to_center(anchors)

# 计算预测的边界框的中心位置。预测偏移量的前两个元素(offset_preds[:, :2])
# 代表中心点的偏移,通过与锚框宽高的比例(除以10作为缩放)相乘后加到锚框的中心位置上。
pred_bbox_xy = (offset_preds[:, :2] * anc[:, 2:] / 10) + anc[:, :2]

# 计算预测的边界框的宽度和高度。预测偏移量的后两个元素(offset_preds[:, 2:])
# 代表宽度和高度的比例变化,通过对这些值除以5后取指数,然后乘以锚框的宽度和高度。
pred_bbox_wh = torch.exp(offset_preds[:, 2:] / 5) * anc[:, 2:]

# 将预测的中心位置和宽高合并,形成中心表示的预测边界框。
pred_bbox = torch.cat((pred_bbox_xy, pred_bbox_wh), axis=1)

# 将预测的边界框从中心表示转换回角落表示(xmin, ymin, xmax, ymax)。
predicted_bbox = d2l.box_center_to_corner(pred_bbox)

# 返回预测的边界框坐标。
return predicted_bbox

当有许多锚框时,可能会输出许多相似的具有明显重叠的预测边界框,都围绕着同一目标。为了简化输出,我们可以使用非极大值抑制(non-maximum suppression,NMS)合并属于同一目标的类似的预测边界框。

以下是非极大值抑制的工作原理:
对于一个预测边界框$B$,目标检测模型会计算每个类别的预测概率。假设最大的预测概率为$p$,则该概率所对应的类别$B$即为预测的类别。具体来说,我们将$p$称为预测边界框$B$的置信度(confidence)。在同一张图像中,所有预测的非背景边界框都按置信度降序排序,以生成列表$L$。然后我们通过以下步骤操作排序列表$L$。

  1. 从$L$中选取置信度最高的预测边界框$B_1$作为基准,然后将所有与$B_1$的IoU超过预定阈值$\epsilon$的非基准预测边界框从$L$中移除。这时,$L$保留了置信度最高的预测边界框,去除了与其太过相似的其他预测边界框。简而言之,那些具有非极大值置信度的边界框被抑制了。
  2. 从$L$中选取置信度第二高的预测边界框$B_2$作为又一个基准,然后将所有与$B_2$的IoU大于$\epsilon$的非基准预测边界框从$L$中移除。
  3. 重复上述过程,直到$L$中的所有预测边界框都曾被用作基准。此时,$L$中任意一对预测边界框的IoU都小于阈值$\epsilon$;因此,没有一对边界框过于相似。
  4. 输出列表$L$中的所有预测边界框。

以下nms函数按降序对置信度进行排序并返回其索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#@save
def nms(boxes, scores, iou_threshold):
"""对预测边界框的置信度进行排序,并通过非极大值抑制(NMS)筛选边界框"""
# 根据边界框的置信度分数进行降序排序,B 存储了排序后的索引
B = torch.argsort(scores, dim=-1, descending=True)
keep = [] # 用于存储通过NMS筛选后保留下来的边界框的索引

# 当还有边界框未处理时,循环继续
while B.numel() > 0:
i = B[0] # 取得当前最高置信度分数的边界框索引
keep.append(i) # 将其添加到保留列表中

if B.numel() == 1: break # 如果只剩下一个边界框,则直接结束循环

# 计算选中的边界框与其他所有边界框的IoU值
iou = box_iou(boxes[i, :].reshape(-1, 4),
boxes[B[1:], :].reshape(-1, 4)).reshape(-1)

# 找出IoU值小于阈值的边界框,这些边界框不被认为是与当前选中边界框重叠的
inds = torch.nonzero(iou <= iou_threshold).reshape(-1)

# 更新B,只保留那些未被当前选中的边界框抑制掉的边界框索引
B = B[inds + 1]

# 返回通过NMS筛选后的边界框索引
return torch.tensor(keep, device=boxes.device)

我们定义以下multibox_detection函数来将非极大值抑制应用于预测边界框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#@save
def multibox_detection(cls_probs, offset_preds, anchors, nms_threshold=0.5,
pos_threshold=0.009999999):
"""使用非极大值抑制来预测边界框"""
# 获取设备信息和批量大小
device, batch_size = cls_probs.device, cls_probs.shape[0]
# 确保锚框的形状是正确的
anchors = anchors.squeeze(0)
# 获取类别数和锚框数
num_classes, num_anchors = cls_probs.shape[1], cls_probs.shape[2]
out = [] # 用于存储最终输出的列表

for i in range(batch_size): # 遍历每个样本
# 提取当前样本的类别概率和偏移量预测
cls_prob, offset_pred = cls_probs[i], offset_preds[i].reshape(-1, 4)
# 计算每个锚框的最大置信度和对应的类别ID
conf, class_id = torch.max(cls_prob[1:], 0)
# 使用偏移量预测和锚框计算预测的边界框
predicted_bb = offset_inverse(anchors, offset_pred)
# 应用NMS处理预测的边界框,去除重叠边界框
keep = nms(predicted_bb, conf, nms_threshold)

# 处理NMS之后未保留的边界框,将它们的类别设置为背景
all_idx = torch.arange(num_anchors, dtype=torch.long, device=device)
combined = torch.cat((keep, all_idx))
uniques, counts = combined.unique(return_counts=True)
non_keep = uniques[counts == 1]
all_id_sorted = torch.cat((keep, non_keep))
class_id[non_keep] = -1 # 未保留的设置为背景类别
# 对类别ID、置信度和边界框坐标进行排序和筛选
class_id = class_id[all_id_sorted]
conf, predicted_bb = conf[all_id_sorted], predicted_bb[all_id_sorted]

# 根据置信度阈值进一步筛选边界框
below_min_idx = (conf < pos_threshold)
class_id[below_min_idx] = -1 # 低于阈值的设置为背景类别
conf[below_min_idx] = 1 - conf[below_min_idx] # 调整置信度

# 合并类别ID、置信度和边界框坐标,形成最终输出
pred_info = torch.cat((class_id.unsqueeze(1),
conf.unsqueeze(1),
predicted_bb), dim=1)
out.append(pred_info)

# 将所有样本的输出堆叠起来,返回最终结果
return torch.stack(out)

现在让我们将上述算法应用到一个带有四个锚框的具体示例中。 为简单起见,我们假设预测的偏移量都是零,这意味着预测的边界框即是锚框。 对于背景、狗和猫其中的每个类,我们还定义了它的预测概率

1
2
3
4
5
6
anchors = torch.tensor([[0.1, 0.08, 0.52, 0.92], [0.08, 0.2, 0.56, 0.95],
[0.15, 0.3, 0.62, 0.91], [0.55, 0.2, 0.9, 0.88]])
offset_preds = torch.tensor([0] * anchors.numel())
cls_probs = torch.tensor([[0] * 4, # 背景的预测概率
[0.9, 0.8, 0.7, 0.1], # 狗的预测概率
[0.1, 0.2, 0.3, 0.9]]) # 猫的预测概率

我们可以在图像上绘制这些预测边界框和置信度。

1
2
3
fig = d2l.plt.imshow(img)
show_bboxes(fig.axes, anchors * bbox_scale,
['dog=0.9', 'dog=0.8', 'dog=0.7', 'cat=0.9'])

../_images/output_anchor_f592d1_234_0.svg

现在我们可以调用multibox_detection函数来执行非极大值抑制,其中阈值设置为0.5。 请注意,我们在示例的张量输入中添加了维度。

我们可以看到返回结果的形状是(批量大小,锚框的数量,6)。 最内层维度中的六个元素提供了同一预测边界框的输出信息。 第一个元素是预测的类索引,从0开始(0代表狗,1代表猫),值-1表示背景或在非极大值抑制中被移除了。 第二个元素是预测的边界框的置信度。 其余四个元素分别是预测边界框左上角和右下角的(�,�)轴坐标(范围介于0和1之间)。

1
2
3
4
5
6
7
8
9
10
11
12
13
output = multibox_detection(cls_probs.unsqueeze(dim=0),
offset_preds.unsqueeze(dim=0),
anchors.unsqueeze(dim=0),
nms_threshold=0.5)
output

"""
tensor([[[ 0.00, 0.90, 0.10, 0.08, 0.52, 0.92],
[ 1.00, 0.90, 0.55, 0.20, 0.90, 0.88],
[-1.00, 0.80, 0.08, 0.20, 0.56, 0.95],
[-1.00, 0.70, 0.15, 0.30, 0.62, 0.91]]])

"""

删除-1类别(背景)的预测边界框后,我们可以输出由非极大值抑制保存的最终预测边界框。

1
2
3
4
5
6
fig = d2l.plt.imshow(img)
for i in output[0].detach().numpy():
if i[0] == -1:
continue
label = ('dog=', 'cat=')[int(i[0])] + str(i[1])
show_bboxes(fig.axes, [torch.tensor(i[2:]) * bbox_scale], label)

../_images/output_anchor_f592d1_258_0.svg

实践中,在执行非极大值抑制前,我们甚至可以将置信度较低的预测边界框移除,从而减少此算法中的计算量。 我们也可以对非极大值抑制的输出结果进行后处理。例如,只保留置信度更高的结果作为最终输出。


多尺度目标检测

我们以输入图像的每个像素为中心,生成了多个锚框。 基本而言,这些锚框代表了图像不同区域的样本。 然而,如果为每个像素都生成的锚框,我们最终可能会得到太多需要计算的锚框。 想象一个$561×728$的输入图像,如果以每个像素为中心生成五个形状不同的锚框,就需要在图像上标记和预测超过200万个锚框($561×728×5$)。

多尺度锚框

减少图像上的锚框数量并不困难。 比如,我们可以在输入图像中均匀采样一小部分像素,并以它们为中心生成锚框。 此外,在不同尺度下,我们可以生成不同数量和不同大小的锚框。 直观地说,比起较大的目标,较小的目标在图像上出现的可能性更多样。 例如,$1×1$、$1×2$和$2×2$的目标可以分别以4、2和1种可能的方式出现在2×2图像上。 因此,当使用较小的锚框检测较小的物体时,我们可以采样更多的区域,而对于较大的物体,我们可以采样较少的区域。

为了演示如何在多个尺度下生成锚框,让我们先读取一张图像。 它的高度和宽度分别为561和728像素。

1
2
3
4
5
6
7
%matplotlib inline
import torch
from d2l import torch as d2l

img = d2l.plt.imread('../img/catdog.jpg')
h, w = img.shape[:2]
h, w

回想一下,在卷积神经网络中,我们将卷积图层的二维数组输出称为特征图。 通过定义特征图的形状,我们可以确定任何图像上均匀采样锚框的中心。

display_anchors函数定义如下。 我们在特征图(fmap)上生成锚框(anchors),每个单位(像素)作为锚框的中心。 由于锚框中的$(x,y)$轴坐标值(anchors)已经被除以特征图(fmap)的宽度和高度,因此这些值介于0和1之间,表示特征图中锚框的相对位置。

由于锚框(anchors)的中心分布于特征图(fmap)上的所有单位,因此这些中心必须根据其相对空间位置在任何输入图像上均匀 分布。 更具体地说,给定特征图的宽度和高度fmap_wfmap_h,以下函数将均匀地 对任何输入图像中fmap_h行和fmap_w列中的像素进行采样。 以这些均匀采样的像素为中心,将会生成大小为s(假设列表s的长度为1)且宽高比(ratios)不同的锚框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def display_anchors(fmap_w, fmap_h, s):
# 设置图像的显示大小
d2l.set_figsize()

# 生成一个模拟的特征图,其深度和真实特征图的深度无关,
# 只是为了使用multibox_prior函数生成锚框
fmap = torch.zeros((1, 10, fmap_h, fmap_w))

# 调用multibox_prior函数生成锚框,sizes参数控制锚框的大小,
# ratios参数控制锚框的宽高比
anchors = d2l.multibox_prior(fmap, sizes=s, ratios=[1, 2, 0.5])

# bbox_scale是一个缩放因子,用于将锚框的坐标从特征图的尺度调整回原始图像的尺度
# 假设变量w和h分别代表原始图像的宽度和高度
bbox_scale = torch.tensor((w, h, w, h))

# 显示锚框。首先,使用plt.imshow(img).axes在图像上绘制锚框,
# 然后利用show_bboxes函数和bbox_scale将锚框的坐标调整并显示在原始图像上
# 注意:这里的img应该是一个图像的数组表示,用于背景显示
d2l.show_bboxes(d2l.plt.imshow(img).axes, anchors[0] * bbox_scale)

首先,让我们考虑探测小目标。 为了在显示时更容易分辨,在这里具有不同中心的锚框不会重叠: 锚框的尺度设置为0.15,特征图的高度和宽度设置为4。 我们可以看到,图像上4行和4列的锚框的中心是均匀分布的。

1
display_anchors(fmap_w=4, fmap_h=4, s=[0.15])

../_images/output_multiscale-object-detection_ad7147_30_0.svg

我们进一步将特征图的高度和宽度减小到四分之一,然后将锚框的尺度增加到0.8。 此时,锚框的中心即是图像的中心

1
display_anchors(fmap_w=2, fmap_h=2, s=[0.4])

../_images/output_multiscale-object-detection_ad7147_54_0.svg


目标检测数据集

下载

1
2
3
4
5
6
7
8
9
10
11
%matplotlib inline
import os
import pandas as pd
import torch
import torchvision
from d2l import torch as d2l

#@save
d2l.DATA_HUB['banana-detection'] = (
d2l.DATA_URL + 'banana-detection.zip',
'5de26c8fce5ccdea9f91267273464dc968d20d72')

读取

通过read_data_bananas函数,我们读取香蕉检测数据集。 该数据集包括一个的CSV文件,内含目标类别标签和位于左上角和右下角的真实边界框坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#@save
def read_data_bananas(is_train=True):
"""读取香蕉检测数据集中的图像和标签"""
data_dir = d2l.download_extract('banana-detection')
csv_fname = os.path.join(data_dir, 'bananas_train' if is_train
else 'bananas_val', 'label.csv')
csv_data = pd.read_csv(csv_fname)
csv_data = csv_data.set_index('img_name')
images, targets = [], []
for img_name, target in csv_data.iterrows():
images.append(torchvision.io.read_image(
os.path.join(data_dir, 'bananas_train' if is_train else
'bananas_val', 'images', f'{img_name}')))
# 这里的target包含(类别,左上角x,左上角y,右下角x,右下角y),
# 其中所有图像都具有相同的香蕉类(索引为0)
targets.append(list(target))
return images, torch.tensor(targets).unsqueeze(1) / 256

通过使用read_data_bananas函数读取图像和标签,以下BananasDataset类别将允许我们创建一个自定义Dataset实例来加载香蕉检测数据集。

1
2
3
4
5
6
7
8
9
10
11
12
13
#@save
class BananasDataset(torch.utils.data.Dataset):
"""一个用于加载香蕉检测数据集的自定义数据集"""
def __init__(self, is_train):
self.features, self.labels = read_data_bananas(is_train)
print('read ' + str(len(self.features)) + (f' training examples' if
is_train else f' validation examples'))

def __getitem__(self, idx):
return (self.features[idx].float(), self.labels[idx])

def __len__(self):
return len(self.features)

最后,我们定义load_data_bananas函数,来为训练集和测试集返回两个数据加载器实例。对于测试集,无须按随机顺序读取它。

1
2
3
4
5
6
7
8
#@save
def load_data_bananas(batch_size):
"""加载香蕉检测数据集"""
train_iter = torch.utils.data.DataLoader(BananasDataset(is_train=True),
batch_size, shuffle=True)
val_iter = torch.utils.data.DataLoader(BananasDataset(is_train=False),
batch_size)
return train_iter, val_iter

让我们读取一个小批量,并打印其中的图像和标签的形状。 图像的小批量的形状为(批量大小、通道数、高度、宽度),看起来很眼熟:它与我们之前图像分类任务中的相同。 标签的小批量的形状为(批量大小,$m$,5),其中$m$是数据集的任何图像中边界框可能出现的最大数量。

小批量计算虽然高效,但它要求每张图像含有相同数量的边界框,以便放在同一个批量中。 通常来说,图像可能拥有不同数量个边界框;因此,在达到$m$之前,边界框少于$m$的图像将被非法边界框填充。 这样,每个边界框的标签将被长度为5的数组表示。 数组中的第一个元素是边界框中对象的类别,其中-1表示用于填充的非法边界框。 数组的其余四个元素是边界框左上角和右下角的($x$ ,$y$)坐标值(值域在0~1之间)。 对于香蕉数据集而言,由于每张图像上只有一个边界框,因此$m=1$。

1
2
3
4
batch_size, edge_size = 32, 256
train_iter, _ = load_data_bananas(batch_size)
batch = next(iter(train_iter))
batch[0].shape, batch[1].shape

演示

让我们展示10幅带有真实边界框的图像。 我们可以看到在所有这些图像中香蕉的旋转角度、大小和位置都有所不同。 当然,这只是一个简单的人工数据集,实践中真实世界的数据集通常要复杂得多。

1
2
3
4
imgs = (batch[0][0:10].permute(0, 2, 3, 1)) / 255
axes = d2l.show_images(imgs, 2, 5, scale=2)
for ax, label in zip(axes, batch[1][0:10]):
d2l.show_bboxes(ax, [label[0][1:5] * edge_size], colors=['w'])

../_images/output_object-detection-dataset_641ef0_66_0.png


单发多框检测SSD

模型

此模型主要由基础网络组成,其后是几个多尺度特征块。 基本网络用于从输入图像中提取特征,因此它可以使用深度卷积神经网络。 单发多框检测论文中选用了在分类层之前截断的VGG现在也常用ResNet替代。 我们可以设计基础网络,使它输出的高和宽较大。 这样一来,基于该特征图生成的锚框数量较多,可以用来检测尺寸较小的目标。 接下来的每个多尺度特征块将上一层提供的特征图的高和宽缩小(如减半),并使特征图中每个单元在输入图像上的感受野变得更广阔。

由于接近顶部的多尺度特征图较小,但具有较大的感受野,它们适合检测较少但较大的物体。 简而言之,通过多尺度特征块,单发多框检测生成不同大小的锚框,并通过预测边界框的类别和偏移量来检测大小不同的目标,因此这是一个多尺度目标检测模型

../_images/ssd.svg


类别预测层

设目标类别的数量为$q$。这样一来,锚框有$q+1$个类别,其中0类是背景。在某个尺度下,设特征图的高和宽分别为$h$和$w$。如果以其中每个单元为中心生成$a$个锚框,那么我们需要对$hwa$个锚框进行分类。如果使用全连接层作为输出,很容易导致模型参数过多。回忆NiN网络一节介绍的使用卷积层的通道来输出类别预测的方法,单发多框检测采用同样的方法来降低模型复杂度。

具体来说,类别预测层使用一个保持输入高和宽的卷积层。这样一来,输出和输入在特征图宽和高上的空间坐标一一对应。考虑输出和输入同一空间坐标($x$、$y$):输出特征图上($x$、$y$)坐标的通道里包含了以输入特征图($x$、$y$)坐标为中心生成的所有锚框的类别预测。因此输出通道数为$a(q+1)$,其中索引为$i(q+1) + j$($0 \leq j \leq q$)的通道代表了索引为$i$的锚框有关类别索引为$j$的预测。

这个公式说明了如何在输出特征图的通道中安排每个锚框的类别预测。对于第($i$)个锚框,它的类别预测被存储在输出特征图的一系列通道中,这些通道的索引从($i(q+1)$)开始,到($i(q+1) + q$)结束。这里的($j$)是类别索引,范围从0到($q$),其中0通常代表背景类别

在下面,我们定义了这样一个类别预测层,通过参数num_anchorsnum_classes分别指定了$a$和$q$。该图层使用填充为1的$3\times3$的卷积层。此卷积层的输入和输出的宽度和高度保持不变。

1
2
3
4
5
6
7
8
9
10
11
%matplotlib inline
import torch
import torchvision
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l


def cls_predictor(num_inputs, num_anchors, num_classes):
return nn.Conv2d(num_inputs, num_anchors * (num_classes + 1),
kernel_size=3, padding=1)

边界框预测层的设计与类别预测层的设计类似。 唯一不同的是,这里需要为每个锚框预测4个偏移量,而不是$q+1$个类别。

1
2
def bbox_predictor(num_inputs, num_anchors):
return nn.Conv2d(num_inputs, num_anchors * 4, kernel_size=3, padding=1)

连结多尺度的预测

正如我们所提到的,单发多框检测使用多尺度特征图来生成锚框并预测其类别和偏移量。在不同的尺度下,特征图的形状或以同一单元为中心的锚框的数量可能会有所不同。因此,不同尺度下预测输出的形状可能会有所不同。

在以下示例中,我们为同一个小批量构建两个不同比例(Y1Y2)的特征图,其中Y2的高度和宽度是Y1的一半。以类别预测为例,假设Y1Y2的每个单元分别生成了$5$个和$3$个锚框。进一步假设目标类别的数量为$10$,对于特征图Y1Y2,类别预测输出中的通道数分别为$5\times(10+1)=55$和$3\times(10+1)=33$,其中任一输出的形状是(批量大小,通道数,高度,宽度)。

1
2
3
4
5
6
7
def forward(x, block):
return block(x)

Y1 = forward(torch.zeros((2, 8, 20, 20)), cls_predictor(8, 5, 10))
Y2 = forward(torch.zeros((2, 16, 10, 10)), cls_predictor(16, 3, 10))
Y1.shape, Y2.shape
#(torch.Size([2, 55, 20, 20]), torch.Size([2, 33, 10, 10]))

正如我们所看到的,除了批量大小这一维度外,其他三个维度都具有不同的尺寸。 为了将这两个预测输出链接起来以提高计算效率,我们将把这些张量转换为更一致的格式。

通道维包含中心相同的锚框的预测结果。我们首先将通道维移到最后一维。 因为不同尺度下批量大小仍保持不变,我们可以将预测结果转成二维的(批量大小,高×宽×通道数)的格式,以方便之后在维度1上的连结。

1
2
3
4
5
def flatten_pred(pred):
return torch.flatten(pred.permute(0, 2, 3, 1), start_dim=1)

def concat_preds(preds):
return torch.cat([flatten_pred(p) for p in preds], dim=1)

这样一来,尽管Y1Y2在通道数、高度和宽度方面具有不同的大小,我们仍然可以在同一个小批量的两个不同尺度上连接这两个预测输出。

1
2
concat_preds([Y1, Y2]).shape
#torch.Size([2, 25300])

高和宽减半块

为了在多个尺度下检测目标,我们在下面定义了高和宽减半块down_sample_blk,该模块将输入特征图的高度和宽度减半。事实上,该块应用了在subsec_vgg-blocks中的VGG模块设计。

更具体地说,每个高和宽减半块由两个填充为$1$的$3\times3$的卷积层、以及步幅为$2$的$2\times2$最大汇聚层组成。我们知道,填充为$1$的$3\times3$卷积层不改变特征图的形状。但是,其后的$2\times2$的最大汇聚层将输入特征图的高度和宽度减少了一半。

对于此高和宽减半块的输入和输出特征图,因为$1\times 2+(3-1)+(3-1)=6$,所以输出中的每个单元在输入上都有一个$6\times6$的感受野。因此,高和宽减半块会扩大每个单元在其输出特征图中的感受野。

1
2
3
4
5
6
7
8
9
10
def down_sample_blk(in_channels, out_channels):
blk = []
for _ in range(2):
blk.append(nn.Conv2d(in_channels, out_channels,
kernel_size=3, padding=1))
blk.append(nn.BatchNorm2d(out_channels))
blk.append(nn.ReLU())
in_channels = out_channels
blk.append(nn.MaxPool2d(2))
return nn.Sequential(*blk)

基本网络块

基本网络块用于从输入图像中抽取特征。 为了计算简洁,我们构造了一个小的基础网络,该网络串联3个高和宽减半块,并逐步将通道数翻倍。 给定输入图像的形状为256×256,此基本网络块输出的特征图形状为32×32($256/2^3=32$)。

1
2
3
4
5
6
7
8
9
def base_net():
blk = []
num_filters = [3, 16, 32, 64]
for i in range(len(num_filters) - 1):
blk.append(down_sample_blk(num_filters[i], num_filters[i+1]))
return nn.Sequential(*blk)

forward(torch.zeros((2, 3, 256, 256)), base_net()).shape
#torch.Size([2, 64, 32, 32])

完整模型

完整的单发多框检测模型由五个模块组成。每个块生成的特征图既用于生成锚框,又用于预测这些锚框的类别和偏移量。在这五个模块中,第一个是基本网络块,第二个到第四个是高和宽减半块,最后一个模块使用全局最大池将高度和宽度都降到1。从技术上讲,第二到第五个区块都是多尺度特征块。

1
2
3
4
5
6
7
8
9
10
def get_blk(i):
if i == 0:
blk = base_net()
elif i == 1:
blk = down_sample_blk(64, 128)
elif i == 4:
blk = nn.AdaptiveMaxPool2d((1,1))
else:
blk = down_sample_blk(128, 128)
return blk

现在我们为每个块定义前向传播。与图像分类任务不同,此处的输出包括:CNN特征图Y;在当前尺度下根据Y生成的锚框;预测的这些锚框的类别和偏移量(基于Y)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def blk_forward(X, blk, size, ratio, cls_predictor, bbox_predictor):
# 对输入X应用一个块(如卷积层、汇聚层等组合),这里的blk可能是一个卷积块或者其他类型的网络层块,
# 它用于提取特征或者改变特征图的尺寸。
Y = blk(X)

# 生成锚框。这是在特征图Y上基于预定义的尺寸(size)和宽高比(ratio)生成的一系列锚框。
# 这些锚框用于后续的对象检测任务。multibox_prior函数根据给定的特征图、尺寸和比率生成锚框。
anchors = d2l.multibox_prior(Y, sizes=size, ratios=ratio)

# 应用类别预测器(cls_predictor)于特征图Y,得到类别预测结果。
# 这个预测器通常是一个卷积层,用于预测每个锚框的类别。
cls_preds = cls_predictor(Y)

# 应用边界框预测器(bbox_predictor)于特征图Y,得到边界框预测结果。
# 这个预测器也通常是一个卷积层,用于预测每个锚框的位置调整值。
bbox_preds = bbox_predictor(Y)

# 返回特征图Y、生成的锚框、类别预测结果和边界框预测结果。
return (Y, anchors, cls_preds, bbox_preds)

回想一下,在图中,一个较接近顶部的多尺度特征块是用于检测较大目标的,因此需要生成更大的锚框。在上面的前向传播中,在每个多尺度特征块上,我们通过调用的multibox_prior函数的sizes参数传递两个比例值的列表。

在下面,0.2和1.05之间的区间被均匀分成五个部分,以确定五个模块的在不同尺度下的较小值:0.2、0.37、0.54、0.71和0.88。之后,他们较大的值由$\sqrt{0.2 \times 0.37} = 0.272$、$\sqrt{0.37 \times 0.54} = 0.447$等给出。

1
2
3
4
sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619], [0.71, 0.79],
[0.88, 0.961]]
ratios = [[1, 2, 0.5]] * 5
num_anchors = len(sizes[0]) + len(ratios[0]) - 1

现在,我们就可以按如下方式定义完整的模型TinySSD了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class TinySSD(nn.Module):
def __init__(self, num_classes, **kwargs):
super(TinySSD, self).__init__(**kwargs)
# num_classes: 模型需要识别的类别数目。
self.num_classes = num_classes
# idx_to_in_channels: 指定了不同层(或称为"块")的输入通道数,这是基于模型架构的设计。
idx_to_in_channels = [64, 128, 128, 128, 128]
for i in range(5):
# 动态地为模型添加五个不同的块,每个块负责不同尺度的特征提取。
# get_blk(i)函数根据索引i返回相应的块。
setattr(self, f'blk_{i}', get_blk(i))
# 为每个块添加一个类别预测器,用于预测锚框所属的类别。
# cls_predictor函数根据输入通道数、锚框数量和类别数创建类别预测器。
setattr(self, f'cls_{i}', cls_predictor(idx_to_in_channels[i], num_anchors, num_classes))
# 为每个块添加一个边界框预测器,用于预测锚框的具体位置。
# bbox_predictor函数根据输入通道数和锚框数量创建边界框预测器。
setattr(self, f'bbox_{i}', bbox_predictor(idx_to_in_channels[i], num_anchors))

def forward(self, X):
# 初始化锚框、类别预测和边界框预测的列表,每个列表包含5个元素,对应于5个不同的块。
anchors, cls_preds, bbox_preds = [None] * 5, [None] * 5, [None] * 5
for i in range(5):
# 对于每个块,使用blk_forward函数进行前向传播。
# blk_forward函数接收当前的输入X和该块相关的参数,返回处理后的X、锚框、类别预测和边界框预测。
X, anchors[i], cls_preds[i], bbox_preds[i] = blk_forward(
X, getattr(self, f'blk_{i}'), sizes[i], ratios[i],
getattr(self, f'cls_{i}'), getattr(self, f'bbox_{i}'))
# 将所有块生成的锚框拼接在一起。
anchors = torch.cat(anchors, dim=1)
# 将所有块的类别预测结果拼接在一起,并调整形状以适应后续处理。
cls_preds = concat_preds(cls_preds)
cls_preds = cls_preds.reshape(cls_preds.shape[0], -1, self.num_classes + 1)
# 将所有块的边界框预测结果拼接在一起。
bbox_preds = concat_preds(bbox_preds)
# 返回最终的锚框、类别预测和边界框预测结果。
return anchors, cls_preds, bbox_preds

创建一个模型实例,然后使用它对一个256×256像素的小批量图像X执行前向传播。

如本节前面部分所示,第一个模块输出特征图的形状为$32×32$。 回想一下,第二到第四个模块为高和宽减半块,第五个模块为全局汇聚层。 由于以特征图的每个单元为中心有4个锚框生成,因此在所有五个尺度下,每个图像总共生成$(32^2+16^2+8^2+4^2+1)×4=5444$个锚框。

1
2
3
4
5
6
7
8
9
10
11
12
net = TinySSD(num_classes=1)
X = torch.zeros((32, 3, 256, 256))
anchors, cls_preds, bbox_preds = net(X)

print('output anchors:', anchors.shape)
print('output class preds:', cls_preds.shape)
print('output bbox preds:', bbox_preds.shape)
"""
output anchors: torch.Size([1, 5444, 4])
output class preds: torch.Size([32, 5444, 2])
output bbox preds: torch.Size([32, 21776])
"""

训练

读取数据和初始化

1
2
batch_size = 32
train_iter, _ = d2l.load_data_bananas(batch_size)

香蕉检测数据集中,目标的类别数为1。 定义好模型后,我们需要初始化其参数并定义优化算法。

1
2
device, net = d2l.try_gpu(), TinySSD(num_classes=1)
trainer = torch.optim.SGD(net.parameters(), lr=0.2, weight_decay=5e-4)

定义损失函数和评价函数

目标检测有两种类型的损失。 第一种有关锚框类别的损失:我们可以简单地复用之前图像分类问题里一直使用的交叉熵损失函数来计算; 第二种有关正类锚框偏移量的损失:预测偏移量是一个回归问题。 但是,对于这个回归问题,我们在这里不使用平方损失,而是使用$L_1$范数损失,即预测值和真实值之差的绝对值。 掩码变量bbox_masks令负类锚框和填充锚框不参与损失的计算。 最后,我们将锚框类别和偏移量的损失相加,以获得模型的最终损失函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 定义分类损失函数,使用交叉熵损失。`reduction='none'`意味着损失将不会在此步骤内部进行求和或平均,以便后续可以自定义操作。
cls_loss = nn.CrossEntropyLoss(reduction='none')
# 定义边界框回归损失函数,使用L1损失。同样地,`reduction='none'`允许后续自定义处理。
bbox_loss = nn.L1Loss(reduction='none')

def calc_loss(cls_preds, cls_labels, bbox_preds, bbox_labels, bbox_masks):
# 获取批量大小和类别数目。
batch_size, num_classes = cls_preds.shape[0], cls_preds.shape[2]
# 计算分类损失。
# 首先,将类别预测结果重塑为二维张量,以适应交叉熵损失函数的输入要求。
# 然后,重塑类别标签以匹配预测结果的形状。
# 最后,将损失重塑回原始批量大小,并计算每个样本的平均损失。
cls = cls_loss(cls_preds.reshape(-1, num_classes),
cls_labels.reshape(-1)).reshape(batch_size, -1).mean(dim=1)
# 计算边界框回归损失。
# 首先,应用边界框掩码(bbox_masks)到预测和标签上,只考虑有目标的预测框。
# 然后,计算每个样本的平均损失。
bbox = bbox_loss(bbox_preds * bbox_masks,
bbox_labels * bbox_masks).mean(dim=1)
# 将分类损失和边界框损失相加,得到每个样本的总损失。
return cls + bbox

我们可以沿用准确率评价分类结果。 由于偏移量使用了$L_1$范数损失,我们使用平均绝对误差来评价边界框的预测结果。这些预测结果是从生成的锚框及其预测偏移量中获得的。

1
2
3
4
5
6
7
def cls_eval(cls_preds, cls_labels):
# 由于类别预测结果放在最后一维,argmax需要指定最后一维。
return float((cls_preds.argmax(dim=-1).type(
cls_labels.dtype) == cls_labels).sum())

def bbox_eval(bbox_preds, bbox_labels, bbox_masks):
return float((torch.abs((bbox_labels - bbox_preds) * bbox_masks)).sum())

训练

在训练模型时,我们需要在模型的前向传播过程中生成多尺度锚框(anchors),并预测其类别(cls_preds)和偏移量(bbox_preds)。 然后,我们根据标签信息Y为生成的锚框标记类别(cls_labels)和偏移量(bbox_labels)。 最后,我们根据类别和偏移量的预测和标注值计算损失函数。为了代码简洁,这里没有评价测试数据集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 设置训练周期数和计时器。
num_epochs, timer = 20, d2l.Timer()
# 初始化动画显示器,用于实时显示训练过程中的分类错误和边界框的平均绝对误差。
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], legend=['class error', 'bbox mae'])

# 将模型移动到指定的设备上,例如GPU。
net = net.to(device)

# 开始训练循环。
for epoch in range(num_epochs):
# 初始化累加器,用于统计分类准确性和边界框的平均绝对误差。
metric = d2l.Accumulator(4) # 分别累加分类错误数、分类总数、边界框绝对误差和边界框总数。
net.train() # 将模型设置为训练模式。
for features, target in train_iter: # 遍历训练数据集。
timer.start() # 开始计时。
trainer.zero_grad() # 清除之前的梯度。
X, Y = features.to(device), target.to(device) # 将数据移动到指定设备。

# 对输入图像生成多尺度的锚框,并预测每个锚框的类别和偏移量。
anchors, cls_preds, bbox_preds = net(X)

# 根据真实标注生成每个锚框的类别标签和偏移量标签。
bbox_labels, bbox_masks, cls_labels = d2l.multibox_target(anchors, Y)

# 计算分类和边界框预测的损失。
l = calc_loss(cls_preds, cls_labels, bbox_preds, bbox_labels, bbox_masks)
l.mean().backward() # 反向传播误差。
trainer.step() # 更新模型参数。

# 更新累加器。
metric.add(cls_eval(cls_preds, cls_labels), cls_labels.numel(),
bbox_eval(bbox_preds, bbox_labels, bbox_masks),
bbox_labels.numel())

# 计算并记录分类错误率和边界框的平均绝对误差。
cls_err, bbox_mae = 1 - metric[0] / metric[1], metric[2] / metric[3]
animator.add(epoch + 1, (cls_err, bbox_mae))

# 输出最终的分类错误率和边界框平均绝对误差。
print(f'class err {cls_err:.2e}, bbox mae {bbox_mae:.2e}')
# 输出训练速度,即每秒可以处理的样本数。
print(f'{len(train_iter.dataset) / timer.stop():.1f} examples/sec on {str(device)}')
"""
class err 3.22e-03, bbox mae 3.16e-03
3353.9 examples/sec on cuda:0
"""

../_images/output_ssd_739e1b_210_1.svg

预测目标

在预测阶段,我们希望能把图像里面所有我们感兴趣的目标检测出来。在下面,我们读取并调整测试图像的大小,然后将其转成卷积层需要的四维格式。

1
2
X = torchvision.io.read_image('../img/banana.jpg').unsqueeze(0).float()
img = X.squeeze(0).permute(1, 2, 0).long()

使用下面的multibox_detection函数,我们可以根据锚框及其预测偏移量得到预测边界框。然后,通过非极大值抑制来移除相似的预测边界框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def predict(X):
# 设置模型为评估模式。这通常会关闭dropout和批量归一化层的行为。
net.eval()
# 将输入数据X送到指定的设备(如GPU),并通过模型获取锚框、类别预测和边界框预测。
anchors, cls_preds, bbox_preds = net(X.to(device))
# 将类别预测的结果通过softmax函数处理,获取预测的概率分布。
# softmax操作应用于每个位置上的类别得分。
# permute操作调整维度的顺序,以符合multibox_detection函数的输入要求。
cls_probs = F.softmax(cls_preds, dim=2).permute(0, 2, 1)
# 使用multibox_detection函数处理预测结果。
# 该函数根据类别概率、边界框预测和锚框生成最终的检测结果。
# 检测结果中每行的格式为[class_id, score, bbox],其中bbox是边界框的坐标。
output = d2l.multibox_detection(cls_probs, bbox_preds, anchors)
# 过滤掉class_id为-1的结果,这些结果代表背景或低置信度的预测。
idx = [i for i, row in enumerate(output[0]) if row[0] != -1]
# 返回过滤后的检测结果。
return output[0, idx]

# 调用predict函数对输入X进行预测,并存储预测结果。
output = predict(X)

最后,我们筛选所有置信度不低于0.9的边界框,做为最终输出。

1
2
3
4
5
6
7
8
9
10
11
12
def display(img, output, threshold):
d2l.set_figsize((5, 5))
fig = d2l.plt.imshow(img)
for row in output:
score = float(row[1])
if score < threshold:
continue
h, w = img.shape[0:2]
bbox = [row[2:6] * torch.tensor((w, h, w, h), device=row.device)]
d2l.show_bboxes(fig.axes, bbox, '%.2f' % score, 'w')

display(img, output.cpu(), threshold=0.9)

../_images/output_ssd_739e1b_246_0.svg


区域卷积神经网络R-CNN

除了上一节描述的单发多框检测之外, 区域卷积神经网络(region-based CNN或regions with CNN features,R-CNN)也是将深度模型应用于目标检测的开创性工作之一。 本节将介绍R-CNN及其一系列改进方法:快速的R-CNN(Fast R-CNN)、更快的R-CNN(Faster R-CNN)和掩码R-CNN(Mask R-CNN。 限于篇幅,我们只着重介绍这些模型的设计思路。

R-CNN

R-CNN首先从输入图像中选取若干(例如2000个)提议区域(如锚框也是一种选取方法),并标注它们的类别和边界框(如偏移量)。然后,用卷积神经网络对每个提议区域进行前向传播以抽取其特征。 接下来,我们用每个提议区域的特征来预测类别和边界框。

../_images/r-cnn.svg

图中展示了R-CNN模型。具体来说,R-CNN包括以下四个步骤:

  1. 对输入图像使用选择性搜索来选取多个高质量的提议区域。这些提议区域通常是在多个尺度下选取的,并具有不同的形状和大小。每个提议区域都将被标注类别和真实边界框;
  2. 选择一个预训练的卷积神经网络,并将其在输出层之前截断。将每个提议区域变形为网络需要的输入尺寸,并通过前向传播输出抽取的提议区域特征;
  3. 将每个提议区域的特征连同其标注的类别作为一个样本。训练多个支持向量机对目标分类,其中每个支持向量机用来判断样本是否属于某一个类别;
  4. 将每个提议区域的特征连同其标注的边界框作为一个样本,训练线性回归模型来预测真实边界框。

尽管R-CNN模型通过预训练的卷积神经网络有效地抽取了图像特征,但它的速度很慢。 想象一下,我们可能从一张图像中选出上千个提议区域,这需要上千次的卷积神经网络的前向传播来执行目标检测。 这种庞大的计算量使得R-CNN在现实世界中难以被广泛应用。


Fast R-CNN

R-CNN的主要性能瓶颈在于,对每个提议区域,卷积神经网络的前向传播是独立的,而没有共享计算。 由于这些区域通常有重叠,独立的特征抽取会导致重复的计算。 Fast R-CNN 对R-CNN的主要改进之一,是仅在整张图象上执行卷积神经网络的前向传播。

../_images/fast-rcnn.svg

它的主要计算如下:

  1. 与R-CNN相比,Fast R-CNN用来提取特征的卷积神经网络的输入是整个图像,而不是各个提议区域。此外,这个网络通常会参与训练。设输入为一张图像,将卷积神经网络的输出的形状记为$1 \times c \times h_1 \times w_1$
  2. 假设选择性搜索生成了$n$个提议区域。这些形状各异的提议区域在卷积神经网络的输出上分别标出了形状各异的兴趣区域。然后,这些感兴趣的区域需要进一步抽取出形状相同的特征(比如指定高度$h_2$和宽度$w_2$),以便于连结后输出。为了实现这一目标,Fast R-CNN引入了兴趣区域汇聚层(RoI pooling):将卷积神经网络的输出和提议区域作为输入,输出连结后的各个提议区域抽取的特征,形状为$n \times c \times h_2 \times w_2$;
  3. 通过全连接层将输出形状变换为$n \times d$,其中超参数$d$取决于模型设计;
  4. 预测$n$个提议区域中每个区域的类别和边界框。更具体地说,在预测类别和边界框时,将全连接层的输出分别转换为形状为$n \times q$($q$是类别的数量)的输出和形状为$n \times 4$的输出。其中预测类别时使用softmax回归。

在Fast R-CNN中提出的兴趣区域汇聚层与CNN中介绍的汇聚层有所不同。在汇聚层中,我们通过设置汇聚窗口、填充和步幅的大小来间接控制输出形状。而兴趣区域汇聚层对每个区域的输出形状是可以直接指定的。

例如,指定每个区域输出的高和宽分别为$h_2$和$w_2$。对于任何形状为$h \times w$的兴趣区域窗口,该窗口将被划分为$h_2 \times w_2$子窗口网格,其中每个子窗口的大小约为$(h/h_2) \times (w/w_2)$。在实践中,任何子窗口的高度和宽度都应向上取整,其中的最大元素作为该子窗口的输出。因此,兴趣区域汇聚层可从形状各异的兴趣区域中均抽取出形状相同的特征。

../_images/roi.svg

作为说明性示例,图中提到,在$4 \times 4$的输入中,我们选取了左上角$3\times 3$的兴趣区域。对于该兴趣区域,我们通过$2\times 2$的兴趣区域汇聚层得到一个$2\times 2$的输出。请注意,四个划分后的子窗口中分别含有元素0、1、4、5(5最大);2、6(6最大);8、9(9最大);以及10。


Faster R-CNN

为了较精确地检测目标结果,Fast R-CNN模型通常需要在选择性搜索中生成大量的提议区域。 Faster R-CNN 提出将选择性搜索替换为区域提议网络(region proposal network),从而减少提议区域的生成数量,并保证目标检测的精度。

../_images/faster-rcnn.svg

描述了Faster R-CNN模型。 与Fast R-CNN相比,Faster R-CNN只将生成提议区域的方法从选择性搜索改为了区域提议网络,模型的其余部分保持不变。具体来说,区域提议网络的计算步骤如下:

  1. 使用填充为1的3×3的卷积层变换卷积神经网络的输出,并将输出通道数记为$c$。这样,卷积神经网络为图像抽取的特征图中的每个单元均得到一个长度为$c$的新特征。
  2. 以特征图的每个像素为中心,生成多个不同大小和宽高比的锚框并标注它们。
  3. 使用锚框中心单元长度为$c$的特征,分别预测该锚框的二元类别(含目标还是背景)和边界框。
  4. 使用非极大值抑制,从预测类别为目标的预测边界框中移除相似的结果。最终输出的预测边界框即是兴趣区域汇聚层所需的提议区域。

值得一提的是,区域提议网络作为Faster R-CNN模型的一部分,是和整个模型一起训练得到的。 换句话说,Faster R-CNN的目标函数不仅包括目标检测中的类别和边界框预测,还包括区域提议网络中锚框的二元类别和边界框预测。 作为端到端训练的结果,区域提议网络能够学习到如何生成高质量的提议区域,从而在减少了从数据中学习的提议区域的数量的情况下,仍保持目标检测的精度。


Mask R-CNN

如果在训练集中还标注了每个目标在图像上的像素级位置,那么Mask R-CNN能够有效地利用这些详尽的标注信息进一步提升目标检测的精度。

../_images/mask-rcnn.svg

Mask R-CNN是基于Faster R-CNN修改而来的。 具体来说,Mask R-CNN将兴趣区域汇聚层替换为了 兴趣区域对齐层,使用双线性插值(bilinear interpolation)来保留特征图上的空间信息,从而更适于像素级预测。 兴趣区域对齐层的输出包含了所有与兴趣区域的形状相同的特征图。 它们不仅被用于预测每个兴趣区域的类别和边界框,还通过额外的全卷积网络预测目标的像素级位置。 本章的后续章节将更详细地介绍如何使用全卷积网络预测图像中像素级的语义。


语义分割和数据集

在之前讨论的目标检测问题中,我们一直使用方形边界框来标注和预测图像中的目标。 本节将探讨语义分割(semantic segmentation)问题,它重点关注于如何将图像分割成属于不同语义类别的区域。 与目标检测不同,语义分割可以识别并理解图像中每一个像素的内容:其语义区域的标注和预测是像素级的。图中展示了语义分割中图像有关狗、猫和背景的标签。 与目标检测相比,语义分割标注的像素级的边框显然更加精细。

../_images/segmentation.svg


图像分割和实例分割

计算机视觉领域还有2个与语义分割相似的重要问题,即图像分割(image segmentation)和实例分割(instance segmentation)。 我们在这里将它们同语义分割简单区分一下。

  • 图像分割 将图像划分为若干组成区域,这类问题的方法通常利用图像中像素之间的相关性。它在训练时不需要有关图像像素的标签信息,在预测时也无法保证分割出的区域具有我们希望得到的语义。以图中的图像作为输入,图像分割可能会将狗分为两个区域:一个覆盖以黑色为主的嘴和眼睛,另一个覆盖以黄色为主的其余部分身体。
  • 实例分割 也叫同时检测并分割(simultaneous detection and segmentation),它研究如何识别图像中各个目标实例的像素级区域。与语义分割不同,实例分割不仅需要区分语义,还要区分不同的目标实例。例如,如果图像中有两条狗,则实例分割需要区分像素属于的两条狗中的哪一条。

数据集

1
2
3
4
5
%matplotlib inline
import os
import torch
import torchvision
from d2l import torch as d2l

数据集的tar文件大约为2GB,所以下载可能需要一段时间。 提取出的数据集位于../data/VOCdevkit/VOC2012

1
2
3
4
5
#@save
d2l.DATA_HUB['voc2012'] = (d2l.DATA_URL + 'VOCtrainval_11-May-2012.tar',
'4e443f8a2eca6b1dac8a6c57641b67dd40621a49')

voc_dir = d2l.download_extract('voc2012', 'VOCdevkit/VOC2012')

进入路径../data/VOCdevkit/VOC2012之后,我们可以看到数据集的不同组件。 ImageSets/Segmentation路径包含用于训练和测试样本的文本文件,而JPEGImagesSegmentationClass路径分别存储着每个示例的输入图像和标签。 此处的标签也采用图像格式,其尺寸和它所标注的输入图像的尺寸相同。 此外,标签中颜色相同的像素属于同一个语义类别。 下面将read_voc_images函数定义为将所有输入的图像和标签读入内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#@save
def read_voc_images(voc_dir, is_train=True):
"""读取所有VOC图像并标注"""
txt_fname = os.path.join(voc_dir, 'ImageSets', 'Segmentation',
'train.txt' if is_train else 'val.txt')
mode = torchvision.io.image.ImageReadMode.RGB
with open(txt_fname, 'r') as f:
images = f.read().split()
features, labels = [], []
for i, fname in enumerate(images):
features.append(torchvision.io.read_image(os.path.join(
voc_dir, 'JPEGImages', f'{fname}.jpg')))
labels.append(torchvision.io.read_image(os.path.join(
voc_dir, 'SegmentationClass' ,f'{fname}.png'), mode))
return features, labels

train_features, train_labels = read_voc_images(voc_dir, True)

下面我们绘制前5个输入图像及其标签。 在标签图像中,白色和黑色分别表示边框和背景,而其他颜色则对应不同的类别。

1
2
3
4
n = 5
imgs = train_features[0:n] + train_labels[0:n]
imgs = [img.permute(1,2,0) for img in imgs]
d2l.show_images(imgs, 2, n);

../_images/output_semantic-segmentation-and-dataset_23ff18_42_0.png

接下来,我们列举RGB颜色值和类名。

1
2
3
4
5
6
7
8
9
10
11
12
13
#@save
VOC_COLORMAP = [[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
[0, 0, 128], [128, 0, 128], [0, 128, 128], [128, 128, 128],
[64, 0, 0], [192, 0, 0], [64, 128, 0], [192, 128, 0],
[64, 0, 128], [192, 0, 128], [64, 128, 128], [192, 128, 128],
[0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0],
[0, 64, 128]]

#@save
VOC_CLASSES = ['background', 'aeroplane', 'bicycle', 'bird', 'boat',
'bottle', 'bus', 'car', 'cat', 'chair', 'cow',
'diningtable', 'dog', 'horse', 'motorbike', 'person',
'potted plant', 'sheep', 'sofa', 'train', 'tv/monitor']

通过上面定义的两个常量,我们可以方便地查找标签中每个像素的类索引。 我们定义了voc_colormap2label函数来构建从上述RGB颜色值到类别索引的映射,而voc_label_indices函数将RGB值映射到在Pascal VOC2012数据集中的类别索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#@save
def voc_colormap2label():
"""构建从RGB到VOC类别索引的映射"""
colormap2label = torch.zeros(256 ** 3, dtype=torch.long)
for i, colormap in enumerate(VOC_COLORMAP):
colormap2label[
(colormap[0] * 256 + colormap[1]) * 256 + colormap[2]] = i
return colormap2label

#@save
def voc_label_indices(colormap, colormap2label):
"""将VOC标签中的RGB值映射到它们的类别索引"""
colormap = colormap.permute(1, 2, 0).numpy().astype('int32')
idx = ((colormap[:, :, 0] * 256 + colormap[:, :, 1]) * 256
+ colormap[:, :, 2])
return colormap2label[idx]

例如,在第一张样本图像中,飞机头部区域的类别索引为1,而背景索引为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
y = voc_label_indices(train_labels[0], voc_colormap2label())
y[105:115, 130:140], VOC_CLASSES[1]


"""
(tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 1, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1]]),
'aeroplane')
"""

预处理数据

在之前的实验,我们通过再缩放图像使其符合模型的输入形状。 然而在语义分割中,这样做需要将预测的像素类别重新映射回原始尺寸的输入图像。 这样的映射可能不够精确,尤其在不同语义的分割区域。 为了避免这个问题,我们将图像裁剪为固定尺寸,而不是再缩放。 具体来说,我们使用图像增广中的随机裁剪,裁剪输入图像和标签的相同区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#@save
def voc_rand_crop(feature, label, height, width):
"""随机裁剪特征和标签图像"""
# 使用torchvision库中的RandomCrop.get_params方法获取随机裁剪的位置
rect = torchvision.transforms.RandomCrop.get_params(
feature, (height, width))
# 根据获取的裁剪位置(rect),对特征图像进行裁剪
feature = torchvision.transforms.functional.crop(feature, *rect)
# 根据同样的裁剪位置(rect),对标签图像进行裁剪,以确保特征和标签的一致性
label = torchvision.transforms.functional.crop(label, *rect)
return feature, label

imgs = []
for _ in range(n):
# 对指定的图像和标签进行n次随机裁剪,裁剪后的图像大小为200x300
# 这里假设train_features[0]和train_labels[0]是需要裁剪的特征和标签图像
imgs += voc_rand_crop(train_features[0], train_labels[0], 200, 300)

# 将裁剪后的图像的通道顺序由CHW(通道、高度、宽度)调整为HWC(高度、宽度、通道),以符合显示的需求
imgs = [img.permute(1, 2, 0) for img in imgs]
# 显示裁剪后的图像,每行显示两个图像,总共显示n个图像
# d2l.show_images函数假设是一个用于展示图像列表的自定义函数
d2l.show_images(imgs[::2] + imgs[1::2], 2, n);

../_images/output_semantic-segmentation-and-dataset_23ff18_90_0.png


自定义语义分割数据集类

我们通过继承高级API提供的Dataset类,自定义了一个语义分割数据集类VOCSegDataset。 通过实现__getitem__函数,我们可以任意访问数据集中索引为idx的输入图像及其每个像素的类别索引。 由于数据集中有些图像的尺寸可能小于随机裁剪所指定的输出尺寸,这些样本可以通过自定义的filter函数移除掉。 此外,我们还定义了normalize_image函数,从而对输入图像的RGB三个通道的值分别做标准化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#@save
class VOCSegDataset(torch.utils.data.Dataset):
"""一个用于加载VOC数据集的自定义数据集"""

def __init__(self, is_train, crop_size, voc_dir):
# 初始化时对图像进行标准化的变换
self.transform = torchvision.transforms.Normalize(
mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
self.crop_size = crop_size # 裁剪大小
# 读取VOC图像数据,返回特征图像和标签图像的列表
features, labels = read_voc_images(voc_dir, is_train=is_train)
# 过滤并标准化特征图像
self.features = [self.normalize_image(feature)
for feature in self.filter(features)]
# 过滤标签图像
self.labels = self.filter(labels)
# 获取颜色映射到标签的映射字典
self.colormap2label = voc_colormap2label()
print('read ' + str(len(self.features)) + ' examples')

def normalize_image(self, img):
# 将图像数据标准化,使其均值为0,标准差为1
return self.transform(img.float() / 255)

def filter(self, imgs):
# 过滤掉尺寸小于裁剪大小的图像
return [img for img in imgs if (
img.shape[1] >= self.crop_size[0] and
img.shape[2] >= self.crop_size[1])]

def __getitem__(self, idx):
# 获取单个样本,包括特征图像和对应的标签,根据索引idx进行随机裁剪
feature, label = voc_rand_crop(self.features[idx], self.labels[idx],
*self.crop_size)
return (feature, voc_label_indices(label, self.colormap2label))

def __len__(self):
# 返回数据集中样本的总数
return len(self.features)


读取

我们通过自定义的VOCSegDataset类来分别创建训练集和测试集的实例。 假设我们指定随机裁剪的输出图像的形状为320×480, 下面我们可以查看训练集和测试集所保留的样本个数。

1
2
3
crop_size = (320, 480)
voc_train = VOCSegDataset(True, crop_size, voc_dir)
voc_test = VOCSegDataset(False, crop_size, voc_dir)

设批量大小为64,我们定义训练集的迭代器。 打印第一个小批量的形状会发现:与图像分类或目标检测不同,这里的标签是一个三维数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
batch_size = 64
train_iter = torch.utils.data.DataLoader(voc_train, batch_size, shuffle=True,
drop_last=True,
num_workers=d2l.get_dataloader_workers())
for X, Y in train_iter:
print(X.shape)
print(Y.shape)
break


"""
torch.Size([64, 3, 320, 480])
torch.Size([64, 320, 480])
"""

整合

最后,我们定义以下load_data_voc函数来下载并读取Pascal VOC2012语义分割数据集。 它返回训练集和测试集的数据迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
#@save
def load_data_voc(batch_size, crop_size):
"""加载VOC语义分割数据集"""
voc_dir = d2l.download_extract('voc2012', os.path.join(
'VOCdevkit', 'VOC2012'))
num_workers = d2l.get_dataloader_workers()
train_iter = torch.utils.data.DataLoader(
VOCSegDataset(True, crop_size, voc_dir), batch_size,
shuffle=True, drop_last=True, num_workers=num_workers)
test_iter = torch.utils.data.DataLoader(
VOCSegDataset(False, crop_size, voc_dir), batch_size,
drop_last=True, num_workers=num_workers)
return train_iter, test_iter

转置卷积

到目前为止,我们所见到的卷积神经网络层,例如卷积层和汇聚层,通常会减少下采样输入图像的空间维度(高和宽)然而如果输入和输出图像的空间维度相同,在以像素级分类的语义分割中将会很方便。 例如,输出像素所处的通道维可以保有输入像素在同一位置上的分类结果。

为了实现这一点,尤其是在空间维度被卷积神经网络层缩小后,我们可以使用另一种类型的卷积神经网络层,它可以增加上采样中间层特征图的空间维度。 本节将介绍 转置卷积(transposed convolution)用于逆转下采样导致的空间尺寸减小。


定义

让我们暂时忽略通道,从基本的转置卷积开始,设步幅为1且没有填充。假设我们有一个$n_h \times n_w$的输入张量和一个$k_h \times k_w$的卷积核。以步幅为1滑动卷积核窗口,每行$n_w$次,每列$n_h$次,共产生$n_h n_w$个中间结果。每个中间结果都是一个$(n_h + k_h - 1) \times (n_w + k_w - 1)$的张量,初始化为0。

为了计算每个中间张量,输入张量中的每个元素都要乘以卷积核,从而使所得的$k_h \times k_w$张量替换中间张量的一部分。请注意,每个中间张量被替换部分的位置与输入张量中元素的位置相对应。最后,所有中间结果相加以获得最终结果。

例如,图中解释了如何为$2\times 2$的输入张量计算卷积核为$2\times 2$的转置卷积。我们可以对输入矩阵X和卷积核矩阵K(实现基本的转置卷积运算)trans_conv

../_images/trans_conv.svg

我们可以对输入矩阵X和卷积核矩阵K实现基本的转置卷积运算trans_conv

1
2
3
4
5
6
7
def trans_conv(X, K):
h, w = K.shape
Y = torch.zeros((X.shape[0] + h - 1, X.shape[1] + w - 1))
for i in range(X.shape[0]):
for j in range(X.shape[1]):
Y[i: i + h, j: j + w] += X[i, j] * K
return Y

与通过卷积核“减少”输入元素的常规卷积相比,转置卷积通过卷积核“广播”输入元素,从而产生大于输入的输出


填充,步幅,多通道

与常规卷积不同,在转置卷积中,填充被应用于的输出(常规卷积将填充应用于输入)。 例如,当将高和宽两侧的填充数指定为1时,转置卷积的输出中将删除第一和最后的行与列。

1
2
3
tconv = nn.ConvTranspose2d(1, 1, kernel_size=2, padding=1, bias=False)
tconv.weight.data = K
tconv(X)

在转置卷积中,步幅被指定为中间结果(输出),而不是输入。 使用图中相同输入和卷积核张量,将步幅从1更改为2会增加中间张量的高和权重,因此输出张量在下图中。

../_images/trans_conv_stride2.svg


全卷积网络

语义分割是对图像中的每个像素分类。 全卷积网络(fully convolutional network,FCN)采用卷积神经网络实现了从图像像素到像素类别的变换。 与我们之前在图像分类或目标检测部分介绍的卷积神经网络不同,全卷积网络将中间层特征图的高和宽变换回输入图像的尺寸:这是通过转置卷积(transposed convolution)实现的。 因此,输出的类别预测与输入图像在像素级别上具有一一对应关系:通道维的输出即该位置对应像素的类别预测


构造模型

下面我们了解一下全卷积网络模型最基本的设计。 如图所示,全卷积网络先使用卷积神经网络抽取图像特征,然后通过1×1卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的高和宽变换为输入图像的尺寸。 因此,模型输出与输入图像的高和宽相同,且最终输出通道包含了该空间位置像素的类别预测。

../_images/fcn.svg

下面,我们使用在ImageNet数据集上预训练的ResNet-18模型来提取图像特征,并将该网络记为pretrained_net。 ResNet-18模型的最后几层包括全局平均汇聚层和全连接层,然而全卷积网络中不需要它们。

1
2
pretrained_net = torchvision.models.resnet18(pretrained=True)
list(pretrained_net.children())[-3:]

接下来,我们创建一个全卷积网络net。 它复制了ResNet-18中大部分的预训练层,除了最后的全局平均汇聚层和最接近输出的全连接层。

1
net = nn.Sequential(*list(pretrained_net.children())[:-2])

给定高度为320和宽度为480的输入,net的前向传播将输入的高和宽减小至原来的1/32,即10和15。

1
2
X = torch.rand(size=(1, 3, 320, 480))
net(X).shape

接下来使用$1\times1$卷积层将输出通道数转换为Pascal VOC2012数据集的类数(21类)最后需要将特征图的高度和宽度增加32倍,从而将其变回输入图像的高和宽。

卷积层输出形状的计算方法:由于$(320-64+16\times2+32)/32=10$且$(480-64+16\times2+32)/32=15$,我们构造一个步幅为$32$的转置卷积层,并将卷积核的高和宽设为$64$,填充为$16$。我们可以看到如果步幅为$s$,填充为$s/2$(假设$s/2$是整数)且卷积核的高和宽为$2s$,转置卷积核会将输入的高和宽分别放大$s$倍。

1
2
3
4
num_classes = 21
net.add_module('final_conv', nn.Conv2d(512, num_classes, kernel_size=1))
net.add_module('transpose_conv', nn.ConvTranspose2d(num_classes, num_classes,
kernel_size=64, padding=16, stride=32))

初始化

在图像处理中,我们有时需要将图像放大,即上采样(upsampling)。双线性插值(bilinear interpolation)是常用的上采样方法之一,它也经常用于初始化转置卷积层。

为了解释双线性插值,假设给定输入图像,我们想要计算上采样输出图像上的每个像素。

  1. 将输出图像的坐标$(x,y)$映射到输入图像的坐标$(x’,y’)$上。例如,根据输入与输出的尺寸之比来映射。请注意,映射后的$x′$和$y′$是实数。

  2. 在输入图像上找到离坐标$(x’,y’)$最近的4个像素。

  3. 输出图像在坐标$(x,y)$上的像素依据输入图像上这4个像素及其与$(x’,y’)$的相对距离来计算。

双线性插值的上采样可以通过转置卷积层实现,内核由以下bilinear_kernel函数构造。限于篇幅,我们只给出bilinear_kernel函数的实现,不讨论算法的原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def bilinear_kernel(in_channels, out_channels, kernel_size):
factor = (kernel_size + 1) // 2
if kernel_size % 2 == 1:
center = factor - 1
else:
center = factor - 0.5
og = (torch.arange(kernel_size).reshape(-1, 1),
torch.arange(kernel_size).reshape(1, -1))
filt = (1 - torch.abs(og[0] - center) / factor) * \
(1 - torch.abs(og[1] - center) / factor)
weight = torch.zeros((in_channels, out_channels,
kernel_size, kernel_size))
weight[range(in_channels), range(out_channels), :, :] = filt
return weight

让我们用双线性插值的上采样实验它由转置卷积层实现。 我们构造一个将输入的高和宽放大2倍的转置卷积层,并将其卷积核用bilinear_kernel函数初始化。

1
2
3
conv_trans = nn.ConvTranspose2d(3, 3, kernel_size=4, padding=1, stride=2,
bias=False)
conv_trans.weight.data.copy_(bilinear_kernel(3, 3, 4));

读取图像X,将上采样的结果记作Y。为了打印图像,我们需要调整通道维的位置。

1
2
3
4
img = torchvision.transforms.ToTensor()(d2l.Image.open('../img/catdog.jpg'))
X = img.unsqueeze(0)
Y = conv_trans(X)
out_img = Y[0].permute(1, 2, 0).detach()

可以看到,转置卷积层将图像的高和宽分别放大了2倍。

1
2
3
4
5
d2l.set_figsize()
print('input image shape:', img.permute(1, 2, 0).shape)
d2l.plt.imshow(img.permute(1, 2, 0));
print('output image shape:', out_img.shape)
d2l.plt.imshow(out_img);

../_images/output_fcn_ce3435_102_1.svg

全卷积网络用双线性插值的上采样初始化转置卷积层。对于1×1卷积层,我们使用Xavier初始化参数。

1
2
W = bilinear_kernel(num_classes, num_classes, 64)
net.transpose_conv.weight.data.copy_(W);

读取数据集

用语义分割读取数据集。 指定随机裁剪的输出图像的形状为320×480:高和宽都可以被32整除。

1
2
batch_size, crop_size = 32, (320, 480)
train_iter, test_iter = d2l.load_data_voc(batch_size, crop_size)

现在我们可以训练全卷积网络了。 这里的损失函数和准确率计算与图像分类中的并没有本质上的不同,因为我们使用转置卷积层的通道来预测像素的类别,所以需要在损失计算中指定通道维。 此外,模型基于每个像素的预测类别是否正确来计算准确率。

1
2
3
4
5
6
def loss(inputs, targets):
return F.cross_entropy(inputs, targets, reduction='none').mean(1).mean(1)

num_epochs, lr, wd, devices = 5, 0.001, 1e-3, d2l.try_all_gpus()
trainer = torch.optim.SGD(net.parameters(), lr=lr, weight_decay=wd)
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)

../_images/output_fcn_ce3435_138_1.svg


预测

在预测时,我们需要将输入图像在各个通道做标准化,并转成卷积神经网络所需要的四维输入格式。

1
2
3
4
def predict(img):
X = test_iter.dataset.normalize_image(img).unsqueeze(0)
pred = net(X.to(devices[0])).argmax(dim=1)
return pred.reshape(pred.shape[1], pred.shape[2])

为了可视化预测的类别给每个像素,我们将预测类别映射回它们在数据集中的标注颜色。

1
2
3
4
def label2image(pred):
colormap = torch.tensor(d2l.VOC_COLORMAP, device=devices[0])
X = pred.long()
return colormap[X, :]

测试数据集中的图像大小和形状各异。 由于模型使用了步幅为32的转置卷积层,因此当输入图像的高或宽无法被32整除时,转置卷积层输出的高或宽会与输入图像的尺寸有偏差。 为了解决这个问题,我们可以在图像中截取多块高和宽为32的整数倍的矩形区域,并分别对这些区域中的像素做前向传播。 请注意,这些区域的并集需要完整覆盖输入图像。 当一个像素被多个区域所覆盖时,它在不同区域前向传播中转置卷积层输出的平均值可以作为softmax运算的输入,从而预测类别。

为简单起见,我们只读取几张较大的测试图像,并从图像的左上角开始截取形状为$320×480$的区域用于预测。 对于这些测试图像,我们逐一打印它们截取的区域,再打印预测结果,最后打印标注的类别。

1
2
3
4
5
6
7
8
9
10
11
voc_dir = d2l.download_extract('voc2012', 'VOCdevkit/VOC2012')
test_images, test_labels = d2l.read_voc_images(voc_dir, False)
n, imgs = 4, []
for i in range(n):
crop_rect = (0, 0, 320, 480)
X = torchvision.transforms.functional.crop(test_images[i], *crop_rect)
pred = label2image(predict(X))
imgs += [X.permute(1,2,0), pred.cpu(),
torchvision.transforms.functional.crop(
test_labels[i], *crop_rect).permute(1,2,0)]
d2l.show_images(imgs[::3] + imgs[1::3] + imgs[2::3], 3, n, scale=2);

../_images/output_fcn_ce3435_174_0.svg