区域卷积神经网络

R-CNN

R-CNN

模型

R-CNN首先从输入图像中选取若干(例如2000个)提议区域,并标注它们的类别和边界框(如偏移量)。然后用卷积神经网络对每个提议区域进行前向传播以抽取其特征。接下来,我们用每个提议区域的特征来预测类别和边界框。

R-CNN的主要思想是利用候选区域生成算法提取可能的物体区域,然后利用卷积神经网络(CNN)提取这些区域的特征,最后通过支持向量机(SVM)进行分类。

  • 候选区域生成(Region Proposals):
    • 使用选择性搜索(Selective Search)算法在图像中生成一系列可能包含物体的候选区域。这些区域通常具有不同的尺寸和形状,旨在覆盖图像中的所有潜在物体。
  • 特征提取(Feature Extraction):
    • 对于每个候选区域,将其缩放到固定大小(如\(227\times227\)像素),然后输入到预训练的卷积神经网络(如AlexNet或VGG16)中提取特征。这一步利用了深度学习在特征表示方面的强大能力。
  • 分类(Classification):
    • 使用支持向量机(SVM)对提取出的特征进行分类,判断每个候选区域属于哪个类别(如人、车、动物等)。每个类别都有对应的SVM分类器。
  • 边框回归(Bounding Box Regression):
    • 为了提高检测框的准确性,RCNN引入了边框回归。通过训练一个回归模型,对候选区域的边框进行微调,使其更接近真实物体的边界。

候选区域生成

目标检测算法通常会在输入图像中采样大量的区域,然后判断这些区域中是否包含我们感兴趣的目标,并调整区域边界从而更准确地预测目标的真实边界框(ground-truth bounding box)。不同的模型使用的区域采样方法可能不同。

锚框

以每个像素为中心,生成多个缩放比和宽高比(aspect ratio)不同的边界框。这些边界框被称为锚框(anchor box)。

锚框是目标检测算法中用于生成候选区域的一种方法,特别是在Faster R-CNN及其后续算法中得到了广泛应用。锚框的生成是基于图像的多个位置和尺度,预定义一系列具有不同尺寸和宽高比的框,这些框作为潜在的物体位置和尺寸的候选。

锚框的生成

  1. 选择基础尺度(Base Size):
    • 定义一个基础尺度,例如\(16\times16\)像素,作为锚框的基准尺度
  2. 定义尺度因子(Scale Factors):
    • 选择一系列尺度因子,例如\(0.5\)\(1.0\)\(2.0\)等,用于生成不同尺度的锚框。每个尺度因子与基础尺度相乘,得到不同大小的锚框。
  3. 定义高宽比(Aspect Ratios):
    • 选择一系列的高宽比,例如\(1:1\)\(1:2\)\(2:1\)等,用于生成不同形状的锚框。不同形状的锚框可以解决目标多尺度的问题(如果锚框只有一种高宽比,则无法适应所有的目标尺寸)
  4. 生成锚框:
    • 对于图像的每个像素点,根据定义的尺度和高宽比,生成一系列锚框。每个锚框的中心点对应于图像的像素点。

锚框的生成公式:

假设图像的某个像素点坐标为\((x, y)\)\(x\)为高位置,\(y\)为宽位置),基础尺度为\(s\)(整数),尺度因子为\(s_k\)(整数),高宽比为\(r\),则生成的锚框坐标可以表示为: \[ \begin{aligned} &x_{\min} = x - \frac{s\cdot s_k\sqrt{r}}{2}\\ &y_{\min} = y - \frac{s\cdot s_k/\sqrt{r}}{2}\\ &x_{\max} = x + \frac{s\cdot s_k\sqrt{r}}{2}\\ &y_{\max} = y + \frac{s\cdot s_k/\sqrt{r}}{2} \end{aligned} \] 由此可得锚框的高宽比和锚框的像素:

高宽比: \[ \frac{x_{\max} - x_{\min}}{y_{\max} - y_{\min}} = \frac{s\cdot s_k\sqrt{r}}{s\cdot s_k/\sqrt{r}} = r \] 像素: \[ (x_{\max}-x_{\min})(y_{\max}-y_{\min}) = (s\cdot s_k\sqrt{r})(s\cdot s_k/\sqrt{r}) = s^2s_k^2 \] 锚框的生成一般是以特征图的每个像素为中心生成的,而不是以原始图像的每个像素为中心进行生成的。如果以原始图像的每个像素为中心生成锚框,会导致产生的锚框数量过多的问题,并且以原始图像生成的锚框映射到特征图上后会产生大量的重复,导致过多的冗余计算。一般我们会以原始图像经过CNN后的特征图(尺寸更小)的每个像素为中心生成锚框,这样产生的锚框数量会更少,锚框之间也具有差异性,不会导致冗余计算。

因为锚框的生成是以特征图的每个像素为中心的,因此在原图像上就需要设置一个下采样,为了将特征图上的位置信息准确地映射回原始图像,需要一个比例因子,这个比例因子就是下采样步长。一般下采样stride设置为:

1
stride = image_size[0] / feature_map_size[0]

