一、目标检测基本概念
1.1 目标检测
目标检测是计算机视觉中的一个重要任务目前基于深度学习的目标检测算法已经成为主流,而相比较于基于深度学习的图像分类任务,目标检测任务更具难度。
任务 | 描述 |
---|---|
图像分类任务 | 对图像进行分类,确认图像的类别 |
目标检测任务 | 除了要识别出图像中目标的类别之外,还要求对目标进行精确定位,并用外接矩形框出 |
二者的具体区别如图所示:
1.2 目标检测的思路
猜
过去数年间,人们集中于基于卷积神经网络(CNN)的目标检测研究,但是随着进一步的探索发现,CNN并不善于直接预测坐标信息,并且一幅图像中可能出现的物体个数也是不定的,模型如何构建也比较棘手。
进而我们采用了一种猜的办法,即通过滑窗的方式罗列图中每一个可能的区域,一个个尝试并送入分类网络中得到其类别,同时我们会对当前的边界框进行微调,这样对于图像中每个区域都能得到(class,x1,y1,x2,y2)五个属性,汇总后最终就得到了图中物体的类别和坐标信息。
该方案的思路就是:先确立众多候选框,再对候选框进行分类和微调
下图更加形象的阐释了这一思路:
图中展示了目标检测的一个过程,候选框在图片上逐像素地移动,对于各个候选框都预测分类结果,得分最高的则为预测最准确的框,其位置为最终要检测目标的位置。
RCNN,YOLO,SSD等众多经典网络模型都是沿着这一思路进行优化发展的。
1.3 目标框定定义方式
在有监督的深度学习训练数据中,共需要包括两项:图像和真实标签(ground truth, GT)
在图像分类任务中,真实标签仅为图像类别。
而在目标检测中,真实标签除了需要含有类别信息之外,还需要包含目标的位置信息(目标的外接矩形bounding box)
bbox的有两种表示形式(x1, y1, x2, y2) 和 (c_x, c_y, w, h),如下图所示:
注意:我们这里所说的坐标,原点是左上角,右侧是x轴正向,下侧是y轴正向,和数学上的常用坐标轴定义不太一样。
之所以使用两种不同的目标框信息表达格式,是因为两种格式会分别在后续不同场景下更加便于计算。
可以通过代码进行两种形式的互换:
def xy_to_cxcy(xy):
return torch.cat([(xy[:, 2:] + xy[:, :2]) / 2, # c_x, c_y
xy[:, 2:] - xy[:, :2]], 1) # w, h
def cxcy_to_xy(cxcy):
return torch.cat([cxcy[:, :2] - (cxcy[:, 2:] / 2), # x_min, y_min
cxcy[:, :2] + (cxcy[:, 2:] / 2)], 1) # x_max, y_max
1.4 交并比(IoU)
在目标检测的任务中,IoU(Intersection over Union)的计算将贯穿整个模型的训练与评估过程,她的目的是用来衡量两个目标框的重叠程度。
具体的计算流程如下:
- 首先获取两个框的坐标,红框坐标: 左上(red_x1, red_y1), 右下(red_x2, red_y2),绿框坐标: 左上(green_x1, green_y1),右下(green_x2, green_y2);
- 计算两个框左上点的坐标最大值:(max(red_x1, green_x1), max(red_y1, green_y1)), 和右下点坐标最小值:(min(red_x2, green_x2), min(red_y2, green_y2))
- 利用2算出的信息计算黄框面积:yellow_area
- 计算红绿框的面积:red_area 和 green_area
- iou = yellow_area / (red_area + green_area – yellow_area)
代码实现IoU的计算:
def find_intersection(set_1,set_2):
"""
(x_min, y_min, x_max, y_max)
找到n1*n2对IOU
:param set1:有n1个框
:param set2:有n2个框
:return:
"""
lower_bounds = torch.max(set_1[:,:2].unsqueeze(1),set_2[:,:2].unsqueeze(0)) # 找到两个框中左上最大的坐标
upper_bounds = torch.min(set_1[:,2:].unsqueeze(1),set_2[:,2:].unsqueeze(0)) # 找到两个框中右下最小的坐标
intersection = torch.clamp(upper_bounds-lower_bounds,min=0) # 计算交集长宽 clamp将数据限制在min到max
return intersection[:,0] * intersection[:,1]
def find_jaccard_overlap(set_1,set_2):
"""
:param set_1: a tensor of dimensions (n1,4)
:param set_2:
:return:
"""
intersection = find_intersection(set_1,set_2)
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个
union = areas_set_1.unsqueeze(1) + areas_set_2.unsqueeze(0) - intersection # n1 * n2
return intersection / union
return iou
1.5 小结
本小节我们首先介绍了目标检测的问题背景,随后分析了一个实现目标检测的解决思路,这也是众多经典检测网络和本章要介绍的模型所采用的思路(即先确立众多候选框,再对候选框进行分类和微调)。最后介绍了bbox和IoU这两个目标检测相关的基本概念。
二、目标检测数据集VOC
2.1 VOC数据集简介
VOC数据集是目标检测领域最常用的标准数据集之一,几乎所有检测方向的论文,如faster_rcnn、yolo、SSD等都会给出其在VOC数据集上训练并评测的效果。我们使用VOC2007和VOC2012这两个最流行的版本作为训练和测试的数据。
数据集划分
VOC数据集在类别上可以分为4大类,20小类,其类别信息如下图所示。
数据集量级
VOC数量集图像和目标数量的基本信息如图所示:
数据集说明
1.JPEGImages
这个文件夹中存放所有的图片,包括训练验证测试用到的所有图片。
2.ImageSets
这个文件夹中包含三个子文件夹,Layout、Main、Segmentation
- Layout文件夹中存放的是train,valid,test和train+valid数据集的文件名
- Segmentation文件夹中存放的是分割所用train,valid,test和train+valid数据集的文件名
- Main文件夹中存放的是各个类别所在图片的文件名,比如cow_val,表示valid数据集中,包含有cow类别目标的图片名称。
3.Annotations
Annotation文件夹中存放着每张图片相关的标注信息,以xml格式的文件存储,可以通过记事本或者浏览器打开,我们以000001.jpg这张图片为例说明标注文件中各个属性的含义,如图:
尽管文本量多,但是真正需要关注的如下:
- filename:图片名称;
- size:图片宽高;
- depth表示图片通道数;
- object:表示目标,包含下面两部分内容;
- 首先是目标类别name为dog。pose表示目标姿势为left,truncated表示是否是一个被截断的目标,1表示是,0表示不是,在这个例子中,只露出狗头部分,所以truncated为1。difficult为0表示此目标不是一个难以识别的目标。
- 然后就是目标的bbox信息,可以看到,这里是以[xmin,ymin,xmax,ymax]格式进行标注的,分别表示dog目标的左上角和右下角坐标。
- 一张图片中有多少需要识别的目标,其xml文件中就有多少个object。上面的例子中有两个object,分别对应人和狗。
2.2 dataloader的构建
准备数据集
由于VOC数据集的存储格式比较复杂,为了使后续训练更加简洁,我们需要对其进行处理。
通过creat_data_list.py
的脚本,我们将xml文件进行解析,并把信息整理到json文件中,便于后续的读取,json中的信息内容大致为:
- label_map.json:类别词典,记录每个类别及其下标。如”aeroplane”: 1, “bicycle”: 2, “bird”: 3………..
- TRAIN_images.json:每一张训练图片的存储位置
- TEST_images.json:同上
- TRAIN_objects.json:每一张训练图片的信息,包括三部分
我们需要先按照ImageSets中的train和test对id进行划分,然后将JPEGImages的图片和Annotations的objects对应起来,具体流程图如下:
首先定义读取xml中objects的函数 parse_annotations:
import torch
import json
import os
import random
import xml.etree.ElementTree as ET
import torchvision.transforms.functional as FT
#GPU设置
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
#Label map
#voc_labels为VOC数据集中20类目标的类别名称
voc_labels = ('aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus', 'car', 'cat', 'chair', 'cow', 'diningtable',
'dog', 'horse', 'motorbike', 'person', 'pottedplant', 'sheep', 'sofa', 'train', 'tvmonitor')
#创建label_map字典,用于存储类别和类别索引之间的映射关系。比如:{1:'aeroplane', 2:'bicycle',......}
label_map = {k: v + 1 for v, k in enumerate(voc_labels)}
#VOC数据集默认不含有20类目标中的其中一类的图片的类别为background,类别索引设置为0
label_map['background'] = 0
#将映射关系倒过来,{类别名称:类别索引}
rev_label_map = {v: k for k, v in label_map.items()} # Inverse mapping
#解析xml文件,最终返回这张图片中所有目标的标注框及其类别信息,以及这个目标是否是一个difficult目标
def parse_annotation(annotation_path):
# 解析xml
tree = ET.parse(annotation_path)
root = tree.getroot()
boxes = list() # 存储bbox
labels = list() # 存储bbox对应的label
difficulties = list() # 存储bbox对应的difficult信息
#遍历xml文件中所有的object,前面说了,有多少个object就有多少个目标
for object in root.iter('object'):
#提取每个object的difficult、label、bbox信息
difficult = int(object.find('difficult').text == '1')
label = object.find('name').text.lower().strip()
if label not in label_map:
continue
bbox = object.find('bndbox')
xmin = int(bbox.find('xmin').text) - 1
ymin = int(bbox.find('ymin').text) - 1
xmax = int(bbox.find('xmax').text) - 1
ymax = int(bbox.find('ymax').text) - 1
#存储
boxes.append([xmin, ymin, xmax, ymax])
labels.append(label_map[label])
difficulties.append(difficult)
#返回包含图片标注信息的字典
return {'boxes': boxes, 'labels': labels, 'difficulties': difficulties}
随后按照ImageSets中Main的train_val.txt和test.txt将图像路径和objects对应顺序保存在TRAIN_images.json,TRAIN_objects.json和TEST_images.json,TEST_objects.json四个文件中.
至此为止,dataset已经准备完毕!
构建dataloader
PascalVOCDataset继承了torch.utils.data.Dataset,然后重写了init , getitem,len 和 collate_fn 四个方法,所以我们需要在代码中完成对他们的处理:
import torch
from torch.utils.data import Dataset
import json
import os
from PIL import Image
class PascalVOCDataset(Dataset):
def __init__(self, data_folder, split, keep_difficult=False):
self.split = split.upper()
assert self.split in {'TRAIN','TEST'}
self.data_folder = data_folder
self.keep_difficult = keep_difficult
with open(os.path.join(data_folder,self.split + '_images.json'),'r') as j:
self.images = json.load(j)
with open(os.path.join(data_folder,self.split + '_objects.json'),'r') as j:
self.objects = json.load(j)
assert len(self.images) == len(self.objects)
#i 代表item 索引号
def __getitem__(self, i):
image = Image.open(self.images[i],mode='r')
image = image.convert('RGB')
objects = self.objects[i]
boxes = objects['boxes']
labels = objects['labels']
difficulties = objects['difficulties']
# 若无需难样本 即keep_difficult为false 将难样本删除
if not self.keep_difficult :
boxes = boxes[1-difficulties] # difficulties 为0和1 去除难样本
labels = labels[1-difficulties]
image, boxes, labels, difficulties = transform(image, boxes, labels, difficulties,split=self.split)
return image, boxes, labels, difficulties
def __len__(self):
return len(self.images)
#将孤立的一张张图片整合成四维形式 objects用列表存储
def collate_fn(self, batch):
images = list()
boxes = list()
labels = list()
difficulties = list()
for b in batch:
images.append(b[0])
boxes.append(b[1])
labels.append(b[2])
difficulties.append(b[3])
images = torch.stack(images,dim=0)
return images, boxes, labels, difficulties
3、数据增强
到第二步完成为止,我们的dataset已经构建完成,虽然已经可以传给torch.utils.data.DataLoader来获得用于输入网络训练的数据,但是在构建过程中切不可漏掉很重要的一步:数据增强
即如下的这一行代码:
image, boxes, labels, difficulties = transform(image, boxes, labels, difficulties, split=self.split)
需要注意的是,涉及位置变化的数据增强方法,同样需要对目标框进行一致的处理,因此目标检测框架的数据处理这部分的代码量通常都不小,且比较容易出bug。这里为了降低代码的难度,我们只是使用了几种比较简单的数据增强。
import json
import os
import torch
import random
import xml.etree.ElementTree as ET
import torchvision.transforms.functional as FT
"""
可以看到,transform分为TRAIN和TEST两种模式,以本实验为例:
在TRAIN时进行的transform有:
1.以随机顺序改变图片亮度,对比度,饱和度和色相,每种都有50%的概率被执行。photometric_distort
2.扩大目标,expand
3.随机裁剪图片,random_crop
4.0.5的概率进行图片翻转,flip
*注意:a. 第一种transform属于像素级别的图像增强,目标相对于图片的位置没有改变,因此bbox坐标不需要变化。
但是2,3,4,5都属于图片的几何变化,目标相对于图片的位置被改变,因此bbox坐标要进行相应变化。
在TRAIN和TEST时都要进行的transform有:
1.统一图像大小到(224,224),resize
2.PIL to Tensor
3.归一化,FT.normalize()
注1: resize也是一种几何变化,要知道应用数据增强策略时,哪些属于几何变化,哪些属于像素变化
注2: PIL to Tensor操作,normalize操作必须执行
"""
def transform(image, boxes, labels, difficulties, split):
"""
Apply the transformations above.
:param image: image, a PIL Image
:param boxes: bounding boxes in boundary coordinates, a tensor of dimensions (n_objects, 4)
:param labels: labels of objects, a tensor of dimensions (n_objects)
:param difficulties: difficulties of detection of these objects, a tensor of dimensions (n_objects)
:param split: one of 'TRAIN' or 'TEST', since different sets of transformations are applied
:return: transformed image, transformed bounding box coordinates, transformed labels, transformed difficulties
"""
#在训练和测试时使用的transform策略往往不完全相同,所以需要split变量指明是TRAIN还是TEST时的transform方法
assert split in {'TRAIN', 'TEST'}
# Mean and standard deviation of ImageNet data that our base VGG from torchvision was trained on
# see: https://pytorch.org/docs/stable/torchvision/models.html
#为了防止由于图片之间像素差异过大而导致的训练不稳定问题,图片在送入网络训练之间需要进行归一化
#对所有图片各通道求mean和std来获得
mean = [0.485, 0.456, 0.406]
std = [0.229, 0.224, 0.225]
new_image = image
new_boxes = boxes
new_labels = labels
new_difficulties = difficulties
# Skip the following operations for evaluation/testing
if split == 'TRAIN':
# A series of photometric distortions in random order, each with 50% chance of occurrence, as in Caffe repo
new_image = photometric_distort(new_image)
# Convert PIL image to Torch tensor
new_image = FT.to_tensor(new_image)
# Expand image (zoom out) with a 50% chance - helpful for training detection of small objects
# Fill surrounding space with the mean of ImageNet data that our base VGG was trained on
if random.random() < 0.5:
new_image, new_boxes = expand(new_image, boxes, filler=mean)
# Randomly crop image (zoom in)
new_image, new_boxes, new_labels, new_difficulties = random_crop(new_image, new_boxes, new_labels,
new_difficulties)
# Convert Torch tensor to PIL image
new_image = FT.to_pil_image(new_image)
# Flip image with a 50% chance
if random.random() < 0.5:
new_image, new_boxes = flip(new_image, new_boxes)
# Resize image to (224, 224) - this also converts absolute boundary coordinates to their fractional form
new_image, new_boxes = resize(new_image, new_boxes, dims=(224, 224))
# Convert PIL image to Torch tensor
new_image = FT.to_tensor(new_image)
# Normalize by mean and standard deviation of ImageNet data that our base VGG was trained on
new_image = FT.normalize(new_image, mean=mean, std=std)
return new_image, new_boxes, new_labels, new_difficulties
4、构建DataLoader
我们只需要将创建好的dataset送入pytorch中即可将其转为DataLoader,代码如下:
# 参数说明:
# 在train时一般设置shufle=True打乱数据顺序,增强模型的鲁棒性
#num_worker表示读取数据时的线程数,一般根据自己设备配置确定(如果是windows系统,建议设默认值0,防止出错)
#pin_memory,在计算机内存充足的时候设置为True可以加快内存中的tensor转换到GPU的速度,具体原因可以百度哈~
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=batch_size, shuffle=True,
collate_fn=train_dataset.collate_fn, num_workers=workers,
pin_memory=True) # note that we're passing the collate function here