查看原文
其他

【技术综述】万字长文详解Faster RCNN源代码

言有三 有三AI 2019-12-26


言有三


毕业于中国科学院,计算机视觉方向从业者,有三工作室等创始人

作者 | 言有三

编辑 | 言有三/Yolo

作为深度学习算法工程师,如果你想提升C++水平,就去研究caffe源代码,如果你想提升python水平,就去研究faster-rcnn源代码吧,caffe源代码我们已经解读过了,今天这一期就解读faster-rcnn源代码

01


Faster R-CNN概述

1.1 基础

目标检测任务关注的是图片中特定目标物体的位置。一个检测任务包含两个子任务,其一是输出这一目标的类别信息,属于分类任务。其二是输出目标的具体位置信息,属于定位任务。


分类的结果是一个类别标签,对于单分类任务而言,它就是一个数,对于多分类任务,就是一个向量。定位任务的输出是一个位置,用矩形框表示,包含矩形框左上角或中间位置的x,y坐标和矩形框的宽度高度。

目标检测在生活中具有非常广泛的应用,它也经过了非常长的发展阶段。与计算机视觉领域里大部分的算法一样,也经历了从传统的人工设计特征和浅层分类器的思路,到大数据时代使用深度神经网络进行特征学习的思路这一过程。


相信大家已经看过很多目标检测的原理综述性文章,如果没有,就参考本公众号今天发的另一篇文章《一文道尽R-CNN系列目标检测》。


本文包括两个部分:

  1. 对经典算法Faster R-CNN的源代码进行详细的说明,选用的代码为caffe版本, 链接为:https://github.com/rbgirshick/py-faster-rcnn。

  2. 基于该框架完成一个简单的实践。在正式解读代码之前,要先说清楚两个重要概念,rpn与roi pooling。


1.2 roi pooing

通常我们训练一次取多张图,也就是一个batch,一个batch中图大小一致,这是为了从源头上把控,从而获得固定维度的特征。所以在早期进行目标检测,会将候选的区域进行裁剪或缩放到统一尺度,如下图红色框。

副作用很明显,一次只选到了目标一部分,或者把目标变形了。


sppnet不从源头而是在最后一环的特征上做处理,将任意尺度(大于4*4)大小的特征,进行3种pooling,串接起来得到固定的21维,从而避免了固定尺寸输入的约束,如下。

当然你用的时候,不必要局限于4*4,2*2,1*1。


以前为了满足全连接层固定大小输入,需要在输入图进行缩放,然后提取特征。现在既然已经可以在特征空间进行缩放了,那么就不必要求输入一样大了


原来的那些只是因为在原始空间中有了轻微的位置或者尺寸改变就要重新提取特征的大量重复操作,就不需要了,因为任意的原始图像中的输入是可以映射到特征图中的,卷积只会改变空间分辨率,不改变比例和位置。

映射很简单,等比例缩放即可,实现时考虑好padding,stride等操作。


那这跟roi pooling有什么关系呢?


ROI pooling是一个简化的spp池化,不需要这么复杂,直接一次分块pooling就行了,在经过了从原始空间到特征空间的映射之后,设定好一个pooled_w,一个pooled_h,就是将W*H的输入pooling为pooled_w*pooled_h的特征图,然后放入全连接层。 它完成的下面的输入输出的变换。

1.2 RPN

region proposal network,可知道这就是一个网络,而且是一个小小的网络。它解决了roi pooling的输入问题,就是如何得到一系列的proposal,也就是原图中的候选框。

图比较难画就借用原文了,如上,而rpn网络的功能示意图如下。

最开始当我们想要去提取一些区域来做分类和回归时,那就穷举嘛,搞不同大小,不同比例的框,在图像中从左到右,从上到下滑动,如上图,一个就选了9种。这样计算量很大,所以selective search利用语义信息改进,避免了滑动,但还是需要在原图中操作,因为特征提取不能共享。


现在不是有了sppnet和roi pooling的框架,把上面的这个在原图中进行穷举滑动的操作,换到了比原图小很多的特征空间(比如224*224 --> 13*13),还是搞滑动窗口,就得到了rpn,如下图。

rpn实现了了上面的输入输出。不同与简单滑窗的是,网络会对这个过程进行学习,得到更有效的框。


剩下的就是一些普通cnn的知识,不用多说,有了上面的这些基础后,我们开始解读代码。

02


py-faster-rcnn框架解读

Faster R-CNN源代码的熟悉几乎是所有从事目标检测的人员必须迈过的坎,由于目录结构比较复杂而且广泛为人所用,涉及的东西非常多,所以我们使用的时候维持该目录结构不变,下面首先对它的目录结构进行完整的分析。


目录下包括caffe-fast-rcnn,data,experiments,lib,models,tools几大模块。

2.1 caffe-fast-rcnn

这是rcnn系列框架的caffe,因为目标检测中使用到了很多官方caffe中不包括的网络层,所以必须进行定制。这里需要注意的是caffe是以子模块的方式被包含在其中,所以使用git clone命令下载代码将得到空文件夹,必须要加上递归参数-recursive,具体做法如下:

git clone --recursive https://github.com/rbgirshick/py-faster-rcnn.git

2.2 data

该文件夹下包含两个子文件夹,一个是scripts,一个是demo。其中demo就是用于存放测试的图像,script存储着若干脚本,它用于获取一些预训练的模型。

比如运行fetch_imagenet_models.sh脚本,会在当前的文件夹下建立imagenet_models目录,并下载VGG_CNN_M_1024.v2.caffemodel VGG16.v2.caffemodel ZF.v2.caffemodel模型,这些是在ImageNet上预先训练好的模型,将用于初始化我们的检测模型的训练。


