简介:这是一份基于SVM分类器与HOG特征实现姿势检测的完整工程,面向计算机视觉和机器学习初学者及开发者,旨在解决图像或视频中的人体姿势识别与分类问题,适合希望从理论走向实战的学习者。工程包含13张测试图片、5个Python脚本(覆盖特征提取、模型训练、视频检测等功能)、1个已训练好的模型文件,以及avi和mp4格式的演示视频,合计22个文件,压缩包约44MB。已有189人学习浏览。通过本工程可深入理解HOG特征描述子的构造流程,包括灰度化、细胞单元划分、局部梯度直方图统计与块归一化,并掌握SVM分类器训练、核函数选择及参数C和γ的调优方法。代码模块化设计清晰,数据预处理、特征提取、训练、测试和实时检测各环节均可直接运行,便于对照学习及二次开发。这一工程可灵活迁移至安全监控、人机交互、虚拟现实等需要姿态识别的实际场景。
1. 基于 SVM 分类器实现姿势检测:HOG 特征与一个能落地的超平面
这套资源不是那种动不动就上深度学习的重型方案,核心就一个svm.model加四个 Python 脚本,用 SVM 分类器配合 HOG 特征完成姿势检测。你在 CPU 机器上就能跑实时视频流,不需要 GPU,模型文件也就几 MB。常见场景是安全监控里区分人站着还是蹲着、摔倒检测的前置判断、人机交互里识别手势姿态。它的原理也很直白:先用 HOG 提取图片里的梯度方向信息,再把特征向量丢给 SVM 找一个间隔最大的分类超平面。适合刚接触机器学习、想做传统视觉方案落地的人,也适合想搞懂 SVM 在真实任务里怎么用的从业者。一句话,这套东西能让你在半小时内跑通「训练模型 → 检测姿势」的完整链路。
2. 项目结构与检测原理:先从文件清单看懂这套姿势检测的调用链
2.1 六个核心文件的职责边界
解压后文件不多,真正决定项目走向的是下面这几个:
| 文件 | 职责 | 运行方式 |
|---|---|---|
extract_features.py | 遍历训练图片,提取 HOG 特征并保存 | 训练前先跑 |
train_svm.py | 读取特征,训练 SVM 模型,保存为svm.model | 特征提取后跑 |
detector.py | 加载模型,对单张图片做姿势检测 | 直接运行 |
video_detector.py | 加载模型,逐帧处理视频或摄像头输入 | 直接运行 |
config.py | 集中管理路径、HOG 参数、SVM 参数 | 被其他脚本引用 |
svm.model | 训练好的分类器参数文件 | 检测脚本加载 |
先跑哪个、后跑哪个,很多人拿到手会懵。我一般建议按extract_features.py → train_svm.py → detector.py → video_detector.py的顺序过一遍,前两个是训练链路,后两个是推理链路。config.py是所有脚本的入口,所有该调的参数都集中在这儿,改参数不用去翻每个脚本。
2.2 SVM 为什么选 HOG 特征:梯度方向直方图与行人姿态的匹配逻辑
SVM 本身不直接看像素,它吃的是特征向量。像素级数据维度太高、对光照和形变太敏感,直接丢给 SVM 很容易翻车。HOG(Histogram of Oriented Gradients)解决的就是这个问题:它统计图像局部区域的梯度方向分布,把「边缘长什么样、朝哪个方向」编码成直方图。
对姿势检测来说,HOG 有个天然优势:人的外形不管站着、蹲着还是弯腰,边缘和梯度方向都有明显的统计规律。比如站立时垂直边缘占主导,蹲下时水平边缘比例上升,HOG 能把这些差异量化出来。而且它对局部光照变化做了块归一化,比原始灰度值稳健得多。相比决策树分类器直接拿原始特征硬切,SVM 配合 HOG 在小样本、高维特征场景下更稳,不容易被个别离群样本带偏。
HOG 的提取路径是固定的:先把图像灰度化并归一化,减少光照干扰;然后切成若干 cell 小格子;在每个 cell 内统计每个像素的梯度强度和方向,形成方向直方图;再把相邻 cell 组成 block 做局部对比度归一化;最后把所有 block 的直方图拼接成一条完整特征向量。这条向量就是 SVM 的输入。
2.3 从训练到检测的完整数据流:svm.model 在其中扮演什么角色
整个项目的数据流可以画成一条线:
训练图片 → extract_features.py → HOG 特征向量(带标签) → train_svm.py → svm.model 测试图片/视频帧 → detector.py/video_detector.py → HOG 特征向量 → svm.model 预测 → 姿势标签 + 检测框svm.model存的是训练阶段找出来的支持向量、权重和偏置,本质上就是那条最优超平面的数学参数。SVM 的核心思想是找一个能把正负样本分开、同时让两类样本到超平面的间隔最大的决策边界。间隔越大,泛化能力通常越好。训练时干的事就是解这个带约束的优化问题,结果是少数几个「支持向量」决定了超平面位置,所以 SVM 对高维特征并不怵。
检测阶段,detector.py加载模型后,会在输入图像上滑动窗口,对每个窗口提取 HOG 特征,送到模型里打分,超过阈值就认为这一块区域存在目标姿势。video_detector.py做的事情本质上一样,只是加了逐帧循环,把每一帧当作一张独立图片来处理。理解了这个数据流,后面的参数调优才有着落点:改config.py里的 HOG 参数会直接影响特征质量,改 SVM 参数会影响分类边界,改检测参数影响的是误检率和召回率。
3. 训练自己的 SVM 姿势分类器:从 HOG 特征提取到模型落盘
3.1 准备训练样本:正负样本比例与目录组织
训练前先检查样本。test_image里那十几张图是给你做验证用的,不是训练集。要训练自己的模型,需要准备两批图:正样本是你要检测的姿势,比如「站立的人」;负样本是背景、其他物体、非目标姿势。目录组织建议这样:
dataset/ ├── positive/ # 正样本,统一裁剪为 64x128 │ ├── 001.jpg │ ├── 002.jpg │ └── ... ├── negative/ # 负样本,可以是任意尺寸的自然图片 │ ├── 001.jpg │ ├── 002.jpg │ └── ... └── test/ # 测试图片正样本尺寸必须统一。常见做法是裁剪成 64x128 像素,这是 OpenCV 行人检测 HOG 描述子的经典窗口尺寸,对全身姿势识别够用。负样本不用裁剪,因为提取特征时会用滑动窗口的方式从负样本里随机切出与窗口尺寸一致的 Patch,这相当于做了数据增强。
正负样本比例是第一个坑点。负样本数量最好不少于正样本的 2 到 3 倍,否则 SVM 训练出来的边界会严重偏向正样本,检测时误报率飙升。我训练时会先按 1:3 的比例准备,之后看误检情况再补负样本,这是一个反复迭代的过程。
3.2 extract_features.py:HOG 参数怎么设才不丢姿态信息
extract_features.py的核心是调用 OpenCV 的HOGDescriptor提取特征。参数在config.py里定义,关键参数如下:
# config.py import os BASE_PATH = os.path.dirname(os.path.abspath(__file__)) POSITIVE_PATH = os.path.join(BASE_PATH, "dataset", "positive") NEGATIVE_PATH = os.path.join(BASE_PATH, "dataset", "negative") MODEL_PATH = os.path.join(BASE_PATH, "svm.model") # HOG 参数 WIN_SIZE = (64, 128) # 检测窗口大小,必须与训练样本尺寸一致 BLOCK_SIZE = (16, 16) # 块大小,决定归一化范围 BLOCK_STRIDE = (8, 8) # 块滑动步长,相邻块之间有重叠 CELL_SIZE = (8, 8) # 细胞单元大小 NBINS = 9 # 梯度方向直方图的 bin 数量特征提取脚本的关键代码:
# extract_features.py import cv2 import numpy as np import os from config import POSITIVE_PATH, NEGATIVE_PATH, WIN_SIZE, BLOCK_SIZE, BLOCK_STRIDE, CELL_SIZE, NBINS def extract_hog_features(image_path): """提取单张图片的 HOG 特征向量""" img = cv2.imread(image_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # HOG 输入必须灰度图 img = cv2.resize(img, WIN_SIZE) # 统一尺寸 hog = cv2.HOGDescriptor(WIN_SIZE, BLOCK_SIZE, BLOCK_STRIDE, CELL_SIZE, NBINS) features = hog.compute(img) # 返回列向量 return features.flatten() # 拉平成一维 def build_dataset(): """遍历正负样本目录,生成特征矩阵和标签向量""" features = [] labels = [] # 正样本标签为 1 for fname in os.listdir(POSITIVE_PATH): fpath = os.path.join(POSITIVE_PATH, fname) features.append(extract_hog_features(fpath)) labels.append(1) # 负样本标签为 -1,SVM 二分类用 +1/-1 比 0/1 更常见 for fname in os.listdir(NEGATIVE_PATH): fpath = os.path.join(NEGATIVE_PATH, fname) features.append(extract_hog_features(fpath)) labels.append(-1) return np.array(features), np.array(labels) if __name__ == "__main__": X, y = build_dataset() np.savez("features.npz", X=X, y=y) print(f"特征矩阵维度: {X.shape}, 标签数量: {y.shape[0]}")这里有个细节需要注意:HOGDescriptor一旦传入参数,特征维度就固定了。以WIN_SIZE=(64,128)、BLOCK_SIZE=(16,16)、BLOCK_STRIDE=(8,8)、CELL_SIZE=(8,8)、NBINS=9为例,维度计算方式是(64/8 - 1) * (128/8 - 1) * (16/8 * 16/8) * 9 = 7 * 15 * 4 * 9 = 3780维。这个数值在训练和检测时必须完全一致,否则模型加载后预测会直接报维度错误。
负样本一般不需要像正样本那样严格裁剪,提取时直接 resize 到窗口大小即可。但如果负样本长宽比和窗口差异太大,resize 后形变严重,反而会引入奇怪的梯度方向,影响分类器性能。我通常会让负样本尽量保持接近 64:128 的长宽比,或者干脆从大图里随机裁剪多个 64x128 的 Patch 作为负样本。
3.3 train_svm.py:核函数、C 值与 gamma 的选择逻辑
特征准备好之后,训练脚本本身不长:
# train_svm.py import cv2 import numpy as np from config import MODEL_PATH # 加载上一步保存的特征 data = np.load("features.npz") X, y = data["X"], data["y"] # 创建 SVM 分类器 svm = cv2.ml.SVM_create() svm.setType(cv2.ml.SVM_C_SVC) # C-SVC,适合二分类 svm.setKernel(cv2.ml.SVM_LINEAR) # 线性核,高维特征下的首选 svm.setC(0.01) # 惩罚因子,越小泛化越好 # 训练并保存 svm.train(X, cv2.ml.ROW_SAMPLE, y) svm.save(MODEL_PATH) print("模型已保存到:", MODEL_PATH)训练参数的选择逻辑要展开说。
核函数这块,线性核在 HOG 特征场景下通常是首选。因为 HOG 特征维度已经很高(3780 维),数据在这个空间里往往已经线性可分,再用 RBF 核做非线性映射反而容易过拟合,而且训练和预测都会慢不少。RBF 核适合的是特征维度低、样本量大的场景。如果你发现线性核效果差,可以换成 RBF:svm.setKernel(cv2.ml.SVM_RBF),然后配svm.setGamma(0.01),但要注意用交叉验证去调 gamma,否则模型就是一个黑匣子,效果好坏全靠撞。
惩罚因子 C 直接控制「误分类惩罚」和「间隔大小」之间的平衡。C 越大,模型越努力把训练样本全分对,但泛化能力变差;C 越小,间隔越宽,训练样本上的准确率可能略降,但测试表现更稳。对姿势检测这种训练样本量不大的任务,我一般从C=0.01起步,最多试到C=1.0。样本量只有几百张时,C 超过 1 就很容易把噪声也学进去。
训练完成后,可以用svm.getSupportVectors()查看支持向量的数量和权重分布,判断训练是否正常。如果支持向量占了训练样本的大半,说明数据本身线性不可分太严重,需要回头检查样本质量或换核函数。
4. 把模型用起来:detector.py 检测流程与参数调节
4.1 加载模型与滑动窗口检测:检测框的坐标逻辑
训练完svm.model,就到了检测环节。detector.py做的事情是:加载模型,对输入图片做多尺度滑动窗口检测,最后画出检测框。
# detector.py import cv2 import numpy as np from config import MODEL_PATH, WIN_SIZE, BLOCK_SIZE, BLOCK_STRIDE, CELL_SIZE, NBINS # 加载训练好的 SVM svm = cv2.ml.SVM_load(MODEL_PATH) # 构造 HOG 描述子 hog = cv2.HOGDescriptor(WIN_SIZE, BLOCK_SIZE, BLOCK_STRIDE, CELL_SIZE, NBINS) # 获取支持向量和偏置,构造检测器 support_vectors = svm.getSupportVectors() rho = svm.getDecisionFunction(0)[0] # 偏置项 hog.setSVMDetector(np.append(support_vectors, [rho], axis=1)) img = cv2.imread("test_image/5.jpg") # 多尺度检测,返回矩形框和置信度 rects, weights = hog.detectMultiScale( img, winStride=(4, 4), # 窗口滑动步长,越小检测越密越慢 padding=(8, 8), # 窗口扩展 padding,能提升边缘目标召回 scale=1.05, # 图像金字塔缩放比例,越小越精细 hitThreshold=0.5 # 分类得分阈值,大于该值判定为目标 ) # 简单非极大值抑制,去掉重叠框 def nms(boxes, scores, threshold=0.3): x1 = boxes[:, 0]; y1 = boxes[:, 1] x2 = boxes[:, 0] + boxes[:, 2]; y2 = boxes[:, 1] + boxes[:, 3] areas = (x2 - x1) * (y2 - y1) order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) w = np.maximum(0, xx2 - xx1) h = np.maximum(0, yy2 - yy1) iou = (w * h) / (areas[i] + areas[order[1:]] - (w * h)) order = order[np.where(iou <= threshold)[0] + 1] return boxes[keep] if len(rects) > 0: filtered = nms(np.array(rects), np.array(weights)) for (x, y, w, h) in filtered: cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(img, "person", (x, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow("Detection", img) cv2.waitKey(0)getDecisionFunction返回的偏置项必须拼到支持向量后面,setSVMDetector才能正常工作,这是 OpenCV 里构造自定义 HOG 检测器的固定套路。少了rho,检测时所有窗口的得分都会整体偏移,常见的现象就是该检出的目标全部漏掉。
winStride、scale、hitThreshold三个参数的联动关系值得细说。winStride越小,滑动窗口越密,检测越准但越慢;scale越小,金字塔层数越多,能检测到的目标尺寸范围越宽,但耗时线性上涨;hitThreshold越低越容易误报,越高越容易漏检。
4.2 检测效果不好时先调什么:窗口步长、尺度与阈值
拿到检测结果后,先别急着调 SVM 参数,优先调检测参数。这几个参数的调节顺序我一般固定走一遍:
第一,看漏检。如果画面里明显有目标但没框出来,先确认目标尺寸和训练窗口尺寸的差异。训练窗口是 64x128,如果视频里人很小,比如只有 40x80 像素,金字塔缩放到最小也没覆盖到,就要降低scale到 1.03 或更小,让金字塔层数更多。反过来,目标很大时,可以先对输入图像做一次降采样,减少金字塔层数,省时间。
第二,看误报。背景区域反复被框出来,先提高hitThreshold,从 0.5 提到 0.8,看误报是否下降。如果还很严重,说明负样本覆盖不够,回训练阶段补负样本,而不是继续堆阈值,否则会把真目标也筛掉。
第三,看检测框不贴合目标。框比目标大一圈或明显偏移,是padding参数导致的。padding会在窗口边界外额外扩展像素参与特征计算,让靠近图像边缘的目标也能被检测到。但这个值设太大,框就「空心化」。一般保持(8,8)或调回(0,0)对比效果。
第四,看重叠框太多。同一个目标被框了三四次,这是没有做非极大值抑制,或者 NMS 的 IoU 阈值设太高。加入上面的nms函数后,把阈值设定在0.3~0.5之间,重叠框基本能清干净。
检测阶段的调参本质上是在「漏检」和「误检」之间找平衡点,不可能一套参数走天下。光照变化大的场景,我一般会在检测前对每帧做一次直方图均衡化,能显著减少误报。
5. 避坑与常见问题排查:从训练翻车到视频检测的五个典型坑
5.1 训练集很小但模型训练很慢
现象:一共就几百张训练图片,train_svm.py跑了好几分钟没结束,CPU 占用率拉满。
原因:HOG 特征维度是 3780 维,几百个样本组成了(N, 3780)的矩阵。如果用了 RBF 核,SVM 要在这个高维空间反复计算核矩阵,复杂度接近 O(N² * dim),样本量稍大就卡住。另一个隐蔽原因是extract_features.py里没有对特征做归一化,数值范围不稳定会增加优化器迭代次数。
解决:先换回线性核,线性 SVM 在高维稀疏场景下训练速度快得多。如果必须用 RBF,对特征做标准化处理,或者直接用StandardScaler把每个维度归一到 0 均值 1 方差。还有一个血泪经验:检查config.py里BLOCK_STRIDE是否设置等于CELL_SIZE,如果BLOCK_STRIDE=(16,16)而不是(8,8),特征维度会从 3780 降到 810,训练快很多,精度损失通常可接受。
5.2 检测框又大又偏
现象:检测出来的矩形框比人大好几倍,框的位置也不在目标中心,看起来像随机框。
原因:最常见的是窗口坐标映射错误。detectMultiScale返回的坐标是相对于缩放后金字塔图像的,如果你在原始图像上直接画框,尺寸和位置都会对不上。我一开始也在这里翻过车,明明是检测到人了,框却画在完全无关的位置。另一个原因是padding值设得太大,导致返回的矩形包含大量背景区域。
解决:先确认detectMultiScale的返回值含义。它返回的rects是(x, y, w, h),直接基于检测时的图像坐标系。如果之前手动做了cv2.resize缩小图片,检测完要把坐标按缩放比例乘回去。至于padding,把它降到(0,0)或(4,4)看一下框是否收紧,这是一个反复对比的过程。
5.3 视频检测卡顿明显
现象:video_detector.py跑pedestrians.avi的时候,画面一帧一帧跳,帧率不到 5 FPS。
原因:每一帧都对整幅图像做多尺度金字塔检测,scale=1.05、winStride=(4,4)的组合意味着每帧要计算几千个窗口的 HOG 特征,CPU 扛不住是正常的。这是 HOG + SVM 方案的固有瓶颈,不是代码有 bug。
解决:几个常用降耗手段。第一,把输入帧缩小一半,检测窗口数量直接降到四分之一;第二,winStride从(4,4)改成(8,8);第三,scale从1.05改成1.1,金字塔层数减少一大半;第四,跳帧处理,每处理 2 到 3 帧才做一次检测,中间帧直接沿用上一帧的检测结果。精度会降一点,但视频场景完全可以接受。实时摄像头场景我一般四招全上,帧率能从 3 FPS 拉到 15 FPS 以上。
5.4 保存的模型加载报错
现象:训练完svm.model,在detector.py里cv2.ml.SVM_load(MODEL_PATH)直接抛异常,或者加载成功但predict结果全是一个值。
原因:OpenCV 版本差异导致模型文件格式不兼容。cv2.SVM_load()是旧版 API,cv2.ml.SVM_load()是 3.x 之后的新 API,两者读同一个文件可能没问题,但内部数据结构不一样。更隐蔽的是:训练时用的cv2.ml.SVM_C_SVC类型,加载后没有重新声明类型和核函数参数,直接调用predict会得到异常结果。
解决:统一用cv2.ml.SVM_create()+svm.save()+cv2.ml.SVM_load()这组新 API,不要新旧混用。加载后,先打印svm.getKernelType()确认核函数类型和训练时一致,再跑预测。如果模型文件是在别的电脑上训练的,确认两边 OpenCV 版本接近,最好都在 4.x 上,模型文件通用性才靠谱。
5.5 灰度图与彩色图混用导致特征维度不一致
现象:训练时一切正常,检测时hog.compute报维度错误,或者检测结果完全失效。
原因:extract_features.py里对训练图片做了cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),但detector.py的检测流程里忘了做同样的灰度转换,直接拿彩色图丢给HOGDescriptor。OpenCV 的 HOG 实现按单通道输入设计,彩色图输入时三通道像素会被当作一个长向量处理,特征维度直接变三倍,和训练时的 3780 维对不上。
解决:在检测脚本的预处理阶段强制加一行灰度转换:
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)还有一个细节:cv2.imread读进来的图是 BGR 顺序,如果中间做过cv2.resize或ROI裁剪,务必确认尺寸是(64, 128),不是(128, 64)。宽高写反在代码层面不会报错,但特征含义会混乱,模型表现就是「很玄学」——有时候检出有时候检不出。遇到这种情况,首先检查预处理链路里是否有任何一步改变了通道数或图像尺寸。
6. 验证与进阶:用测试图片跑通流程,再试视频检测
拿到项目后,我建议按下面这个顺序做一次完整验证,确认每个环节都是通的:
# 1. 查看配置文件,确认路径存在 python -c "from config import *; print(MODEL_PATH)" # 2. 用测试图片跑单张检测 python detector.py # 3. 用自带视频跑检测 python video_detector.py --video pedestrians.avi # 4. 自己提取特征、训练模型、重新检测 python extract_features.py python train_svm.py python detector.py前两步是快速验证,确认svm.model没有被损坏、OpenCV 环境没问题。第三步看视频场景下的帧率和检测稳定性。第四步才是真正把项目变成自己的——用自己的样本训练,然后对比效果。
进阶玩法的核心是调config.py里的检测参数。我习惯把scale、winStride、hitThreshold做成命令行参数,批量用几张不同的测试图对比效果,而不是每次改代码重新跑。检测效果稳定后,可以试试把video_detector.py的输入从视频文件换成摄像头:
cap = cv2.VideoCapture(0) # 0 代表默认摄像头 while True: ret, frame = cap.read() if not ret: break # 帧率优化:先缩小再检测 small = cv2.resize(frame, (frame.shape[1] // 2, frame.shape[0] // 2)) rects, weights = hog.detectMultiScale(small, winStride=(8, 8), scale=1.1) # 坐标恢复:乘以缩放系数 for (x, y, w, h) in rects: x, y, w, h = x * 2, y * 2, w * 2, h * 2 cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)摄像头场景和录像场景有个关键差别:摄像头的光照和背景随时在变,训练时的负样本很快就会失效。我自己的习惯是,每到一个新场景先录一段 30 秒的背景视频,提取里面的帧作为负样本重新训练一次,正样本可以沿用。这套「场景自适应负样本」的做法,比单纯调阈值管用得多。
还有一个小技巧:hitThreshold不要设成固定值,可以在视频每一帧里取检测得分的动态分位数作为阈值,相当于做自适应阈值。这样目标进入画面时检测框更稳定,不会出现前几帧有框、后几帧框突然消失的情况。
这套项目最让我意外的是,传统 SVM 方案在姿势检测上并没有被深度学习完全碾压。对于场景固定、姿势类别固定的任务,HOG + SVM 的实时性和模型体积优势很明显。我也是第一次跑视频检测翻车之后养成了习惯:每次改完参数,先跑单帧图片确认框的位置正常,再上视频流,绝不跳过单帧验证直接调视频。这个小流程帮我挡掉了至少五六次「看似改好了、一上视频就放飞」的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取