OpenCV AdaBoost + Haar目标检测技术内幕(下)

OpenCV AdaBoost + Haar目标检测技术内幕(下)

上文介绍了检测部分,本文接着介绍训练算法。

PS:上文传送门如下:

白裳丶:OpenCV AdaBoost + Haar目标检测技术内幕(上)zhuanlan.zhihu.com图标


4 AdaBoost背景介绍

在了解AdaBoost之前,先介绍弱学习和强学习的概念:

  • 弱学习:识别错误率小于1/2,即准确率仅比随机猜测略高的学习算法
  • 强学习:识别准确率很高的学习算法

显然,无论对于任何分类问题,弱学习都比强学习容易获得的多。简单的说:AdaBoost就是一种把简单的弱学习拼装成强学习的方法。事实上,在OpenCV图像检测模块训练程序中实现了4种AdaBoost算法:

  1. DAB(Discrete AdaBoost)
  2. RAB(Real AdaBoost)
  3. LB(LogitBoost)
  4. GAB(Gentle AdaBoost)

其中DAB即是AdaBoost.M1(AdaBoost.M2是DAB的多分类版本),GAB是opencv_traincascade.exe的默认AdaBoost方法。

4.1 DAB(Discrete AdaBoost)介绍

DAB是最常见的AdaBoost算法,其算法原理如上所示。DAB算法的核心是不断的寻找当前权重下的最优分类器,然后加大被最优分类器误判样本的权重并重新训练,直到得到强分类器。说到这里不得不佩服前辈大神的奇思妙想,在实际应用中几乎不可能直接找到有效的强分类器,而获得弱分类器则相对简单很多,DAB算法只需要通过迭代就能组合弱分类器最终得到强分类器。

一起来看一个DAB例子。假设空间中分布着N=10个点,其中5个蓝色的正(+)样本点和5个红色的负(-)样本点,如图1。开始前初始化权重数组,每个样本权重为w_{i} = 1/N = 1/10

图4-1 初始情况

在这里限定弱分类器只能是水平or竖直的直线。那么找到一个最优弱分类器 f_{1} 将样本分为2类,其中圆圈中3个样本表示被 f_{1} 错误分类,此时计算 f_{1} 分类误差 e_{1}f_{1} 的权重 c_{1} ,然后更新所有的样本权重 w_{i}

图4-2 M=1时分类情况(图中e_m就是e_1)

可以看到,当M=1循环结束时,直观上被 f_{1} 误分类的样本权重变大(被 f_{1} 错误分类的3个+变大),下次训练时则更加“重视”这几个样本。注意,更新权重后不要忘记权重归一化(权重具体值没有写出来,请读者自行推导)。

图4-3 M=2时分类情况

还是类似,加大被 f_{2} 误分类的样本权重(这次是三个-)。

图4-3 M=3时分类情况

最后通过权重 c_{1}c_{2}c_{3} 将弱分类器组成一个强分类器F

图4-4 最终分类器

可以看出循环M次则训练M个弱分类器,并由这M个分类器最终组成强分类器。

最后给出论文中DAB的原始版本:

4.2 GAB(Gentle AdaBoost)介绍

首先定义加权平方和误差WSE(即weighted square error)如下,其中 w_{i} 表示权重, f(x_{i}) 表示弱分类器对样本 x_{i} 的输出值, y_{i} 表示样本 x_{i} 的标签:

惯例,列出GAB算法的官方描述:

仔细观察可以发现,GAB和DAB有2处不同,解释如下:

  1. DAB和GAB使用的分类器权重误差不一样,GAB是“weighted least-squares”,也就是上面的WSE。应该比较好理解。
  2. DAB和GAB的弱分类器对样本 x_{i}f(x_{i}) 不一样。DAB的 f(x_{i}) 不是+1就是-1;而GAB的 f(x_{i}) 输出的是一种类似于概率的值。