另外还在该目录下建立数据集的软链接。通常情况下,对于一些通用的数据集,我们会将它们放在公用的目录而不是某一个项目下,所以这里通常需要建立通用数据集的软链接,比如PASCAL VOC的目录。

ln -s /your/path/to/VOC2012/VOCdevkit VOCdevkit2012

2.3 experiments

下面分为3个目录,分别是log,cfgs,scripts。cfgs存放的就是配置文件,比如faster_rcnn_end2end的配置如下:

EXP_DIR: faster_rcnn_end2end

TRAIN:

  HAS_RPN: True

  IMS_PER_BATCH: 1

  BBOX_NORMALIZE_TARGETS_PRECOMPUTED: True

  RPN_POSITIVE_OVERLAP: 0.7

  RPN_BATCHSIZE: 256

  PROPOSAL_METHOD: gt

  BG_THRESH_LO: 0.0

TEST:

  HAS_RPN: True

其中比较重要的包括HAS_RPN,RPN_BATCHSIZE等。Log目录就存放log日志。scripts存放的就是bash训练脚本,可以用end2end或者alt_opt两种方式训练。


每一个训练脚本都包含两个步骤,训练和测试。

time ./tools/train_net.py --gpu ${GPU_ID} \

  --solver models/${PT_DIR}/${NET}/faster_rcnn_end2end/solver.prototxt \

  --weights data/imagenet_models/${NET}.v2.caffemodel \

  --imdb ${TRAIN_IMDB} \

  --iters ${ITERS} \

  --cfg experiments/cfgs/faster_rcnn_end2end.yml \

  ${EXTRA_ARGS}

如上就是训练部分代码,对于默认的任务,我们不需要修改这里的代码,但是如果我们不想用预训练,或者相关的配置发生了变化,比如yml格式的配置文件,预训练模型的前缀格式,需要配置自定义的数据集等等,则需要修改此处代码。


2.4 Models目录

该目录下包含两个文件夹,coco和pascal voc,可知是两个通用数据集。在各个数据集的子目录下存储了一系列模型结构的配置,如models/pascal/VGG_CNN_M_1024/目录,存储的就是用于训练coco数据集的VGG模型。

在该目录下,有fast_rcnn,faster_rcnn_alt_opt,faster_rcnn_end_to_end三套模型结构,各自有所不同。

fast_rcnn即fast_rcnn方法,它下面只包含了train.prototxt,test.prototxt,solver.prototxt三个文件,它对rcnn的改进主要在于重用了卷积特征,没有region proposal框架。

faster_rcnn_alt_opt,faster_rcnn_end_to_end都是faster rcnn框架,包括了region proposal模块。在faster_rcnn_alt_opt目录下,包含了4个训练文件和对应的solver文件,为:

stage1_fast_rcnn_solver30k40k.pt,stage1_fast_rcnn_train.pt,stage1_rpn_solver60k80k.pt,stage1_rpn_train.pt,stage2_fast_rcnn_solver30k40k.pt,stage2_fast_rcnn_train.pt,

stage2_rpn_solver60k80k.pt,stage2_rpn_train.pt。

其中stage1过程是分别采用了ImageNet分类任务上训练好的模型进行region proposal的学习和faster rcnn检测的学习,stage2则是在stage1已经训练好的模型的基础上进行进一步的学习。


faster_rcnn_end_to_end就是端到端的训练方法,使用起来更加简单,所以我们这一小节会使用faster_rcnn_end_to_end方法,目录下只包括了train.prototxt,test.prototxt,solver.prototxt。

当要在我们自己的数据集上完成检测任务的时候,就可以建立与pascal_voc和coco平级的目录。


2.5 Lib目录

lib目录下包含了非常多的子目录,包括datasets,fast_rcnn,nms,pycocotools,roi_data_layer,rpn,transform,utils,这是faster rcnn框架中很多方法的实现目录,下面对其进行详细解读。


(1) utils目录

这是最基础的一个目录,主要就是blob.py和bbox.pyx。blob.py用于将图像进行预处理,包括减去均值,缩放等操作,然后封装到caffe的blob中。

    for i in xrange(num_images):

        im = ims[i]

        blob[i, 0:im.shape[0], 0:im.shape[1], :] = im

    channel_swap = (0, 3, 1, 2)

    blob = blob.transpose(channel_swap)

封装的核心代码如上,首先按照图像的存储格式(H,W,C)进行赋值,然后调整通道和高,宽的顺序,这在我们使用训练好的模型进行预测的时候是必要的操作。而且,有的时候采用RGB格式进行训练,有的使用采用BGR格式进行训练,也需要做对应的调整。

bbox.pyx用于计算两个box集合的overlaps,即重叠度。一个输入是(N,4)形状的真值boxes,一个输入是(K,4)形状的查询boxes,输出为(N,K)形状,即逐个box相互匹配的结果。


(2) datasets目录

datasets目录下有目录VOCdevkit-matlab-wrapper,tools,以及脚本coco.py,pascal_voc.py,voc_eval.py,factory.py,ds_util.py,imdb.py。

我们按照调用关系来看,首先是ds_util.py,它包含了一些最基础的函数,比如unique_boxes函数,可以通过不同尺度因子的缩放,从一系列的框中获取不重复框的数组指标,用于过滤重复框。

具体实现采用了hash的方法,代码如下:

v = np.array([1, 1e3, 1e6, 1e9])

hashes = np.round(boxes * scale).dot(v).astype(np.int)

box的4个坐标,与上面的v进行外积后转换为一个数,再进行判断。

xywh_to_xyxy,xyxy_to_xywh函数分别是框的两种表达形式的相互转换,前者采用一个顶点坐标和长宽表示矩形,后者采用两个顶点坐标表示矩形,各有用途。

validata_boxes函数用于去除无效框,即超出边界或者左右,上下,不满足几何关系的点,比如右边顶点的x坐标小于左边顶点。filer_small_boxes用于去掉过小的检测框。