其中image_size为原始图像尺寸,feature_map_size为特征图尺寸。这种计算下采样步长的方式是一种简单而一致的方法,保证了从原始图像到特征图的转换过程中的比例关系是均匀的。它基于这样一个假设:每个卷积层和池化层的操作在水平和垂直方向上的下采样比例是相同的。当网络结构设计中使用了相同的卷积核大小、填充和步长时,最终得到的特征图在两个维度上的下采样比例是相同的,因此可以使用 image_size[0] / feature_map_size[0]image_size[1] / feature_map_size[1] 来计算步长,并且两者结果相同。

在原图像上,两个水平或竖直相邻的、大小形状相同的锚框像素中心的距离就是下采样步长stride

代码:

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
import torch

def generate_anchor_boxes(image_size, feature_map_size, base_size, scales, aspect_ratios, stride):
"""
生成锚框。

:param image_size: 原始图像的尺寸,例如(Height, Width)
:param feature_map_size: 特征图的尺寸,例如(Height, Width)
:param base_size: 基础尺度
:param scales: 尺度因子列表
:param aspect_ratios: 长宽比列表
:param stride: 特征图相对于原始图像的下采样步长
:return: 生成的锚框,形状为(N, 4),其中N是锚框的总数,4代表(x_min, y_min, x_max, y_max)
"""
anchors = []
for i in range(feature_map_size[0]):
for j in range(feature_map_size[1]):
# 加0.5的目的是使得生成的锚框不会以像素的边缘为中心,而是以该像素的中心位置为中心
x_center = (j + 0.5) * stride
y_center = (i + 0.5) * stride
for scale in scales:
for aspect_ratio in aspect_ratios:
h = base_size * scale * torch.sqrt(torch.tensor(aspect_ratio))
w = base_size * scale / torch.sqrt(torch.tensor(aspect_ratio))
x1 = x_center - w / 2
y1 = y_center - h / 2
x2 = x_center + w / 2
y2 = y_center + h / 2
anchors.append([x1, y1, x2, y2])
return torch.tensor(anchors)

# 示例参数
image_size = (800, 800) # 假设原始图像尺寸为800x800
feature_map_size = (50, 50) # 假设特征图尺寸为50x50
base_size = 16 # 基础尺度
scales = [0.5, 1.0, 2.0] # 尺度因子
aspect_ratios = [1/3, 1/2, 1, 2, 3] # 长宽比
stride = image_size[0] / feature_map_size[0] # 下采样步长

# 生成锚框
anchor_boxes = generate_anchor_boxes(image_size, feature_map_size, base_size, scales, aspect_ratios, stride)

# 打印生成的锚框
print(anchor_boxes)

示例:

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
77
78
79
80
81
82
import torch
from torchvision import transforms
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from PIL import Image


def generate_anchor_boxes(image_size, feature_map_size, base_size, scales, aspect_ratios, stride, selected_pixels):
"""
生成锚框。

:param image_size: 原始图像的尺寸,例如(Height, Width)
:param feature_map_size: 特征图的尺寸,例如(Height, Width)
:param base_size: 基础尺度
:param scales: 尺度因子列表
:param aspect_ratios: 长宽比列表
:param stride: 特征图相对于原始图像的下采样步长
:param selected_pixels: 选定的像素点列表,元素为 (i, j) 形式,表示特征图上的位置
:return: 生成的锚框,形状为(N, 4),其中N是锚框的总数,4代表(x_min, y_min, x_max, y_max)
"""
anchors = []
for i, j in selected_pixels:
# 加0.5的目的是使得生成的锚框不会以像素的边缘为中心,而是以该像素的中心位置为中心
x_center = (j + 0.5) * stride
y_center = (i + 0.5) * stride
for scale in scales:
for aspect_ratio in aspect_ratios:
h = base_size * scale * torch.sqrt(torch.tensor(aspect_ratio))
w = base_size * scale / torch.sqrt(torch.tensor(aspect_ratio))
x1 = x_center - w / 2
y1 = y_center - h / 2
x2 = x_center + w / 2
y2 = y_center + h / 2
anchors.append([x1, y1, x2, y2])
return torch.tensor(anchors)


def draw_anchors(image_path, base_size, scales, aspect_ratios, selected_pixels):
"""
在图像上绘制锚框。

:param image_path: 输入图像的路径
:param base_size: 基础尺度
:param scales: 尺度因子列表
:param aspect_ratios: 长宽比列表
:param selected_pixels: 选定的像素点列表,元素为 (i, j) 形式,表示特征图上的位置
"""
# 打开图像并获取其尺寸
image = Image.open(image_path)
image_size = image.size

# 此处我们默认输入图像的宽高相等,方便设置宽高相等的特征图尺寸
if image_size[0] != image_size[1]:
size = max(image_size[0], image_size[1])
resize = transforms.Resize((size, size))
image = resize(image)
image_size = image.size
feature_map_size = (50, 50) # 假设特征图尺寸为50x50
stride = image_size[0] / feature_map_size[0] # 下采样步长

# 生成锚框
anchor_boxes = generate_anchor_boxes(image_size, feature_map_size, base_size, scales, aspect_ratios, stride, selected_pixels)

