webooru icon indicating copy to clipboard operation
webooru copied to clipboard

MTCNN算法与代码理解—人脸检测和人脸对齐联合学习 - shine-lee - 博客园

Open madobet opened this issue 4 years ago • 0 comments

博客:blog.shinelee.me | 博客园 | CSDN

主页https://kpzhang93.github.io/MTCNN_face_detection_alignment/index.html
论文https://arxiv.org/abs/1604.02878
代码官方 matlab 版C++ caffe 版
第三方训练代码tensorflowmxnet

MTCNN,恰如论文标题《Joint Face Detection and Alignment using Multi-task Cascaded Convolutional Networks》所言,采用级联 CNN 结构,通过多任务学习,同时完成了两个任务——人脸检测和人脸对齐,输出人脸的 Bounding Box 以及人脸的关键点(眼睛、鼻子、嘴)位置

MTCNN 又好又快,提出时在FDDBWIDER FACEAFLW数据集上取得了当时(2016 年 4 月)最好的结果,速度又快,现在仍被广泛使用作为人脸识别的前端,如InsightFacefacenet

MTCNN 效果为什么好,文中提了 3 个主要的原因:

  1. 精心设计的级联 CNN 架构(carefully designed cascaded CNNs architecture)
  2. 在线困难样本挖掘(online hard sample mining strategy)
  3. 人脸对齐联合学习(joint face alignment learning)

下面详细介绍。

总体而言,MTCNN 方法可以概括为:图像金字塔 + 3 阶段级联 CNN,如下图所示

对输入图像建立金字塔是为了检测不同尺度的人脸,通过级联 CNN 完成对人脸 由粗到细(coarse-to-fine) 的检测,所谓级联指的是 前者的输出是后者的输入,前者往往先使用少量信息做个大致的判断,快速将不是人脸的区域剔除,剩下可能包含人脸的区域交给后面更复杂的网络,利用更多信息进一步筛选,这种由粗到细的方式在保证召回率的情况下可以大大提高筛选效率。下面为 MTCNN 中级联的 3 个网络(P-Net、R-Net、O-Net),可以看到它们的网络层数逐渐加深输入图像的尺寸(感受野)在逐渐变大 12→24→48最终输出的特征维数也在增加 32→128→256,意味着利用的信息越来越多。

工作流程是怎样的?
首先,对原图通过双线性插值构建图像金字塔,可以参看前面的博文《人脸检测中,如何构建输入图像金字塔》。构建好金字塔后,将金字塔中的图像逐个输入给 P-Net。

  • P-Net:其实是个全卷积神经网络(FCN),前向传播得到的特征图在每个位置是个 32 维的特征向量,用于判断每个位置处约 12×12 大小的区域内是否包含人脸,如果包含人脸,则回归出人脸的 Bounding Box,进一步获得 Bounding Box 对应到原图中的区域,通过NMS保留分数最高的 Bounding box 以及移除重叠区域过大的 Bounding Box。
  • R-Net:是单纯的卷积神经网络(CNN),先将 P-Net 认为可能包含人脸的 Bounding Box 双线性插值到 24×24,输入给 R-Net,判断是否包含人脸,如果包含人脸,也回归出 Bounding Box,同样经过 NMS 过滤。
  • O-Net:也是纯粹的卷积神经网络(CNN),将 R-Net 认为可能包含人脸的 Bounding Box 双线性插值到 48×48,输入给 O-Net,进行人脸检测和关键点提取。

需要注意的是:

  1. face classification判断是不是人脸使用的是 softmax,因此输出是 2 维的,一个代表是人脸,一个代表不是人脸
  2. bounding box regression回归出的是 bounding box 左上角和右下角的偏移 dx1,dy1,dx2,dy2,因此是 4 维的
  3. facial landmark localization回归出的是左眼、右眼、鼻子、左嘴角、右嘴角共 5 个点的位置,因此是 10 维的
  4. 训练阶段,3 个网络都会将关键点位置作为监督信号来引导网络的学习, 但在预测阶段,P-Net 和 R-Net 仅做人脸检测,不输出关键点位置(因为这时人脸检测都是不准的),关键点位置仅在 O-Net 中输出。
  5. Bounding box关键点输出均为归一化后的相对坐标,Bounding Box 是相对待检测区域(R-Net 和 O-Net 是相对输入图像),归一化是相对坐标除以检测区域的宽高,关键点坐标是相对 Bounding box 的坐标,归一化是相对坐标除以 Bounding box 的宽高,这里先建立起初步的印象,具体可以参看后面准备训练数据部分和预测部分的代码细节。

