HOG特征+SVM姿势检测工程包:从特征提取到训练推理全解析

发布时间:2026/10/25 18:50:56
HOG特征+SVM姿势检测工程包:从特征提取到训练推理全解析

简介:基于支持向量机(SVM)与方向梯度直方图(HOG)特征实现姿势检测的完整项目资源,面向计算机视觉、机器学习初学者及AI应用开发者,适用于行人姿态识别、人机交互、安全监控等场景。压缩包内共22个文件,其中5个Python脚本覆盖特征提取、SVM训练与视频检测全流程,13张JPG图片为不同姿势的测试样本,另有预训练模型、配置文件及AVI/MP4演示视频,包体大小44MB。已有189人学习下载,具备不错的参考价值。读者可通过该资源直观理解HOG特征向量的细胞单元、块归一化等构造步骤,以及SVM核函数选择、惩罚参数调优、模型训练与调用的完整链路,并能直接利用训练好的模型对静态图像和实时视频进行姿势检测实验。项目代码结构清晰,适合作为机器学习入门实践的动手素材。

1. 基于SVM分类器实现姿势检测:HOG特征 + 线性SVM的落地工程包

“基于SVM分类器实现姿势检测”这个项目,把传统计算机视觉里最经典的一条路线——HOG特征描述人形轮廓,再用SVM分类器决定“这里有没有人、是什么姿势”——做成了一个可以直接跑的完整工程包。里面不只有一份训练好的svm.model,还有extract_features.py、train_svm.py、detector.py、video_detector.py四份脚本,以及config.py集中管理全部参数。整条链路不需要GPU,普通CPU上跑视频检测也能保持可用帧率。适合两类人:一是刚入门目标检测、想理解HOG和SVM是怎么配合的开发者;二是需要在轻量场景里快速验证传统CV方案的工程师。这套东西能解决的核心问题,就是让你别从零搭起,而是直接改参数、换数据、重训模型。

2. 先拆检测链路:HOG特征如何描述“人形”,SVM如何分类姿势

2.1 HOG特征提取:从灰度图到方向梯度直方图的完整步骤

HOG(Histogram of Oriented Gradients)的核心想法很朴素:一张图里目标的边缘和形状,可以用局部区域中梯度方向的统计分布来近似表达。一个人站着还是蹲着,躯干和四肢的边缘朝向会有系统性差异,把这种差异量化成特征向量,分类器才有东西可学。

实际提取时分为几步。先把图像灰度化,再做Gamma归一化来压制光照和阴影的干扰;然后对每个像素算梯度幅值和方向,通常用一维梯度算子[-1, 0, 1]分别在X和Y方向做卷积。接着把图像切成若干8×8像素的细胞单元(cell),在每个cell内把360度方向的梯度投票到9个直方图bin里,得到一个9维描述。最后把相邻的2×2个cell合成一个块(block),对这个块内串联后的直方图做一次L2范数归一化,这一步主要消除局部光照和对比度差异带来的影响。

OpenCV把这套流程封装在了cv2.HOGDescriptor里,只需要设置五个参数就能干活,我一般这样初始化:

import cv2 hog = cv2.HOGDescriptor( _winSize=(64, 128), # 检测窗口大小,训练和推理必须一致 _blockSize=(16, 16), # 块大小,决定归一化的局部范围 _blockStride=(8, 8), # 块滑动步长,控制特征重叠程度 _cellSize=(8, 8), # 细胞单元大小 _nbins=9 # 梯度方向直方图的bin数量 )

参数里最影响特征维度的是cellSize和blockStride。窗口64×128、cell 8×8、block 16×16、stride 8×8、nbins 9时,整个窗口被划分成7×15个块,每个块串4个cell的直方图,最终特征维度是7×15×4×9=3780维。这个维度是固定的,正好是SVM能直接吃进去的标准输入格式。cellSize从8改成4,特征维度会涨到四倍以上,训练和推理内存消耗都上来了,但对小目标的细节捕捉会更好,需要根据自己数据去权衡。

2.2 为什么选线性SVM:高维特征下核函数的取舍

分类器的任务是找一个决策边界,把正负样本在特征空间里划开。SVM的做法是找间隔最大的那个超平面,边界由离它最近的那些样本点决定,这些点称为支持向量。核函数决定这个边界在原始空间里是直线还是曲线。

这个项目里选线性核就够了,原因很实际:HOG特征维度动辄几千,高维空间里样本通常已经近似线性可分,硬上RBF核或者多项式核容易过拟合,训练时间也成倍增加。线性核需要调的参数主要就一个——惩罚系数C。C小,说明对误分类容忍度高、决策边界更平滑;C大,说明模型更偏向把训练样本全部正确分开,但泛化能力可能变差。样本量不大的姿势检测场景里,我一般会先把C设在0.01附近,观察交叉验证结果再往大调。

还有一个现实约束直接影响选型:OpenCV的HOGDescriptor做滑动窗口检测时,底层只支持线性SVM的系数向量。用RBF核可能在离线测试里指标好看,但落不到detectMultiScale的推理管线上。这也是为什么很多实际项目宁可牺牲一点精度也要用线性核。

顺带说一个常被拿来对比的方案:决策树分类器。决策树对高维连续型特征不太擅长,容易过拟合,而且它的输出是类别标签,不像SVM给的是“样本到超平面的距离”这个连续置信度。置信度在做检测框过滤时很有用——score低于阈值的窗口可以直接丢弃,决策树做不到这一点。

2.3 项目文件结构:四份脚本在检测链路中的分工

打开这个源码包,文件分两类。数据部分:models目录下的svm.model是训练好的分类器,test_image里放着13张测试图片,另有pedestrians.avi和Esquina_Democratica.mp4两个视频用于帧级验证;代码部分:config.py集中管理路径和参数,extract_features.py负责从训练图片中批量提取HOG特征,train_svm.py读取特征并训练出svm.model,detector.py和video_detector.py分别对图片和视频做推理。

整条数据流是:训练图片 →extract_features.py生成特征矩阵和标签 →train_svm.py训练并保存模型 → 推理时detector.py加载模型做滑动窗口检测。config.py里一个参数的改动会同时影响特征提取和训练两条路径。所以拿到这套代码的第一步不是急着训练,而是把config.py从头到尾过一遍,确认每个值都符合自己的预期。

3. 把模型训出来:config.py参数设置与训练全流程

3.1 config.py参数解读:窗口、细胞、块和惩罚系数C

这个项目的config.py主要集中了四类参数:数据路径、HOG参数、SVM训练参数、推理参数。我拿到任何一份目标检测源码包,都会先读这几个值,因为它们共同决定了特征维度和训练行为:

参数名推荐值作用
WIN_SIZE(64, 128)检测窗口尺寸,训练和推理必须一致
CELL_SIZE(8, 8)细胞单元尺寸,越小特征越密
BLOCK_SIZE(16, 16)块尺寸,控制归一化的局部范围
BLOCK_STRIDE(8, 8)块滑动步长,越小特征重叠度越高
N_BINS9梯度方向直方图柱数
SVM_C0.01惩罚系数,控制误分类容忍度和过拟合程度

这里重点说三个。第一是WIN_SIZE:训练样本、extract_features.py、detector.py三处使用的窗口必须一致,否则特征维度对不上,模型加载后直接报错。第二是BLOCK_STRIDE:如果从8改小到4,特征维度会从3780暴涨到15000以上,训练时间和内存都明显上升;改大了又容易漏掉局部结构信息。第三是SVM_C:在样本量小的时候最敏感,0.01是一个稳妥起点。当训练样本超过几百张时,我会把C往0.1方向调,大多数情况下效果更稳。

路径类参数也值得花一分钟核对。extract_features.py要读正负样本目录,train_svm.py要写features.npy和models/svm.model,detector.py要加载models/svm.model。源码包里的路径大概率是相对路径,跨平台跑的时候容易因为路径分隔符不一致报FileNotFoundError。我习惯把config.py里的路径改成基于os.path.dirname(__file__)的方式拼接,无论项目挪到哪个目录都不会跑丢。

3.2 extract_features.py:把训练图片批量转成HOG特征向量

extract_features.py做的事不复杂:遍历训练图片目录,对每张图片统一缩放到WIN_SIZE,计算HOG特征,把特征和标签拼成一个特征矩阵保存。常见实现是这样的:

import cv2 import numpy as np import os from config import WIN_SIZE, CELL_SIZE, BLOCK_SIZE, BLOCK_STRIDE, N_BINS def compute_hog_feature(img): """计算单张图片的HOG特征向量""" img = cv2.resize(img, WIN_SIZE) # 统一窗口大小 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) hog = cv2.HOGDescriptor( _winSize=WIN_SIZE, _blockSize=BLOCK_SIZE, _blockStride=BLOCK_STRIDE, _cellSize=CELL_SIZE, _nbins=N_BINS ) feat = hog.compute(gray) # 返回 (N, 1) 列向量 return feat.flatten() # 拉成一维特征向量 def extract_data(pos_dir, neg_dir): features, labels = [], [] # 正样本:标签为1,代表有人/有目标姿势 for fname in os.listdir(pos_dir): img = cv2.imread(os.path.join(pos_dir, fname)) if img is None: continue features.append(compute_hog_feature(img)) labels.append(1) # 负样本:标签为0,代表背景 for fname in os.listdir(neg_dir): img = cv2.imread(os.path.join(neg_dir, fname)) if img is None: continue features.append(compute_hog_feature(img)) labels.append(0) return np.array(features), np.array(labels) if __name__ == '__main__': feats, labs = extract_data('dataset/pos', 'dataset/neg') np.save('features.npy', feats) np.save('labels.npy', labs) print('特征矩阵维度:', feats.shape)

关键点在第一处:hog.compute返回的是一个(N, 1)的列向量,flatten()之后才变成Sklearn或OpenCV SVM能接受的(1, N)样本格式。第二个容易踩的坑是cv2.imread读入的图片尺寸如果比WIN_SIZE还小,resize会强行拉伸出很多插值噪声。我一般在加载后检查img.shape[0]和img.shape[1],至少保证宽高不小于64和128,否则这张样本要么丢掉,要么先裁剪再缩放。正样本目录和负样本目录的图片数量差距也不要太大,建议控制在3:1以内,负样本过多时SVM的决策边界会偏保守,后面推理阶段误检少了但漏检会明显变多。

3.3 train_svm.py:用OpenCV训练并保存svm.model

训练脚本读入上一步保存的特征矩阵和标签,构造SVM分类器,设置核函数和C值,训练完成后把模型存到models目录。推荐直接用OpenCV的SVM,而不是Sklearn,原因很直接:OpenCV的模型保存格式可以和cv2.HOGDescriptor在推理侧直接配合,省掉一次模型转换:

import cv2 import numpy as np from config import SVM_C, MODEL_PATH # 加载特征和标签,类型必须严格匹配 features = np.load('features.npy').astype(np.float32) labels = np.load('labels.npy').astype(np.int32) # 创建SVM分类器 svm = cv2.ml.SVM_create() svm.setType(cv2.ml.SVM_C_SVC) # C-SVC,适合二分类 svm.setKernel(cv2.ml.SVM_LINEAR) # 线性核 svm.setC(SVM_C) # 惩罚系数 svm.setTermCriteria((cv2.TERM_CRITERIA_MAX_ITER, 1000, 1e-6)) # 训练 svm.train(features, cv2.ml.ROW_SAMPLE, labels) # 保存模型 svm.save(MODEL_PATH) print(f'模型已保存到 {MODEL_PATH}')

这里有个容易被忽略的细节:setTermCriteria指定了迭代上限和精度阈值,不设置时某些OpenCV版本会用很小的默认值,模型可能还没收敛就提前退出训练。另一个常见问题是数据类型,OpenCV SVM要求特征必须转成np.float32,标签必须是np.int32,直接用numpy默认的int64或者float64会在训练时抛类型不匹配的异常。

项目自带的svm.model就是这条管线产出的结果。如果你打算用自己采集的数据重训,我强烈建议先把原来的svm.model备份一份——训练过程本身是随机的吗?不是,但你的数据变了,模型效果大概率会比原作者调好的差,这时候需要回到这个文件来对比验证。

3.4 验证训练结果:用test_image目录里的13张图片跑推理

模型保存成功后,用detector.py跑一下test_image里的13张测试图片,确认模型不是“训完了不能用的纸面模型”。detector.py的常见实现思路是先加载SVM模型,再把它转成HOGDescriptor能接受的检测器向量:

import cv2 import numpy as np from config import MODEL_PATH def load_detector(model_path): """从SVM模型构造HOG检测器向量""" svm = cv2.ml.SVM_load(model_path) # 取支持向量和决策函数系数 sv = svm.getSupportVectors() rho = svm.getDecisionFunction(0)[0] weight = sv[0] if sv.shape[0] > 1 else sv.reshape(-1) detector = np.append(weight, -rho) return detector def detect_image(image_path, detector): hog = cv2.HOGDescriptor() hog.setSVMDetector(detector) img = cv2.imread(image_path) boxes, weights = hog.detectMultiScale( img, winStride=(4, 4), padding=(8, 8), scale=1.05 ) for (x, y, w, h), score in zip(boxes, weights): if score > 0.5: cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) return img if __name__ == '__main__': detector = load_detector(MODEL_PATH) result = detect_image('test_image/8.jpg', detector) cv2.imwrite('test_result.jpg', result)

load_detector函数是所有推理代码里最关键的拼图。svm.model本身不能直接喂给HOGDescriptor,必须把支持向量和决策函数的截距拼成一个一维向量,OpenCV的HOG检测器内部拿这个向量去计算每个滑动窗口的得分。rho就是决策函数里的偏移项,在OpenCV里它返回的是负值,拼接时取其相反数。

detectMultiScale的winStride和scale是直接影响检测效果的两个参数。winStride控制滑动窗口步长,步长越小检测越精细但计算量越大;scale控制图像金字塔的缩放系数,1.05 表示每层缩小5%,对近距离大目标比较友好,1.1 以上则更偏向检测中等大小目标。推理时先不追求速度,把这两个值调保守一点,确认13张测试图都能框出目标,再去考虑性能优化。

4. 避坑指南:从svm.model到视频检测的五个高发坑

4.1 模型训练好了,detectMultiScale检出全是空框

现象:train_svm.py跑完,模型文件正常生成,detector.py也加载成功,但detectMultiScale返回的矩形列表是空的,13张测试图一张都检不出来。

原因:90%的情况是setSVMDetector用的向量构造错了。cv2.HOGDescriptor不接受普通SVM模型对象,它要求把支持向量和决策函数截距拼成符合内部格式的一维数组。拼接顺序、正负号其中一个错了,检测器得分全部是负值,滑动窗口一个都过不了阈值。另外,直接拿svm.model去predict而不是走detectMultiScale,得到的输出也不能直接当作检测框。

解决:按上面3.4节load_detector的写法,用getSupportVectors()取权重向量,用getDecisionFunction(0)[0]取rho,拼接时用np.append(weight, -rho)。每个OpenCV版本对getSupportVectors的返回结构有细微差异,保险起见先print(sv.shape)确认维度,如果返回多行只取对应决策函数那行。

4.2 单张图片能检到,视频里检测框疯狂抖动

现象:detector.py跑单张测试图片,检测框位置基本合理;换成video_detector.py跑pedestrians.avi,相邻帧的框跳来跳去,位置抖、尺寸也忽大忽小。

原因:detectMultiScale是逐帧独立检测的,没有任何时间上下文。视频帧的压缩噪声、运动模糊、光照变化都会让同一个目标在相邻帧拿到略有偏移的检测框,逐帧看都还行,连起来看就非常难受。

解决:给框做时间维度的平滑,最简单的办法是对检测框的中心坐标做指数移动平均,或者直接限制检测框在一两帧内的位移幅度。另一个思路是把winStride从 (4,4) 调大到 (8,8),滑窗重叠度降低后,检测结果的随机抖动也会明显减少,代价是小目标更容易漏检。我会先用scale=1.05观察抖动,不够再往1.1调。

4.3 站姿能检到、坐姿和背面漏检严重

现象:用源码包自带的svm.model检测,站立、面朝镜头的行人能框出来,坐着的、背对摄像头走过去的行人全部漏检。

原因:正样本里站姿和正面视角覆盖太多,HOG特征对姿态和视角非常敏感,训练集分布偏向某一类,决策边界就对其他姿态产生系统性误判。这和SVM算法本身没关系,是数据分布和测试场景不一致。

解决:扩大训练集的姿态覆盖,把坐、蹲、弯腰、侧身、背面都收进正样本。每类姿势至少保证几十个样本,不要为了凑数量重复裁剪同一张图的不同区域。原始项目没有提供姿势标签,需要自己采集或从视频中截帧、裁剪、打标签,再用extract_features.py重新生成特征并训练。

4.4 误检多到没法看:路灯杆、垃圾桶都被框出来

现象:跑Esquina_Democratica.mp4时,除了行人,路灯杆、垃圾桶、墙边的绿植都被画上了框。

原因:负样本太少或者背景太单调。detectMultiScale会在每个尺度的每个窗口都做一次分类,训练时负样本如果只是网上找的几十张风景图,和视频实际背景差异太大,分类器无法区分“真实的背后背景”和“需要丢弃的背景”。

解决:从实际视频中截取误检帧,把误检区域存成负样本,加入训练集重新训练。这种“困难负样本挖掘”是目标检测里最经典的做法。做法很粗暴但有效:跑一遍视频,把所有误检框对应的图像区域存成jpg,放进负样本目录,重新extract_features.py+train_svm.py,通常一轮下来误检数量能砍掉一半以上。

4.5 OpenCV版本不同,svm.model加载失败

现象:在自己机器上训练好模型,拷贝到同事电脑或者其他部署环境,detector.py报模型加载失败。

原因:OpenCV从3.x到4.x的SVM模型序列化格式有过调整,4.0之后的文件解析方式不完全兼容3.4生成的模型,反过来也一样。直接拷贝.model文件不是跨环境传递模型的好方法。

解决:最保险的做法是不要传递.model文件,而是把训练好的支持向量和rho另存为numpy的.npy文件,在新环境里重新拼装检测器向量。另一个办法是在项目里用requirements.txt固定OpenCV版本,部署时统一用同一个版本,避免模型文件格式漂移。

5. 进阶:把二分类检测器扩展成多姿势分类器

5.1 一对多策略:为每个姿势训练一个SVM

源码包默认解决的是“有人/没人”二分类,真要识别“站、坐、蹲、走”这些具体姿势,需要升级为多分类。最稳的方案不是改单个SVM为多核,而是训练N个一对一多分类器——每个姿势类别训练一个独立SVM,正样本是这个姿势,负样本是其他姿势加背景。

训练时,三个SVM的config.py需要分别维护,或者在config.py里加一个POSTURE_TYPE字段区分。每个分类器用相同的HOG参数和窗口尺寸,保证特征维度一致。推理时,对同一个滑动窗口分别调用三个SVM的得分,取得分最高的作为最终姿势标签。

5.2 多分类推理时的置信度比较与NMS

三个SVM同时对一个窗口输出得分,直接比较原始得分不严谨,因为不同分类器的决策边界差异会导致分数分布不一致。常见做法是先对每个分类器的得分做归一化——用验证集统计每个分类器得分的均值,再乘以一个校准系数。更简单的操作是只关注相对大小,把阈值统一设定在同样的分位点,这样误检不会跟着某个分类器跑偏。

另一个绕不开的步骤是非极大值抑制(NMS)。同一目标可能被多个窗口覆盖,导致多个姿势分类器同时产生高响应。先在每个姿势内部做NMS,合并重叠框,然后对最终保留的框比较不同姿势的归一化得分,输出得分最高的标签。这一步在video_detector.py里用OpenCV的cv2.dnn.NMSBoxes就能完成,不需要自己实现IoU计算。

5.3 验证多分类模型的工程习惯

多分类模型比二分类更容易出现“看起来效果很好、实际换个场景就崩”的情况。我的习惯是固定一个视频片段作为回归测试集,每次重训之后,跑一遍完整检测,记录三类指标:站立检出率、坐姿检出率、误检框数量。这三个数字同时记录,不能只看其中一个。如果站立检出率涨了但误检框翻倍,说明模型的决策边界过于激进,需要调高阈值或补充负样本。

从那以后,我每次调整config.py或者补充训练数据,都强制走完一遍回归测试流程,把三个指标记录下来再决定是否保留修改。这一步能挡住很多“感觉差不多、实际上退步了”的改动。这个项目本身的代码结构已经把二分类链路搭得很干净,往上扩展多分类并不需要重写,只是在extract_features.py和detector.py里增加一个姿势维度的循环。希望这份拆解能让你少走几步弯路,直接把这个工程包用起来。

本文还有配套的精品资源,点击获取