原理介绍
MTCNN,Multi-task convolutional neural network(多任务卷积神经网络),将人脸区域检测与人脸关键点检测放在了一起,它的主题框架类似于cascade。总体可分为P-Net、R-Net、和O-Net三层网络结构。
它是2016年中国科学院深圳研究院提出的用于人脸检测任务的多任务神经网络模型,该模型主要采用了三个级联的网络,采用候选框加分类器的思想,进行快速高效的人脸检测。这三个级联的网络分别是快速生成候选窗口的P-Net、进行高精度候选窗口过滤选择的R-Net和生成最终边界框与人脸关键点的O-Net。和很多处理图像问题的卷积神经网络模型,该模型也用到了图像金字塔、边框回归、非最大值抑制等技术。
MTCNN实现的流程就是下面这张图:
1.构建图像金字塔。
2.P-Net,NMS。
3.R-Net,NMS。
4.O-Net,NMS。

1、构建金字塔
首先我们将图片进行不同程度的缩放,来构建图像金字塔,已检测不同大小的人脸。主要由缩放系数factor进行,factor取值为0.709。简单来说,就是将图片长宽乘以0.709,直到长宽小于12。

'''
下面是代码:
我们输入一张图片,对图片的长宽进行调整,使其不低于500,这样不会太小
也不会太大。这里我们返回scale列表,里面是图片缩放比例。
'''
#-----------------------------#
# 计算原始输入图像
# 每一次缩放的比例
#-----------------------------#
def calculateScales(img):
copy_img = img.copy()
pr_scale = 1.0
h,w,_ = copy_img.shape
if min(w,h)>500:
pr_scale = 500.0/min(h,w)
w = int(w*pr_scale)
h = int(h*pr_scale)
elif max(w,h)<500:
pr_scale = 500.0/max(h,w)
w = int(w*pr_scale)
h = int(h*pr_scale)
scales = []
factor = 0.709
factor_count = 0
minl = min(h,w)
while minl >= 12:
scales.append(pr_scale*pow(factor, factor_count))
minl *= factor
factor_count += 1
return scales
2、PNet
全称为Proposal Network,其基本的构造是一个全卷积网络。对上一步构建完成的图像金字塔,通过一个全卷积网络进行初步特征提取与标定边框,并进行Bounding-Box Regression调整窗口与NMS进行大部分窗口的过滤。

PNet的网络结构是一个全卷积的神经网络结构,如下图所示:

该训练网络的输入是一个12*12大小的图片,所以训练前需要生成PNet网络的训练数据。训练数据可以通过和Guarantee True Box的IOU的计算生成一系列的bounding box。可以通过滑动窗口或者随机采样的方法获取训练数据,训练数据分为三种正样本,负样本,中间样本。其中正阳本是生成的滑动窗口和Guarantee True Box的IOU大于0.65,负样本是IOU小于0.3,中间样本是IOU大于0.4小于0.65。
然后把bounding box resize成12*12大小的图片,转换成12*12*3的结构,生成PNet网络的训练数据。训练数据通过10个3*3*3的卷积核 ,2*2 的Max Pooling(stride=2)操作,生成10个5*5的特征图。接着通过16个3*3*10的卷积核,生成16个3*3的特征图。接着通过32个3*3*16的卷积核,生成32个1*1的特征图。最后针对32个1*1的特征图,可以通过2个1*1*32的卷积核,生成2个1*1的特征图用于分类;4个1*1*32的卷积核,生成4个1*1的特征图用于回归框判断;10个1*1*32的卷积核,生成10个1*1的特征图用于人脸轮廓点的判断。
'''
下面是代码实现:
Pnet有两个输出,classifier用于判断这个网格点上的框的可信度,
bbox_regress表示框的位置。但是这里我们得到的位置不是真实位置,毕竟是缩放。
'''
#-----------------------------#
# 粗略获取人脸框
# 输出bbox位置和是否有人脸
#-----------------------------#
def create_Pnet(weight_path):
input = Input(shape=[None, None, 3])
x = Conv2D(10, (3, 3), strides=1, padding='valid', name='conv1')(input)
x = PReLU(shared_axes=[1,2],name='PReLU1')(x)
x = MaxPool2D(pool_size=2)(x)
x = Conv2D(16, (3, 3), strides=1, padding='valid', name='conv2')(x)
x = PReLU(shared_axes=[1,2],name='PReLU2')(x)
x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv3')(x)
x = PReLU(shared_axes=[1,2],name='PReLU3')(x)
classifier = Conv2D(2, (1, 1), activation='softmax', name='conv4-1')(x)
# 无激活函数,线性。
bbox_regress = Conv2D(4, (1, 1), name='conv4-2')(x)
model = Model([input], [classifier, bbox_regress])
model.load_weights(weight_path, by_name=True)
return model
下面是将bbox_regress映射到真实图像上,进行一次解码过程。
解码过程利用detect_face_12net函数实现,其实现步骤如下(需要配合代码理解):
1、判断哪些网格点的置信度较高,即该网格点内存在人脸。
2、记录该网格点的x,y轴。
3、利用函数计算bb1和bb2,分别代表图中框的左上角基点和右下角基点,二者之间差了11个像素,堆叠得到boundingbox 。(因为最小是12×12,所以用11)
4、利用bbox_regress计算解码结果,解码公式为boundingbox = boundingbox + offset12.0scale。(映射到原图)
#-------------------------------------#
# 对pnet处理后的结果进行处理
#-------------------------------------#
def detect_face_12net(cls_prob,roi,out_side,scale,width,height,threshold):
# 0,1表示维度的翻转
cls_prob = np.swapaxes(cls_prob, 0, 1)
roi = np.swapaxes(roi, 0, 2)
stride = 0
# stride略等于2,图片压缩比例(经过p-net导致的),(x,y)是有人脸概率大于threshold的点
if out_side != 1:
stride = float(2*out_side-1)/(out_side-1)
(x,y) = np.where(cls_prob>=threshold)
boundingbox = np.array([x,y]).T
# 找到对应原图的位置,p-net比例+图像金字塔比例
bb1 = np.fix((stride * (boundingbox) + 0 ) * scale)
bb2 = np.fix((stride * (boundingbox) + 11) * scale)
# plt.scatter(bb1[:,0],bb1[:,1],linewidths=1)
# plt.scatter(bb2[:,0],bb2[:,1],linewidths=1,c='r')
# plt.show()
boundingbox = np.concatenate((bb1,bb2),axis = 1)
dx1 = roi[0][x,y]
dx2 = roi[1][x,y]
dx3 = roi[2][x,y]
dx4 = roi[3][x,y]
score = np.array([cls_prob[x,y]]).T
offset = np.array([dx1,dx2,dx3,dx4]).T
# offset为偏置,得到在原图的框
boundingbox = boundingbox + offset*12.0*scale
rectangles = np.concatenate((boundingbox,score),axis=1)
# 把框变成正方形
rectangles = rect2square(rectangles)
pick = []
# 坐标不为0,且不超过长宽。
for i in range(len(rectangles)):
x1 = int(max(0 ,rectangles[i][0]))
y1 = int(max(0 ,rectangles[i][1]))
x2 = int(min(width ,rectangles[i][2]))
y2 = int(min(height,rectangles[i][3]))
sc = rectangles[i][4]
if x2>x1 and y2>y1:
pick.append([x1,y1,x2,y2,sc])
return NMS(pick,0.3)
#-----------------------------#
# 将长方形调整为正方形
#-----------------------------#
def rect2square(rectangles):
w = rectangles[:,2] - rectangles[:,0]
h = rectangles[:,3] - rectangles[:,1]
l = np.maximum(w,h).T
rectangles[:,0] = rectangles[:,0] + w*0.5 - l*0.5
rectangles[:,1] = rectangles[:,1] + h*0.5 - l*0.5
rectangles[:,2:4] = rectangles[:,0:2] + np.repeat([l], 2, axis = 0).T
return rectangles
3、RNet
全称为Refine Network,其基本的构造是一个卷积神经网络,相对于第一层的P-Net来说,增加了一个全连接层,因此对于输入数据的筛选会更加严格。在图片经过P-Net后,会留下许多预测窗口,我们将所有的预测窗口送入R-Net,这个网络会滤除大量效果比较差的候选框,最后对选定的候选框进行Bounding-Box Regression和NMS进一步优化预测结果。R-Net使用在最后一个卷积层之后使用了一个128的全连接层,保留了更多的图像特征,准确度性能也优于P-Net。
RNet的模型结构如下所示:

模型输入为24*24大小的图片,通过28个3*3*3的卷积核和3*3(stride=2)的max pooling后生成28个11*11的特征图;通过48个3*3*28的卷积核和3*3(stride=2)的max pooling后生成48个4*4的特征图;通过64个2*2*48的卷积核后,生成64个3*3的特征图;把3*3*64的特征图转换为128大小的全连接层;对回归框分类问题转换为大小为2的全连接层;对bounding box的位置回归问题,转换为大小为4的全连接层;对人脸轮廓关键点转换为大小为10的全连接层。
#-----------------------------#
# mtcnn的第二段
# 精修框
#-----------------------------#
def create_Rnet(weight_path):
input = Input(shape=[24, 24, 3])
# 24,24,3 -> 11,11,28
x = Conv2D(28, (3, 3), strides=1, padding='valid', name='conv1')(input)
x = PReLU(shared_axes=[1, 2], name='prelu1')(x)
x = MaxPool2D(pool_size=3,strides=2, padding='same')(x)
# 11,11,28 -> 4,4,48
x = Conv2D(48, (3, 3), strides=1, padding='valid', name='conv2')(x)
x = PReLU(shared_axes=[1, 2], name='prelu2')(x)
x = MaxPool2D(pool_size=3, strides=2)(x)
# 4,4,48 -> 3,3,64
x = Conv2D(64, (2, 2), strides=1, padding='valid', name='conv3')(x)
x = PReLU(shared_axes=[1, 2], name='prelu3')(x)
# 3,3,64 -> 64,3,3
x = Permute((3, 2, 1))(x)
x = Flatten()(x)
# 576 -> 128
x = Dense(128, name='conv4')(x)
x = PReLU( name='prelu4')(x)
# 128 -> 2 128 -> 4
classifier = Dense(2, activation='softmax', name='conv5-1')(x)
bbox_regress = Dense(4, name='conv5-2')(x)
model = Model([input], [classifier, bbox_regress])
model.load_weights(weight_path, by_name=True)
return model
同样,我们需要调整框位置和映射。
#-------------------------------------#
# 对pnet处理后的结果进行处理
#-------------------------------------#
def filter_face_24net(cls_prob,roi,rectangles,width,height,threshold):
prob = cls_prob[:,1]
pick = np.where(prob>=threshold)
rectangles = np.array(rectangles)
x1 = rectangles[pick,0]
y1 = rectangles[pick,1]
x2 = rectangles[pick,2]
y2 = rectangles[pick,3]
sc = np.array([prob[pick]]).T
dx1 = roi[pick,0]
dx2 = roi[pick,1]
dx3 = roi[pick,2]
dx4 = roi[pick,3]
w = x2-x1
h = y2-y1
x1 = np.array([(x1+dx1*w)[0]]).T
y1 = np.array([(y1+dx2*h)[0]]).T
x2 = np.array([(x2+dx3*w)[0]]).T
y2 = np.array([(y2+dx4*h)[0]]).T
rectangles = np.concatenate((x1,y1,x2,y2,sc),axis=1)
rectangles = rect2square(rectangles)
pick = []
for i in range(len(rectangles)):
x1 = int(max(0 ,rectangles[i][0]))
y1 = int(max(0 ,rectangles[i][1]))
x2 = int(min(width ,rectangles[i][2]))
y2 = int(min(height,rectangles[i][3]))
sc = rectangles[i][4]
if x2>x1 and y2>y1:
pick.append([x1,y1,x2,y2,sc])
return NMS(pick,0.3)
4、ONet
全称为Output Network,基本结构是一个较为复杂的卷积神经网络,相对于R-Net来说多了一个卷积层。O-Net的效果与R-Net的区别在于这一层结构会通过更多的监督来识别面部的区域,而且会对人的面部特征点进行回归,最终输出五个人脸面部特征点。
ONet是MTCNN中的最后一个网络,用于做网络的最后输出。ONet的训练数据生成类似于RNet,检测数据为图片经过PNet和RNet网络后,检测出来的bounding boxes,包括正样本,负样本和中间样本。ONet的模型结构如下所示:

模型输入是一个48*48*3大小的图片,通过32个3*3*3的卷积核和3*3(stride=2)的max pooling后转换为32个23*23的特征图;通过64个3*3*32的卷积核和3*3(stride=2)的max pooling后转换为64个10*10的特征图;通过64个3*3*64的卷积核和3*3(stride=2)的max pooling后转换为64个4*4的特征图;通过128个2*2*64的卷积核转换为128个3*3的特征图;通过全链接操作转换为256大小的全链接层;最好生成大小为2的回归框分类特征;大小为4的回归框位置的回归特征;大小为10的人脸轮廓位置回归特征。
#-----------------------------#
# mtcnn的第三段
# 精修框并获得五个点
#-----------------------------#
def create_Onet(weight_path):
input = Input(shape = [48,48,3])
# 48,48,3 -> 23,23,32
x = Conv2D(32, (3, 3), strides=1, padding='valid', name='conv1')(input)
x = PReLU(shared_axes=[1,2],name='prelu1')(x)
x = MaxPool2D(pool_size=3, strides=2, padding='same')(x)
# 23,23,32 -> 10,10,64
x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv2')(x)
x = PReLU(shared_axes=[1,2],name='prelu2')(x)
x = MaxPool2D(pool_size=3, strides=2)(x)
# 8,8,64 -> 4,4,64
x = Conv2D(64, (3, 3), strides=1, padding='valid', name='conv3')(x)
x = PReLU(shared_axes=[1,2],name='prelu3')(x)
x = MaxPool2D(pool_size=2)(x)
# 4,4,64 -> 3,3,128
x = Conv2D(128, (2, 2), strides=1, padding='valid', name='conv4')(x)
x = PReLU(shared_axes=[1,2],name='prelu4')(x)
# 3,3,128 -> 128,12,12
x = Permute((3,2,1))(x)
# 1152 -> 256
x = Flatten()(x)
x = Dense(256, name='conv5') (x)
x = PReLU(name='prelu5')(x)
# 鉴别
# 256 -> 2 256 -> 4 256 -> 10
classifier = Dense(2, activation='softmax',name='conv6-1')(x)
bbox_regress = Dense(4,name='conv6-2')(x)
landmark_regress = Dense(10,name='conv6-3')(x)
model = Model([input], [classifier, bbox_regress, landmark_regress])
model.load_weights(weight_path, by_name=True)
return model
同样,我们需要对结果进行处理:
#-------------------------------------#
# 对onet处理后的结果进行处理
#-------------------------------------#
def filter_face_48net(cls_prob,roi,pts,rectangles,width,height,threshold):
prob = cls_prob[:,1]
pick = np.where(prob>=threshold)
rectangles = np.array(rectangles)
x1 = rectangles[pick,0]
y1 = rectangles[pick,1]
x2 = rectangles[pick,2]
y2 = rectangles[pick,3]
sc = np.array([prob[pick]]).T
dx1 = roi[pick,0]
dx2 = roi[pick,1]
dx3 = roi[pick,2]
dx4 = roi[pick,3]
w = x2-x1
h = y2-y1
pts0= np.array([(w*pts[pick,0]+x1)[0]]).T
pts1= np.array([(h*pts[pick,5]+y1)[0]]).T
pts2= np.array([(w*pts[pick,1]+x1)[0]]).T
pts3= np.array([(h*pts[pick,6]+y1)[0]]).T
pts4= np.array([(w*pts[pick,2]+x1)[0]]).T
pts5= np.array([(h*pts[pick,7]+y1)[0]]).T
pts6= np.array([(w*pts[pick,3]+x1)[0]]).T
pts7= np.array([(h*pts[pick,8]+y1)[0]]).T
pts8= np.array([(w*pts[pick,4]+x1)[0]]).T
pts9= np.array([(h*pts[pick,9]+y1)[0]]).T
x1 = np.array([(x1+dx1*w)[0]]).T
y1 = np.array([(y1+dx2*h)[0]]).T
x2 = np.array([(x2+dx3*w)[0]]).T
y2 = np.array([(y2+dx4*h)[0]]).T
rectangles=np.concatenate((x1,y1,x2,y2,sc,pts0,pts1,pts2,pts3,pts4,pts5,pts6,pts7,pts8,pts9),axis=1)
pick = []
for i in range(len(rectangles)):
x1 = int(max(0 ,rectangles[i][0]))
y1 = int(max(0 ,rectangles[i][1]))
x2 = int(min(width ,rectangles[i][2]))
y2 = int(min(height,rectangles[i][3]))
if x2>x1 and y2>y1:
pick.append([x1,y1,x2,y2,rectangles[i][4],
rectangles[i][5],rectangles[i][6],rectangles[i][7],rectangles[i][8],rectangles[i][9],rectangles[i][10],rectangles[i][11],rectangles[i][12],rectangles[i][13],rectangles[i][14]])
return NMS(pick,0.3)
5、非极大抑制NMS
作用:去除预测结果中重复的框
预测出的结果出现很多重复框的原因:
- 由于我们传入一张图片时,会不断对图片做金字塔下采样,每一次都要对采样后的图片去检测人脸;
- 当我们检测时是用P网络以步长为2的大小在图片上滑动,由于滑动步长过小,会导致一个人脸被框了很多次

