1. 程式人生 > >MTCNN 人臉檢測論文解讀,及tensorflow程式碼實現

MTCNN 人臉檢測論文解讀,及tensorflow程式碼實現

MTCNN簡介

《Joint Face Detection and Alignment using Multi-task Cascaded Convolutional Networks》論文解讀。
相比於R-CNN系列通用檢測方法,本文更加針對人臉檢測這一專門的任務,速度和精度都有足夠的提升。R-CNN,Fast R-CNN,FasterR-CNN這一系列的方法不是一篇部落格能講清楚的,有興趣可以找相關論文閱讀。類似於TCDCN,本文提出了一種Multi-task的人臉檢測框架,將人臉檢測和人臉特徵點檢測同時進行。論文使用3個CNN級聯的方式,和Viola-Jones類似,實現了coarse-to-fine的演算法結構。

MTCNN主要包括三個部分,PNet,RNet,ONet
其中PNet在訓練階段的輸入尺寸為12*12,RNet的輸入尺寸為24*24, ONet的輸入尺寸為48*48.  PNet網路引數最小,ceffemodel僅有28.2KB, 所以速度最快.RNet的網路引數次之,caffemodel大小為407.9KB, ONet的caffemodel大小為1.6M,三個網絡合起來不到2M.

訓練階段

訓練資料生成

該演算法訓練資料來源於wider和celeba兩個公開的資料庫,wider提供人臉檢測資料,在大圖上標註了人臉框groundtruth的座標資訊,celeba提供了5個landmark點的資料。根據參與任務的不同,將訓練資料分為四類:人臉正樣本(positives)、非人臉負樣本(negatives)、部分臉(partfaces)、關鍵點(landmark)。positives、negatives、partfaces由隨機取的框與groundtruth的overlap區域比例的大小決定,大於0.65為positives,小於0.3為negatives,0.4到0.65之間為partfaces。positives和negatives參與到分類任務,positives和partfaces參與到迴歸任務,landmark資料參與到關鍵點回歸任務。關鍵點回歸僅在第三個net中實用,此時landmark資料的人臉框位置可由前兩個net的模型檢測得到,或是由landmark的座標位置擬合出來。在每個batchSize中的樣本比例如下,positives:negatives:partfaces:landmark = 1 : 3 : 1 : 2。到此為止,資料的來源組成我們都交代完畢,但是如何生成訓練資料呢,這也是很多MTCNN的擁簇者希望能開源訓練程式碼。本文以caffe為例,採用hdf5資料格式,先由指令碼隨機在wider上擷取positives、negatives、partfaces,注意要覆蓋到不同尺度的,負樣本的量級達到2000w左右(該文作者透露)。之所以採用hdf5的資料格式,是考慮到能夠方便的實現多label,以前兩個net為例,筆者採用的label為7為,分別是 f1 c1 f2 dx1 dy1 dx2 dy2,f1和f2是標記位,f1標記該樣本是否參與分類,f2標記該樣本是否參與迴歸,f1和f2後面緊跟的是真實的label資訊,c1是樣本類別,dx dy是偏移量。與此對應的是,筆者自己實現了帶標誌位的softmax和euclidean loss

正負樣本,部分樣本提取:

  1. 從Wider_face隨機選出邊框,然後和標註資料計算IOU,如果大於0.65,則為正樣本,大於0.4小於0.65為部分樣本,小於0.4為負樣本.
  2. 計算邊框偏移.對於邊框,(x1,y1)為左上角座標,(x2,y2)為右下角座標,新剪裁的邊框座標為(xn1,yn1),(xn2,yn2),width,height.則offset_x1 = (x1 - xn1)/width,同上,計算另三個點的座標偏移.
  3. 對於正樣本,部分樣本均有邊框資訊,而對於負樣本不需要邊框資訊

關鍵點樣本提取

  1. 從celeba中提取,可以根據標註的邊框,在滿足正樣本的要求下,隨機裁剪出圖片,然後調整關鍵點的座標.
