动手学深度学习——目标检测

经过个人整理。来源:13.4. 锚框 — 动手学深度学习 2.0.0-beta1 documentation

目录

基本概念

边界框

转换函数

画出边界框

锚框

生成锚框

画出锚框

交并比(IoU)

使用训练数据标记锚框

将真实边界框分配给锚框

求锚框相对于边界框的偏移量

为锚框标记类别和偏移量

预测

使用锚框和偏移量生成预测边界框

使用非极大值抑制处理预测边界框

举例测试

目标检测算法:SDD(单发多框检测)

特点

多尺度检测

数据集

 SSD模型

类别预测层

边界框预测层 

连结多尺度的预测

前向传播

完整的模型

训练模型

读取数据集和初始化

定义损失函数和评价函数 

训练

预测目标

区域卷积神经网络(R-CNN)

R-CNN

Fast R-CNN

Faster R-CNN

Mask R-CNN

小结

需要注意的函数

torch.set_printoptions

torch.meshgrid

reshape(-1)

repeat和repeat_interleave

torch.stack()

切片时使用None

unique


很多时候图像里有多个我们感兴趣的目标,我们不仅想知道它们的类别,还想得到它们在图像中的具体位置。 在计算机视觉里,我们将这类任务称为目标检测(object detection)或目标识别(object recognition)。

%matplotlib inline
import torch
from d2l import torch as d2l

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

基本概念

边界框

在目标检测中,我们通常使用边界框(bounding box)来描述对象的空间位置。 边界框是矩形的,两种常用的边界框表示:(中心,宽度,高度)和(左上,右下)。坐标的原点是图像的左上角,向右的方向为轴的正方向,向下的方向为轴的正方向。

转换函数

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

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


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

画出边界框

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)


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'));

动手学深度学习——目标检测_第1张图片

锚框

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

生成锚框

假设输入图像的高度为ℎ,宽度为,缩放比为∈(0,1],宽高比为>0。 那么锚框的宽度和高度分别是ws√r和hs/√r。设置缩放比(scale)取值1,…,和宽高比(aspect ratio)取值1,…,,只考虑包含1或1的组合:(1,1),(1,2),…,(1,),(2,1),(3,1),…,(,1). 也就是说,以同一像素为中心的锚框的数量是+−1。 对于整个输入图像,我们将共生成ℎ(+−1)个锚框。

指定输入图像、尺寸列表和宽高比列表,然后此将返回所有的锚框。函数对data的使用其实就是取出图像的高和宽(in_height和in_width),生成锚框中心点的时候,将高和宽分别分为in_height和in_width份,所以是在每个像素点上生成锚框。

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
    center_w = (torch.arange(in_width, device=device) + offset_w) * steps_w
    shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij')
    shift_y, shift_x = shift_y.reshape(-1), shift_x.reshape(-1) 
    # shift_y和shift_x元素组合就是中心点 reshape(-1)变成一维,shape:[center_h*center_w]

    # 生成“boxes_per_pixel”个高和宽,之后用于创建锚框的四角坐标(xmin,xmax,ymin,ymax)
    w = torch.cat((size_tensor * torch.sqrt(ratio_tensor[0]),
                   sizes[0] * torch.sqrt(ratio_tensor[1:])))\
                   * in_height / in_width  # 包含 r1 或 s1 的组合  shape:[boxes_per_pixel]   
    h = torch.cat((size_tensor / torch.sqrt(ratio_tensor[0]),
                   sizes[0] / torch.sqrt(ratio_tensor[1:])))
    # 除以2来获得半高和半宽
    # repeat前,转置的形状:[boxes_per_pixel,4]
    anchor_manipulations = torch.stack((-w, -h, w, h)).T.repeat(
                                        in_height * in_width, 1) / 2

    # 每个中心点都将有“boxes_per_pixel”个锚框,
    # 所以生成含所有锚框中心的网格,重复了“boxes_per_pixel”次
    # shift_x, shift_y, shift_x, shift_y是要加载-w, -h, w, h上的
    # out_grid 行:in_height*in_width*boxes_per_pixel  列: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
    
    return output.unsqueeze(0)

应用举例

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

print(h, w)  # 561 728
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
torch.Size([1, 2042040, 4])

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

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

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

画出锚框

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)]
        rect = d2l.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中轴和轴的坐标值已分别除以图像的宽度和高度。 绘制锚框时,我们需要恢复它们原始的坐标值

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'])

动手学深度学习——目标检测_第2张图片

交并比(IoU)

通过像素集的杰卡德(Jaccard)系数来测量两个边界框的相似性, 对于两个边界框,通常将它们的杰卡德系数称为交并比(intersection over union,IoU)。给定集合A和B,他们的杰卡德系数是他们交集的大小除以他们并集的大小:

动手学深度学习——目标检测_第3张图片

 动手学深度学习——目标检测_第4张图片

