最近在整理视觉特征相关的笔记,发现一个很有意思的现象:大家聊行人检测,张口就是各种深度网络,但真到边缘设备、老系统改造、快速原型验证的时候,很多人又默默把HOG特征描述算子翻了出来。这玩意儿是2005年前后提出的经典方法,一套“HOG特征+SVM分类器”的组合拳,在很长一段时间里就是行人检测的代名词。即便放到现在,理解它的每一步设计,对理解卷积神经网络为什么能用、怎么设计,也很有帮助。
这篇学习笔记我打算把HOG从原理到工程讲透,覆盖五个部分:它为什么能用来做行人检测、特征具体怎么算、经典参数是怎么定出来的、训练检测的完整管线长什么样、以及我在实际测试里踩过的坑和优化思路。适合刚接触传统视觉方案的读者,也适合想快速捡起这套方法的从业者。
1. 为什么一个“老”特征现在还要学:HOG的价值不在新旧,在思路
1.1 从“像素值”到“有方向信息”的描述子,到底改了什么
先想一个基础问题:如果用像素值直接当特征做行人检测,会碰见什么麻烦?最典型的就是光照变化:同一个行人,早晨和傍晚拍出来,同一块区域的像素值能差出好几倍,分类器很容易被这种变化带偏。另一个问题是位置偏移:行人在画面里稍微挪半个肩膀,所有像素坐标就全变了,特征向量也跟着剧烈变化,学过的那套空间关系就失效了。
HOG特征的核心思路,是不直接看像素颜色,而是看图像局部区域的梯度方向分布。所谓梯度,本质就是像素亮度变化最剧烈的方向和幅度。一个行人的轮廓、头肩的弧线、手臂和腿的边缘,在图像上都会形成非常明确的梯度响应。HOG把整张图切成若干小格子,在每个格子里统计边缘方向直方图,再把所有格子串起来,就得到一个能描述“目标外形轮廓”的向量。
这个思路的关键转变在于:它不再问“这个位置是什么颜色”,而是问“这块区域里边缘朝哪些方向走、强度有多大”。颜色容易受光照影响,但边缘方向这种结构信息要稳定得多。这也是HOG能扛住光照变化的基本盘。
1.2 行人检测为什么钟爱HOG:它恰好匹配了人的外形特点
行人检测这个任务,对特征有几个要求。第一要能区分“人形轮廓”和“杂乱背景纹理”;第二要对一定程度的形变鲁棒,人是非刚体,走路时手臂摆动、腿弯曲,框里的姿态变化很大;第三要算得快,早期实时代码在CPU上就得跑起来。
HOG恰好在这三件事上都有优势。一个直立或者行走的人,无论姿态怎么变,头肩的轮廓始终近似椭圆,躯干有一条明显的竖直主轴,四肢虽然会动,但边缘方向仍然集中在几个主要角度上。HOG把这种方向分布统计出来以后,哪怕姿态不同,分布的基本格局还在,这就给了分类器可学的东西。
相比之下,很多全局特征,比如直接把整张图缩放成一个固定尺寸的低分辨率像素模板,对姿态变化极其敏感,一个弯腰动作就能让模板匹配彻底失效。而HOG是分块统计的,某个格子内的方向分布对局部小范围偏移并不敏感,这种“局部统计+空间分块”的组合,正好给了它对形变的宽容度。
另外说个容易被忽略的点:HOG特征后来被证明和现代CNN的底层特征提取方式在思路上高度一致。很多卷积网络第一个卷积层学到的边缘检测滤波器,视觉上看就是HOG梯度模板的变体。想要理解深度学习为什么能work,回头看HOG实际上是一条捷径。这也是我把这篇笔记放在学习路线前面的原因。
2. HOG特征是怎么算出来的:从灰度图到3780维向量的完整链路
2.1 预处理和梯度计算:图像里到底是什么信号在驱动特征
HOG的输入一般要先转成灰度图。颜色信息在这个框架里不是主角,因为梯度本身已经包含了轮廓信息,RGB三个通道还会让计算量翻三倍,效果却未必更好。把灰度图统一缩放到目标窗口尺寸,比如64x128,这一步是为了让特征维度固定下来,否则不同尺寸的图算出来的特征长度不一致,SVM没法统一处理。
接下来是整个流程里最核心的一步:计算梯度。经典的实现方式非常简单,就是用两个一维离散微分模板对图像做卷积。水平方向用[-1, 0, 1],垂直方向用转置的[1, 0, -1]^T,得到水平梯度Gx和垂直梯度Gy:
Gx = I(x+1, y) - I(x-1, y)Gy = I(x, y+1) - I(x, y-1)Magnitude = sqrt(Gx^2 + Gy^2)Angle = arctan(Gy / Gx)
注意角度这里有一个重要选择:HOG用的是无符号方向,也就是角度范围0到180度,而不是0到360度。原因很直白:一个直立的行人,他头部左侧的边缘方向和右侧的边缘方向,在180度范围内会被映射到相近的角区间,如果采用360度有符号方向,对称结构会被拆成两个完全不同的方向,对行人这种刚性对称的目标并不友好。
梯度幅值代表“这个边缘有多明显”,梯度方向代表“这个边缘朝哪走”。后面所有直方图统计,都是在用这两条信息。这个阶段我个人建议可以顺手把梯度图可视化一下:用OpenCV算一次梯度,把方向映射成色相、幅值映射成饱和度,你会直观看到行人轮廓被“点亮”,而背景的平滑区域变成暗色。这一步对理解特征非常有帮助。
2.2 cell直方图、block归一化与特征串联:特征向量的组装过程
梯度算完后,就要开始分块统计。先明确几个概念,它们是从小到大的嵌套关系:
| 概念 | 典型尺寸 | 作用 |
|---|---|---|
| 像素级别 | 1x1 | 计算梯度 |
| cell | 8x8像素 | 统计方向直方图 |
| block | 2x2个cell | 局部对比度归一化 |
| 检测窗口 | 64x128像素 | 最终特征向量的边界 |
每个cell内部,要把180度范围分成9个bin,每个bin覆盖20度。cell里每个像素根据自己梯度方向投到对应bin,投票权重就是梯度幅值。这里有个实现细节叫“软投票”,也就是常说的双线性插值:像素的梯度方向落在两个bin夹角中间时,不是只投给最近的bin,而是按距离比例拆给相邻两个bin,同时在cell的x、y空间坐标上也做类似插值。这样直方图会更平滑,不会因为某个像素方向刚好卡在边界上产生剧烈跳变。
单个cell产出一个9维直方图后,问题来了:梯度幅值对光照和对比度非常敏感。同样一块白色衣服,在强光下边缘梯度幅值是阴天的好几倍,这会让直方图数值整体缩放。所以要把多个cell组成一个block做归一化。经典配置是2x2个cell组成一个block,这样一个block里有4个9维直方图,串联成36维向量。归一化方法常用L2-Hys,也就是先做一次L2归一化,再把分量截断到0.2,最后再做一次L2归一化。截断是为了抑制过大梯度对整体向量的支配,相当于给强边缘“限流”。
block在窗口里不是固定不动的,它要滑动。经典配置下每个block横向步长和纵向步长都是一个cell(8像素)。这样一来,在64x128的检测窗口里,横向有(64/8 - 1) = 7个block位置,纵向有(128/8 - 1) = 15个block位置,总计105个block。每个block贡献36维,最后得到105 x 36 = 3780维的特征向量。这个数后面会反复出现,因为它就是OpenCV里HOG描述子默认输出的维度。
用代码验证这一点非常方便:
from skimage.feature import hog import cv2 img = cv2.imread("person.png", cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (64, 128)) fd = hog( img, orientations=9, pixels_per_cell=(8, 8), cells_per_block=(2, 2), block_norm="L2-Hys", visualize=False, ) print(fd.shape) # 输出: (3780,)3. 经典参数为什么是这套:64x128、8x8、9个bin背后的取舍逻辑
3.1 窗口尺寸、cell和block承载的物理含义
很多人直接把64x128、8x8、2x2、9这些参数背下来,但很少去想它们是怎么来的。其实每一个参数背后都有非常实际的考虑。
检测窗口64x128,对应大约1:2的宽高比,这是成年人直立站姿最常见的投影比例。窗口内实际包含的不仅是身体本身,还预留了约16像素的边界,相当于给头部和脚底留了“呼吸空间”。如果直接把标注框贴边resize到窗口,特征的边缘比例会失真,训练出来的模型也容易对人头出框、脚底截断的情况误判。
cell尺寸8x8,是多种因素折中的结果。这个尺度大约意味着一个cell内部可能覆盖行人身体某个局部,比如小腿的一段、手臂的一截。在这个尺度上统计方向直方图,既能描述局部纹理结构,又不会因为格子太小而对肢体角度变化过于敏感。如果cell增大到16x16,一个人体的方向信息会被糊成一大锅粥,头部和肩膀的轮廓细节丢失;如果cell缩到4x4,特征维度暴涨,同时每个格子里像素太少,统计噪声会非常明显。
block为什么是2x2个cell,这跟重叠归一化的思想有关。block在滑动时步长只有一个cell,所以相邻block之间有三格cell是重叠的。也就是说,同一个cell会被多个不同的block覆盖、归一化多次。这种重叠设计让特征在空间上更平滑,目标只要移动一小段距离,特征变化是渐变的而不是突变的,对SVM来说极其重要。如果不做重叠,目标只要平移半个cell,特征立刻跳到另一组数值,分类器很容易被这种跳变干扰。
3.2 bin数量与角度范围的权衡:为什么9个bin“刚刚好”
方向直方图的bin数量,决定了特征对方向分辨率的上限。180度分成9个bin,每个bin20度。对于行人轮廓这种结构信号,20度的方向粒度已经足够区分头肩弧线、竖直躯干、倾斜四肢这几类主要方向。如果把bin数增加到18个,每个bin只有10度,看着分辨率高了,但同一个人因为姿态微调,方向分布很容易从一个bin翻到隔壁bin,反而让特征不稳定。而且维度会从3780变成7560,训练和检测的计算量都翻倍,检测率却未必有实质提升。
还有一点是关于方向是否带符号。前面提到HOG使用0到180度的无符号方向,这在行人身上是对的,但拿来做其他目标检测时不一定通用。比如识别手写数字,方向的正负信息可能带语义;而人形轮廓本质是左右对称结构,用无符号方向可以让对称的边缘归并到相近方向,从而减少维度浪费。做工程时如果换目标,这几组参数都要重新验证,不能惯性沿用。
我自己做过一组对比测试,在公开行人数据集上跑不同参数配置,结果很有意思:
| 配置 | 窗口维度 | 检测效果变化 |
|---|---|---|
| cell 8x8, block 2x2, 9 bins | 3780 | 基准配置,稳定可靠 |
| cell 8x8, block 2x2, 18 bins | 7560 | 训练稍慢,检测率几乎持平 |
| cell 16x16, block 2x2, 9 bins | 810 | 训练快,但漏检明显增加 |
| cell 4x4, block 2x2, 9 bins | 13320 | 维度大,对遮挡更敏感 |
结论很清晰:那套经典配置不是随手凑的,而是在大量实验里稳定出来的平衡点。做原型验证时,直接用默认配置就够了,不要一上来就调参。
4. 从特征到检测结果:SVM训练、窗口滑动与后处理全流程
4.1 构建训练集与线性SVM:难例挖掘才是精度分水岭
特征准备好了,接下来需要一个分类器把“行人特征”和“非行人特征”分开。经典方案是线性SVM。为什么用线性而不选更复杂的核?因为HOG特征的维度已经很高,而且经过了重叠block的局部归一化,在经验上呈现出较好的线性可分性。线性SVM训练快、预测更快,在检测阶段每滑动一个窗口只需要一次点积运算,这在CPU上非常关键。
训练数据的准备有一些门道。正样本是裁剪好的行人图像块,统一resize到64x128。负样本是从不含行人的场景图里随机裁剪的64x128图像块,数量一般是正样本的几倍甚至十倍以上。只靠随机负样本往往不够,因为随机采样到的都是“容易”的负样本,分类器学不到难例。真正拉高精度的操作叫难例挖掘,也就是训练完第一轮模型后,把模型放到大量纯背景图上跑检测,收集所有被误判为行人的区域,把这些“难例”重新放入负样本集合,再训练第二轮。
这个流程通常要重复两到三轮。每轮迭代,模型都会被迫去处理上一轮骗过它的模式,比如电线杆、树干的竖直纹理、车窗的反射轮廓。我见过不少初学项目,正样本几百张、负样本几千张就直接训,漏检率可能不高,但误检率会高得离谱。原因就是负样本里缺少那些和行人局部轮廓相似的背景模式。
正样本的数量也不是越多越好,关键在多样性。同一段视频里连续帧截出来的样本,姿态和背景几乎一样,提供的信息高度冗余。更有价值的做法是加入翻转、小幅旋转、平移裁剪、亮度扰动,相当于做数据增强,让SVM看到的正例覆盖每种姿态的可能性。我自己一般会把正样本做一次水平翻转直接翻倍,再按1.05、0.95两个尺度做小幅缩放裁剪,规模不一定要特别大,但分布要广。
4.2 检测阶段的完整链路:尺度金字塔、滑动窗口和NMS
检测器和训练器不一样,它不是输入一张图直接告诉你“哪里有行人”,而是要在整张图上做密集搜索。搜索分两层:空间上的滑动窗口和尺度上的图像金字塔。
滑动窗口很好理解,用64x128的窗口从图像左上角开始,按固定步长往右往下挪,每挪到一个位置就把窗口内的图像块缩放到固定尺寸,提取HOG特征,送给SVM打分。步长一般用4到8像素:步长越小,定位越精细,但计算量成倍增加;步长太大,窗口可能正好错过行人身体的一半。
尺度金字塔解决的是行人远近造成的尺寸变化。做法是把输入图像按比例逐步缩小或者放大,比如每层缩放1.05倍,每层都做一次完整滑窗。这样远处的小人会在某个金字塔层级变成接近64x128的大小,从而被捕获。这个1.05的倍率很微妙,倍率越小,层级越多,越不会漏掉中间尺寸,但耗时线性上涨;倍率1.10会快很多,但可能正好跳过某个体型偏瘦的行人尺寸。
后处理主要是非极大值抑制。因为同一个行人往往会被多个相邻窗口、多个相邻尺度同时检测到,一堆重叠框集中在一起。NMS的思路是:把所有检测框按得分排序,从最高分框开始,把和它重叠面积超过阈值的其他框全部删除,再处理下一个。IOU阈值通常取0.3到0.5,阈值过低会漏掉两个挨得很近的人,阈值过高又会保留大量重复框。
用OpenCV跑一遍现成的完整检测流程很简单:
import cv2 hog = cv2.HOGDescriptor() hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector()) image = cv2.imread("street.png") rects, weights = hog.detectMultiScale( image, winStride=(4, 4), padding=(8, 8), scale=1.05, hitThreshold=0, )这段代码里,winStride控制窗口移动步长,padding给窗口四周补了8像素边缘,相当于给缩放后的图像块预留边框缓冲,scale控制金字塔每层缩放比例。hitThreshold是SVM分类面的偏移阈值,调高它会减少误检、同时可能增加漏检。跑完之后rects就是最终的行人框列表,weights对应每个框的分类得分。
5. 实际工程里最容易翻车的几个细节与优化方向
5.1 我测试这套方案时踩过的坑,每一个都值得记下来
第一个坑是训练和检测阶段的预处理不一致。训练时我把正样本做了灰度化加上高斯模糊去噪,检测时却忘了加模糊,结果特征分布出现系统性偏差,检测率掉了一截。特征工程的铁律是:训练管线怎么处理,检测管线必须一模一样地处理,任何一处不一致都可能让整套模型废掉。
第二个坑和正样本裁剪有关。有些公开数据集的标注框本身就是紧贴人体的,直接resize到64x128会让行人充满整个窗口,头顶和脚底贴边。这种样本训练出来的模型,对边缘被截断的行人特别不友好,检测时点位稍微偏一点,得分就掉到阈值以下。正确做法是标注框向外扩展10%到15%,再裁剪resize,相当于人工制造padding。
第三个坑是负样本里的“假难例”太多。很多人收集负样本时,喜欢把整张风景图全部塞进去,但负样本里大量重复的天空、道路区域不会给模型带来任何新约束,反而让训练集臃肿。更有效的是做难例挖掘,动态收集误检区域。这里我踩过最惨的一次,是只做了三万多张随机负样本,训练结果在工厂场景里把灭火器、消防栓、立式空调全检测成行人,因为它们的竖直柱状轮廓和人体躯干实在太像了。后来专门收集了这些设备的图像作为难例重新训练,误检才压下来。
第四个坑是NMS参数。在密集人群场景里,两个行人站得很近,重叠度超过0.6,如果我的IOU阈值设成0.5,会把其中一个人直接删掉,导致漏检。反过来,在稀疏场景里,同一行人被多个框包围,阈值设成0.8又会保留一堆重复框。实际做法是分场景调阈值,并结合分类得分做二次筛选,别指望一个值通吃所有情况。
| 问题 | 表现 | 处理办法 |
|---|---|---|
| 预处理不一致 | 检测率整体下降 | 训练和检测共用同一套预处理函数 |
| 正样本无padding | 行人贴边时漏检 | 标注框扩10%~15%再裁剪 |
| 负样本太“简单” | 背景柱状物误检 | 难例挖掘,迭代训练2~3轮 |
| NMS阈值固定 | 密集场景漏检或重复框 | 按场景调IOU阈值或二次筛选 |
| 只跑单尺度 | 远处小人漏检 | 金字塔层级覆盖,最小尺度调到人脸高度 |
5.2 提速与增强:让HOG方案还能继续服务工程
HOG+SVM最让人头疼的是计算量。在一个1080p画面里,按4像素步长滑窗加金字塔扫描,纯Python实现可能一帧要好几秒。工程化的时候有几种常见的提速手段。
积分直方图是第一种。传统做法是每滑到一个窗口就重新提取一遍特征,大量重复计算了窗口之间的公共区域。积分直方图把每个方向的梯度先做积分图,然后任何矩形区域的直方图都可以通过几次查表和加减法直接得到,窗口滑动的计算量从与面积相关降为与边界相关,速度可以提升一个数量级。
第二种是候选区域预筛。不要用滑窗盲搜,先利用运动信息或背景建模锁定有变化的区域,只在这些区域里跑HOG,原本几秒的处理直接压到百毫秒级。很多老项目里,HOG是被放在帧差法或者混合高斯背景模型后面的,目的就是把算力集中在真正可疑的区域。
第三种是特征增强。HOG对纹理丰富但轮廓弱的场景并不敏感,比如穿花衣服的人,颜色纹理变化剧烈但梯度方向很乱。实践中可以把它和LBP纹理特征或者颜色自相似特征拼接起来,维度可能高一些,但误检率往往能有明显下降。在深度学习不好部署的老设备上,这种传统特征融合方案还有很大的生存空间。
第四种思路也比较实用:把HOG作为辅助信号和CNN特征做串联。有些嵌入式设备跑不动大型CNN,但能跑一个非常轻量的小网络,这时可以把HOG特征作为额外输入拼进网络第一层,相当于给网络注入预先设计好的边缘先验,能提升收敛速度,也能在小模型上补回一部分精度。
写在最后的一点经验
真正把HOG这套东西吃透,最好的方式不是直接调库,而是找一个周末,把灰度图到3780维向量的每一步用手写代码走一遍,然后可视化每个cell的直方图输出,你会在视觉上直观感受到特征是如何“描述”一个人的。我调试时的习惯是顺便把梯度方向图叠加在原图上,用颜色表示方向,看到头肩和腿部被清晰的边缘响应覆盖,再回头理解参数,很多疑惑会自然消失。
如果只是做工程,那就直接基于OpenCV的默认配置起步,换数据集后先评估再考虑调参数。大多数情况下,问题不是出在特征上,而是出在训练样本的质量、难例挖掘的深度以及检测管线的预处理一致性上。把这几个基本功做扎实,HOG在行人检测上的表现,依然比很多人想象的要靠谱。