#
生成positive,negative,part樣本 import sys sys.path.append('D:\\Anaconda2\\libs') # 在windows系統上匯入python庫目錄 import numpy as np import cv2 import os import numpy.random as npr from utils import IoU # stdsize隨機crop的視窗大小positive,negative,part樣本都對應此大小 stdsize = 48 anno_file = "E:/face_alignment/data/CelebA/Anno/mtcnn_train_label_2.txt" im_dir = "E:/face_alignment/data/CelebA/Img/img_celeba.7z/img_celeba/" pos_save_dir = str(stdsize) + "/positive" part_save_dir = str(stdsize) + "/part" neg_save_dir = str(stdsize) + '/negative' save_dir = "./" + str(stdsize) # 生成資料夾函式 def mkr(dr): if not os.path.exists(dr): os.mkdir(dr) mkr(save_dir) mkr(pos_save_dir) mkr(part_save_dir) mkr(neg_save_dir) # 開啟儲存pos,neg,part檔名標籤的txt檔案這三個是生成檔案 f1 = open(os.path.join(save_dir, 'pos_' + str(stdsize) + '.txt'), 'w') f2 = open(os.path.join(save_dir, 'neg_' + str(stdsize) + '.txt'), 'w') f3 = open(os.path.join(save_dir, 'part_' + str(stdsize) + '.txt'), 'w') # 開啟原始圖片標註txt檔案 with open(anno_file, 'r') as f: annotations = f.readlines() num = len(annotations) print "%d pics in total" % num p_idx = 0 # positive n_idx = 0 # negative d_idx = 0 # part idx = 0 box_idx = 0 # 原始圖片根據標註的bbox生成negative,posotive,part圖片標註形式也做相應變化 for annotation in annotations: #逐行讀取按作者的方式每行為一個原圖 annotation = annotation.strip().split(' ') #對讀取的每一行按空格進行切片 im_path = annotation[0] # 第1個為圖片名 bbox = map(float, annotation[1:-10]) #第2個~~倒數第11個為bbox # pts = map(float, annotation[5:]) pts = map(float, annotation[-10:]) #倒數第10個~~倒數第1個為landmark # 對bbox進行reshape4個一列 boxes = np.array(bbox, dtype=np.float32).reshape(-1, 4) im_path = im_dir + im_path #圖片地址拼接 img = cv2.imread(im_path) #讀取圖片 idx += 1 if idx % 100 == 0: print idx, "images done" height, width, channel = img.shape neg_num = 0 # 生成nagative每個原圖生成100個negative sample while neg_num < 100: # size表示neg樣本大小在40和min(width, height)/2之間隨機取一個整數 size = npr.randint(40, min(width, height) / 2) # neg的左上角座標(x1,y1)在0和(width - size)之間隨機取一個整數 nx = npr.randint(0, width - size) ny = npr.randint(0, height - size) # 隨機生成的bbox位置(x1,y1),(x2,y2) crop_box = np.array([nx, ny, nx + size, ny + size]) # 計算隨機生成的bbox和原圖中所有標註bboxs的交併比 Iou = IoU(crop_box, boxes) # 在原圖中crop對應的區域圖片作為negative sample cropped_im = img[ny : ny + size, nx : nx + size, :] # 對crop的影象進行resize大小為stdsize*stdsize resized_im = cv2.resize(cropped_im, (stdsize, stdsize), interpolation=cv2.INTER_LINEAR) # 如果crop_box與所有boxes的Iou都小於0.3那麼認為它是nagative sample if np.max(Iou) < 0.3: # Iou with all gts must below 0.3 # 儲存圖片的地址和圖片名 save_file = os.path.join(neg_save_dir, "%s.jpg"%n_idx) # 往neg_48.txt檔案中寫入該negative樣本的圖片地址和名字分類標籤 f2.write(str(stdsize)+"/negative/%s"%n_idx + ' 0\n') # 儲存該負樣本圖片 cv2.imwrite(save_file, resized_im) n_idx += 1 neg_num += 1 backupPts = pts[:] # 該列表用於landmark for box in boxes: #逐行讀取每次迴圈處理一個box # box (x_left, y_top, x_right, y_bottom) x1, y1, x2, y2 = box w = x2 - x1 + 1 h = y2 - y1 + 1 # 忽略小臉 # in case the ground truth boxes of small faces are not accurate if max(w, h) < 12 or x1 < 0 or y1 < 0: continue # 生成 positive examples and part faces # 每個box隨機生成50個boxIou>=0.65的作為positive examples0.4<=Iou<0.65的作為part faces其他忽略 for i in range(50): pts = backupPts[:] # size表示隨機生成樣本的大小在int(min(w, h) * 0.8) np.ceil(1.25 * max(w, h)) 之間 size = npr.randint(int(min(w, h) * 0.8), np.ceil(1.25 * max(w, h))) # delta 表示相對於標註box center的偏移量 delta_x = npr.randint(-w * 0.2, w * 0.2) delta_y = npr.randint(-h * 0.2, h * 0.2) # nx,ny表示偏移後的box座標位置 nx1 = max(x1 + w / 2 + delta_x - size / 2, 0) ny1 = max(y1 + h / 2 + delta_y - size / 2, 0) nx2 = nx1 + size ny2 = ny1 + size # 去掉超出原圖的box if nx2 > width or ny2 > height: continue crop_box = np.array([nx1, ny1, nx2, ny2]) #bbox偏移量的計算 x1 = nx1 + float(size)*offset_x1 推導而來可以參考bounding box regression部落格 offset_x1 = (x1 - nx1) / float(size) offset_y1 = (y1 - ny1) / float(size) offset_x2 = (x2 - nx1) / float(size) offset_y2 = (y2 - ny1) / float(size) # landmark偏移量的計算即landmark相對於隨機生成bbox的歸一化相對位置 for k in range(len(pts) / 2): pts[k*2] = (pts[k*2] - nx1) / float(size); pts[k*2+1] = (pts[k*2+1] - ny1) / float(size); cropped_im = img[int(ny1) : int(ny2), int(nx1) : int(nx2), :] resized_im = cv2.resize(cropped_im, (stdsize, stdsize), interpolation=cv2.INTER_LINEAR) # 將box reshape為一行 box_ = box.reshape(1, -1) # Iou>=0.65的作為positive examples if IoU(crop_box, box_) >= 0.65: save_file = os.path.join(pos_save_dir, "%s.jpg"%p_idx) # 將圖片路徑類別偏移量寫入到positive_48.txt檔案中 f1.write(str(stdsize)+"/positive/%s"%p_idx + ' 1 %f %f %f %f'%(offset_x1, offset_y1, offset_x2, offset_y2)) # 將landmark寫入到positive_48.txt檔案中 for k in range(len(pts)): f1.write(" %f" % pts[k]) f1.write("\n") cv2.imwrite(save_file, resized_im) p_idx += 1 # 0.4<=Iou<0.65的作為part faces elif IoU(crop_box, box_) >= 0.4: save_file = os.path.join(part_save_dir, "%s.jpg"%d_idx) f3.write(str(stdsize)+"/part/%s"%d_idx + ' -1 %f %f %f %f'%(offset_x1, offset_y1, offset_x2, offset_y2)) for k in range(len(pts)): f3.write(" %f" % pts[k]) f3.write("\n") cv2.imwrite(save_file, resized_im) d_idx += 1 box_idx += 1 print "%s images done, pos: %s part: %s neg: %s"%(idx, p_idx, d_idx, n_idx) f1.close() f2.close() f3.close()