#@save
def box_iou(boxes1, boxes2):
    """计算两个锚框或边界框列表中成对的交并比"""
    box_area = lambda boxes: ((boxes[:, 2] - boxes[:, 0]) *
                              (boxes[:, 3] - boxes[:, 1]))    
    # boxes1,boxes2:(boxes1的数量,4) (boxes2的数量,4)    
    # areas1,areas2:(boxes1的数量,), (boxes2的数量,)   
    areas1 = box_area(boxes1)
    areas2 = box_area(boxes2)
    # inter_upperlefts,inter_lowerrights,inters的形状:(boxes1的数量,boxes2的数量,2)
    # 切片的时候使用None起到升维的作用。升维的效果是boxes1的每一行都和boxes2的所有行比较
    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_areas和union_areas的形状:(boxes1的数量,boxes2的数量)
    inter_areas = inters[:, :, 0] * inters[:, :, 1]
    # areas1[:, None] + areas2 结果形状:(boxes1的数量,boxes2的数量)
    union_areas = areas1[:, None] + areas2 - inter_areas 
    return inter_areas / union_areas

使用训练数据标记锚框

目标检测训练集带有真实边界框(ground-truth bounding box)的位置及其包围物体类别的标签。训练时,将每个锚框视为一个训练样本,通过锚框预测的准确与否来更新各种参数。我们为每个图像生成多个锚框,显然目标物体边界框不可能和锚框的大小和位置一模一样,因此模型要预测真实边界框相对于锚框的偏移量(offset),通过该偏移量可以计算得到预测的真实边界框,此外还要给出每个锚框的类别(class)。

将真实边界框分配给锚框

没有比较将锚框和所有真实边界框比较。把最接近的真实边界框分配给锚框,用该真实边界框的位置和类别标签标记锚框

给定图像,假设锚框是1,2,…,,真实边界框是1,2,…,,其中≥。 让我们定义一个矩阵∈ℝ×,其中元素是锚框和真实边界框的IoU。 该算法包含以下步骤:

  1. 在矩阵中找到最大的元素,行索引和列索引分别表示为1和1。将真实边界框1分配给锚框1,丢弃矩阵中1行和1列中的所有元素。
  2. 在矩阵中找到剩余元素中最大的元素,行索引和列索引分别表示为2和2。将真实边界框2分配给锚框2,并丢弃矩阵中2行和2列中的所有元素。
  3. 继续,直到丢弃掉矩阵中列中的所有元素。此时,我们已经为这个锚框各自分配了一个真实边界框。
  4. 遍历剩下的−个锚框。例如,给定任何锚框,在矩阵的第行中找到与的IoU最大的真实边界框,只有当此IoU大于预定义的阈值时,才将分配给。

例子:矩阵中的最大值为23、71、54、92,之后,我们只需要遍历剩余的锚框1, 3, 4, 6, 8,然后根据阈值确定是否为它们分配真实边界框

动手学深度学习——目标检测_第5张图片

其实可以考虑这样的算法:对于每一个anchor,找出与其IoU最大的真实边界框,根据阈值,决定是否配对(也就是只采取上面算法的第四步)。这种算法存在问题:可能一个真实边界框和几个anchor的IoU都很大,于是分配给这几个anchor,但是其他真实边界框能配对的anchor变少了。其实有一个anchor来预测一个真实边界框就足够了,不如将其他anchor释放掉,这样其他真实边界框能配对的anchor选择空间更大了。

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矩阵,元素xij是锚框i和真实边界框j的IoU
    jaccard = box_iou(anchors, ground_truth)
    # 对于每个锚框,记录分配的真实边界框的索引
    anchors_bbox_map = torch.full((num_anchors,), -1, dtype=torch.long,
                                  device=device)  
    # 给每一个anchor分配交并比最大的真实边界框,并且经过阈值筛选
    max_ious, indices = torch.max(jaccard, dim=1)
    anc_i = max_ious >= iou_threshold  
    anchors_bbox_map[anc_i] = indices[anc_i]
    # 遍历矩阵中最大IoU,将每个真实边界框分配给与其最大IoU的anchor
    col_discard = torch.full((num_anchors,), -1)
    row_discard = torch.full((num_gt_boxes,), -1)
    for _ in range(num_gt_boxes):
        max_idx = torch.argmax(jaccard)
        box_idx = (max_idx % num_gt_boxes).long() # max_idx位于的列,代表边界框的索引
        anc_idx = (max_idx / num_gt_boxes).long() # max_idx位于的行,代表anchor的索引
        anchors_bbox_map[anc_idx] = box_idx  # 配对
        jaccard[:, box_idx] = col_discard  # 配对完了,将二者从矩阵中丢弃
        jaccard[anc_idx, :] = row_discard
    return anchors_bbox_map

求锚框相对于边界框的偏移量

假设一个锚框被分配了一个真实边界框。 一方面,锚框的类别将被标记为与相同。 另一方面,锚框的偏移量将根据和中心坐标的相对位置以及这两个框的相对大小进行标记。给定框和,中心坐标分别为(,)和(,),宽度分别为和,高度分别为ℎ和ℎ。 我们可以将的偏移量标记为:

动手学深度学习——目标检测_第6张图片

 其中常量的默认值为 ===ℎ=0,==0.1, =ℎ=0.2

def offset_boxes(anchors, assigned_bb, eps=1e-6):
    """对锚框偏移量的转换"""
    c_anc = box_corner_to_center(anchors)
    c_assigned_bb = 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)。 背景类别的锚框通常被称为“负类”锚框,其余的被称为“正类”锚框。实现以下multibox_target函数,来标记锚框的类别和偏移量anchors参数)。 此函数将背景类别的索引设置为零,然后将新类别的整数索引递增一。

