放寒假了,闲来无事就开始捣鼓人脸识别了。这次看了一篇2016年的论文,算是比较新的了。论文提到一种名为“基于多任务级联卷积神经网络进行人脸检测和对齐”的算法,英文名 Joint Face Detection and Alignment using Multi-task Cascaded Convolutional Networks,简称MtCNN。论文地址如下:
MTCNN_face_detection_alignment
说实话,当我看到这篇论文的时候,网上已经有很多介绍其原理的文章了。这些文章写的很不错,也给予我很大的帮助。因此,我更希望在自己的博文里介绍更为具体的实现步骤,并分享自己实现过程中总结到的经验。
算法大体介绍
这部分的内容网上应该很多了,因此我简要介绍一下。先看图:
MtCNN总体来说分为三个部分:PNet、RNet和ONet。将图片输入后,首先由PNet分析,产生若干个候选框(包括候选框坐标、候选框中5个关键点坐标,共14个数据),以及每个候选框的置信度,然后将所有候选框进行NMS(非极大值抑制算法)计算,将输出结果映射到原图像上。随后,在原图像上截取出PNet确定的所有图像片段,并将其缩放至24×24大小,然后交由RNet处理。
RNet经过计算,输出每个候选框的置信度和修正值。此时,再次运行NMS算法,将置信度高于阈值的候选框加以修正(即加上修正值),然后输出结果。输出结果后,在原图像中裁剪出RNet确定的图像片段,缩放至48×48后交由ONet处理。ONet的处理方式和RNet相似,也是首先进行运算,然后产生置信度和修正值,此外还会产生5个关键点坐标。将产生的结果进行NMS计算后,将置信度大于阈值的候选框进行修正,最后输出结果。
因此,MtCNN使用PNet确定所有候选框,这里主要发挥了PNet体积小、速度快的优势。在确定候选框后,由RNet和ONet进行进一步精炼,最后得出结果。接下来,我将针对每个网络进行具体介绍。
PNet
先看PNet的网络结构图:
PNet的结构十分简单,首先是1个3×3的卷积层,然后是1个2×2的最大池化层(这里的padding均为valid)。池化完毕后,继续跟2个3×3的卷积层,此时结果的维度变为1×1×32,最后分别跟3个不同的1×1卷积层,产生1×1×2、1×1×4和1×1×10共3种输出。
因此,PNet是全卷积网络(FCN),全卷积网络的优点在于可以输入任意尺寸的图像,同时使用卷积运算代替了滑动窗口运算,大幅提高了效率。关于全卷积网络的详细介绍,请参阅我的另一篇博文。总之,使用全卷积网络的PNet,不仅支持任意大小的图像,在速度上还比传统的滑动窗口法快很多。这大概就是最近很多级联目标检测算法都使用全卷积网络作为第一层网络的原因。
由于PNet的输入大小是12×12,因此单纯的PNet只能检测12×12大小的图像中的人脸。这显然是不实际的。为了让PNet能够检测多尺度范围的人脸,有必要对原图像进行缩放。这就是引入图像金字塔的原因。
图像金字塔
首先确定最小大小(一般设为12,即PNet的输入大小),然后将图片按照一定的缩放比(例如0.79)进行缩放。当缩放后的图片的长、宽中,有一个小于等于12时,则停止缩放。至此,从原始尺寸到最小尺寸,产生了一系列图像。此时,12×12的PNet就可以检测大小不同的人脸了。因为检测框大小不变,但是输入图像的尺寸发生了变化。相关代码如下所示:
# img: 输入图像
img_h, img_w, _ = img.shape
min_size = min(img_h, img_w)
scale_list, scale = [1.0], 1.0
total_boxes = np.empty((0, 9))
while min_size >= 12:
# 将缩放后的图像大小加入列表中
scale_list.append(scale)
# factor: 缩放比,这里设为0.79
min_size *= factor
scale *= factor
for scale in scale_list:
# 按照图像大小对图像进行缩放
resize = (int(img_w * scale), int(img_h * scale))
img_resized = cv2.resize(img, resize,
interpolation=cv2.INTER_AREA)
# 对图像进行预处理(中心化)
img_resized = (img_resized - 127.5) * 0.0078125
# 计算,并获得计算结果
cls, pos = self.pnet.get_output(self.sess,
np.transpose([img_resized], (0, 2, 1, 3)))
将图像缩放完毕后,就可以进行计算了。对于 x * y 的输入,将产生大小为 ( x − 12 2 + 1 ) ∗ ( y − 12 2 + 1 ) (\frac{x-12}{2}+1) * (\frac{y-12}{2}+1) (2x−12+1)∗(2y−12+1)的输出。因为池化层的步长是2,所以上述式子的分母为2。产生结果后,需要做的就是产生边界框(Bounding Box)。这里的边界框是一个比较坑的地方,考虑到PNet的输入实在太小,因此在训练的时候很难截取到完全合适的人脸,因此训练边界框的生成时广泛采用了部分样本。因此,PNet直接输出的边界框并不是传统回归中的边界坐标,而是预测人脸位置相对于输入图片的位置差。所以,需要专门的算法将位置差转换为真实位置。
相关代码如下:
def gen_bbox(cls, reg, threshold, scale):
cls = np.transpose(cls)
# 获取4个坐标点
dx1 = np.transpose(reg[:, :, 0])
dy1 = np.transpose(reg[:, :, 1])
dx2 = np.transpose(reg[:, :, 2])
dy2 = np.transpose(reg[:, :, 3])
# 获取置信度大于阈值的边界框
row, col = np.where(cls >= threshold)
score = cls[(row, col)]
reg = np.transpose(np.vstack([dx1[(row, col)],
dy1[(row, col)], dx2[(row, col)], dy2[(row, col)]]))
pos = np.transpose(np.vstack([(row, col)]))
# 转换为真实位置
bbox = np.hstack([np.fix((2 * pos + 1) / scale),
np.fix((2 * pos + 12) / scale),
np.expand_dims(score, 1), reg])
return bbox, reg
上述代码中,cls是每个候选框的置信度。由于图像是二维的,所以cls也是二维的。因此,row和col就是置信度大于阈值的候选框的坐标。那么,bbox中的前两项就是每个候选框在原始图像中的像素坐标(乘上2是因为搜索的步长为2)。因此,bbox的内容分别为:bbox所在的候选框的像素坐标、候选框的置信度以及候选框自身的坐标位置差。
接下来,对上述产生的结果使用NMS算法,算法的本质就是挑选出置信度最大的候选框:
def nms(boxes, threshold, use_min=False):
x1, y1 = boxes[:, 0], boxes[:, 1]
x2, y2 = oxes[:, 2], boxes[:, 3]
score, area = boxes[:, 4], (x2 - x1 + 1) * (y2 - y1 + 1)
score_idx, counter = np.argsort(score), 0
pick = np.zeros_like(score, dtype=np.int16)
while score_idx.size > 0:
max_idx = score_idx[-1]
pick[counter] = max_idx
idx = score_idx[0:-1]
xx1 = np.maximum(x1[max_idx], x1[idx])
yy1 = np.maximum(y1[max_idx], y1[idx])
xx2 = np.minimum(x2[max_idx], x2[idx])
yy2 = np.minimum(y2[max_idx], y2[idx])
inter = np.maximum(0.0, xx2 - xx1 + 1) *
np.maximum(0.0, yy2 - yy1 + 1)
if use_min:
out = inter / np.minimum(area[max_idx], area[idx])
else:
out = inter / (area[max_idx] + area[idx] - inter)
score_idx = score_idx[np.where(out <= threshold)]
counter += 1
pick = pick[0:counter]
return pick
NMS算法计算完毕后,返回从输入的bbox中挑选出的目标索引,因此首先根据索引挑选出目标bbox,然后根据目标bbox中指定的像素坐标和坐标位置差,确定人脸的真实坐标。根据上面所说,bbox的前面4项是bbox在原图像中的像素坐标,而最后面四项是候选框区域相对于像素坐标的偏差。因此,将原像素坐标加上偏差值,即可得到候选框的坐标。
具体实现代码如下:
picks = self.pnet.nms(total_boxes, 0.7)
total_boxes = total_boxes[picks, :]
# 获得BBox的长度和宽度
box_w = total_boxes[:, 2] - total_boxes[:, 0]
box_h = total_boxes[:, 3] - total_boxes[:, 1]
# 根据长度和宽度以及偏差,得出BBox中人脸的位置
offset_x1 = total_boxes[:, 0] + total_boxes[:, 5] * box_w
offset_y1 = total_boxes[:, 1] + total_boxes[:, 6] * box_h
offset_x2 = total_boxes[:, 2] + total_boxes[:, 7] * box_w
offset_y2 = total_boxes[:, 3] + total_boxes[:, 8] * box_h
total_boxes = np.transpose(np.vstack([offset_x1, offset_y1,
offset_x2, offset_y2, total_boxes[:, 4]]))
# 修正结果为正方形
total_boxes = self.pnet.to_square(total_boxes)
此时,产生的 total_boxes 就是PNet的运行结果了。在交由RNet处理之前,进行 to_square 操作,操作的目的在于将候选框修正为方形。因为RNet和ONet的输入都是方形的,所以直接修正输入框为方形,比通过图像缩放的方法强制修改为方形效果更好,后者会造成图像显示失真。修正为方形的代码实现如下:
def to_square(bbox):
width = bbox[:, 2] - bbox[:, 0]
height = bbox[:, 3] - bbox[:, 1]
length = np.maximum(width, height)
bbox[:, 0] = bbox[:, 0] + width * 0.5 - length * 0.5
bbox[:, 1] = bbox[:, 1] + height * 0.5 - length * 0.5
bbox[:, 2:4] = bbox[:, 0:2] + np.transpose
(np.tile(length, (2, 1)))
return bbox
至此,PNet的运行就结束了。产生的结果将输入RNet层进行下一步操作。RNet将在下一篇文章中介绍。
注意: 由于实际应用时不需要5个特征点的信息,因此我在实现的时候并没有编写求特征点的代码。此外,由于模型在训练过程中,对x坐标和y坐标的判定方式和OpenCV相反,因此代码中存在多个转置操作,本质上是为了适应模型的处理。
我在学习的过程中,参考了 David Sandberg 和 Seanlinx 的代码,在这里一并表示感谢。