網路結構

三個net的網路結構如上圖所示,注意pnet是全卷積的結構,不包含fc層。筆者在訓練pnet和rnet的時候,並沒有加入landmark迴歸的任務,分類和人臉框迴歸的loss_weight之比為1:0.5,onet加入landmark迴歸,分類、人臉框迴歸和關鍵點回歸的loss_weight之比為1:0.5:0.5。
Net arth.png
訓練主要包括三個任務

  1. 人臉分類任務:利用正樣本和負樣本進行訓練
  2. 人臉邊框迴歸任務:利用正樣本和部分樣本進行訓練
  3. 關鍵點檢測任務:利用關鍵點樣本進行訓練

代價函式

Loss function1.pngLoss function2.png
loss修改 由於訓練過程中需要同時計算3個loss,但是對於不同的任務,每個任務需要的loss不同. 所有在整理資料中,對於每個圖片進行了15個label的標註資訊

  1. 第1列:為正負樣本標誌,1正樣本,0負樣本,2部分樣本,3關鍵點資訊
  2. 第2-5列:為邊框偏移,為float型別,對於無邊框資訊的資料,全部置為-1
  3. 第6-15列:為關鍵點偏移,為floagt型別,對於無邊框資訊的資料,全部置為-1

修改softmax_loss_layer.cpp 增加判斷,只對於1,0計算loss值 修改euclidean_loss_layer.cpp 增加判斷,對於置為-1的不進行loss計算 困難樣本選擇 論文中作者對與人臉分類任務,採用了線上困難樣本選擇,實現過程如下: 修改softmax_loss_layer.cpp,根據計算出的loss值,進行排序,只對於前70%對應的資料,進行反向傳播.

預測階段

TIP:預測可輸入任意大小的圖片(因為P_net是全卷積網路)

Test pipline.png

  • Proposal Network (P-Net):在構建影象金字塔的基礎上,利用fully convolutional network來進行檢測,同時利用boundingbox regression 和 NMS來進行修正。(注意:這裡的全卷積網路與R-CNN裡面帶反捲積的網路是不一樣的,這裡只是指只有卷積層,可以接受任意尺寸的輸入,靠網路stride來自動完成滑窗)
  • Refine Network (R-Net):該網路結構還是通過邊界框迴歸和NMS來去掉那些false-positive區域。

只是由於該網路結構和P-Net網路結構有差異,多了一個全連線層,所以會取得更好的抑制false-positive的作用。

  • Output Network (O-Net):該層比R-Net層又多了一層卷基層,所以處理的結果會更加精細。作用和R-Net層作用一樣。但是該層對人臉區域進行了更多的監督,同時還會輸出5個地標(landmark)。

IOU概念:
IOU.png