接下来看imdb.py,这是数据集类imdb的定义脚本,非常重要。从它的初始化函数_init_可以看出,类成员变量包括数据集的名字self._name,检测的类别名字self._classes与具体的数量self._num_classes,候选区域的选取方法self._obj_proposer,roi数据集self._roidb与它的指针self._roidb_handler,候选框提取默认采用了selective_search方法。


roidb是它最重要的数据结构,它是一个数组。数组中的每一个元素其实就是一张图的属性,以字典的形式存储它的若干属性,共4个key,为boxes,gt_overlaps,gt_classes,flipped。


候选框boxes就是一个图像中的若干的box,每一个box是一个4维的向量,包含左上角和右下角的坐标。类别信息gt_classes,就是对应boxes中各个box的类别信息。真值gt_overlaps,它的维度大小为boxes的个数乘以类别的数量,可知存储的就是输入box和真实标注之间的重叠度。另外如果设置了变量flipped,还可以存储该图像的翻转版本,这就是一个镜像操作,是最常用的数据增强操作。


roidb的生成调用了create_roidb_from_box_list函数,它将输入的box_list添加到roidb中。如果没有gt_roidb的输入,那么就是下面的逻辑,可见就是将boxes存入数据库中,并初始化gt_overlaps,gt_classes等变量。

boxes = box_list[i]

num_boxes = boxes.shape[0]

overlaps = np.zeros((num_boxes, self.num_classes), dtype=np.float32)

overlaps = scipy.sparse.csr_matrix(overlaps)

roidb.append({

                'boxes' : boxes,

                'gt_classes' : np.zeros((num_boxes,), dtype=np.int32),

                'gt_overlaps' : overlaps,

                'flipped' : False,

                'seg_areas' : np.zeros((num_boxes,), dtype=np.float32),

            })

如果输入gt_roidb非空,则需要将输入的box与其进行比对计算得到gt_overlaps,代码如下:

if gt_roidb is not None and gt_roidb[i]['boxes'].size > 0:

gt_boxes = gt_roidb[i]['boxes']

    gt_classes = gt_roidb[i]['gt_classes']

    gt_overlaps = bbox_overlaps(boxes.astype(np.float),gt_boxes.astype(np.float))

    argmaxes = gt_overlaps.argmax(axis=1)

    maxes = gt_overlaps.max(axis=1)

I = np.where(maxes > 0)[0]

overlaps[I, gt_classes[argmaxes[I]]] = maxes[I]

将输入的boxes与数据库中boxes进行比对,调用了bbox_overlaps函数。比对完之后结果存入 overlaps。


bbox_overlaps的结果overlaps是一个二维矩阵,第一维大小等于输入boxes中的框的数量,第二维就是类别数目,所存储的每一个值就是与真实标注进行最佳匹配的结果,即重叠度。但是最后存储的时候调用了overlaps = scipy.sparse.csr_matrix(overlaps)进行稀疏压缩,因为其中大部分的值其实是空的,一张图包含的类别数目有限。


还有一个变量gt_classes,在从该函数创建的时候并未赋值,即等于0,因为这个函数是用于将从rpn框架中返回的框添加到数据库中,并非是真实的标注。当gt_classes非零,说明是真实的标注,这样的数据集就是train或者val数据集,它们在一开始就被创建,反之则是test数据集。gt_classes非零的样本和为零的样本在数据集中是连续存储的。


该脚本中另一个重要的函数就是evaluate_recall,这就是用于计算average iou的函数。它的输入包括candidate_boxes,即候选框。假如没有输入,则评估时取该roidb中的非真值box。threholds,即IoU阈值,如果没有输入则默认从0.5到0.95,按照0.05的步长迭代。area,用于评估的面积大小阈值,默认覆盖0到1e10的尺度,尺度是指框的面积。还有一个limit,用于限制评估的框的数量。

返回平均召回率average recall,每一个IoU重合度阈值下的召回向量,设定的IoU阈值向量,以及所有的真值标签。

当进行评估的时候,首先要按照上面设计的面积大小阈值,得到有效的index。

max_gt_overlaps = self.roidb[i]['gt_overlaps'].toarray().max(axis=1)

gt_inds = np.where((self.roidb[i]['gt_classes'] > 0) &

      (max_gt_overlaps == 1))[0]   首先获得需要评估的index

gt_boxes = self.roidb[i]['boxes'][gt_inds, :]  得到对应的boxes

gt_areas = self.roidb[i]['seg_areas'][gt_inds]

valid_gt_inds = np.where((gt_areas >= area_range[0]) &

       (gt_areas <= area_range[1]))[0]  得到符合面积约束的index

gt_boxes = gt_boxes[valid_gt_inds, :]

num_pos += len(valid_gt_inds)  记录符合条件的框的个数

计算重叠度的过程是对每一个真值box进行遍历,寻找到与其重叠度最大的候选框,得到各个真值box的被重叠度。挑选其中被重叠度最高的真值box,然后找到对应的与其重叠度最高的box,得到了一组匹配和相应的重叠度。标记这两个box,后续的迭代不再使用,然后循环计算,直到所有的真值框被遍历完毕。

pascal_voc.py和coco.py就是利用上面的几个脚本来创建对应这两个数据集的格式,用于后续对模型的测试,下面就是pascal voc的数据库的创建过程。

def _load_pascal_annotation(self, index):

        """

        Load image and bounding boxes info from XML file in the PASCAL VOC

        format.

        """

        filename = os.path.join(self._data_path, 'Annotations', index + '.xml')

        tree = ET.parse(filename)

        objs = tree.findall('object')

        if not self.config['use_diff']:

            non_diff_objs = [

                obj for obj in objs if int(obj.find('difficult').text) == 0]

            objs = non_diff_objs

        num_objs = len(objs)

        boxes = np.zeros((num_objs, 4), dtype=np.uint16)

        gt_classes = np.zeros((num_objs), dtype=np.int32)

        overlaps = np.zeros((num_objs, self.num_classes), dtype=np.float32)

       