def multibox_target(anchors, labels):
    """使用真实边界框标记锚框"""
    batch_size, anchors = labels.shape[0], anchors.squeeze(0)  # 所有批次使用同一个anchors?
    batch_offset, batch_mask, batch_class_labels = [], [], []
    device, num_anchors = anchors.device, anchors.shape[0]
    for i in range(batch_size):
        # labels:第0维大小是批量的数量(可能是图片的数量), 第1维大小是真实边界框的数量
        # 第2维是每个样本的类别(第0个元素)和真实边界框位置
        label = labels[i, :, :]
        anchors_bbox_map = assign_anchor_to_bbox(label[:, 1:], anchors, device)
        # 初始化给anchor分配的类标签和的边界框为零
        class_labels = torch.zeros(num_anchors, dtype=torch.long,
                                   device=device)
        assigned_bb = torch.zeros((num_anchors, 4), dtype=torch.float32,
                                  device=device)
        
        # 使用真实边界框来标记锚框的类别
        # anchors_bbox_map小于0对应的是没有配对真实边界框的anchor
        indices_true = torch.nonzero(anchors_bbox_map >= 0)  
        bb_idx = anchors_bbox_map[indices_true]  # 索引与anchor配对的边界框
        class_labels[indices_true] = label[bb_idx, 0].long() + 1 # 0是留给背景的,所以其他分类标签要加一
        batch_class_labels.append(class_labels)
        
        # 有的anchor对应的是背景。重复4遍是因为每个anchor有四个offset
        bbox_mask = ((anchors_bbox_map >= 0).float().unsqueeze(-1)).repeat(1, 4)   
        batch_mask.append(bbox_mask.reshape(-1))
        # 偏移量转换并使用bbox_mask筛选
        assigned_bb[indices_true] = label[bb_idx, 1:]        
        offset = offset_boxes(anchors, assigned_bb) * bbox_mask
        batch_offset.append(offset.reshape(-1))        
        
    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代表猫),其余四个元素是左上角和右下角的(,)轴坐标(范围介于0和1之间)。 我们还构建了五个锚框,用左上角和右下角的坐标进行标记:0,…,4(索引从0开始)

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']);

动手学深度学习——目标检测_第7张图片

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

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

返回的结果中有三个元素,都是张量格式

  • 第三个元素包含标记的输入锚框的类别。首先,在所有的锚框和真实边界框配对中,锚框4与猫的真实边界框的IoU是最大的。 因此,4的类别被标记为猫。在剩下的配对中,锚框1和狗的真实边界框有最大的IoU。 因此,1的类别被标记为狗。 遍历剩下的三个未标记的锚框:0、2和3。 对于0,与其拥有最大IoU的真实边界框的类别是狗,但IoU低于预定义的阈值(0.5),因此该类别被标记为背景; 对于2,与其拥有最大IoU的真实边界框的类别是猫,IoU超过阈值,所以类别被标记为猫; 对于3,与其拥有最大IoU的真实边界框的类别是猫,但值低于阈值,因此该类别被标记为背景。

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

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

        labels[1]:tensor([[0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 0., 0., 0., 0., 1., 1., 1., 1.]])

  • 第一个元素包含了为每个锚框标记的四个偏移值。 负类锚框的偏移量被标记为零。

        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]])

预测

在预测时,我们为每个图像生成多个锚框,为这些锚框一一预测类别和偏移量,根据预测的偏移量调整它们的位置以获得预测的边界框,最后只输出符合特定条件的预测边界框

使用锚框和偏移量生成预测边界框

预测好的边界框则根据其中某个带有预测偏移量的锚框而生成

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

使用非极大值抑制处理预测边界框

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

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

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

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

def nms(boxes, scores, iou_threshold):
    """对预测边界框的置信度进行排序,并且做非极大值抑制"""
    # scores的索引是预测边界框对应的anchor的序号,即B保存的是anchor序号
    B = torch.argsort(scores, dim=-1, descending=True) 
    keep = []  # 保留预测边界框的序号
    while B.numel() > 0:        
        keep.append(B[0])
        if B.numel() == 1: break
        # 求最大置信度的预测边界框和其他预测边界框的IoU
        iou = box_iou(boxes[B[0], :].reshape(-1, 4),
                      boxes[B[1:], :].reshape(-1, 4)).reshape(-1)
        inds = torch.nonzero(iou <= iou_threshold).reshape(-1) # 大于阈值的不要        
        # 这地方不能用  inds=iou <= iou_threshold,因为下面inds+1        
        B = B[inds + 1]  # iou中不包含B[0]自己,所以inds要+1,并且把B[0]排除了
    return torch.tensor(keep, device=boxes.device)

 将非极大值抑制应用于预测边界框

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) # 对于不同批次,anchors用一套固定的值
    num_classes, num_anchors = cls_probs.shape[1], cls_probs.shape[2] # 行是类,列是anchor
    out = []
    for i in range(batch_size):
        cls_prob, offset_pred = cls_probs[i], offset_preds[i].reshape(-1, 4)
        conf, class_id = torch.max(cls_prob[1:], 0)  # 每一个anchor预测置信度最高的类
        predicted_bb = offset_inverse(anchors, offset_pred) # 计算出预测实际边界框
        # 边界框最大预测概率所对应的类别即为其预测的类别。
        # 非极大值抑制是不关注边界框预测的,真正想要的是conf排序后的索引信息
        keep = nms(predicted_bb, conf, nms_threshold) # 经过非极大值抑制筛选出的anchor序号

        # 找到所有的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) # counts是每一个uniques出现的次数
        non_keep = uniques[counts == 1] # non_keep中元素只出现一次,即被nms函数从all_idx中删除了
        class_id[non_keep] = -1  # 将类设置为背景        
        # all_id_sorted能让经过nms保留的预测边界框排在前面,使结果更好看
        all_id_sorted = torch.cat((keep, non_keep))          
        class_id = class_id[all_id_sorted]
        conf, predicted_bb = conf[all_id_sorted], predicted_bb[all_id_sorted]
        # pos_threshold是一个用于非背景预测的阈值,小于该值即认为预测anchor为背景
        below_min_idx = (conf < pos_threshold)
        class_id[below_min_idx] = -1
        # 转变置信度为anchor是背景的置信度,这是因为背景的预测概率一开始一律设为0
        conf[below_min_idx] = 1 - conf[below_min_idx]
        
        pred_info = torch.cat((class_id.unsqueeze(1),
                               conf.unsqueeze(1),
                               predicted_bb), dim=1)
        out.append(pred_info)
    return torch.stack(out)