fig, ax = plt.subplots(1)
ax.imshow(image)
for box in anchor_boxes:
x1, y1, x2, y2 = box
rect = patches.Rectangle((x1, y1), x2 - x1, y2 - y1, linewidth=2, edgecolor='r', facecolor='none')
ax.add_patch(rect)
plt.show()


# 示例参数
base_size = 160 # 基础尺度
scales = [0.5, 1.0, 2.0] # 尺度因子
aspect_ratios = [1 / 2, 1, 2] # 长宽比
# 只选择几个像素点,例如 (0,0), (25,25), (49,49)
selected_pixels = [(0,0), (25,25), (49,49)]


image_path = '屏幕截图 2025-01-17 152054.png'
draw_anchors(image_path, base_size, scales, aspect_ratios, selected_pixels)

结果:

锚框的筛选

并不是每个锚框都能够较好的覆盖图像中的物体。想要判定一个锚框是否能够较好地覆盖图像中的物体,可以衡量锚框和真实边界框的相似性。杰卡德系数(Jaccard)可以衡量两个集合之间的相似性:给定集合\(\mathcal{A}\)\(\mathcal{B}\),它们的杰卡德系数是它们交集的大小除以它们并集的大小: \[ J(\mathcal{A}, \mathcal{B}) = \frac{|\mathcal{A}\cap\mathcal{B}|}{|\mathcal{A}\cup\mathcal{B}|} \] 对于两个边界框,它们的杰卡德系数通常称为交并比(intersection over union, IoU),即两个边界框相交面积与相并面积之比。交并比的取值范围在0和1之间:0表示两个边界框无重合像素,1表示两个边界框完全重合。

IoU

我们可以使用交并比来衡量锚框和真实边界框、以及不同锚框之间的相似度

代码:

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
import torch

# 运行代码时,调用 iou(box1, box2) 函数将返回一个 (N, M) 的 IoU 矩阵,其中 iou_matrix[i][j] 表示 box1[i] 和 box2[j] 之间的 IoU 值
def iou(box1, box2):
"""
计算两个锚框之间的交并比(IoU)。

:param box1: 第一个锚框,形状为 (N, 4),其中 4 代表 (x_min, y_min, x_max, y_max)
:param box2: 第二个锚框,形状为 (M, 4),其中 4 代表 (x_min, y_min, x_max, y_max)
:return: IoU 矩阵,形状为 (N, M),存储 box1 中每个锚框与 box2 中每个锚框之间的 IoU 值
"""
# 获取 box1 的边界
box1_x1, box1_y1, box1_x2, box1_y2 = box1[:, 0], box1[:, 1], box1[:, 2], box1[:, 3]
# 获取 box2 的边界
box2_x1, box2_y1, box2_x2, box2_y2 = box2[:, 0], box2[:, 1], box2[:, 2], box2[:, 3]

# 计算交集的边界
intersection_x1 = torch.max(box1_x1.unsqueeze(1), box2_x1)
intersection_y1 = torch.max(box1_y1.unsqueeze(1), box2_y1)
intersection_x2 = torch.min(box1_x2.unsqueeze(1), box2_x2)
intersection_y2 = torch.min(box1_y2.unsqueeze(1), box2_y2)

# 计算交集的面积
intersection_area = torch.clamp(intersection_x2 - intersection_x1, min=0) * torch.clamp(intersection_y2 - intersection_y1, min=0)

# 计算 box1 和 box2 的面积
box1_area = (box1_x2 - box1_x1) * (box1_y2 - box1_y1)
box2_area = (box2_x2 - box2_x1) * (box2_y2 - box2_y1)

# 计算并集的面积
union_area = box1_area.unsqueeze(1) + box2_area - intersection_area

# 计算 IoU
iou_matrix = intersection_area / union_area
return iou_matrix


# 示例使用
if __name__ == "__main__":
box1 = torch.tensor([[0, 0, 10, 10], [5, 5, 15, 15]])
box2 = torch.tensor([[5, 5, 15, 15], [20, 20, 30, 30]])
iou_matrix = iou(box1, box2)
print(iou_matrix)

在训练集中标注锚框

在训练集中,我们将每个锚框视为一个训练样本。为了训练目标检测模型,我们需要将每个锚框的类别(class)和偏移量(offset)标签,其中前者是与锚框相关的对象的类别,后者是锚框相对于真实边界框的偏移量。在预测时,我们为每个图像生成多个锚框,预测所有锚框的类别和偏移量,根据预测的偏移量调整它们的位置以获得预测的边界框,最后只输出符合特定条件的预测边界框。

目标检测训练集带有真实边界框的位置及其包围物体类别的标签。要标记任何生成的锚框,我们可以参考分配到的最接近此锚框的真实边界框的位置和类别标签。

将真实边界框分配给锚框