# "Seg" area for pascal is just the box area

        seg_areas = np.zeros((num_objs), dtype=np.float32)

 

        # Load object bounding boxes into a data frame.

        for ix, obj in enumerate(objs):

            bbox = obj.find('bndbox')

            # Make pixel indexes 0-based

            x1 = float(bbox.find('xmin').text) - 1

            y1 = float(bbox.find('ymin').text) - 1

            x2 = float(bbox.find('xmax').text) - 1

            y2 = float(bbox.find('ymax').text) - 1

            cls = self._class_to_ind[obj.find('name').text.lower().strip()]

            boxes[ix, :] = [x1, y1, x2, y2]

            gt_classes[ix] = cls

            overlaps[ix, cls] = 1.0

            seg_areas[ix] = (x2 - x1 + 1) * (y2 - y1 + 1)

 

        overlaps = scipy.sparse.csr_matrix(overlaps)

 

        return {'boxes' : boxes,

                'gt_classes': gt_classes,

                'gt_overlaps' : overlaps,

                'flipped' : False,

                'seg_areas' : seg_areas}

从上面脚本可知,输入就是xml格式的标注文件,通过obj变量获得x1,y1,x2,y2,即标注信息,以及cls类别信息,并标注overlaps等于1。另外seg_areas实际上就是标注框的面积。


(3) nms目录

该目录下主要是cpu和gpu版本的非极大值抑制计算方法,非极大抑制算法在目标检测中应用相当广泛,其主要目的是消除多余的框,找到最佳的物体检测位置。

实现的核心思想是首先将各个框的置信度进行排序,然后选择其中置信度最高的框A,将其作为标准,同时设置一个阈值。然后开始遍历其他框,当其他框B与A的重合程度超过阈值就将B舍弃掉,然后在剩余的框中选择置信度最大的框,重复上述操作。

我们以py_cpu_nms.py为例,并添加了注释。

import numpy as np

def py_cpu_nms(dets, thresh):

    """Pure Python NMS baseline."""

    x1 = dets[:, 0]

    y1 = dets[:, 1]

    x2 = dets[:, 2]

    y2 = dets[:, 3]

    scores = dets[:, 4]  

    areas = (x2 - x1 + 1) * (y2 - y1 + 1)  #此处用于计算每一个框的面积

    order = scores.argsort()[::-1]    #按照分数大小对其进行从高到低排序

 

    keep = []

    while order.size > 0:

        i = order[0]     #取分数最高的那个框

        keep.append(i)  #保留这个框

        xx1 = np.maximum(x1[i], x1[order[1:]])

        yy1 = np.maximum(y1[i], y1[order[1:]])

        xx2 = np.minimum(x2[i], x2[order[1:]])

        yy2 = np.minimum(y2[i], y2[order[1:]])        #计算当前分数最大矩形框与其他矩形框的相交后的坐标

 

        w = np.maximum(0.0, xx2 - xx1 + 1)

        h = np.maximum(0.0, yy2 - yy1 + 1)     

        inter = w * h      #计算相交框的面积

        ovr = inter / (areas[i] + areas[order[1:]] - inter)  #计算IOU:重叠面积/(面积1+面积2-重叠面积)

 

        inds = np.where(ovr <= thresh)[0]   #取出IOU小于阈值的框

        order = order[inds + 1]     #更新排序序列

 

    return keep

(4) roi data layer目录

该目录下有3个脚本,layer.py,minibatch.py,roidb.py。


layer.py包含了caffe的RoIDataLayer网络层的实现。通常来说一个caffe网络层的实现,需要包括setup,forward,backward等函数的实现,对于数据层还需实现shuffle,批量获取数据等函数。


Roidatalayer是一个数据层,也是训练时的输入层,其中最重要的函数是setup函数,用于设置各类输出数据的尺度信息。


根据是否有RPN模块,这两种情况下的配置是不一样的,我们直接看caffe的网络配置就能明白,比较fast rcnn和faster rcnn。


首先是fast rcnn:

name: "VGG_CNN_M_1024"

layer {

  name: 'data'

  type: 'Python'

  top: 'data'

  top: 'rois'

  top: 'labels'

  top: 'bbox_targets'

  top: 'bbox_inside_weights'

  top: 'bbox_outside_weights'

  python_param {

    module: 'roi_data_layer.layer'

    layer: 'RoIDataLayer'

    param_str: "'num_classes': 21"

  }

}

可以看到,它的top输出为rois,labels,bbox_targets, bbox_inside_weights, bbox_outside_weights总共5个属性。


rois是selective search方法提取出的候选区域,尺度为(1,5),按照(index,x1,y1,x2,y2)的格式来存储。labels和bbox_targets是区域的分类和回归标签,bbox_inside_weights是正样本回归loss的权重,默认为1,负样本为0,表明在回归任务中,只采用正样本进行计算。bbox_outside_weights用于平衡正负样本的权重,它们将在计算SmoothL1Loss的时候被使用,各自的计算方法如下:

bbox_inside_weights[labels == 1, :] = np.array(cfg.TRAIN.RPN_BBOX_INSIDE_WEIGHTS)

if cfg.TRAIN.RPN_POSITIVE_WEIGHT < 0:

    # uniform weighting of examples (given non-uniform sampling)

    num_examples = np.sum(labels >= 0)

    positive_weights = np.ones((1, 4)) * 1.0 / num_examples

    negative_weights = np.ones((1, 4)) * 1.0 / num_examples