举例测试

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

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]])  # 猫的预测概率

 绘制这些预测边界框和置信度

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

动手学深度学习——目标检测_第8张图片

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

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]]])

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

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

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)

 动手学深度学习——目标检测_第9张图片

目标检测算法:SDD(单发多框检测)

在featrue map上生成锚框(卷积层输出的每个通道叫featrue map),使用锚框在输入图像上感受野区域内的信息,来预测真实边界框类别和偏移量。

特点

  • 单发多框检测是一种多尺度目标检测模型。基于基础网络块和各个多尺度特征块,单发多框检测在不同模块的featrue map(卷积层输出的每个通道叫featrue map)上生成不同数量和不同大小的锚框,并通过预测这些锚框的类别和偏移量检测不同大小的目标。
  • 在训练单发多框检测模型时,损失函数是根据锚框的类别和偏移量的预测及标注值计算得出的

多尺度检测

SDD可以利用深层神经网络在多个层次上对图像进行分层表示,从而实现多尺度目标检测。经过层层卷积得到的featrue map都是对整个输入图像的映射。不同层的featrue map中,每个单元在输入图像上拥有不同大小的感受野:靠近输出层的featrue map更小,每个单元具有更大的感受野,可以从输入图像中检测到较大的目标,每个锚框使用较小的缩放比例scale,即采样更多的区域来检测较小的物体;靠近输入层的featrue map单元具有更窄的感受野,可以从输入图像中检测到较小的目标,每个锚框使用较大的缩放比例scale,即采样更多的区域来检测较小的物体。下面通过画图来理解。

首先读取图片,得到图片的长和宽

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

下面的函数输入是featrue map的宽、高和缩放比例。

def display_anchors(fmap_w, fmap_h, s):
    d2l.set_figsize()
    # 前两个维度上的值不影响输出
    fmap = torch.zeros((1, 10, fmap_h, fmap_w))
    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)

假设某一个featrue map宽是4,长是4,共有16个单元。在这个featrue map上,依然在每个单元上生成锚框,一共有16个中心。由于整个输入图片被映射成为16个单元,所以每个中心上的锚框检测的是整张图片的1/16(也是该单元在输入图片上的感受野)。如下图,对输入图片进行了4×4采样

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

动手学深度学习——目标检测_第10张图片

将featrue map的⾼度和宽度减⼩⼀半。用4个单元映射了输入图像,相比与上图,每个单元具有更大的感受野,锚框检测的是输入图片的1/4,从输入图像中检测到更大的目标,锚框的缩放程度应该更小(s更大,从0.15增长到了0.4)。

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

动手学深度学习——目标检测_第11张图片

在不同featrue map上生成的锚框,看上去大小都一样,检测的输入图像范围其实不一样。因此,在不同featrue map上生成多尺度的锚框,起到了多尺度检测的效果。另一个好处是,相比于直接在图像上的每个点生成锚框,在featrue map上生成锚框相当于在输入图像上均匀采样,以小部分像素(而不是输入图像的每个像素)为中心生成锚框,减少了锚框数量

数据集

用于目标检测的数据加载与图像分类的数据加载类似。但是,在目标检测中,标签还包含了真实边界框的信息。

拍摄了一组香蕉的照片,并生成了1000张不同角度和大小的香蕉图像。 然后,我们在一些背景图片的随机位置上放一张香蕉的图像。 最后,我们在图片上为这些香蕉标记了边界框。

%matplotlib inline
%matplotlib inline
import os
import pandas as pd
import torch
import torchvision
from PIL import Image
from d2l import torch as d2l
import torchvision.transforms as transforms

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


