天天看點

人臉識别系列三 | MTCNN算法詳解下篇

前言

上篇講解了MTCNN算法的算法原理以及訓練細節,這篇文章主要從源碼實作的角度來解析一下MTCNN算法。我要解析的代碼來自github的https://github.com/ElegantGod/ncnn中的mtcnn.cpp。

網絡結構

再貼一下MTCNN的網絡結構,友善注釋代碼的時候可以随時檢視。

人臉識别系列三 | MTCNN算法詳解下篇

MTCNN代碼運作流程

人臉識别系列三 | MTCNN算法詳解下篇

代碼中的關鍵參數

  • nms_threshold: 三次非極大值抑制篩選人臉框的IOU門檻值,三個網絡可以分别設定,值設定的過小,nms合并的太少,會産生較多的備援計算。
  • threshold:人臉框得分門檻值,三個網絡可單獨設定門檻值,值設定的太小,會有很多框通過,也就增加了計算量,還有可能導緻最後不是人臉的框錯認為人臉。
  • mean_vals:三個網絡輸入圖檔的均值,需要單獨設定。
  • norm_vals:三個網絡輸入圖檔的縮放系數,需要單獨設定。
  • min_size: 最小可檢測圖像,該值大小,可控制圖像金字塔的階層數的參數之一,越小,階層越多,計算越多。本代碼取了40。
  • factor:生成圖像金字塔時候的縮放系數, 範圍(0,1),可控制圖像金字塔的階層數的參數之一,越大,階層越多,計算越多。本文取了0.709。
  • MIN_DET_SIZE:代表PNet的輸入圖像長寬,都為12。

代碼執行流程

生成圖像金字塔

關鍵參數minsize和factor共同決定了圖像金字塔的層數,也就是生成的圖檔數量。

這部分的代碼如下:

// 縮放到12為止
  int MIN_DET_SIZE = 12;
  // 可以檢測的最小人臉
  int minsize = 40;
  float m = (float)MIN_DET_SIZE / minsize;
  minl *= m;
  float factor = 0.709;
  int factor_count = 0;
  vector<float> scales_;
  while (minl>MIN_DET_SIZE) {
    if (factor_count>0)m = m*factor;
    scales_.push_back(m);
    minl *= factor;
    factor_count++;
  }      

這部分代碼中的​

​MIN_DET_SIZE​

​代表縮放的最小尺寸不可以小于12,也就是從原圖縮放到12為止。​

​scales​

​這個​

​vector​

​儲存的是每次縮放的系數,它的尺寸代表了可以縮放出的圖檔的數量。其中​

​minsize​

​代表可以檢測到的最小人臉大小,這裡設定為40。縮放後的圖檔尺寸可以用以下公式計算:

m

i

n

L

=

o

r

g

L

(

12

/

m

i

n

s

i

z

e

)

f

a

c

t

o

r

n

minL=orgL*(12/minsize)*factor^n

minL=orgL∗(12/minsize)∗factorn,其中n就是​

​scales​

​的長度,即特征金字塔層數。

PNet

Pnet隻做檢測和回歸任務。在上篇文章中我們知道PNet是要求12*12的輸入的,實際上再訓練的時候是這樣做的。但是測試的時候并不需要把金字塔的每張圖像resize到12乘以12喂給PNet,因為它是全卷積網絡,以直接将resize後的圖像喂給網絡進行Forward。這個時候得到的結果就不是

1

1

2

1*1*2

1∗1∗2和

1

1

4

1*1*4

1∗1∗4,而是

m

m

2

m*m*2

m∗m∗2和

m

m

4

m*m*4

m∗m∗4。這樣就不用先從resize的圖上截取各種

12

12

3

12*12*3

12∗12∗3的圖再送入網絡了,而是一次性送入,再根據結果回推每個結果對應的

12

12

12*12

12∗12的圖在輸入圖檔的什麼位置。

然後對于金字塔的每張圖,網絡forward後都會得到屬于人臉的機率以及人臉框回歸的結果。每張圖檔會得到

