首先展示下改进前后的效果:
改进前 (视频 1)
改进后 (视频 1)
改进前 (视频 2)
改进后 (视频 2)
接下来我将会介绍改进了哪些地方,并且最后会给出改进后的完整代码。
决定机器学习训练效果最关键的因素是什么,是模型吗🥺?并不是,比模型更关键的是数据集的质量😠,即使模型再强大没有足够的数据一样训练不出什么成果。我们来看看前一篇使用的数据集:
https://www.kaggle.com/andrewmvd/face-mask-detection
这个数据集包含了 853 张图片 (部分图片没有使用),其中各个分类的数量如下:
是不是感觉比较少?如果需要自己采集数据,那么就得加班加点多采集一些😕。而这次用的是现成的数据集,那么我们可以去找一找有没有其他数据集可以一起用,还记得介绍 Fast-RCNN 的文章吗?这篇文章用的数据集只包含了人脸区域,没有包含是否戴口罩的标记,但仔细看数据内容会发现图片里面的人脸都没有戴口罩,那么我们可以把这些数据全部当成不戴口罩的区域,一共有 24533 个:
https://www.kaggle.com/vin1234/count-the-number-of-faces-present-in-an-image
加在一起以后:
再仔细看一下,带了口罩但姿势不正确的区域的数量明显太少了,不足以做出正确的判断,我们可以把这些区域全部归到戴口罩的区域里面,也就是只判断你戴口罩,你戴的姿势对不对老子管不着🤬。加在一起以后:
好了,再想想有没有办法可以增加数据量?其实有一个非常简单的方法,把图片左右翻转就可以让数据量变成两倍:
除了左右翻转以外我们还可以使用旋转图片,扩大缩小图片,添加噪点等方式增加数据量。左右翻转以后的最终数据量如下,总数据量大概是原来的 14 倍😱:
读取两个数据集的代码如下(最后会给出完整代码):
# 加载图片和图片对应的区域与分类列表
# { (路径, 是否左右翻转): [ 区域与分类, 区域与分类, .. ] }
# 同一张图片左右翻转可以生成一个新的数据,让数据量翻倍
box_map = defaultdict(lambda: [])
for filename in os.listdir(DATASET_1_IMAGE_DIR):
# 从第一个数据集加载
xml_path = os.path.join(DATASET_1_ANNOTATION_DIR, filename.split(".")[0] + ".xml")
if not os.path.isfile(xml_path):
continue
tree = ET.ElementTree(file=xml_path)
objects = tree.findall("object")
path = os.path.join(DATASET_1_IMAGE_DIR, filename)
for obj in objects:
class_name = obj.find("name").text
x1 = int(obj.find("bndbox/xmin").text)
x2 = int(obj.find("bndbox/xmax").text)
y1 = int(obj.find("bndbox/ymin").text)
y2 = int(obj.find("bndbox/ymax").text)
if class_name == "mask_weared_incorrect":
# 佩戴口罩不正确的样本数量太少 (只有 123),模型无法学习,这里全合并到戴口罩的样本
class_name = "with_mask"
box_map[(path, False)].append((x1, y1, x2-x1, y2-y1, CLASSES_MAPPING[class_name]))
box_map[(path, True)].append((x1, y1, x2-x1, y2-y1, CLASSES_MAPPING[class_name]))
df = pandas.read_csv(DATASET_2_BOX_CSV_PATH)
for row in df.values:
# 从第二个数据集加载,这个数据集只包含没有戴口罩的图片
filename, width, height, x1, y1, x2, y2 = row[:7]
path = os.path.join(DATASET_2_IMAGE_DIR, filename)
box_map[(path, False)].append((x1, y1, x2-x1, y2-y1, CLASSES_MAPPING["without_mask"]))
box_map[(path, True)].append((x1, y1, x2-x1, y2-y1, CLASSES_MAPPING["without_mask"]))
# 打乱数据集 (因为第二个数据集只有不戴口罩的图片)
box_list = list(box_map.items())
random.shuffle(box_list)
print(f"found {len(box_list)} images")
翻转图片的代码如下,同时会翻转区域的 x 坐标 (图片宽度 - 原 x 坐标 - 区域宽度):
for (image_path, flip), original_boxes_labels in box_list:
with Image.open(image_path) as img_original: # 加载原始图片
sw, sh = img_original.size # 原始图片大小
if flip:
img = resize_image(img_original.transpose(Image.FLIP_LEFT_RIGHT)) # 翻转然后缩放图片
else:
img = resize_image(img_original) # 缩放图片
image_index = len(image_tensors) # 图片在批次中的索引值
image_tensors.append(image_to_tensor(img)) # 添加图片到列表
true_boxes_labels = [] # 图片对应的真实区域与分类列表
# 添加真实区域与分类列表
for box_label in original_boxes_labels:
x, y, w, h, label = box_label
if flip: # 翻转坐标
x = sw - x - w
数据量变多以后会需要更多的训练时间,前一篇文章在 GTX1650 显卡上训练大概需要 3 小时,而这一篇则需要 15 小时左右🐍。
我们可以让模型更贴合数据以改进训练效果。在前一篇文章我介绍了 Faster-RCNN 的区域生成网络会根据锚点 (Anchor) 判断图片中的各个部分是否包含对象:
因为 CNN 模型输出矩阵的大小是 通道数量,图片长度/8,图片宽度/8
,也就是每个锚点对应 8x8 像素的区域,区域生成网络需要根据 8x8 像素的区域判断这个区域是否有可能包含对象。这篇使用的代码在处理图片之前会先把图片缩放到 256x192,8x8 的区域相对起来似乎过小了,我们可以把锚点区域扩大到 16x16,使得区域生成网络判断起来有更充分的依据。扩大锚点区域同时需要修改 CNN 模型,使得输出矩阵大小为 通道数量,图片长度/16,图片宽度/16
,这个修改将会在后面介绍。
需要注意的是扩大锚点区域以后会减弱检测小对象的能力,但这篇的图片中的人脸区域基本上都在 16x16 以上,所以不会受到影响。
此外,前一篇还介绍了每个锚点都会对应多个形状:
通过观察数据我们可以发现人脸的长宽比例接近 1:1,并且我们不需要检测人脸以外的东西,所以我们可以删掉长宽比例 1:2 与 2:1 的形状,减少模型的计算量。
总结起来我们可以这样修改生成锚点的参数:
修改前
AnchorSpan = 8 # 锚点之间的距离,应该等于原有长宽 / resnet 输出长宽
AnchorScales = (0.5, 1, 2, 3, 4, 5, 6) # 锚点对应区域的缩放比例列表
AnchorAspects = ((1, 2), (1, 1), (2, 1)) # 锚点对应区域的长宽比例列表
修改后
AnchorSpan = 16 # 锚点之间的距离,应该等于原有长宽 / resnet 输出长宽
AnchorScales = (1, 2, 4, 6, 8) # 锚点对应区域的缩放比例列表
AnchorAspects = ((1, 1),) # 锚点对应区域的长宽比例列表
在这里我们学到了应该根据数据和检测场景来决定锚点区域大小和长宽比例,如果需要检测的物体相对图片都比较大,那么就可以相应的增加锚点区域大小;如果需要检测的物体形状比较固定,那么就可以相应调整长宽比例,例如检测车辆可以用 1:2,检测行人可以用 3:1,检测车牌可以用 1:3 等等。
因为上面修改了锚点之间的距离从 8x8 到 16x16,我们需要把 CNN 模型输出的矩阵大小从 通道数量,图片长度/8,图片宽度/8
修改到 通道数量,图片长度/16,图片宽度/16
,这个修改非常的简单,再加一层卷积层即可。因为这篇使用的是 Resnet 模型,这里会在后面多加一个块,代码如下:
修改前
self.rpn_resnet = nn.Sequential(
nn.Conv2d(3, self.previous_channels_out, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(self.previous_channels_out),
nn.ReLU(inplace=True),
self._make_layer(BasicBlock, channels_out=16, num_blocks=2, stride=1),
self._make_layer(BasicBlock, channels_out=32, num_blocks=2, stride=2),
self._make_layer(BasicBlock, channels_out=64, num_blocks=2, stride=2),
self._make_layer(BasicBlock, channels_out=128, num_blocks=2, stride=2))
修改后
self.rpn_resnet = nn.Sequential(
nn.Conv2d(3, self.previous_channels_out, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(self.previous_channels_out),
nn.ReLU(inplace=True),
self._make_layer(BasicBlock, channels_out=8, num_blocks=2, stride=1),
self._make_layer(BasicBlock, channels_out=16, num_blocks=2, stride=2),
self._make_layer(BasicBlock, channels_out=32, num_blocks=2, stride=2),
self._make_layer(BasicBlock, channels_out=64, num_blocks=2, stride=2),
self._make_layer(BasicBlock, channels_out=128, num_blocks=2, stride=2))
self.cls_resnet
也需要做出同样的修改。
此外为了适应更多的数据量,这里还增加了根据区域截取特征后缩放到的大小:
# 根据区域截取特征后缩放到的大小
self.pooling_size = 16
这样判断分类的时候会使用 通道数量x16x16
,即 128x16x16
的数据。需要注意的是这么做不一定有好处,判断分类使用的数据越大就越有可能发生过拟合现象 (训练集正确率很高但验证集正确率却不行,不能用于识别未知数据),实际需要根据训练结果做出调整。
我们知道区域生成网络会针对各个锚点的各个形状输出是否可能包含对象,输出值越接近 1 那么就越可能包含对象,越接近 0 那么就越不可能包含对象,我们可以把这个输出值当作分数,分数越高代表区域越有可能包含对象。接下来标签分类网络会针对区域生成网络给出的区域进行识别,每个区域的每个分类都会输出一个值,经过 softmax 计算以后得出各个分类的概率 (加起来会等于 1),这个概率也可以拿来作为分数使用。
最终我们可以给 Faster-RCNN 输出的各个包含对象的区域赋予一个分数:
分数 = 区域生成网络输出值 * 最大值(softmax(标签分类网络各个分类输出值))
分数将会介于 0 ~ 1 之间。
原则上分数越高代表模型对这个区域越有把握,我们可以根据这个分数可以用来调整阈值,也可以根据这个分数来更高合并预测结果区域的算法。但实际上你可能会看到分数为 1 但结果是错误的区域,所以只能说原则上。
返回分数的代码请参考后面完整代码的 MyModel.forward
函数中关于 rpn_score
与 cls_score
的部分。
还记得介绍 Fast-RCNN 的文章里面,我提到了合并结果区域的几个方法:
前一篇文章的 Faster-RCNN 模型使用了第三个方法,但上面我们输出分数以后可以选择第二个方法,即先按分数对区域进行排序,然后选择重合的区域中分数最高的区域作为结果,并去除其他重合的区域。这个方法也称作 NMS (Non Max Suppression) 法:
使用这种方法的好处是输出的区域将会更小,看起来更精确,但如果场景是检测障碍物那么最好还是使用第三种方法🤕。
合并预测结果区域的代码如下,这里我把函数写到 MyModel
类里面了:
# 判断是否应该合并重叠区域的重叠率阈值
IOU_MERGE_THRESHOLD = 0.30
# 是否使用 NMS 算法合并区域
USE_NMS_ALGORITHM = True
@staticmethod
def merge_predicted_result(cls_result):
"""合并预测结果区域"""
# 记录重叠的结果区域, 结果是 [ [(标签, 区域, RPN 分数, 标签识别分数)], ... ]
final_result = []
for label, box, rpn_score, cls_score in cls_result:
for index in range(len(final_result)):
exists_results = final_result[index]
if any(calc_iou(box, r[1]) > IOU_MERGE_THRESHOLD for r in exists_results):
exists_results.append((label, box, rpn_score, cls_score))
break
else:
final_result.append([(label, box, rpn_score, cls_score)])
# 合并重叠的结果区域
# 使用 NMS 算法: RPN 分数 * 标签识别分数 最高的区域为结果区域
# 不使用 NMS 算法: 使用所有区域的合并,并且选取数量最多的标签 (投票式)
for index in range(len(final_result)):
exists_results = final_result[index]
if USE_NMS_ALGORITHM:
exists_results.sort(key=lambda r: r[2]*r[3])
final_result[index] = exists_results[-1]
else:
cls_groups = defaultdict(lambda: [])
for r in exists_results:
cls_groups[r[0]].append(r)
most_common = sorted(cls_groups.values(), key=len)[-1]
label = most_common[0][0]
box_merged = most_common[0][1]
for _, box, _, _ in most_common[1:]:
box_merged = merge_box(box_merged, box)
rpn_score_mean = sum(x for _, _, x, _ in most_common) / len(most_common)
cls_score_mean = sum(x for _, _, _, x in most_common) / len(most_common)
final_result[index] = (label, box_merged, rpn_score_mean, cls_score_mean)
return final_result
最后我们修改以下判断是否停止训练的逻辑,之前的判断依据是 验证集的区域生成正确率或标签分类正确率在 20 次训练以后没有更新
则停止训练,但计算标签分类正确率的时候用的是 预测结果中区域范围与实际范围重叠率超过阈值并且分类一致的结果数量 / 实际范围的总数量
,也就是标签分类正确率代表了模型可以找出百分之多少的区域并且正确判断它们的分类,因为标签分类正确率会基于区域生成正确率,所以我们可以只使用标签分类正确率判断是否停止训练。修改以后的判断依据为 验证集的标签分类正确率在 20 次训练以后没有更新
则停止训练。
# 记录最高的验证集正确率与当时的模型状态,判断是否在 20 次训练后仍然没有刷新记录
# 只依据标签分类正确率判断,因为标签分类正确率同时基于 RPN 正确率
if validating_cls_accuracy > validating_cls_accuracy_highest:
validating_rpn_accuracy_highest = validating_rpn_accuracy
validating_rpn_accuracy_highest_epoch = epoch
validating_cls_accuracy_highest = validating_cls_accuracy
validating_cls_accuracy_highest_epoch = epoch
save_tensor(model.state_dict(), "model.pt")
print("highest cls validating accuracy updated")
elif (epoch - validating_rpn_accuracy_highest_epoch > 20 and
epoch - validating_cls_accuracy_highest_epoch > 20):
# 在 20 次训练后仍然没有刷新记录,结束训练
print("stop training because highest validating accuracy not updated in 20 epoches")
break
需要注意的是我给出的计算正确率的方法是比较简单的,更准确的方法是计算 mAP (mean Average Precision),具体可以参考这篇文章,我给出的方法实际只相当于文章中的 Recall
。
如果您发现该资源为电子书等存在侵权的资源或对该资源描述不正确等,可点击“私信”按钮向作者进行反馈;如作者无回复可进行平台仲裁,我们会在第一时间进行处理!
加入交流群
请使用微信扫一扫!