目录
摘要
本周主要学习了R-CNN的整个系列,详细学习了R-CNN、Fast R-CNN、Faster R-CNN的原理。本篇博客将会详细解释上述3个模型的异同,以及通过哪些方面的改进使初版R-CNN的检测速度越来越快的。同时会侧重介绍每个模型的特有模块,Faster R-CNN会有PyTorch代码实现。
Abstract
This week, I mainly studied the entire series of R-CNN, delving into the principles of R-CNN, Fast R-CNN, and Faster R-CNN in detail. This blog will provide a detailed explanation of the similarities and differences among the three models mentioned above, as well as the improvements made to the detection speed of the first version of R-CNN. At the same time, it will focus on introducing the unique modules of each model, and Faster R-CNN will have PyTorch code implementation.
一、R-CNN
先看看R-CNN整体结构,然后再具体解释每一个模块,R-CNN网络模型如下图所示:
输入一张image,图像经过Selctive Search提取大约2k的候选区域(如上图第二步);将每个候选框调整大小为 227x227输入拥有5层卷积的AlexNet网络进行特征提取;将提取的特征传入SVM进行分类操作,将分类好的候选框进行NMS操作留下最终预测框;预测框可能会与真实框存在一些差异,所以需要bbox regressor进行边界框回归;预测框与真实框之间是通过匹配策略进行一一对应的。
R-CNN是属于two-stage,即先通过算法选出候选区域,再进行物体分类。
1.1 Selective Search
R-CNN候选区域的选取并不是通过枚举的方式每个都去试,而是采用Selective Search(选择性搜索),这样会省去一部分无用候选区域。
Selective Search算法主要根据多种颜色空间(考虑RGB、灰度、HSV及其变种等)、多种相似度度量标准(既考虑颜色相似度,又考虑纹理、大小、重叠情况等)、通过改变阈值初始化原始区域(阈值越大,分割的区域越少)3种策略来给像素进行分组,并计算相似区域并进行划分。如下图所示:
1.2 AlexNet
经过SS算法划选候选区域之后,将这些候选框(x,y,W,H)调整大小为 227x227。这里调整候选框大小的方式是先将候选区域图像进行填充padding=16,再将其缩放为 227x227,如下图D第二行所示:
这里扩充16px之后,效果会更好,因为在做卷积时不会损失边缘信息,同时也会看见更多特征,作者也在论文中解释道,如下图所示:
将 227x227x3 的张量传入AlexNet网络,进行特征提取, 经过AlextNet的5层卷积之后,提取出4096的特征,如下图所示:
这里的AlexNet预先在ImageNet数据集上进行预训练,可能会疑惑AlexNet的fc8层输出1k个物体分类,不也可以进行候选区域的分类工作吗,为什么这里只提取其特征并没有微调将最后的全连接层直接更改为目标个数的输出呢?
作者在论文中也做出的解释,说如果直接将最后一层改为目标数据集的20个分类输出,其准确率会比将候选区域送入SVM进行二分类的结果差4个点,主要原因是微调数据量过少,在加上将IoU>0.5的候选框当作正样本,进而更加影响准确率,如下图所示:
1.3 SVM
支持向量机(support vector machines,SVM)是一种二分类模型,它将实例的特征向量(1.2中通过卷积之后的4096维特征)映射为空间中的一些点,SVM的目的就是想要画出一条线,以尽可能好的区分这两类点,以至如果以后有了新的点,这条线也能做出很好的分类。详细原理解释请移步这篇博客。
正因为SVM是一个二分类模型,所以它只能判断该特征是否属于该类,或者不属于。在论文中作者选用了VOC数据集,是一个有20类目标类别的数据集,所以需要进行20次的SVM判断,外加一次是否为背景的判断,共21个SVM。以上SVM需要在目标数据集上进行训练得到。
共N+1类,N=20,如下图所示:
VOC数据集中主要包含20个目标类别,如下图所示:
数据集下载链接:The PASCAL Visual Object Classes Challenge 2012 (VOC2012) (ox.ac.uk)
1.4 非极大值抑制(NMS)
经过上述SVM判断类别之后,共2k个候选区域都有了对应的类别标识。但是一张图片通常只要2、3个检测目标,所以在2k个候选框中有许多重复的预测框,我们需要将重复的去除。
NMS是通过在整张图片的预测框中,将所有预测的概率进行从高到低排序,选择概率最高的预测框,将与该预测框交并比IoU>0.5的预测框全部去除,因为认为IoU>0.5框的重合度较大。重复上述步骤,就剩下最后的目标检测预测框。
交并比IoU:
IoU=两个框的交集 / 两个框的并集
1.5 Bbox Regressor
在所有候选区域经过NMS之后,剩下的预测框检测的位置可能不会完全精准,因为预测框是通过1.1中的SS算法选出来的,所以不一定会完全精确。
bbox regressor(框回归)将会与训练数据集中标记的框进行比对,进而更新框的位置使其更加精准。
若预测框位置为,而真实框的位置为,其中x、y是表示中心点位置,w、h是表示框的长宽,则:
中心点位置是偏移相加,而长宽是按照比例相乘。
该模型需要学习的参数是、、、,将上述与真实位置进行损失计算,从而更新参数,达到修正预测框的目的。
1.6 匹配策略
可能有人会疑惑预测框该如何与真实框一一进行匹配,然后进行误差计算呢?
这里采用了匹配策略,因为在训练时,会先对训练数据集进行目标框的标注,即真实框。这些真实框在最后会与预测框进行匹配,每个真实框会选择与其IoU最大的预测框进行配对。这样就能够进行1.5步骤中的框回归操作了。
到此R-CNN的完整网络框架就介绍介绍了,但R-CNN至今很少使用的原因还是因为训练阶段多、步骤繁琐,需要微调网络、训练SVM,以及训练边框回归器;再加上训练严重耗时、占用磁盘空间大,5000张图像产生几百G的特征文件等。
尽管如此,其思想仍然影响了后面许多目标检测的网络。不要停息步伐,让我们继续看看R-CNN的改进Fast R-CNN和Faster R-CNN吧。
二、Fast R-CNN
2.1 优势
正因为R-CNN是先对输入图像提取候选区域之后,再对候选区域进行特征提取。这不仅拖慢了检测速度,还需要很大的内存空间去存储这些数据。于是有了Fast R-CNN的提出,让我们来看看该模型是如何让R-CNN快起来的吧!
作者在论文中提到Fast R-CNN改进之后的一些优势,与R-CNN相比有着更好的精确度,由R-CNN的两步检测变为一步,模型的所有参数可更新,不需要额外的空间缓存特征。接下来,我们来看看是怎样的改进达到了上述的优势。
2.2 网络结构
Fast R-CNN网络模型如下图所示:
Fast R-CNN先对整体的输入图像进行特征提取,而不是先选取候选区域再提取特征;然后,再根据候选区域在原图上的位置,通过RoI投影来获取到在上一步特征图上的候选区域特征,这样仅通过一次特征提取过程就可以得到后期所有候选区域的局部特征,大大缩短了检测时间。
在得到候选区域的局部特征之后,经过RoI pooling layer(RoI池化)得到一个固定大小的特征图像。
作者在论文中写道,经过RoI pooling layer之后会得到大小固定的特征图像,即池化的窗口和移动大小是自适应的。
在经过池化之后的特征再经过两个全连接层之后,将其特征拉直;然后分别传入两个全连接层,一个是将全连接层的输出接入softmax做分类操作,另一个是将全连接层的输出做框回归操作。
Fast R-CNN的输入共两个部分,一个是输入需要进行目标检测的图像;另一个是候选区域的RoI。
Fast R-CNN模型的物体分类模块的主体是一个有5个最大池化层和5-13个卷积层组成的VGG网络,该网络在ImageNet上进行分类任务的预训练,然后对训练好的网络进行3个地方的微调。VGG网络结构图如下图所示:
- 将VGG网络中最后一个最大池化层替换为RoI池化
- 将最后1k个特征的全连接层替换为两个并行层,即分类和框回归
- 就将输入改为两个部分,不仅输入图像,还需要输入RoI,以进行特征映射
在完成以上微调之后,就该使用自己的数据集进行训练以适应具体任务。该模型在微调中,也做了一些改动:
- 所有参数可更新,即迁移学习的模型VGG中的预训练参数,微调之后两个并行全连接层中的参数都能够在自己的训练数据集中进行更新;
- 损失函数计算的变化;
在上图中,我们不难发现Fast R-CNN主要是在框回归的损失函数上做了改动。右下角的函数图像中,我们可以看出在x值增大时,R-CNN的损失函数会造成梯度爆炸的问题;而Fast R-CNN很好的解决了这个问题,当x增大时,其导数可以为正负1。
- 在训练过程中进行小批量的采样,每次仅输入两张图像,一张图像产生64个候选区域,共128个。
三、Faster R-CNN
Fast R-CNN在不计候选区域提取的时间,仅检测分类上可以做到近乎实时。但是通过Selective Search方法进行候选区域划分恰是最耗时的,而Faster R-CNN正是提出了应对之策。
采用SS算法提取候选区域,一张图像大约需要2秒;即使采用EdgeBoxes也需要0.2秒。这些候选区域都是在CPU上进行计算,能不能使用GPU加速候选区域的提取呢?
3.1 改进
我们先看看Faster R-CNN在Fast R-CNN上做了哪些改进,对比图如下图所示:
Faster R-CNN将候选区域的提取改为深度卷积神经网络RPN,同时对特征提取的卷积层进行参数共享操作,这样一张图像仅需进行一次特征提取,便可进行候选区域选取、分类预测,以及框预测。
3.2 RPN
RPN即区域建议网络,RPN是指图像输入,经过卷积层特征提取,到候选区域选取的整个过程;不仅仅是从特征图中选取候选区域的过程。因为候选区域选取和图像特征提取都需要经过卷积层进行特征提取,所以就合并为一次,通过共享参数的方法实现。通过上述方法,处理每张图像的时间可以缩短至10ms。
我们来具体看看RPN的实现过程,RPN结构图如下图所示:
输入图像经过在ImageNet上预训练的VGG网络进行特征提取,得到的特征图像,然后经过滑动窗口将该部分特征传入一个微型网络,以实现候选区域的提取。
在了解大致原理之后,我们来看看small network的具体内容,结构图如下图所示:
滑动窗口会通过 3x3 的卷积层将其特征变为256维的低维特征。然后分别传入cls、reg两个并行层。
- cls layer
cls layer(目标分类层),256维低维特征通过全连接层之后,再经过一次softmax,便可输出概率。这里2k scores中的2是指二分类概率,即为哪一类的概率,或为背景。
- reg layer
reg layer(框回归),产生候选区域的坐标,这里的4k coordinates中的4是指候选框的坐标,即(中心点横坐标x,中心点竖坐标y,长,宽)。
- k
上面返回的数据中都有k的存在,k即多少个anchor box(参考框)。因为根据滑动窗口中的特征产生的候选框不是凭空产生的,事先定义了不同比例的参考框。
这里作者实现设定了3个尺度、3个长宽比,共9个不同的anchors,即 k=9。每个anchor以滑动窗口为中心进行缩放,上面将的滑动窗口特征会通过 3x3 的卷积变为256维的低维特征,可能换为每个anchor通过 3x3 的卷积变为256维的低维特征更好理解。
作者采用了以上9种大小的anchor,足以覆盖数据集中的所有特征形状。
最后,将整个网络放一起通过VOC数据集微调即可,损失函数如下:
3.3 代码
分类网络选择ResNet50+fpn在ImageNet上进行预训练,迁移学习在VOC数据集上对Faster R-CNN进行微调,PyTorch代码如下所示:
- ResNet50+fpn
import os
import datetime
import torch
import transforms
from network_files import FasterRCNN, FastRCNNPredictor
from backbone import resnet50_fpn_backbone
from my_dataset import VOCDataSet
from train_utils import GroupedBatchSampler, create_aspect_ratio_groups
from train_utils import train_eval_utils as utils
def create_model(num_classes, load_pretrain_weights=True):
backbone = resnet50_fpn_backbone(pretrain_path="./backbone/resnet50.pth",
norm_layer=torch.nn.BatchNorm2d,
trainable_layers=3)
# 训练自己数据集时不要修改这里的91,修改的是传入的num_classes参数
model = FasterRCNN(backbone=backbone, num_classes=91)
if load_pretrain_weights:
# 载入预训练模型权重
weights_dict = torch.load("./backbone/fasterrcnn_resnet50_fpn_coco.pth", map_location='cpu')
missing_keys, unexpected_keys = model.load_state_dict(weights_dict, strict=False)
if len(missing_keys) != 0 or len(unexpected_keys) != 0:
print("missing_keys: ", missing_keys)
print("unexpected_keys: ", unexpected_keys)
# get number of input features for the classifier
in_features = model.roi_heads.box_predictor.cls_score.in_features
# replace the pre-trained head with a new one
model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)
return model
def main(args):
device = torch.device(args.device if torch.cuda.is_available() else "cpu")
print("Using {} device training.".format(device.type))
# 用来保存coco_info的文件
results_file = "results{}.txt".format(datetime.datetime.now().strftime("%Y%m%d-%H%M%S"))
data_transform = {
"train": transforms.Compose([transforms.ToTensor(),
transforms.RandomHorizontalFlip(0.5)]),
"val": transforms.Compose([transforms.ToTensor()])
}
VOC_root = args.data_path
# check voc root
if os.path.exists(os.path.join(VOC_root, "VOCdevkit")) is False:
raise FileNotFoundError("VOCdevkit dose not in path:'{}'.".format(VOC_root))
# load train data set
# VOCdevkit -> VOC2012 -> ImageSets -> Main -> train.txt
train_dataset = VOCDataSet(VOC_root, "2012", data_transform["train"], "train.txt")
train_sampler = None
# 是否按图片相似高宽比采样图片组成batch
# 使用的话能够减小训练时所需GPU显存,默认使用
if args.aspect_ratio_group_factor >= 0:
train_sampler = torch.utils.data.RandomSampler(train_dataset)
# 统计所有图像高宽比例在bins区间中的位置索引
group_ids = create_aspect_ratio_groups(train_dataset, k=args.aspect_ratio_group_factor)
# 每个batch图片从同一高宽比例区间中取
train_batch_sampler = GroupedBatchSampler(train_sampler, group_ids, args.batch_size)
# 注意这里的collate_fn是自定义的,因为读取的数据包括image和targets,不能直接使用默认的方法合成batch
batch_size = args.batch_size
nw = min([os.cpu_count(), batch_size if batch_size > 1 else 0, 8]) # number of workers
print('Using %g dataloader workers' % nw)
if train_sampler:
# 如果按照图片高宽比采样图片,dataloader中需要使用batch_sampler
train_data_loader = torch.utils.data.DataLoader(train_dataset,
batch_sampler=train_batch_sampler,
pin_memory=True,
num_workers=nw,
collate_fn=train_dataset.collate_fn)
else:
train_data_loader = torch.utils.data.DataLoader(train_dataset,
batch_size=batch_size,
shuffle=True,
pin_memory=True,
num_workers=nw,
collate_fn=train_dataset.collate_fn)
# load validation data set
# VOCdevkit -> VOC2012 -> ImageSets -> Main -> val.txt
val_dataset = VOCDataSet(VOC_root, "2012", data_transform["val"], "val.txt")
val_data_set_loader = torch.utils.data.DataLoader(val_dataset,
batch_size=1,
shuffle=False,
pin_memory=True,
num_workers=nw,
collate_fn=val_dataset.collate_fn)
# create model num_classes equal background + 20 classes
model = create_model(num_classes=args.num_classes + 1)
# print(model)
model.to(device)
# define optimizer
params = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.SGD(params,
lr=args.lr,
momentum=args.momentum,
weight_decay=args.weight_decay)
scaler = torch.cuda.amp.GradScaler() if args.amp else None
# learning rate scheduler
lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer,
step_size=3,
gamma=0.33)
# 如果指定了上次训练保存的权重文件地址,则接着上次结果接着训练
if args.resume != "":
checkpoint = torch.load(args.resume, map_location='cpu')
model.load_state_dict(checkpoint['model'])
optimizer.load_state_dict(checkpoint['optimizer'])
lr_scheduler.load_state_dict(checkpoint['lr_scheduler'])
args.start_epoch = checkpoint['epoch'] + 1
if args.amp and "scaler" in checkpoint:
scaler.load_state_dict(checkpoint["scaler"])
print("the training process from epoch{}...".format(args.start_epoch))
train_loss = []
learning_rate = []
val_map = []
for epoch in range(args.start_epoch, args.epochs):
# train for one epoch, printing every 10 iterations
mean_loss, lr = utils.train_one_epoch(model, optimizer, train_data_loader,
device=device, epoch=epoch,
print_freq=50, warmup=True,
scaler=scaler)
train_loss.append(mean_loss.item())
learning_rate.append(lr)
# update the learning rate
lr_scheduler.step()
# evaluate on the test dataset
coco_info = utils.evaluate(model, val_data_set_loader, device=device)
# write into txt
with open(results_file, "a") as f:
# 写入的数据包括coco指标还有loss和learning rate
result_info = [f"{i:.4f}" for i in coco_info + [mean_loss.item()]] + [f"{lr:.6f}"]
txt = "epoch:{} {}".format(epoch, ' '.join(result_info))
f.write(txt + "\n")
val_map.append(coco_info[1]) # pascal mAP
# save weights
save_files = {
'model': model.state_dict(),
'optimizer': optimizer.state_dict(),
'lr_scheduler': lr_scheduler.state_dict(),
'epoch': epoch}
if args.amp:
save_files["scaler"] = scaler.state_dict()
torch.save(save_files, "./save_weights/resNetFpn-model-{}.pth".format(epoch))
# plot loss and lr curve
if len(train_loss) != 0 and len(learning_rate) != 0:
from plot_curve import plot_loss_and_lr
plot_loss_and_lr(train_loss, learning_rate)
# plot mAP curve
if len(val_map) != 0:
from plot_curve import plot_map
plot_map(val_map)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description=__doc__)
# 训练设备类型
parser.add_argument('--device', default='cuda:0', help='device')
# 训练数据集的根目录(VOCdevkit)
parser.add_argument('--data-path', default='./', help='dataset')
# 检测目标类别数(不包含背景)
parser.add_argument('--num-classes', default=20, type=int, help='num_classes')
# 文件保存地址
parser.add_argument('--output-dir', default='./save_weights', help='path where to save')
# 若需要接着上次训练,则指定上次训练保存权重文件地址
parser.add_argument('--resume', default='', type=str, help='resume from checkpoint')
# 指定接着从哪个epoch数开始训练
parser.add_argument('--start_epoch', default=0, type=int, help='start epoch')
# 训练的总epoch数
parser.add_argument('--epochs', default=15, type=int, metavar='N',
help='number of total epochs to run')
# 学习率
parser.add_argument('--lr', default=0.01, type=float,
help='initial learning rate, 0.02 is the default value for training '
'on 8 gpus and 2 images_per_gpu')
# SGD的momentum参数
parser.add_argument('--momentum', default=0.9, type=float, metavar='M',
help='momentum')
# SGD的weight_decay参数
parser.add_argument('--wd', '--weight-decay', default=1e-4, type=float,
metavar='W', help='weight decay (default: 1e-4)',
dest='weight_decay')
# 训练的batch size
parser.add_argument('--batch_size', default=8, type=int, metavar='N',
help='batch size when training.')
parser.add_argument('--aspect-ratio-group-factor', default=3, type=int)
# 是否使用混合精度训练(需要GPU支持混合精度)
parser.add_argument("--amp", default=False, help="Use torch.cuda.amp for mixed precision training")
args = parser.parse_args()
print(args)
# 检查保存权重文件夹是否存在,不存在则创建
if not os.path.exists(args.output_dir):
os.makedirs(args.output_dir)
main(args)
- 预测代码
import os
import time
import json
import torch
import torchvision
from PIL import Image
import matplotlib.pyplot as plt
from torchvision import transforms
from network_files import FasterRCNN, FastRCNNPredictor, AnchorsGenerator
from backbone import resnet50_fpn_backbone, MobileNetV2
from draw_box_utils import draw_objs
def create_model(num_classes):
# resNet50+fpn+faster_RCNN
# 注意,这里的norm_layer要和训练脚本中保持一致
backbone = resnet50_fpn_backbone(norm_layer=torch.nn.BatchNorm2d)
model = FasterRCNN(backbone=backbone, num_classes=num_classes, rpn_score_thresh=0.5)
return model
def time_synchronized():
torch.cuda.synchronize() if torch.cuda.is_available() else None
return time.time()
def main():
# get devices
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print("using {} device.".format(device))
# create model
model = create_model(num_classes=21)
# load train weights
weights_path = "./fasterrcnn_voc2012.pth"
assert os.path.exists(weights_path), "{} file dose not exist.".format(weights_path)
weights_dict = torch.load(weights_path, map_location='cpu')
weights_dict = weights_dict["model"] if "model" in weights_dict else weights_dict
model.load_state_dict(weights_dict)
model.to(device)
# read class_indict
label_json_path = './pascal_voc_classes.json'
assert os.path.exists(label_json_path), "json file {} dose not exist.".format(label_json_path)
with open(label_json_path, 'r') as f:
class_dict = json.load(f)
category_index = {str(v): str(k) for k, v in class_dict.items()}
# load image
original_img = Image.open("../data/Image/mot.png")
plt.imshow(original_img)
plt.show()
original_img = original_img.convert('RGB')
# from pil image to tensor, do not normalize image
data_transform = transforms.Compose([transforms.ToTensor()])
img = data_transform(original_img)
# expand batch dimension
img = torch.unsqueeze(img, dim=0)
model.eval() # 进入验证模式
with torch.no_grad():
# init
img_height, img_width = img.shape[-2:]
init_img = torch.zeros((1, 3, img_height, img_width), device=device)
model(init_img)
t_start = time_synchronized()
predictions = model(img.to(device))[0]
t_end = time_synchronized()
print("inference+NMS time: {}".format(t_end - t_start))
predict_boxes = predictions["boxes"].to("cpu").numpy()
predict_classes = predictions["labels"].to("cpu").numpy()
predict_scores = predictions["scores"].to("cpu").numpy()
if len(predict_boxes) == 0:
print("没有检测到任何目标!")
plot_img = draw_objs(original_img,
predict_boxes,
predict_classes,
predict_scores,
category_index=category_index,
box_thresh=0.5,
line_thickness=3,
font='./Arial.ttf',
font_size=20)
plt.imshow(plot_img)
plt.show()
# 保存预测的图片结果
plot_img.save("test_result.jpg")
if __name__ == '__main__':
main()
代码检测结构如下:
- 输入
- 输出
- 输入
- 输出
模型测试结构整体准确度还是挺高的,因为训练微调数据集较小的缘故,在一些较为复制的图像上的结果不是特别理想。
总结
本周的学习到此结束,下周将继续SSD和YOLO模型的学习。
如有错误,请各位大佬指出,谢谢!