m

m

2

m*m*2

m∗m∗2個分類得分和

m

m

4

m*m*4

m∗m∗4個人回歸坐标,然後結合​

​scales​

​可以将每個滑窗映射回原圖,得到真實坐标。

接下來,先根據上面的​

​threshold​

​參數将得分低的區域排除掉,然後執行一遍NMS去除一部分備援的重疊框,最後,PNet就得到了一堆人臉框,當然結果還不精細,需要繼續往下走。Pnet的代碼為:

for (size_t i = 0; i < scales_.size(); i++) {
        int hs = (int)ceil(img_h*scales_[i]);
        int ws = (int)ceil(img_w*scales_[i]);
        //ncnn::Mat in = ncnn::Mat::from_pixels_resize(image_data, ncnn::Mat::PIXEL_RGB2BGR, img_w, img_h, ws, hs);
        ncnn::Mat in;
        resize_bilinear(img_, in, ws, hs);
        //in.substract_mean_normalize(mean_vals, norm_vals);
        ncnn::Extractor ex = Pnet.create_extractor();
        ex.set_light_mode(true);
        ex.input("data", in);
        ncnn::Mat score_, location_;
        ex.extract("prob1", score_);
        ex.extract("conv4-2", location_);
        std::vector<Bbox> boundingBox_;
        std::vector<orderScore> bboxScore_;
        generateBbox(score_, location_, boundingBox_, bboxScore_, scales_[i]);
        nms(boundingBox_, bboxScore_, nms_threshold[0]);

        for (vector<Bbox>::iterator it = boundingBox_.begin(); it != boundingBox_.end(); it++) {
            if ((*it).exist) {
                firstBbox_.push_back(*it);
                order.score = (*it).score;
                order.oriOrder = count;
                firstOrderScore_.push_back(order);
                count++;
            }
        }
        bboxScore_.clear();
        boundingBox_.clear();
    }      

其中有2個關鍵的函數,分别是​

​generateBox​

​​和​

​nms​

​​,我們分别來解析一下,首先看​

​generateBox​

​:

// 根據Pnet的輸出結果,由滑框的得分,篩選可能是人臉的滑框,并記錄該框的位置、人臉坐标資訊、得分以及編号
void mtcnn::generateBbox(ncnn::Mat score, ncnn::Mat location, std::vector<Bbox>& boundingBox_, std::vector<orderScore>& bboxScore_, float scale) {
    int stride = 2;
    int cellsize = 12;
    int count = 0;
    //score p 判定為人臉的機率
    float *p = score.channel(1);
    // 人臉框回歸偏移量
    float *plocal = location.channel(0);
    Bbox bbox;
    orderScore order;
    for (int row = 0; row<score.h; row++) {
        for (int col = 0; col<score.w; col++) {
            if (*p>threshold[0]) {
                bbox.score = *p;
                order.score = *p;
                order.oriOrder = count;
                // 對應原圖中的坐标
                bbox.x1 = round((stride*col + 1) / scale);
                bbox.y1 = round((stride*row + 1) / scale);
                bbox.x2 = round((stride*col + 1 + cellsize) / scale);
                bbox.y2 = round((stride*row + 1 + cellsize) / scale);
                bbox.exist = true;
                // 在原圖中的大小
                bbox.area = (bbox.x2 - bbox.x1)*(bbox.y2 - bbox.y1);
                // 目前人臉框的回歸坐标
                for (int channel = 0; channel<4; channel++)
                    bbox.regreCoord[channel] = location.channel(channel)[0];
                boundingBox_.push_back(bbox);
                bboxScore_.push_back(order);
                count++;
            }
            p++;
            plocal++;
        }
    }
}      

