人脸识别系列三 | MTCNN算法详解下篇

前言

上篇讲解了MTCNN算法的算法原理以及训练细节,这篇文章主要从源码实现的角度来解析一下MTCNN算法。我要解析的代码来自github的https://github.com/ElegantGod/ncnn中的mtcnn.cpp。

网络结构

再贴一下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。缩放后的图片尺寸可以用以下公式计算:
minL=orgL*(12/minsize)*factor^n,其中n就是scales的长度,即特征金字塔层数。

PNet

Pnet只做检测和回归任务。在上篇文章中我们知道PNet是要求12*12的输入的,实际上再训练的时候是这样做的。但是测试的时候并不需要把金字塔的每张图像resize到12乘以12喂给PNet,因为它是全卷积网络,以直接将resize后的图像喂给网络进行Forward。这个时候得到的结果就不是1*1*21*1*4,而是m*m*2m*m*4。这样就不用先从resize的图上截取各种12*12*3的图再送入网络了,而是一次性送入,再根据结果回推每个结果对应的12*12的图在输入图片的什么位置。
然后对于金字塔的每张图,网络forward后都会得到属于人脸的概率以及人脸框回归的结果。每张图片会得到m*m*2个分类得分和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个关键的函数,分别是generateBoxnms,我们分别来解析一下,首先看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:


在这里插入图片描述

结果图1:

在这里插入图片描述

原图2(一张有T神的图片):

在这里插入图片描述

结果图2:

在这里插入图片描述

后记

MTCNN的实时性和鲁棒性都是相当不错的,现在相当多公司的检测任务和识别任务都是借鉴了MTCNN算法,这个算法对于当代的目标检测任务有重要意义。

参考文章

https://blog.csdn.net/fuwenyan/article/details/77573755


欢迎关注我的微信公众号GiantPadaCV,期待和你一起交流机器学习,深度学习,图像算法,优化技术,比赛及日常生活等。


图片.png
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容