为了说明,不妨设有x1~x5共5个样本,某次训练中选取了某个Feature来度量样本,对应的Feature value为h1~h5。然后选择某个阈值 t 将样本分为左右两个部分。对于DAB:

  • 对于DAB左边输出 f(x_{i})=-1 ,右边 f(x_{j})=+1
  • 而GAB左边输出 f(x_{i})=P_{left} ,右边 f(x_{j})=P_{right} ,表示“加权离散度”。

获得 f(x_{i}) 之后通过调整 t 的大小,使WSE最小,即可完成该次训练。其中最终的 t 就是之前的弱分类器阈值。具体GAB如何应用在训练中将在后续章节中讲解。

从DAB到GAB,实现了从“是否”到“概率”的跨越。所以,千万不要以“对=正 and 错=负”这种非常绝对的概念去理解GAB,而要以概率论去理解,毕竟GAB是gentle的!

5 AdaBoost训练算法(重点哦)

本节分析AdaBoost训练算法,包括每个stage如何收集样本;如何训练每个弱分类器;如何界定强分类器的阈值等内容。

5.1 Precision vs Recall

对于所有的监督学习(supervised learning),都需要一组正样本(positive samples)和负样本(negative samples)。以训练人脸检测器为例,人脸图片即正样本,所有的非人脸区域即为负样本。对于一组positive samples和negative samples,经过检测器后,会有以下4种情况:

positive sample会产生:

  1. TP(true positive),即positive sample被检测器判定为目标
  2. FN(false negative),即positive samples被检测器判断为非目标,相当于检测器“漏掉”了目标

negative sample会产生:

  1. TN(true negative),即negative sample被检测器判定为非目标
  2. FP(false positive),即negative sample被检测器判定为目标,相当于产生了“误检” or “虚警”

也就是说,在实际中不仅关心是否检测错误,更关心的是把“什么”检测成了“什么”。

图5-1 Precision vs Recall

其中论文中常见的两个指标:

precision = tp / (tp + fp)

recall = tp / (tp + fn)

一般在实际应用中,希望precision和recall都很高。还是以人脸检测为例,不妨假设某张图中有10个人脸。

  1. 若检测器只发现了1个人脸,此时 precision=1 虽然很高,但是 recall=0.1 非常低
  2. 若检测器发现了50个人脸(假设包含了10个真人脸),此时 recall=1 很高,但是 precision=10/50=0.2 很低

所以只有precision和recall都比较高时,讨论检测器的参数才有意义。但是现实情况中鱼和熊掌不可兼得,很难做到precision和recall都很高,所以会绘制precision-recall曲线评估检测器。

5.2 hitRate与falseAlarm

图5-2 opencv_traincascade.exe工具命令

而在Adaboost训练过程中,我们更关心的是minHitRate和maxFalseAlarmRate参数,如图5-2红框。在OpenCV的boost.cpp中CvCascadeBoost::isErrDesired()函数,对每一个stage有如下定义:

float hitRate = ((float) numPosTrue) / ((float) numPos);
float falseAlarm = ((float) numFalse) / ((float) numNeg);

换个表达方式:

hitRate = tp / (tp + fn) = recall

falseAlarm = fp / (tn + fp)

这里hitRate称为“命中率”,度量检测器对正样本的通过能力,显然越接近1越好;而falseAlarm称为“虚警率”,度量检测器对负样本的通过能力,显然越接近0越好。

图5-3 adaboost级联结构

考虑到Haar+Adaboost的stage间“串联”形式,如图5-3,stageNum个stage串联后,已知每个stage的hitRate(i)和falseAlarm(i),整个检测器最终的hitRate和falseAlarmRate为:

hitRate = hitRate_{1} \times hitRate_{2} \times ..... \times hitRate_{n} = \prod_{1}^{n}hitRate_{i}

falseAlarmRate = falseAlarm_{1} \times ...... \times falseAlarm_{n} = \prod_{1}^{n}falseAlarm_{i}

