简介:一份面向机器学习与计算机视觉初学者的完整代码包,围绕基于SVM分类器实现姿势检测,解决图像、视频中人体姿态识别与分类问题。内容涵盖HOG特征提取、SVM模型训练与推理等核心环节,配有可运行的Python脚本、已训练好的svm.model模型、13张jpg测试图片以及avi与mp4演示视频,压缩包共22个文件,整体44MB,结构清晰,便于按流程复现实验。资源特别演示了HOG特征的预处理、细胞单元划分、梯度直方图构建与块归一化等关键步骤,并说明SVM如何通过核函数映射、最大化间隔来区分不同姿态类别。已有189人学习下载。通过该项目可掌握从特征工程到分类器调参的完整实践路径,包括方向梯度直方图计算、核函数选择、惩罚因子C与gamma参数调整,并可直接基于OpenCV对视频帧进行实时姿态检测,适合课程设计、毕业设计或入门AI项目实战。
1. 基于SVM分类器实现姿势检测:为什么在深度学习时代还要翻它的牌
在嵌入式设备上做一次姿势分类,深度学习模型还在排队加载权重的时候,SVM方案已经跑完一整轮检测了。基于SVM分类器实现姿势检测,本质上不是把原始图像丢给分类器,而是先提取人体关键点,再把关键点转成归一化坐标与关节角度特征,最后用一个核SVM完成站、坐、蹲、躺这类离散姿势的判别。这个方案的优势非常务实:小样本能训、训练只要几秒钟、模型只有几百KB、分类依据还能翻出支持向量来给人讲清楚。它适合的场景也很明确——边缘设备部署、样本量在几千量级、业务只需要区分有限几种姿势状态。反直觉的是,在深度学习几乎包揽视觉任务的今天,SVM姿势分类在工业落地里从未真正退场。
2. 把姿势翻译成SVM吃得下的特征:关键点归一化与角度构造
SVM分类器吃的是固定维度的特征向量,不是图像。你在这一步要做的第一件事,就是把一张人像变成一组数字。常见做法是先用MediaPipe Pose或OpenPose这类关键点检测器,提取人体的肩、肘、腕、髋、膝、踝坐标,然后把这些坐标加工成特征。这里有个新手最容易踩的误区:以为把坐标直接拼成向量喂给SVM就行。实际完全不是这么回事,原始坐标里有大量与“姿势”无关的干扰信息,直接喂进去,模型会把画面的位置、人物的高矮、离摄像头的远近都当成分类依据,换个人换个位置就全盘崩溃。
2.1 为什么关键点坐标不能直接扔给SVM
先想清楚三个问题。第一,平移问题:同样一个站姿,人站在画面左边和右边,所有关键点的x坐标完全不同;第二,尺度问题:同一个动作,人离摄像头近时坐标数值大,离得远时数值小;第三,人体比例差异:高个子和矮个子,同一姿势下肩到髋的距离、手臂长度都不一样。SVM的决策边界在特征空间里是固定的,坐标一飘,特征点就在空间里乱窜,边界再优美也拦不住。
解决方案是平移归一化加尺度归一化。我一般以髋关节中心为原点——髋是躯干的支撑点,受四肢摆动影响最小——把所有关键点坐标减去髋中心坐标,完成平移;再以肩中心到髋中心的距离作为尺度因子,把所有坐标除以这个距离。这样处理之后,特征与人的画面位置、离摄像头远近基本无关,只剩姿势本身的信息。有一个细节值得注意:肩髋距离在站立和坐姿下有明显差异,但蹲下时躯干前倾也会压缩这个距离,所以它本身也可以作为一个特征,而不是只当分母用。
2.2 角度特征与比例特征:SVM姿势检测的特征工程实操
坐标归一化之后能用,但还不够稳。比如一个人抬手到半空和完全举起,手腕坐标的变化受手臂长度影响很大,换个臂长不同的人,同一动作的坐标特征就变了。角度特征没有这个问题——肩、肘、腕三点算出的肘关节角度,与臂长、距离、画面位置都无关,它只描述“弯曲了多少”。这是SVM姿势检测特征工程里最值得花时间的地方。
我常用的特征向量是7维:左右肘关节角、左右膝关节角、躯干倾角、手到髋的归一化距离、髋到踝的归一化距离。膝角能区分站与蹲,肘角能区分举手和平举,躯干倾角能区分弯腰和直立,髋踝距离用来区分坐姿和蹲姿——这两者膝角都接近九十度附近,但坐姿髋踝距离大、蹲姿髋踝距离小。角度计算用余弦定理:以肘关节为例,先算肩到肘的向量和腕到肘的向量,再求夹角的余弦值,最后取反余弦得角度。
import cv2 import mediapipe as mp import numpy as np mp_pose = mp.solutions.pose pose = mp_pose.Pose(static_image_mode=True, model_complexity=1, min_detection_confidence=0.5) def extract_features(landmarks, img_w=640, img_h=480): # landmarks: MediaPipe 返回的 33 个关键点列表 pts = np.array([[lm.x * img_w, lm.y * img_h] for lm in landmarks], dtype=np.float32) # 以髋关节中心为原点做平移归一化 left_hip = pts[mp_pose.PoseLandmark.LEFT_HIP] right_hip = pts[mp_pose.PoseLandmark.RIGHT_HIP] hip_center = (left_hip + right_hip) / 2.0 pts = pts - hip_center # 以肩髋距离为尺度因子做缩放归一化 left_shoulder = pts[mp_pose.PoseLandmark.LEFT_SHOULDER] right_shoulder = pts[mp_pose.PoseLandmark.RIGHT_SHOULDER] shoulder_center = (left_shoulder + right_shoulder) / 2.0 scale = np.linalg.norm(shoulder_center) # 已平移到原点,直接取模 if scale < 1e-6: return None pts = pts / scale # 计算肘关节角、膝关节角、躯干倾角、归一化距离 angle = lambda p1, p2, p3: np.degrees(np.arccos( np.clip(np.dot(p1 - p2, p3 - p2) / (np.linalg.norm(p1 - p2) * np.linalg.norm(p3 - p2) + 1e-6), -1.0, 1.0))) left_elbow = angle(pts[mp_pose.PoseLandmark.LEFT_SHOULDER], pts[mp_pose.PoseLandmark.LEFT_ELBOW], pts[mp_pose.PoseLandmark.LEFT_WRIST]) right_elbow = angle(pts[mp_pose.PoseLandmark.RIGHT_SHOULDER], pts[mp_pose.PoseLandmark.RIGHT_ELBOW], pts[mp_pose.PoseLandmark.RIGHT_WRIST]) left_knee = angle(pts[mp_pose.PoseLandmark.LEFT_HIP], pts[mp_pose.PoseLandmark.LEFT_KNEE], pts[mp_pose.PoseLandmark.LEFT_ANKLE]) right_knee = angle(pts[mp_pose.PoseLandmark.RIGHT_HIP], pts[mp_pose.PoseLandmark.RIGHT_KNEE], pts[mp_pose.PoseLandmark.RIGHT_ANKLE]) # 躯干倾角:肩中心到髋中心向量与竖直方向的夹角 torso_vec = shoulder_center - hip_center # 已平移,肩中心坐标即可 torso_angle = np.degrees(np.arctan2(abs(torso_vec[0]), abs(torso_vec[1]))) # 手到髋归一化距离:取左右手腕到髋中心的平均距离 left_hand_dist = np.linalg.norm(pts[mp_pose.PoseLandmark.LEFT_WRIST]) right_hand_dist = np.linalg.norm(pts[mp_pose.PoseLandmark.RIGHT_WRIST]) # 髋到踝归一化距离:取左右髋踝距离平均值的倒数关系由数值本身表达 left_leg_len = np.linalg.norm(pts[mp_pose.PoseLandmark.LEFT_ANKLE] - pts[mp_pose.PoseLandmark.LEFT_HIP]) right_leg_len = np.linalg.norm(pts[mp_pose.PoseLandmark.RIGHT_ANKLE] - pts[mp_pose.PoseLandmark.RIGHT_HIP]) return np.array([left_elbow, right_elbow, left_knee, right_knee, torso_angle, (left_hand_dist + right_hand_dist) / 2.0, (left_leg_len + right_leg_len) / 2.0])这段代码里有几个关键点需要解释。static_image_mode=True表示对独立帧做检测,而不是当成视频流做跟踪;如果输入的是连续视频帧,把这个参数改成False并同时传入min_tracking_confidence,检测会更稳定但会引入跟踪延迟。角度计算中的np.clip是为了防止浮点误差导致余弦值超出[-1,1],这在实际坐标里很常见,不裁剪会出现大量nan。最后返回的7维数组就是喂给SVM的特征向量,每一维的物理含义都明确,后面模型不好用时可以直接回溯到具体某个特征上去排查问题。
2.3 数据采集与标注:足够多样本的最小规范
特征流程定下来之后,下一步是采集训练数据。我一般建议每个姿势类别采集不少于500条有效帧,类别越多,这个基数再适当放大。采集时不要只让一个人站得端端正正做标准动作,那样训练出来的模型只能在同一个人身上用。正确做法是找3到5个人,身高体型有差异,站在距离摄像头1.5米到3米的不同位置,每个姿势做几组变速动作——比如站起来的时候稍微左右晃动一下,坐下的时候往前倾一下再坐直。这些看似不标准的动作,恰恰是SVM泛化能力的主要来源。
采集节奏上,视频抽帧比逐张拍照效率高很多。录一段30秒的视频,按5到10帧每秒抽帧,一个姿势轻松拿到两三百样本。抽完帧打标签时,建议把“不确定”的帧直接丢掉,不要硬标。训练集里混入错标样本,比混入噪声特征危害更大——SVM会对错标点生成诡异的局部边界。另外,连续视频帧之间有强相似性,训练前做一次帧间隔过滤或者分组,可以避免同类样本过度集中影响决策边界。
3. 训练SVM姿势分类器:核函数、C、gamma的调参路线
特征准备好了,接下来是训练SVM分类器。这一步的核心不是把代码跑通,而是理解三个直接影响分类效果的量:核函数、正则化参数C、核宽度gamma。很多人对SVM调参的理解停留在“用网格搜索随便跑一下”,但不懂这三个参数的物理含义,网格设得再大也是在碰运气。
3.1 RBF核为什么是默认选项:一次线性核对照
SVM的原理是间隔最大化——找一个超平面,让两类样本到平面的距离最大。但姿势分类的边界几乎不可能是线性的。举个例子,站姿和蹲姿在“膝角-髋踝距离”这个二维平面上的分布,是一条弯的边界:站姿膝角大髋踝距离大,蹲姿膝角小髋踝距离也小,但中间还有坐姿这个夹在两者之间的类别。线性核只能画直线,遇到这种三分类重叠区域直接失控,通常准确率只能到八成上下就上不去了。
RBF核的作用是把原始特征映射到高维空间,在高维空间里原本弯弯曲曲的边界被拉直,SVM再去找线性超平面。映射是隐式的,不需要你手动构造高维特征,代价是多了gamma这个旋钮。我自己的经验是,姿势检测这个任务无脑先上RBF核,除非你的分类数只有两类且特征分布明显线性可分,才值得试线性核。RBF默认就能覆盖线性核的效果,但反过来不行。
3.2 特征缩放与类别不平衡:两个容易被忽视的前置处理
RBF核内部计算的是样本之间的距离,依赖特征向量的范数。如果某一维特征数值范围比其他维度大一个量级,它会主导整个距离计算,其他维度的区分能力等于被废掉。7维特征里,角度量纲是0到180,而归一化距离量纲是0到几,差异不算大,但依然建议做标准化。注意,标准化必须放进Pipeline里做,不能先在整个X上fit再切分训练集和验证集,否则验证集的信息会泄漏进训练过程,得到虚高的指标。这个坑后面我会专门展开。
类别不平衡同样要提前处理。在真实的姿势检测场景里,“站”这个状态往往占了六成以上,“蹲”可能只有百分之十几。SVM的间隔最大化会被大样本类别牵着走,决策边界整体偏向多数类。最省事的做法是训练时指定class_weight='balanced',让SVM按类别频率反比调整错误代价。评估指标也要同步换掉——不要盯着accuracy看,改用macro F1和混淆矩阵逐类检查召回率,否则整体准确率九成但蹲姿完全没识别出来的情况,光看总分根本发现不了。
3.3 GridSearchCV搜索C和gamma:完整训练流程示例
下面给出我惯用的训练骨架。特征矩阵X是N行7列,来自上一章的特征提取流程;y是标签数组,类别用整数编码。把StandardScaler和SVC放进同一个Pipeline,再用GridSearchCV做交叉验证,这样scaler和SVM的参数搜索在同一个流程里完成,不会出现泄漏。
from sklearn.svm import SVC from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import GridSearchCV, StratifiedKFold from sklearn.metrics import classification_report import numpy as np # X: (n_samples, 7) 特征矩阵, y: (n_samples,) 标签数组 X = np.array(feature_list) # feature_list 由 extract_features 逐帧生成 y = np.array(label_list) pipeline = Pipeline([ ('scaler', StandardScaler()), ('svm', SVC(kernel='rbf', class_weight='balanced', probability=True, random_state=42)) ]) param_grid = { 'svm__C': [0.1, 1, 10, 100], 'svm__gamma': [0.001, 0.01, 0.1, 1] } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) search = GridSearchCV(pipeline, param_grid, cv=cv, scoring='f1_macro', n_jobs=-1) search.fit(X, y) print('best params:', search.best_params_) print('best CV F1:', search.best_score_) final_model = search.best_estimator_ y_pred = final_model.predict(X) print(classification_report(y, y_pred))这段代码有三个值得展开的细节。第一,scoring='f1_macro'对类别不平衡更敏感,比默认的accuracy更能反映小类别的表现;如果你特别在意某一类姿势的误报,可以改成f1_weighted或者自定义评分器。第二,param_grid里的C和gamma都按数量级搜索,这是SVM调参的基本节奏——先粗略摸到量级,再在最优值附近细化搜索。比如这里C搜到10最优,下一步可以把C设为[5, 10, 20]再跑一轮。第三,probability=True会启用Platt缩放,让SVM额外输出概率值,代价是增加训练时间和模型体积;如果只做硬分类不需要置信度,可以关掉这选项,模型会更小推理更快。
C和gamma的物理含义直接影响你选择网格范围。C是正则化参数,C越大SVM越努力让每个训练样本都分类正确,边界越复杂,越容易过拟合;C越小边界越平滑,但欠拟合风险增加。gamma是RBF核的宽度,gamma越小决策边界越平滑,过大的gamma会在每个样本周围画“孤岛”,训练集指标好看,来一个新样本就翻车。在姿势检测这个场景,C落在1到100之间、gamma落在0.01到1之间,是覆盖大多数情况的起始搜索区间。gamma超过1时我就开始警惕过拟合,几乎不会用到10以上的值。
提示:网格搜索跑完,先看交叉验证分数和训练集分数的差距。如果训练集F1接近1.0而交叉验证明显低,说明C或gamma过大,边界过拟合了;两个分数都很低,说明特征本身区分度不够,优先回特征工程调,不要执着于调参。
4. 姿势检测里最常见的5个翻车现场:现象、原因与修复
做完特征和训练,你以为模型能直接上线了?这里分享五个我自己和身边团队真实遇到过的坑,按“现象→原因→解决”的顺序写。每一条背后都是一个完整的排错经历,有些问题甚至能毁掉整个方案的可信度。
4.1 归一化统计量泄漏:训练分数虚高得离谱
现象:训练集F1高达0.97,交叉验证分数0.96,模型一上真实数据,准确率直接掉到0.6。整个团队对着一模一样的数据找了两天原因。
原因:代码里先对全部特征矩阵X做了StandardScaler().fit(X),然后再切分训练集和验证集。scaler在全体数据上计算的均值和方差包含了验证集的信息,等于验证集在考试之前已经拿到了答案。特征标准化后,验证集被平移到了训练集附近,SVM分起来当然容易。
解决:把StandardScaler放进Pipeline,跟着GridSearchCV一起走。Pipeline保证每一折交叉验证里scaler只在当前训练折上fit,验证折完全不参与统计量计算。这也是为什么前一章的代码要把scaler和SVC揉进同一个Pipeline的根本原因。
4.2 角度接近0°或180°时噪声放大
现象:站姿时膝关节接近完全伸直,角度在168°到180°之间来回抖,模型在“站”和“微蹲”之间跳变,画面里人明明没动,标签却在闪。
原因:三点近共线时,坐标只要抖动一两个像素,反余弦函数计算出的角度就会发生很大变化。关节越接近伸直,这种放大效应越明显。这是三角函数本身的数值特性,不是SVM的问题。
解决:两个常见做法,我更推荐第一个。一是直接改用cos值替代角度值进入特征向量——余弦函数在0°和180°附近导数趋近于0,天然抑制小扰动;二是对角度做饱和截断,比如大于175°一律按175°算,但这只是治标。注意,改了特征之后要重新跑一遍交叉验证,不要只改计算不改评估。
4.3 训练数据太干净:单场景泛化的血泪经验
现象:训练时全部站在同一面白墙前、同一光照、同一距离采集,测试时换成走廊、工位或者户外场景,误判率急剧上升。
原因:不是特征没有过滤背景,而是采集时人的动作范围太窄。所有人只在固定位置做标准动作,很多合法的姿态空间在训练集里完全没有被覆盖。SVM对新特征区域的判断本质上是外推,边界画到没见过的地方就是黑匣子,给什么结果全靠运气。
解决:采集阶段故意制造扰动——前后走动、上下蹲起、左右倾斜身体、靠近再远离摄像头。训练数据“脏”一点,模型上线才稳。这是我吃过亏换来的血泪经验:你采集时偷的懒,模型上线后会加倍还给你。
4.4 多人同框导致关键点错配
现象:画面里同时出现两个人时,特征向量出现跳变,同一个姿势一会识别成站一会识别成坐,完全不可用。
原因:MediaPipe Pose默认只输出一个人,多人入画时它会锁定一个目标,但在遮挡、交叉、走出画面等情况下会在多人之间跳切,关键点坐标跟着错配,特征自然乱掉。
解决:最简单的方案是业务上限制单人入画。如果多人不可避,就在关键点检测之前先跑一个人体检测器,裁剪出最大目标的ROI再提关键点。先检测再提点,而不是直接在整幅图上提点,能消除九成以上的多人干扰。这也是为什么我前面强调,SVM分类器本身不复杂,复杂的是前置的那条数据链路。
4.5 类别不均衡把决策边界带偏
现象:整体准确率93%,看起来不错,翻混淆矩阵才发现“蹲”的召回率只有35%,大量蹲姿样本被分到了“站”。
原因:训练数据里“站”占了70%以上,SVM为了最大化整体间隔,牺牲少数类是常规操作。多数类的密度高,边界被推挤到少数类一侧,这是间隔最大化策略下的必然结果。
解决:训练时加上class_weight='balanced',让少数类样本的错误代价提高。评估改用macro F1,逐类看召回率而不是只看总分。如果加了balanced之后少数类召回上来了但精确率掉得厉害,说明两类特征在原始空间重叠太严重,这时候要做的是回特征工程加维度,而不是继续调参。
5. 跨场景验证与置信度输出:让SVM姿势检测真正可交付
模型训好不是终点,能不能经得起换人、换场景的考验,才决定这个方案值不值得投入。我见过太多团队拿随机切分的交叉验证分数去汇报,上线第一天就被真实数据打脸。
5.1 按人切分的LOSO验证,而不是按帧随机切
随机切分训练集和验证集时,同一个人的相邻帧有极强的相似性,模型相当于已经见过这个人了,验证分数虚高。正确的做法是按人分组做验证——把所有样本按人员ID分组,训练时彻底排除一个人的全部数据,用剩下的人训练,再在这个人身上测。这叫做留一受试者交叉验证(LOSO)。
from sklearn.model_selection import LeaveOneGroupOut from sklearn.model_selection import cross_val_score # groups: 与 y 等长的数组,每个样本对应的人员ID logo = LeaveOneGroupOut() scores = cross_val_score(search.best_estimator_, X, y, groups=groups, cv=logo, scoring='f1_macro') print('LOSO F1 per subject:', scores) print('mean LOSO F1:', scores.mean())注意这里用的是上一步网格搜索得到的最优模型,且在交叉验证过程中Pipeline会在每一折重新fit scaler。LOSO分数才是这个方案真实上线的预期值。如果你的LOSO分数比随机切分低一大截,说明模型对特定人物产生了过拟合,回去检查一下你的训练集里是不是某个人占了太多比例。
5.2 decision_function做低置信抛弃:宁可不判断,不要错判断
SVM本身输出的是样本到决策超平面的距离,不是概率。probability=True会把这个距离通过Platt缩放转成概率,但在小样本上这个校准经常不稳定,输出的概率值不可全信。我习惯直接用decision_function的距离绝对值作为置信度参考——离超平面越远,分类越确定。
import numpy as np # 用最优模型对一段视频逐帧预测 distances = final_model.decision_function(frame_features) scores = distances.max(axis=1) # 多分类时取最大间隔对应的分数 pred_classes = distances.argmax(axis=1) confidence_threshold = 0.35 for i, score in enumerate(scores): if score < confidence_threshold: pred_classes[i] = -1 # -1 表示低置信,丢弃该帧阈值0.35不是拍脑袋定的,我一般会在验证集上画出正确分类和错误分类的距离分布,取两者交集最小的位置。阈值设太严会频繁丢帧,设太松又拦不住错判。在交互式应用里,输出一个“不确定”状态远比输出一个错误姿势状态要安全,前者让上层逻辑有机会兜底,后者直接误导决策。这也是SVM方案相比深度学习方案的一个隐形的优势:决策边界的几何距离天然就是置信度,不需要额外训练一个置信度网络。
5.3 特征维度小幅扰动测试:上线前最后一关
模型交付前,我习惯给特征向量加一点小幅度的随机扰动,跑一批鲁棒性测试。比如在角度维度上加±5°的噪声,在距离维度上加±0.1的噪声,观察分类结果是否稳定。SVM对近决策边界的样本本就敏感,如果加5%的噪声就有大量样本跨边界翻转,说明这个分类边界在真实环境中会因为关键点抖动而不稳定,需要回头调整特征或者采集更多边界样本。
至于部署,sklearn模型用joblib.dump保存,边缘设备上用对应的joblib.load加载推理即可;模型本身只是支持向量和核函数参数的组合,几百KB的大小对嵌入式设备非常友好。推理时只需算一遍特征向量再跑一次决策函数,CPU占用率几乎可以忽略,这也是SVM方案在工业场景里至今没被淘汰的根本原因。
我早期的姿势检测项目就是吃了没做LOSO验证的亏,汇报时指标漂亮,用户一换立刻露馅,从那以后我给自己定了个规矩:任何分类模型,先按人切分验证,再谈上线。特征、参数、验证这条路跑通之后,SVM姿势检测这套方案的价值就很清楚了——它不跟深度学习拼上限,拼的是在有限资源、有限数据、需要解释的场景里稳稳兜住底线。希望帮到你。
本文还有配套的精品资源,点击获取