Mtcnn人臉檢測實踐(一)
放寒假了,閒來無事就開始搗鼓人臉識別了。這次看了一篇2016年的論文,算是比較新的了。論文提到一種名為“基於多工級聯卷積神經網路進行人臉檢測和對齊”的演算法,英文名 Joint Face Detection and Alignment using Multi-task Cascaded Convolutional Networks,簡稱MtCNN。論文地址如下:
說實話,當我看到這篇論文的時候,網上已經有很多介紹其原理的文章了。這些文章寫的很不錯,也給予我很大的幫助。因此,我更希望在自己的博文裡介紹更為具體的實現步驟,並分享自己實現過程中總結到的經驗。
演算法大體介紹
這部分的內容網上應該很多了,因此我簡要介紹一下。先看圖:
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 的輸入,將產生大小為的輸出。因為池化層的步長是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相反,因此程式碼中存在多個轉置操作,本質上是為了適應模型的處理。
MtCNN有很多實現的程式碼,我認為自己的實現較為簡潔且易於理解。由於目前程式處於測試階段,程式碼暫時不能公開。後續我將逐步公開自己的程式碼,並完善註釋資訊。感謝你的關注。此外,我在學習的過程中,參考了 David Sandberg 和 Seanlinx 的程式碼,在這裡一併表示感謝。