给定图像,假设锚框是\(A_1, A_2, \cdots, A_{n_a}\),真实边界框是\(B_1, B_2, \cdots, B_{n_b}\),其中\(n_a\geq n_b\)。定义一个矩阵\(\mathbf{X}\in\mathbb{R}^{n_a\times n_b}\),其中第\(i\)行、第\(j\)列的元素\(x_{ij}\)是锚框\(A_i\)和真实边界框\(B_j\)的IoU。该算法包含以下步骤:

  1. 在矩阵\(\mathbf{X}\)中找到最大的元素,并将它的行索引和列索引分别表示为\(i_1\)\(j_1\)。然后将真实边界框\(B_{j_1}\)分配给锚框\(A_{i_1}\)(因为\(A_{i_1}\)\(B_{j_1}\)是所有锚框和真实边界框配对中最相近的)。在第一个分配完成后,丢弃矩阵中\(i_1\)行和\(j_1\)列中的所有元素。
  2. 在矩阵\(\mathbf{X}\)中找到剩余元素中最大的元素,并将它的行索引和列索引分别表示为\(i_2\)\(j_2\)。将真实边界框\(B_{j_2}\)分配给锚框\(A_{i_2}\),并丢弃矩阵中\(i_2\)行与\(j_2\)列中的所有元素。
  3. 此时,矩阵\(\mathbf{X}\)中两行和两列中的元素已被丢弃。继续,直到丢弃掉矩阵\(\mathbf{X}\)\(n_b\)列中的所有元素。此时已经为这\(n_b\)个锚框各自分配了一个真实边界框。
  4. 只遍历剩下的\(n_a-n_b\)个锚框。例如,给定任何锚框\(A_i\),在矩阵\(\mathbf{X}\)的第\(i\)行中找到与\(A_i\)的IoU的最大的真实边界框\(B_j\),只有当此IoU大于预定义的阈值时,才将\(B_j\)分配给\(A_i\)

如果锚框未被分配到真实边界框,此锚框为背景。

将真实边界框分配给锚框

代码:

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
import torch


def iou(box1, box2):
"""
计算两个锚框之间的交并比(IoU)。

:param box1: 第一个锚框,形状为 (N, 4),其中 4 代表 (x_min, y_min, x_max, y_max)
:param box2: 第二个锚框,形状为 (M, 4),其中 4 代表 (x_min, y_min, x_max, y_max)
:return: IoU 矩阵,形状为 (N, M)
"""
box1_x1, box1_y1, box1_x2, box1_y2 = box1[:, 0], box1[:, 1], box1[:, 2], box1[:, 3]
box2_x1, box2_y1, box2_x2, box2_y2 = box2[:, 0], box2[:, 1], box2[:, 2], box2[:, 3]

intersection_x1 = torch.max(box1_x1.unsqueeze(1), box2_x1)
intersection_y1 = torch.max(box1_y1.unsqueeze(1), box2_y1)
intersection_x2 = torch.min(box1_x2.unsqueeze(1), box2_x2)
intersection_y2 = torch.min(box1_y2.unsqueeze(1), box2_y2)

intersection_area = torch.clamp(intersection_x2 - intersection_x1, min=0) * torch.clamp(intersection_y2 - intersection_y1, min=0)
box1_area = (box1_x2 - box1_x1) * (box1_y2 - box1_y1)
box2_area = (box2_x2 - box2_x1) * (box2_y2 - box2_y1)
union_area = box1_area.unsqueeze(1) + box2_area - intersection_area
iou_matrix = intersection_area / union_area
return iou_matrix


def assign_anchors_to_gt(anchor_boxes, gt_boxes, iou_threshold=0.5):
"""
将真实边界框分配给锚框。

:param anchor_boxes: 锚框,形状为 (N, 4),其中 4 代表 (x_min, y_min, x_max, y_max)
:param gt_boxes: 真实边界框,形状为 (M, 4),其中 4 代表 (x_min, y_min, x_max, y_max)
:param iou_threshold: 交并比阈值,用于判断是否分配
:return: 分配结果,形状为 (N,),存储每个锚框对应的真实边界框的索引,-1 表示未分配
"""
num_anchors = anchor_boxes.shape[0]
num_gt_boxes = gt_boxes.shape[0]
# 计算 IoU 矩阵
iou_matrix = iou(anchor_boxes, gt_boxes)
assignment = torch.full((num_anchors,), -1, dtype=torch.long)
# 先分配 n_b 个锚框
for _ in range(min(num_anchors, num_gt_boxes)):
i, j = torch.where(iou_matrix == torch.max(iou_matrix))
i, j = i[0], j[0]
assignment[i] = j
iou_matrix[i, :] = -1
iou_matrix[:, j] = -1
# 再分配剩下的锚框
if num_anchors > num_gt_boxes:
for i in range(num_anchors):
if assignment[i] == -1:
max_iou_index = torch.argmax(iou_matrix[i, :])
if iou_matrix[i, max_iou_index] >= iou_threshold:
assignment[i] = max_iou_index
return assignment


# 示例使用
if __name__ == "__main__":
anchor_boxes = torch.tensor([[0, 0, 10, 10], [5, 5, 15, 15], [20, 20, 30, 30], [30, 30, 40, 40]])
gt_boxes = torch.tensor([[2, 2, 12, 12], [22, 22, 32, 32]])
assignment = assign_anchors_to_gt(anchor_boxes, gt_boxes)
print(assignment)

标记类别和偏移量

