图像增广

在卷积神经网络提到大型数据集是成功应用深度神经网络的先决条件

图像增广在对训练图像进行一系列的随机变化之后,生成相似但不同的训练样本,从而扩大了训练集的规模

应用图像增广的原因是,随机改变训练样本可以减少模型对某些属性的依赖,从而提高模型的泛化能力,例如可以以不同的方式裁剪图像,使感兴趣的对象出现在不同的位置,减少模型对于对象出现位置的依赖;还可以调整亮度、颜色等因素来降低模型对颜色的敏感度

图像增广技术对于AlexNet的成功是必不可少的

1
2
3
4
5
6
7
8
import torch
import torchvision # 图像数据 + 图像模型 + 图像处理
from torchvision import transforms # 图像数据预处理与增强
from torchvision import datasets # 数据集
from torch.utils import data # 数据加载接口
from torch import nn
import matplotlib.pyplot as plt
from PIL import Image

常用方法

在对常用图像增广方法的探索时,将使用下面这个尺寸为400×500的图像作为示例

1
2
3
4
img = Image.open('imgs/cat1.jpg')
plt.imshow(img)
plt.axis('off')
plt.show()
cat1

大多数图像增广方法都具有一定的随机性

定义辅助函数apply,此函数在输入图像img上多次运行图像增广方法aug并显示所有结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):  #@save
"""Plot a list of images."""
figsize = (num_cols * scale, num_rows * scale)
_, axes = plt.subplots(num_rows, num_cols, figsize=figsize)
axes = axes.flatten() # 把子图对象展开成一维列表方便索引

for i, (ax, img) in enumerate(zip(axes, imgs)):
if torch.is_tensor(img): # 如果是张量,就转为numpy
img = img.detach().numpy()
ax.imshow(img)
ax.axis('off')
if titles:
ax.set_title(titles[i])
plt.show()

def apply(img, aug, num_rows=2, num_cols=4, scale=1.5):
# aug: 增强操作对象(transform)
Y = [aug(img) for _ in range(num_rows * num_cols)]
show_images(Y, num_rows, num_cols, scale=scale)

翻转和裁剪

左右翻转图像通常不会改变对象的类别,这是最早且最广泛使用的图像增广方法之一

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

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

上下翻转图像不如左右图像翻转那样常用,至少对于这个图像,上下翻转不会妨碍识别

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

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

示例图片中猫位于图片的中间,但并非所有图片都是这样,汇聚层可以降低卷积层对目标位置的敏感性,另外也可以通过对图像进行随机裁剪,使物体以不同的比例出现在图像的不同位置,也可以降低模型对目标位置的敏感性

随机裁剪的区域面积占原始面积的10%到100%,该区域的宽高比从0.5~2之间随机取值,然后区域的宽度和高度都被缩放到200像素

正常都是取均匀分布,除非另外说明

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

改变颜色

另一种增广方法是改变颜色,可以改变图像颜色的四个方面:亮度、对比度、饱和度和色相

在下面的代码中随机更改图像的亮度,随机值为原始图像的50%-150%

1
2
3
4
5
apply(img, transforms.ColorJitter(
brightness=0.5, contrast=0, saturation=0, hue=0
))
# 范围为[max(0, 1 - x), 1 + x],或者直接输入元组指定(min, max)
# 四个参数的设定方法都是一样的

但是一般都是处理灰度图片,所以主要调整亮度

202511012114

如果同时更改

1
2
3
color_aug = transforms.ColorJitter(
brightness=0.5, contrast=0.5, saturation=0.5, hue=0.5)
apply(img, color_aug)
202511012115

综合使用

在实践中将结合多种图像增广方法,可以通过使用一个Compose实例来综合上面定义的不同的图像增广方法,并将它们应用到每个图像

1
2
3
4
5
augs = transforms.Compose([
transforms.RandomHorizontalFlip(),
color_aug,
shape_aug])
apply(img, augs)

训练

使用图像增广来训练模型,这里使用CIFAR-10数据集,而不是之前使用的Fashion-MNIST数据集,这是因为Fashion-MNIST数据集中对象的位置和大小已被规范化,而CIFAR-10数据集中对象的颜色和大小差异更明显

