【动手学深度学习】26 锚框 1 锚框锚框是目标检测中的一个重要概念简单来说就像是预先在图像上摆放好各种形状的“模具”用来告诉模型“在这里可能会有一个物体”。1.1 为什么需要锚框在深度学习之前传统的检测方法(如滑动窗口)计算量巨大。如果我们让模型直接预测物体的坐标由于物体的尺寸和宽高比千变万化神经网络很难从头学习这种极大的回归范围。锚框的作用是将搜索空间缩小降低难度模型不再“凭空”预测位置而是预测锚框相对于真实边缘框的偏移(Offset)处理多尺度通过在同一位置设置不同大小和比例的锚框可以同时检测出小目标如远处的行人和大目标如近处的卡车。由此笔者联想到了自己的毕设***三维重建中动态高斯建设技术的思想 —— 将动态时空表征解耦为静态几何属性和动态位移偏差对于任意时刻 t 并不凭空预测高斯点的属性而是预测其相对于静态属性的偏移由此实现动态场景的稳定连续表示 1.2 锚框的核心参数通常情况下我们会为图像中的每个像素点(或特征图上的每个点)生成多个锚框这些模锚框由两个参数决定①尺寸(锚框的大小) ②宽高比(锚框的形状)1.3 锚框的工作流程在学习锚框的算法流程之前我们需要了解一个概念交并比IoUIoU-交并比IoU用来计算两个框之间的相似度0表示无重叠1表示重合它是Jacquard 指数的一个特殊情况给定两个集合在训练阶段主要涉及以下三个步骤A. 生成与分配算法会在特征图的每个位置生成若干锚框每个锚框是一个训练样本。接着我们需要判断哪些锚框是有用的计算每个锚框与所有GT 框的IoU下面的示例有4个真实边缘框生成了9个锚框赋予标号的算法有很多种。一些办法是选取与各真实边界框的IoU最大值的锚框作为正例样本但是这会导致只保留若干个正样本有时会生成上百万的锚框那上百万个未选中的锚框都将作为负例样本严重失衡。因此可以采用阈值筛选的方式如果锚框与某个真实边界框的 IoU 超过阈值通常为0.7或者与某个GT框IoU最大则标记为正样本将其赋予对应边界框的标号如果 IoU 小于阈值通常为0.3则标记为负样本背景忽略样本IoU在0.3~0.7之间的锚框忽略掉不参与训练实际上的做法不尽相同比如生成锚框一些方法是固定生成一些是根据图片生成...B. 边界框回归模型会学习4个偏移量来微调锚框中心点的位移、宽高的缩放。通过这种方式原始锚框逐渐被“推”向边界框C. 非极大值抑制(NMS)由于一个物体往往会被多个锚框同时捕捉到NMS会根据置信度排序保留最高分的框删除与其重叠度(IoU)大于阈值的其他锚框。—— 重复此过程直到所有预测要么被选中要么被去掉—— 每个物体只剩下一个最准确的边界框。另外在训练时总损失由两部分组成分类损失这是一个二分类或多分类问题判断锚框内是背景还是物体及具体类别回归损失仅针对正样本计算衡量模型预测的偏移量与真实偏移量之间的差异常用 Smoooth L1 或 IoU Loss小结基于锚框做预测的算法是一类目标检测算法它首先生成大量锚框再对每个锚框赋予标号随后对于正样本预测其与真实边界框的偏移实现优化逐渐逼近与真实边界框的位置在此过程中通过某方法来去除冗余的预测NMS是一种常见办法 。2 代码实现这部分的代码确实比较难理解耐心学习吧~首先修改输出精度以获得更简洁的输出。%matplotlib inline import torch from d2l import torch as d2l torch.set_printoptions(2) # 精简输出精度2.1 初始化2.1.1 生成多个锚框目标检测算法通常在输入图像中采样大量的区域然后判断这些区域中是否包含我们感兴趣的目标并调整区域边界从而更准确地预测目标的GT框。不同的模型使用的区域采样的方法可能不同课程中介绍了其中一种方法以每个像素为中心生成多个缩放比和宽高比不同的边界框这些边界框被称为锚框。具体实现假设输入图像的尺寸以图像的每个像素为中心生成不同形状的锚框其中缩放比为代表锚框相对于原图的大小比例宽高比为锚框自身的宽高之比。这里推导卡了很久按照下面的理解方式和书上一致(注意r是锚框的高宽比和图像宽高比的比为生成多个不同形状的锚框我们将设置多种缩放比取值)和宽高比取值。若使用这些比例和宽高比的所有组合以每个像素为中心时输入图像将有个锚框容易导致过高的复杂度。因此实践中只考虑包含或的组合由此以同一像素为中心的锚框数量是。对于整个输入图像将共生成个锚框。(1) 生成锚框输入图像数据、尺寸、宽高比之比输出对于该图像以每个像素为中心不同形状/尺寸的锚框def multibox_prior(data, sizes, ratios): 生成以每个像素为中心具有不同形状的锚框 # 1初始化参数 in_height, in_width data.shape[-2:] # shape:(N,C,W,H)-批次、通道、宽、高 device, num_sizes, num_ratios data.device, len(sizes), len(ratios) boxes_per_pixel (num_sizes num_ratios - 1) # 锚框数量 size_tensor torch.tensor(sizes, devicedevice) # 缩放比 ratio_tensor torch.tensor(ratios, devicedevice) # 宽高比 # 2计算像素中心坐标 # 为了将锚点移动到像素的中心需要设置偏移量。 # 因为一个像素的高为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, devicedevice) offset_h) * steps_h center_w (torch.arange(in_width, devicedevice) offset_w) * steps_w # 生成二维网格shift_y、shift_x都是形状为(h,w)的矩阵分别存每个格点的y坐标、x坐标 shift_y, shift_x torch.meshgrid(center_h, center_w, indexingij) # 展平为1维长度(h×w) shift_y, shift_x shift_y.reshape(-1), shift_x.reshape(-1) #3生成“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 # 处理矩形输入对宽度做等比例矫正 h torch.cat((size_tensor / torch.sqrt(ratio_tensor[0]), sizes[0] / torch.sqrt(ratio_tensor[1:]))) # 4从宽高生成锚框四角偏移量 # 除以2来获得半高和半宽 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], dim1).repeat_interleave(boxes_per_pixel, dim0) output out_grid anchor_manipulations return output.unsqueeze(0)整体思路可以归结如下输入: data(N,C,H,W), sizes[s1,s2...], ratios[r1,r2...] 1. 计算每个像素的归一化中心坐标 (cx, cy) → 共 H×W 个中心 2. 根据 sizes 和 ratios 组合出 boxes_per_pixel 种 (w, h) 3. 对每个中心 对每种 (w, h) xmin cx - w/2 ymin cy - h/2 xmax cx w/2 ymax cy h/2 4. 输出形状: (1, H×W×boxes_per_pixel, 4)img d2l.plt.imread(data/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(2) 绘制锚框show_bboxes函数以图像中某个像素为中心绘制对应的边界框def show_bboxes(axes, bboxes, labelsNone, colorsNone): 显示所有边界框 def _make_list(obj, default_valuesNone): 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], vacenter, hacenter, fontsize9, colortext_color, bboxdict(facecolorcolor, lw0))d2l.set_figsize() bbox_scale torch.tensor((w, h, w, h)) fig d2l.plt.imshow(img) show_bboxes(fig.axes, boxes[250, 500, :, :] * bbox_scale, [s0.75, r1, s0.5, r1, s0.25, r1, s0.75, r2, s0.75, r0.5])2.1.2 计算IoU值可以看到蓝色框“较好地”把狗狗覆盖住。若已知目标的真实边界框那此处的“好”该如和量化呢——计算该边界框面积与gt边界框面积的IoU值。下面函数用来衡量不同边界框直接的相似度。给定锚框或边界框的列表以下box_iou函数将在两个列表中计算它们成对的交并比。def box_iou(boxes1, boxes2): 计算两个锚框或边界框列表中成对的交并比 输入boxes1:(N,4)boxes(M,4) 输出IoU值矩阵(N,M) 每个框用(xmin, ymin, xmax, ymax) # (1)计算每个框的面积 box_area lambda boxes: ((boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])) areas1 box_area(boxes1) areas2 box_area(boxes2) # (2)计算交集的左上角和左下角 # 整个函数最精妙的部分利用了 PyTorch 的广播broadcasting机制来一次性计算所有框对的交集。 inter_upperlefts torch.max(boxes1[:, None, :2], boxes2[:, :2]) inter_lowerrights torch.min(boxes1[:, None, 2:], boxes2[:, 2:]) # .clamp(min0)把所有负数截断为 0表示不相交时交集为 0 inters (inter_lowerrights - inter_upperlefts).clamp(min0) # inter_areasandunion_areas的形状:(boxes1的数量,boxes2的数量) inter_areas inters[:, :, 0] * inters[:, :, 1] # (3)计算并集 union_areas areas1[:, None] areas2 - inter_areas return inter_areas / union_areas整体思路归结如下输入: boxes1(N,4), boxes2(M,4) │ ├─→ 计算面积 → areas1(N,), areas2(M,) │ ├─→ 广播计算交集左上角 → inter_upperlefts(N,M,2) │ └─→ 广播计算交集右下角 → inter_lowerrights(N,M,2) │ ↓ 宽高相减 → clamp(0) → 交集面积(N,M) │ ↓ areas1(N,1) areas2(M,) - inter_areas(N,M) 并集面积(N,M) │ ↓ inter_areas / union_areas IoU(N,M)2.1.3 在训练数据中标注锚框在训练集中我们将每个锚框视为一个训练样本为了训练目标检测模型我们需要每个锚框的类别class和偏移量offset标签其中前者是与锚框相关的对象的类别后者是真实边界框相对于锚框的偏移量。在预测时为每个图像生成多个锚框预测所有锚框的类别和偏移量根据预测的偏移量调整它们的位置以获得预测的边界框最后只输出符合特定条件的预测边界框。目标检测训练集带有真实边界框的位置及其包围物体类别的标签。要标记任何生成的锚框我们可以参考分配到的最接近此锚框的真是边界框的位置和类别标签。下面的算法能够把最接近的真实边界框分配给锚框。(1) 将真实边界框分配给锚框这里直接把书里的内容截过来def assign_anchor_to_bbox(ground_truth, anchors, device, iou_threshold0.5): 将最接近的真实边界框分配给锚框 num_anchors, num_gt_boxes anchors.shape[0], ground_truth.shape[0] # 1计算IoU矩阵 jaccard box_iou(anchors, ground_truth) # 2对于每个锚框初始化分配结果 anchors_bbox_map torch.full((num_anchors,), -1, dtypetorch.long, devicedevice) # 3根据阈值决定是否分配真实边界框锚框视角够阈值就分配 max_ious, indices torch.max(jaccard, dim1) anc_i torch.nonzero(max_ious iou_threshold).reshape(-1) box_j indices[max_ious iou_threshold] anchors_bbox_map[anc_i] box_j # 4贪心策略-确保每个真实框至少有一个锚框 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() # 列号真实框索引 anc_idx (max_idx / num_gt_boxes).long() # 行号锚框索引 anchors_bbox_map[anc_idx] box_idx jaccard[:, box_idx] col_discard # 该真实框对应的整列设为 -1 jaccard[anc_idx, :] row_discard # 该锚框对应的整行设为 -1 return anchors_bbox_map上述流程可以总结为输入: anchors(N,4), ground_truth(M,4) 1. 计算 IoU 矩阵 jaccard(N, M) 2. 【阈值分配】 对每个锚框 i 找到 max_j IoU(i, j) 如果 max_iou 0.5 anchors_bbox_map[i] argmax_j 3. 【贪心分配 - 确保全覆盖】 重复 M 次 找到全局最大 IoU 的位置 (anc_idx, box_idx) anchors_bbox_map[anc_idx] box_idx 将第 anc_idx 行和第 box_idx 列设为 -1屏蔽 4. 返回 anchors_bbox_map(N,)第四段可能会覆盖第三段的结果同一个锚框被重新分配但最终效果是所有真实框都有锚框负责同时尽量多的锚框参与了分配。(2) 标记类别和偏移量分配完成后需要为每个锚框生成训练标签类别标签-1或某个真实框的类比偏移量标签模型预测锚框到真实框的偏移量。偏移量的计算公式其中常量的默认值为。前两项代表位置偏移后两项表示尺寸缩放偏移使用对数函数确保尺度缩放更稳定。在代码实现中可以乘以一些常数来进行特征缩放使得损失函数更容易优化。#save def offset_boxes(anchors, assigned_bb, eps1e-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], axis1) return offset其中未分配的锚框背景偏移量标注为 0。另外新类别的整数索引递增一。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) bbox_mask ((anchors_bbox_map 0).float().unsqueeze(-1)).repeat( 1, 4) # 将类标签和分配的边界框坐标初始化为零 class_labels torch.zeros(num_anchors, dtypetorch.long, devicedevice) assigned_bb torch.zeros((num_anchors, 4), dtypetorch.float32, devicedevice) # 使用真实边界框来标记锚框的类别。 # 如果一个锚框没有被分配标记其为背景值为零 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:] # 偏移量转换 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)//返回值分析bbox_ooffset边界框偏移量每个锚框相对于匹配到的真实框的4维偏移回归目标——对于没有匹配任何真实框的锚框背景offset值会后续被bbox_mask屏蔽实际不参与位置损失。bbox_mask边界框掩码取值为0.0/1.0。0.0表示背景锚框负样本1.0表示匹配到真实物体框正样本锚框需要计算位置回归损失。class_labels类别标签分类分支的训练标签用来计算交叉熵分类损失。(3) 一个例子下面为猫、狗分配了标注了边界框另外构建了5个锚框。ground_truth torch.tensor([[0, 0.1, 0.08, 0.52, 0.92], [1, 0.48, 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, [cat, dog], k) show_bboxes(fig.axes, anchors * bbox_scale, [0, 1, 2, 3, 4]);下面根据猫和狗的真实边界框标注这些锚框的分类和偏移量。labels multibox_target(anchors.unsqueeze(dim0), ground_truth.unsqueeze(dim0)) labels//分析张量①每个锚框标记的4个偏移值。注意负类锚框的偏移量标记为0。张量②掩码变量形状为批量大小锚框数的4倍。掩码变量中的元素与每个锚框的4个偏移量一一对应。用于过滤掉负类偏移量。张量③最初锚框与狗的真实边界框的IoU最大因此被标记为狗。去除包含或狗的真实边界框的配对在剩下的配对中锚框和猫的真实边界框有最大IoU因此的类别被标记为猫。接下来遍历剩余的3个未标记的锚框、和。对于与其拥有最大IoU的真实边界框的类别是猫但由于IoU值没有超过预定义的阈值0.5因此标记背景0对于、同理。2.2 训练在预测时先生成多个锚框再为这些锚框一一预测类别和偏移量。一个预测好的边界框则根据其中某个带有预测偏移量的锚框而生成。下面的函数将锚框和偏移量预测作为输入应用逆偏移变换来返回预测的边界框坐标。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), axis1) predicted_bbox d2l.box_center_to_corner(pred_bbox) return predicted_bbox当有许多锚框时可能会输出许多相似的具有明显重叠的预测边界框都围绕同一目标。为了简化输出可使用非极大值抑制non-maxing supressionNMS合并属于同一目标的类似的预测边界框。(1) 非极大值抑制的工作原理对于一个预测边界框B目标检测模型会计算每个类别的预测概率。假设最大的预测概率为则该概率所对应的类别 B 即为预测的类别。此处的即称为预测边界框 B 的置信度。在同一张图像上所有预测的非背景边界框都按置信度降序排序以生成列表。接着Step1.从中选取最高的预测边界框作为基准然后将所有与的IoU超过预定义阈值的非基准预测边界框从中移除。这时保留了置信度最高的预测边界框去除了与其太过相似的其他预测边界框——那些具有非极大值置信度的边界框被抑制了。Step2.从中选取第二高的预测边界框作为又一个基准然后将所有与的IoU............重复上述过程直到中的所有预测边界框都曾被用作基准。此时中任意一对预测边界框的IoU都小于阈值输出列表中所有预测边界框。下面函数按降序对置信度进行排序并返回其索引。def nms(boxes, scores, iou_threshold): 对预测边界框的置信度进行排序 B torch.argsort(scores, dim-1, descendingTrue) keep [] # 保留预测边界框的指标 while B.numel() 0: i B[0] keep.append(i) if B.numel() 1: break iou box_iou(boxes[i, :].reshape(-1, 4), boxes[B[1:], :].reshape(-1, 4)).reshape(-1) inds torch.nonzero(iou iou_threshold).reshape(-1) B B[inds 1] return torch.tensor(keep, deviceboxes.device)下面函数来将非极大值抑制应用于预测边界框。#save def multibox_detection(cls_probs, offset_preds, anchors, nms_threshold0.5, pos_threshold0.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) conf, class_id torch.max(cls_prob[1:], 0) predicted_bb offset_inverse(anchors, offset_pred) keep nms(predicted_bb, conf, nms_threshold) # 找到所有的non_keep索引并将类设置为背景 all_idx torch.arange(num_anchors, dtypetorch.long, devicedevice) combined torch.cat((keep, all_idx)) uniques, counts combined.unique(return_countsTrue) non_keep uniques[counts 1] all_id_sorted torch.cat((keep, non_keep)) class_id[non_keep] -1 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 conf[below_min_idx] 1 - conf[below_min_idx] pred_info torch.cat((class_id.unsqueeze(1), conf.unsqueeze(1), predicted_bb), dim1) out.append(pred_info) return torch.stack(out)(2) 应用示例为简单起见假设预测的偏移量都是零即预测的边界框都是锚框。对于背景、狗和猫其中的每个类定义它的预测概率。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], # cat的预测概率 [0.1, 0.2, 0.3, 0.9]]) # dog的预测概率可以在图像上绘制这些预测边界框和置信度fig d2l.plt.imshow(img) show_bboxes(fig.axes, anchors * bbox_scale, [cat0.9, cat0.8, cat0.7, dog0.9])现在开始执行非极大值抑制其中阈值设置为0.5。注意源代码在示例的张量中添加了维度。output multibox_detection(cls_probs.unsqueeze(dim0), offset_preds.unsqueeze(dim0), anchors.unsqueeze(dim0), nms_threshold0.5) output//分析返回的形状是批量大小锚框的数量6。最内层维度中的6个元素提供了同一预测边界框的输出信息。第一个元素是预测的类索引从0开始0代表cat1代表dog-1表示背景或在nms过程中被移除了第2个元素是预测的边界框的置信度其余4个元素分别表示预测边界框左上角和右下角的x,y轴坐标。范围介于0和1之间。删除-1类别的预测边界框后可以输出由nms保存的最终预测边界框。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)实践中在执行非极大值抑制前可以将置信度较低的预测边界框移除从而减少此算法中的计算量。我们也可以对非极大值抑制的输出结果进行后处理。例如只保留置信度更高的结果作为最终输出。小结下面整体梳理一下过程。为图像生成多个锚框训练样本。计算锚框与真实边界框所有组合的IoU值。根据IoU值对训练数据分配真实边界框。确保每个边界框都至少有一个锚框对应一些锚框可能此时为锚框标记标签具体正样本类别和边界框相对其的偏移量位置和形状偏移。将锚框、偏移量作为输入目标检测模型会预测其对应每个正样本类别的预测概率。有许多锚框时可能会输出许多相似/重叠的预测边界框期间采用非极大值抑制策略减少锚框最终保留的锚框各自差异大从而减免不必要的计算量