MediaPipe + KNN 健身动作计数:从骨骼提取到状态机实战
发布时间:2026/10/1 17:23:23来源:尧图网络
简介这是一套基于MediaPipe与KNN分类算法的健身动作计数Python项目源码面向具备一定Python基础、希望快速实现引体向上、深蹲、俯卧撑自动计数的开发者与健身应用爱好者。其核心思路是先提取人体关键点并归一化编码再用k-NN完成姿态分类配合指数移动平均平滑结果因此切换运动类型几乎无需改动代码只需在读取视频或调用摄像头时选择对应动作即可。资源包共61个文件、约18.45MB包含10个py源码模块、6个csv训练特征文件、5个xml配置、3段mp4示例视频及若干图片与字体资源覆盖姿态分类、结果平滑、运动计数、可视化、训练集提取与校验、视频处理等完整环节并附项目说明文档。目前已有270人学习。读者可据此掌握从关键点编码到计数输出的完整链路理解KNN在动作识别中的落地方式并借助示例视频与训练集快速复现和二次开发。1. 从摄像头到计数MediaPipe KNN 的健身动作识别到底怎么落地健身房里对着镜子数引体向上数到第七个就开始怀疑自己是不是多数了两个——这种场景几乎每个练过的人都遇到过。用 MediaPipe 加 KNN 做健身计数器解决的正是这个数不准的问题MediaPipe 负责从摄像头画面里提取人体骨骼关键点KNN 负责判断当前处于动作的哪个阶段两者配合就能自动计数引体向上、深蹲、俯卧撑。这套方案的核心优势在于不需要训练深度神经网络KNN 是惰性学习算法几十个样本就能跑起来普通笔记本的 CPU 就能实时推理。适合有 Python 基础、想做一个能跑通的计算机视觉小项目的开发者也适合想理解姿态估计 分类器这套组合拳的入门者。下面从环境搭建讲到参数调优把这条链路拆开说清楚。2. 环境搭建与 MediaPipe 骨骼提取从零跑通第一帧2.1 安装依赖与验证 MediaPipe 是否正常工作很多人卡在第一步不是代码写错而是环境没配对。MediaPipe 对 Python 版本有要求目前稳定支持 Python 3.8 到 3.11用 3.12 可能会遇到 wheel 包缺失的问题。我一般建议用 conda 建一个独立环境避免和系统里的其他包打架。# 创建独立环境指定 Python 3.10 conda create -n fitness_counter python3.10 -y conda activate fitness_counter # 安装核心依赖 pip install mediapipe opencv-python numpy scikit-learn # 验证安装 python -c import mediapipe as mp; print(mp.__version__)这里四个包各有分工mediapipe 提供姿态估计模型opencv-python 负责读摄像头和画图numpy 做数组运算scikit-learn 提供 KNN 分类器。版本方面不需要锁死但 mediapipe 建议用 0.10.x 以上的版本早期版本的关键点索引有差异。安装完成后跑一行验证命令能打印出版本号就说明环境没问题。如果 pip 安装 mediapipe 报错大概率是两个原因一是 Python 版本太新二是系统缺少 Visual C 运行库Windows 平台。前者换 3.10 解决后者去微软官网下载 vc_redist 安装即可。Linux 上如果报libGL相关错误装一下libgl1-mesa-glue就行。2.2 用 MediaPipe Pose 提取 33 个关键点MediaPipe Pose 模型会在每帧画面中检测人体的 33 个关键点包括鼻子、肩膀、手肘、手腕、髋部、膝盖、脚踝等。做健身计数只需要其中一部分——引体向上关注肩膀和手肘深蹲关注髋部和膝盖俯卧撑关注手肘和肩膀。import cv2 import mediapipe as mp mp_pose mp.solutions.pose mp_draw mp.solutions.drawing_utils # 初始化 Pose 模型 pose mp_pose.Pose( static_image_modeFalse, # 视频流用 False model_complexity1, # 0/1/2越大越准但越慢 smooth_landmarksTrue, # 平滑关键点减少抖动 min_detection_confidence0.5, # 检测置信度阈值 min_tracking_confidence0.5 # 跟踪置信度阈值 ) cap cv2.VideoCapture(0) # 0 表示默认摄像头 while cap.isOpened(): ret, frame cap.read() if not ret: break # MediaPipe 需要 RGB 输入 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results pose.process(rgb) if results.pose_landmarks: # 绘制骨架 mp_draw.draw_landmarks( frame, results.pose_landmarks, mp_pose.POSE_CONNECTIONS ) # 提取关键点坐标 landmarks results.pose_landmarks.landmark # 以左肩为例x/y/z 是归一化坐标0~1 left_shoulder landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER] print(f左肩: x{left_shoulder.x:.3f}, y{left_shoulder.y:.3f}) cv2.imshow(Pose, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码是整个项目的地基。几个参数值得展开说model_complexity控制模型大小0 是最轻量的适合树莓派这类边缘设备1 是默认值精度和速度平衡2 最准但帧率会掉。smooth_landmarks开启后会对关键点做时序平滑好处是画面不抖坏处是快速动作时会有轻微延迟——做引体向上这种爆发式动作时如果发现计数滞后可以把它关掉试试。min_detection_confidence和min_tracking_confidence分别控制初始检测和后续跟踪的阈值光线差的环境可以降到 0.3但太低会引入误检。关键点的坐标是归一化的x 和 y 都在 0 到 1 之间原点在画面左上角。这意味着不同分辨率的摄像头输出的坐标范围一致后续计算角度时不需要做分辨率适配。z 轴表示深度以髋部中心为原点数值越小表示越靠近摄像头做深蹲时可以用 z 轴辅助判断身体是否前倾。2.3 从关键点到角度计算关节夹角的通用方法光有关键点坐标还不够KNN 需要的是数值特征。最直接的特征就是关节夹角——手肘弯曲角度、膝盖弯曲角度、髋部角度。用向量点积公式就能算。import numpy as np def calculate_angle(a, b, c): 计算三个点构成的角度b 为顶点 a, b, c 格式为 [x, y] 返回角度值0~180 a np.array(a) b np.array(b) c np.array(c) # 向量 ba 和 bc ba a - b bc c - b # 点积公式求夹角 cosine np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc)) # 防止浮点误差导致超出 [-1, 1] cosine np.clip(cosine, -1.0, 1.0) angle np.degrees(np.arccos(cosine)) return angle # 以引体向上为例计算左肩-左肘-左腕的夹角 def get_elbow_angle(landmarks, mp_pose): shoulder landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER.value] elbow landmarks[mp_pose.PoseLandmark.LEFT_ELBOW.value] wrist landmarks[mp_pose.PoseLandmark.LEFT_WRIST.value] return calculate_angle( [shoulder.x, shoulder.y], [elbow.x, elbow.y], [wrist.x, wrist.y] )calculate_angle这个函数是整个项目的核心工具三个动作的计数都靠它。逻辑很直白把顶点到两个端点的向量算出来用点积公式求余弦值再反余弦得到角度。np.clip那行是血泪经验——浮点运算有时候会算出 1.0000001 这种值不裁剪的话arccos会返回 NaN整个计数就崩了。不同动作关注的角度不同。引体向上看手肘角度手臂伸直时约 160 到 180 度下巴过杠时手肘弯曲到约 40 到 60 度。深蹲看膝盖角度站立时约 170 到 180 度蹲到底时约 70 到 90 度。俯卧撑看手肘角度撑起时约 160 到 180 度下降到最低点时约 80 到 100 度。这些角度范围是后续 KNN 分类的依据也是判断动作是否标准的参考线。3. KNN 分类器训练用几十个样本教会模型识别动作阶段3.1 为什么选 KNN 而不是 LSTM 或 SVM做动作识别第一反应可能是上 LSTM 做时序建模或者用 SVM 做分类。但在这个场景下 KNN 有三个实际优势。第一样本需求极低——每个动作阶段采集 20 到 30 帧就能训练LSTM 至少需要几百条完整动作序列。第二训练是瞬时的KNN 没有显式训练过程把数据存进去就完事改样本后立刻生效调试体验好很多。第三可解释性强预测时看最近的 K 个邻居属于哪一类能直观判断模型为什么做出这个判断。SVM 虽然也能用小样本但需要调核函数和惩罚系数多了一层调参负担。KNN 只有一个 K 值需要调而且 K 值的物理含义很明确——看周围几个邻居投票。对于引体向上、深蹲、俯卧撑这种动作模式固定、阶段划分清晰的场景KNN 的准确率完全够用。当然 KNN 也有边界。如果动作速度变化很大比如有人做引体向上快起快落有人慢起慢落同一角度值可能对应不同阶段。这时候需要加入角速度作为特征或者用滑动窗口做时序平滑。另一个边界是多人场景MediaPipe 默认只检测画面中置信度最高的人多人同时训练需要额外处理。3.2 采集训练数据每个动作阶段至少 30 帧数据采集的流程是打开摄像头摆出目标姿势按一个键记录当前帧的角度特征和标签。我一般会写一个采集脚本把特征和标签存成 CSV。import csv import cv2 import mediapipe as mp from calculate_angle import get_elbow_angle # 前面定义的函数 mp_pose mp.solutions.pose pose mp_pose.Pose(min_detection_confidence0.5, min_tracking_confidence0.5) cap cv2.VideoCapture(0) # 标签映射0手臂伸直1手肘弯曲 current_label 0 data_rows [] print(按 0 标记手臂伸直按 1 标记手肘弯曲按 s 保存按 q 退出) while cap.isOpened(): ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results pose.process(rgb) if results.pose_landmarks: landmarks results.pose_landmarks.landmark angle get_elbow_angle(landmarks, mp_pose) # 画面显示当前角度和标签 cv2.putText(frame, fAngle: {angle:.1f} Label: {current_label}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(Collect, frame) key cv2.waitKey(1) 0xFF if key ord(0): current_label 0 elif key ord(1): current_label 1 elif key ord(s) and results.pose_landmarks: data_rows.append([angle, current_label]) print(f已保存 {len(data_rows)} 条: angle{angle:.1f}, label{current_label}) elif key ord(q): break # 写入 CSV with open(training_data.csv, w, newline) as f: writer csv.writer(f) writer.writerow([angle, label]) writer.writerows(data_rows) print(f共保存 {len(data_rows)} 条数据) cap.release() cv2.destroyAllWindows()采集时有个关键点不要只在一个固定位置采集。手臂伸直的角度在不同人身上可能是 165 度也可能是 178 度手肘弯曲的角度也因人而异。所以采集时要覆盖不同角度值——手臂伸直时稍微前后晃动一下让角度在 150 到 180 之间都有样本手肘弯曲时从 30 度到 70 度都采一些。每个标签至少 30 条最好 50 条以上这样 KNN 的决策边界才稳。采集完成后打开 CSV 看一眼数据分布。如果两个标签的角度范围有重叠比如手臂伸直采到了 140 度手肘弯曲也采到了 140 度那 KNN 在这个区间就会摇摆。解决办法是把重叠区的样本删掉或者增加一个过渡态标签让模型学会识别中间状态。3.3 训练 KNN 并确定最佳 K 值数据有了训练 KNN 只需要几行代码。但 K 值怎么选有讲究——K 太小容易受噪声影响K 太大边界模糊。import pandas as pd from sklearn.neighbors import KNeighborsClassifier from sklearn.model_selection import cross_val_score import numpy as np # 读取数据 df pd.read_csv(training_data.csv) X df[[angle]].values y df[label].values # 用交叉验证选最佳 K 值 best_k 3 best_score 0 for k in range(1, 16): knn KNeighborsClassifier(n_neighborsk) scores cross_val_score(knn, X, y, cv5, scoringaccuracy) mean_score scores.mean() print(fK{k}, 交叉验证准确率{mean_score:.3f}) if mean_score best_score: best_score mean_score best_k k print(f最佳 K 值: {best_k}, 准确率: {best_score:.3f}) # 用最佳 K 值训练最终模型 final_knn KNeighborsClassifier(n_neighborsbest_k) final_knn.fit(X, y) # 保存模型 import joblib joblib.dump(final_knn, knn_model.pkl)交叉验证选 K 值的逻辑是把数据分成 5 份轮流用其中 4 份训练、1 份验证取平均准确率。K 从 1 试到 15准确率最高的那个就是最佳 K。实际跑下来单特征只有角度的情况下 K3 或 K5 通常最好。如果准确率曲线在某个 K 值后明显下降说明 K 太大了邻居里混入了其他类别的样本。joblib.dump把训练好的模型存成文件后续计数脚本直接加载不用每次重新训练。模型文件很小几十 KB方便分发。如果要加更多特征比如角速度、身体倾斜角把X从单列改成多列就行KNN 会自动处理多维特征空间。4. 三个动作的计数逻辑状态机 KNN 预测结果4.1 引体向上计数器手肘角度 状态机有了 KNN 分类器每帧都能得到一个当前处于哪个阶段的预测。但计数不能只靠单帧预测——人可能在某个角度停留导致连续多帧都预测为同一阶段。需要一个状态机来管理阶段转换。import cv2 import mediapipe as mp import joblib import numpy as np from calculate_angle import calculate_angle # 加载模型 knn joblib.load(knn_model.pkl) mp_pose mp.solutions.pose pose mp_pose.Pose(min_detection_confidence0.5, min_tracking_confidence0.5) cap cv2.VideoCapture(0) counter 0 stage None # up 或 down def get_angle(landmarks, p1, p2, p3): a [landmarks[p1.value].x, landmarks[p1.value].y] b [landmarks[p2.value].x, landmarks[p2.value].y] c [landmarks[p3.value].x, landmarks[p3.value].y] return calculate_angle(a, b, c) while cap.isOpened(): ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results pose.process(rgb) if results.pose_landmarks: lm results.pose_landmarks.landmark # 计算左臂手肘角度 angle get_angle(lm, mp_pose.PoseLandmark.LEFT_SHOULDER, mp_pose.PoseLandmark.LEFT_ELBOW, mp_pose.PoseLandmark.LEFT_WRIST) # KNN 预测阶段 prediction knn.predict([[angle]])[0] # 状态机计数 if prediction 1: # 手肘弯曲引体向上拉起 stage up if prediction 0 and stage up: # 手臂伸直完成一次 stage down counter 1 print(f引体向上计数: {counter}) # 画面显示 cv2.putText(frame, fCount: {counter}, (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 3) cv2.putText(frame, fAngle: {angle:.1f}, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 0), 2) cv2.imshow(Pull-up Counter, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()状态机的逻辑是只有先进入up状态手肘弯曲再回到down状态手臂伸直才计一次数。这防止了在手臂伸直时反复预测为down导致重复计数。stage变量记录上一次的状态只有状态发生up → down的转换时才counter 1。引体向上的一个常见问题是下巴过杠的判断。手肘角度只能判断手臂是否弯曲但有些人手臂弯曲了但下巴没过杠。如果要严格判断需要加入鼻子或下巴的 y 坐标与杠的 y 坐标比较。简化版可以只用手肘角度适合自己训练时参考严格版需要额外检测横杠位置实现复杂度会高不少。4.2 深蹲计数器膝盖角度 髋部高度辅助深蹲的计数逻辑和引体向上类似但关注的是膝盖角度。站立时膝盖约 170 到 180 度蹲到底时约 70 到 90 度。状态机逻辑一样先蹲下膝盖弯曲再站起膝盖伸直计一次。# 深蹲角度计算 def get_knee_angle(landmarks, mp_pose): hip landmarks[mp_pose.PoseLandmark.LEFT_HIP.value] knee landmarks[mp_pose.PoseLandmark.LEFT_KNEE.value] ankle landmarks[mp_pose.PoseLandmark.LEFT_ANKLE.value] return calculate_angle( [hip.x, hip.y], [knee.x, knee.y], [ankle.x, ankle.y] ) # 在计数循环中替换角度计算 angle get_knee_angle(lm, mp_pose) prediction knn_squat.predict([[angle]])[0] if prediction 1: # 蹲下 stage down if prediction 0 and stage down: # 站起 stage up counter 1深蹲有个额外问题膝盖角度在站立时和蹲下时差异很大但有些人蹲得浅膝盖只弯到 120 度就起来了。如果训练数据里蹲下标签只采了 70 到 90 度的样本那 120 度会被预测为站立导致不计数。解决办法是在采集数据时把蹲下的范围放宽从 70 度到 130 度都采一些让模型学会识别部分下蹲也算蹲下。另一个辅助特征是髋部高度。站立时髋部 y 坐标较小画面靠上蹲下时 y 坐标增大画面靠下。可以把髋部 y 坐标作为第二个特征加入 KNN提高区分度。但要注意不同人身高不同髋部 y 坐标的绝对值没有可比性需要做归一化——比如用髋部 y 除以肩膀到脚踝的距离。4.3 俯卧撑计数器手肘角度 身体直线检测俯卧撑和引体向上都用手肘角度但角度范围不同。俯卧撑撑起时手肘约 160 到 180 度下降到最低点时约 80 到 100 度。如果直接用引体向上的模型会因为角度范围不匹配导致误判。所以每个动作需要单独训练一个 KNN 模型或者用同一个模型但重新采集对应动作的数据。# 俯卧撑角度计算和引体向上一样用手肘 def get_pushup_angle(landmarks, mp_pose): shoulder landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER.value] elbow landmarks[mp_pose.PoseLandmark.LEFT_ELBOW.value] wrist landmarks[mp_pose.PoseLandmark.LEFT_WRIST.value] return calculate_angle( [shoulder.x, shoulder.y], [elbow.x, elbow.y], [wrist.x, wrist.y] ) # 俯卧撑状态机 if prediction 1: # 手肘弯曲身体下降 stage down if prediction 0 and stage down: # 手肘伸直撑起 stage up counter 1俯卧撑的一个坑是身体是否保持直线。有些人塌腰或撅屁股手肘角度到了但动作不标准。可以用肩膀-髋部-脚踝的夹角来判断身体是否直线正常俯卧撑这个角度应该在 160 到 180 度之间。如果小于 150 度说明塌腰了可以在画面上给出提示但不影响计数——计数和动作质量判断是两件事分开处理更清晰。三个动作的计数逻辑可以整合到一个脚本里用一个变量切换当前动作模式加载对应的 KNN 模型和角度计算函数。这样一套代码就能支持三种计数器切换动作时只需要改一个配置项。5. 避坑与排查那些让计数器翻车的细节5.1 关键点抖动导致计数跳变现象计数器偶尔会一次动作加两下或者动作做到一半突然加一下。原因MediaPipe 的关键点估计在帧与帧之间有抖动即使人站着不动手肘角度也可能在 5 到 10 度范围内波动。如果这个波动刚好跨过 KNN 的决策边界预测结果就会在 0 和 1 之间反复跳状态机被反复触发。解决开启smooth_landmarksTrue做时序平滑或者在角度值上做滑动平均。我一般用 5 帧的滑动窗口把最近 5 帧的角度取平均后再送进 KNN。这样单帧的抖动被平滑掉计数稳定很多。代价是响应延迟增加约 5 帧30fps 下约 160ms对健身计数来说完全可以接受。5.2 光线不足导致关键点丢失现象光线暗的时候画面上的骨架突然消失计数器停止工作。原因MediaPipe 的姿态估计模型依赖图像中的人体轮廓光线不足时轮廓不清晰检测置信度低于min_detection_confidence阈值模型直接返回空结果。解决把min_detection_confidence和min_tracking_confidence都降到 0.3同时改善照明——在摄像头旁边加一个补光灯或者把训练位置换到窗边。如果还是不行考虑用红外摄像头但普通 USB 红外摄像头分辨率低关键点精度会下降。最实用的方案还是加灯。5.3 摄像头角度导致角度计算偏差现象同一个动作换个位置站计数结果就不一样了。原因MediaPipe 输出的是 2D 归一化坐标如果摄像头不是正对训练者身体在画面中会有透视变形计算出的关节角度和真实角度有偏差。比如摄像头从侧面拍深蹲时膝盖角度看起来比实际小。解决固定摄像头位置正对训练者高度与髋部齐平。如果必须侧面拍摄需要在训练数据采集时就用同样的角度让 KNN 学习这个视角下的角度模式。换视角就要重新采集数据这是 KNN 方案的一个局限——它学的是特定视角下的角度分布视角变了分布就变了。5.4 KNN 模型加载后预测结果全为同一类现象训练时交叉验证准确率 95% 以上但实际运行时所有预测都是 0。原因最常见的是特征尺度问题。如果训练时用的角度是 0 到 180 的原始值预测时传入了归一化到 0 到 1 的值KNN 的距离计算就完全错了。另一个原因是模型文件加载失败knn.predict返回的是默认值。解决在预测前打印一下输入特征的值确认和训练时的范围一致。如果训练时用了StandardScaler做标准化预测时也必须用同一个 scaler 转换。我一般会在模型保存时把 scaler 一起存进去加载时一起加载避免遗漏。5.5 多人入镜导致关键点串人现象画面里出现两个人时骨架在两个人之间跳来跳去计数完全乱套。原因MediaPipe Pose 默认只检测置信度最高的一个人当两个人交替成为最高置信度时关键点就在两人之间切换。解决最简单的办法是确保画面里只有一个人。如果无法避免可以用 MediaPipe 的 PoseLandmarker 多姿态版本但那个模型更大更慢。另一个思路是用目标检测先框出主要人物再在框内做姿态估计但实现复杂度高。对于健身计数这个场景单人使用是默认前提多人场景不是这个方案的目标。6. 从能跑到好用三个提升计数精度的小技巧第一个技巧是动态阈值替代固定 KNN。KNN 的决策边界是训练时确定的但每个人的关节活动范围不同。可以在运行时记录最近 30 帧的角度最大值和最小值动态调整分类阈值。比如手肘角度的最大值是 175 度最小值是 45 度那中间值 110 度就是天然的阶段分界线。这个方法不需要训练数据自适应性强适合快速验证。from collections import deque angle_buffer deque(maxlen30) # 每帧把角度加入缓冲区 angle_buffer.append(angle) if len(angle_buffer) 30: max_angle max(angle_buffer) min_angle min(angle_buffer) mid_angle (max_angle min_angle) / 2 # 动态判断阶段 if angle mid_angle 10: stage down elif angle mid_angle - 10: if stage down: counter 1 stage up第二个技巧是加入角速度特征。单纯用角度判断阶段在动作缓慢时容易在边界附近反复触发。加入角速度后可以判断角度正在快速减小还是角度稳定不变。角速度用相邻两帧的角度差除以时间间隔得到。KNN 的输入从一维变成二维[angle, angular_velocity]分类边界更清晰。采集数据时也要同时记录角速度训练流程不变。第三个技巧是用双阶段验证。一次完整的引体向上应该包含角度从大到小和角度从小到大两个过程。可以在状态机里加一个计数器记录连续多少帧处于up状态和down状态。只有up持续超过 5 帧且down持续超过 5 帧才计一次数。这能过滤掉因为抖动导致的瞬时状态切换代价是快速动作可能漏计——如果一次引体向上从拉到放只用了 10 帧5 帧的阈值就太长了。实际调参时根据动作速度调整一般 3 到 5 帧比较合适。这三个技巧可以叠加使用。我自己的习惯是先用动态阈值快速验证摄像头和角度计算是否正常然后切换到 KNN 加角速度特征做正式计数最后用双阶段验证过滤抖动。整套跑下来在光线充足、单人、正对摄像头的条件下引体向上和深蹲的计数准确率能到 95% 以上俯卧撑因为身体姿态变化大稍低一些约 90%。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网