對于非極大值抑制(NMS),應該先了解一下它的原理。簡單解釋一下就是說:當兩個box空間位置非常接近,就以score更高的那個作為基準,看IOU即重合度如何,如果與其重合度超過門檻值,就抑制score更小的box,因為沒有必要輸出兩個接近的box,隻保留score大的就可以了。之後我也會盤點各種NMS算法,講講他們的原理,已經在目标檢測學習總結路線中規劃上了,請打開公衆号的深度學習欄中的目标檢測路線推文檢視我的講解思維導圖。代碼如下,這段代碼以打擂台的生活場景進行注釋,比較好了解:

void mtcnn::nms(std::vector<Bbox> &boundingBox_, std::vector<orderScore> &bboxScore_, const float overlap_threshold, string modelname) {
    if (boundingBox_.empty()) {
        return;
    }
    std::vector<int> heros;
    //sort the score
    sort(bboxScore_.begin(), bboxScore_.end(), cmpScore);

    int order = 0;
    float IOU = 0;
    float maxX = 0;
    float maxY = 0;
    float minX = 0;
    float minY = 0;
    // 規則,站上擂台的擂台主,永遠都是勝利者
    while (bboxScore_.size()>0) {
        order = bboxScore_.back().oriOrder; //取得分最高勇士的編号ID
        bboxScore_.pop_back(); // 勇士出列
        if (order<0)continue; //死的?下一個!(order在(*it).oriOrder = -1;改變)
        if (boundingBox_.at(order).exist == false) continue; //記錄擂台主ID
        heros.push_back(order);
        boundingBox_.at(order).exist = false;//目前這個Bbox為擂台主,簽訂生死簿

        for (int num = 0; num<boundingBox_.size(); num++) {
            if (boundingBox_.at(num).exist) {// 活着的勇士
                //the iou
                maxX = (boundingBox_.at(num).x1>boundingBox_.at(order).x1) ? boundingBox_.at(num).x1 : boundingBox_.at(order).x1;
                maxY = (boundingBox_.at(num).y1>boundingBox_.at(order).y1) ? boundingBox_.at(num).y1 : boundingBox_.at(order).y1;
                minX = (boundingBox_.at(num).x2<boundingBox_.at(order).x2) ? boundingBox_.at(num).x2 : boundingBox_.at(order).x2;
                minY = (boundingBox_.at(num).y2<boundingBox_.at(order).y2) ? boundingBox_.at(num).y2 : boundingBox_.at(order).y2;
                //maxX1 and maxY1 reuse 
                maxX = ((minX - maxX + 1)>0) ? (minX - maxX + 1) : 0;
                maxY = ((minY - maxY + 1)>0) ? (minY - maxY + 1) : 0;
                //IOU reuse for the area of two bbox
                IOU = maxX * maxY;
                if (!modelname.compare("Union"))
                    IOU = IOU / (boundingBox_.at(num).area + boundingBox_.at(order).area - IOU);
                else if (!modelname.compare("Min")) {
                    IOU = IOU / ((boundingBox_.at(num).area<boundingBox_.at(order).area) ? boundingBox_.at(num).area : boundingBox_.at(order).area);
                }
                if (IOU>overlap_threshold) {
                    boundingBox_.at(num).exist = false; //如果該對比框與擂台主的IOU夠大,挑戰者勇士戰死
                    for (vector<orderScore>::iterator it = bboxScore_.begin(); it != bboxScore_.end(); it++) {
                        if ((*it).oriOrder == num) {
                            (*it).oriOrder = -1;//勇士戰死标志
                            break;
                        }
                    }
                }
                //那些距離擂台主比較遠迎戰者幸免于難,将有機會作為擂台主出現
            }
        }
    }
    //從生死簿上剔除,擂台主活下來了
    for (int i = 0; i<heros.size(); i++)
        boundingBox_.at(heros.at(i)).exist = true;
}      

RNet

這以階段就和PNet相比,就需要将圖像resize到(24,24)了。然後剩下的過程也和PNet一樣,做nms。最後還多了一個​

​refineAndSquareBox​

​​的後處理過程,這個函數是把所有留下的框變成正方形并且将這些框的邊界限定在原圖長寬範圍内。注意一下,這個階段​

​refineAndSquareBox​

​​是在​

​nms​

​之後做的。

