1 目标检测基础

在图像分类任务里,我们假定图片里只有一个主体对象,只需要关注和识别该对象的类别即可。然而,如果我们对一张图片中的多个对象都感兴趣,我们不仅想要知道它们各自的类别,还想知道它们在图片中的具体位置。在计算机视觉中,我们称这类任务为目标检测、物体检测或对象检测(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( ws \sqrt{r}, \ \frac{hs}{\sqrt{r}}\right)

确定其中心点位置便可以固定一个锚框。


我们假设锚框的高和宽分别为 h_1, w_1,则有:

\begin{aligned} &s^2 = \frac{w_1h_1}{wh}\\ &\frac{w_1}{h_1} = \frac{w}{h}r \end{aligned}


我们可以通过不同的 sr,以及中心位置,来遍历所有可能的区域。下面我们设定一组不同的大小的 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 个锚框。它们各有四个元素,同前一样是左上和右下的 xy 轴坐标,但被分别除以了高和宽使得数值在 01 之间。

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,或称为交并比)。它的取值范围在 01 之间。0 表示边界框不相关,1 则表示完全一样。

IoU

1.4 标注训练集的锚框

在训练时,我们将每个锚框都视作一个训练样本。为了训练目标检测模型,我们需要为每个锚框标注两类标签:

  • 锚框所包含的类别,简称类别
  • 真实边界框相对于锚框的偏移量,简称偏移量(offset)

目标检测的一般做法是:

  1. 生成多个锚框
  2. 预测每个锚框的类别与偏移量
  3. 依据预测是偏移量调整锚框的位置从而获得预测边界框
  4. 筛选预测边界框得到需要输出的预测边界框

下面我们详细说明如何为锚框分配与其相似的真实边界框:

假设图像中锚框分别为 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。

  1. 找出 X 中的最大元素,并将该元素的行、列索引分别记作 i_1, j_1。我们为锚框 A_ {i_1} 分配真实边界框 B_ {j_1}。(显然,锚框 A_ {i_1} 与真实边界框 B_{j_1} 的相似度为最高)
  2. 丢掉 X 中第 i_ 1 行和第 j_ 1 列的所有元素,找出 X 中剩余元素中的最大者,并将该元素的行、列索引分别记作 i_ 2, j_ 2。我们为锚框 A_ {i_2} 分配真实边界框 B_ {j_2}。依次类推,直到 X 中所有 n_{b} 列元素都被丢掉。
  3. 为剩余的 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)
NMS

实践中,我们可以在执行非极大值抑制前将置信度较低的预测边界框移除,从而减小非极大值抑制的计算量。我们还可以筛选非极大值抑制的输出,例如只保留其中置信度较高的结果作为最终输出。

1.6 小结

  • 以每个像素为中心,我们生成多个大小和宽高比不同的锚框。
  • 交并比是两个边界框相交面积与相并面积之比。
  • 在训练集中,我们为每个锚框标注两类标签:一是锚框所含目标的类别;二是真实边界框相对锚框的偏移量。
  • 预测时,我们可以使用非极大值抑制来移除相似的预测边界框,从而令结果简洁。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,009评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,808评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,891评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,283评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,285评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,409评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,809评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,487评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,680评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,499评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,548评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,268评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,815评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,872评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,102评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,683评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,253评论 2 341

推荐阅读更多精彩内容