def read_data_bananas(is_train=True):
    """读取香蕉检测数据集中的图像和标签"""
    # Image.open()读出来的图片是PIL格式,要转换为tensor格式
    totensor = transforms.ToTensor()  
    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(totensor(Image.open(
            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


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)


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

上面三个辅助函数的作用分别是:读取数据,创建dataset和创建dataloader。读取数据的时候遇到问题No such operator image::read_file,解决方法参考:No such operator image::read_file问题解决_iwill323的博客-CSDN博客

展示10幅带有真实边界框的图像

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'])

动手学深度学习——目标检测_第12张图片

 SSD模型

模型主要由基础网络组成,其后是几个多尺度特征块。

  • 基本网络用于从输入图像中提取特征,可以使用深度卷积神经网络。SSD原论文中选用了VGG(丢弃分类层),现在也常用ResNet替代。可以将基础网络设计成输出的高和宽较大,用来检测尺寸较小的目标。
  • 每个多尺度特征块将上一层提供的feature map的高和宽缩小(如减半),使得feature map中每个单元在输入图像上的感受野变得更广阔,用来检测尺寸较大的目标。

通过多尺度特征块,SSD检测生成不同大小的锚框,并通过预测边界框的类别和偏移量来检测大小不同的目标,因此这是一个多尺度目标检测模型。

动手学深度学习——目标检测_第13张图片

%matplotlib inline
import torch
import torchvision
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l

基本网络块

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

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

高和宽减半块

基本网络块使用了feature map高和宽减半块down_sample_blk。 该块应用了 VGG模块设计,每个高和宽减半块由两个填充为1的3×3的卷积层、以及步幅为2的2×2最大汇聚层组成。前者不改变feature map的形状,后者将输入feature map的高度和宽度减少了一半。 输出中的每个单元在输入上都有一个6×6的感受野[1×2+(3−1)+(3−1)=6]。

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)

输出的通道是out_channels,高和宽是输入的一半

类别预测层

设目标类别的数量为。这样一来,锚框有+1个类别,其中0类是背景。设feature map的高和宽分别为ℎ和,以每个单元为中心生成个锚框,那么需要对ℎ个锚框进行分类。如果使用全连接层作为输出,很容易导致模型参数过多。 图像分类模型使用卷积层来预测类别,单发多框检测采用同样的方法来降低模型复杂度。

假设CNN网络给出的中间输出是张feature map,每张feature map上都有ℎ个不同的空间位置,在相同空间位置可以看作含有个单元,这些单元在输入图像上的感受野相同,表征了同一感受野内的输入图像信息。 因此,可以使用feature map在同一空间位置的个单元生成使用此空间位置生成的个锚框类别和偏移量。本质上讲,这是用输入图像在某个感受野区域内的信息,来预测输入图像上与该区域位置相近的锚框的类别和偏移量

具体来说,类别预测层使用一个保持输入高和宽的卷积层,对于这种卷积层,输出特征图上(、)坐标的通道里包含了以输入特征图(、)坐标为中心生成的所有锚框的类别预测。 因此输出通道数为(+1),其中索引为(+1)+(0≤≤)的通道代表了锚框关于类别的预测。

在下面,我们定义了这样一个类别预测层,通过参数num_anchorsnum_classes分别指定了和。 该图层使用填充为1的3×3的卷积层。

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个偏移量,而不是+1个类别。

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

连结多尺度的预测

不同层的feature map的形状,或以同一单元为中心生成的锚框的数量可能会有所不同。 因此,它们预测输出的形状可能会有所不同。

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

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]))

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

通道维包含中心相同的锚框的预测结果,首先将通道维移到最后一维(因为后面模型中对预测结果进行了reshape处理:cls_preds.reshape(cls_preds.shape[0], -1, self.num_classes + 1))。 因为不同尺度下批量大小仍保持不变,我们可以将预测结果转成二维的(批量大小,高×宽×通道数)的格式,以方便之后在维度1上的连结。

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在通道数、高度和宽度方面具有不同的大小,我们仍然可以在同一个小批量的两个不同尺度上连接这两个预测输出。

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

前向传播

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

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

现在为每个块blk定义前向传播。与图像分类任务不同,每个块除了要生成feature map,还要生成锚框,及预测类别和偏移量。

def blk_forward(X, blk, size, ratio, cls_predictor, bbox_predictor):
    Y = blk(X)
    anchors = d2l.multibox_prior(Y, sizes=size, ratios=ratio) # 根据Y生成的锚框
    cls_preds = cls_predictor(Y)  # 预测的锚框的类别(基于Y)
    bbox_preds = bbox_predictor(Y)  # 预测的锚框偏移量(基于Y)
    return (Y, anchors, cls_preds, bbox_preds)

较接近顶部的多尺度特征块是用于检测较大目标的,因此需要生成更大的锚框。 在上面的前向传播中,通过multibox_prior函数的sizes参数传递两个比例值。 在下面,0.2和1.05之间的区间被均匀分成五个部分,不同模块的size较小值:0.2、0.37、0.54、0.71和0.88。 size较大值由√0.2×0.37=0.272、√0.37×0.54=0.447等给出。

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

完整的模型