//second stage
    count = 0;
    for (vector<Bbox>::iterator it = firstBbox_.begin(); it != firstBbox_.end(); it++) {
        if ((*it).exist) {
            ncnn::Mat tempIm;
            copy_cut_border(img, tempIm, (*it).y1, img_h - (*it).y2, (*it).x1, img_w - (*it).x2);
            ncnn::Mat in;
            resize_bilinear(tempIm, in, 24, 24);
            ncnn::Extractor ex = Rnet.create_extractor();
            ex.set_light_mode(true);
            ex.input("data", in);
            ncnn::Mat score, bbox;
            ex.extract("prob1", score);
            ex.extract("conv5-2", bbox);
            if ((score[1])>threshold[1]) {
                for (int channel = 0; channel<4; channel++)
                    it->regreCoord[channel] = bbox[channel];
                it->area = (it->x2 - it->x1)*(it->y2 - it->y1);
                it->score = score[1];
                secondBbox_.push_back(*it);
                order.score = it->score;
                order.oriOrder = count++;
                secondBboxScore_.push_back(order);
            }
            else {
                (*it).exist = false;
            }
        }
    }
    printf("secondBbox_.size()=%d\n", secondBbox_.size());
    if (count<1)return;
    nms(secondBbox_, secondBboxScore_, nms_threshold[1]);
    refineAndSquareBbox(secondBbox_, img_h, img_w);      

ONet

ONet相比于前面2個階段,多了一個關鍵點回歸的過程。同時需要注意的是這個階段​

​refineAndSquareBox​

​​是在​

​nms​

​之前做的。經過這個階段,出來的框就是我們苦苦追尋的人臉框啦,完結。

count = 0;
    for (vector<Bbox>::iterator it = secondBbox_.begin(); it != secondBbox_.end(); it++) {
        if ((*it).exist) {
            ncnn::Mat tempIm;
            copy_cut_border(img, tempIm, (*it).y1, img_h - (*it).y2, (*it).x1, img_w - (*it).x2);
            ncnn::Mat in;
            resize_bilinear(tempIm, in, 48, 48);
            ncnn::Extractor ex = Onet.create_extractor();
            ex.set_light_mode(true);
            ex.input("data", in);
            ncnn::Mat score, bbox, keyPoint;
            ex.extract("prob1", score);
            ex.extract("conv6-2", bbox);
            ex.extract("conv6-3", keyPoint);
            if (score[1]>threshold[2]) {
                for (int channel = 0; channel<4; channel++)
                    it->regreCoord[channel] = bbox[channel];
                it->area = (it->x2 - it->x1)*(it->y2 - it->y1);
                it->score = score[1];
                for (int num = 0; num<5; num++) {
                    (it->ppoint)[num] = it->x1 + (it->x2 - it->x1)*keyPoint[num];
                    (it->ppoint)[num + 5] = it->y1 + (it->y2 - it->y1)*keyPoint[num + 5];
                }

                thirdBbox_.push_back(*it);
                order.score = it->score;
                order.oriOrder = count++;
                thirdBboxScore_.push_back(order);
            }
            else
                (*it).exist = false;
        }
    }

    printf("thirdBbox_.size()=%d\n", thirdBbox_.size());      

效果

我們來試試MTCNN算法的檢測效果。

原圖1:

人臉識别系列三 | MTCNN算法詳解下篇

結果圖1:

人臉識别系列三 | MTCNN算法詳解下篇

原圖2(一張有T神的圖檔):

人臉識别系列三 | MTCNN算法詳解下篇

結果圖2:

人臉識别系列三 | MTCNN算法詳解下篇

後記

MTCNN的實時性和魯棒性都是相當不錯的,現在相當多公司的檢測任務和識别任務都是借鑒了MTCNN算法,這個算法對于當代的目标檢測任務有重要意義。

參考文章

歡迎關注我的微信公衆号GiantPadaCV,期待和你一起交流機器學習,深度學習,圖像算法,優化技術,比賽及日常生活等。