搞定目标检测(SSD篇)(上)

Figure 1

目标检测(Object Detection)的任务是找出图像中所有感兴趣的目标(物体),确定它们的位置和大小。由于各类物体有不同的形状、大小和数量,加上物体间还会相互遮挡, 因此目标检测一直都是机器视觉领域中最具挑战性的难题之一。

如上图所示,目标检测就是用一个矩形来定位一个物体并判断该物体是什么?现阶段,主流算法中表现最好的是SSD和YOLO,前者就是本文要用到的算法。实际上,不管是用SSD还是YOLO,目标检测过程都可以分解为两个独立的操作:

  • 定位(location): 用一个矩形(bounding box)来框定物体,bounding box一般由4个整数组成,分别表示矩形左上角和右下角的x和y坐标,或矩形的左上角坐标以及矩形的长和高。
  • 分类(classification): 识别bounding box中的(最大的)物体。

了解了目标检测的基本原理后,接下来,我会带你在Pascal VOC2012数据集上完成单目标检测和多目标检测的挑战。Github: https://github.com/alexshuang/pascal-voc-pytorch

Pascal VOC2012 / Notebook

你可以在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。

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]])

The Largest Object

我们先从单目标检测开始,即只定位和识别图像中最大最明显的那个物体。多目标检测的攻略,我把它放到了搞定目标检测(SSD篇)(下)

为什么单目标检测的对象是图像中最大的物体?
回答这个问题之前需要你先思考另一问题:卷积神经网络(CNN)是如何识别图像中的物体的?

CNN通过卷积核(kernel)扫描整个图像矩阵来提取图像特征,经过pooling层处理的特征矩阵又成为了下一层的图像矩阵,接着被一下层的kernel提取更深层的特征,如此循环往复,最后这些特征会被全链接层转换为所有分类的概率,概率最高的分类就是物体所属的分类。

图像特征提取的原理是Receptive Field(感受域)。如下图所示,由于图像中央位置的特征被kernel扫描的次数最多,所以提取到的特征最多、颜色最深,而图像四个角只被kernel扫描过1、2次,所以提取到的特征最少、颜色最浅。

Figure 2: 6x6 Receptive Field

因此,CNN对图像中越靠近中央位置、体型越大的物体的判断越准确。换句话说,边缘位置的物体识别难度更大。

Prepare Data

# (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']))

上图的餐桌就是图像中最大的物体。

Classification

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个分类的数据集来说还是准确的,尤其是可以准确识别出处在图像边缘的船。

Bounding Box

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模型的训练参数:

  • aug_tfms中定义了tfm_y=TfmType.COORD,这意味着在做data augmentation的时候,y(bounding box)和x一样,也做相同的调整,例如,图像从355x500 scale为224x224,bounding box的坐标也会根据224x224大小重新定位。
  • continuous=True,表示y(bounding box)是整型数而不是分类,不需要将其转换成分类index。
  • 使用L1损失函数。

Object Detection: 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块,然后对每一块做单目标检测。

END

本文介绍了目标检测的基本原理:定位 + 分类,并详细分析了单目标检测的原理和实现方法。下一篇博文我会详解如何用SSD搞定多目标检测。


欢迎关注和点赞,你的鼓励将是我创作的动力

欢迎转发至朋友圈,公众号转载请后台留言申请授权~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 207,248评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,681评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,443评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,475评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,458评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,185评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,451评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,112评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,609评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,083评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,163评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,803评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,357评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,357评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,590评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,636评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,925评论 2 344

推荐阅读更多精彩内容