class TinySSD(nn.Module):
    def __init__(self, num_classes, **kwargs):
        super(TinySSD, self).__init__(**kwargs)
        self.num_classes = num_classes
        in_channels = [64, 128, 128, 128, 128]
        for i in range(5):
            # 即赋值语句self.blk_i=get_blk(i)  
            # 每一个模块用于提取特征的卷积层不一样,用于预测种类和偏移量的卷积层一样
            setattr(self, f'blk_{i}', get_blk(i))
            setattr(self, f'cls_{i}', cls_predictor(in_channels[i],
                                                    num_anchors, num_classes))
            setattr(self, f'bbox_{i}', bbox_predictor(in_channels[i],
                                                      num_anchors))

    def forward(self, X):
        anchors, cls_preds, bbox_preds = [None] * 5, [None] * 5, [None] * 5
        for i in range(5):
            # getattr(self,'blk_%d'%i)即访问self.blk_i
            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}'))
        
        # 合并不同模块的anchor、预测种类和偏移量
        anchors = torch.cat(anchors, dim=1) # anchors形状:(1, 锚框数, 4)
        cls_preds = concat_preds(cls_preds) # 形状:(批量大小,锚框数乘以类别数+1)
        cls_preds = cls_preds.reshape(
            cls_preds.shape[0], -1, self.num_classes + 1) # #2d变3d,方便做softmax预测类别,其中-1那一维代表锚框数
        bbox_preds = concat_preds(bbox_preds) # 形状:(批量大小,锚框数乘以4)
        return anchors, cls_preds, bbox_preds

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

第一个模块经过三次减半,输出特征图的形状为32×32,第二到第四个模块为高和宽减半块,第五个模块为全局汇聚层。 由于以特征图的每个单元为中心有4个锚框生成,因此在所有五个尺度下,每个图像总共生成(32×32+16×16+8×8+4×4+1)×4=5444个锚框。

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])

训练模型

读取数据集和初始化

注意d2l自带的load_data_bananas可能有问题,所以使用前面修改过的load_data_bananas函数(前面提到过)。香蕉检测数据集中,目标的类别数为1。定义好模型后,初始化其参数并定义优化算法

batch_size = 32
train_iter, _ = load_data_bananas(batch_size)

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

定义损失函数和评价函数 

目标检测有两种类型的损失。

  • 锚框类别的损失:可以简单地使用交叉熵损失函数来计算,其中真实值是为锚框分配的真实边界框
  • 正类锚框偏移量的损失:预测偏移量是一个回归问题。使用1范数损失之所以不用L2损失,是因为很多锚框离边界框很远,平方之后数值会特别大,而我们只关心几个比较好的锚框,那些离得远的锚框无所谓,所以也不需要MSE那样将误差大的进行平方加权。 使用掩码变量bbox_masks令负类锚框和填充锚框不参与损失的计算。

最后,我们将锚框类别和偏移量的损失相加,以获得模型的最终损失函数。

cls_preds.reshape(-1, num_classes)和cls_labels.reshape(-1)生成的行都是锚框数

cls_loss = nn.CrossEntropyLoss(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 = bbox_loss(bbox_preds * bbox_masks,
                     bbox_labels * bbox_masks).mean(dim=1)
    return cls + bbox

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

cls_preds的最后一维是类别预测结果,每个锚框对每个类都有一个预测值,cls_preds.argmax(dim=-1)就是锚框预测的最终种类

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)。 最后,我们根据类别和偏移量的预测和标注值计算损失函数。为了代码简洁,这里没有评价测试数据集。

num_epochs, timer = 20, d2l.Timer()
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
                        legend=['class error', 'bbox mae'])
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 '
      f'{str(device)}')

预测目标

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

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

使用下面的multibox_detection函数,输入是类别可能醒醒, 预测的偏移量, anchor位置,输出结果每一行对应一个anchor,第一个元素是预测的类别,第二个元素是置信度,后面跟着预测边界框(通过非极大值抑制来移除相似的预测边界框)。

def predict(X):
    net.eval()
    anchors, cls_preds, bbox_preds = net(X.to(device))
    cls_probs = F.softmax(cls_preds, dim=2).permute(0, 2, 1)
    output = d2l.multibox_detection(cls_probs, bbox_preds, anchors)
    idx = [i for i, row in enumerate(output[0]) if row[0] != -1]
    return output[0, idx]

output = predict(X)

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

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)

动手学深度学习——目标检测_第14张图片

区域卷积神经网络(R-CNN)

介绍区域卷积神经网络(region-based CNN或regions with CNN features,R-CNN)及其一系列改进方法:快速的R-CNN(Fast R-CNN)、更快的R-CNN(Faster R-CNN) 和掩码R-CNN(Mask R-CNN)。 

R-CNN

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

动手学深度学习——目标检测_第15张图片

R-CNN包括以下四个步骤:

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

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

Fast R-CNN

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

动手学深度学习——目标检测_第16张图片

Fast R-CNN模型的主要计算如下:

  1. Fast R-CNN使用整个图像作为CNN的输入,提取特征。此外,这个网络通常会参与训练。设输入为一张图像,将卷积神经网络的输出的形状记为1××ℎ1×1。
  2. 假设选择性搜索生成了个提议区域,分别标出它们在CNN输出上对应的兴趣区域。使用兴趣区域池化层(RoI pooling),将CNN的输出和提议区域(兴趣区域)作为输入抽取出形状相同的特征(比如指定高度ℎ2和宽度2),连结各个提议区域抽取的特征,形状为××ℎ2×2。
  3. 通过全连接层将输出形状变换为×,其中超参数取决于模型设计。
  4. 预测个提议区域中每个区域的类别和边界框。将全连接层的输出分别转换为形状为×(是类别的数量)的输出和形状为×4的输出,作为每个提议区域关于类别和边界框的预测。其中预测类别时使用softmax回归。

