目标检测基础
锚框
目标检测算法通常会在输入图像中采样大量的区域,然后判断这些区域中是否包含我们感兴趣的目标,并调整区域边缘从而更准确地预测目标的真实边界框(ground-truth bounding box)。不同的模型使用的区域采样方法可能不同。这里我们介绍其中的一种方法:它以每个像素为中心生成多个大小和宽高比(aspect ratio)不同的边界框。这些边界框被称为锚框(anchor box)。
生成多个锚框
假设输入图像高为 ,宽为。我们分别以图像的每个像素为中心生成不同形状的锚框。设大小为且宽高比为,那么锚框的宽和高将分别为和。当中心位置给定时,已知宽和高的锚框是确定的。
下面我们分别设定好一组大小和一组宽高比。如果以每个像素为中心时使用所有的大小与宽高比的组合,输入图像将一共得到个锚框。虽然这些锚框可能覆盖了所有的真实边界框,但计算复杂度容易过高。因此,我们通常只对包含或的大小与宽高比的组合感兴趣,即
也就是说,以相同像素为中心的锚框的数量为。对于整个输入图像,我们将一共生成个锚框。
代码实现
numpy知识点:
-
np.meshgrid(x, y)
:使用np.meshgrid()函数生成网格点坐标矩阵,之后可以用np.stack()
指定轴axis还原各点坐标。
代码示例:>>>x = np.arange(2) >>>y = np.arange(3) >>>x_1, y_1 = np.meshgrid(x, y) >>>print(x_1) [[0 1] [0 1] [0 1]] >>>print(y_1) [[0 0] [1 1] [2 2]] >>>s_x = x_1.reshape(-1) >>>s_y = y_1.reshape(-1) >>>np.stack((s_x, s_y), axis=1) array([[0, 0], [1, 0], [0, 1], [1, 1], [0, 2], [1, 2]]) #得到了x列表中所有元素与y列表中所有元素组成的所有二维坐标
生成锚框的函数:指定输入、一组大小和一组宽高比,该函数将返回输入的所有锚框
def MultiBoxPrior(feature_map, sizes=[0.75, 0.5, 0.25], ratios=[1, 2, 0.5]):
"""
# 按照「9.4.1. 生成多个锚框」所讲的实现, anchor表示成(xmin, ymin, xmax, ymax).
https://zh.d2l.ai/chapter_computer-vision/anchor.html
Args:
feature_map: torch tensor, Shape: [N, C, H, W]. [输入图像的个数,三个通道的值,高度,宽度]
sizes: List of sizes (0~1) of generated MultiBoxPriores.
ratios: List of aspect ratios (non-negative) of generated MultiBoxPriores.
Returns:
anchors of shape (1, num_anchors, 4). 由于batch里每个都一样, 所以第一维为1 第三维是4:生成的锚框左上角和右下角坐标值(xmin, ymin, xmax, ymax)共4个
"""
pairs = [] # pair of (size, sqrt(ration))
# 生成n + m -1个框
for r in ratios:
pairs.append([sizes[0], math.sqrt(r)])
for s in sizes[1:]:
pairs.append([s, math.sqrt(ratios[0])])
pairs = np.array(pairs)
# 生成相对于坐标中心点的框(x,y,x,y)
ss1 = pairs[:, 0] * pairs[:, 1] # size * sqrt(ration)
ss2 = pairs[:, 0] / pairs[:, 1] # size / sqrt(ration)
base_anchors = np.stack([-ss1, -ss2, ss1, ss2], axis=1) / 2
#将坐标点和anchor组合起来生成hw(n+m-1)个框输出
h, w = feature_map.shape[-2:]
shifts_x = np.arange(0, w) / w #除以w和h是因为最后要相对于图片大小为1来生成1,所以要标准化
shifts_y = np.arange(0, h) / h
shift_x, shift_y = np.meshgrid(shifts_x, shifts_y) #使用np.meshgrid()函数生成网格点坐标矩阵,之后可以用np.stack()指定轴axis还原各点坐标
shift_x = shift_x.reshape(-1) #保存了所有x轴坐标
shift_y = shift_y.reshape(-1)
shifts = np.stack((shift_x, shift_y, shift_x, shift_y), axis=1) #shifts中保存了所有像素点的坐标值(有两个x两个y是因为分别是左上点和右下点)
anchors = shifts.reshape((-1, 1, 4)) + base_anchors.reshape((1, -1, 4)) #第0维保存所有anchors个数,第1维保存所有像素点个数,相加时会自动在设为-1的维上扩展
return torch.tensor(anchors, dtype=torch.float32).view(1, -1, 4)
交并比(IoU)
一种衡量锚框和真实边界框之间相似度的方法:两个边界框相交面积与相并面积之比。
代码实现
pytorch知识点:
-
torch.unsqueeze(axis)
:在指定轴上扩充维度 -
torch.clamp(input, min, max, out=None) → Tensor
:将输入input张量每个元素的夹紧到区间 [min,max],并返回结果到一个新张量。(原张量中超出最小或最大限制的元素,将被置为最小或最大值)
求得交并比的函数:
def compute_intersection(set_1, set_2):
"""
计算anchor之间的交集
Args:
set_1: a tensor of dimensions (n1, 4), anchor表示成(xmin, ymin, xmax, ymax)
set_2: a tensor of dimensions (n2, 4), anchor表示成(xmin, ymin, xmax, ymax)
Returns:
intersection of each of the boxes in set 1 with respect to each of the boxes in set 2, shape: (n1, n2)
"""
# PyTorch auto-broadcasts singleton dimensions
lower_bounds = torch.max(set_1[:, :2].unsqueeze(1), set_2[:, :2].unsqueeze(0)) # (n1, n2, 2)
upper_bounds = torch.min(set_1[:, 2:].unsqueeze(1), set_2[:, 2:].unsqueeze(0)) # (n1, n2, 2)
intersection_dims = torch.clamp(upper_bounds - lower_bounds, min=0) # (n1, n2, 2) #利用clamp()函数限制upper-lower大于等于0(若小于0则置为0)
return intersection_dims[:, :, 0] * intersection_dims[:, :, 1] # (n1, n2)
def compute_jaccard(set_1, set_2):
"""
计算anchor之间的Jaccard系数(IoU)
Args:
set_1: a tensor of dimensions (n1, 4), anchor表示成(xmin, ymin, xmax, ymax)
set_2: a tensor of dimensions (n2, 4), anchor表示成(xmin, ymin, xmax, ymax)
Returns:
Jaccard Overlap of each of the boxes in set 1 with respect to each of the boxes in set 2, shape: (n1, n2)
"""
# Find intersections
intersection = compute_intersection(set_1, set_2) # (n1, n2)
# Find areas of each box in both sets
areas_set_1 = (set_1[:, 2] - set_1[:, 0]) * (set_1[:, 3] - set_1[:, 1]) # (n1)
areas_set_2 = (set_2[:, 2] - set_2[:, 0]) * (set_2[:, 3] - set_2[:, 1]) # (n2)
# Find the union
# PyTorch auto-broadcasts singleton dimensions
union = areas_set_1.unsqueeze(1) + areas_set_2.unsqueeze(0) - intersection # (n1, n2) #直接用两个框的面积减去交集的面积就得到并集的面积
return intersection / union # (n1, n2)
标注训练集的锚框
-
在训练集中,我们将每个锚框视为一个训练样本。为了训练目标检测模型,我们需要为每个锚框标注两类标签:
- 锚框所含目标的类别,简称类别。
- 真实边界框相对锚框的偏移量,简称偏移量(offset)。
在目标检测时,我们首先生成多个锚框,然后为每个锚框预测类别以及偏移量,接着根据预测的偏移量调整锚框位置从而得到预测边界框,最后筛选需要输出的预测边界框。
-
为锚框分配与其相似的真实边界框的方法:
- 假设图像中锚框分别为,真实边界框分别为,且。定义矩阵,其中第行第列的元素为锚框与真实边界框的交并比。
- 首先,找出矩阵中最大元素,并将该元素的行索引与列索引分别记为。为锚框分配真实边界框。显然,锚框和真实边界框在所有的“锚框—真实边界框”的配对中相似度最高。接下来,将矩阵中第行和第列上的所有元素丢弃。找出矩阵中剩余的最大元素,并将该元素的行索引与列索引分别记为。为锚框分配真实边界框,再将矩阵中第行和第列上的所有元素丢弃。此时矩阵中已有两行两列的元素被丢弃。依此类推,直到矩阵中所有列元素全部被丢弃。这个时候,我们已为个锚框各分配了一个真实边界框。
- 接下来,我们只遍历剩余的个锚框:给定其中的锚框,根据矩阵的第行找到与交并比最大的真实边界框,且只有当该交并比大于预先设定的阈值(threshold)时,才为锚框分配真实边界框;而若小于阈值,则将该锚框标记为背景。
- 举例说明:
如上图(左)所示,假设矩阵中最大值为,我们将为锚框分配真实边界框。然后,丢弃矩阵中第2行和第3列的所有元素,找出剩余阴影部分的最大元素,为锚框分配真实边界框。接着如上图(中)所示,丢弃矩阵中第7行和第1列的所有元素,找出剩余阴影部分的最大元素,为锚框分配真实边界框。最后如上图(右)所示,丢弃矩阵中第5行和第4列的所有元素,找出剩余阴影部分的最大元素,为锚框分配真实边界框。之后,我们只需遍历除去的剩余锚框,并根据阈值判断是否为剩余锚框分配真实边界框。
-
标注锚框的类别和偏移量的方法:
如果一个锚框被分配了真实边界框,将锚框的类别设为的类别,并根据和的中心坐标的相对位置以及两个框的相对大小为锚框标注偏移量。
如果一个锚框没有被分配真实边界框,我们只需将该锚框的类别设为背景。类别为背景的锚框通常被称为负类锚框,其余则被称为正类锚框。
-
由于数据集中各个框的位置和大小各异,因此这些相对位置和相对大小通常需要一些特殊变换,才能使偏移量的分布更均匀从而更容易拟合。设锚框及其被分配的真实边界框的中心坐标分别为和,和的宽分别为和,高分别为和,一个常用的技巧是将的偏移量标注为
其中常数的默认值为。
代码实现
def assign_anchor(bb, anchor, jaccard_threshold=0.5):
"""
# 按照「9.4.1. 生成多个锚框」图9.3所讲为每个anchor分配真实的bb, anchor表示成归一化(xmin, ymin, xmax, ymax).
https://zh.d2l.ai/chapter_computer-vision/anchor.html
Args:
bb: 真实边界框(bounding box), shape:(nb, 4)
anchor: 待分配的anchor, shape:(na, 4)
jaccard_threshold: 预先设定的阈值
Returns:
assigned_idx: shape: (na, ), 每个anchor分配的真实bb对应的索引, 若未分配任何bb则为-1
"""
na = anchor.shape[0]
nb = bb.shape[0]
jaccard = compute_jaccard(anchor, bb).detach().cpu().numpy() # shape: (na, nb)
assigned_idx = np.ones(na) * -1 # 存放标签初始全为-1
# 先为每个bb分配一个anchor(不要求满足jaccard_threshold)
jaccard_cp = jaccard.copy()
for j in range(nb):
i = np.argmax(jaccard_cp[:, j])
assigned_idx[i] = j
jaccard_cp[i, :] = float("-inf") # 赋值为负无穷, 相当于去掉这一行
# 处理还未被分配的anchor, 要求满足jaccard_threshold
for i in range(na):
if assigned_idx[i] == -1:
j = np.argmax(jaccard[i, :])
if jaccard[i, j] >= jaccard_threshold:
assigned_idx[i] = j
return torch.tensor(assigned_idx, dtype=torch.long)
def xy_to_cxcy(xy):
"""
将(x_min, y_min, x_max, y_max)形式的anchor转换成(center_x, center_y, w, h)形式的.
https://github.com/sgrvinod/a-PyTorch-Tutorial-to-Object-Detection/blob/master/utils.py
Args:
xy: bounding boxes in boundary coordinates, a tensor of size (n_boxes, 4)
Returns:
bounding boxes in center-size coordinates, a tensor of size (n_boxes, 4)
"""
return torch.cat([(xy[:, 2:] + xy[:, :2]) / 2, # c_x, c_y
xy[:, 2:] - xy[:, :2]], 1) # w, h
def MultiBoxTarget(anchor, label):
"""
# 按照「9.4.1. 生成多个锚框」所讲的实现, anchor表示成归一化(xmin, ymin, xmax, ymax).
https://zh.d2l.ai/chapter_computer-vision/anchor.html
Args:
anchor: torch tensor, 输入的锚框, 一般是通过MultiBoxPrior生成, shape:(1,锚框总数,4)
label: 真实标签, shape为(bn, 每张图片最多的真实锚框数, 5)
第二维中,如果给定图片没有这么多锚框, 可以先用-1填充空白, 最后一维中的元素为[类别标签, 四个坐标值]
Returns:
列表, [bbox_offset, bbox_mask, cls_labels]
bbox_offset: 每个锚框的标注偏移量,形状为(bn,锚框总数*4)
bbox_mask: 形状同bbox_offset, 每个锚框的掩码, 一一对应上面的偏移量, 负类锚框(背景)对应的掩码均为0, 正类锚框的掩码均为1
cls_labels: 每个锚框的标注类别, 其中0表示为背景, 形状为(bn,锚框总数)
"""
assert len(anchor.shape) == 3 and len(label.shape) == 3
bn = label.shape[0]
def MultiBoxTarget_one(anc, lab, eps=1e-6):
"""
MultiBoxTarget函数的辅助函数, 处理batch中的一个
Args:
anc: shape of (锚框总数, 4)
lab: shape of (真实锚框数, 5), 5代表[类别标签, 四个坐标值]
eps: 一个极小值, 防止log0
Returns:
offset: (锚框总数*4, )
bbox_mask: (锚框总数*4, ), 0代表背景, 1代表非背景
cls_labels: (锚框总数, 4), 0代表背景
"""
an = anc.shape[0]
# 变量的意义
assigned_idx = assign_anchor(lab[:, 1:], anc) # (锚框总数, )
print("a: ", assigned_idx.shape)
print(assigned_idx)
bbox_mask = ((assigned_idx >= 0).float().unsqueeze(-1)).repeat(1, 4) # (锚框总数, 4)
print("b: " , bbox_mask.shape)
print(bbox_mask)
cls_labels = torch.zeros(an, dtype=torch.long) # 0表示背景
assigned_bb = torch.zeros((an, 4), dtype=torch.float32) # 所有anchor对应的bb坐标
for i in range(an):
bb_idx = assigned_idx[i]
if bb_idx >= 0: # 即非背景
cls_labels[i] = lab[bb_idx, 0].long().item() + 1 # 注意要加一
assigned_bb[i, :] = lab[bb_idx, 1:]
# 如何计算偏移量
center_anc = xy_to_cxcy(anc) # (center_x, center_y, w, h)
center_assigned_bb = xy_to_cxcy(assigned_bb)
offset_xy = 10.0 * (center_assigned_bb[:, :2] - center_anc[:, :2]) / center_anc[:, 2:]
offset_wh = 5.0 * torch.log(eps + center_assigned_bb[:, 2:] / center_anc[:, 2:])
offset = torch.cat([offset_xy, offset_wh], dim = 1) * bbox_mask # (锚框总数, 4)
return offset.view(-1), bbox_mask.view(-1), cls_labels
# 组合输出
batch_offset = []
batch_mask = []
batch_cls_labels = []
for b in range(bn):
offset, bbox_mask, cls_labels = MultiBoxTarget_one(anchor[0, :, :], label[b, :, :])
batch_offset.append(offset)
batch_mask.append(bbox_mask)
batch_cls_labels.append(cls_labels)
bbox_offset = torch.stack(batch_offset)
bbox_mask = torch.stack(batch_mask)
cls_labels = torch.stack(batch_cls_labels)
return [bbox_offset, bbox_mask, cls_labels]
输出预测边界框
- 在模型预测阶段,我们先为图像生成多个锚框,并为这些锚框一一预测类别和偏移量。随后,我们根据锚框及其预测偏移量得到预测边界框。
- 当锚框数量较多时,同一个目标上可能会输出较多相似的预测边界框。为了使结果更加简洁,我们可以移除相似的预测边界框。常用的方法叫作非极大值抑制(non-maximum suppression,NMS):
- 对于一个预测边界框,模型会计算各个类别的预测概率。设其中最大的预测概率为,该概率所对应的类别即的预测类别。我们也将称为预测边界框的置信度。在同一图像上,我们将预测类别非背景的预测边界框按置信度从高到低排序,得到列表。
- 从中选取置信度最高的预测边界框作为基准,将所有与的交并比大于某阈值的非基准预测边界框从中移除。这里的阈值是预先设定的超参数。此时,保留了置信度最高的预测边界框并移除了与其相似的其他预测边界框。
- 接下来,从中选取置信度第二高的预测边界框作为基准,将所有与的交并比大于某阈值的非基准预测边界框从中移除。重复这一过程,直到中所有的预测边界框都曾作为基准。此时中任意一对预测边界框的交并比都小于阈值。最终,输出列表中的所有预测边界框。
代码实现
from collections import namedtuple
Pred_BB_Info = namedtuple("Pred_BB_Info", ["index", "class_id", "confidence", "xyxy"])
def non_max_suppression(bb_info_list, nms_threshold = 0.5):
"""
非极大抑制处理预测的边界框
Args:
bb_info_list: Pred_BB_Info的列表, 包含预测类别、置信度等信息
nms_threshold: 阈值
Returns:
output: Pred_BB_Info的列表, 只保留过滤后的边界框信息
"""
output = []
# 先根据置信度从高到低排序
sorted_bb_info_list = sorted(bb_info_list, key = lambda x: x.confidence, reverse=True)
# 循环遍历删除冗余输出
while len(sorted_bb_info_list) != 0:
best = sorted_bb_info_list.pop(0)
output.append(best)
if len(sorted_bb_info_list) == 0:
break
bb_xyxy = []
for bb in sorted_bb_info_list:
bb_xyxy.append(bb.xyxy)
iou = compute_jaccard(torch.tensor([best.xyxy]),
torch.tensor(bb_xyxy))[0] # shape: (len(sorted_bb_info_list), )
n = len(sorted_bb_info_list)
sorted_bb_info_list = [sorted_bb_info_list[i] for i in range(n) if iou[i] <= nms_threshold]
return output
def MultiBoxDetection(cls_prob, loc_pred, anchor, nms_threshold = 0.5):
"""
# 按照「9.4.1. 生成多个锚框」所讲的实现, anchor表示成归一化(xmin, ymin, xmax, ymax).
https://zh.d2l.ai/chapter_computer-vision/anchor.html
Args:
cls_prob: 经过softmax后得到的各个锚框的预测概率, shape:(bn, 预测总类别数+1, 锚框个数)
loc_pred: 预测的各个锚框的偏移量, shape:(bn, 锚框个数*4)
anchor: MultiBoxPrior输出的默认锚框, shape: (1, 锚框个数, 4)
nms_threshold: 非极大抑制中的阈值
Returns:
所有锚框的信息, shape: (bn, 锚框个数, 6)
每个锚框信息由[class_id, confidence, xmin, ymin, xmax, ymax]表示
class_id=-1 表示背景或在非极大值抑制中被移除了
"""
assert len(cls_prob.shape) == 3 and len(loc_pred.shape) == 2 and len(anchor.shape) == 3
bn = cls_prob.shape[0]
def MultiBoxDetection_one(c_p, l_p, anc, nms_threshold = 0.5):
"""
MultiBoxDetection的辅助函数, 处理batch中的一个
Args:
c_p: (预测总类别数+1, 锚框个数)
l_p: (锚框个数*4, )
anc: (锚框个数, 4)
nms_threshold: 非极大抑制中的阈值
Return:
output: (锚框个数, 6)
"""
pred_bb_num = c_p.shape[1]
anc = (anc + l_p.view(pred_bb_num, 4)).detach().cpu().numpy() # 加上偏移量
confidence, class_id = torch.max(c_p, 0)
confidence = confidence.detach().cpu().numpy()
class_id = class_id.detach().cpu().numpy()
pred_bb_info = [Pred_BB_Info(
index = i,
class_id = class_id[i] - 1, # 正类label从0开始
confidence = confidence[i],
xyxy=[*anc[i]]) # xyxy是个列表
for i in range(pred_bb_num)]
# 正类的index
obj_bb_idx = [bb.index for bb in non_max_suppression(pred_bb_info, nms_threshold)]
output = []
for bb in pred_bb_info:
output.append([
(bb.class_id if bb.index in obj_bb_idx else -1.0),
bb.confidence,
*bb.xyxy
])
return torch.tensor(output) # shape: (锚框个数, 6)
batch_output = []
for b in range(bn):
batch_output.append(MultiBoxDetection_one(cls_prob[b], loc_pred[b], anchor[0], nms_threshold))
return torch.stack(batch_output)
多尺度目标检测
- 如果以图像每个像素为中心都生成锚框,很容易生成过多锚框而造成计算量过大。减少锚框个数的一种简单方法是在输入图像中均匀采样一小部分像素,并以采样的像素为中心生成锚框。此外,在不同尺度下,我们可以生成不同数量和不同大小的锚框。
- 较小目标比较大目标在图像上出现位置的可能性更多。因此,当使用较小锚框来检测较小目标时,我们可以采样较多的区域;而当使用较大锚框来检测较大目标时,我们可以采样较少的区域。