前阵子在啃经典的人脸检测框架,一路看下来,其他地方都能跟上,偏偏在Haar特征计算这块被绕得晕头转向。论文里那句“用两个矩形区域的像素差来表示特征”很好背,但真到自己写代码时,边界坐标怎么算、矩形和用什么公式、模板面积不一致怎么办,全是问题。后来我干脆拿一支笔,找了一幅5x5的小图,把积分图一格一格算出来,再把特征模板往上套。手算完那一遍,之前所有模糊的地方都清晰了。这篇是一份完整的学习记录,也是同在坑里的同学可以参考的操作手册。文章不会跳步,从最基本的矩形特征定义讲起,到积分图原理、特征值公式、数量统计、光照归一化,最后附一份从头实现的Python代码。
1. 被特征公式绕晕之后,我选择把每个矩形都手算一遍
1.1 学习记录缘起:我卡在了哪里
最开始接触Haar特征,是从“人脸检测”这个任务入手的。检测人脸的本质问题可以简化成:给定一个图像窗口,判断它里面有没有人脸。传统做法是提取一堆特征,然后交给分类器去判断。而Haar特征就是被设计用来描述图像局部明暗关系的一种特征。
我一开始的理解很粗浅:不就是把图像分成黑白两块,白块像素和减去黑块像素和嘛。但一打开具体实现代码,顿时懵了。特征模板里每个矩形都有权重,有的矩形权重是-1,有的矩形权重是-1甚至不是整数;矩形位置不是直观的“左边白、右边黑”,而是通过“左上角坐标、宽高、权重系数”描述的。更别提积分图里那个“多一圈0”的边界处理,索引稍微算错一位,结果就完全对不上。
后来我意识到,问题不是Haar特征本身难,而是很多资料跳过了计算细节,直接给公式。公式里每个符号看着都认识,但合在一起不知道坐标怎么落下。所以这篇记录,我打算用一个尽量小的图像,把计算过程一步一步铺开,让每个矩形和都能对得上号。
1.2 Haar特征的本质:把“局部明暗关系”翻译成几个矩形
Haar特征本质上是一种区域特征。它不去比较单个像素点,而是比较几个矩形区域之间的像素和关系。这样做有两个好处:一是矩形区域包含更多像素,对噪声不那么敏感;二是矩形区域可以用积分图加速,后面会详细讲。
一个Haar特征模板通常由白色矩形和黑色矩形组成,特征值就是白色区域像素和减去黑色区域像素和。比如眼睛区域通常比它下方的脸颊区域暗,那么一个“上黑下白”的垂直边缘特征就能描述这种关系;鼻梁两侧通常比鼻梁暗,那么一个“两边黑、中间白”的线性特征也能派上用场。
在具体实现里,比“白减黑”更严谨的说法是:每个矩形都带一个权重,白色矩形权重为+1,黑色矩形权重为-1,特征值等于所有矩形“权重乘以该矩形区域像素和”的总和。当黑白矩形面积相等时,这个定义和“白减黑”完全一致;当面积不相等时,就必须引入权重来归一化,否则大面积矩形会主导特征值。这一点很多人第一次接触时会漏掉,后面我会专门讲。
1.3 五类基础模板的直观描述
经典Haar特征的基础模板一共有五类:
- 两矩形边缘特征:左右结构,适合描述垂直边缘,比如脸的一侧到背景的过渡;还有上下结构,适合描述水平边缘。
- 三矩形线性特征:三个矩形横向排列,或者纵向排列,适合描述“亮-暗-亮”或“暗-亮-暗”的条纹关系。
- 四矩形对角特征:四个矩形呈田字格分布,适合描述对角线方向的明暗差异。
- 中心环绕特征:一个中心矩形和包围它的外框矩形形成对比,类似斑点检测的感受野。
在24x24的检测窗口里,这些模板可以放在任意位置,也可以按不同比例缩放。模板一旦移动一个像素、放大一个刻度,就产生一个新的特征。所以Haar特征不是一个特征,而是一整类特征的集合,数量可以达到十几万个。分类器要从这么多特征里挑出有用的那几百个,这是后面Adaboost算法要做的事。
2. 积分图:为什么矩形求和能从n次加法变成4次查表
2.1 积分图的定义和递推
积分图(Integral Image)是Haar特征能高效计算的根本原因。它的定义很简单:积分图在位置(x, y)处的值,等于原图像从左上角(0,0)到(x,y)这个矩形范围内所有像素的和。也就是说,积分图存的是“二维前缀和”。
如果只用暴力方法,计算一个矩形区域的像素和,需要把区域内所有像素逐个累加。矩形越大,加法次数越多。积分图的做法是:在预处理阶段把每个位置的左上角总和都算好,之后计算任意矩形和时,只需要从积分图里取四个值做加减法。
积分图本身也可以用递推公式高效构建。设原图像为I,积分图为II,那么:
II(i, j) = I(i, j) + II(i-1, j) + II(i, j-1) - II(i-1, j-1)
这个公式的逻辑是:当前点的左上角总和,等于当前像素值,加上正上方区域的总和,再加上正左方区域的总和。但正上方和正左方都包含了左上角那块重叠区,所以要把左上角区域的总和减掉一次,否则就重复计算了。这和“二维前缀和”的加法是一个道理。
用numpy实现时,常见做法是先对行方向做累加,再对列方向做累加,一行代码就能得到。不过初学者最好先手写一遍循环递推,理解清楚每个格子的含义,再回到高效率实现。
2.2 矩形和公式的推导
有了积分图,任意矩形区域的和可以统一写成四个点的组合。
假设要计算原图像中左上角为(r1, c1)、右下角为(r2, c2)的矩形区域像素和,公式是:
S = II(r2, c2) - II(r1-1, c2) - II(r2, c1-1) + II(r1-1, c1-1)
很多第一次接触的人会问:为什么不是两个点相减?因为矩形是一个二维区域,一维前缀和只需要两个端点,二维前缀和涉及到四个角。想象二维前缀和矩阵里,每个位置都存着“从全局左上角到这里”的和。取右下角II(r2,c2),代表的是左上角到右下角的大矩形和;要扣掉上方多算的部分,和左边多算的部分;但上方和左边在左上角处存在一块重叠区域被扣了两次,所以要加回来一次。这个“左上重叠加回来”的操作,正是二维前缀和比一维多出去的复杂度。
实际代码里,为了省去r1-1、c1-1在边界处变成负索引的问题,通常把积分图在外面多包一圈0。也就是积分图尺寸为(原图高度+1, 原图宽度+1),第0行和第0列全是0。这样矩形左上角坐标为(t,l)、右下角坐标为(b,r)时,矩形和变成:
S = II(b+1, r+1) - II(t, r+1) - II(b+1, l) + II(t, l)
这个形式在后面的Python实现里最常见,也最不容易出错。
2.3 为什么说计算量从“几百次加法”变成“3次加减法”
暴力计算一个20x20的矩形区域和,需要400次加法。如果用积分图,无论矩形多大,都只是三次加减法。这个差异在Haar特征计算里被放得很大,因为检测一个人脸窗口可能要计算几百个特征,每张图像又有几十万个滑动窗口。没有积分图,人脸检测算法根本跑不动。
我记得刚理解这一点时,最大的感受是:Haar特征真正聪明的不是黑白矩形的选取,而是积分图为“快速求任意矩形和”提供了统一解法。如果没有积分图,哪怕特征模板设计得再有道理,计算成本也高到无法实用。
3. 特征值计算:从模板到黑白矩形的加权差值
3.1 特征值的统一公式
Haar特征的特征值计算,可以用一个统一公式表达:
feature_value = Σ (w_k * S_k)
其中,k遍历特征模板中的每个矩形,w_k是该矩形的权重系数,S_k是该矩形覆盖区域的像素和。经典白色矩形的权重为+1,黑色矩形的权重为-1。求和的结果,就是白区域、黑区域之间的加权像素差。
用这个统一公式,模板的可读性会好很多。比如一个左右结构的二矩形特征,包含两个矩形:左边矩形权重+1,右边矩形权重-1。特征值就是左矩形像素和减右矩形像素和。当一个特征模板包含面积不等的矩形时,需要给每个矩形乘以一个归一化系数,使权重与面积成反比关系,避免面积差异淹没真正的对比信息。实际实现中,有些库会直接把权重系数存成小数,比如-0.5和+0.5,但数值上等价于面积归一化。
3.2 模板权重与面积归一化的关系
为什么面积不等时不能直接相减?举个例子:一个三矩形横向模板,中间白矩形宽1像素,两边黑矩形总宽2像素。如果直接“白区域和”减“黑区域和”,那么两侧黑矩形因为面积更大,在数值上天然占优势,即使中间区域明显更亮,特征值也可能被大面积黑色区域带偏。所以必须对矩形权重做归一化,通常做法是让正负权重的总面积相等。比如中间白矩形面积为A1,两侧黑矩形总面积为A2,则权重可以设为+1和-A1/A2。这样当所有区域亮度相同时,特征值为0;真正有明暗对比时,特征值正负和大小才有意义。
在阅读各种实现时,你会发现权重系数已经被预先算好,写入特征描述文件,不需要每次临时计算。理解了面积归一化原理,再看那些奇怪的系数就不会觉得迷惑了。
3.3 特征值正负号带来的歧义
只看特征值绝对值,无法判断模板内部谁亮谁暗。比如“左白右黑”和“左黑右白”两个模板,在同一个图像窗口上得到的特征值符号相反,但绝对值相同。这对Adaboost分类器来说不是问题,因为分类器可以学习“特征值大于阈值则为人脸”或者“特征值小于阈值则为人脸”这两种方向。但如果你自己写逻辑、直接假设“特征值大于0就是人脸”,就会踩坑。
我复盘时发现,最简单的方法是:训练阶段让分类器自己决定阈值和分析方向,不要在特征提取阶段强加“谁应该更亮”的假设。手算特征值时,也只要保证符号定义一致即可。实践中有种常见的可视化误解:把白色矩形看成“看起来是白的区域”,其实白色矩形只表示权重为正,不代表图像那个地方真的是白色,这个后面也会再说。
4. 手算演示:5x5图像上的完整Haar特征计算
4.1 构造示例图像和积分图
纸上谈兵不如手算一遍。我构造了一个很简单的5x5灰度图像,像素值从1到9均匀递增,这样每一步计算结果都能肉眼验算:
1 2 3 4 5 2 3 4 5 6 3 4 5 6 7 4 5 6 7 8 5 6 7 8 9先按递推公式计算积分图。为了代码阶段方便,我采用包一圈0的形式,积分图是6x6,第0行和第0列全是0:
0 0 0 0 0 0 0 1 3 6 10 15 0 3 8 15 24 35 0 6 15 27 42 60 0 10 24 42 64 90 0 15 35 60 90 125可以验证一下:积分图第2行第3列的8,表示原图前2行前2列所有元素之和,即1+2+2+3=8,正确。积分图右下角的125,表示整张5x5图像所有元素之和,把每行加起来也确实是125。
4.2 计算一个水平二矩形特征值
取一个最简单的水平二矩形特征:两个矩形都是2行1列,左边为白色矩形,右边为黑色矩形,放在图像左上角。
白色矩形覆盖原图第1到第2行、第1列,区域元素是1和2,暴力累加和为3。用积分图查:左上角(0,0),右下角(1,1),套用公式:
S_white = II(2,1) - II(0,1) - II(2,0) + II(0,0) = 3 - 0 - 0 + 0 = 3
黑色矩形覆盖第1到第2行、第2列,区域元素是2和3,和为5。用积分图:
S_black = II(2,2) - II(0,2) - II(2,1) + II(0,1) = 8 - 0 - 3 + 0 = 5
于是特征值 = 3 - 5 = -2。
这里有个有意思的细节:虽然左边像素平均比右边小,但特征值是-2,说明在这个位置,右侧矩形更亮。要判断这个特征在区分什么,必须结合模板方向和符号约定一起看。
4.3 换位置、换尺寸再算一例
把同一个特征向右移动一列:白色矩形覆盖第1到第2行、第2列,黑色矩形覆盖第1到第2行、第3列。
白区域元素是2和3,和为5;黑区域元素是3和4,和为7;特征值 = 5 - 7 = -2。由于示例图像是均匀递增的对角结构,移动后特征值保持不变,正好可以用来验证计算过程的一致性。
再换一个尺寸:用2行2列的白色矩形和2行2列的黑色矩形,放在图像左上角。白色矩形覆盖第1到第2行、第1到第2列,黑矩形覆盖第1到第2行、第3到第4列。
白区域元素:1,2,2,3,和是8。黑区域元素:3,4,4,5,和是16。特征值 = 8 - 16 = -8。用积分图验证白区域:
S_white = II(2,2) - II(0,2) - II(2,0) + II(0,0) = 8 - 0 - 0 + 0 = 8
黑区域:
S_black = II(2,4) - II(0,4) - II(2,2) + II(0,2) = 24 - 0 - 8 + 0 = 16
特征值 -8,和暴力计算完全一致。到这里,积分图的四个角取点逻辑应该就比较清晰了。
4.4 手算结果和程序验算对照
我写完Python代码后,第一件事就是拿这组手算数据去对。结果完全一致,说明索引公式没有写错。强烈建议任何人在实现Haar特征计算前,都先做一次这样的小规模手算。不要直接在大图上debug,一旦结果不对,你很难判断是积分图错了、矩形定义错了,还是坐标转换错了。像这种5x5的小例子,每一步都能人工验算,能快速定位问题。
5. 特征数量吓人:24x24窗口里的18万特征是怎么来的
5.1 特征数量统计公式
在24x24检测窗口里,Haar特征数量达到18万级别。这个数字第一次看到时很吓人,但只要推导一下数量公式,它的来源就清楚了。
对于一个固定尺寸的矩形模板,设它宽为w、高为h,检测窗口宽为W、高为H。这个模板在窗口中可以摆放的位置数量是:
(W - w + 1) * (H - h + 1)
这只是固定尺寸的情况。实际特征模板还可以缩放,比如二矩形水平特征的基本单位宽是2,但宽可以扩展到3、4甚至24,高也可以从1扩展到24。对所有可能的尺寸求和,就是总特征数。
以最简单的二矩形水平特征为例,基本宽度至少2,高度至少1。在24x24窗口里,这个单一类型的特征数量是:
Σ(w=2 to 24) Σ(h=1 to 24) (24 - w + 1) * (24 - h + 1)
先算关于w的求和:当w从2到24时,(24-w+1)分别等于23、22、...、1,求和为276。再算关于h的求和:当h从1到24时,(24-h+1)分别等于24、23、...、1,求和为300。两者相乘,得到82800。也就是说,仅仅“左右二矩形”这一种特征结构,在24x24窗口里就有82800个变体。
5.2 各类模板累加后的总量
再把垂直二矩形、三矩形线性、四矩形对角等模板都加进去,总量很快超过180000。具体数字会因为模板集合定义不同而有差异,但量级是稳定的:十万到二十万。这也是为什么训练阶段要用Adaboost做特征选择,而不是把所有特征都直接丢进模型。18万个特征在24x24窗口里听起来很多,但每个特征都只是从积分图里取几个值算一次加减法,计算成本可控。真正贵的是训练阶段要从18万特征里反复筛选。
5.3 特征多不慌,Boosting负责选
这么多特征,很多是冗余的、甚至是噪声。经典做法是用Adaboost在每一轮迭代中从18万个特征里挑出一个分类错误率最低的特征,然后调整样本权重继续下一轮。最终选出的几百个特征,组合成一个强分类器,再级联成快速检测器。
理解特征数量的具体计算,能帮助理解为什么早期的目标检测可以工作在实时速度下:特征数量虽然大,但单特征计算极其快;分类器级联结构让大多数非人脸窗口在前几层就被快速拒绝,根本不需要计算后面几百个特征。很多资料直接说“Haar特征有18万个”,却没有解释这18万是怎么来的。自己推一遍数量公式,以后再看到“特征多到爆炸”这种说法,就不会只是头皮发麻了。
6. 光照一变特征值就跑偏:方差归一化必须做
6.1 同一块人脸在不同光照下的特征值差异
Haar特征值本质上和像素亮度是线性关系。如果图像整体变亮,例如所有像素值翻倍,那么所有矩形区域的像素和也会翻倍,特征值整体也翻倍。问题是,人脸检测希望“同一个人不管光照亮一点还是暗一点,都能被检测出来”。如果直接用原始特征值训练分类器,模型就要额外学习不同光照下的分布差异,检测鲁棒性会下降。
解决办法是在计算特征值后做方差归一化。具体来说,计算检测窗口内像素的均值和标准差,然后把特征值除以标准差。更常见的做法是先做整体归一化,让窗口内的像素分布具有统一的均值和方差,再提取特征。这一操作等价于对特征值做尺度归一化,因为在数学上,像素整体乘常数后,特征值也线性放大,除以标准差后正好抵消掉这个放大系数。
6.2 积分平方图快速算方差
要快速计算窗口内的方差,除了积分图,还需要一张积分平方图。积分平方图每个点的值,等于原图像从左上角到该点所有像素平方值的和。有了积分图和积分平方图,任意窗口内的像素和、像素平方和都能O(1)得到。
假设窗口内有N个像素,像素和为S,像素平方和为Q,那么:
均值 mean = S / N
方差 var = Q / N - mean^2
标准差 sigma = sqrt(var)
实际代码里,积分平方图可以用同样的二维前缀和方法构建,只是原像素值先做平方。这样归一化特征值的计算开销依然很小,不会破坏整个框架的实时性。
6.3 归一化前后的特征值长什么样
回到之前5x5的示例图像。全图均值是5,平方和是725,方差是725/25 - 25 = 4,标准差为2。在左上角那个2行2列白矩形加2行2列黑矩形的特征,原特征值是-8。把这个值除以标准差2,得到归一化特征值-4。
现在模拟光照整体变亮:把每个像素乘以2。新图像中,每个区域的像素和都翻倍,那个特征值变成-16,但新图像的标准差也翻倍为4,归一化后还是-4。完美抵消。这就是方差归一化的价值:它让特征值不再依赖全局亮度,而只反映局部的相对对比关系。
在真实训练流程中,归一化通常在检测窗口内部完成,而不是在整张图像上完成。因为检测窗口的局部光照分布才是分类器真正关心的,全图归一化反而会丢失局部对比信息。
7. 从零写一份Python实现:积分图+通用Haar特征
7.1 数据结构设计
我自己实现时,没有直接复用工具库,而是用最原始的numpy实现一遍,方便随时debug。整个代码围绕两个核心函数:积分图构建、矩形和查询。然后再用特征矩形列表表达一个Haar特征模板。
积分图最稳的写法是外面包一圈0。这样做的好处是,任意矩形的边界索引不需要判断是否为0,直接用即可。数据类型要用int64,避免小图还好、大图累加时整数溢出。
7.2 核心代码与说明
下面是我实现中用到的核心代码。先看积分图构建:
import numpy as np def build_integral_image(img): h, w = img.shape ii = np.zeros((h + 1, w + 1), dtype=np.int64) ii[1:, 1:] = img.cumsum(axis=0).cumsum(axis=1) return ii这里ii[i+1, j+1]代表原图左上角(0,0)到第i行第j列的矩形和。包了一圈0,所以第0行第0列全是0。
然后是矩形和查询函数:
def rect_sum(ii, top, left, bottom, right): # top, left, bottom, right 都是原始图像坐标,包含端点 return ii[bottom + 1, right + 1] - ii[top, right + 1] \ - ii[bottom + 1, left] + ii[top, left]这个函数的四个index分别对应:右下角、右上角外侧、左下角外侧、左上角外侧。多包一圈0之后,top和left传入0是安全的。
接下来定义一个计算特征值的函数。每个特征由若干矩形描述,每个矩形包含权重系数和坐标范围:
def haar_feature_value(ii, rects): # rects: list of (weight, top, left, bottom, right) total = 0 for weight, top, left, bottom, right in rects: total += weight * rect_sum(ii, top, left, bottom, right) return total7.3 手算例子回归验证
用第4章的图像做回归:
img = np.array([ [1, 2, 3, 4, 5], [2, 3, 4, 5, 6], [3, 4, 5, 6, 7], [4, 5, 6, 7, 8], [5, 6, 7, 8, 9], ]) ii = build_integral_image(img) # 左上角2x1白矩形 + 2x1黑矩形 feature_rects = [ (1.0, 0, 0, 1, 0), # 白矩形:行0-1,列0 (-1.0, 0, 1, 1, 1), # 黑矩形:行0-1,列1 ] print(haar_feature_value(ii, feature_rects)) # -2跑出来就是-2,和第4.2节手算一致。再用2x2白矩形加2x2黑矩形的例子,输出-8,也一致。这两组回归用例像定海神针一样,后续不管怎么改代码结构,只要这两个数字不变,基本可以放心。
7.4 性能对比:暴力求和 vs 积分图
我用代码顺手做了个对比。在100x100的图像上,随机生成5000个20x20的矩形区域,分别用暴力累加和积分图查询计算总和。暴力方法需要对每个矩形平均做400次加法,5000个矩形就是200万次加法;积分图方法无论矩形多大都只要三次加减法,5000次查询总共1.5万次加减法。
实测下来,暴力版本耗时大约是积分图版本的几十倍到上百倍,具体倍数取决于图像大小和矩形数量。这个差距在检测窗口数量极大的真实场景里会被放大到不可接受。所以理解积分图,不只是为了“看懂公式”,更是为了在工程上真正跑得动算法。
8. 复盘:我在计算和实现过程中踩过的几个坑
8.1 积分图多一圈边界,索引偏移搞错
我第一次自己写时,积分图没有包一圈0,矩形查询函数里到处是top-1、left-1的判断,写了一堆if。结果某个边界矩形总是差一个值。后来改成包一圈0,所有索引直接变成bottom+1、right+1这种正索引,代码简洁且正确。多出来的那一圈0看着不起眼,实际上是把边界条件从“特判”变成“自然处理”,这是二维前缀和实现里很关键的习惯。
8.2 图像数据类型溢出
用numpy读图像时,常见像素类型是uint8,取值范围0到255。积分图如果复用uint8类型存储,累加几千个像素后必然溢出。我第一次跑大图时,矩形和出现负数,检查半天才发现是dtype问题。积分图务必使用int64或者更大的整数类型。积分平方图更要注意,像素平方后的数值增长更快,一定也要用高精度类型。
8.3 模板权重千万别省
有些简化教程里只讲“白减黑”,不提面积归一化。我在实现三矩形特征时,一开始没加权重,特征值明显偏向面积大的矩形。后来把权重改成面积归一化形式,特征值才有了正确的对比含义。如果将来你自己定义新的Haar类型特征,设计权重时先问一句:所有矩形面积总和是否平衡?正权重总面积和负权重总面积是否一致?
8.4 特征可视化时的黑白误解
把特征模板画出来时,白色矩形只表示权重为正,黑色矩形只表示权重为负,不代表图像上这个区域真的是白色或黑色。有人看可视化图,以为特征就是在找“白的块”和“黑的块”,这是理解偏差。真正对应的语义是:白色矩形覆盖的区域像素和,相对于黑色矩形覆盖的区域像素和,是更大还是更小。
另外,特征值正负号的方向,在实际训练时由分类器自动学习。不需要手工规定“这个特征必须为正才算人脸”。我一开始总想给每个特征规定一个生理学解释,后来发现很徒劳。很多特征经过Adaboost筛选后,组合方式非常复杂,单个特征往往没有直观语义。接受这个事实,反而能更快推进实验。
最后再分享一个习惯:实现这类传统算法时,手算一个小例子和跑通代码同样重要。我在5x5图像上手算的那几步,比看十篇教程都管用。如果你也卡在Haar特征上,建议别直接在大图上调参,先拿小图像,拿出纸笔,把积分图和特征值一步一步算出来。算通了,后面所有代码逻辑都会顺理成章;算不通,那就去检查自己的索引,多半是某个加减号或者边界偏移的问题。