else:

    assert ((cfg.TRAIN.RPN_POSITIVE_WEIGHT > 0) &

            (cfg.TRAIN.RPN_POSITIVE_WEIGHT < 1))

    positive_weights = (cfg.TRAIN.RPN_POSITIVE_WEIGHT /

                        np.sum(labels == 1))

    negative_weights = ((1.0 - cfg.TRAIN.RPN_POSITIVE_WEIGHT) /

                        np.sum(labels == 0))

bbox_outside_weights[labels == 1, :] = positive_weights

bbox_outside_weights[labels == 0, :] = negative_weights


 然后是faster rcnn:

name: "VGG_CNN_M_1024"

layer {

  name: 'input-data'

  type: 'Python'

  top: 'data'

  top: 'im_info'

  top: 'gt_boxes'

  python_param {

    module: 'roi_data_layer.layer'

    layer: 'RoIDataLayer'

    param_str: "'num_classes': 21"

  }

}

可以看到,它的top输出是im_info,gt_boxes,两者的尺度分别为(1,3)和(1,4)。而上面的rois,labels,bbox_targets, bbox_inside_weights, bbox_outside_weights全部通过rpn框架来生成,rpn框架我们下面讲。


roi_data中需要批量获取数据,实现就在minibatch.py中了,它实现一次从roidb中获取多个样本的操作,主要函数是get_minibatch,根据是否使用rpn来进行操作。


如果使用rpn,则只需要输出gt_boxes和im_info,直接从roidb数据库中获取即可。如果不使用rpn,则需要自己来生成前景和背景的rois训练图片,调用了两个函数_sample_rois,_project_im_rois。


_sample_rois函数生成前景和背景,接口如下:

_sample_rois(roidb, fg_rois_per_image, rois_per_image, num_classes)

通过rois_per_image指定需要生成的训练样本的数量, fg_rois_per_image指定前景正样本的数量。一个前景正样本就是满足与真值box中的最大重叠度大于一定阈值cfg.TRAIN.FG_THRESH的样本,一个背景就是与真值box中的最大重叠度大于一定阈值cfg.TRAIN.FG_THRELO,小于一定阈值cfg.TRAIN.BG_THRESH_SH的样本,选择样本的方法当然就是从符合条件的样本中随机选择,如果满足条件的样本不够,那就按照最低值来选择。

_project_im_rois就是一个缩放,因为训练的时候使用了不同的尺度。


(5) rpn目录

该目录就是region proposal模块,包含有generate_anchors.py,proposal_layer.py,anchor_target_layer.py,proposal_target_layer.py,generate.py脚本。


rpn有几个任务需要完成,产生一些anchors,完成anchor到图像空间的映射,得到训练样本。


generate_anchors脚本就是用于产生anchors,它使用16*16的参考窗口,产生3个比例(1:1,1:2,2:1),三个缩放尺度(0.5, 1, 2)的anchors,共9个。在原论文中对应到原始图像空间,3个尺度是(128, 256与512),代码如下:

def generate_anchors(base_size=16, ratios=[0.5, 1, 2],

                     scales=2**np.arange(3, 6)):

    base_anchor = np.array([1, 1, base_size, base_size]) - 1

    ratio_anchors = _ratio_enum(base_anchor, ratios)

    anchors = np.vstack([_scale_enum(ratio_anchors[i, :], scales)

                         for i in xrange(ratio_anchors.shape[0])])

    return anchors

anchor_target_layer.py就是实现了AnchorTargetLayer,它与generate_anchors配合使用,共同产生anchors的样本rpn,用于rpn的分类和回归任务,anchor_target_layer层的caffe配置如下。

layer {

  name: 'rpn-data'

  type: 'Python'

  bottom: 'rpn_cls_score'

  bottom: 'gt_boxes'

  bottom: 'im_info'

  bottom: 'data'

  top: 'rpn_labels'

  top: 'rpn_bbox_targets'

  top: 'rpn_bbox_inside_weights'

  top: 'rpn_bbox_outside_weights'

  python_param {

    module: 'rpn.anchor_target_layer'

    layer: 'AnchorTargetLayer'

    param_str: "'feat_stride': 16"

  }

}

可知,anchor_target_layer的输入是gt_boxes,im_info,rpn_cls_score,data,输出就是rpn_labels,rpn_bbox_targets, rpn_bbox_inside_weights, rpn_bbox_outside_weights。rpn_cls_score是rpn网络的第一个卷积的分类分支的输出,rpn网络正是通过rpn-data的指导学习到了如何提取proposals。


假如输入rpn网络的为256*13*13的特征,那么一个rpn的输出通常是一个18*13*13的分类特征图和一个36*13*13的回归特征图,它们都是通过1*1的卷积生成。13*13就是特征图的大小,它并不会改变。一个18*1*1就对应每一个位置的9个anchor的分类信息,这里的分类不管具体的类别,只分前景与背景,anchor显示里面有物体存在时可对其进行回归。一个36*1*1就对应每一个位置的9个anchor的回归信息,这是一个相对值。后面要做的,就是利用这些anchors,生成propasals了。


proposal_layer脚本,定义了ProposalLayer的类,就是从rpn的输出开始,得到最终的proposals,输入有三个,网络配置如下。

layer {

  name: 'proposal'

  type: 'Python'

  bottom: 'rpn_cls_prob_reshape'

  bottom: 'rpn_bbox_pred'

  bottom: 'im_info'

  top: 'rpn_rois'

#  top: 'rpn_scores'

  python_param {

    module: 'rpn.proposal_layer'

    layer: 'ProposalLayer'

    param_str: "'feat_stride': 16"

  }

}

可以看到,输入了rpn_bbox_pred,rpn_cls_pro_shape以及im_info,输出rpn_rois,也就是object proposals,由bbox_transform_inv函数完成坐标变换,接口如下:

proposals = bbox_transform_inv(anchors, bbox_deltas)