def IoU(box, boxes):
    """Compute IoU between detect box and gt boxes

    Parameters:
    ----------
    box: numpy array , shape (5, ): x1, y1, x2, y2, score
        input box
    boxes: numpy array, shape (n, 4): x1, y1, x2, y2
        input ground truth boxes

    Returns:
    -------
    ovr: numpy.array, shape (n, )
        IoU
    """
    box_area = (box[2] - box[0] + 1) * (box[3] - box[1] + 1)
    area = (boxes[:, 2] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 1] + 1)
    xx1 = np.maximum(box[0], boxes[:, 0])
    yy1 = np.maximum(box[1], boxes[:, 1])
    xx2 = np.minimum(box[2], boxes[:, 2])
    yy2 = np.minimum(box[3], boxes[:, 3])

    # compute the width and height of the bounding box
    w = np.maximum(0, xx2 - xx1 + 1)
    h = np.maximum(0, yy2 - yy1 + 1)

    inter = w * h
    ovr = inter / (box_area + area - inter)
    return ovr

非極大值抑制(NMS)概念:
RCNN會從一張圖片中找出n個可能是物體的矩形框,然後為每個矩形框為做類別分類概率:
NMS.png
就像上面的圖片一樣,定位一個車輛,最後演算法就找出了一堆的方框,我們需要判別哪些矩形框是沒用的。非極大值抑制的方法是:先假設有6個矩形框,根據分類器的類別分類概率做排序,假設從小到大屬於車輛的概率 分別為A、B、C、D、E、F。

  1. 從最大概率矩形框F開始,分別判斷A~E與F的重疊度IOU是否大於某個設定的閾值;
  2. 假設B、D與F的重疊度超過閾值,那麼就扔掉B、D;並標記第一個矩形框F,是我們保留下來的。
  3. 從剩下的矩形框A、C、E中,選擇概率最大的E,然後判斷E與A、C的重疊度,重疊度大於一定的閾值,那麼就扔掉;並標記E是我們保留下來的第二個矩形框。

就這樣一直重複,找到所有被保留下來的矩形框。 非極大值抑制(NMS)顧名思義就是抑制不是極大值的元素,搜尋區域性的極大值。這個區域性代表的是一個鄰域,鄰域有兩個引數可變,一是鄰域的維數,二是鄰域的大小。這裡不討論通用的NMS演算法,而是用於在目標檢測中用於提取分數最高的視窗的。例如在行人檢測中,滑動視窗經提取特徵,經分類器分類識別後,每個視窗都會得到一個分數。但是滑動視窗會導致很多視窗與其他視窗存在包含或者大部分交叉的情況。這時就需要用到NMS來選取那些鄰域裡分數最高(是行人的概率最大),並且抑制那些分數低的視窗。

def py_nms(dets, thresh, mode="Union"):
    """
    greedily select boxes with high confidence
    keep boxes overlap <= thresh
    rule out overlap > thresh
    :param dets: [[x1, y1, x2, y2 score]]
    :param thresh: retain overlap <= thresh
    :return: indexes to keep
    """
    x1 = dets[:, 0]
    y1 = dets[:, 1]
    x2 = dets[:, 2]
    y2 = dets[:, 3]
    scores = dets[:, 4]

    areas = (x2 - x1 + 1) * (y2 - y1 + 1)
    order = scores.argsort()[::-1]

    keep = []
    while order.size > 0:
        i = order[0]
        keep.append(i)
        xx1 = np.maximum(x1[i], x1[order[1:]])
        yy1 = np.maximum(y1[i], y1[order[1:]])
        xx2 = np.minimum(x2[i], x2[order[1:]])
        yy2 = np.minimum(y2[i], y2[order[1:]])

        w = np.maximum(0.0, xx2 - xx1 + 1)
        h = np.maximum(0.0, yy2 - yy1 + 1)
        inter = w * h
        if mode == "Union":
            ovr = inter / (areas[i] + areas[order[1:]] - inter)
        elif mode == "Minimum":
            ovr = inter / np.minimum(areas[i], areas[order[1:]])
        #keep
        inds = np.where(ovr <= thresh)[0]
        order = order[inds + 1]

    return keep

預測實現程式碼

class MtcnnDetector(object):


    def __init__(self,
                 detectors,
                 min_face_size=25,
                 stride=2,
                 threshold=[0.6, 0.7, 0.7],
                 scale_factor=0.79,
                 #scale_factor=0.709,#change
                 slide_window=False):

        self.pnet_detector = detectors[0]
        self.rnet_detector = detectors[1]
        self.onet_detector = detectors[2]
        self.min_face_size = min_face_size
        self.stride = stride
        self.thresh = threshold
        self.scale_factor = scale_factor
        self.slide_window = slide_window

    def convert_to_square(self, bbox):
        """
            convert bbox to square
        Parameters:
        ----------
            bbox: numpy array , shape n x 5
                input bbox
        Returns: