13.1-13.4. 计算机视觉【1】

发布于:2025-06-18 ⋅ 阅读:(23) ⋅ 点赞:(0)

在 6节和 7节中,我们研究了计算机视觉中常用的各种卷积神经网络,并将它们应用到简单的图像分类任务中。 本章开头,我们将介绍两种可以改进模型泛化的方法,即图像增广和微调,并将它们应用于图像分类。 由于深度神经网络可以有效地表示多个层次的图像,因此这种分层表示已成功用于各种计算机视觉任务,例如目标检测(object detection)、语义分割(semantic segmentation)和样式迁移(style transfer)。 秉承计算机视觉中利用分层表示的关键思想,我们将从物体检测的主要组件和技术开始,继而展示如何使用完全卷积网络对图像进行语义分割,然后我们将解释如何使用样式迁移技术来生成像本书封面一样的图像。 最后在结束本章时,我们将本章和前几章的知识应用于两个流行的计算机视觉基准数据集。

13.1. 图像增广

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

13.1.1. 常用的图像增广方法

大多数图像增广方法都具有一定的随机性。为了便于观察图像增广的效果,我们下面定义辅助函数apply。 此函数在输入图像img上多次运行图像增广方法aug并显示所有结果。

def apply(img, aug, num_rows=2, num_cols=4, scale=1.5):
    Y = [aug(img) for _ in range(num_rows * num_cols)]
    d2l.show_images(Y, num_rows, num_cols, scale=scale)
13.1.1.1. 翻转和裁剪

左右翻转图像通常不会改变对象的类别。这是最早且最广泛使用的图像增广方法之一。 接下来,我们使用transforms模块来创建RandomFlipLeftRight实例,这样就各有50%的几率使图像向左或向右翻转。

上下翻转图像不如左右图像翻转那样常用。但是,至少对于这个示例图像,上下翻转不会妨碍识别。接下来,我们创建一个RandomFlipTopBottom实例,使图像各有50%的几率向上或向下翻转。

import torch
import torchvision
from torch import nn
from d2l import torch as d2l

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

在这里插入图片描述

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

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

在这里插入图片描述

13.1.1.2. 改变颜色

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

13.1.1.3. 结合多种图像增广方法

在实践中,我们将结合多种图像增广方法。比如,我们可以通过使用一个Compose实例来综合上面定义的不同的图像增广方法,并将它们应用到每个图像。
在这里插入图片描述

13.1.2. 使用图像增广进行训练

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

为了在预测过程中得到确切的结果,我们通常对训练样本只进行图像增广,且在预测过程中不使用随机操作的图像增广。 在这里,我们只使用最简单的随机左右翻转。 此外,我们使用ToTensor实例将一批图像转换为深度学习框架所要求的格式,即形状为(批量大小,通道数,高度,宽度)的32位浮点数,取值范围为0~1

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

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

13.1.3. 小结

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

13.2. 微调

前面的一些章节介绍了如何在只有6万张图像的Fashion-MNIST训练数据集上训练模型。 我们还描述了学术界当下使用最广泛的大规模图像数据集ImageNet,它有超过1000万的图像和1000类的物体。 然而,我们平常接触到的数据集的规模通常在这两者之间。

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

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

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

13.2.1. 步骤

本节将介绍迁移学习中的常见技巧:微调(fine-tuning)。如 图13.2.1所示,微调包括以下四个步骤。

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

在这里插入图片描述

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

13.2.2. 热狗识别

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

13.2.2.1. 获取数据集

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

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

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

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

注意
在这里插入图片描述
在这里插入图片描述

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

# 使用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(),
    # 在使用Normalize 之前,需要对图像调用ToTensor进行归一化
    torchvision.transforms.ToTensor(),
    normalize])

test_augs = torchvision.transforms.Compose([
    torchvision.transforms.Resize([256, 256]),
    torchvision.transforms.CenterCrop(224),
    torchvision.transforms.ToTensor(),
    normalize])
13.2.2.2. 定义和初始化模型

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

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

预训练的源模型实例包含许多特征层和一个输出层fc。 此划分的主要目的是促进对除输出层以外所有层的模型参数进行微调。 下面给出了源模型的成员变量fc。
在这里插入图片描述
在ResNet的全局平均汇聚层后,全连接层转换为ImageNet数据集的1000个类输出。 之后,我们构建一个新的神经网络作为目标模型。 它的定义方式与预训练源模型的定义方式相同,只是最终层中的输出数量被设置为目标数据集中的类数(而不是1000个)。

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

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