RoI pooling可从形状各异的兴趣区域中均抽取出形状相同的特征,并且对输出形状是可以直接指定的。例如,指定每个区域输出的高和宽分别为ℎ2和2。 对于任何形状为ℎ×的兴趣区域窗口,该窗口将被划分为ℎ2×2子窗口网格,其中每个子窗口的大小约为(ℎ/ℎ2)×(/2)。 在实践中,任何子窗口的高度和宽度都应向上取整,其中的最大元素作为该子窗口的输出,输出形状便成为ℎ2×2。

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

动手学深度学习——目标检测_第17张图片

下面,我们演示了RoI pooling的计算方法。 假设卷积神经网络抽取的特征X的高度和宽度都是4,且只有单通道。

import torch
import torchvision

X = torch.arange(16.).reshape(1, 1, 4, 4)
X
tensor([[[[ 0.,  1.,  2.,  3.],
          [ 4.,  5.,  6.,  7.],
          [ 8.,  9., 10., 11.],
          [12., 13., 14., 15.]]]])

假设输入图像的高度和宽度都是40像素,且选择性搜索在此图像上生成了两个提议区域。 每个区域由5个元素表示:区域目标类别、左上角和右下角的(,)坐标。

rois = torch.Tensor([[0, 0, 0, 20, 20], [0, 0, 10, 30, 30]])

由于X的高和宽是输入图像高和宽的1/10,因此,两个提议区域的坐标先按spatial_scale乘以0.1。 然后,在X上分别标出这两个兴趣区域X[:, :, 0:3, 0:3]X[:, :, 1:4, 0:4]。 最后,在2×2的RoI pooling层中,每个兴趣区域被划分为子窗口网格,并进一步抽取相同形状2×2的特征。 

torchvision.ops.roi_pool(X, rois, output_size=(2, 2), spatial_scale=0.1)
tensor([[[[ 5.,  6.],
          [ 9., 10.]]],
        [[[ 9., 11.],
          [13., 15.]]]])

Faster R-CNN

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

动手学深度学习——目标检测_第18张图片

RPN的计算步骤如下:

  1. 使用填充为1的3×3的卷积层变换卷积神经网络的输出,并将输出通道数记为。这样,右侧CNN为左侧CNN的输出feature map中的每个单元生成数量为的新特征。
  2. 以右侧卷积层feature map的每个像素为中心,生成多个不同大小和宽高比的锚框并标注它们。
  3. 使用锚框中心单元的特征(特征数量为),分别预测该锚框的二元类别(含目标还是背景)和边界框。
  4. 使用非极大值抑制,从预测类别为目标的预测边界框中移除相似的结果,输出的预测边界框即是RoI pooling所需的提议区域。

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

Mask R-CNN

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

动手学深度学习——目标检测_第19张图片

Mask R-CNN将Faster R-CNN的RoI pooling层替换为了 兴趣区域对齐层RoI align。RoI pooling在无法均分时有填充,但对于像素级标号来说,这种填充会造成像素级偏移,在边界处标号预测不准。RoI align简单来说不会填充,而使用双线性插值(bilinear interpolation)来保留特征图上的空间信息,输出的feature map与所有与兴趣区域的形状相同,从而更适于像素级预测。这些feature map不仅被用于预测每个兴趣区域的类别和边界框,还通过额外的全卷积网络预测目标的像素级位置。

小结

  • R-CNN对图像选取若干提议区域,使用卷积神经网络对每个提议区域执行前向传播以抽取其特征,然后再用这些特征来预测提议区域的类别和边界框。
  • Fast R-CNN对R-CNN的一个主要改进:只对整个图像做卷积神经网络的前向传播。它还引入了兴趣区域汇聚层,从而为具有不同形状的兴趣区域抽取相同形状的特征。
  • Faster R-CNN将Fast R-CNN中使用的选择性搜索替换为参与训练的区域提议网络,这样后者可以在减少提议区域数量的情况下仍保证目标检测的精度。
  • Mask R-CNN在Faster R-CNN的基础上引入了一个全卷积网络,从而借助目标的像素级位置进一步提升目标检测的精度。

需要注意的函数

torch.set_printoptions

精简输出精度

>>torch.set_printoptions(2)
>>a = torch.tensor([1.2345])
>>print(a)

tensor([1.23])

>>torch.set_printoptions(threshold=5)
>>torch.arange(10)

tensor([0, 1, 2,  ..., 7, 8, 9])

torch.meshgrid

>>a = torch.tensor([1,2,3])
>>b = torch.tensor([4,5,6])
>>xx, yy = torch.meshgrid(a,b)

tensor([[1, 1, 1],
        [2, 2, 2],
        [3, 3, 3]])
tensor([[4, 5, 6],
        [4, 5, 6],
        [4, 5, 6]])

reshape(-1)

变成一维数据

>>print(xx.reshape(-1))

tensor([1, 1, 1, 2, 2, 2, 3, 3, 3])

>>a = torch.arange(10)
>>b = torch.nonzero(a>5)

tensor([[6],
        [7],
        [8],
        [9]])

>>a[b]

tensor([[6],
        [7],
        [8],
        [9]])

