博客:blog.shinelee.me | 博客園 | CSDN
寫在前面
主頁:https://kpzhang93.github.io/MTCNN_face_detection_alignment/index.html
論文:https://arxiv.org/abs/1604.02878
代碼:官方matlab版、C++ caffe版
第三方訓練代碼:tensorflow、mxnet
MTCNN,恰如論文標題《Joint Face Detection and Alignment using Multi-task Cascaded Convolutional Networks》所言,采用級聯CNN結構,通過多任務學習,同時完成了兩個任務——人臉檢測和人臉對齊,輸出人臉的Bounding Box以及人臉的關鍵點(眼睛、鼻子、嘴)位置。
MTCNN 又好又快,提出時在FDDB、WIDER FACE和AFLW數據集上取得了當時(2016年4月)最好的結果,速度又快,現在仍被廣泛使用作為人臉識別的前端,如InsightFace和facenet。
MTCNN效果為什么好,文中提了3個主要的原因:
- 精心設計的級聯CNN架構(carefully designed cascaded CNNs architecture)
- 在線困難樣本挖掘(online hard sample mining strategy)
- 人臉對齊聯合學習(joint face alignment learning)
下面詳細介紹。
算法Pipeline詳解
總體而言,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\times12\)大小的區域內是否包含人臉,如果包含人臉,則回歸出人臉的Bounding Box,進一步獲得Bounding Box對應到原圖中的區域,通過NMS保留分數最高的Bounding box以及移除重疊區域過大的Bounding Box。
- R-Net:是單純的卷積神經網絡(CNN),先將P-Net認為可能包含人臉的Bounding Box 雙線性插值到\(24\times24\),輸入給R-Net,判斷是否包含人臉,如果包含人臉,也回歸出Bounding Box,同樣經過NMS過濾。
- O-Net:也是純粹的卷積神經網絡(CNN),將R-Net認為可能包含人臉的Bounding Box 雙線性插值到\(48\times 48\),輸入給O-Net,進行人臉檢測和關鍵點提取。
需要注意的是:
- face classification判斷是不是人臉使用的是softmax,因此輸出是2維的,一個代表是人臉,一個代表不是人臉
- bounding box regression回歸出的是bounding box左上角和右下角的偏移\(dx1, dy1, dx2, dy2\),因此是4維的
- facial landmark localization回歸出的是左眼、右眼、鼻子、左嘴角、右嘴角共5個點的位置,因此是10維的
- 在訓練階段,3個網絡都會將關鍵點位置作為監督信號來引導網絡的學習, 但在預測階段,P-Net和R-Net僅做人臉檢測,不輸出關鍵點位置(因為這時人臉檢測都是不准的),關鍵點位置僅在O-Net中輸出。
- 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\times 5\)的卷積核替換為\(3\times 3\)
- 增加網絡深度
這樣使網絡的表達能力更強,同時運行時間更少。
MTCNN效果好的后面2個原因在線困難樣本挖掘和人臉對齊聯合學習將在下一節介紹。
如何訓練
損失函數
MTCNN的多任務學習有3個任務,1個分類2個回歸,分別為face classification、bounding box regression以及facial landmark localization,分類的損失函數使用交叉熵損失,回歸的損失函數使用歐氏距離損失,如下:

對於第\(i\)個樣本,\(L_i^{det}\)為判斷是不是人臉的交叉熵損失,\(L_i^{box}\)為bounding box回歸的歐式距離損失,\(L_i^{landmark}\)為關鍵點定位的歐氏距離損失,任務間權重通過\(\alpha_j\)協調,配置如下:

同時,訓練數據中有含人臉的、有不含人臉的、有標注了關鍵點的、有沒標注關鍵點的,不同數據能參與的訓練任務不同,比如不含人臉的負樣本自然無法用於訓練bounding box回歸和關鍵點定位,於是有了\(\beta_i^j \in \{ 0, 1\}\),指示每個樣本能參與的訓練任務,例如對於不含人臉的負樣本其\(\beta_i^{det}=1, \beta_i^{box}=0, \beta_i^{landmark}=0\)。
至此,我們已經清楚了MTCNN多任務學習的損失函數。
訓練數據准備
MTCNN准備了4種訓練數據:
- Negatives:與ground-truth faces的\(IOU < 0.3\)的圖像區域,
lable = 0 - Positives:與ground-truth faces的\(IOU \ge 0.65\)的圖像區域,
lable = 1 - Part faces:與ground-truth faces的\(0.4 \le IOU < 0.65\)的圖像區域,
lable = -1 - 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的寬高進行歸一化,注意兩者的歸一化是不一樣的,具體見代碼:
## in gen_12net_data.py
# pos and part face size [minsize*0.8,maxsize*1.25]
size = npr.randint(int(min(w, h) * 0.8), np.ceil(1.25 * max(w, h)))
# delta here is the offset of box center
if w<5:
print (w)
continue
#print (box)
delta_x = npr.randint(-w * 0.2, w * 0.2)
delta_y = npr.randint(-h * 0.2, h * 0.2)
#show this way: nx1 = max(x1+w/2-size/2+delta_x)
# x1+ w/2 is the central point, then add offset , then deduct size/2
# deduct size/2 to make sure that the right bottom corner will be out of
nx1 = int(max(x1 + w / 2 + delta_x - size / 2, 0))
#show this way: ny1 = max(y1+h/2-size/2+delta_y)
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])
#yu gt de offset
##### x1 y1 x2 y2 為 ground truth bbox, nx1 ny1 nx2 ny2為crop的區域,size為crop的區域size ######
offset_x1 = (x1 - nx1) / float(size)
offset_y1 = (y1 - ny1) / float(size)
offset_x2 = (x2 - nx2) / float(size)
offset_y2 = (y2 - ny2) / float(size)
#crop
cropped_im = img[ny1 : ny2, nx1 : nx2, :]
#resize
resized_im = cv2.resize(cropped_im, (12, 12), interpolation=cv2.INTER_LINEAR)
##########################################################################
## in gen_landmark_aug_12.py
#normalize land mark by dividing the width and height of the ground truth bounding box
# landmakrGt is a list of tuples
for index, one in enumerate(landmarkGt):
# (( x - bbox.left)/ width of bounding box, (y - bbox.top)/ height of bounding box
rv = ((one[0]-gt_box[0])/(gt_box[2]-gt_box[0]), (one[1]-gt_box[1])/(gt_box[3]-gt_box[1]))
# put the normalized value into the new list landmark
landmark[index] = rv
需要注意的是,對於P-Net,其為FCN,預測階段輸入圖像可以為任意大小,但在訓練階段,使用的訓練數據均被resize到\(12\times 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
據此來設置\(\beta_i^j\),對每一個樣本看其屬於那種訓練數據,對其能參與的任務將\(\beta\)置為1,不參與的置為0。
至於在線困難樣本挖掘,僅在訓練face/non-face classification時使用,具體做法是:對每個mini-batch的數據先通過前向傳播,挑選損失最大的前70%作為困難樣本,在反向傳播時僅使用這70%困難樣本產生的損失。文中的實驗表明,這樣做在FDDB數據級上可以帶來1.5個點的性能提升。
具體怎么實現的?這里以MTCNN-Tensorflow / train_models / mtcnn_model.py代碼為例,用label來指示是哪種數據,下面為代碼,重點關注valid_inds和loss(square_error)的計算(對應\(\beta_i^j\)),以及cls_ohem中的困難樣本挖掘。
# in mtcnn_model.py]
# pos=1, neg=0, part=-1, landmark=-2
# 通過cls_ohem, bbox_ohem, landmark_ohem來計算損失
num_keep_radio = 0.7 # mini-batch前70%做為困難樣本
# face/non-face 損失,注意在線困難樣本挖掘(前70%)
def cls_ohem(cls_prob, label):
zeros = tf.zeros_like(label)
#label=-1 --> label=0net_factory
#pos -> 1, neg -> 0, others -> 0
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)
# get the number of rows of class_prob
num_row = tf.to_int32(cls_prob.get_shape()[0])
#row = [0,2,4.....]
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)
# set pos and neg to be 1, rest to be 0
valid_inds = tf.where(label < zeros,zeros,ones)
# get the number of POS and NEG examples
num_valid = tf.reduce_sum(valid_inds)
###### 困難樣本數量 #####
keep_num = tf.cast(num_valid*num_keep_radio,dtype=tf.int32)
#FILTER OUT PART AND LANDMARK DATA
loss = loss * valid_inds
loss,_ = tf.nn.top_k(loss, k=keep_num) ##### 僅取困難樣本反向傳播 #####
return tf.reduce_mean(loss)
# bounding box損失
#label=1 or label=-1 then do regression
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)
# keep pos and part examples
valid_inds = tf.where(tf.equal(tf.abs(label), 1),ones_index,zeros_index)
#(batch,)
#calculate square sum
square_error = tf.square(bbox_pred-bbox_target)
square_error = tf.reduce_sum(square_error,axis=1)
#keep_num scalar
num_valid = tf.reduce_sum(valid_inds)
#keep_num = tf.cast(num_valid*num_keep_radio,dtype=tf.int32)
# count the number of pos and part examples
keep_num = tf.cast(num_valid, dtype=tf.int32)
#keep valid index square_error
square_error = square_error*valid_inds
# keep top k examples, k equals to the number of positive examples
_, 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
'''
#keep label =-2 then do landmark detection
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) ##### 將label=-2的置為1,其余為0 #####
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*num_keep_radio,dtype=tf.int32)
keep_num = tf.cast(num_valid, dtype=tf.int32)
square_error = square_error*valid_inds # 在計算landmark_ohem損失時只計算beta=1的 #####
_, 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)
多任務學習的代碼片段如下:
# in train.py
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)
#LR_EPOCH [8,14]
#boundaried [num_batch,num_batch]
boundaries = [int(epoch * data_num / config.BATCH_SIZE) for epoch in config.LR_EPOCH]
#lr_values[0.01,0.001,0.0001,0.00001]
lr_values = [base_lr * (lr_factor ** x) for x in range(0, len(config.LR_EPOCH) + 1)]
#control learning rate
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\times N\),在bounding box regression分支上將得到一個3維張量\(m\times n \times 4\),共有\(m\times n\)個位置,每個位置對應輸入圖像中一個\(12\times 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\times 48\)輸入給O-Net,先獲得bounding box(同上),因為O-Net輸出的landmark也是歸一化后的相對坐標,通過bounding box的長寬和bounding box左上角求取landmark 在原圖中的位置,如下所示:

至此,預測過程中的要點也介紹完畢了,以上。