finetune_net = torchvision.models.resnet18(pretrained=True)
finetune_net.fc = nn.Linear(finetune_net.fc.in_features, 2)
nn.init.xavier_uniform_(finetune_net.fc.weight);
13.2.2.3. 微调模型

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

# 如果param_group=True,输出层中的模型参数将使用十倍的学习率
def train_fine_tuning(net, learning_rate, batch_size=128, num_epochs=5,
                      param_group=True):
    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)
    devices = d2l.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"]]
        # 使用不同的参数组,最后的全连接层使用10倍的学习率
        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(net, train_iter, test_iter, loss, trainer, num_epochs,
                   devices)

13.2.3. 小结

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

13.3. 目标检测和边界框

13.3.1. 边界框

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

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

13.3.2. 小结

  • 目标检测不仅可以识别图像中所有感兴趣的物体,还能识别它们的位置,该位置通常由矩形边界框表示。
  • 我们可以在两种常用的边界框表示(中间,宽度,高度)和(左上,右下)坐标之间进行转换。

13.4. 锚框

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

首先,让我们修改输出精度,以获得更简洁的输出。

%matplotlib inline
import torch
from d2l import torch as d2l

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

13.4.1. 生成多个锚框

在这里插入图片描述

#@save
def multibox_prior(data, sizes, ratios):
    """生成以每个像素为中心具有不同形状的锚框"""
    in_height, in_width = data.shape[-2:]
    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)

    # 为了将锚点移动到像素的中心,需要设置偏移量。
    # 因为一个像素的高为1且宽为1,我们选择偏移我们的中心0.5
    offset_h, offset_w = 0.5, 0.5
    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  # (h, ), 纵向归一化坐标
    center_w = (torch.arange(in_width, device=device) + offset_w) * steps_w  # (w, ), 横向归一化坐标
    shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij')  # (h, w), 每个网格的纵向坐标和横向坐标
    shift_y, shift_x = shift_y.reshape(-1), shift_x.reshape(-1)  # (h*w,)

    # 生成“boxes_per_pixel”个高和宽, 之后用于创建锚框的四角坐标(xmin,xmax,ymin,ymax)
    h = torch.cat(
        [
            size_tensor / torch.sqrt(ratio_tensor[0]),
            sizes[0] / torch.sqrt(ratio_tensor[1:])
        ]
    )
    w = torch.cat(
        [
            # s1*r1, s2*r1, ..., sn*r1, 只包含r1
            size_tensor * torch.sqrt(ratio_tensor[0]),
            # s1*r2, s1*r3, ..., s1*rn, 只包含s1, 比上面的少一个重复的s1*r1
            sizes[0] * torch.sqrt(ratio_tensor[1:])
        ]
    ) * in_height / in_width  # 这里*是必须的, 优先保证宽高比r可以被满足
    
    # 除以2来获得每个像素锚框的半高和半宽
    # (4, m+n-1) -> (m+n-1, 4) -> (h*w*(m+n-1), 4)
    anchor_manipulations = torch.stack((-w, -h, w, h)).T.repeat(
                                        in_height * in_width, 1) / 2

    # 每个中心点都将有“boxes_per_pixel”个锚框,所以生成含所有锚框中心的网格,重复了“boxes_per_pixel”次
    # (h*w, 4) -> ((h*w)*(m+n-1), 4)
    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
    # (1, (h*w)*(m+n-1), 4)
    return output.unsqueeze(0)

注意

  1. 下面这个锚框的宽度和高度中的 h ,实际上就是图像的高度h
    在这里插入图片描述
    这样做的主要目的是为了优先保证锚框的宽高比,而缩放比以h为参考进行缩放,最终保证整个锚框的面积是原图的 s 2 s^2 s2。这里不是对高度和宽度分别进行缩放,比如 h s r hs \sqrt{r} hsr w s / r ws/\sqrt{r} ws/r 。这是因为如果输入图像是长方形,并且设置的宽高比 r=1(即想让锚框为正方形),那么按照这个公式计算出来的锚框仍然是长方形,无法形成正方形。

    其次就是由于这个公式优先保证锚框的宽高比,所以缩放比是不一定能保证的。因为只要锚框的宽高比和图像的宽高比不一致,那么缩放比就一定只有总面积能满足,而不可能是宽、高都满足缩放

    因此代码中计算锚框宽高的时候,先以高度h为基准进行缩放,锚框的高度为 h s r hs \sqrt{r} hsr ;然后宽度有一个 *in_height/in_width 的操作,最终锚框的宽度为 h / w ∗ w s / r = h s / r h/w * ws/\sqrt{r} = hs /\sqrt{r} h/wws/r =hs/r ,是为了保证最终的面积缩放比为 s 2 s^2 s2

  2. torch.mesh_grid 函数:
    在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

13.4.2. 交并比(IoU)

在这里插入图片描述

#@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)  # (M, )
    areas2 = box_area(boxes2)  # (N, )
    
    # inter_upperlefts,inter_lowerrights,inters的形状: (boxes1的数量,boxes2的数量,2)
    # 相交矩形的左上角坐标,是两个矩形左上角坐标的最大值
    # (M, 1, 2), (N, 2) -> (M, N, 2)
    inter_upperlefts = torch.max(boxes1[:, None, :2], boxes2[:, :2])
    # 相交矩形的右下角坐标,是两个矩形右下角坐标的最小值
    # (M, 1, 2), (N, 2) -> (M, N, 2)
    inter_lowerrights = torch.min(boxes1[:, None, 2:], boxes2[:, 2:])
    # 右下角坐标 - 左上角坐标,clamp=0防止不想交的时候结果为负数
    inters = (inter_lowerrights - inter_upperlefts).clamp(min=0)
    
    # inter_areasandunion_areas的形状:(boxes1的数量,boxes2的数量)
    # 交集面积, (M, N)
    inter_areas = inters[:, :, 0] * inters[:, :, 1]
    # 并集面积, (M, 1) + (N,) - (M, N), 结果是(M, N)
    union_areas = areas1[:, None] + areas2 - inter_areas
    # (M, N), M个框和N个框之间的IOU
    return inter_areas / union_areas

13.4.3. 在训练数据中标注锚框

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

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

13.4.3.1. 将真实边界框分配给锚框

在这里插入图片描述

在这里插入图片描述
此算法在下面的assign_anchor_to_bbox函数中实现。

#@save
def assign_anchor_to_bbox(ground_truth, anchors, device, iou_threshold=0.5):
    """将最接近的真实边界框分配给锚框"""
    # M, N
    num_anchors, num_gt_boxes = anchors.shape[0], ground_truth.shape[0]
    # 位于第i行和第j列的元素x_ij是锚框i和真实边界框j的IoU
    jaccard = box_iou(anchors, ground_truth)  # (M, N)
    # 对于每个锚框,分配的真实边界框的张量
    # (M,), 先全部都填充为-1,表示不给锚框分配真值
    anchors_bbox_map = torch.full((num_anchors,), -1, dtype=torch.long,
                                  device=device)
    # 根据阈值,决定是否分配真实边界框
    # (M,), (M,) 每一行的最大值,每一行最大值所在列的索引 
    max_ious, indices = torch.max(jaccard, dim=1)
    # (M',), 返回非零元素的索引,相当于是一个mask
    anc_i = torch.nonzero(max_ious >= iou_threshold).reshape(-1)
    # (M',), 每一行非零元素所在列的索引
    box_j = indices[max_ious >= iou_threshold]
    # 先把每一行满足IOU阈值的进行索引赋值
    anchors_bbox_map[anc_i] = box_j
    col_discard = torch.full((num_anchors,), -1)
    row_discard = torch.full((num_gt_boxes,), -1)
    # 优先遍历每个真值,给他找对应的anchor
    for _ in range(num_gt_boxes):
        max_idx = torch.argmax(jaccard)
        # %是列坐标,也就是真值索引
        box_idx = (max_idx % num_gt_boxes).long()
        # /是行坐标,也就是anchor索引
        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
    # (M, ), 每个锚框对应的真值框索引
    return anchors_bbox_map
13.4.3.2. 标记类别和偏移量

在这里插入图片描述

#@save
def offset_boxes(anchors, assigned_bb, eps=1e-6):
    """对锚框偏移量的转换"""
    # (c_x, c_y, w, h)
    c_anc = d2l.box_corner_to_center(anchors)
    c_assigned_bb = d2l.box_corner_to_center(assigned_bb)
    # (M, 2), 中心点的相对偏移量
    offset_xy = 10 * (c_assigned_bb[:, :2] - c_anc[:, :2]) / c_anc[:, 2:]
    # (M, 2), 宽高的相对偏移量
    offset_wh = 5 * torch.log(eps + c_assigned_bb[:, 2:] / c_anc[:, 2:])
    # (M, 4), (c_x, c_y, w, h)
    offset = torch.cat([offset_xy, offset_wh], axis=1)
    return offset

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

