Haar特征计算过程,是我在参加某个开源学习小组时收获最大的一段内容。很多人刚接触计算机视觉时,第一反应是深度学习、卷积神经网络,一听到Haar特征就会问“这玩意儿是不是早过时了”。其实恰恰相反,如果你去面试图像算法岗,十有八九会被问到“Haar特征是怎么算的?积分图是什么?”,哪怕你平时做的全是深度学习项目,这两个问题也经常被用来考察基础。更实在的是,Haar特征配合积分图的思路,一旦真正理解了,很多图像处理问题都会打开新路子。这篇文章就把Haar特征计算过程从头到尾拆开讲一遍,从定义、数学原理、积分图加速,到手算示例和Python代码,再附上我实操时踩过的坑,希望帮你一次性弄明白。
1. 先把概念理清楚:Haar特征到底在算什么
1.1 一条边、一块亮斑,怎么变成数字
Haar特征最早来自小波分析里的Haar基函数思想,后来被用到了图像领域,专门用来描述局部的明暗对比模式。它不关心像素的具体颜色,更关心“这块区域是不是比旁边那块区域亮/暗”,用大白话说,就是通过相邻矩形区域的像素和差异,把“这里有一条边界”“这里有一块亮斑”这样的视觉模式转换成数值。
具体怎么转?先定义几种矩形模板,最基础的有两类:两矩形特征和三矩形特征。两矩形特征由一个白色矩形和一个黑色矩形组成,特征值就是白色区域像素总和减去黑色区域像素总和。三矩形特征通常中间是白色、两边是黑色,或者反过来,特征值变成两侧黑色区域的像素和减去中间白色区域的像素和。到了四矩形特征,就比较复杂,比如中心区域与外围区域的差异,这个常用在检测眼睛、鼻梁周围那种中心暗、四周亮的对称结构。
下面我按常见分类整理一下:
| 模板类型 | 结构示意 | 典型用途 |
|---|---|---|
| 边缘特征(两矩形) | 左右/上下两块黑白 | 检测垂直或水平边缘 |
| 线性特征(三矩形) | 中间一块亮,两边暗 | 检测细长条状结构,比如鼻梁 |
| 中心环绕特征 | 中心亮/暗,四周相反 | 检测对称的暗斑或亮斑 |
| 对角线特征 | 对角分成两块黑白相间 | 检测对角线方向纹理 |
就这么一个简单定义,为什么它能代表“特征”?你想想,人脸眼睛区域的像素通常比脸颊暗,鼻梁比两侧暗,嘴巴区域和周围肤色有明显分界——这些结构虽然复杂,但归根结底都可以拆成长宽不等的矩形对比。Haar特征做的就是把这些结构用“哪个框里像素值加起来大、哪个框里加起来小”来描述。值是正的,说明白色区域更亮;值是负的,说明黑色区域更亮。这个符号不是重点,重点是这个差值的大小和分布模式具备区分能力。
1.2 一个窗口到底能生成多少Haar特征
你可能想问:既然模板种类就这么几种,那特征数量应该不多吧?错了,Haar特征最大的问题就是数量爆炸。
假设检测窗口固定大小,比如24×24,一个横向二矩形特征模板,尺寸可以伸缩,位置可以滑动。基本计算方式是这样:如果模板基础宽度是w、高度是h,窗口宽度是W、高度是H,那么在一个固定尺寸下,水平方向可以有W - w + 1个合法位置,垂直方向有H - h + 1个合法位置,而模板尺寸本身又可以按整数倍缩放。把所有模板、所有尺度、所有位置全部枚举一遍,24×24窗口内的Haar特征总数量会超过18万个。
这个“18万”不是一个可以忽略的数字。如果每个特征都要对原图直接计算一遍矩形区域像素和,即便用很快的遍历方式,检测一个窗口也会慢到没法用。也正因如此,后面才会有积分图这种神级加速手段,而且在训练阶段,18万个特征不可能全部用上,必须靠后面提到的提升算法去挑一小部分有效特征。理解这一点很重要:Haar特征本身只是提供了一个巨大的候选池,真正让它发挥作用的,是“快速计算”和“特征筛选”这两件事。
2. 积分图:让特征计算从“慢悠悠”变“秒算”
2.1 积分图是什么:先做一张“累计账本”
积分图这个思路特别像记账的时候先记累计金额。假设你想知道一个月里任何一小段时间花了多少钱,最笨的方法是每天翻流水账,把这段时间逐天相加;聪明一点的做法是平时就记一个“到今天为止一共花了多少钱”,那么任意时段的花费,只需要用“到结束日的累计金额 - 到开始日前一天的累计金额”就够了。
图像里的积分图就是这个记账本。定义很简单:积分图在某个位置上的值,等于原始图像左上角到该位置这个矩形区域内所有像素的灰度值之和。用公式表达,ii(x, y) = sum(I(i, j)),其中i从1到x,j从1到y。这样一张积分图,尺寸和原图一样大(实现时通常多一圈padding,后面会讲),但每个位置都存的是一个累计和。
构建积分图有现成递推公式,不需要每个位置重新从左上角遍历一遍:
ii(x, y) = I(x, y) + ii(x-1, y) + ii(x, y-1) - ii(x-1, y-1)第一次看到这个公式的人往往会懵,为什么要减掉一个ii(x-1, y-1)?因为ii(x-1, y)已经包含了左上方区域的和,ii(x, y-1)也包含了左上方区域的和,两者相加会把左上角那块重叠部分加两遍,必须减掉一次。这就是积分图递推公式的精髓,也是后面矩形求和公式里“加回重叠”操作的镜像。
2.2 任意矩形区域求和,只要查四次表
有了积分图,计算原始图像中任意一个矩形区域的像素和,变成了四次数组访问和三次加减法:
sum(x1, y1, x2, y2) = ii(x2, y2) - ii(x1-1, y2) - ii(x2, y1-1) + ii(x1-1, y1-1)这里(x1, y1)是矩形左上角坐标,(x2, y2)是右下角坐标。为什么要加上最后那一项?因为把右上角ii(x1-1, y2)和左下角ii(x2, y1-1)都减掉时,左上角重叠区域ii(x1-1, y1-1)被减了两次,所以得加回来一次。这个逻辑和递推公式完全对称。
实际操作中,为了处理边界方便,很多人会把积分图做成比原图大一圈的数组,第0行和第0列全部是0。这样原来公式里的负索引就全变成0,代码里不需要写一堆if x1-1 < 0的判断,实现起来干净利落。下面代码实现时就按这个方式做。
2.3 用积分图计算Haar特征值
现在我们回到Haar特征本身。一个二矩形Haar特征,就是白色区域像素和减去黑色区域像素和。既然任意矩形区域都能用四次积分图查表获得,那么一个二矩形特征就只需要:
- 算白色矩形像素和,4次查表;
- 算黑色矩形像素和,4次查表;
- 做一次减法。
总共8次查表、几次加减法,跟特征模板的尺寸完全无关。换句话说,不管这个特征是1×1还是20×20,只要积分图构建好了,计算成本都一样,这是Haar特征能实用化的关键。这也是为什么VJ那个时代的人脸检测器能跑到每帧15fps以上的原因之一:窗口滑动、尺寸缩放都不需要重新统计原图,全部查表解决。
构建积分图本身也是一次全图扫描,对每张图只需要做一次。如果同时对一个图像金字塔做检测,每层都需要单独构建积分图,但每层构建成本也只是单次O(图像面积),相比原来每个尺度都要遍历全部窗口、每个窗口都要算几十上百个矩形和,提升不是一个数量级的。
3. 从零手算一次Haar特征:完整流程拆解
3.1 准备一张小图和一组模板
理论讲再多,不如手算一次。我拿一个5×5的灰度矩阵做例子,像素值我编得尽量简单,方便口算验证:
列0 列1 列2 列3 列4 行0 1 2 1 2 1 行1 3 1 3 1 3 行2 1 2 1 2 1 行3 2 1 2 1 2 行4 1 3 1 3 1假设我要用一个横向二矩形特征,左边两块当作白色区域,右边两块当作黑色区域。具体来说:
- 白色区域:第0列到第1列,第0行到第4行,坐标为
(x=0, y=0)到(x=1, y=4) - 黑色区域:第2列到第3列,第0行到第4行,坐标为
(x=2, y=0)到(x=3, y=4)
这里要注意,Haar特征的矩形不一定刚好是上下两半或者左右两半,你可以定义任意宽度比例,比如白区两列、黑区两列,模板整体宽4列、高5行。只要矩形之间不重叠,就是一个合理特征。
3.2 构建积分图,再查表算特征值
先把这张5×5矩阵的积分图算出来。我采用大一圈的6×6数组,第0行、第0列为0:
第一个位置ii(1,1)等于原图(0,0)的像素值1。然后按递推公式慢慢填。最终积分图应该是这个形状:
0 0 0 0 0 0 0 0 1 3 4 6 7 0 0 4 8 15 18 28 0 0 5 11 19 24 37 0 0 7 14 24 29 46 0 0 8 19 29 38 61你随便挑一个格子验证,比如ii(3,3),按定义应该是原图前3行前3列像素之和:1+2+1+3+1+3+1+2+1=15,表格里第3行第3列确实是15,说明填表过程没问题。
然后用前面矩形求和公式:
- 白色区域像素和 =
ii(2,5) - ii(0,5) - ii(2,0) + ii(0,0)。从表里查,ii(2,5)是原图第0行到第4行、第0列到第1列的和,按定义应该是7+11+7+8+8=41?等等,我重新核对一下表格数据。
其实用手写5×5表直接累加更稳妥。白色区域第0列和第1列,所有行加起来:第0行1+2=3,第1行3+1=4,第2行1+2=3,第3行2+1=3,第4行1+3=4,总和=3+4+3+3+4=17。黑色区域第2列和第3列:第0行1+2=3,第1行3+1=4,第2行1+2=3,第3行2+1=3,第4行1+3=4,总和=3+4+3+3+4=17。这样一来特征值 = 17 - 17 = 0。
这个结果会让人有点疑惑:怎么算出来是0?因为这张图的行模式周期性太强,左右两半确实完全对称,特征值当然为0。这恰好说明一个很重要的事实:特征值不是越大或者越小就越好,它只是描述了一块区域内明暗对比的数值。如果特征正好覆盖在颜色分布均匀的区域,结果就是0左右的小值。AdaBoost后面要做的,就是给每个特征找一个阈值,比如“当特征值大于某个值时判为前景,否则判为背景”。0本身没有优劣之分。
为了让例子更直观,我可以把原矩阵改一下,让白色区域整体偏亮一些。假设把第0列和第1列每个像素都加5,其他不变。那白色区域总和变成17 + 5×10 = 67,黑色区域仍是17,特征值 = 50。正数说明白色区域更亮,方向就出来了。
3.3 特征归一化:光照一变,什么都没意义了
直接算出的特征值有一个隐患:光照敏感。假设同一张人脸,白天和晚上拍的像素值整体偏亮或偏暗,计算出来的特征值会整体变化,同一个检测器的阈值就不稳定了。经典的检测方案里会做一步方差归一化,原理是先计算整个检测窗口的均值和方差,然后用它对特征值做归一化。
一个简化的做法:计算出整个窗口像素的标准差,把每次特征值除以标准差。由于标准差本身也随光照同尺度变化,除法就能抵消大部分光照影响。在实际工程中,更常见的方式是构建积分图和平方积分图,窗口均值用积分图算,窗口标准差用平方积分图算,这样归一化过程同样可以快速完成。平方积分图理解起来也不难,就是原始图像先每个像素平方,再构建积分图,查询方式和普通积分图完全一样。
3.4 一段能跑的参考代码
下面是刚才手算过程的代码实现,从灰度图到积分图再到特征值的完整链条。注意我用的是numpy数组,为了方便演示,没有加复杂的边界判断,而是采用大一圈的padding方式:
import numpy as np def build_integral_image(gray): """构建积分图,返回比原图多一行一列的二维数组""" h, w = gray.shape ii = np.zeros((h + 1, w + 1), dtype=np.int64) for i in range(1, h + 1): for j in range(1, w + 1): ii[i, j] = (gray[i - 1, j - 1] + ii[i - 1, j] + ii[i, j - 1] - ii[i - 1, j - 1]) return ii def rect_sum(ii, x1, y1, x2, y2): """计算原图坐标 (x1,y1) 到 (x2,y2) 矩形内像素和,坐标从0开始""" return (ii[y2 + 1, x2 + 1] - ii[y1, x2 + 1] - ii[y2 + 1, x1] + ii[y1, x1]) def haar_two_rect(ii, white_rect, black_rect): """white_rect/black_rect 格式: (x1, y1, x2, y2)""" return rect_sum(ii, *white_rect) - rect_sum(ii, *black_rect) gray = np.array([ [1, 2, 1, 2, 1], [3, 1, 3, 1, 3], [1, 2, 1, 2, 1], [2, 1, 2, 1, 2], [1, 3, 1, 3, 1] ], dtype=np.uint8) ii = build_integral_image(gray) white = (0, 0, 1, 4) black = (2, 0, 3, 4) value = haar_two_rect(ii, white, black) print("特征值:", value) # 输出 0如果你把原图像素改一下再跑,就能直观看到特征值怎样随宽度变化。
输入一张真实灰度图也可以直接复用上面的代码,只要把gray = cv2.imread(...)读进来再转灰度就行。如果图像比较大,纯Python双循环构建积分图会有点慢,可以改用OpenCV的cv2.integral,它内部做了优化,速度会快非常多,而且支持不同的输出精度类型。
4. 实操中的常见坑与排查心得
4.1 积分图会溢出吗?别用太小的整数类型
这是我最开始踩过的一个坑。一张分辨率稍微大一点的图,比如1920×1080灰度图,像素值按0到255算,积分图里最大位置的累计值可能达到1920×1080×255,约5.3亿。如果你用uint16存,最大值只有65535,直接溢出,后面所有特征值计算全部是错的,而且错得毫无规律,排查起来非常头疼。
所以构建积分图时,内存类型至少要int32,最保险的是int64。用OpenCV时也要明确指定sdepth参数,默认情况下可能是已知精度,但强烈建议显式写成cv2.CV_32S或cv2.CV_64F。如果做方差归一化,还需要平方积分图,平方之后的累计值更大,尤其要用大的整数类型,不然分分钟溢出。
4.2 矩形坐标越界:要么越界,要么算错位置
特征模板在窗口里枚举时,最容易犯的错误是坐标算错。积分图的查询公式里面,x1-1这种索引减1的操作,一旦你直接实现而没有padding,在矩形贴着左边界或上边界时会出现负索引。我建议统一采用“积分图比原图多一圈”的方案,坐标从0开始,转换到积分图索引时都加1,这样边界情况全部自动消解,代码里不用写一堆条件判断。
另外,矩形右边界和底边界也要检查,枚举缩放尺寸的时候,x2不能大于窗口宽度减1,y2不能大于高度减1。实战中最好先把所有合法特征的位置、尺寸全部枚举出来,生成一个列表,再去计算对应特征值,不要边算边判断,否则逻辑容易乱。
4.3 白色区域和黑色区域搞反了,特征值会变号吗
会变号,但通常不是问题。Haar特征值本身可正可负,同一个特征,你把白色和黑色对调,数值就从正变负。在训练阶段,提升算法在选弱分类器时,本来就要同时考虑正负两个方向,它会尝试特征值 > 阈值判正,也尝试特征值 < 阈值判正,选出错误率更小的那个方向。所以不需要你自己去规定哪个区域必须亮,哪个区域必须暗。不少开源实现为了直观,会在训练前算完所有特征后取绝对值,这样做能省一半的阈值搜索方向,但也会丢失一些微妙的信息,建议刚开始学习时保留正负号,不要盲目加绝对值。
4.4 特征数量太多怎么办,别试图全部算一遍
18万个特征如果全部显式计算,训练时内存占用会非常夸张。很多初学者会把每个特征全算一遍存在numpy数组里,算到后面计算机直接卡死。我的建议是:
- 如果目标是学习和理解,只挑几个典型模板,在几张图上可视化它们的响应,重点看特征图和原图的结构对应关系;
- 如果想跑一个完整的检测Demo,直接用OpenCV内置的训练结果,不要自己从头训练;
- 如果确实要自己训练一个小模型,尽量用能增量更新的方法,比如构建一个生成器,每次从特征池中随机采样一部分特征做弱分类器选择,而不是一次性把全部特征矩阵放到内存里。
4.5 用OpenCV的integral函数时候,注意返回类型和通道顺序
OpenCV的cv2.integral(src, sum, sdepth)用法很简洁,但它返回的积分图数组维度是(H+1, W+1),首行首列是全0,这个和手写版本一致。但有一点要留意:如果你的图像是彩色图或者没有预先转成灰度,OpenCV会默认按通道分别计算积分图,返回的就不是单通道二维数组,而是多通道三维数组,后面你取特征值时就会踩坑。务必先转灰度再积分。
5. 学完之后往哪延伸更值
5.1 Haar特征不是孤立的,它在经典检测框架里只是第一棒
算完一个Haar特征,只是整个过程的第一步。经典的目标检测框架通常把整件事拆成三步:首先,从一个检测窗口里穷举生成海量Haar特征;其次,用提升方法从这些特征中挑选少数几个高区分度的特征,组合成一个强分类器;最后,多个强分类器级联起来,先快速拒绝大部分不含目标的窗口,再对剩下的少数窗口做精细判断。
这里我推荐一个很实用的理解方式:把Haar特征当成一个“候选池”,AdaBoost负责在这个池子里打捞金矿。单个Haar特征太弱,单独拿出来当分类器,准确率可能只比随机好一点点,但它计算极快,AdaBoost每轮都会挑一个在当时区分度最高的特征,并且给分错的样本加权,下一轮就更容易选出和上一轮互补的特征。最后组合起来的强分类器,往往只用几十个特征就能达到很高的准确率,而且推理时只用算这几十个特征,不是把18万个全算一遍。
5.2 手工特征和深度学习特征的关系,别站错队
现在很多人一提到传统视觉特征就摇头,觉得都是过去式。但你在实际项目里可能会遇到这样的场景:硬件资源有限,模型太大部署不上;或者需要极低延迟,深度学习还没有压缩到足够快;又或者整个系统里只是需要在固定位置判断一个窗口里有没有某种小目标,完全没必要拖一个大模型上去。这时候Haar、LBP这类手工特征依然能打。
再看现在几乎成为默认方案的卷积神经网络,它本质上也在学特征,只不过特征是在训练时自动从数据中总结出来的,而不是手动定义矩形模板。理解Haar特征能帮你更好地理解“特征”这件事的本质:不管手工还是自动,都要捕捉局部结构的统计规律。另外,Haar特征那种用积分图做快速区域聚合的思想,在深度学习里也有变体,比如一些快速池化方法会预计算累计和来加速任意区域特征聚合,底层思路是共通的,学完不亏。
我个人建议,入门计算机视觉不要一上来就只泡在深度学习框架里,花半天时间把Haar特征手推一遍,再做点小实验,对图像的局部纹理、区域统计、特征筛选、光照鲁棒性这些概念的体感会强很多。积累下来的积分图套路,后面你写滑动窗口算法、做区域聚合、搞感兴趣区域统计时都能用到,这笔账怎么算都不亏。