假设我们第一次在一张图上框出了五个人脸,置信度分别为0.98,0.83,0.75,0.81,0.67,前三个置信度为一个人脸,后两个为一个人脸。
我们将这五个框根据置信度排序,取出最大的置信度(0.98)的框分别和剩下的框做iou,保留iou小于阈值的框(代码中阈值设置的是0.3),这样就剩下0.81和0.67这两个框了,重复上面的过程,取出置信度(0.81)大的框,和剩下的框做iou,保留iou小于阈值的框。这样最后只剩下0.98和0.81这两个人脸框了。
nms的计算方式
def NMS(rectangles,threshold):
if len(rectangles)==0:
return rectangles
boxes = np.array(rectangles)
x1 = boxes[:,0]
y1 = boxes[:,1]
x2 = boxes[:,2]
y2 = boxes[:,3]
s = boxes[:,4]
area = np.multiply(x2-x1+1, y2-y1+1)
I = np.array(s.argsort())
pick = []
while len(I)>0:
xx1 = np.maximum(x1[I[-1]], x1[I[0:-1]]) #I[-1] have hightest prob score, I[0:-1]->others
yy1 = np.maximum(y1[I[-1]], y1[I[0:-1]])
xx2 = np.minimum(x2[I[-1]], x2[I[0:-1]])
yy2 = np.minimum(y2[I[-1]], y2[I[0:-1]])
w = np.maximum(0.0, xx2 - xx1 + 1)
h = np.maximum(0.0, yy2 - yy1 + 1)
inter = w * h
o = inter / (area[I[-1]] + area[I[0:-1]] - inter)
pick.append(I[-1])
I = I[np.where(o<=threshold)[0]]
result_rectangle = boxes[pick].tolist()
return result_rectangle
MTCNN模型推理
MTCNN的Inference流程如下图所示:

由原始图片和PNet生成预测的bounding boxes。输入原始图片和PNet生成的bounding box,通过RNet,生成校正后的bounding box。输入元素图片和RNet生成的bounding box,通过ONet,生成校正后的bounding box和人脸面部轮廓关键点。执行过程如下所示:
1.首先读入要检测的图片:image = cv2.imread(imagepath)
2.加载训练好的模型参数,构建检测对象:detector = MtcnnDetector
3.执行推理操作:all_boxes,landmarks = detector.detect_face(image)
4.绘制目标框:cv2.rectangle(image, box,(0,0,255))
不足之处:可以看出,MTCNN是使用分阶段的方式实现人脸检测及人脸对齐任务,即每一个阶段由一个网络组成,使用中需要对这些网络进行分阶段的训练,这样的识别方式显然不是一种端对端的学习方式,人脸识别效率慢。且网络泛化能力弱、鲁棒性差。