#@save
def multibox_target(anchors, labels):
    """使用真实边界框标记锚框"""
    # labels: (b, N, 1+4), 1是lable, 4是真值框坐标
    # anchors: (1, M, 4), 1是bs, M是锚框个数,4是锚框坐标
    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, :, :]  # (N, 5)
        # (M, 4), (N, 4) 进行匹配,结果为 (M,) 即每个锚框匹配的真值框索引
        anchors_bbox_map = assign_anchor_to_bbox(
            label[:, 1:], anchors, device)
        # (M,) -> (M, 4), 匹配上真值框的那些锚框的mask
        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)
        # 使用真实边界框来标记锚框的类别。
        # 如果一个锚框没有被分配,标记其为背景(值为零)
        # (M',),匹配上真值框的那些锚框的索引, 类似一个mask
        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
        assigned_bb[indices_true] = label[bb_idx, 1:]
        # 偏移量转换,(M, 4), 就是把计算的offset进行统计量上的坐标转换
        offset = offset_boxes(anchors, assigned_bb) * bbox_mask
        batch_offset.append(offset.reshape(-1))  # (M*4)
        batch_mask.append(bbox_mask.reshape(-1))  #(M*4)
        batch_class_labels.append(class_labels)  # (M,)
    bbox_offset = torch.stack(batch_offset)  # (bs, M*4)
    bbox_mask = torch.stack(batch_mask)  # (bs, M*4)
    class_labels = torch.stack(batch_class_labels)  # (bs,)
    return (bbox_offset, bbox_mask, class_labels)
13.4.3.3. 一个例子

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

# 注意锚框加一个维度是固定bs=1, 真值加一个bs=1的维度是这里仅为了示例,真实情况bs可以>1
labels = multibox_target(anchors.unsqueeze(dim=0),
                         ground_truth.unsqueeze(dim=0))

在这里插入图片描述
在这里插入图片描述

13.4.4. 使用非极大值抑制预测边界框

在预测时,我们先为图像生成多个锚框,再为这些锚框一一预测类别和偏移量。 一个预测好的边界框则根据其中某个带有预测偏移量的锚框而生成。 下面我们实现了offset_inverse函数,该函数将锚框和偏移量预测作为输入,并应用逆偏移变换来返回预测的边界框坐标。(就是把之前对offset进行的统计量操作再去掉,返回真实的offset值)

#@save
def offset_inverse(anchors, offset_preds):
    """根据带有预测偏移量的锚框来预测边界框"""
    anc = d2l.box_corner_to_center(anchors)
    pred_bbox_xy = (offset_preds[:, :2] * anc[:, 2:] / 10) + anc[:, :2]
    pred_bbox_wh = torch.exp(offset_preds[:, 2:] / 5) * anc[:, 2:]
    pred_bbox = torch.cat((pred_bbox_xy, pred_bbox_wh), axis=1)
    predicted_bbox = d2l.box_center_to_corner(pred_bbox)
    return predicted_bbox

在这里插入图片描述

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

#@save
def nms(boxes, scores, iou_threshold):
    """对预测边界框的置信度进行排序"""
    # (N,), B是按照得分降序排列的索引
    B = torch.argsort(scores, dim=-1, descending=True)
    keep = []  # 保留预测边界框的指标
    while B.numel() > 0:
        i = B[0]  # 当前遍历的边界框索引
        keep.append(i)  # 当前边界框先保留
        if B.numel() == 1: break  # 只剩当前最后一个边界框,直接保留后break
        # (1, 4), (N, 4), 最后结果为(1, N), 即当前边界框和剩余N个边界框的IOU
        iou = box_iou(boxes[i, :].reshape(-1, 4),
                      boxes[B[1:], :].reshape(-1, 4)).reshape(-1)
        # 当前边界框和剩余N个边界框中IOU<阈值的那些索引,保留
        inds = torch.nonzero(iou <= iou_threshold).reshape(-1)  # (N')
        # 下一轮循环使用剩余的边界框,这里+1是因为上面计算IOU的时候用的是B[1:],索引从1开始
        B = B[inds + 1]
    return torch.tensor(keep, device=boxes.device)

我们定义以下multibox_detection函数来将非极大值抑制应用于预测边界框。 这里的实现有点复杂,请不要担心。我们将在实现之后,马上用一个具体的例子来展示它是如何工作的。

