在图像分类任务里,我们假定图片里只有一个主体对象,只需要关注和识别该对象的类别即可。然而,如果我们对一张图片中的多个对象都感兴趣,我们不仅想要知道它们各自的类别,还想知道它们在图片中的具体位置。在计算机视觉中,我们称这类任务为目标检测、物体检测或对象检测(Object Detection)。
我们先来了解一下,在目标检测领域,人们是如何定义对象的位置的。我们仅仅关注下图中的两个主体对象:猫和狗。
import numpy as np
from matplotlib import pyplot as plt
np.set_printoptions(2) # 修改了 NumPy 的打印精度
%matplotlib inline
img_name = '../images/catdog.jpg'
img = plt.imread(img_name)
plt.imshow(img)
plt.show()
1.1 边界框
在目标检测中,我们通常使用边界框(bounding box)来描述目标位置。边界框是一个矩形框,可用矩形左上角和右下角的坐标确定。上图的坐标原点为左上角,原点往右和往下分别为 $x$ 轴和 $y$ 轴的正方向。
# 注意坐标轴原点是图片的左上角。bbox 是 bounding box 的缩写。
dog_bbox = [300, 200, 600, 450]
cat_bbox = [30, 30, 230, 200]
将边界框 $(x_{左上}, y_{左上}, x_{右下}, y_{右下})$ 格式转换成 matplotlib 格式:$(x_{左上}, y_{左上}, \text{宽}, \text{高})$
def bbox_to_rect(bbox, color):
return plt.Rectangle(
xy=(bbox[0], bbox[1]), width=bbox[2]-bbox[0], height=bbox[3]-bbox[1],
fill=False, edgecolor=color, linewidth=2)
下面画出图像与边界框:
fig, ax = plt.subplots()
plt.imshow(img)
ax.add_patch(bbox_to_rect(dog_bbox, 'blue'))
ax.add_patch(bbox_to_rect(cat_bbox, 'red'))
ax.autoscale_view()
plt.show()
1.2 锚框
目标检测算法通常会在输入图片中采样大量的区域,然后判断这些区域是否有我们感兴趣的物体,并调整区域边缘从而更准确预测物体的真实边界框。不同的模型使用不同的区域采样方法,这里我们介绍其中的一种:它以每个像素为中心生成数个大小和宽高比的比例不同的边界框(称之为锚(máo)框,anchor box)。
假设输入图片高为 $h$,宽为 $w$,那么大小为 $s\in (0,1]$ 和比例为 $r > 0$ 的锚框的高宽分别为:
\[\left(\frac{ws}{\sqrt{r}}, hs \sqrt{r}\right)\]确定其中心点位置便可以固定一个锚框。
我们假设锚框的高和宽分别为 $h_1, w_1$,则有:
\[\begin{aligned} &s^2 = \frac{w_1h_1}{wh}\\ &\frac{h_1}{w_1} = \frac{h}{w}r \end{aligned}\]我们可以通过不同的 $s$ 和 $r$,以及中心位置,来遍历所有可能的区域。下面我们设定一组不同的大小的 $s_1\,\ldots\,s_n$,与不同大小的 $r_1\,\ldots\,r_m$。如果我们对每个像素都使用这些组合,则输入图片将会得到 $wh mn$ 个锚框。为了减少计算量,通常我们仅仅对由包含 $s_{1}$ 或 $r_{1}$ 对应的锚框感兴趣。这样,我们的锚框数量则减少为 $n + m-1$ 个。
上述的采样方法实现在 contribe.ndarray
中的 MultiBoxPrior
函数。通过指定输入数据(我们只需要访问其形状),锚框的采样大小和比例,这个函数将返回所有采样到的锚框。
from mxnet import contrib, nd
h, w = img.shape[0:2]
x = nd.random.uniform(shape=(1, 3, h, w)) # 构造一个输入数据,
y = contrib.nd.MultiBoxPrior(x, sizes=[.75, .5, .25], ratios=[1, 2, .5])
y.shape
(1, 1532800, 4)
y.shape
其返回结果格式为 (批量大小,锚框个数,4)
。可以看到我们生成了 $1$ 百万以上个锚框。将其变形成 $(高,宽,n+m-1,4)$ 后,我们可以方便的访问以任何一个像素为中心的所有锚框。下面例子里我们访问以 $(250, 250)$ 为中心的 5 个锚框。它们各有四个元素,同前一样是左上和右下的 $x$、$y$ 轴坐标,但被分别除以了高和宽使得数值在 $0$ 和 $1$ 之间。
boxes = y.reshape((h, w, 5, 4))
boxes[250, 250, :, :]
[[ 0.11 0.15 0.67 0.9 ]
[ 0.2 0.27 0.58 0.77]
[ 0.3 0.4 0.48 0.65]
[-0.01 0.26 0.79 0.79]
[ 0.19 -0.01 0.59 1.05]]
<NDArray 5x4 @cpu(0)>
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 = bbox_to_rect(bbox.asnumpy(), 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))
bbox_scale = nd.array((w, h, w, h)) # 需要乘以高和宽使得符合我们的画图格式。
fig, ax = plt.subplots()
plt.imshow(img)
show_bboxes(ax, boxes[220, 350, :, :]*bbox_scale, ['s=.75, r=1', 's=.5, r=1', 's=.25, r=1', 's=.75, r=2', 's=.75, r=.5'])
1.3 IoU:交并比
在介绍如何使用锚框参与训练和预测前,我们先介绍如何计算两个边界框的距离。我们知道集合相似度的最常用衡量标准叫做 Jaccard 距离。给定集合 $A, B$,它们的距离定义为集合的交集除以集合的并集:
\[J(A, B) = \frac{|A\cap B|}{| A \cup B|}\]边界框指定了一块像素区域,其可以看成是像素点的集合。因此我们可以定类似的距离,即我们使用两个边界框的相交面积除以相并面积来衡量它们的相似度。这被称之为交集除并集(Intersection over Union,简称 IoU,或称为交并比)。它的取值范围在 $0$ 和 $1$ 之间。$0$ 表示边界框不相关,$1$ 则表示完全一样。
1.4 标注训练集的锚框
在训练时,我们将每个锚框都视作一个训练样本。为了训练目标检测模型,我们需要为每个锚框标注两类标签:
- 锚框所包含的类别,简称类别
- 真实边界框相对于锚框的偏移量,简称偏移量(offset)
目标检测的一般做法是:
- 生成多个锚框
- 预测每个锚框的类别与偏移量
- 依据预测是偏移量调整锚框的位置从而获得预测边界框
- 筛选预测边界框得到需要输出的预测边界框
下面我们详细说明如何为锚框分配与其相似的真实边界框:
假设图像中锚框分别为 $A_1, A_2, \cdots, A_ {n_a}$, 真实边界框分别为 $B_1, B_2, \cdots, B_ {n_b}$, 且 $n_b \geq n_a$。定义矩阵 $X \in \mathbb{R}^{n_a \times n_b}$, 其中 $(X)_ {ij}$ 为锚框 $A_i$ 与真实边界框 $B_j$ 的 IoU。
- 找出 $X$ 中的最大元素,并将该元素的行、列索引分别记作 $i_1, j_1$。我们为锚框 $A_ {i_1}$ 分配真实边界框 $B_ {j_1}$。(显然,锚框 $A_ {i_1}$ 与真实边界框 $B_{j_1}$ 的相似度为最高)
- 丢掉 $X$ 中第 $i_ 1$ 行和第 $j_ 1$ 列的所有元素,找出 $X$ 中剩余元素中的最大者,并将该元素的行、列索引分别记作 $i_ 2, j_ 2$。我们为锚框 $A_ {i_2}$ 分配真实边界框 $B_ {j_2}$。依次类推,直到 $X$ 中所有 $n_{b}$ 列元素都被丢掉。
- 为剩余的 $n_{b} - n_{a}$ 个锚框分配真实边界框:给定其中的锚框 $A_i$,依据 $X$ 的第 ${1}$ 行找到与 $A_ i$ 的交并比最大的真实边界框 $B_j$,只有当该 IoU 的值大于预先设定的阈值时,才为锚框 $A_i$ 分配真实边界框 $B_j$。
如果一个锚框 $A$ 被分配了真实边界框 ${B}$,将锚框 $A$的类别设为 ${B}$ 的类别,并根据 ${B}$ 和 $A$ 的中心坐标相对位置以及两个框的相对大小为锚框 $A$ 标注偏移量。由于数据集中各个框的位置和大小各异,这些相对位置和相对大小通常需要一些特殊变换,才能使偏移量的分布更均匀从而更容易拟合。设锚框 $A$ 及其被分配的真实边界框 ${B}$ 的中心坐标分别为 $(x_a, y_a),(x_b, y_b)$,$A$ 和 ${B}$ 的宽分别为 $w_{a}$ 和 $w_{b}$,高分别为 $h_{a}$, $h_{b}$,一个常用的技巧是将 $A$ 的偏移量标注为
\[\left(\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_a}{w_a}- \mu_w}{\sigma_w}, \frac{\log \frac{h_b}{h_a}- \mu_h}{\sigma_h} \right)\]其中常数的默认值为 $\mu_x = \mu_y = \mu_w = \mu_h = 0, \sigma_x = \sigma_y = 0.1, \sigma_w = \sigma_h = 0.2$。如果一个锚框没有被分配真实边界框,我们只需将该锚框的类别设为背景。类别为背景的锚框通常被称为负类锚框,其余则被称为正类锚框。
下面来看一个具体的例子。我们构造 ${6}$ 个锚框,其与真实边界框的位置如下图示。
ground_truth = nd.array([[0, .1, .08, .35, .42], [1, .45, .42, 1, 1]])
anchors = nd.array([[ .8, .1, 11, .3], [.1, .1, .35, .36],
[.15, .15, .35, .35], [.57, .45, .85, .85],
[0.57, 0.3, 0.92, 0.9], [0.47, 0.3, 0.82, 0.89]])
fig, ax = plt.subplots()
plt.imshow(img)
show_bboxes(ax, ground_truth[:,1:]*bbox_scale, ['cat','dog'])
show_bboxes(ax, anchors*bbox_scale, ['0', '1', '2', '3', '4', '5']);
我们可以通过 contrib.nd
模块中的 MultiBoxTarget
函数来对锚框生成标号。我们把锚框和真实边界框加上批量维(实际中我们会批量处理数据),然后构造一个任意的锚框预测结果,其形状为(批量大小,类别数 $+1$,锚框数),其中第 $0$ 类为背景。
labels = contrib.nd.MultiBoxTarget(anchors.expand_dims(axis=0),
ground_truth.expand_dims(axis=0),
nd.zeros((1, 2, 6)))
返回的结果里有三项,均为 NDArray。第三项表示为锚框标注的类别。
labels[2]
[[0. 1. 0. 0. 2. 0.]]
<NDArray 1x6 @cpu(0)>
返回值的第二项为掩码(mask)变量,形状为 (批量大小,锚框个数 x 4)
。掩码变量中的元素与每个锚框的四个偏移量一一对应。 由于我们不关心对背景的检测,有关负类的偏移量不应影响目标函数。通过按元素乘法,掩码变量中的 0
可以在计算目标函数之前过滤掉负类的偏移量。(其中正类锚框对应的元素为 1
,负类为 0
)
labels[1]
[[0. 0. 0. 0. 1. 1. 1. 1. 0. 0. 0. 0. 0. 0. 0. 0. 1. 1. 1. 1. 0. 0. 0. 0.]]
<NDArray 1x24 @cpu(0)>
返回的第一项是为每个锚框标注的四个偏移量,其中负类锚框的偏移量标注为 0
。
labels[0]
[[ 0. 0. 0. 0. 0. 0.77 0. 1.34 0. 0. 0. 0.
0. 0. 0. 0. -0.57 1.83 2.26 -0.17 0. 0. 0. 0. ]]
<NDArray 1x24 @cpu(0)>
1.5 标注测试集的锚框
预测同训练类似,是对每个锚框预测其包含的物体类别和与真实边界框的位移。因为我们生成了大量的锚框,所以可能导致对同一个物体产生大量相似的预测边界框。为了使得结果更加简洁,我们需要消除相似的冗余预测框。这里常用的方法是非极大值抑制(Non-Maximum Suppression,简称 NMS)。对于相近的预测边界框,NMS 只保留物体标号预测置信度最高的那个。 关于“非极大值抑制”的详细内容见 非极大值抑制(NMS)。
具体来说,对于每个物体类别(非背景),我们先获取每个预测边界框里被判断包含这个类别物体的概率。然后我们找到概率最大的那个边界框,如果其置信度大于某个阈值,那么保留它到输出。接下来移除掉其它所有的跟这个边界框的 IoU 大于某个阈值的边界框。在剩下的边界框里我们再找出预测概率最大的边界框,一直重复前面的移除过程,直到我们遍历保留或者移除了每个边界框。
下面来看一个具体的例子。我们先构造四个锚框,为了简单起见我们假设预测偏移全是 0
,然后构造了类别预测。
anchors = nd.array([[0.1, 0.08, 0.32, 0.42], [0.08, 0.2, 0.46, 0.65],
[0.45, 0.6, 0.82, 0.91], [0.55, 0.2, 0.9, 0.88]])
offset_preds = nd.array([0] * anchors.size)
cls_probs = nd.array([[0] * 4, # 背景的预测概率。
[0.1, 0.2, 0.3, 0.9], # 猫的预测概率。
[0.9, 0.8, 0.5, 0.1]]) # 狗的预测概率。
在图像上打印预测边界框和它们的置信度(我随便设定的):
fig = plt.imshow(img)
show_bboxes(fig.axes, anchors * bbox_scale,
['cat=0.9', 'cat=0.6', 'dog=0.7', 'dog=0.9'])
我们使用 contrib.nd
模块的 MultiBoxDetection
函数来执行非极大值抑制并设阈值为 0.5
。这里为 NDArray 输入都增加了样本维。我们看到,返回结果的形状为(批量大小,锚框个数,6)。其中每一行的 6 个元素代表同一个预测边界框的输出信息。第一个元素是索引从 0 开始计数的预测类别(0
为猫,1
为狗),其中 -1
表示背景或在非极大值抑制中被移除。第二个元素是预测边界框的置信度。剩余的四个元素分别是预测边界框左上角的 $x, y$ 轴坐标和右下角的 $x, y$ 轴坐标(值域在 0 到 1 之间)。
output = contrib.ndarray.MultiBoxDetection(
cls_probs.expand_dims(axis=0), offset_preds.expand_dims(axis=0),
anchors.expand_dims(axis=0), nms_threshold=0.5)
output
[[[1. 0.9 0.1 0.08 0.32 0.42]
[0. 0.9 0.55 0.2 0.9 0.88]
[1. 0.8 0.08 0.2 0.46 0.65]
[1. 0.5 0.45 0.6 0.82 0.91]]]
<NDArray 1x4x6 @cpu(0)>
我们移除掉类别为 -1
的预测边界框,并可视化非极大值抑制保留的结果。
fig = plt.imshow(img)
for i in output[0].asnumpy():
if i[0] == -1:
continue
label = ('dog=', 'cat=')[int(i[0])] + str(i[1])
show_bboxes(fig.axes, [nd.array(i[2:]) * bbox_scale], label)
实践中,我们可以在执行非极大值抑制前将置信度较低的预测边界框移除,从而减小非极大值抑制的计算量。我们还可以筛选非极大值抑制的输出,例如只保留其中置信度较高的结果作为最终输出。
1.6 小结
- 以每个像素为中心,我们生成多个大小和宽高比不同的锚框。
- 交并比是两个边界框相交面积与相并面积之比。
- 在训练集中,我们为每个锚框标注两类标签:一是锚框所含目标的类别;二是真实边界框相对锚框的偏移量。
- 预测时,我们可以使用非极大值抑制来移除相似的预测边界框,从而令结果简洁。