这里的bbox_deltas就是上面的rpn_bbox_pred,它就是预测的anchor的偏移量,它的尺寸大小是(1, 4 * A, H, W),其中H,W就是特征图的大小,而A就是基础anchors的个数,就是上面的9。Anchors的大小则是(K * A, 4),其中K是偏移位置的种类,偏移位置就是将anchors在特征图上进行滑动的偏移量,可知包含了x和y两个方向,产生的方法如下:

shift_x = np.arange(0, width) * self._feat_stride

shift_y = np.arange(0, height) * self._feat_stride

shift_x, shift_y = np.meshgrid(shift_x, shift_y)

shifts = np.vstack((shift_x.ravel(), shift_y.ravel(),

         shift_x.ravel(), shift_y.ravel())).transpose()

得到了初始的proposals之后,再经过裁剪,过滤,排序,非极大值抑制后就可以用了。

proposal_target_layer,就是从上面选择出的object proposals采样得到训练样本,流程与上面roi_data_layer层中没有rpn模块时产生训练样本类似,因此不再赘述。

labels, rois, bbox_targets, bbox_inside_weights = _sample_rois(

            all_rois, gt_boxes, fg_rois_per_image,

            rois_per_image, self._num_classes)

最后是generate脚本,就是高层的调用脚本,即使用RPN方法从imdb或者图像中产生proposals。


(6) fast_rcnn目录

该目录有bbox_transform.py,config.py,nms.wrapper.py,test.py,train.py几个脚本。

config.py是一个配置参数的脚本,它配置了非常多的默认变量,非常重要。如果想要修改,也不应该在该脚本中修改,而是到先前提到的experements目录下进行配置。


配置包含两部分,一个是训练部分的配置,一个是测试部分的配置。训练部分的配置如下,我们添加注释。

# Training options

__C.TRAIN.SCALES = (600,) #训练尺度,可以配置为一个数组

__C.TRAIN.MAX_SIZE = 1000 #缩放后图像最长边的上限

__C.TRAIN.IMS_PER_BATCH = 2 #每一个batch使用的图像数量

__C.TRAIN.BATCH_SIZE = 128 #每一个batch中使用的ROIs的数量

__C.TRAIN.FG_FRACTION = 0.25 #每一个batch中前景的比例

__C.TRAIN.FG_THRESH = 0.5 #ROI前景阈值

__C.TRAIN.BG_THRESH_HI = 0.5 #ROI背景高阈值

__C.TRAIN.BG_THRESH_LO = 0.1 #ROI背景低阈值

__C.TRAIN.USE_FLIPPED = True #训练时是否进行水平翻转

__C.TRAIN.BBOX_REG = True #是否训练回归

__C.TRAIN.BBOX_THRESH = 0.5 #用于训练回归的roi与真值box的重叠阈值

__C.TRAIN.SNAPSHOT_ITERS = 10000 #snapshot间隔

__C.TRAIN.SNAPSHOT_INFIX = '' #snapshot前缀

__C.TRAIN.BBOX_NORMALIZE_TARGETS = True #bbox归一化方法,去均值和方差

__C.TRAIN.BBOX_NORMALIZE_MEANS = (0.0, 0.0, 0.0, 0.0)

__C.TRAIN.BBOX_NORMALIZE_STDS = (0.1, 0.1, 0.2, 0.2)

__C.TRAIN.BBOX_INSIDE_WEIGHTS = (1.0, 1.0, 1.0, 1.0) #rpn 前景box权重

__C.TRAIN.PROPOSAL_METHOD = 'selective_search' #默认proposal方法

__C.TRAIN.ASPECT_GROUPING = True #一个batch中选择尺度相似的样本

__C.TRAIN.HAS_RPN = False #是否使用RPN

__C.TRAIN.RPN_POSITIVE_OVERLAP = 0.7 #正样本IoU阈值

__C.TRAIN.RPN_NEGATIVE_OVERLAP = 0.3 #负样本IoU阈值

__C.TRAIN.RPN_CLOBBER_POSITIVES = False

__C.TRAIN.RPN_FG_FRACTION = 0.5 #前景样本的比例

__C.TRAIN.RPN_BATCHSIZE = 256 #RPN样本数量

__C.TRAIN.RPN_NMS_THRESH = 0.7 #NMS阈值

__C.TRAIN.RPN_PRE_NMS_TOP_N = 12000 #使用NMS前,要保留的top scores的box数量

__C.TRAIN.RPN_POST_NMS_TOP_N = 2000 #使用NMS后,要保留的top scores的box数量

__C.TRAIN.RPN_MIN_SIZE = 16 #原始图像空间中的proposal最小尺寸阈值

测试时相关配置类似,此处不再一一解释。


bbox_transform.py中的bbox_transform函数计算的是两个N * 4的矩阵之间的相关回归矩阵,两个输入矩阵一个是anchors,一个是gt boxes,本质上是在求解每一个anchor相对于它的对应gt box的(dx, dy, dw, dh)的四个回归值,返回结果的shape为[N, 4],使用了log指数变换。


bbox_transform.py中的bbox_transform_inv函数用于将rpn网络产生的deltas进行变换处理,求出变换后的对应到原始图像空间的boxes,它输入boxes和deltas,boxes表示原始anchors,即未经任何处理仅仅是经过平移之后产生的anchors,shape为[N, 4],N表示anchors的数目。deltas就是RPN网络产生的数据,即网络'rpn_bbox_pred'层的输出,shape为[N, (1 + classes) * 4],classes表示类别数目,1 表示背景,N表示anchors的数目,核心代码如下。

widths = boxes[:, 2] - boxes[:, 0] + 1.0

heights = boxes[:, 3] - boxes[:, 1] + 1.0

ctr_x = boxes[:, 0] + 0.5 * widths

