
简介基于MediaPipe与KNN分类算法实现的健身计数源码包面向需要完成引体向上、深蹲、俯卧撑等动作计数的Python开发者项目不依赖骨骼角度计算而是通过姿态关键点归一化编码与k-NN分类识别动作完成状态切换运动只需调整输入选择通用性强。源码基于Python 3.7和MediaPipe 0.8.10构建囊括姿态编码、分类、结果平滑、计数、可视化以及训练集提取与校验等模块各模块职责清晰便于理解从关键点特征到动作计数的完整流程并二次开发。压缩包共61个文件以10个Python脚本、6个CSV特征集、3个MP4示例视频为核心另含XML配置、图片与说明文档整体18.45MB可在PyCharm中对照运行。附带的项目说明和示例数据可帮助读者快速上手适合作为课程设计或健身应用开发参考目前已有270人学习下载值得姿态识别方向开发者收藏实践。1. 用MediaPipe加KNN做健身计数器到底解决了什么问题引体向上数到一半忘了是第几个、深蹲深度不够但计数照加、俯卧撑做到后面肩膀借力动作变形——这类问题靠人眼能看出来靠代码很难写规则。这个标题里的方案就是用MediaPipe Pose把人体骨架抽成33个关键点再把关键点换算成关节角度交给KNN分类算法判断当前动作处在哪个相位最后用状态机完成计数。它解决的痛点是不需要训练深度学习模型不需要GPU一个普通笔记本就能跑适合正在做人机交互课设、姿态识别入门或者想给健身房做训练工具的人。我第一次跑通这条链路时最大的感受是MediaPipe负责把“人”变成“数据”KNN负责把“数据”变成“判断”两者接口干净调试起来非常顺手。这套方案的核心逻辑不是“认出你在做引体向上”而是“判断当前这一帧你的身体是处于上升位还是下放位”。这两个相位一旦能稳定分类计数就是一个状态切换的问题。下面按复现路径把原理、代码和坑一一道来。2. 为什么选MediaPipe和KNN相位分类比动作识别更靠谱2.1 MediaPipe Pose把帧变成33个骨架点计数就有了“数字底座”MediaPipe Pose是谷歌开源的人体姿态估计方案输入一帧RGB图像输出33个关键点的归一化坐标。所谓归一化就是x和y都除以了图像宽高取值在0到1之间z坐标表示关键点到相机的相对深度量纲和x/y不一样。关键点索引按序号排列比如11是左肩、12是右肩、13是左肘、14是右肘、15是左腕、16是右腕、23是左髋、24是右髋、25是左膝、26是右膝、27是左踝、28是右踝。做计数不关心眉毛眼睛核心就躯干和四肢这十几个点。为什么选它而不是OpenPose或者自训练的关键点模型因为MediaPipe在CPU上就能跑到接近实时的帧率代码集成简单一个mp.solutions.pose就能把模型拉起来对初学者极其友好。它支持三个模型档位lite、full、heavy分别在精度和速度之间做取舍。我一般用full检测置信度设0.5跟踪置信度设0.5这两个阈值组合在多数室内环境下表现稳定。如果你在树莓派上跑就需要降到lite。MediaPipe真正厉害的地方不是单帧检测而是相邻帧之间的跟踪。当人体在画面中连续运动时它会复用上一帧的关键点位置做跟踪速度明显快于每帧重新检测。但这也带来一个副作用当人快速动作导致画面模糊或者肢体互相遮挡时跟踪结果会短暂跳变这一点在后面的避坑章节会重点讲。2.2 KNN分类算法做的是“相位判断”不是“动作识别”很多人看到“KNN分类算法”会以为它负责识别引体向上、深蹲、俯卧撑这三个动作类别。实际上在这类健身计数器项目里更常见的分工是每个动作单独训练一个KNN二分类器分类目标是当前帧处于“上升相位”还是“下降相位”或者叫“高位”和“低位”。动作类型由人工切换或入口参数指定不交给KNN判断。这么设计是有道理的。引体向上的高位和低位在关节角度特征上的差异非常清晰低位时肘关节接近伸直高位时肘关节大幅弯曲。深蹲的高低位则体现在膝关节和髋关节角度上。俯卧撑更特殊肘角变化是主特征但身体直线度也参与判别。这些特征在“同一个动作内部”做区分类别边界简单、线性可分度好KNN这种惰性学习分类器完全够用不需要上SVM或者神经网络。KNN的原理一句话说透把训练样本看成高维空间中的点预测一个新样本时找到离它最近的k个点让这k个点投票决定类别。它有三个特性直接影响工程实践。第一没有显式训练过程训练就是存样本所以新增数据很灵活。第二特征空间的距离由所有维度共同决定特征必须做标准化否则量纲大的维度会完全压过量纲小的维度。第三k值太小会过拟合k值太大会把边界抹平一般从3到7之间试。2.3 一帧数据走完的完整流水线关键点→特征→分类→状态机整个计数程序在一帧内的处理流程是固定的理解这条流水线比看懂任何一段代码都重要。第一步从摄像头或视频文件读取帧转为RGB格式交给MediaPipe。第二步MediaPipe输出33个关键点如果画面里没有人检测结果为空这一步就要跳过后续处理。第三步从关键点坐标计算当前动作对应的关节角度得到一个形状为(5,)或(7,)的特征向量。第四步用StandardScaler标准化特征喂给KNN分类器得到相位预测。第五步把预测结果放进滑动窗口做多数表决得到平滑后的相位。第六步把相位喂给状态机状态机只在检测到“低位→高位”的切换时把计数器加一。这条链路里最容易出错的位置不是KNN而是第三步的特征提取。角度计算依赖三点坐标任何一个关键点跳变都会让角度发生几十度的突变直接影响KNN的决策。所以我在实际开发时习惯先把角度特征实时可视化出来看到波形稳定了再往下做分类而不是一上来就调KNN参数。特征波形断断续续KNN调得再花哨也救不回来。3. 复现这套Python源码从环境搭建到特征采集3.1 依赖安装与Python版本选择一个墙角坑提前说明这个项目依赖四个核心库mediapipe、opencv-python、scikit-learn、numpy。安装命令如下。pip install mediapipe opencv-python scikit-learn numpy如果你用的是3.12或更高版本的Pythonmediapipe大概率没有预编译的wheel包pip会尝试从源码编译在普通机器上几乎必失败。我建议直接装Python 3.8到3.10之间的版本这几个版本都有现成的二进制的wheel包装完就能用。装完验证一下。python -c import mediapipe as mp; print(mp.__version__)能输出版本号就说明环境没问题。如果import时报错提示找不到mediapipe.solutions常见原因是mediapipe版本太旧和numpy版本冲突。解决办法是先升级numpy再重装mediapipe或者反过来固定numpy版本到1.24.x再装最新mediapipe。这类兼容性问题属于生态常态换版本组合是常规手段。opencv-python和mediapipe的配合也要注意。MediaPipe的Pose.process()要求输入RGB图像而OpenCV的VideoCapture读出来是BGR格式必须先用cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)转换。漏了这一步图像看起来只是颜色不对但关键点检测精度会明显下降因为模型是在RGB分布上训练的。别问我怎么知道的第一次跑通项目时检测总是不准排查了半天才发现是这种低级问题。3.2 源码项目的常规目录结构采集、训练、推理三个模块分开这类健身计数器源码一套合理的目录结构会把采集、训练、推理分成三个独立模块。通常长下面这样。因为我没有看过你手上zip包的具体内容只能按这类项目最常见的做法来拆。project/ ├── collect_data.py # 数据采集录一段动作视频生成特征CSV ├── train_knn.py # 模型训练读取CSV训练KNN并保存模型 ├── counter.py # 实时计数读取摄像头加载模型跑状态机 ├── features.py # 共同的特征提取函数 ├── models/ │ ├── pullup_knn.pkl # 引体向上模型 │ ├── squat_knn.pkl # 深蹲模型 │ └── pushup_knn.pkl # 俯卧撑模型 └── data/ ├── pullup_up.csv ├── pullup_down.csv └── ...采集、训练、推理分开的价值在于你可以单独优化每一环。比如先录一段视频离线跑特征提取看特征波形是否正确确认没问题之后再训练模型最后才接实时视频流。不然三个环节的错误混在一起出了bug很难定位。我自己的习惯是先写features.py因为所有环节都依赖它把它调试稳定了后面就是流水线作业。3.3 最小的关键点特征采集脚本一帧落到CSV一行采集脚本是训练KNN的第一步。它的任务是读入你的动作视频或摄像头画面用MediaPipe提取关键点计算特征向量然后把特征和标签写入CSV。下面是一段最小可用的采集脚本重点看特征如何组织、标签如何写入。import cv2 import mediapipe as mp import csv import os mp_pose mp.solutions.pose pose mp_pose.Pose( min_detection_confidence0.5, min_tracking_confidence0.5, model_complexity1 # 0lite, 1full, 2heavy ) cap cv2.VideoCapture(0) csv_path data/pullup_down.csv os.makedirs(data, exist_okTrue) def extract_features(lm): # lm是33个关键点对象列表 # 引体向上特征左右肘角、左右肩角 def angle(a, b, c): import math v1 (a.x - b.x, a.y - b.y, a.z - b.z) v2 (c.x - b.x, c.y - b.y, c.z - b.z) dot v1[0]*v2[0] v1[1]*v2[1] v1[2]*v2[2] n1 math.sqrt(sum(v**2 for v in v1)) n2 math.sqrt(sum(v**2 for v in v2)) if n1 0 or n2 0: return 0.0 cos max(-1.0, min(1.0, dot / (n1 * n2))) return math.degrees(math.acos(cos)) left_elbow angle(lm[11], lm[13], lm[15]) # 左肩-左肘-左腕 right_elbow angle(lm[12], lm[14], lm[16]) # 右肩-右肘-右腕 left_shoulder angle(lm[23], lm[11], lm[13]) # 左髋-左肩-左肘 right_shoulder angle(lm[24], lm[12], lm[14]) # 右髋-右肩-右肘 return [left_elbow, right_elbow, left_shoulder, right_shoulder] print(按 s 保存当前帧特征到CSV按 q 退出) while True: ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) result pose.process(rgb) if result.pose_landmarks: feats extract_features(result.pose_landmarks.landmark) cv2.putText(frame, detected, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(collect, frame) key cv2.waitKey(1) 0xFF if key ord(s) and result.pose_landmarks: with open(csv_path, a, newline) as f: writer csv.writer(f) writer.writerow(feats [down]) print(saved:, feats) elif key ord(q): break cap.release() cv2.destroyAllWindows()这段代码的逻辑不复杂每帧调用MediaPipe检测检测到人体就计算四个肘肩角度按s键把特征和标签追加写入CSV。这里我把标签硬编码为“down”因为这段代码的目标是采集引体向上低位样本。要采高位样本把标签改成“up”再跑一遍就行。代码里有三个参数值得说。model_complexity1选择完整模型精度和速度的折中如果你机器性能弱设为0可以减少一半以上的耗时。min_detection_confidence0.5是检测阶段的最低置信度调高到0.7会让误检变少但也会让弱光环境下的漏检变多。按s保存而不是自动保存是为了让你能在动作的最低点或者最高点精确暂停只保存最典型的那几帧避免过渡帧污染训练数据。实际采集时有个重要原则标签必须是“明确的一帧”。不要在动作中途按保存键那种半上不下的过渡帧会让KNN的类别边界模糊。我一般每个动作的每个相位采集200到300帧也就是从视频里挑出几十个稳定帧数据量不需要太大。4. 核心代码拆解KNN训练参数与计数状态机的配合4.1 特征向量怎么定义三种动作分别取哪几个关节角特征工程是整个项目的灵魂。同一个MediaPipe关键点数据特征选得好KNN分类精度轻松上95%特征选得糙调参调到头也过不了80%。三个动作的特征选取逻辑完全不同我按经验整理了一张表。动作特征维度具体特征关键点索引组合引体向上4维左肘角、右肘角、左肩角、右肩角(11,13,15)、(12,14,16)、(23,11,13)、(24,12,14)深蹲4维左膝角、右膝角、左髋角、右髋角(23,25,27)、(24,26,28)、(11,23,25)、(12,24,26)俯卧撑5维左肘角、右肘角、左肩髋踝直线度、右肩髋踝直线度、肩髋连线倾角(11,13,15)、(12,14,16)、(11,23,27)、(12,24,28)、(11,23)两点向量与垂直方向夹角为什么引体向上取肩角和肘角而不取腕角因为引体向上握杠时手腕角度基本不变肘角和肩角才是驱动相位变化的核心。深蹲取膝角和髋角道理一样下蹲时膝盖弯曲、髋部折叠这两个角度从接近180度一路缩到90度以下变化范围大且稳定。俯卧撑最特殊。它的相位区分主要靠肘角但“身体是否成一条直线”是判别动作是否标准的关键。肩、髋、踝三点连线的角度越接近180度说明身体越直。如果塌腰或者撅屁股这个角度会明显变小。我额外加了肩髋连线倾角因为它能区分你是“标准俯卧撑”还是“跪姿俯卧撑”后者躯干倾斜角度完全不同。特征维度宁缺毋滥维度太多反而让KNN在小样本下过拟合。4.2 KNN训练代码与参数讲解k值、距离权重与标准化采集完特征CSV之后训练代码就非常短了。但短代码里藏着两个关键操作标准化和k值选择。import pandas as pd import joblib from sklearn.neighbors import KNeighborsClassifier from sklearn.preprocessing import StandardScaler df pd.read_csv(data/pullup.csv) # 最后一列是标签列 X df.iloc[:, :-1].values.astype(float) y df.iloc[:, -1].values # 标准化KNN基于距离特征量纲不一致会把距离计算带偏 scaler StandardScaler() X_scaled scaler.fit_transform(X) # k5距离加权投票距离越近的样本话语权越大 knn KNeighborsClassifier( n_neighbors5, weightsdistance, metriceuclidean ) knn.fit(X_scaled, y) # 保存模型和scaler推理时两者必须一起加载 joblib.dump(knn, models/pullup_knn.pkl) joblib.dump(scaler, models/pullup_scaler.pkl) print(训练完成样本数:, len(X), 类别:, knn.classes_)这五行代码里有三个参数决定成败。StandardScaler是必须的它把每个特征列的均值变成0、方差变成1。不加这一步肘角特征取值范围0到180肩角也是0到180两个量纲一致还好但如果你后面加入坐标距离类特征取值范围只有0到0.5就会被角度特征完全淹没等于白加。n_neighbors5是最常用的起点值样本量300左右时5是个安全选择。太小的k比如1会把噪声样本直接变成决策边界太大的k比如20会把高低位的边界抹平。weightsdistance意味着距离新的测试点更近的训练样本拥有更大的投票权重这比等权投票更抗噪声。训练完成后一定要打印看一眼classes_数组确认类别标签是[down, up]而不是只学到一个类别。这种“一点通”的翻车经常发生采集数据时按保存键太早所有样本都落在了同一相位模型就变成了只会输出一个类别的废物。训练集划分也不该省。用train_test_split切出20%做验证看分类报告里的F1分数而不是只看准确率。相位分类如果两类样本数量严重不均衡比如down有300个、up只有30个准确率会虚高F1才能暴露问题。4.3 计数状态机防止重复计数的实现模型训练好之后的实时计数逻辑我用下面这段来展示。重点不在KNN预测而在状态机的切换条件。import cv2 import mediapipe as mp import numpy as np import joblib knn joblib.load(models/pullup_knn.pkl) scaler joblib.load(models/pullup_scaler.pkl) mp_pose mp.solutions.pose pose mp_pose.Pose(min_detection_confidence0.5, min_tracking_confidence0.5) cap cv2.VideoCapture(0) counter 0 state none # 状态变量none / up / down history [] # 滑动窗口最多存5帧预测结果 def extract_features(lm): # 和采集脚本完全一致的特征提取逻辑 ... while True: ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) result pose.process(rgb) if not result.pose_landmarks: cv2.imshow(counter, frame) if cv2.waitKey(1) 0xFF ord(q): break continue feats extract_features(result.pose_landmarks.landmark) feats_scaled scaler.transform([feats]) pred knn.predict(feats_scaled)[0] # 5帧多数表决过滤单帧抖动 history.append(pred) if len(history) 5: history.pop(0) pred_smooth max(set(history), keyhistory.count) # 状态机只有 down - up 的切换才计数 if state none: state pred_smooth elif state up and pred_smooth down: state down elif state down and pred_smooth up: state up counter 1 print(count:, counter) cv2.putText(frame, fcount: {counter} state: {state}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(counter, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()状态机是这个项目的核心工程逻辑。它的本质是一个只能按“up到down、down到up”顺序迁移的有限状态机。为什么必须这样因为KNN对每一帧独立分类在动作过渡到一半时相位预测会在up和down之间来回切换。如果没有状态机你可能在同一个引体向上动作里看到3到4次“up→down→up”的抖动计数器就加了三四次。状态机的关键设计是up→down只切换不计数down→up才计数。这意味着一个完整动作必须经过“从高到低、再从低到高”两个阶段任何一次重复预测都不会导致重复计数。我第一次自己写的时候偷懒用“检测到up就计数”结果半程动作都被算了两次后来老老实实补上状态机才稳定。滑动窗口的5帧多数表决和状态机是双保险。多数表决能过滤掉KNN的单帧误判状态机保证计数逻辑的严谨。你会发现窗口大小设5是个甜点值设3在快速动作时过滤能力不够设7会让计数反应变迟钝等你看到计数跳变时实际动作已经过去两三秒了。5. 复现翻车避坑骨架抖动、镜像翻转与计数错乱的排查5.1 画面里没有人或半遮挡时骨架点全None程序直接报错现象是程序跑着跑着突然抛异常报错指向result.pose_landmarks.landmark那一行提示NoneType没有landmark属性。原因是MediaPipe检测不到人体时pose_landmarks字段是None程序却照常尝试提取特征。站在摄像头前时没事人一离开画面或者做动作时身体被扶手挡住大半就会触发。解决处理每一帧前必须做空值判断。上面计数代码里我已经写了if not result.pose_landmarks: continue这个判断必须放在特征提取之前。采集脚本里也要同样处理否则录数据时人偶尔出画程序就崩了。顺便说一句即使检测到人体部分关键点也可能置信度极低导致坐标为0如果特征里出现突然的角度跳变大概率就是这种情况。5.2 背对镜头和左右镜像让MediaPipe标点翻转相位分类乱掉现象是正面朝摄像头时计数正常转身背对摄像头之后KNN预测结果开始乱跳引体向上下降和上升完全反了。原因是MediaPipe默认按“镜面视角”输出关键点也就是画面中你看到的人的左臂对应关键点索引11但当你背对摄像头时身体映射关系会让人困惑——实际物理世界中的左臂在画面里变成了右边。MediaPipe在这个场景下的点标记可能会出现左右翻转或者检测到的左右肩、左右肘的坐标关系反转。解决一是训练和推理时保持同一个朝向不要正面朝向采集训练数据、背面朝向做推理。二是如果需要支持背面训练数据必须也包含背面样本让KNN学到镜像后的特征分布。实际上最省事的做法是全程正面朝向摄像头引体向上面对杠、深蹲面对摄像头、俯卧撑头朝摄像头方向这样关键点的左右关系永远一致。这个坑最隐蔽因为它不报错只是计数结果莫名其妙地错。5.3 特征量纲不统一KNN距离被肘关节角主导现象是模型训练完验证准确率95%一接实时视频预测结果在up和down之间疯狂横跳而且对动作幅度的大小特别敏感。原因是特征里肘角变化范围0到180度而俯卧撑的肩髋踝直线度特征取值范围可能只有150到180度变化范围不足30度。KNN用欧氏距离衡量样本相似度时角度绝对值大的特征天然拥有更大的“距离话语权”小范围变化的特征被完全压制。解决用StandardScaler对所有特征做标准化让每个维度均值0、方差1。上面训练代码里已经包含了这一步但很多人容易在推理时漏掉——只加载KNN模型而忘记加载scaler推理前不做标准化就喂给模型。scaler和模型必须成对保存成对加载这是血的教训。另外如果你的特征里混合了角度值和归一化距离值标准化就更重要了。5.4 KNN预测结果在临界帧反复横跳计数重复加一现象是明明只做了12个引体向上计数器最后显示15个。回看打印的预测状态日志发现某个动作中出现了多次up到down的切换。原因是KNN对连续帧的预测是独立事件动作在临界位置时关节角度特征恰好落在两个类别边界附近相邻几帧预测结果不连续。比如下放到最低点时肘角可能有时判成up有时判成down状态机就被反复触发。解决靠状态机本身只能保证“一个完整周期计一次”不能阻止边界抖动带来的虚切换。必须加滑动窗口多数表决让当前帧的预测结果参考前四帧的投票。窗口大小的选择看动作速度慢动作用7帧正常速度用5帧快动作用3帧。同时还可以给状态机加“最小停留帧数”条件只有预测结果稳定保持某个相位超过3帧才切换状态。这个条件加上之后重复计数的问题基本根除。5.5 训练样本太少且左右不均衡模型泛化不了现象是自己录的数据自己测很准换个场地、换个人测就开始漏。原因是KNN是记忆型分类器它的泛化能力完全取决于训练样本覆盖了多少种情况。如果你只在固定光线、固定背景、固定身高下录了100帧KNN记住的就是那个特定环境下的特征分布。新手最容易犯的错是“一个动作录一段视频就完事”样本没有覆盖动作幅度的连续变化。解决采集数据时要刻意覆盖动作的各种幅度。引体向上要录半程的和全程的、深蹲要录浅蹲和全蹲、不同的人左右手发力习惯不同也要分别录。每种相位的样本不少于150帧两类样本数量差距不要超过2倍。如果左右侧数据不均衡比如右侧角度特征参与训练多、左侧参与少到了实际推理时左侧动作的计数准确率就会明显更差。6. 让计数器更稳的后续手段增强、缓冲与半帧验证6.1 训练数据增强的思路KNN没有训练过程所以“数据增强”不是对数据集做变换迭代而是在采集阶段就制造多样性。做法很简单录数据时不要只在一个固定位置录前后左右各挪半米把光线条件也换一遍。另一个有效手段是“角度微扰”把已采集特征向量的每个维度随机加减2到5度生成大量合成样本。这样操作的前提是原始数据质量足够高不然就是把噪声放大。我用这个思路给俯卧撑数据做过一次增强原始样本200帧按每维加减0到3度的均匀扰动扩充到800帧KNN在验证集上的F1从0.86升到了0.92。增强之后记得重新标准化因为均值可能变了。6.2 时序平滑的参数建议时序平滑除了滑动窗口多数表决还可以用指数移动平均来处理KNN的类别概率输出。KNeighborsClassifier.predict_proba()能输出每个类别的概率把这个概率做EMA超过0.6才判定为当前相位。这个阈值比多数表决更细腻适合动作速度较慢的深蹲和引体向上。快速动作比如俯卧撑EMA延迟偏大建议只用窗口表决。6.3 离线验证与误计数的检查方法实时计数跑得再欢最终还是要离线验证才敢信。我的习惯是录一段包含10个标准动作的视频记下真实次数然后跑一遍离线推理程序比较程序计数和真实计数的差值。如果差值为0说明状态机和KNN配合正常如果差值在1到2之间先看是漏计还是重复计漏计说明状态机某次没完成完整的down到up切换重复计说明边界抖动没过滤干净据此针对性调整。这个方案的下一步方向因人而异。可以往动作质量评估走给肘角阈值加规则判断动作是否标准也可以把KNN换成一个5层的MLP用同样的特征向量做分类精度会上一个台阶。我做这类项目养成的一个习惯是每调一次参数就把当时的模型精度和踩到的坑记在项目说明文档末尾。每次翻车的记录下次都能省掉大量排查时间。希望这份基于MediaPipe和KNN的健身计数器拆解能帮到你从搭建环境到跑通计数最顺的路就是先采集数据再训练模型最后调状态机一步都不要跳。本文还有配套的精品资源点击获取