物体检测算法通常在输入图像中对大量区域进行采样,确定这些区域是否包含感兴趣的对象,并调整区域的边缘,以便更准确地预测目标的真实边界框。不同的模型可能使用不同的区域抽样方法。这里,我们介绍了一种这样的方法:它以每个像素为中心生成多个不同大小和宽高比的边框。这些边界框称为Anchor。我们将在下面几节中练习基于Anchor的对象检测。
首先,导入本节所需的包或模块。这里,我们修改了PyTorch的打印精度。因为打印张量实际上调用了PyTorch的打印函数,所以本节打印的张量中的浮点数更简洁。
1
2
3
4
5
%matplotlib inline
from d2l import torch as d2l
import torch
torch.set_printoptions(2)
Generating Multiple Anchor Boxes
假设输入图像的高度为 $h$,宽度为 $w$。我们生成以图像每个像素为中心的不同形状的Anchor。假设尺寸 $s \in (0, 1]$, 宽高比 $r > 0$, Anchor框的宽度和高度分别是 $ws\sqrt{r}$ 和 $hs / \sqrt{r}$。 当给定中心位置时,确定一个已知宽度和高度的Anchor框。
下面我们设置一组尺寸$s_1, …, s_n$ 和 一组宽高比 $r_1, …, r_m$。 如果我们使用所有大小和宽高比的组合,以每个像素为中心,输入图像将有 $whnm$ 个 Anchor 框。尽管这些 Anchor 框可以覆盖所有的ground-truth边界框,但计算复杂度往往过高。因此,我们通常只对包含尺寸为 $s1$ 或 宽高比为$r1$ 的组合感兴趣,也就是说:
\[(s_1, r_1), (s_1, r_2), ..., (s_1, r_m), (s_2, r_1), (s_3, r_1), ..., (s_n, r_1) \tag{1}\]即,以同一像素为中心的 Anchor 框个数为 $n + m - 1$。对于整个输入图像,我们将生成总共 $wh(n + m - 1)$ 个Anchor框。
上述产生Anchor框的方式由 multibox_prior 函数实现。 我们指定输入、一组大小和一组宽高比,这个函数将返回所有输入的 Anchor 框。
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#@save
def multibox_prior(data, sizes, ratios):
"""
args:
data : 图像
sizes : 尺寸集
ratios : 宽高比集
return:
output(tensor) : 用在dataloader中可将anchor组成batch
"""
#--- tensor的后两维就是图像的高和宽 ---
in_height, in_width = data.shape[-2:]
#--- device: cpu or cuda ---
#--- num_sizes: 就是尺寸的个数 n ---
#--- num_ratios: 就是宽高比的个数 m---
device, num_sizes, num_ratios = data.device, len(sizes), len(ratios)
#--- 每个像素有 (n + m - 1) 个anchor框 ---
boxes_per_pixel = (num_sizes + num_ratios - 1)
size_tensor = torch.tensor(sizes, device=device)
ratio_tensor = torch.tensor(ratios, device=device)
#--- offset 用于将anchor移动到每个像素的中心 ---
#--- steps_h, steps_w 是归一化的系数,用于将图像的宽高归一化到0到1之间---
offset_h, offset_w = 0.5, 0.5
steps_h = 1.0 / in_height
steps_w = 1.0 / in_width
#--- 假设图像为 256 x 512 ---
#--- 首先将 x 轴 和 y 轴都加0.5, 使得anchor位于每个像素的中心 ---
#--- 再将宽高进行归一化 ---
center_h = (torch.arange(in_height, device=device) + offset_h) * steps_h
center_w = (torch.arange(in_width, device=device) + offset_w) * steps_w
#--- h:(0.5, 1.5, ..., 255.5) -> 归一化 ---
#--- w:(0.5, 1.5, ..., 511.5) -> 归一化 ---
#--- meshgrid的返回值: ---
#--- shift_y: (0.5, 0.5, ..., 0.5) ---
#--- (1.5, 1.5, ..., 1.5) ---
#--- ... ---
#--- (255.5, 255.5, ..., 255.5) ---
#--- 共256行, 共512列, 数值为未归一化时的数值 ---
#--- shift_x: (0.5, 1.5, ..., 511.5) ---
#--- (0.5, 1.5, ..., 511.5) ---
#--- ... ---
#--- (0.5, 1.5, ..., 511.5) ---
#--- 共256行, 共512列, 数值为未归一化时的数值 ---
shift_y, shift_x = torch.meshgrid(center_h, center_w)
#--- shift_y, shift_x shape: (256 x 512) ---
shift_y, shift_x = shift_y.reshape(-1), shift_x.reshape(-1)
#--- w_anchor = w * s * sqrt{r} ---
#--- 因为已经做过归一化了, 所以这里 w 就是 1 ---
#--- 由于ssd最初是为方形图像(300x300)开发的,因此 in_height / in_width 用于处理矩形输入---
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:])))
#--- w_anchor = w * s * sqrt{r} 和 h_anchor = h * s / sqrt{r} 都是正数 ---
#--- 为了让anchor可以上下左右移动, 需要对 w_anchor 和 h_anchor 除以2再取正负 ---
anchor_manipulations = torch.stack((-w, -h, w, h)).T.repeat(in_height * in_width, 1) / 2
#--- 四个轴分别对应每个 anchor 的 left, bottom, right, top ---
#--- 形状: (h * w * (n + m - 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
#--- 将 anchors组成batch ---
return output.unsqueeze(0)
Q:
1
2
3
w = torch.cat((size_tensor * torch.sqrt(ratio_tensor[0]),
sizes[0] * torch.sqrt(ratio_tensor[1:])))\
* in_height / in_width
为什么这里要乘以 in_height / in_width ?
A: 由于ssd最初是为方形图像(300x300)开发的,因此 in_height / in_width 用于处理矩形输入。
可以看出,返回的 Anchor 框变量y的形状为 (batch size,number of anchor boxes,4)。
1
2
3
4
5
6
7
img = d2l.plt.imread('../img/catdog.jpg')
h, w = img.shape[0:2]
print(h, w)
X = torch.rand(size=(1, 3, h, w)) # Construct input data
Y = multibox_prior(X, sizes=[0.75, 0.5, 0.25], ratios=[1, 2, 0.5])
Y.shape
输出:
1
2
3
561 728
torch.Size([1, 2042040, 4])
将Anchor框的变量 Y 的形状改变为(图像高度,图像宽度,以同一像素为中心的Anchor框数量,4)后,我们可以得到以指定像素为中心的所有Anchor框。在下面的例子中,我们访问第一个以(250,250)为中心的Anchor框。它有四个元素:位于Anchor框左上角的x、y轴坐标和位于Anchor框右下角的x、y轴坐标。x 轴和 y 轴的坐标值分别除以图像的宽度和高度,取值范围为 $0 \thicksim 1$。
1
2
boxes = Y.reshape(h, w, 5, 4)
boxes[250, 250, 0, :]
输出:
1
tensor([0.06, 0.07, 0.63, 0.82])
为了描述图像中以一个像素为中心的所有Anchor框,我们首先定义 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
#@save
def show_bboxes(axes, bboxes, labels=None, colors=None):
"""Show bounding boxes."""
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))
正如我们刚才看到的,变量框中的 $x$ 轴和 $y$ 轴的坐标值分别除以图像的宽度和高度。在绘制图像时,我们需要恢复Anchor框的原始坐标值,因此定义变量 bbox_scale。现在,我们可以在图像中以(250,250)为中心绘制所有Anchor框。如你所见,大小为0.75、宽高比为1的蓝色Anchor框很好地覆盖了图像中的狗。
1
2
3
4
5
6
d2l.set_figsize()
bbox_scale = torch.tensor((w, h, w, h))
fig = d2l.plt.imshow(img)
show_bboxes(fig.axes, boxes[250, 250, :, :] * bbox_scale,
['s=0.75, r=1', 's=0.5, r=1', 's=0.25, r=1', 's=0.75, r=2',
's=0.75, r=0.5'])
Intersection over Union
我们刚刚提到,Anchor框很好地覆盖了图像中的狗。如果目标的真实边界框是已知的,怎么能很好地在这里量化?一种直观的方法是测量Anchor框与真实边界框之间的相似性。我们知道,Jaccard指标可以度量两个集合之间的相似性。给定集合 $A$ 和 $B$,它们的Jaccard指标是它们交集的大小除以它们并集的大小。
\[J(A, B) = \frac{\mid A \cap B \mid}{\mid A \cup B \mid} \tag{2}\]事实上,我们可以把边界框的像素区域看作是像素的集合。这样,我们就可以通过两个边界框的像素集的Jaccard索引来度量这两个边界框的相似性。当我们测量两个边界框的相似性时,我们通常将Jaccard指标称为交并比(intersection over union, IoU),它是两个边界框相交面积与并集面积的比值,如图13.4.1所示。IoU的取值范围为 $0 \thicksim 1$, 0表示两个边界框之间没有重叠像素,1表示两个边界框相等。
在本节的其余部分中,我们将使用IoU来度量Anchor框和ground-truth边界框以及不同Anchor框之间的相似性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#@save
def box_iou(boxes1, boxes2):
#--- 计算形状为(N, 4) 和 (M, 4)的boxes的交并比 ---
#--- 计算面积 ---
box_area = lambda boxes: ((boxes[:, 2] - boxes[:, 0]) * boxes[:, 3] - boxes[:, 1])
area1 = box_area(boxes1)
area2 = box_area(boxes2)
#--- None用于增加维度, boxes1中的每个元素都要和boxes2中的元素进行运算 ---
#--- 比如 [[1] ---
#--- [2]] ---
#--- 和 [3, 4] 进行加法运算 ---
#--- 得到 [[4], [5] ---
#--- [5], [6]] ---
lt = torch.max(boxes1[:, None, :2], boxes2[:, :2]) # [N, M, 2]
rb = torch.min(boxes1[:, None, 2:], boxes2[:, 2:]) # [N, M, 2]
wh = (rb - lt).clamp(min=0) #[N, M, 2]
inter = wh[:, :, 0] * wh[:, :, 1] #[N, M]
unioun = area1[:, None] + area2 - inter
return inter / unioun
它的输出为 (N, M), N为 Anchor Box 个数, M 为 Ground-truth Box 个数。
Labeling Training Set Anchor Boxes
在训练集中,我们将每个Anchor框作为一个训练示例。为了训练目标检测模型,我们需要为每个Anchir框标记两种类型的标签:一是Anchor框中包含的目标类别(category),二是ground-truth边界框相对于Anchor框的偏移量(offset)。在目标检测中,我们首先生成多个Anchor框, 预测每个Anchor框的category和offset,根据预测的offset调整Anchor框位置得到用于预测的边界框,最后过滤预测边界框, 输出需要的边界框。
我们知道,在目标检测训练集中,每一幅图像都有标记ground-truth边界框的位置和包含的目标类别。Anchor框生成后,我们主要根据与Anchor框相似的ground-truth边界框的位置和category信息对Anchor框进行标注。那么我们如何将ground-truth边界框分配到相似的Anchor框呢?
假设在图像中的Anchor框是$A_1, A_2, …, A_{n_a}$以及ground-truth框是$B_1, B_2, …, B_{n_b}$, 并且$n_a > n_b$。 定义矩阵 $X \in R^{n_a \times n_b}$, 其中第 $i$ 行和第 $j$ 列的$x_{ij}$是Anchor框 $A_i$ 和 ground-truth 边界框 $B_j$的交并比。首先,我们找到矩阵 $X$ 中最大的元素,并记录该元素的行索引和列索引为 $i_1$, $j_1$。我们将ground-truth边界框 $B_{j_1}$分配给Anchor框$A_{i_1}$。显然,Anchor框 $A_{i_1}$ 与 ground-truth 边界框 $B_{j_1}$ 在 所有Anchor框 和 ground-truth 边界框对 中相似性最高。接下来,丢弃矩阵 $X$ 中第$i_1$ 行和第 $j_1$ 列中的所有元素。找到矩阵 $X$ 中剩余的最大元素,并记录该元素的行索引和列索引为 $i_2$, $j_2$。我们将ground-truth边界框 $B_{j_2}$ 分配给 Anchor框 $A_{i_2}$ ,然后丢弃矩阵 $X$ 中第 $i_2$ 行和 $j_2$ 列中的所有元素。此时,矩阵 $X$ 中有两行和两列的元素已经被丢弃。
我们继续,直到丢弃矩阵 $X$ 中 $n_b$ 列中的所有元素。此时,我们已经为 $n_b$ 个Anchor框中的每个Anchor框分配了一个ground-truth边界框。接下来,我们遍历剩下的 $n_a - n_b$ Anchor框。给定Anchor框 $A_i$,根据矩阵 $X$ 的第 $i$ 行,找到 $Ai$ 交并比最大的边界框 $B_j$,当交并比大于预定阈值时,才将 ground-truth 边界框 $B_j$ 赋给Anchor框 $A_i$。
如图13.4.2(左)所示,假设矩阵 $X$ 的最大值为 $x_{23}$,则我们将 ground-truth 边界框 $B_3$ 赋值给Anchor框 $A_2$。然后,我们丢弃矩阵第2行和第3列的所有元素,找到剩余阴影区域中最大的元素 $x_{71}$,并将ground-truth边界框 $B_1$ 赋值给Anchor框 $A_7$。然后,如图13.4.2(中)所示,去掉矩阵第7行、第1列的所有元素,找到剩余阴影区域最大的元素 $x_{54}$ ,将ground-truth边界框 $B_4$ 赋值给Anchor框 $A_5$。最后,如图13.4.2(右)所示,去掉矩阵第5行、第4列的所有元素,找到剩余阴影区域最大的元素 $x_{92}$,将 ground-truth 边界框 $B_2$ 赋值给Anchor框 $A_9$。然后,我们只需要遍历剩下的Anchor框 $A_1$, $A_3$, $A_4$, $A_6$, $A_8$,并根据阈值决定是否为剩下的Anchor框分配ground-truth边界框。
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
#@save
def match_anchor_to_bbox(ground_truth, anchors, device, iou_threshold=0.5):
#--- num_anchors: n_{a} ---
#--- num_gt_boxes: n_{b} ---
num_anchors, num_gt_boxes = anchors.shape[0], ground_truth.shape[0]
#--- 输出为 (N, M), N = n_a, M = n_b ---
jaccard = box_iou(anchors, ground_truth)
#--- 制造一张哈希表, 用于映射anchor和bbox的关系 ---
#--- key: anc_i, value: box_j ---
anchors_bbox_map = torch.full((num_anchors, ), -1, dtype=torch.long, device=device)
#--- box_iou 返回的就是我用用于筛选 anchor 和 gt_box 的表 ---
#--- 找到每一行中最大交并比最大的 gt_box 和 anchor, 返回它们的索引(列号) ---
max_iou, indices = torch.max(jaccard, dim=1)
#--- 找到交并比大于阈值的anchor索引 ---
anc_i = torch.nonzero(max_iou >= 0.5).reshape(-1)
#--- 找到交并比大于阈值的box索引 ---
box_j = indices[max_iou >= 0.5]
#--- 删去这些anchor和box所在的行和列 ---
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
jaccard[anc_idx, :] = row_discard
return anchors_bbox_map
接下来我们标记 Anchor 框的类别和偏移量。如果 Anchor框 A 被指定为 ground-truth bbox 框 B,则Anchor框A的类别为B的类别。根据 B和 A 中心坐标的相对位置以及两个框的相对大小 设置Anchor框A的偏移量。由于数据集中不同框的位置和大小可能不同,这些相对位置和相对大小通常需要一些特殊的变换,以使偏移量分布更均匀,更容易拟合。设Anchor框 A 及其指定的ground-truth bbox框B的中心坐标为 $(x_a, y_a)$,$(x_b, y_b)$, A 和 B 的宽度分别为 $w_a, w_b$,高度分别为 $h_a, h_b$。在这种情况下,一种常见的技术是将$A$ 的偏移量标记为
\[(\frac{\frac{x_b - x_a}{w_a} - \mu_x}{\sigma_x}, \frac{\frac{y_b - y_a}{h_a} - \mu_y}{\sigma_y}, \frac{log \frac{w_b}{w_a} - \mu_w}{\sigma_w}, \frac{log\frac{h_b}{h_a} - \mu_h}{\sigma_h})\]这些常量的默认值为 $\mu_x = \mu_y = \mu_w = \mu_h = 0, \sigma_x = \sigma_y = 0.1, \sigma_w = \sigma_h = 0.2$。 这个变换在下面的 offset_boxes 函数中实现。如果Anchor框没有被指定为ground-truth边界框,我们只需要将Anchor框的类别设置为background。分类为背景的Anchor 框通常被称为负Anchor框,其余的被称为正Anchor框。
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
def offset_boxes(anchors, assigned_bb, eps=1e-6):
c_anc = d2l.box_corner_to_center(anchors)
c_assigned_bb = d2l.box_corner_to_center(assigned_bb)
#--- 对 anchor 和 box 进行变换, 除以0.1等于乘以10 ---
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.shape: (num of anchors, 4) ---
offset = torch.cat([offset_xy, offset_wh], axis=1)
return offset
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):
#--- labels: (batch, num_gt_box, 5) ---
label = labels[i, :, :]
#--- 这里已经将anchor筛选过一轮了 ---
anchors_bbox_map = match_anchor_to_bbox(label[:, 1:], anchors, device=device)
#--- mask变量中的元素与每个Anchor框的四个偏移值一一对应 ---
#--- 通过乘以元素,mask变量中的0可以在计算目标函数之前过滤掉负的类偏移量 ---
bbox_mask = ((anchors_bbox_map >= 0).float().unsqueeze(-1)).repeat(1, 4)
class_labels = torch.zeros(num_anchors, dtype=torch.long, device=device)
assigned_bb = torch.zeros((num_anchors, 4), dtype=torch.float32, device=device)
indices_true = torch.nonzero(anchors_bbox_map >= 0).reshape(-1)
bb_idx = anchors_bbox_map[indices_true]
#--- 标签种类 + 1, 0为背景 ---
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
#--- offset.reshape(-1): num of anchors x 4 ---
batch_offset.append(offset.reshape(-1))
#--- bbox_mask.reshape(-1): numof anchors x 4 ---
batch_mask.append(bbox_mask.reshape(-1))
#--- class_labels: num of anchors ---
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)
下面我们将演示一个详细的示例。我们为读取的图片中的猫和狗定义 ground-truth bbox, 第一个要素是类别(猫为1, 狗为0)和剩下的四个要素是 $x, y$ 轴的左上角坐标 和 $x, y$ 轴的右下角坐标(0和1之间的值范围)。这里,我们构造了5个Anchor框,分别用左上角和右下角的坐标进行标记,分别记录为 $A_0、、A_4$ (程序中的索引从0开始)。首先,在图像中绘制这些Anchor框和ground-truth边界框的位置。
1
2
3
4
5
6
7
8
9
ground_truth = torch.tensor([[0, 0.1, 0.08, 0.52, 0.92],
[1, 0.55, 0.2, 0.9, 0.88]])
anchors = torch.tensor([[0, 0.1, 0.2, 0.3], [0.15, 0.2, 0.4, 0.4],
[0.63, 0.05, 0.88, 0.98], [0.66, 0.45, 0.8, 0.8],
[0.57, 0.3, 0.92, 0.9]])
fig = d2l.plt.imshow(img)
show_bboxes(fig.axes, ground_truth[:, 1:] * bbox_scale, ['dog', 'cat'], 'k')
show_bboxes(fig.axes, anchors * bbox_scale, ['0', '1', '2', '3', '4'])
我们可以使用 multibox_target 函数为 Anchor框标记类别和偏移量。这个函数将背景类别设置为0,并将目标类别的整数索引从加1(1代表狗,2代表猫)。
我们使用 unsqueeze 函数 给 anchor框 和 ground-truth bbox 增加维度, 并且构造一个随机预测结果, 它形状为 (batch_size, number of categories including background, number of anchor boxes) 。
1
2
labels = multibox_target(anchors.unsqueeze(dim=0),
ground_truth.unsqueeze(dim=0))
返回的结果中有三项,它们都是张量格式。第三项表示标记为Anchor框的类别。
1
2
3
4
labels[2]
tensor([[0, 1, 2, 0, 2]])
我们根据图像中Anchor框和ground-truth bbox的位置来分析这些已标记的类别。首先,在所有Anchor和ground-truth bbox对中,Anchor框 $A_4$ 对猫的ground-truth 边界框的IoU最大,因此Anchor $A_4$ 的类别被标记为猫。不考虑猫的真实边界框和Anchor边界框 $A_4$, 在剩余的Anchor和ground-truth边界框对, 狗的真实边界框和Anchor框 $A_1$具有最大交并比, 所以Anchor框 $A_1$ 的类别是 狗。接下来,遍历其余三个未标记的Anchor框。Anchor框 $A_0$ 具有最大交并比的真实边界框的种类是狗, 但是交并比小于阈值(默认为0.5), 因此种类被标记为背景; Anchor框 $A_2$ 具有最大交并比的真实边界框的种类是猫, 并且交并比大于阈值, 因此种类被标记为猫; Anchor框 $A_3$ 具有最大交并比的真实边界框的种类是猫, 但是交并比小于阈值(默认为0.5), 因此种类被标记为背景;
返回值的第二项是一个mask变量, 它的形状是 (batch size, four times the number of anchor boxes)。 mask变量中的元素与每个Anchor框的四个偏移值一一对应。因为我们不关心背景检测,负类的偏移量不应该影响目标函数。通过乘以元素,mask变量中的0可以在计算目标函数之前过滤掉负类的偏移量。
1
2
3
4
5
labels[1]
tensor([[0., 0., 0., 0., 1., 1., 1., 1., 1., 1., 1., 1., 0., 0., 0., 0., 1., 1.,
1., 1.]])
返回的第一项是为每个Anchor框标记的四个偏移值,负类Anchor框的偏移值标记为0。
1
2
3
4
5
6
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]])
Bounding Boxes for Prediction
在模型预测阶段,我们首先为图像生成多个Anchor框,然后对这些Anchor框逐个预测类别和偏移量。然后,根据Anchor框及其预测偏移量得到预测边界框。
下面我们实现函数 offset_inverse,它接受 anchors 和offset predictions作为输入,并应用逆偏移量变换来返回预测的边框坐标。
1
2
3
4
5
6
7
8
#@save
def offset_inverse(anchors, offset_preds):
c_anc = d2l.box_corner_to_center(anchors)
c_pred_bb_xy = (offset_preds[:, :2] * c_anc[:, 2:] / 10) + c_anc[:, :2]
c_pred_bb_wh = torch.exp(offset_preds[:, 2:] / 5) * c_anc[:, 2:]
c_pred_bb = torch.cat((c_pred_bb_xy, c_pred_bb_wh), axis=1)
predicted_bb = d2l.box_center_to_corner(c_pred_bb)
return predicted_bb
当有许多Anchor框时,同一个目标可能会输出许多类似的预测边界框。为了简化结果,我们可以删除类似的预测框。一种常用的方法称为非极大抑制(NMS)。
让我们来看看NMS是如何工作的。对于一个预测的边界框 $B$, 模型计算每个类别的预测概率。假设最大的预测概率是 $p$,这个概率对应的类别就是 $B$ 的预测类别。 在同一幅图像上,根据置信度从高到低对除背景外以外预测类别的预测边框进行排序,得到列表 $L$。从 $L$ 中选择置信度最高的预测边界框 $B_1$ 作为基线,计算$B_1$与其它边界框的交并比, 从 $L$ 中删除所有与 $B_1$ 交并比小于某一阈值非基准预测边界框。这里的阈值是一个预置的超参数。此时,$L$ 保留了置信度最高的预测边界框,并删除了其他类似的预测边界框。接下来,选择 $L$ 中置信度第二高的预测边界框 $B_2$ 作为基线,计算$B_2$与其它边界框的交并比, 删除 $L$ 中 所有与 $B_2$ 交并比小于某一阈值的所有非基准预测边界框。重复这个过程,直到 $L$ 中的所有预测框都被用作基线。此时,$L$ 中任何一对预测框的IoU都大于阈值。最后,将所有预测框输出到列表 $L$ 中。
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
56
57
58
59
#@save
def nms(boxes, scores, iou_threshold):
# sorting scores by the descending order and return their indices
B = torch.argsort(scores, dim=-1, descending=True)
keep = [] # boxes indices that will be kept
while B.numel() > 0:
i = B[0]
keep.append(i)
if B.numel() == 1: break
# 计算置信度最高的box 和 其余box 的 IoU
# shape: (M, N) -> M * N
iou = box_iou(boxes[i, :].reshape(-1, 4),
boxes[B[1:], :].reshape(-1, 4)).reshape(-1)
print(iou.shape)
inds = torch.nonzero(iou <= iou_threshold).reshape(-1)
print(inds)
B = B[inds + 1]
return torch.tensor(keep, device=boxes.device)
#@save
def multibox_detection(cls_probs, offset_preds, anchors, nms_threshold=0.5,
pos_threshold=0.00999999978):
device, batch_size = cls_probs.device, cls_probs.shape[0]
print("batch_size: ", batch_size)
# 每次只能输入batch 1
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)
# 求anchor框是猫还是狗, 返回元组([value1, valu2, ...], [index1, index2, ...])
conf, class_id = torch.max(cls_prob[1:], 0)
# 根据anchor框和预测的偏移量 得到 预测的边界框
predicted_bb = offset_inverse(anchors, offset_pred)
# NMS之后保留下的预测框, 它是一个索引组成的列表
keep = nms(predicted_bb, conf, 0.5)
# Find all non_keep indices and set the class_id to background
all_idx = torch.arange(num_anchors, dtype=torch.long, device=device)
combined = torch.cat((keep, all_idx))
uniques, counts = combined.unique(return_counts=True)
non_keep = uniques[counts == 1]
all_id_sorted = torch.cat((keep, non_keep))
class_id[non_keep] = -1
# 将class_id 调整为keep在前,non_keep在后的顺序
class_id = class_id[all_id_sorted]
conf, predicted_bb = conf[all_id_sorted], predicted_bb[all_id_sorted]
# threshold to be a positive prediction
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), dim=1)
out.append(pred_info)
return torch.stack(out)
接下来,我们将看一个详细的示例。首先,构建四个Anchor框。为了简单起见,我们假设预测偏移量都为0。这意味着预测框就是Anchor框。最后,我们为每个类别构造一个预测概率。
1
2
3
4
5
6
7
8
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, # Predicted probability for background
[0.9, 0.8, 0.7, 0.1], # Predicted probability for dog
[0.1, 0.2, 0.3, 0.9]
]) # Predicted probability for cat
在图像上打印预测边框及其置信度。
1
2
3
fig = d2l.plt.imshow(img)
show_bboxes(fig.axes, anchors * bbox_scale,
['dog=0.9', 'dog=0.8', 'dog=0.7', 'cat=0.9'])
我们使用 multibox_detection 函数进行 NMS,将阈值设置为0.5。这样就给张量输入增加了一个维数。可以看到,返回结果的形状为 (batch size, number of anchor boxes, 6)。 每行的6个元素表示同一个预测框的输出信息。第一个元素是预测的类别索引,它从0开始(0是dog, 1是cat)。-1 表示背景或者用NMS删除。第二个元素是预测边界框的置信度。其余四个元素是预测框左上角的 $x、y$ 轴坐标和右下角的 $x、y$轴坐标(取值范围在0到1之间)。
1
2
3
4
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]]])
我们去掉类别-1的预测框,并将NMS保留的结果可视化。
1
2
3
4
5
6
fig = d2l.plt.imshow(img)
for i in output[0].detach().numpy():
if i[0] == -1:
continue
label = ('dog=', 'cat=')[int(i[0])] + str(i[1])
show_bboxes(fig.axes, [torch.tensor(i[2:]) * bbox_scale], label)
在实践中,我们可以在执行NMS之前删除具有较低置信度的预测框,从而减少NMS的计算量。我们还可以过滤NMS的输出,例如, 只保留具有较高置信度的结果作为最终输出。
Summary
- 我们生成了多个以每个像素为中心的大小和宽高比不同的Anchor框。
- IoU,也称为Jaccard指数,衡量两个边界框的相似性。它是两个边界框的相交面积与联合面积之比。
- 在训练集中,我们为每个 Anchor 框标记两种类型的标签:一种是 Anchor 框中包含的目标类别,另一种是ground-truth边界框相对于Anchor框的偏移量。
- 在进行预测时,我们可以使用非极大值抑制(non-maximum suppression, NMS)去除相似的预测框,从而简化结果。
Exercises
- 改变 multibox_prior 函数中的 sizes 和 ratios,并观察生成的Anchor框的变化。
- 构建两个IoU为0.5的边界框,并观察它们的重合情况。
- 通过标记本节定义的Anchor框偏移量来验证offset label[0]的输出(常量是默认值)
- 修改 “Labeling Training Set Anchor Boxes” 和 “Output Bounding Boxes for Prediction” 部分中的 Anchor变量。结果如何变化?
-
Previous
【d2l】Object Detection and Bounding Boxes -
Next
【深度学习】Knowledge-guided semantic computing network