MTCNN 效果好的第 1 个原因是精心设计的级联 CNN 架构,其实,级联的思想早已有之,而使用级联 CNN 进行人脸检测的方法是在 2015 CVPR《A convolutional neural network cascade for face detection》中被率先提出,MTCNN 与之的差异在于:

  • 减少卷积核数量(层内)
  • 将 5×5 的卷积核替换为 3×3
  • 增加网络深度

这样使网络的表达能力更强,同时运行时间更少。

MTCNN 效果好的后面 2 个原因在线困难样本挖掘人脸对齐联合学习将在下一节介绍。

MTCNN 的多任务学习有 3 个任务,1 个分类 2 个回归,分别为 face classification、bounding box regression 以及 facial landmark localization,分类的损失函数使用交叉熵损失,回归的损失函数使用欧氏距离损失,如下:

对于第 i 个样本,Lidet 为判断是不是人脸的交叉熵损失,Libox 为 bounding box 回归的欧式距离损失,Lilandmark 为关键点定位的欧氏距离损失,任务间权重通过αj 协调,配置如下:

同时,训练数据中有含人脸的、有不含人脸的、有标注了关键点的、有没标注关键点的,不同数据能参与的训练任务不同,比如不含人脸的负样本自然无法用于训练 bounding box 回归和关键点定位,于是有了βij∈{0,1},指示每个样本能参与的训练任务,例如对于不含人脸的负样本其βidet=1,βibox=0,βilandmark=0。

至此,我们已经清楚了 MTCNN 多任务学习的损失函数。

MTCNN 准备了 4 种训练数据:

  1. Negatives:与 ground-truth faces 的 IOU<0.3 的图像区域,lable = 0
  2. Positives:与 ground-truth faces 的 IOU≥0.65 的图像区域,lable = 1
  3. Part faces:与 ground-truth faces 的 0.4≤IOU<0.65 的图像区域,lable = -1
  4. Landmark faces:标记了 5 个关键点的人脸图像,lable = -2

这 4 种数据是如何组织的呢?以MTCNN-Tensorflow为例:

Since MTCNN is a Multi-task Network,we should pay attention to the format of training data.The format is:
[path to image] [cls_label] [bbox_label] [landmark_label]
For neg sample, cls_label=0, bbox_label=[0,0,0,0], landmark_label=[0,0,0,0,0,0,0,0,0,0].
For pos sample, cls_label=1, bbox_label(calculate), landmark_label=[0,0,0,0,0,0,0,0,0,0].
For part sample, cls_label=-1, bbox_label(calculate), landmark_label=[0,0,0,0,0,0,0,0,0,0].
For landmark sample, cls_label=-2, bbox_label=[0,0,0,0], landmark_label(calculate).

数量之比依次为 3:1:1:2,其中,Negatives、Positives 和 Part faces 通过 WIDER FACE 数据集 crop 得到,landmark faces 通过 CelebA 数据集 crop 得到,先 crop 区域,然后看这个区域与哪个 ground-truth face 的 IOU 最大,根据最大 IOU 来生成 label,比如小于 0.3 的标记为 negative。

P-Net 训练数据的准备可以参见 gen_12net_data.py、gen_landmark_aug_12.py、gen_imglist_pnet.py 和 gen_PNet_tfrecords.py,代码很直观,这里略过 crop 过程,重点介绍 bounding box label 和 landmark label 的生成。下面是 gen_12net_data.py 和 gen_landmark_aug_12.py 中的代码片段,bounding box 和 landmark 的 label 为归一化后的相对坐标offset_x1, offset_y1, offset_x2, offset_y2为 bounding box 的 label,使用 crop 区域的 size 进行归一化rv为 landmark 的 label,使用 bbox 的宽高进行归一化,注意两者的归一化是不一样的,具体见代码:



size = npr.randint(int(min(w, h) * 0.8), np.ceil(1.25 * max(w, h)))


if w<5:
    print (w)
    continue

delta_x = npr.randint(-w * 0.2, w * 0.2)
delta_y = npr.randint(-h * 0.2, h * 0.2)




nx1 = int(max(x1 + w / 2 + delta_x - size / 2, 0))