#@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]
    # anchor默认都是 (1, N, 4), N是anchor的数量
    anchors = anchors.squeeze(0)
    # cls_probs (bs, C+1, N), C+1是包括背景类别
    num_classes, num_anchors = cls_probs.shape[1], cls_probs.shape[2]
    out = []
    for i in range(batch_size):
    	# (C+1, N), (N*4,)
        cls_prob, offset_pred = cls_probs[i], offset_preds[i].reshape(-1, 4)
        # (N,), 沿着dim=0计算N个anchor得分最大的得分和类别
        conf, class_id = torch.max(cls_prob[1:], 0)
        # (N, 4), anchor加上offset之后预测的bbox坐标
        predicted_bb = offset_inverse(anchors, offset_pred)
        # (N',), nms之后保留的那些anchor索引
        keep = nms(predicted_bb, conf, nms_threshold)

        # 找到所有的non_keep索引,并将类设置为背景
        all_idx = torch.arange(num_anchors, dtype=torch.long, device=device)
        combined = torch.cat((keep, all_idx))
        # 返回数据中的唯一值数组(如果是数字还会升序排列),以及每个唯一值出现的次数
        uniques, counts = combined.unique(return_counts=True)
        # 唯一值出现次数为1,说明是all_idx中的,而不是keep中的,也就是non_keep
        non_keep = uniques[counts == 1]
        all_id_sorted = torch.cat((keep, non_keep))
        # non_keep的设置为-1,这里是把nms过滤掉的设置为背景
        class_id[non_keep] = -1  
        # 按照keep的得分降序排列顺序,重新计算预测的类别、得分、bbox
        class_id = class_id[all_id_sorted]
        conf, predicted_bb = conf[all_id_sorted], predicted_bb[all_id_sorted]
        # pos_threshold是一个用于非背景预测的阈值
        below_min_idx = (conf < pos_threshold)
        # 这里是把阈值<非背景类别的,设置为背景
        class_id[below_min_idx] = -1
        # 疑问:这里是为什么呢?
        # 解答:感觉意思是原来预测的是非背景的概率,现在类别设置成背景,所以概率就是1-非背景概率
        conf[below_min_idx] = 1 - conf[below_min_idx]
        # (N, 1), (N, 1), (N, 4) -> (N, 6)
        pred_info = torch.cat((class_id.unsqueeze(1),
                               conf.unsqueeze(1),
                               predicted_bb), dim=1)
        out.append(pred_info)
    # (B, N, 6)
    return torch.stack(out)

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

# (N, 4), 初始的anchor
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]])
# (N*4,), 每个anchor预测的offset
offset_preds = torch.tensor([0] * anchors.numel())
# (C+1, N), C是实际要预测的类别,+1是多一个背景类别, N是anchor数量
cls_probs = torch.tensor([[0] * 4,  # 背景的预测概率
                      [0.9, 0.8, 0.7, 0.1],  # 狗的预测概率
                      [0.1, 0.2, 0.3, 0.9]])  # 猫的预测概率

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

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

# 注意anchor的unsqueeze(0)是默认添加一个维度,其他艾迪unsqueeze都是为了模拟batch的维度
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]]])

在这里插入图片描述

13.4.5. 小结

  • 我们以图像的每个像素为中心生成不同形状的锚框。
  • 交并比(IoU)也被称为杰卡德系数,用于衡量两个边界框的相似性。它是相交面积与相并面积的比率。
  • 在训练集中,我们需要给每个锚框两种类型的标签。一个是与锚框中目标检测的类别,另一个是锚框真实相对于边界框的偏移量。
  • 预测期间可以使用非极大值抑制(NMS)来移除类似的预测边界框,从而简化输出。

13.5. 多尺度目标检测

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

13.5.1. 多尺度锚框

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

在这里插入图片描述
回想一下,在 6.2节中,我们将卷积图层的二维数组输出称为特征图。 通过定义特征图的形状,我们可以确定任何图像上均匀采样锚框的中心。

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

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

def display_anchors(fmap_w, fmap_h, s):
    d2l.set_figsize()
    # 前两个维度上的值不影响输出
    fmap = torch.zeros((1, 10, fmap_h, fmap_w))
    # multibox_prior
    anchors = d2l.multibox_prior(fmap, sizes=s, ratios=[1, 2, 0.5])
    bbox_scale = torch.tensor((w, h, w, h))
    d2l.show_bboxes(d2l.plt.imshow(img).axes,
                    anchors[0] * bbox_scale)

网站公告

今日签到

点亮在社区的每一天
去签到