目标检测(Object Detection)的任务是找出图像中所有感兴趣的目标(物体),确定它们的位置和大小。由于各类物体有不同的形状、大小和数量,加上物体间还会相互遮挡, 因此目标检测一直都是机器视觉领域中最具挑战性的难题之一。
如上图所示,目标检测就是用一个矩形来定位一个物体并判断该物体是什么?现阶段,主流算法中表现最好的是SSD和YOLO,前者就是本文要用到的算法。实际上,不管是用SSD还是YOLO,目标检测过程都可以分解为两个独立的操作:
了解了目标检测的基本原理后,接下来,我会带你在Pascal VOC2012数据集上完成单目标检测和多目标检测的挑战。Github: https://github.com/alexshuang/pascal-voc-pytorch。
你可以在Pascal的官网上找到我们要使用的数据集。Visual Object Classes Challenge 2012 (VOC2012),是Pascal在2012年举办的机器视觉大赛,现在则作为经典的机器视觉数据集为大家所用,它涵盖了Classification/Detection(目标检测)、Segmentation(图像分割)、Action Classification(行为分类)等领域。
!cd {PATH} && tar xf VOCtrainval_11-May-2012.tar
!find {PATH} -type d
data/pascal
data/pascal/VOCdevkit
data/pascal/VOCdevkit/VOC2012
data/pascal/VOCdevkit/VOC2012/SegmentationObject
data/pascal/VOCdevkit/VOC2012/Annotations
data/pascal/VOCdevkit/VOC2012/JPEGImages
data/pascal/VOCdevkit/VOC2012/SegmentationClass
data/pascal/VOCdevkit/VOC2012/ImageSets
data/pascal/VOCdevkit/VOC2012/ImageSets/Segmentation
data/pascal/VOCdevkit/VOC2012/ImageSets/Main
data/pascal/VOCdevkit/VOC2012/ImageSets/Action
data/pascal/VOCdevkit/VOC2012/ImageSets/Layout
目标检测只需要JPEGImages和Annotations这两个目录中的数据。JPEGImages是图像文件目录,而Annotations目录中记录的是图像样本的标签信息,即目标(物体)的分类信息、bounding box以及物体对应的图像文件编号等。
Pascal官方提供的标签信息是.xml格式的,为了便于读取数据,有人已经将xml转成csv格式,它可以在这里下载。
!cd {PATH} && wget -q https://storage.googleapis.com/coco-dataset/external/PASCAL_VOC.zip
!cd {PATH} && unzip -q PASCAL_VOC.zip
trn_json = json.load((METADATA_PATH/'pascal_train2012.json').open())
val_json = json.load((METADATA_PATH/'pascal_val2012.json').open())
trn_json.keys(), val_json.keys()
(dict_keys(['images', 'type', 'annotations', 'categories']),
dict_keys(['images', 'type', 'annotations', 'categories']))
可以看到,trn_json和val_json的结构相同,所以我们只需要分析其中一个即可,我选取了val_json进行EDA。
print("images:")
display(val_json['images'][i])
print("\nannotations:")
display(val_json['annotations'][i])
print("\ncategories:")
display(val_json['categories'][i])
print("\ntype:")
display(val_json['type'][i])
images:
{'file_name': '2008_000002.jpg', 'height': 375, 'id': 2008000002, 'width': 500}
annotations:
{'area': 117445,
'bbox': [33, 10, 415, 283],
'category_id': 20,
'id': 1,
'ignore': 0,
'image_id': 2008000002,
'iscrowd': 0,
'segmentation': [[33, 10, 33, 293, 448, 293, 448, 10]]}
categories:
{'id': 1, 'name': 'aeroplane', 'supercategory': 'none'}
type:
'i'
- images:
’file_name’: 图像文件名
’id’: 图像文件id- annotations:
’bbox’: bounding box,4个数从左到右分别是:矩形左上角的x、y坐标,width,height
’category_id’: 目标(物体)的分类id
’image_id’: 目标(物体)所属的图像文件id
’ignore’: 是否忽略该目标(物体)- categories:
’id’: 分类id,总共20个分类
’name’: 分类名
以上就是我们要用到的字段,通过’image_id’和’category_id’就可以把图像和它所有的物体关联起来。
i2clas = {o['id']:o['name'] for o in val_json['categories']}
i2fn = {o['id']:o['file_name'] for o in val_json['images']}
fnames = [o['file_name'] for o in val_json['images']]
annos = collections.defaultdict(lambda: [])
for o in val_json['annotations']:
if o['ignore'] == 0:
annos[i2fn[o['image_id']]].append((o['bbox'], o['category_id']))
annos就是我们所需要的:通过文件名可以找到图像文件中所有物体的bounding box和分类信息。
i = 8
fpath = IMG_PATH/fnames[i]
fig, ax = plt.subplots(figsize=(6, 4))
show_img(ax, fpath)
show_bbox(ax, annos[fnames[i]])
我们先从单目标检测开始,即只定位和识别图像中最大最明显的那个物体。多目标检测的攻略,我把它放到了搞定目标检测(SSD篇)(下)。
为什么单目标检测的对象是图像中最大的物体?
回答这个问题之前需要你先思考另一问题:卷积神经网络(CNN)是如何识别图像中的物体的?
CNN通过卷积核(kernel)扫描整个图像矩阵来提取图像特征,经过pooling层处理的特征矩阵又成为了下一层的图像矩阵,接着被一下层的kernel提取更深层的特征,如此循环往复,最后这些特征会被全链接层转换为所有分类的概率,概率最高的分类就是物体所属的分类。
图像特征提取的原理是Receptive Field(感受域)。如下图所示,由于图像中央位置的特征被kernel扫描的次数最多,所以提取到的特征最多、颜色最深,而图像四个角只被kernel扫描过1、2次,所以提取到的特征最少、颜色最浅。
因此,CNN对图像中越靠近中央位置、体型越大的物体的判断越准确。换句话说,边缘位置的物体识别难度更大。
# (y, x, y`, x`) -> (x, y, width, height)
def from_bb(bb): return np.array([bb[1], bb[0], bb[3]-bb[1]+1, bb[2]-bb[0]+1])
# (x, y, width, height) -> (y, x, y`, x`)
def to_bb(bb): return np.array([bb[1], bb[0], bb[1]+bb[3]-1, bb[0]+bb[2]-1])
fnames, bboxes, claz = [], [], []
for k, (b, c) in train_lrg_annos.items():
fnames.append(k)
bboxes.append(' '.join([str(o) for o in to_bb(b)]))
claz.append(c)
train_lrg_bb_df = pd.DataFrame({'fname': fnames, 'bounding box': bboxes}, columns=['fname', 'bounding box'])
display(train_lrg_bb_df.head())
train_lrg_bb_df.to_csv(PATH/'train_lrg_bbox.csv', index=False)
train_lrg_clas_df = pd.DataFrame({'fname': fnames, 'class': claz}, columns=['fname', 'class'])
display(train_lrg_clas_df.head())
train_lrg_clas_df.to_csv(PATH/'train_lrg_clas.csv', index=False)
前文已经说过,目标检测可以分为定位和分类两个独立的操作,所以我为他们分别创建了两个数据集,并且通过to_bb()函数转换了bounding box的格式:(x, y, width, height) -> (y, x, y’, x’),(y’, x’)是矩形右下角的坐标,之所以要这样转换是因为后续训练时,神经网络会以 (y, x, y’, x’)的格式来处理bounding box,反之,from_bb()则是用于转换成draw_bbox()所需要的格式。
i = 8
fig, ax = plt.subplots(figsize=(6, 4))
show_img(ax, IMG_PATH/fnames[i])
show_lrg_bbox(ax, (from_bb(np.array(train_lrg_bb_df.loc[i, 'bounding box'].split(' ')).astype(int)),
train_lrg_clas_df.loc[i, 'class']))
上图的餐桌就是图像中最大的物体。
arch = resnet34
sz = 224
bs = 64
tfms = tfms_from_model(arch, sz, aug_tfms=transforms_side_on, crop_type=CropType.NO)
md = ImageClassifierData.from_csv(PATH, JPEG_PATH, PATH/'train_lrg_clas.csv',
bs=bs, tfms=tfms, val_idxs=val_idxs)
lr = 1e-2
learn.fit(lr, 1, cycle_len=2, use_clr=(10, 10))
learn.unfreeze()
lrs = np.array([lr / 100, lr / 10, lr])
learn.fit(lrs / 10, 1, cycle_len=2, use_clr=(20, 10))
epoch trn_loss val_loss accuracy
0 0.601184 0.528094 0.836655
1 0.334767 0.498876 0.847487
训练参数:pretrained resnet34,H-filp,random rotate(0~30),non-crop, learning rate(0.01)。
之所以要non-crop,是因为对于那些处在图像边缘的物体来说,随机剪切就是灭顶之灾。
图像分类模型的测试结果如下图所示,模型准确率是0.847487,对于20个分类的数据集来说还是准确的,尤其是可以准确识别出处在图像边缘的船。
arch = resnet34
sz = 224
bs = 64
aug_tfms = [
RandomFlip(tfm_y=TfmType.COORD),
RandomRotate(5, p=0.5, tfm_y=TfmType.COORD),
RandomLighting(0.1, 0.1, tfm_y=TfmType.COORD)
]
tfms = tfms_from_model(arch, sz, aug_tfms=aug_tfms, crop_type=CropType.NO, tfm_y=TfmType.COORD)
md = ImageClassifierData.from_csv(PATH, JPEG_PATH, PATH/'train_lrg_bbox.csv', bs=bs,
tfms=tfms, val_idxs=val_idxs, continuous=True)
learn = ConvLearner.pretrained(arch, md)
learn.opt_fn = optim.Adam
learn.crit = F.l1_loss
lr = 1e-1
learn.fit(lr, 1, cycle_len=3, use_clr=(10, 10))
learn.unfreeze()
lrs = np.array([lr / 100, lr / 10, lr])
learn.fit(lrs / 20, 1, cycle_len=4, use_clr=(10, 10))
epoch trn_loss val_loss
0 24.534589 23.231544
1 21.299118 46.510004
2 19.73664 17.859919
3 18.409843 16.747539
相比于classification模型,bounding box模型的训练参数:
class ObjectDataset(Dataset):
def __init__(self, ds, y): self.ds, self.y = ds, y
def __getitem__(self, i):
xi, yi = self.ds[i]
return (xi, (yi, self.y[i]))
@property
def c(self): return c
@property
def is_multi(self): return False
@property
def is_reg(self): return True
@property
def sz(self): return sz
arch = resnet34
sz = 224
bs = 64
aug_tfms = [
RandomFlip(tfm_y=TfmType.COORD),
RandomRotate(5, p=0.5, tfm_y=TfmType.COORD),
RandomLighting(0.1, 0.1, tfm_y=TfmType.COORD)
]
tfms = tfms_from_model(arch, sz, aug_tfms=aug_tfms, crop_type=CropType.NO, tfm_y=TfmType.COORD)
clas_md = ImageClassifierData.from_csv(PATH, JPEG_PATH, PATH/'train_lrg_clas.csv', bs=bs, tfms=tfms, val_idxs=val_idxs)
md = ImageClassifierData.from_csv(PATH, JPEG_PATH, PATH/'train_lrg_bbox.csv', bs=bs, tfms=tfms, val_idxs=val_idxs,
continuous=True)
c = md.c + clas_md.c
md.trn_dl.dataset = ObjectDataset(md.trn_ds, clas_md.trn_y)
md.val_dl.dataset = ObjectDataset(md.val_ds, clas_md.val_y)
统一模型的前提是合并数据。对于两个模型的输出–y,它们的处理方式并不相同,虽然我们可以定义一个新的ImageClassifierData类,但我们这里采用更灵活的方式,复用现有的Fastai library库函数,通过ObjectDataset类将md和clas_md组合起来,当pytorch调用DataLoader来读取mini-batch data时,会调用ObjectDataset的getitem()。
nin = 7 * 7 * 512
nf = 512
ps = np.array([0.25, 0.5])
def custom_head():
return nn.Sequential(
Flatten(),
nn.ReLU(),
nn.Dropout(ps[0]),
nn.Linear(nin, nf),
nn.ReLU(),
nn.Dropout(ps[1]),
nn.Linear(nf, c)
)
custom_head_f = custom_head()
learn = ConvLearner.pretrained(arch, md, custom_head=custom_head_f)
除了数据集,神经网络模型也需要修改。数据集合并后,模型的输出(y)自然也包括两部分结果:bounding box(前4个数)、分类概率(后20个数),它们不仅长度不同,处理方式也不相同,所以模型的输出层并不是激活函数,而是把输出交给object_loss()、clas_metric()和bb_metric(),让它们自个处理。
def object_loss(preds, targs):
bb_t, clas_t = targs
bb_p = preds[:, :4]
bb_p = F.sigmoid(bb_p) * sz
clas_p = preds[:, 4:]
return F.l1_loss(bb_p, bb_t) + F.cross_entropy(clas_p, clas_t) * 20
def clas_metric(preds, targs): return accuracy(preds[:, 4:], targs[1])
def bb_metric(preds, targs):
bb_p = preds[:, :4]
bb_p = F.sigmoid(bb_p) * sz
return F.l1_loss(bb_p, targs[0])
learn.crit = object_loss
learn.metrics = [clas_metric, bb_metric]
之所以F.cross_entropy(clas_p, clas_t) * 20,是因为clas_loss远小于bbox_loss,模型训练时会只优化bound box而忽略了classification,导致Object Detection模型的分类准确性远低于classification模型。所以,这里我将F.cross_entropy(clas_p, clas_t) * 20,让这两个loss值达到某种平衡。
lr = 1e-3
learn.fit(lr, 1, cycle_len=2, use_clr=(10, 10))
learn.unfreeze()
lrs = np.array([lr / 100, lr / 10, lr])
learn.fit(lrs, 1, cycle_len=2, use_clr=(20, 10))
epoch trn_loss val_loss clas_metric bb_metric
0 48.056418 37.632276 0.807192 24.481316
1 37.165874 34.25995 0.832322 22.201957
经过简单的模型训练,我们来验证模型的效果。从这几个结果来看,分类是基本正确的,但定位则有些偏差。实际上,这算是意料之中的结果,因为resnet模型是为图像分类开发的,它的强项是图像特征提取,而非图形结构定位,而且resnet的最后会扔掉数据矩阵中所有的空间信息,即对数据矩阵做flatten()或adaptivepooling()操作,而bounding box恰恰又是需要保留空间信息的,所以bounding box loss才会远大于classification loss。至于它们的loss差距能否缩小,下集揭晓。
x, y = next(iter(md.val_dl))
yp = predict_batch(learn.model, x)
bb_p, clas_p = yp[:, :4], yp[:, 4:]
bb = F.sigmoid(bb_p) * sz
clas = torch.max(F.log_softmax(clas_p), dim=1)[1]
x = md.val_ds.ds.denorm(x)
fig, axes = plt.subplots(3, 3, figsize=(8, 6))
for i, ax in enumerate(axes.flat):
ax.imshow(x[i])
xywh = from_bb(bb[i])
draw_bbox(ax, xywh)
draw_txt(ax, xywh[:2], clas_md.classes[clas[i]])
ax.axis('off')
plt.tight_layout()
为什么从单目标检测开始?
这个问题是值得深思的。在画面中随意画几个矩形框,视野只集中在矩形内,那是人而非计算机。单目标检测即是开始,也是结束,SSD之所以能同时进行多目标检测又达到较高准确率,本质上它把图像分为N块,然后对每一块做单目标检测。
本文介绍了目标检测的基本原理:定位 + 分类,并详细分析了单目标检测的原理和实现方法。下一篇博文我会详解如何用SSD搞定多目标检测。