而∏表示连乘符号。那么:

  1. 为了让检测器最终的 hitRate 接近1,每一个stage的 hitRate_{i} 必须很大,即每一个stage的正样本通过率必须非常高。
  2. 同理,为了让检测器最终的 falseAlarmRate 接近0,每一个stage的 falseAlarm_{i} 必须很小,即每一个stage的负样本虚警率必须比较低。

从图2中可以看到默认 minHitRate = 0.995 ,默认 maxFalseAlarmRate = 0.5 。假设 stageNum = 20 时,最终检测器有:

hitRate ≈ x^{stageNum} = x^{20} = 0.904610...

falseAlarmRate ≈ x^{stageNum} = 0.5^{20} = 0.00000095...

然后换一组参数, minHitRate = 0.9maxFalseAlarmRate = 0.6 时,最终的检测器 hitRate 竟然掉到了0.12,Amazing!

hitRate ≈ 0.9^{20} = 0.12157...

falseAlarmRate ≈ 0.6^{20}= 0.000036...

由此看出每一个stage的 minHitRate 必须非常高(>=0.995)!而 maxFalseAlarmRate 则相对“温和”一些。

总结一下:

  1. 由于串联的级数量很多,minHitRate必须非常接近1,才能保证最终检测器有较好的recall;
  2. falseAlarmRate相当于对检测器的precision作了约束;

5.3 样本收集过程

首先分析每一个stage训练时如何收集样本,事实上每一个stage训练使用的正负样本都不同。

  1. 正样本patches收集过程:opencv_trancascade.exe使用的正样本是一个vec文件,即由opencv_createsamples.exe把一组固定 w\times h 大小的图片转换为二进制vec文件(只是读取图片并转化为灰度图,并按照二进制格式保存下来而已,不做任何改变)。由于经过如此处理的正样本就是固定 w\times h 大小的patches,所以正样本可以直接进入训练。
  2. 负样本patches收集过程:而使用的负样本就不一样了,是一个包含任意大小图片路径的txt文件。在寻找负样本的过程中,程序会以图像金字塔(pyramid)+滑动窗口的模式(sliding-window)去遍历整个负样本集,以获取 w\times h 大小的负样本patches。
  3. 对1和2步骤来中这些正负样本的patches进行分类 :获取到这些固定w x h大小的正/负样本patches后,利用已经训练好的stage分类这些patches,并且从正样本中收集numPos个TP patches;从负样本中收集numNeg个FP patches(假设样本是足够的)。之后利用TP作为正样本,FP作为负样本训练下一个stage。

那么对于 stage_{0} ,直接收集numPos个来自正样本的patches + numNeg个来自负样本的patches进行训练;对于 stage_{i}(i >0) ,则利用已经训练好的 stage_{0}stage_{i-1} 分类这些patches,分别从正样本patches中收集numPos个TP,从负样本patches中收集numNeg个FP(numPos和numNeg参数是在opencv_traincascade中预先设置的)。

图5-4 每个Stage训练前收集样本示意图

每一个stage都要进行上述收集+分类过程,所以实际中每一个stage所使用的训练样本也都不一样!

在OpenCV的cascadeclassifier.cpp中,有如下fillPassedSamples()函数负责填充训练样本(修改此函数可以实现下文提到的保存TP和FP)。

int CvCascadeClassifier::fillPassedSamples( int first, int count, bool isPositive, double minimumAcceptanceRatio, int64& consumed )
{
    int getcount = 0;
    Mat img(cascadeParams.winSize, CV_8UC1);
    for( int i = first; i < first + count; i++ )
    {
        for( ; ; )
        {
            if( consumed != 0 && ((double)getcount+1)/(double)(int64)consumed <= minimumAcceptanceRatio )
                return getcount;

            bool isGetImg = isPositive ? imgReader.getPos( img ) : imgReader.getNeg( img ); //读取样本
            if( !isGetImg )
                return getcount; //如果不能读取样本(出错or样本消耗光了),返回
            consumed++; //只要读取到了样本,不管判断结果如何,消耗量consumed增加1

            //当参数isPositive = true时,填充正样本队列,此时选择TP进入队列
            //当参数isPositive = false时,填充负样本队列,此时选择FP进入队列
            featureEvaluator->setImage( img, isPositive ? 1 : 0, i ); //将样本img塞入训练队列中
            if( predict( i ) == 1 ) //根据isPositive判断是否是TP/FP, 是则break进入下一个;反之继续循环,并覆盖上面setImage的样本
            {
                getcount++; //真正添加进训练队列的数量
                printf("%s current samples: %d\r", isPositive ? "POS":"NEG", getcount);
                break;
            }
        }
    }
    return getcount;
}

接下来看看第20行的predict()函数:

int CvCascadeClassifier::predict( int sampleIdx )
{
    CV_DbgAssert( sampleIdx < numPos + numNeg );
    for (vector< Ptr<CvCascadeBoost> >::iterator it = stageClassifiers.begin();
        it != stageClassifiers.end(); it++ )
    {
        if ( (*it)->predict( sampleIdx ) == 0.f )
            return 0;
    }
    return 1;
}

再进入第7行的(*it)->predict()函数:

float CvCascadeBoost::predict( int sampleIdx, bool returnSum = false ) const
{
    CV_Assert( weak );
    double sum = 0;
    CvSeqReader reader;
    cvStartReadSeq( weak, &reader );
    cvSetSeqReaderPos( &reader, 0 );
    for( int i = 0; i < weak->total; i++ )
    {
        CvBoostTree* wtree;
        CV_READ_SEQ_ELEM( wtree, reader );
        sum += ((CvCascadeBoostTree*)wtree)->predict(sampleIdx)->value; //stage内的弱分类器wtree输出值求和sum
    }
    if( !returnSum )
        //默认进行sum和stageThreshold比较
        //当sum<stageThreshold,输出0,否决当前样本;sum>stageThreshold,输出1,通过
        sum = sum < threshold - CV_THRESHOLD_EPS ? 0.0 : 1.0;
    return (float)sum; //若returnSum==true则不与stageThreshold比较,直接返回弱分类器输出之和。下文用到
}

看到这就很清晰了,默认returnSum为false时每个stage内部弱分类器wtree的输出值加起来和stageTheshold比较,当样本通过时输出1,不通过输出0。那么对于positive samples,输出1即是TP;对于negative samples,输出1即是FP。至此代码与上述内容对应,over!

那么引申一个问题,我们可以在采集负样本的时候修改fillPassedSamples保存采集到的区域,用于清理漏标,如图5-5。

图5-5 训练汽车检测器采集的负样本(有漏标)

5.3 分类器的训练过程

分析到到此,读者已经了解如何获得了用于训练的正负样本。

获取样本后,接下来分析如何运用这些正负样本训练每一个stage分类器。为了方便理解,以下章节都是以maxDepth=1为例分析训练过程,其他深度请自行分析代码。

在收集到numPos个TP和numNeg个FP后,就可以训分类器了,过程如下:

  • 首先计算所有Haar特征对这numPos+numNeg个样本patches的特征值,排序后分别保存在的vector中,如图5-6
图5-6 分类器训练过程示意图
  • 按照如下方式遍历每个存储特征值的vector
  • 至此,已经有很多弱分类器了。但是哪一个弱分类器最好呢?所以要挑选最优弱分类器放进stage中。

通过前面2步后每一个弱分类器都有一个基于Best split threshold的GAB WSE ERROR,那么显然选择ERROR最小的那个弱分类器作为最优弱分类器放进当前训练的stage中。

  • 依照GAB方法更新当前训练的stage中每个样本的权重

对numPos+numNeg个权重按照如下公式更新权重(注意更新后需要对权重进行归一化)。

  • 计算当前的强分类器阈值stageThreshold
  1. 使用当前的stage中已经训练好的弱分类器去检测样本中的每一TP,计算弱分类器输出值之和保存在eval中(如果不明白,请查阅第三节“并联的弱分类器”)。
  2. 对eval升序排序
  3. 利用minHitRate参数估计一个比例thresholdIdx,以eval[thresholdIdx]作为stage阈值stageThreshold,显然TP越多估计的stageThreshold越准确。