现在我们可以为每个锚框标记类别和偏移量。假设一个锚框\(A\)被分配了一个真实边框\(B\)。一方面,锚框\(A\)的类别将被标记为与\(B\)相同;另一方面,锚框\(A\)的偏移量将根据\(B\)\(A\)中心坐标的相对位置以及这两个框的相对大小进行标记。

为什么不直接使用锚框与边界框左上与右下坐标的差直接进行回归:直接使用左上和右下坐标,会对锚框的大小非常敏感,对于大的锚框和小的锚框,同样的坐标差代表的相对变化是不同的。例如,对于一个大的锚框\((0, 0, 100, 100)\)和一个小的锚框\((0, 0, 10, 10)\),如果真实边框相对于它们的偏移都是\((5, 5)\),其意义是不同的。对于大锚框,这个偏移相对较小,但对于小锚框,这个偏移就很大。使用这种方式无法对不同大小的锚框进行统一的回归处理,模型难以学到通用的规律。

偏移量包含四个部分: \[ \begin{aligned} &\Delta x = \frac{(x_{gt}-x_a)}{w_a}\\ &\Delta y = \frac{y_{gt}-y_a}{h_a}\\ &\Delta w = \log\Big(\frac{w_{gt}}{w_a}\Big)\\ &\Delta h = \log\Big(\frac{h_{gt}}{h_a}\Big) \end{aligned} \] 其中\((x_a, y_a)\)为锚框的中心位置,\((w_a, h_a)\)为锚框的宽度和高度;\((x_{gt}, y_{gt})\)为真实边界框的中心位置,\((w_{gt}, h_{gt})\)为真实边界框的宽度和高度。

\(\Delta x\)\(\Delta y\)为中心位置偏移量,这两个偏移量表示真实边界框的中心相对于锚框中心在水平和垂直方向上的相对偏移量。通过除以锚框的宽度和高度进行归一化,可以使偏移量不受锚框尺寸的影响,使模型能够更好地学习不同大小锚框的中心位置调整,同时也有利于模型学习不同尺度的目标。

\(\Delta w\)\(\Delta h\)为宽度和高度的缩放偏移量。为什么使用对数函数而不是直接相比:

  • 尺度不变性:对数函数可以将乘法关系转换为加法关系,这在处理不同尺度的边界框时非常有用。例如,如果边界框的宽度或者高度增加一倍,其对数值将增加一个固定的量,而不是翻倍。这有助于模型在学习过程中更加稳定
  • 对称性:对于同样比例的增加和减少,其对数值的变化是相同的。如果直接使用比例,当\(w_a\)很小时,\(w_a\)稍微变化就会对\(w_{gt}/w_a\)的值产生很大的影响;而\(w_a\)很大时,\(w_a\)就算变化幅度比较大,对\(w_{gt}/w_a\)值的变化的影响也很小。

如果一个锚框没有被分配真实边界框,我们只需要将锚框的类别标记为背景(background)。背景类别的锚框通常被称为负类锚框,其余的被称为正类锚框。负类锚框无需进行回归。

使用非极大值抑制预测边界框

在预测时,我们先为图像生成多个锚框,再为这些锚框一一预测类别和偏移量。一个预测好的边界框则根据其中某个带有预测偏移量的锚框而生成。

当有许多锚框时,可能会输出许多相似的具有明显重叠的预测边界框,都围绕着同一目标。为了简化输出,我们可以使用非极大值抑制(non-maximum suppression, NMS)合并属于同一目标的类似的预测边界框。