ny1 = int(max(y1 + h / 2 + delta_y - size / 2, 0))
nx2 = nx1 + size
ny2 = ny1 + size

if nx2 > width or ny2 > height:
    continue 
crop_box = np.array([nx1, ny1, nx2, ny2])


offset_x1 = (x1 - nx1) / float(size) 
offset_y1 = (y1 - ny1) / float(size)
offset_x2 = (x2 - nx2) / float(size)
offset_y2 = (y2 - ny2) / float(size)

cropped_im = img[ny1 : ny2, nx1 : nx2, :]

resized_im = cv2.resize(cropped_im, (12, 12), interpolation=cv2.INTER_LINEAR)





for index, one in enumerate(landmarkGt):
    
    rv = ((one[0]-gt_box[0])/(gt_box[2]-gt_box[0]), (one[1]-gt_box[1])/(gt_box[3]-gt_box[1]))
    
    landmark[index] = rv

需要注意的是,对于 P-Net,其为 FCN,预测阶段输入图像可以为任意大小,但在训练阶段,使用的训练数据均被 resize 到 12×12,以便于控制正负样本的比例(避免数据不平衡)。

因为是级联结构训练要分阶段依次进行,训练好 P-Net 后,用 P-Net 产生的候选区域来训练 R-Net,训练好 R-Net 后,再生成训练数据来训练 O-Net。P-Net 训练好之后,根据其结果准备 R-Net 的训练数据,R-Net 训练好之后,再准备 O-Net 的训练数据,过程是类似的,具体可以参见相关代码,这里就不赘述了。

4 种训练数据参与的训练任务如下:

  • Negatives 和 Positives 用于训练 face classification
  • Positives 和 Part faces 用于训练 bounding box regression
  • landmark faces 用于训练 facial landmark localization

据此来设置βij,对每一个样本看其属于那种训练数据,对其能参与的任务将β置为 1,不参与的置为 0。

至于在线困难样本挖掘,仅在训练 face/non-face classification 时使用,具体做法是:对每个 mini-batch 的数据先通过前向传播,挑选损失最大的前 70% 作为困难样本,在反向传播时仅使用这 70% 困难样本产生的损失。文中的实验表明,这样做在 FDDB 数据级上可以带来 1.5 个点的性能提升。

具体怎么实现的?这里以MTCNN-Tensorflow / train_models / mtcnn_model.py代码为例,用label来指示是哪种数据,下面为代码,重点关注valid_indslosssquare_error)的计算(对应βij),以及cls_ohem中的困难样本挖掘




num_keep_radio = 0.7 


def cls_ohem(cls_prob, label):
    zeros = tf.zeros_like(label)
    

    
    label_filter_invalid = tf.where(tf.less(label,0), zeros, label)
    num_cls_prob = tf.size(cls_prob)
    cls_prob_reshape = tf.reshape(cls_prob,[num_cls_prob,-1])
    label_int = tf.cast(label_filter_invalid,tf.int32)
    
    num_row = tf.to_int32(cls_prob.get_shape()[0])
    
    row = tf.range(num_row)*2
    indices_ = row + label_int
    label_prob = tf.squeeze(tf.gather(cls_prob_reshape, indices_))
    loss = -tf.log(label_prob+1e-10)
    zeros = tf.zeros_like(label_prob, dtype=tf.float32)
    ones = tf.ones_like(label_prob,dtype=tf.float32)
    
    valid_inds = tf.where(label < zeros,zeros,ones)
    
    num_valid = tf.reduce_sum(valid_inds)

    
    keep_num = tf.cast(num_valid*num_keep_radio,dtype=tf.int32)
    
    loss = loss * valid_inds
    loss,_ = tf.nn.top_k(loss, k=keep_num) 
    return tf.reduce_mean(loss)



def bbox_ohem(bbox_pred,bbox_target,label):
    '''

    :param bbox_pred:
    :param bbox_target:
    :param label: class label
    :return: mean euclidean loss for all the pos and part examples
    '''
    zeros_index = tf.zeros_like(label, dtype=tf.float32)
    ones_index = tf.ones_like(label,dtype=tf.float32)
    
    valid_inds = tf.where(tf.equal(tf.abs(label), 1),ones_index,zeros_index)
    
    
    square_error = tf.square(bbox_pred-bbox_target)
    square_error = tf.reduce_sum(square_error,axis=1)
    
    num_valid = tf.reduce_sum(valid_inds)
    
    
    keep_num = tf.cast(num_valid, dtype=tf.int32)
    
    square_error = square_error*valid_inds
    
    _, k_index = tf.nn.top_k(square_error, k=keep_num)
    square_error = tf.gather(square_error, k_index)

    return tf.reduce_mean(square_error)