ctr_y = boxes[:, 1] + 0.5 * heights

dx = deltas[:, 0::4]

dy = deltas[:, 1::4]

dw = deltas[:, 2::4]

dh = deltas[:, 3::4]

pred_ctr_x = dx * widths[:, np.newaxis] + ctr_x[:, np.newaxis]

pred_ctr_y = dy * heights[:, np.newaxis] + ctr_y[:, np.newaxis]

pred_w = np.exp(dw) * widths[:, np.newaxis]

pred_h = np.exp(dh) * heights[:, np.newaxis]

pred_boxes = np.zeros(deltas.shape, dtype=deltas.dtype)

# x1

pred_boxes[:, 0::4] = pred_ctr_x - 0.5 * pred_w

# y1

pred_boxes[:, 1::4] = pred_ctr_y - 0.5 * pred_h

# x2

pred_boxes[:, 2::4] = pred_ctr_x + 0.5 * pred_w

# y2

pred_boxes[:, 3::4] = pred_ctr_y + 0.5 * pred_h

可以看出,它与bbox_transform是配合使用的,bbox_transform使用了对数变换将anchor存储下来,而bbox_transform_inv则将其恢复到图像空间。

网络的回归坐标预测的是一个经过平移和尺度缩放的因子,如果采用原始的图像坐标,则可能覆盖从0~1000这样几个数量级差距的数值,很难优化。


train.py和test.py分别是训练主脚本和测试主脚本。在训练主脚本中,定义了一个类solverWrapper,包含训练的函数和存储模型结果的函数。


test.py脚本中最重要的函数是im_detect,它的输入是caffe的模型指针,输入BGR顺序的彩色图像,以及可选的R*4的候选框,这适用于使用selective search提取候选框的方法,拥有rpn框架的faster rcnn则不需要。


返回包括两个值,一个是scores,一个是boxes。scores就是各个候选框中各个类别的概率,boxes就是各个候选中的目标的回归坐标。


im_detect方法首先调用_get_blobs函数,它输入im和boxes。在_get_blobs函数中首先调用_get_image_blob获得不同尺度的测试输入,测试尺度在cfg.TEST.SCALES中进行配置。

假如没有rpn网络,则boxes非空,这时候需要配置的输入为blobs['rois']。调用_get_rois_blob函数,它会调用_project_im_rois得到不同尺度的输入RoI。


假如有rpn网络,则需要配置blobs['im_info'],它会用于辅助RPN框架从特征空间到原始图像空间的映射。

Forward部分代码如下:

forward_kwargs = {'data': blobs['data'].astype(np.float32, copy=False)}

if cfg.TEST.HAS_RPN:

   forward_kwargs['im_info'] = blobs['im_info'].astype(np.float32, copy=False)

else:

   forward_kwargs['rois'] = blobs['rois'].astype(np.float32, copy=False)

blobs_out = net.forward(**forward_kwargs)

前向传播的结果在blobs_out中,分类器如果使用SVM,则分类结果为scores = net.blobs['cls_score'].data,如果使用cnn softmax,则分类结果为scores = blobs_out['cls_prob']。

如果有边界回归网络,获取回归结果的代码如下:

box_deltas = blobs_out['bbox_pred']

pred_boxes = bbox_transform_inv(boxes, box_deltas)

pred_boxes = clip_boxes(pred_boxes, im.shape)

可知原始的回归结果是一个偏移量,它需要通过bbox_transform_inv反投影到图像空间。test.py脚本中还包含函数apply_nms,用于对网络输出的结果进行非极大值抑制。


2.6 tools 目录

该目录包含的就是最高层的可执行脚本,包括_init_paths.py,compress_net.py,demo.py,eval_recall.py,reval.py,rpn_genetate.py这几个脚本。


_init_paths.py,用于初始化若干路径,包括caffe的路径以及lib的路径,一般大型的工程用这样的一个文件剥离出路径是很好的选择。


compress_net.py,这是用于压缩参数的脚本,使用了SVD矩阵分解的方法来对模型进行压缩,这通常对于全连接层是非常有效的,因为对于一些经典的网络如AlexNet,VGGNet等,全连接层占据了网络的绝大部分参数。脚本中给出的例子对VGGNet的fc6层和fc7层进行了压缩,读者可以使用这个脚本去对更多的带全连接层的网络进行压缩尝试。


demo.py,这是一个demo演示脚本,调用了fast_rcnn中的test脚本中的检测函数,使用了工程自带的一些图像以及预先提取好的proposal,配置好模型之后就可以进行演示。如果要测试自己的模型和数据,也可以非常方便进行修改。


eval_recall.py,这是用于在测试数据集上对所训练的模型进行评估的脚本,默认使用的数据集是voc_2007_test,它会统计在不同阈值下的检测框召回率。


reval.py:对已经检测好的结果进行评估。


rpn_genetate.py,这个脚本调用了rpn中的genetate函数,产生一个测试数据集的proposal并将其存储到pkl文件。


test_net.py,测试训练好的fast rcnn网络的脚本,调用了fast rcnn的test函数。


train_faster_rcnn_alt_opt.py,这是faster rcnn文章中的使用交替的训练方法来训练faster rcnn网络的具体实现,它包括4个阶段,分别是:


RPN第1阶段,使用在imagenet分类任务上进行训练的模型来初始化参数,生成proposals。

fast rcnn第1阶段,使用在imagenet分类任务上进行训练的模型来初始化参数,使用刚刚生成的proposal进行fast rcnn的训练。

RPN第2阶段,使用fast rcnn训练好的参数进行初始化,并生成proposal。

fast rcnn 第2阶段,使用RPN第2阶段中的模型进行参数初始化。

train_net.py,训练脚本。

train_svms.py,R-CNN网络的SVM训练脚本,可以不关注。