b的形状是[4, 1],切片出来的张量形状也是

[4, 1]

>>c = b.reshape(-1)

tensor([6, 7, 8, 9])

经过reshape(-1),c的形状是[4],切片出来的张量形状也是[4]

>>d = a>5
>>a[d]

tensor([6, 7, 8, 9])  

结果一样

repeat和repeat_interleave

>>a = torch.tensor([[1,2],[4,5]])

repeat(第一维度复制的次数, 第二维度复制的次数)。repeat相当于将该张量复制,然后在某一维度concat起来

>>a.repeat([1,3])

tensor([[1, 2, 1, 2, 1, 2],
        [4, 5, 4, 5, 4, 5]])

>>a.repeat([2,2])

tensor([[1, 2, 1, 2],
        [4, 5, 4, 5],
        [1, 2, 1, 2],
        [4, 5, 4, 5]])

repeat_interleave(复制的次数, dim=复制的维度)。repeat_interleave是将张量中的元素沿某一维度复制n次,即复制后的张量沿该维度相邻的n个元素是相同的

>>a.repeat_interleave(3, dim=0)

tensor([[1, 2],
        [1, 2],
        [1, 2],
        [4, 5],
        [4, 5],
        [4, 5]])

>>a.repeat_interleave(3, dim=1)

tensor([[1, 1, 1, 2, 2, 2],
        [4, 4, 4, 5, 5, 5]])

也可以写成a.repeat_interleave(3, 1)

torch.stack()

stack(tensors, dim=0)

官方解释:沿着一个新维度对输入张量序列进行连接。 序列中所有的张量都应该为相同形状。

浅显说法:把多个2维的张量凑成一个3维的张量;多个3维的凑成一个4维的张量…以此类推,也就是在增加新的维度进行堆叠。

>>a = torch.ones([3,3])
>>torch.stack((a,a), dim=0).shape

torch.Size([2, 3, 3])

>>torch.stack((a,a), dim=1).shape

torch.Size([3, 2, 3])

>>torch.stack((a,a), dim=2).shape

torch.Size([3, 3, 2])

切片时使用None

增加维度

a = torch.tensor([[1,2,3],[4,5,6]])

>>print(a[:, None].shape) # 在第1维增加维度

torch.Size([2, 1, 3])

>>a[:, None, None].shape  # 在第1、2维增加维度

torch.Size([2, 1, 1, 3])

>>a[:, 1].shape

torch.Size([2])

>>a[:, None, 1].shape # 1代表没有维度,所以放在哪里都一样

torch.Size([2, 1])

>>a[:, 1, None].shape

torch.Size([2, 1])

>>a[:, None, None, 1].shape

torch.Size([2, 1, 1])

>>a[:, None, 1, None].shape

torch.Size([2, 1, 1])

>>a = torch.tensor([[1,2,3],[4,5,6]])

>>a[:, None].shape

torch.Size([2, 1, 3])

>>a[:, None, None].shape

torch.Size([2, 1, 1, 2])

>>a[:, :2].shape

torch.Size([2, 2])

>>a[:, None, :2].shape

torch.Size([2, 1, 2])

>>a[:, :2, None].shape

torch.Size([2, 2, 1])

>>a[:, None, None, :2].shape

torch.Size([2, 1, 1, 2])

>>a[:, None, :2, None].shape

torch.Size([2, 1, 2, 1])

用于计算:

>>a = torch.tensor([[1,2,3,5],[12,5,6,7],[13,8,9,8]])
>>b = torch.tensor([[11,12,13,8],[15,15,16,9]])
如果不升维,由于两个矩阵行数不一样,torch.max(a[:, :2], b[:, :2])会报错

>>torch.max(a[:, None, :1], b[:, :1])

tensor([[[11],
         [14]],
        [[12],
         [14]],
        [[15],
         [15]]])

>>torch.max(a[:, None, :1], b[:, :1]).shape

torch.Size([3, 2, 1])

可见,升维的效果是a的每一行都和b的所有行比较

>>a = torch.rand([2,2])[:, 0]

tensor([0.24, 0.80])

>>b = torch.rand([3,3])[:, 0]

tensor([0.30, 0.39, 0.91])

a + b会报错,因为长度不一样

>>a[:, None] + b

tensor([[0.54, 0.63, 1.15],
        [1.10, 1.19, 1.71]])

结果的第一行是a的第一个数加b的所有数,以此类推

unique

>>a = torch.tensor([5,1,2,3,2,4,5,6,3])
>>uniques, counts = a.unique(return_counts=True)
>>uniques, counts

tensor([1, 2, 3, 4, 5, 6]) tensor([1, 2, 2, 1, 2, 1])

>>uniques[counts == 1]

tensor([1, 4, 6])

列表和常数相乘

a = [1,2,3,4,5,6,7,8,9]
b = a[2:7]
>>b

[3, 4, 5, 6, 7]

>>b*2

[3, 4, 5, 6, 7, 3, 4, 5, 6, 7]

>>b*2+b

[3, 4, 5, 6, 7, 3, 4, 5, 6, 7, 3, 4, 5, 6, 7]

只有对python的list类型才如此,对于pytorch的tensor类型和numpy的数据类型是正常的算术运算

你可能感兴趣的:(动手学深度学习,深度学习,目标检测,计算机视觉,人工智能)