对于一个预测边界框\(B\),目标检测模型会计算每个类别的预测概率。假设最大的预测概率为\(p\),则该概率所对应的类别即为\(B\)的预测类别。具体来说,我们将\(p\)称为预测边界框\(B\)的置信度(confidence)。在同一张图像中,所有的预测的非背景边框都按置信度降序排序,以生成列表\(L\)。然后我们通过以下步骤操作排序列表\(L\)

  1. \(L\)中选取置信度最高的预测边界框\(B_1\)作为基准,然后将所有与\(B_1\)的IoU超过预定阈值\(\epsilon\)的非基准预测边界框从\(L\)中移除。这时,\(L\)保留了置信度最高的预测边界框,去除了与其太过相似的其他预测边界框。简而言之,那些具有非极大值置信度的边界框被抑制了。
  2. \(L\)中选取置信度第二高的预测边界框\(B_2\)作为又一个基准,然后将所有与\(B_2\)的IoU大于\(\epsilon\)的非基准预测边界框从\(L\)中移除。
  3. 重复上述过程,直到\(L\)中的所有预测边界框都曾被用作基准。此时,\(L\)中任意一对预测边界框的IoU都小于阈值\(\epsilon\);因此,没有一对边界框过于相似。
  4. 输出列表\(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
import torch

def torch_non_max_suppression(boxes, scores, iou_threshold):
"""
此函数使用 PyTorch 实现非极大值抑制
:param boxes: 形状为 [N, 4] 的张量,其中 N 是边界框的数量,每个边界框的格式为 [x1, y1, x2, y2]
:param scores: 形状为 [N] 的张量,表示每个边界框的置信度得分
:param iou_threshold: 交并比阈值,用于判断是否抑制边界框
:return: 保留的边界框的索引列表
"""
# 确保输入是 PyTorch 张量
boxes = torch.as_tensor(boxes, dtype=torch.float32)
scores = torch.as_tensor(scores, dtype=torch.float32)
keep = []
if boxes.numel() == 0: # 如果没有边界框,直接返回空列表
return keep
x1 = boxes[:, 0]
y1 = boxes[:, 1]
x2 = boxes[:, 2]
y2 = boxes[:, 3]
area = (x2 - x1) * (y2 - y1)
_, order = scores.sort(0, descending=True) # 按照置信度得分降序排序
while order.numel() > 0:
if order.numel() == 1: # 当只有一个边界框时,直接添加并返回
keep.append(order.item())
break
index = order[0].item()
keep.append(index)
# 计算当前边界框与其他边界框的交并比
xx1 = torch.max(x1[order[0]], x1[order[1:]])
yy1 = torch.max(y1[order[0]], y1[order[1:]])
xx2 = torch.min(x2[order[0]], x2[order[1:]])
yy2 = torch.min(y2[order[0]], y2[order[1:]])
w = torch.clamp(xx2 - xx1, min=0.0)
h = torch.clamp(yy2 - yy1, min=0.0)
intersection = w * h
iou = intersection / (area[order[0]] + area[order[1:]] - intersection)
# 抑制交并比大于阈值的边界框
mask = (iou <= iou_threshold).nonzero().squeeze()
order = order[mask + 1]
# 由于 mask 中的索引是相对于 iou 张量的,而 iou 张量是通过 area[order[1:]] 计算得到的,这意味着 iou 张量是从 order 张量的第 1 个元素开始计算的(排除了当前最高置信度的边界框),因此,为了从 order 张量中正确地选取元素,需要将 mask 的索引加 1
return keep

if __name__ == "__main__":
boxes = torch.tensor([[10, 10, 50, 50], [30, 30, 60, 60], [40, 40, 70, 70], [200, 200, 300, 300]])
scores = torch.tensor([0.9, 0.8, 0.7, 0.6])
iou_threshold = 0.5
keep_indices = torch_non_max_suppression(boxes, scores, iou_threshold)
print("保留的边界框索引:", keep_indices)

非极大值抑制的问题和改进方案

在传统的非极大值抑制(NMS)中,当两个边界框的交并比(IoU)超过设定的阈值时,具有较低置信度得分的边界框会被直接抑制(置零或删除)。然而,这种硬抑制方式可能会导致一些问题,尤其是当多个目标的边界框相互重叠时,可能会错误地删除一些真正的目标。

Soft-NMS是对传统NMS的改进,旨在解决这个问题:Soft-NMS的核心思想是不直接将与高分边界框重叠度高的边界框的置信度置零或删除,而是根据它们与高分边界框的IoU来降低它们的置信度得分。这样,即使一个边界框与高分边界框有较高的重叠度,它仍然有机会被保留,只是其置信度会根据重叠成都而降低,避免了过度抑制。

算法步骤:

设边界框集合\(B = {b_i}\),每个边界框\(b_i = (x_1, y_1, x_2, y_2)\),其中\((x_1, y_1)\)是左上角坐标,\((x_2, y_2)\)是右下角坐标。相应的置信度得分集合\(S = {s_i}\)\(N_t\)为IoU阈值。

  1. 初始化最终的边界框集合\(D\)为空集

  2. 选择置信度得分最高的边界框\(M\),将其从\(B\)中移除并添加到\(D\)

  3. 对于\(B\)中剩余的每个边界框\(b_i\),计算其与\(M\)的IoU值:\(\mathrm{IoU} = \mathrm{IoU}(M, b_i)\)

  4. 根据得分衰减函数更新\(b_i\)的置信度得分\(s_i\)。常见的得分衰减函数有两种:

    • 线性函数: \[ s_i = \left\{ \begin{aligned} &s_i\quad&\mathrm{IoU}(M, b_i)< N_t\\ &s_i(1-\mathrm{IoU}(M, b_i))\quad&\mathrm{IoU}(M, b_i)\geq N_t \end{aligned} \right. \]

    • 高斯函数: \[ s_i = s_ie^{-\frac{\mathrm{IoU}(M, b_i)^2}{\sigma}} \] 其中\(\sigma\)是一个超参数,通常设置为0.3

  5. 重复步骤2-4,直到\(B\)为空集

在某些情况下,高斯函数可能会比线性函数提供更高的召回率,因为它不会像线性函数那样快速地将边界框的置信度降为零,从而保留了更多可能是目标的边界框,尤其在目标密集或有遮挡的情况下。线性函数的计算通常更简单,因为它只涉及基本的乘法和减法运算;而高斯函数需要计算指数运算,计算开销相对较大,但对于现代硬件和大多数目标检测任务来说,这种差异通常是可以接受的。

代码:

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
import torch


def soft_nms(boxes, scores, iou_threshold=0.3, sigma=0.5, score_threshold=0.001, method='gaussian'):
"""
实现 Soft-NMS 算法
:param boxes: 形状为 [N, 4] 的张量,存储边界框,格式为 [x1, y1, x2, y2]
:param scores: 形状为 [N] 的张量,存储边界框的置信度得分
:param iou_threshold: 交并比阈值
:param sigma: 高斯函数的标准差
:param score_threshold: 最终保留边界框的最低置信度得分
:param method: 得分衰减函数的类型,'linear' 或 'gaussian'
:return: 保留的边界框和对应的得分
"""
# 确保输入是 PyTorch 张量
boxes = torch.as_tensor(boxes, dtype=torch.float32)
scores = torch.as_tensor(scores, dtype=torch.float32)
keep = []
while scores.numel() > 0:
# 找到得分最高的边界框
max_score_index = torch.argmax(scores)
max_score_box = boxes[max_score_index]
keep.append(max_score_index)
# 计算 IoU
ious = calculate_iou(max_score_box.unsqueeze(0), boxes)
# 抑制
if method == 'linear':
scores[ious[0] >= iou_threshold] *= (1 - ious[0][ious[0] >= iou_threshold])
elif method == 'gaussian':
scores *= torch.exp(-(ious[0] * ious[0]) / sigma)
# 移除得分低于阈值的边界框
mask = scores > score_threshold
boxes = boxes[mask]
scores = scores[mask]
final_boxes = boxes[keep]
final_scores = scores[keep]
return final_boxes, final_scores


def calculate_iou(box, boxes):
"""
计算一个边界框和多个边界框的交并比
:param box: 形状为 [1, 4] 的张量,单个边界框
:param boxes: 形状为 [N, 4] 的张量,多个边界框
:return: 形状为 [N] 的张量,存储 IoU 值
"""
x1 = torch.max(box[:, 0], boxes[:, 0])
y1 = torch.max(box[:, 1], boxes[:, 1])
x2 = torch.min(box[:, 2], boxes[:, 2])
y2 = torch.min(box[:, 3], boxes[:, 3])
intersection_area = torch.clamp(x2 - x1, min=0) * torch.clamp(y2 - y1, min=0)
box_area = (box[:, 2] - box[:, 0]) * (box[:, 3] - box[:, 1])
boxes_area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
iou = intersection_area / (box_area + boxes_area - intersection_area)
return iou

if __name__ == "__main__":
boxes = torch.tensor([[10, 10, 50, 50], [30, 30, 60, 60], [40, 40, 70, 70], [200, 200, 300, 300]])
scores = torch.tensor([0.9, 0.8, 0.7, 0.6])
final_boxes, final_scores = soft_nms(boxes, scores)
print("保留的边界框:", final_boxes)
print("保留的得分:", final_scores)

Fast R-CNN

模型

Fast R-CNN
Fast R-CNN
Fast R-CNN

Fast R-CNN通过卷积神经网络对原图像进行特征提取,得到通道更多但宽高更小的特征图。候选区域的生成:会以特征图的每个像素为中心生成多个尺度的候选区域,需要注意的是此时生成的候选区域依旧是在原图上表示的,然后将这些候选区域通过RoI Pooling层(兴趣区域池化层,Region of Interest)变为固定大小的特征表示,比如将这些候选区域通过池化层变为\(7\times7\)的特征图。

经过RoI池化后,这些由候选区域生成的特征图继续被展平为一维向量,通过一个或多个全连接层,进一步提取特征信息。

最后,我们需要对这些候选区域进行类别预测和边框回归,在全连接层后面,分别接两个线性层,其中一个用于Softmax回归进行类别预测,另一个用于预测每个类别的边界框位置偏移量。

注意:对于边界框的预测,由于每个候选框都可能被分成背景类或者某个目标类别,对于每个候选区域,在进行边框回归时都将其对应的各个类别都进行回归,然后选取其所属于的那个类别的回归预测。也就是说,如果有num_classes个分类,全连接层的输出维度为n,那么类别预测就应该是nn.Linear(n, num_classes),边框回归预测就应该是nn.Linear(n, num_classes * 4),即一个锚框对每个类别都预测其偏移位置\(\Delta x\)\(\Delta y\)\(\Delta w\)\(\Delta h\)

一个图像上的每一个候选框都可以视为一个训练样本

ROI Pooling

ROI Pooling(Region of Interest Pooling)是一种在对象检测任务中使用的特殊池化操作,它的作用是从特征图中提取出于输入图像中特定区域(即感兴趣区域,Region of Insterest)相对应的特征图块,并将这些块池化到固定大小的特征图上,以便后续的分类器可以使用这些特征进行分类。

ROI Pooling接受两个输入,一个是卷积神经网络提取的特征图,另一个是感兴趣区域的坐标。这些坐标通常是在输入图像的原始像素空间中定义的,并且于特征图的空间位置相对应。

实现步骤:

  1. 将ROI映射到特征图空间:

    • 由于ROIs是在原始图像上定义的,需要将其位置\((x_1, y_1, x_2, y_2)\)(其中\((x_1, y_1)\)是ROI在原始图像的左上角坐标,\((x_2, y_2)\)是ROI在原始图像的右下角坐标)映射到特征图空间。假设原始图像的尺寸为\(H_{\mathrm{img}}\times W_{\mathrm{img}}\),特征图的尺寸为\(H\times W\),则映射公式为: \[ \begin{aligned} &x_1' = \bigg\lfloor x_1\times\frac{H}{H_{\mathrm{img}}}\bigg\rfloor\\ &y_1' = \bigg\lfloor y_1\times\frac{W}{W_{\mathrm{img}}}\bigg\rfloor\\ &x_2' = \bigg\lceil x_2\times\frac{H}{H_{\mathrm{img}}}\bigg\rceil\\ &y_2' = \bigg\lceil y_2\times\frac{W}{W_{\mathrm{img}}}\bigg\rceil \end{aligned} \] 这里使用向下取整和向上取整是为了确保ROI完全覆盖对应的区域。
  2. 划分池化区域:

    • 将映射后的ROI区域\((x_1', y_1', x_2', y_2')\)划分为\(h\times w\)份,其中\(h\)\(w\)均为定值,对每一份均使用最大池化得到其中最大的那个元素.

    • 划分方法:每份的宽和高: \[ \begin{aligned} &w_{\mathrm{bin}} = \Big\lfloor\frac{x_2'-x_1'}{w}\Big\rfloor\\ &b_{\mathrm{bin}} = \Big\lfloor\frac{y_2'-y_1'}{h}\Big\rfloor \end{aligned} \] 如果\(\frac{x_2'-x_1'}{w}\)或者\(\frac{y_2'-y_1'}{h}\)无法整除,则在最后一份的宽或者高将余下的部分加上。

    • 对于一个\(c\)通道的特征图,对每个通道都进行相同的池化操作,最终得到一个\(c\times h\times w\)的池化后特征图

代码实现:

1
2
import torchvision
torchvision.ops.roi_pool(input: Tensor, boxes: Union[Tensor, List[Tensor]], output_size: None, spatial_scale: float = 1.0)
  • input:特征图,为原始图像经过CNN提取特征后的张量,形状为Tensor[N, C, H, W]
  • boxes:ROI坐标,形状为Tensor[K, 5]或者List[Tensor[L, 4]]
    • Tensor[K, 5]K表示ROI的数量,5这一维度包含了两个重要信息:图像索引和ROI的坐标信息。
    • List[Tensor[L, 4]]:列表中的每个元素对应着批量中一张图像的ROI信息
  • output_size:指定池化后的尺寸,格式为intTurple[int, int],以(高度, 宽度)表示
  • spatical_scale:将原始图像上的roi映射到特征图上的缩放因子。例如,如果原图像是\(224\times224\)的尺寸,特征图的尺寸是\(112\times112\),需要将spatical_scale设置为0.5(原始图像缩放 0.5x 的结果)。上面的\(\frac{H}{H_\mathrm{img}}\)就是这里的spatical_scale

原图像经过CNN特征提取后得到的特征图:

原图像经过CNN后得到的特征图

将原图像上的ROI映射到特征图上:

将原图像上的ROI映射到特征图上

\(h=2, w=2\),将映射后的ROI划分为\(2\times2\)份:

在特征图上划分ROI

对每个部分做max pooling:

对每个部分做max pooling

整体过程如下:

ROI Pooling

代码

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
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
from torchvision.models import vgg16
from torch.utils.data import DataLoader
from torchvision.datasets import CocoDetection
from torchvision.transforms import transforms

class FastRCNN(nn.Module):
def __init__(self, num_classes):
super().__init__()
self.base_model = vgg16(pretrained=True)
self.base_model = nn.Sequential(*list(self.base_model.features)[:-1])
self.roi_pool = torchvision.ops.roi_pool()
self.flatten = nn.Flatten()
self.fc = nn.Sequential(
nn.Linear(512 * 7 * 7, 4096),
nn.ReLU(),
nn.Linear(4096, 4096),
nn.ReLU()
)
self.cls_score = nn.Linear(4096, num_classes)
self.bbox_pred = nn.Linear(4096, num_classes * 4)

def forward(self, x, rois):
# rois为感兴趣区域,需要传入
features = self.base_model(x)
# 对每个ROI进行ROI池化
pooled_feats = []
for roi in rois:
roi_feat = self.roi_pool(features, [roi], output_size=(7, 7))
pooled_feats.append(roi_feat)
pooled_feats = torch.cat(pooled_feats)
pooled_feats = self.flatten(pooled_feats)
fc_feats = self.fc(pooled_feats)
cls_score = self.cls_score(fc_feats)
bbox_pred = self.bbox_pred(fc_feats)
return cls_score, bbox_pred

Faster R-CNN

Faster R-CNN

相比于Fast R-CNN,Faster R-CNN引入了RPN(Region Proposal Network),通过神经网络自动生成候选区域。

Faster R-CNN首先通过预训练的卷积神经网络(如ResNet-18、VGG-16)提取图像特征,然后将特征分别传入ROI Pooling和RPN层:

对于RPN层,将传入的特征图通过一个\(3\times3\)的Same卷积,生成中间特征,再对该中间特征分别使用两个\(1\times1\)的卷积分支,


区域卷积神经网络
https://blog.shinebook.net/2025/03/15/人工智能/理论基础/深度学习/区域卷积神经网络/
作者
X
发布于
2025年3月15日
许可协议