上述1-3过程由boost.cpp中的CvCascadeBoost::isErrDesired()函数实现,关键代码如下:

int numPos = 0;
for( int i = 0; i < sCount; i++ ) //遍历样本
    if( ((CvCascadeBoostTrainData*)data)->featureEvaluator->getCls( i ) == 1.0F ) //if current sample is TP
        eval[numPos++] = predict( i, true ); //predict加入true参数后,会返回当前stage中弱分类器输出之和(如上文predict介绍)
icvSortFlt( &eval[0], numPos, 0 ); //升序排序
int thresholdIdx = (int)((1.0F - minHitRate) * numPos); //按照minHitRate估计一个比例作为index
threshold = eval[ thresholdIdx ]; //取该index的值作为stageThreshold

至此,stage中的弱分类器+stageThreshold等参数都是完整的

  • 重复之前stage训练步骤(黑体字),直到满足下列任意一个条件后停止并输出当前的stage
  1. stage中弱分类器的数量 >= maxWeakCount参数
  2. 利用当前的stage去检测FP获得当前stage的falseAlarmRate,当falseAlarmRate < maxFalseAlarmRate停止

同样是boost.cpp中的CvCascadeBoost::isErrDesired()函数:

int numNeg = 0;
for( int i = 0; i < sCount; i++ )
{
    if( ((CvCascadeBoostTrainData*)data)->featureEvaluator->getCls( i ) == 0.0F )
    {
        numNeg++;
        if( predict( i ) ) //predict==1也就是falseAlarm,虚警,即俗称的误报
            numFalse++;
    }
}
float falseAlarm = ((float) numFalse) / ((float) numNeg);

一直训练,直到满足 falseAlarm < maxFalseAlarm 后停止当前stage训练。

  • 然后重复训练每一个stage,直到满足下面任意一个条件:
  1. stage数量 >= numStages
  2. 所有stage总的falseAlarmRate < pow(maxFalseAlarmRate,numStages)

5.5 一个OpenCV训练的小例子

作者选用了约12000张人脸样本,使用opencv_traincascade.exe程序,设置numPos=numNeg=10000,w=h=24,进行一次简单的训练。如下图红线所示,在 stage_{0} 的时候,程序直接从正负样本中各抽取10000张24x24大小的子图片进行训练,获得了一个包含3个决策树的强分类器。

stage_{1} 的时,扫描了10008张正样本后获得了10000个TP(当前实际hitRate=10000/10008);扫描了很多负样本窗口后获得了10000个FP(比例为acceptanceRatio=0.243908,即是当前实际falseAlarmRate)。训练完成后获得一个包含5个决策树的强分类器。

可以看到,在一般情况下,随着训练的进行,acceptancesRatio会越来越低,即直观上看每一级收集的FP会越来越像正样本;那么为了区分TP与FP,每一个stage包含的决策树也会逐渐增多。

注:上图中每一级的前几个分类器HR=1,FA=1,这是由训练程序中的数值计算细节导致的,有兴趣的朋友可以自己去翻代码......

5.6 训练过程总结

其实回顾一下,整个分类器的训练过程可以分为以下几个步骤:

  1. 寻找TP和FP作为训练样本
  2. 计算每个Haar特征在当前权重下的Best split threshold+leftvalue+rightvalue,组成了一个个弱分类器
  3. 通过WSE寻找最优的弱分类器
  4. 更新权重
  5. 按照minHitRate估计stageThreshold
  6. 重复上述1-5步骤,直到falseAlarmRate到达要求,或弱分类器数量足够。停止循环,输出stage。
  7. 进入下一个stage训练


OpenCV Cascade Boosting介绍就结束了。

求赞求打赏!谢谢看官老爷们!

编辑于 07-10

文章被以下专栏收录