1
2
all_images = datasets.CIFAR10(root="../data", train=True, download=True)
show_images([all_images[i][0] for i in range(32)], 4, 8, scale=0.8);
202511012145

为了保证预测结果稳定,只在训练阶段使用图像增广,预测时不进行随机增广操作

在这里只使用最简单的随机左右翻转,使用ToTensor实例将一批图像转换为深度学习框架所要求的格式,即形状为(批量大小,通道数,高度,宽度)的32位浮点数,取值范围为0~1

1
2
3
4
5
6
train_augs = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.ToTensor()])

test_augs = transforms.Compose([
transforms.ToTensor()])

定义一个辅助函数,以便于读取图像和应用图像增广

PyTorch数据集提供的transform参数应用图像增广来转化图像

1
2
3
4
5
6
7
8
9
10
def load_cifar10(is_train, augs, batch_size):
dataset = datasets.CIFAR10(
root="../data", # 数据保存的路径
train=is_train, # 是否加载训练集(True)或测试集(False)
transform=augs, # 对图像进行的预处理
download=True # 若本地无数据则自动下载
)
dataloader = data.DataLoader(dataset, batch_size=batch_size,
shuffle=is_train, num_workers=get_dataloader_workers())
return dataloader

data.DataLoader

  • dataset:加载的数据集
  • batch_size:每次从数据集中取多少个样本组成一个 batch
  • shuffle:是否在每个 epoch 开始前随机打乱数据,训练一般True
  • num_workers:并行加载数据的子进程数

多GPU训练

在CIFAR-10数据集上训练ResNet-18模型,需要利用多GPU实现

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 train_batch_ch13(net, X, y, loss, trainer, devices):
"""用多GPU进行小批量训练"""
if isinstance(X, list): # 如果X是多个张量输入,比如文本
X = [x.to(devices[0]) for x in X]
else:
X = X.to(devices[0]) # 普通图像任务:单个张量
y = y.to(devices[0])
net.train() # 切换到训练模式
trainer.zero_grad() # 清空上一轮的梯度
pred = net(X) # 前向计算,得到预测
l = loss(pred, y)
l.sum().backward() # 对所有 GPU 上的损失求和后反向传播
trainer.step() # 参数更新
train_loss_sum = l.sum()
train_acc_sum = accuracy(pred, y)
return train_loss_sum, train_acc_sum

#@save
def train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
devices=try_all_gpus()):
"""用多GPU进行模型训练"""
timer, num_batches = Timer(), len(train_iter)
animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0, 1],
legend=['train loss', 'train acc', 'test acc'])
# 把网络包装成多 GPU 模型
net = nn.DataParallel(net, device_ids=devices).to(devices[0])
for epoch in range(num_epochs):
# 4个维度:储存训练损失,训练准确度,实例数,特点数
metric = Accumulator(4)
for i, (features, labels) in enumerate(train_iter):
timer.start()
l, acc = train_batch_ch13(
net, features, labels, loss, trainer, devices)
metric.add(l, acc, labels.shape[0], labels.numel())
timer.stop()
if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
animator.add(epoch + (i + 1) / num_batches,
(metric[0] / metric[2], metric[1] / metric[3],
None))
test_acc = evaluate_accuracy_gpu(net, test_iter)
animator.add(epoch + 1, (None, None, test_acc))
animator.show()
print(f'loss {metric[0] / metric[2]:.3f}, train acc '
f'{metric[1] / metric[3]:.3f}, test acc {test_acc:.3f}')
print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec on '
f'{str(devices)}')

自定义resnet18网络,把第一层改为3×3卷积层,因为cifar10的图片不是224,也是32×32,如果保持7×7会出问题,而且减少了第一组的池化层

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
48
49
50
51
52
53
54
55
# 基本残差块
class Residual(nn.Module):
def __init__(self, in_channels, out_channels, use_1x1conv=False, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3,
padding=1, stride=stride)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3,
padding=1)
if use_1x1conv:
# 当通道数不匹配时,用 1x1 卷积调整维度
self.conv3 = nn.Conv2d(in_channels, out_channels,
kernel_size=1, stride=stride)
else:
self.conv3 = None
self.bn1 = nn.BatchNorm2d(out_channels)
self.bn2 = nn.BatchNorm2d(out_channels)

def forward(self, X):
Y = nn.functional.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
Y += X
return nn.functional.relu(Y)

# ResNet-18主体
def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
blk = []
for i in range(num_residuals):
if i == 0 and not first_block:
blk.append(Residual(in_channels, out_channels,
use_1x1conv=True, stride=2))
else:
blk.append(Residual(out_channels, out_channels))
return blk

def resnet18(num_classes, in_channels=3):
# 输入层
net = nn.Sequential(
nn.Conv2d(in_channels, 64, kernel_size=3, stride=1, padding=3), # 这里给改小了,因为图片尺寸小
nn.BatchNorm2d(64),
nn.ReLU() # 不需要再池化了
)

# 四个阶段,每个阶段通道数分别是 64, 128, 256, 512
net.add_module("resnet_block1", nn.Sequential(*resnet_block(64, 64, 2, first_block=True)))
net.add_module("resnet_block2", nn.Sequential(*resnet_block(64, 128, 2)))
net.add_module("resnet_block3", nn.Sequential(*resnet_block(128, 256, 2)))
net.add_module("resnet_block4", nn.Sequential(*resnet_block(256, 512, 2)))

# 全局平均池化 + 全连接层
net.add_module("global_avg_pool", nn.AdaptiveAvgPool2d((1, 1)))
net.add_module("fc", nn.Sequential(nn.Flatten(), nn.Linear(512, num_classes)))

return net

可以定义train_with_data_aug函数,使用图像增广来训练模型

该函数获取所有的GPU,并使用Adam作为训练的优化算法,将图像增广应用于训练集,最后调用刚刚定义的用于训练和评估模型的train_ch13函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 设置批量大小、设备列表和网络结构
batch_size, devices, net = 256, try_all_gpus(), resnet18(10, 3)
# 定义权重初始化函数
def init_weights(m):
# 只对线性层和卷积层进行 Xavier 初始化
if type(m) in [nn.Linear, nn.Conv2d]:
nn.init.xavier_uniform_(m.weight)

net.apply(init_weights)

def train_with_data_aug(train_augs, test_augs, net, lr=0.001):
# 加载 CIFAR-10 数据集
train_iter = load_cifar10(True, train_augs, batch_size)
test_iter = load_cifar10(False, test_augs, batch_size)
# 定义损失函数(交叉熵损失)
loss = nn.CrossEntropyLoss(reduction="none")
# 定义优化器,这里使用 Adam,自适应学习率算法
trainer = torch.optim.Adam(net.parameters(), lr=lr)
train_ch13(net, train_iter, test_iter, loss, trainer, 10, devices)
1
2
3
4
5
6
7
8
9
10
epoch 1, loss 1.427, acc 0.490, test acc 0.498, time 82.98 sec
epoch 2, loss 0.884, acc 0.687, test acc 0.517, time 83.32 sec
epoch 3, loss 0.659, acc 0.770, test acc 0.675, time 82.61 sec
epoch 4, loss 0.524, acc 0.818, test acc 0.758, time 83.24 sec
epoch 5, loss 0.430, acc 0.851, test acc 0.767, time 82.97 sec
epoch 6, loss 0.360, acc 0.875, test acc 0.805, time 83.55 sec
epoch 7, loss 0.307, acc 0.893, test acc 0.819, time 83.18 sec
epoch 8, loss 0.258, acc 0.910, test acc 0.838, time 83.17 sec
epoch 9, loss 0.217, acc 0.926, test acc 0.844, time 82.58 sec
epoch 10, loss 0.186, acc 0.936, test acc 0.851, time 81.58 sec
image-20251102145949769
1
2
loss 0.186, train acc 0.936, test acc 0.851
1383.1 examples/sec on [device(type='cuda', index=0)]

这里仅使用随机左右翻转的图像增广来训练模型

小结

  • 图像增广基于现有的训练数据生成随机图像,来提高模型的泛化能力
  • 为了在预测过程中得到确切的结果,通常对训练样本只进行图像增广,而在预测过程中不使用带随机操作的图像增广
  • 深度学习框架提供了许多不同的图像增广方法,这些方法可以被同时应用

微调

由于训练样本数量有限,训练模型的准确性可能无法满足实际要求

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

步骤

迁移学习中的常见技巧:微调(fine-tuning)

包括以下四个步骤:

  1. 在源数据集(例如ImageNet数据集)上预训练神经网络模型,即源模型
  2. 创建一个新的神经网络模型,即目标模型,这将复制源模型上的所有模型设计及其参数(不包括输出层)。前面层的参数包含通用特征,可直接用于新任务;输出层与旧任务标签相关,因此需重新训练
  3. 向目标模型添加输出层,其输出数是目标数据集中的类别数,然后随机初始化该层的模型参数
  4. 在目标数据集上训练目标模型,输出层将从头开始进行训练,而所有其他层的参数将根据源模型的参数进行微调
finetune

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

热狗识别

在一个小型数据集上微调ResNet模型,该模型已在ImageNet数据集上进行了预训练。小型数据集包含数千张包含热狗和不包含热狗的图像,使用微调模型来识别图像中是否包含热狗

获取数据集

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

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

1
2
3
4
5
6
DATA_HUB = dict()
DATA_URL = 'http://d2l-data.s3-accelerate.amazonaws.com/'
DATA_HUB['hotdog'] = ( #@save
DATA_URL + 'hotdog.zip',
'fba480ffa8aa7e0febbb511d181409f899b9baa5')
data_dir = download_extract('hotdog')

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

1
2
train_imgs = datasets.ImageFolder(os.path.join(data_dir, 'train'))
test_imgs = 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)]
show_images(hotdogs + not_hotdogs, 2, 8, scale=1.4);
202511021414
  • 输入图像用源模型(预训练模型)原始数据集的均值和标准差来做标准化

    1
    2
    3
    4
    5
    normalize = torchvision.transforms.Normalize(
    # ImageNet 数据集统计得到
    [0.485, 0.456, 0.406], # 每个通道的均值(mean)
    [0.229, 0.224, 0.225] # 每个通道的标准差(std)
    )
  • 在训练期间,首先从图像中裁切随机大小和随机长宽比的区域,然后将该区域缩放为224×224的输入图像,再进行随机水平翻转和标准化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    train_augs = torchvision.transforms.Compose([
    # 随机裁剪图像到224×224
    torchvision.transforms.RandomResizedCrop(224),
    # 随机水平翻转图像,默认概率0.5
    torchvision.transforms.RandomHorizontalFlip(),
    # 把PIL图像或numpy数组转换为Tensor格式,并把像素归一化到 [0,1]
    torchvision.transforms.ToTensor(),
    # 按ImageNet统计值对图像进行标准化
    normalize])
  • 在测试过程中将图像的高度和宽度都缩放到256像素,然后裁剪中央224×224区域作为输入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    test_augs = torchvision.transforms.Compose([
    # 先把图像缩放到256×256,保持长宽比
    torchvision.transforms.Resize([256, 256]),
    # 从中心裁剪出224×224区域,与训练时的尺寸一致
    torchvision.transforms.CenterCrop(224),
    # 转换为Tensor格式,并把像素归一化到 [0,1]
    torchvision.transforms.ToTensor(),
    # 使用同样的ImageNet标准化参数
    normalize])

定义和初始化模型

使用在ImageNet数据集上预训练的ResNet-18作为源模型,在这里指定pretrained=True以自动下载预训练的模型参数

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

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

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

在ResNet的全局平均池化层后,原来的全连接层输出1000个ImageNet类

构建目标模型时,结构与预训练模型相同,只将输出层改为目标数据集的类别数,目标模型的特征层参数继承自预训练模型,只需用较小学习率微调

输出层参数随机初始化,需要更大学习率(约为特征层的10倍)来从头训练

1
2
3
finetune_net = torchvision.models.resnet18(pretrained=True) # 使用 在 ImageNet 上训练好的权重
finetune_net.fc = nn.Linear(finetune_net.fc.in_features, 2) # 调整输出层的输出类
nn.init.xavier_uniform_(finetune_net.fc.weight);

微调模型

定义了一个训练函数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
data_dir = download_extract('hotdog') # 需要前定义data_dir

# 如果param_group=True,输出层中的模型参数将使用十倍的学习率
def train_fine_tuning(net, learning_rate, batch_size=128, num_epochs=5, param_group=True):
train_iter = data.DataLoader(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)
devices = try_all_gpus()
loss = nn.CrossEntropyLoss(reduction="none")
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)
train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, devices)
1
train_fine_tuning(finetune_net, 5e-5)
1
2
3
4
5
epoch 1, loss 2.481, acc 0.708, test acc 0.924, time 22.67 sec
epoch 2, loss 0.281, acc 0.912, test acc 0.931, time 19.62 sec
epoch 3, loss 0.446, acc 0.888, test acc 0.879, time 19.63 sec
epoch 4, loss 0.217, acc 0.932, test acc 0.931, time 19.73 sec
epoch 5, loss 0.176, acc 0.930, test acc 0.919, time 19.72 sec
202511021506
1
2
loss 0.176, train acc 0.930, test acc 0.919
339.0 examples/sec on [device(type='cuda', index=0)]

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

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)
1
2
3
4
5
epoch 1, loss 1.664, acc 0.673, test acc 0.812, time 19.94 sec
epoch 2, loss 0.385, acc 0.837, test acc 0.844, time 19.95 sec
epoch 3, loss 0.395, acc 0.824, test acc 0.795, time 19.47 sec
epoch 4, loss 0.392, acc 0.830, test acc 0.766, time 19.94 sec
epoch 5, loss 0.371, acc 0.836, test acc 0.844, time 19.73 sec
202511021507
1
2
loss 0.371, train acc 0.836, test acc 0.844
340.4 examples/sec on [device(type='cuda', index=0)]

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

小结

  • 迁移学习将从源数据集中学到的知识迁移到目标数据集,微调是迁移学习的常见技巧
  • 除输出层外,目标模型从源模型中复制所有模型设计及其参数,并根据目标数据集对这些参数进行微调。但是目标模型的输出层需要从头开始训练
  • 通常,微调参数使用较小的学习率,而从头开始训练输出层可以使用更大的学习率

思考题

  1. 将输出层finetune_net之前的参数设置为源模型的参数,在训练期间不要更新它们。模型的准确性如何变化?

    1
    2
    3
    4
    5
    6
    7
    # 1. 先冻结所有参数
    for param in finetune_net.parameters():
    param.requires_grad = False

    # 2. 再单独解冻输出层(fc层)
    for param in finetune_net.fc.parameters():
    param.requires_grad = True

    冻结除输出层外的所有参数时模型训练更快、更稳定,但由于特征无法适应新任务,准确率通常比“微调全部层”略低

目标检测和边界框

在图像分类中通常假设图像只有一个主要目标,但若图像中存在多个对象,不仅要识别它们的类别,还要确定它们在图像中的位置,将这类任务称为目标检测(object detection)

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

接下来以下图为例

1
2
3
4
img = Image.open('imgs/catdog.jpg')
plt.imshow(img)
plt.axis('off')
plt.show()
202511021602

边界框

在目标检测中通常使用**边界框(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
22
23
#@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
# axis=-1 表示在最后一个维度上拼接,也就是每一行变成 [中心x, 中心y, 宽, 高]
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
# 把结果在最后一个维度拼接为 [x1, y1, x2, y2]
boxes = torch.stack((x1, y1, x2, y2), axis=-1)
return boxes

将根据坐标信息定义图像中狗和猫的边界框

图像中坐标的原点是图像的左上角,向右的方向为x轴的正方向,向下的方向为y轴的正方向

1
dog_bbox, cat_bbox = [60.0, 45.0, 378.0, 516.0], [400.0, 112.0, 655.0, 493.0]

通过转换两次来验证边界框转换函数的正确性

1
2
3
4
boxes = torch.tensor((dog_bbox, cat_bbox))
box_center_to_corner(box_corner_to_center(boxes)) == boxes
# tensor([[True, True, True, True],
# [True, True, True, True]])

可以将边界框在图中画出,以检查其是否准确

画之前定义一个辅助函数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
4
5
6
img = Image.open('imgs/catdog.jpg')
fig, ax = plt.subplots()
ax.imshow(img)
ax.add_patch(bbox_to_rect(dog_bbox, 'blue')) # add_patch内输入rect
ax.add_patch(bbox_to_rect(cat_bbox, 'red'))
plt.show()
202511021616

锚框

目标检测算法通常会在输入图像中采样大量的区域然后判断这些区域中是否包含我们感兴趣的目标,并调整区域边界从而更准确地预测目标的真实边界框(ground-truth bounding box)

不同的模型使用的区域采样方法可能不同,这里使用的是以每个像素为中心,生成多个缩放比和宽高比(aspect ratio)不同的边界框,这些边界框被称为锚框(anchor box)

修改输出精度,以获得更简洁的输出

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

生成多个锚框

假设输入图像的高度为$h$,高度为$w$,以图像的每个像素为中心生成不同形状的锚框:缩放比为$s\in (0, 1]$,宽高比为$r>0$
$$
A = hw \rightarrow A_{anchor} = s^2\times A=s^2 hw=h_aw_a
$$
代入$w_a = rh_a$
$$
rh_a^2=s^2hw\rightarrow h_a=s\sqrt{hw/r}\rightarrow w_a=s\sqrt{hwr}
$$
在很多检测网络中输入图像$h=w$,那么锚框的宽度和高度
$$
w_a = hs\sqrt r\qquad h_a=hs/\sqrt r
$$
要生成多个不同形状的锚框只需要修改缩放比$s_1,\ldots, s_n$和宽高比$r_1,\ldots, r_m$,当使用这些比例和长宽比的所有组合以每个像素为中心时,输入图像将总共有$whnm$个锚框,尽管这些锚框可能会覆盖所有真实边界框,但计算复杂性很容易过高

在实践中,只考虑包含$s_1$或$r_1$的组合
$$
(s_1, r_1), (s_1, r_2), \ldots, (s_1, r_m), (s_2, r_1), (s_3, r_1), \ldots, (s_n, r_1).
$$
以同一像素为中心的锚框的数量是$n+m-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
#@save
def multibox_prior(data, sizes, ratios):
"""生成以每个像素为中心具有不同形状的锚框"""
# 输入特征图的高和宽,例如(1, 3, 32, 32),则 in_height=32, in_width=32
in_height, in_width = data.shape[-2:]
device, num_sizes, num_ratios = data.device, len(sizes), len(ratios)
# 以同一像素为中心的锚框的数量是 n+m-1
boxes_per_pixel = (num_sizes + num_ratios - 1)
# 把缩放比 (sizes) 和宽高比 (ratios) 转成 Tensor
size_tensor = torch.tensor(sizes, device=device)
ratio_tensor = torch.tensor(ratios, device=device)

# 为了让锚框中心落在像素中心,设置偏移量 0.5
offset_h, offset_w = 0.5, 0.5
# 把坐标缩放到 [0, 1] 区间
steps_h = 1.0 / in_height # 在y轴上缩放步长
steps_w = 1.0 / in_width # 在x轴上缩放步长

# 生成锚框的所有中心点
center_h = (torch.arange(in_height, device=device) + offset_h) * steps_h
center_w = (torch.arange(in_width, device=device) + offset_w) * steps_w
# 生成所有像素中心点网格(shift_y, shift_x)
shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij') # y绑定i(行),x绑定j(列)
# 拉平成一维向量(方便后续拼接)
shift_y, shift_x = shift_y.reshape(-1), shift_x.reshape(-1)

# 得到每个锚框在归一化坐标系(0~1)下的宽高
# 只考虑包含s_1或r_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来获得半高和半宽
# stack拼接,每列为一种框,然后转置变成每行一种框,然后复制到每个像素上
anchor_manipulations = torch.stack((-w, -h, w, h)).T.repeat(in_height * in_width, 1) / 2

# 每个中心点都将有“boxes_per_pixel”个锚框,
# 所以生成含所有锚框中心的网格,重复了“boxes_per_pixel”次
out_grid = torch.stack([shift_x, shift_y, shift_x, shift_y],
dim=1).repeat_interleave(boxes_per_pixel, dim=0)
output = out_grid + anchor_manipulations
return output.unsqueeze(0) # 增加一个 batch 维度

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

1
2
3
4
5
img = Image.open('imgs/catdog.jpg')
w, h = img.size # PIL 返回 (width, height)
X = torch.rand(size=(1, 3, h, w))
Y = multibox_prior(X, sizes=[0.75, 0.5, 0.25], ratios=[1, 2, 0.5])
boxes = Y.reshape(h, w, 5, 4)

将锚框变量Y的形状更改为(图像高度,图像宽度,以同一像素为中心的锚框的数量,4)后可以获得以指定像素的位置为中心的所有锚框

1
boxes = Y.reshape(h, w, 5, 4)

访问以(250,250)为中心的第一个锚框

1
boxes[250, 250, 0, :]
1
tensor([0.06, 0.07, 0.63, 0.82])

为了显示以图像中以某个像素为中心的所有锚框,定义下面的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
30
#@save
def show_bboxes(axes, bboxes, labels=None, colors=None):
"""显示所有边界框"""
def _make_list(obj, default_values=None):
if obj is None:
obj = default_values
elif not isinstance(obj, (list, tuple)):
obj = [obj]
return obj

# 处理标签和颜色,使它们一定是列表格式
labels = _make_list(labels)
colors = _make_list(colors, ['b', 'g', 'r', 'm', 'c'])

# 遍历每一个边界框进行绘制
for i, bbox in enumerate(bboxes):
color = colors[i % len(colors)]
# bbox 为 [xmin, ymin, xmax, ymax]
# bbox_to_rect 会返回 Rectangle(xy=(xmin, ymin), width=w, height=h)
rect = bbox_to_rect(bbox.detach().numpy(), color)

# 将矩形框添加到坐标轴上
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中xy轴的坐标值已经分别除以图像的宽度和高度,绘制锚框时需要恢复它们原始的坐标值,在下面定义了变量bbox_scale,现在可以绘制出图像中所有以(250,250)为中心的锚框了

1
2
3
4
5
6
bbox_scale = torch.tensor((w, h, w, h))
fig, ax = plt.subplots()
ax.imshow(img)
show_bboxes(ax, 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'])
202511021727

缩放比为0.75且宽高比为1的蓝色锚框很好地围绕着图像中的狗

交并比(IoU)

如果已知目标的真实边界框,那么这里的“好”该如何如何量化呢?

直观地说,可以衡量锚框和真实边界框之间的相似性

杰卡德系数(Jaccard)可以衡量两组之间的相似性,给定集合$\mathcal{A}$和$\mathcal{B}$,他们的杰卡德系数是他们交集的大小除以他们并集的大小:
$$
J(\mathcal{A},\mathcal{B}) = \frac{\left|\mathcal{A} \cap \mathcal{B}\right|}{\left| \mathcal{A} \cup \mathcal{B}\right|}.
$$
两个边界框的相似性可用它们像素区域的杰卡德系数衡量,这个系数通常称为交并比
(intersection over union,IoU)
,即两个边界框相交面积与相并面积之比

交并比的取值范围在0和1之间:0表示两个边界框无重合像素,1表示两个边界框完全重合

iou

接下来部分将使用交并比来衡量锚框和真实边界框之间、以及不同锚框之间的相似度

给定两个锚框或边界框的列表,以下box_iou函数将在这两个列表中计算它们成对的交并比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#@save
def box_iou(boxes1, boxes2):
"""计算两个锚框或边界框列表中成对的交并比"""
box_area = lambda boxes: ((boxes[:, 2] - boxes[:, 0]) *
(boxes[:, 3] - boxes[:, 1]))
# boxes1,boxes2,areas1,areas2的形状:
# boxes1:(boxes1的数量,4),
# boxes2:(boxes2的数量,4),
# areas1:(boxes1的数量,),
# areas2:(boxes2的数量,)
areas1 = box_area(boxes1)
areas2 = box_area(boxes2)
# inter_upperlefts,inter_lowerrights,inters的形状:
# (boxes1的数量,boxes2的数量,2)
inter_upperlefts = torch.max(boxes1[:, None, :2], boxes2[:, :2])
inter_lowerrights = torch.min(boxes1[:, None, 2:], boxes2[:, 2:])
inters = (inter_lowerrights - inter_upperlefts).clamp(min=0)
# inter_areasandunion_areas的形状:(boxes1的数量,boxes2的数量)
inter_areas = inters[:, :, 0] * inters[:, :, 1]
union_areas = areas1[:, None] + areas2 - inter_areas
return inter_areas / union_areas