在熟悉了框架后,就可以使用我们的数据进行训练了。

03


网络分析

下面我们开始一个任务,就来个猫脸检测吧,使用VGG CNN 1024网络,看一下网络结构图,然后我们按模块解析一下。

3.1 input

layer {

  name: 'input-data'

  type: 'Python'

  top: 'data'

  top: 'im_info'

  top: 'gt_boxes'

  python_param {

    module: 'roi_data_layer.layer'

    layer: 'RoIDataLayer'

    param_str: "'num_classes': 2"

  }

}

这里要改的,就是num_classes,因为我们只有一个猫脸,前景类别数目等于1。


3.2 rpn

layer {

  name: "rpn_conv/3x3"

  type: "Convolution"

  bottom: "conv5"

  top: "rpn/output"

  param { lr_mult: 1.0 }

  param { lr_mult: 2.0 }

  convolution_param {

    num_output: 256

    kernel_size: 3 pad: 1 stride: 1

    weight_filler { type: "gaussian" std: 0.01 }

    bias_filler { type: "constant" value: 0 }

  }

}

layer {

  name: "rpn_relu/3x3"

  type: "ReLU"

  bottom: "rpn/output"

  top: "rpn/output"

}

layer {

  name: "rpn_cls_score"

  type: "Convolution"

  bottom: "rpn/output"

  top: "rpn_cls_score"

  param { lr_mult: 1.0 }

  param { lr_mult: 2.0 }

  convolution_param {

    num_output: 18   # 2(bg/fg) * 9(anchors)

    kernel_size: 1 pad: 0 stride: 1

    weight_filler { type: "gaussian" std: 0.01 }

    bias_filler { type: "constant" value: 0 }

  }

}

 

layer {

  name: "rpn_bbox_pred"

  type: "Convolution"

  bottom: "rpn/output"

  top: "rpn_bbox_pred"

  param { lr_mult: 1.0 }

  param { lr_mult: 2.0 }

  convolution_param {

    num_output: 36   # 4 * 9(anchors)

    kernel_size: 1 pad: 0 stride: 1

    weight_filler { type: "gaussian" std: 0.01 }

    bias_filler { type: "constant" value: 0 }

  }

}

具体的网络拓扑结构图如下:

从上图可以看出,rpn网络的输入来自于conv5卷积层的输出,后面接了rpn_conv/3x3层,输出通道数为256,stride=1。


rpn_conv/3x3层产生了两个分支,一个是rpn_cls_score,一个是rpn_bbox_pred,分别是分类和回归框的特征。


rpn_cls_score输出为18个通道,这是9个anchors的前背景概率,它一边和gt_boxes,im_info,data一起作为AnchorTargetLayer层的输入,产生分类的真值rpn_labels,回归的真值rpn_bbox_targets。另一边则经过rpn_cls_score_reshape进行reshape,然后与rpn_labels一起产生分类损失。


rpn_bbox_ppred输出为36个通道,就是9个anchors的回归预测结果,它与rpn_bbox_targets比较产生回归损失。rpn_cls_score_reshape重新reshape后得到rpn_cls_prob,rpn_cls_prob_shape,它与rpn_bbox_pred以及输入,共同得到了region prososal,就是候选的检测框。


在ProposalLayer层中配置了一个重要参数,就是feat_stride,这是前面的卷积层的feat_stride大小。ProposalLayer层完成的功能就是根据RPN的输出结果,提取出所需的目标框,而目标框是在原始的图像空间,所以这里需要预先计算出feat_stride的大小。ProposalLayer层的输出与data层一起获得最终的proposal roi,这将作为roi pooling层的输入。


3.3 roi pooing

前面得到了proposal roi之后,就可以进行roi pooling层,配置如下:

layer {

  name: "roi_pool5"

  type: "ROIPooling"

  bottom: "conv5"

  bottom: "rois"

  top: "pool5"

  roi_pooling_param {

    pooled_w: 6

    pooled_h: 6

    spatial_scale: 0.0625 # 1/16

  }

}

可以看到,它配置了几个参数,最终spatial_scale对应的就是前面的feat_stride,等于1/16,用于从图像空间的roi到特征空间roi的投影。

而pooled_w,pooled_h则是最终要pooling的特征图的大小,这里配置为6*6,从13*13的输入下采样而来。

04


训练与测试

写到这里我们就简略一些。要做的就是三步,为了简单,保持使用pascal接口,步骤如下。

(1)准备voc格式的数据,可以找开源数据集或者使用labelme等工具集标注,然后配置好路径。替换掉pacvoc的ImageSets/Main目录下面的文件list,以及JPEGS和Annotations目录下的文件。

(2)然后到lib\datasets\pascal_voc.py中更改self._classes中的类别,由于我们这里是二分类的检测,所以将多余的类别删除,只保留背景,添加face类别。

(3)使用experements/tools下面的脚本训练吧。


遇到了坑,就直接跳和爬吧!

感受一下大小脸,大姿态,遮挡,误检,漏检。

路漫漫其修远兮.......


如果想了解更多,欢迎关注知乎《有三AI学院》。


十月开始,我们有三AI学院开启了“稷”划和“济”划,帮助想入行以及想取得更多实战经验的同学。内容覆盖从自动驾驶到美颜直播等领域的实战项目,从图像基础到深度学习理论的系统知识,欢迎关注。

有三AI“【济】划”,从图像基础到深度学习

有三AI“十一月【稷】划”,从调参大法到3D重建

有三AI“十月【稷】划”,从自动驾驶到模型优化

《有三说深度学习系列课程》已经上线网易云,欢迎关注。


如果想加入我们,后台留言吧

转载后台联系,侵权必究


微信

Longlongtogo

公众号内容

1 图像基础|2 深度学习|3 行业信息


往期有三精选

学员分享

开源框架

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存