def landmark_ohem(landmark_pred,landmark_target,label):
    '''
    :param landmark_pred:
    :param landmark_target:
    :param label:
    :return: mean euclidean loss
    '''
    
    ones = tf.ones_like(label,dtype=tf.float32)
    zeros = tf.zeros_like(label,dtype=tf.float32)
    valid_inds = tf.where(tf.equal(label,-2),ones,zeros) 
    square_error = tf.square(landmark_pred-landmark_target)
    square_error = tf.reduce_sum(square_error,axis=1)
    num_valid = tf.reduce_sum(valid_inds)
    
    keep_num = tf.cast(num_valid, dtype=tf.int32)
    square_error = square_error*valid_inds 
    _, k_index = tf.nn.top_k(square_error, k=keep_num)
    square_error = tf.gather(square_error, k_index)
    return tf.reduce_mean(square_error)


多任务学习的代码片段如下:


if net == 'PNet':
    image_size = 12
    radio_cls_loss = 1.0;radio_bbox_loss = 0.5;radio_landmark_loss = 0.5;
elif net == 'RNet':
    image_size = 24
    radio_cls_loss = 1.0;radio_bbox_loss = 0.5;radio_landmark_loss = 0.5;
else:
    radio_cls_loss = 1.0;radio_bbox_loss = 0.5;radio_landmark_loss = 1;
    image_size = 48



total_loss_op  = radio_cls_loss*cls_loss_op + radio_bbox_loss*bbox_loss_op + radio_landmark_loss*landmark_loss_op + L2_loss_op
train_op, lr_op = train_model(base_lr, total_loss_op, num)

def train_model(base_lr, loss, data_num):
    """
    train model
    :param base_lr: base learning rate
    :param loss: loss
    :param data_num:
    :return:
    train_op, lr_op
    """
    lr_factor = 0.1
    global_step = tf.Variable(0, trainable=False)
    
    
    boundaries = [int(epoch * data_num / config.BATCH_SIZE) for epoch in config.LR_EPOCH]
    
    lr_values = [base_lr * (lr_factor ** x) for x in range(0, len(config.LR_EPOCH) + 1)]
    
    lr_op = tf.train.piecewise_constant(global_step, boundaries, lr_values)
    optimizer = tf.train.MomentumOptimizer(lr_op, 0.9)
    train_op = optimizer.minimize(loss, global_step)
    return train_op, lr_op

以上对应论文中的损失函数。

预测过程与算法 Pipeline 详解一节讲述的一致,直接看一下官方 matlab 代码,这里重点关注 P-Net FCN 是如何获得 Bounding box 的,以及 O-Net 最终是如何得到 landmark 的,其余部分省略。

将图像金字塔中的每张图像输入给 P-Net,若当前输入图像尺寸为 M×N,在 bounding box regression 分支上将得到一个 3 维张量 m×n×4,共有 m×n 个位置,每个位置对应输入图像中一个 12×12 的区域,而输入图像相对原图的尺度为scale,进一步可以获得每个位置对应到原图中的区域范围,如下所示:

而每个位置处都有个 4 维的向量,其为 bounding box 左上角和右下角的偏移dx1, dy1, dx2, dy2,通过上面的训练过程,我们知道它们是归一化之后的相对坐标,通过对应的区域以及归一化后的相对坐标就可以获得原图上的 bounding box,如下所示,dx1, dy1, dx2, dy2为归一化的相对坐标,求到原图中的 bounding box 坐标的过程为生成训练数据 bounding box label 的逆过程。

landmark 位置通过 O-Net 输出得到,将人脸候选框 resize 到 48×48 输入给 O-Net,先获得 bounding box(同上),因为 O-Net 输出的 landmark 也是归一化后的相对坐标,通过 bounding box 的长宽和 bounding box 左上角求取 landmark 在原图中的位置,如下所示:

至此,预测过程中的要点也介绍完毕了,以上。

madobet avatar Jun 28 '20 04:06 madobet