MediaPipe姿态估计+KNN实现引体向上、深蹲、俯卧撑通用计数模型
简介一份面向健身动作计数场景的Python源码包基于MediaPipe人体关键点检测与KNN分类算法实现了引体向上、深蹲、俯卧撑三种动作的自动识别与计数。项目采用数据驱动方式利用训练集特征向量分类姿态切换运动类型几乎无需改代码适合学习姿态识别或开发健身计数工具的开发者参考。压缩包共61个文件含10个Python源文件、28个pyc编译文件、6个CSV训练数据文件以及示例视频、图片、配置文件、说明文档和字体资源整体约18.45MB。源码模块覆盖关键点归一化、KNN姿态分类、结果平滑、动作计数、视频摄像头处理、训练集提取校验等完整流程并带测试视频和样例图片可直接运行观察效果。已有270人学习下载适合作为一套完整可复用的健身计数器实践方案。1. 引体向上、深蹲、俯卧撑共用一个计数模型MediaPipe 姿态估计 KNN 分类的落地套路项目标题里最值钱的不是健身计数而是共用一套代码。市面上大多数计数器项目是按动作写死骨骼夹角引体向上看肘角、深蹲看膝角、俯卧撑看肩肘连线换一个动作就得改一套阈值动作不标准还会漏计。这个项目用 MediaPipe 提取 33 个姿态关键点做归一化编码后丢给 KNN 分类器判断当前帧属于哪个动作阶段三种运动共用同一套特征提取和计数逻辑只在调用时传一个动作名称。适合想用姿态识别做运动计数、又不想被繁琐角度阈值困住的 Python 开发者也适合拿来做课程设计的在校学生——代码拆分清晰每个模块职责单一读起来比一般毕设源码省力得多。下面直接从技术栈拆起再落到具体跑通步骤和踩坑记录。2. 理解计数器的核心姿态编码、KNN 分类与计数状态机2.1 为什么选 KNN 而不是角度阈值或深度学习分类角度阈值法的问题在于动作标准是人为定义的。引体向上有人手臂不完全伸直就拉上去深蹲有人习惯半蹲阈值定松了漏计定紧了误计而且三种动作各要一套参数维护成本高。端到端深度学习分类比如用 LSTM 直接识别正在做一个引体向上需要大量标注好的时序数据普通人很难凑出够用的训练集。KNN 把问题简化成当前这一帧的姿态像不像训练样本里的某类姿态。它不需要训练过程只是把训练集的特征向量存起来推理时计算当前帧特征与所有样本的距离取 K 个最近邻投票得出分类结果。它的优势在于一是特征空间里每个点都有明确含义可以可视化检查分类边界二是新增动作只需要补充样本不需要重新跑训练三是对于三分类up/down/transition这种简单任务KNN 的准确率已经足够而且推理速度快可以做到实时。2.2 poseembedding.py 的核心关键点归一化编码MediaPipe Pose 输出的 landmarks 是 33 个关键点每个点有 x、y、z 和可见度。如果直接把原始坐标丢给分类器同一个动作在不同画面尺寸、不同人体远近下坐标差异巨大模型学到的全是画面里的位置而不是人体姿态。所以 poseembedding.py 做的是把关键点从绝对坐标转成相对关系。我拆代码时重点看了它的归一化策略取躯干上两个稳定关键点通常是左肩和右肩作为参考系计算躯干向量长度作为缩放基数把所有关键点坐标减去躯干中心点再除以躯干长度。这样转出来的特征向量只描述肢体相对躯干的角度和伸展程度与拍摄距离、画面大小基本无关。这部分是整条流水线的质量天花板归一化做得不好后面 KNN 分类再准也没有用。2.3 poseclassifier.py 的分类逻辑距离度量与投票机制分类器内部维护一个特征向量列表和对应的标签列表标签通常按动作阶段定义。以引体向上为例分为 down手臂伸直吊在单杠下和 up下巴过杠两个阶段过渡帧不需要单独分类。推理时计算当前帧特征与每个训练样本的欧氏距离取距离最近的 K 个样本统计标签出现次数票数最高的标签就是当前帧的姿态类别。这里有一个容易忽略的参数distance_threshold。如果当前帧与最近邻样本的距离都超过了这个阈值说明当前姿态在训练集里没有对应的熟人分类器会返回 unknown。这个机制在处理视频开头人体还没完全入画、或者摄像角度与训练样本差异过大时特别有用避免把垃圾帧强行归入某个动作阶段。2.4 counter.py 的计数状态机为什么分类正确不等于计数正确分类正确是计数的必要条件但不是充分条件。如果单纯统计出现 up 标签的次数人挂在杠上晃两下就会多计。counter.py 实现的是一个简单的状态机只有从 down 状态转移到 up 状态才计数加一。换句话说一次完整动作 一个完整的 down → up → down 循环中间任何重复帧都不会触发多次计数。实现上通常维护一个字符串记录历史分类结果比如连续 3 帧都是 up 才认为真正进入了 up 状态连续 3 帧都是 down 才回到 down 状态避免了单帧抖动导致的误判。这个连续 N 帧确认的思路比单纯的状态切换更稳代价是引入几帧延迟在实时场景下感知不明显。# 计数逻辑示意与项目 counter.py 思路一致 class RepCounter: def __init__(self, confirm_frames3): self.state down # 当前状态down / up self.confirm_frames confirm_frames # 连续几帧确认状态切换 self.state_count 0 self.rep_count 0 def update(self, pose_label): # pose_label: down / up / unknown if pose_label unknown: self.state_count 0 return self.rep_count if pose_label self.state: self.state_count 0 else: self.state_count 1 if self.state_count self.confirm_frames: # 状态切换只有 down-up 才计数 if self.state down and pose_label up: self.rep_count 1 self.state pose_label self.state_count 0 return self.rep_count代码逻辑说明状态机维护当前状态、待确认帧计数器和总次数。当连续 3 帧分类结果与当前状态不同且目标状态为 up 时才完成 down → up 切换并计数。unknown 标签直接清零待确认帧防止半人半画面干扰状态判断。参数说明confirm_frames 决定了计数灵敏度设为 1 时单帧跳变就会触发计数适合动作干脆利落的场景设为 5 时抗抖动强但动作速度快时可能漏掉短暂的 up 阶段需要按动作速度调整。计数阶段还有一个常见坑动作做到一半人离开画面状态停留在 up回来时状态机仍然认为在 up新动作的 down 帧会先触发 up → down 切换不会误计但如果用的是简单的检测到 down 归零逻辑就会漏计。所以状态机必须贯穿始终不能在视频流里随便重置。3. 跑通引体向上计数环境配置与视频处理全流程3.1 环境约束为什么必须在 Python 3.7 和 MediaPipe 0.8.10 下跑项目说明里写明了测试环境是 win10 python3.7 mediapipe0.8.10这个版本号不是随手写的。MediaPipe 在 0.8.x 时代对 Python 版本有硬性要求Python 3.8 以上在 Windows 下安装老版本 MediaPipe 大概率会报 DLL load failed 或者找不到 pose 模块。而新版本的 MediaPipe 又把 pose 模型的 API 改了landmark 的索引和输出字段都有变化直接拿新版本跑老代码会报属性错误。所以我建议严格按照 requirements.txt 里锁定的版本搭环境。用 venv 创建独立虚拟环境是一个好习惯避免把系统 Python 环境搞乱。# Windows 下创建虚拟环境并安装依赖 python -m venv .venv .venv\Scripts\activate pip install -r requirements.txt # requirements.txt 中关键依赖 # mediapipe0.8.10 # opencv-python4.5.5.64 # numpy1.21.6参数说明venv 路径下 Scripts 目录的 activate 是 Windows 的激活脚本Linux 或 macOS 对应的是 bin/activate。mediapipe 0.8.10 需要 numpy 1.21.x 配合numpy 2.0 之后移除了部分旧接口MediaPipe 内部会报错。安装完成后验证 MediaPipe 能否正常加载 pose 模型这一步能提前暴露很多环境问题import mediapipe as mp mp_pose mp.solutions.pose pose mp_pose.Pose( static_image_modeFalse, model_complexity1, min_detection_confidence0.5, min_tracking_confidence0.5 ) print(MediaPipe Pose loaded:, pose is not None)代码逻辑说明初始化 Pose 模型实例static_image_mode 设为 False 表示视频流模式模型会利用帧间跟踪来减少检测开销。model_complexity 为 1 表示中等模型精度和速度平衡。参数说明min_detection_confidence 是初始检测的最低置信度低于 0.5 时画面里的人会被忽略min_tracking_confidence 是后续跟踪的最低置信度如果上一帧跟踪丢失会重新做全图检测。3.2 videoprocess.py 的完整处理链帧读取、姿态提取、分类、计数、可视化videoprocess.py 是主流程模块串联了前面所有子模块。它的处理流程是打开视频文件或用 OpenCV 打开摄像头逐帧读取每帧先转成 RGB 颜色空间MediaPipe 要求 RGBOpenCV 默认 BGR送入 Pose 模型得到 landmarks再做归一化编码送入 KNN 分类器得到当前帧姿态标签最后更新计数器并在画面上绘制骨架和结果。这个流程里最容易出错的地方是图像颜色空间转换。OpenCV 读进来的帧是 BGR 顺序直接送进 MediaPipe 会得到完全错误的姿态——人脸和手臂的关键点位置全乱。转换代码是在循环里对每一帧调用 cv2.cvtColor 完成的漏了这一步输出就是乱的。# 单帧处理流程骨架对应 videoprocess.py 内部实现 import cv2 import mediapipe as mp cap cv2.VideoCapture(video-sample/chinup.mp4) mp_pose mp.solutions.pose pose mp_pose.Pose() while cap.isOpened(): ret, frame cap.read() if not ret: break # OpenCV 默认 BGRMediaPipe 需要 RGB frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results pose.process(frame_rgb) if results.pose_landmarks: # 提取 33 个关键点的归一化坐标 landmarks results.pose_landmarks.landmark # 送入归一化编码 KNN 分类 # feature pose_embedding(landmarks) # label knn_classifier(feature) # counter.update(label) pass cap.release()代码逻辑说明循环逐帧处理先转颜色空间再调用 pose.process 推理。results.pose_landmarks 为 None 表示当前帧没有检测到人体跳过后续步骤。实际项目中特征提取和分类的调用就嵌在这个 if 分支里。参数说明VideoCapture 的参数可以是视频文件路径也可以是摄像头设备号0 表示第一个摄像头。用摄像头时建议把帧率限制在 20 FPS 以内否则 MediaPipe 推理速度跟不上队列积压会导致延迟越来越高。3.3 运行入口指定运动类型并处理视频输出main.py 是整个项目的入口它的交互方式比较简单让用户输入或选择运动类型然后启动视频处理。如果是视频文件处理完成后可以用 OpenCV 的 VideoWriter 把标注了骨架和计数结果的帧写成新视频方便回看验证。以下是运行视频检测的基本命令配合摄像头使用时需要额外注意窗口显示的效率# 运行视频文件检测 python main.py --source video-sample/chinup.mp4 --exercise chinup # 运行摄像头实时检测 python main.py --source camera --exercise squat参数说明--source 指定输入源视频路径或 camera--exercise 指定运动类型当前支持 chinup引体向上、squat深蹲、pushup俯卧撑。这个参数会在加载训练集时决定使用哪一套特征向量和标签。输出视频时帧绘制频率和编码器是关键。常见做法是用 mp4v 编码器帧率设为输入视频的原始帧率。如果输出帧率与输入不一致回放时会发现动作节奏变快或变慢导致计数结果看着对不上实际动作次数。这里比较稳妥的做法是从输入视频对象里读取原始 FPS 属性直接赋值给输出视频的 VideoWriter。4. 自定义训练集从录制样本到生成可用的 CSV 特征库4.1 理解训练集结构每个动作需要哪些样本KNN 分类器的训练就是整理样本特征集。这个项目的训练样本不是公开数据集而是需要自己录制或收集视频后提取。训练集的组织形式直接影响分类效果我建议先理解样本结构再动手准备数据。以引体向上为例down 状态下需要包含手臂完全伸直的静止帧、从杠上跳下瞬间的帧、动作过程中下摆到最低点的帧。up 状态下需要包含下巴过杠的帧、但也要包含下巴略微超过杠和明显超过杠的帧。训练样本越贴近真实运动过程中的姿态分布分类器在视频推理时越不容易出现漏计或误计。提取关键点并生成特征向量的代码在 extracttrainingsetkeypoint.py 里# 从视频中提取关键点并保存特征向量的核心步骤 import cv2 import mediapipe as mp import csv mp_pose mp.solutions.pose pose mp_pose.Pose() cap cv2.VideoCapture(train_data/chinup_down.mp4) with open(fitness_poses_csvs_out/chinup_down.csv, w, newline) as f: writer csv.writer(f) while cap.isOpened(): ret, frame cap.read() if not ret: break frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results pose.process(frame_rgb) if results.pose_landmarks: # 归一化编码后写入一维特征向量 features pose_embedding(results.pose_landmarks.landmark) writer.writerow(features) cap.release()代码逻辑说明逐帧检测姿态将每帧的归一化特征向量作为一行写入 CSV。CSV 的列数等于归一化后的特征维度行数等于有效帧数。参数说明训练视频中人体占比很关键。归一化以躯干长度为基准如果人体在画面中只占很小比例关键点的像素误差相对躯干长度会被放大训练特征噪声增大。我一般会把人体在画面中的高度控制在画面高度的 60% 以上再录制训练样本。4.2 trainingsetprocess.py 的样本检验过滤垃圾帧和标签校正录完原始视频后直接生成的 CSV 往往包含大量垃圾帧——人体被遮挡、半身出画、过渡动作等。如果直接把这些特征向量写进训练集KNN 在推理时会被这些错误样本干扰。trainingsetprocess.py 做的事情就是对 CSV 做二次处理预览每个样本的姿态类别人工或半自动地把明显不属于目标类别的帧剔除或重新标记。这部分我用下来最关键的经验是宁可样本量少而精不要多而杂。每种姿态类别保留 100 到 200 帧高质量样本已经足够 KNN 分类器工作了重点在于覆盖姿态的多样性而不是数量。比如录引体向上的 down 状态不要只录静态挂在杠上的视频要录整个离心下放的过程这样 down 样本里会包含不同手臂角度的帧分类边界会更宽松。生成训练集后建议从视频中抽几帧做可视化验证# 可视化每个训练样本的关键点骨架 import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(fitness_poses_csvs_out/chinup_down.csv, headerNone) first_frame df.iloc[0].values # 原始坐标是归一化编码后的特征需要可视化时逆变换回关键点坐标 # 分别以 x 和 y 为横纵坐标绘制 33 个关键点代码逻辑说明读入 CSV取第一帧特征向量按关键点索引还原坐标后绘制骨架图。这里的绘制只是一个验证手段目的是确认特征值没有异常跳变。参数说明pandas 的 read_csv 默认把第一行当作列名所以加载时 headerNone 避免把第一帧特征向量当作标题丢掉。这个细节如果忽略了分类器会莫名其妙少一列数据报维度不匹配的错误。4.3 K 值的选择3、5、7 到底差多少K 值是这个模型里最值得调的超参数。K1 时分类器等同于最近邻对噪声极度敏感训练样本里一帧错误姿态就能让推理结果跳变K 值过大时分类器会忽略局部姿态差异把手肘略微弯曲的 up 帧也归到 down 类。实际测试下来三种动作里 K5 是稳健的默认值。原因在于这类动作的分类本质上是两到三个姿态簇的区分样本簇之间的间距远大于簇内噪声K5 时最近邻投票已经能稳定反映当前帧属于哪个簇。K7 的效果差异很小但会增加少量计算量虽然可忽略。在样本量只有几百帧时K 值不宜超过样本量的开方否则 vote 结果会被某一类样本的密度主导。经验上还有个更快的方法把 K 值当成滑动窗口长度来感受。K3 时分类结果抖动明显K5 时基本稳定K9 时动作切换会产生明显的滞后感因为需要积累更多帧才能翻盘。找到手感之后再配合 resultsmooth.py 的指数移动平均做二次平滑整体效果会非常顺。5. 避坑与排查MediaPipe 版本兼容、计数抖动、摄像头延迟的 6 个典型问题5.1 安装 MediaPipe 0.8.10 报错根本没有 wheel 包现象在 Python 3.9 或 3.10 环境下执行 pip install mediapipe0.8.10提示找不到匹配版本或编译失败。原因MediaPipe 0.8.10 的官方 wheel 只覆盖了特定 Python 版本高版本 Python 没有对应的预编译包源码编译在 Windows 上几乎不可能完成。解决按项目说明使用 Python 3.7 创建虚拟环境。如果系统已经装了 Python 3.10可以用 conda 或 pyenv 安装 Python 3.7 再建环境不要尝试在 3.10 里强行装老版本。5.2 姿态检测正常但 KNN 分类结果乱跳down 和 up 交替无序现象画面上骨架稳定但分类结果每帧都在 down/up 之间抖计数疯狂累加。原因训练集里 down 和 up 样本的特征分布重叠严重最典型的原因是录训练视频时动作没做完整段比如 down 样本只录了手臂半弯的帧与 up 样本中手臂接近伸直的低位帧在特征空间里距离太近。解决重新录制训练视频确保 down 样本覆盖肌肉完全放松、手臂完全伸直的状态up 样本覆盖下巴在杠上方和杠齐平的状态两类样本的特征分布拉得越开分类抖动越小。5.3 计数漏掉动作视频里明明做了 10 个只计了 6 个现象推理时分类结果正确但 counter 始终不计数或只计一部分。原因confirm_frames 设置过大导致短暂的 up 阶段还没来得及累积到确认帧数人已经回到 down 状态。常见于节奏快的动作或动作不标准的视频。解决把 confirm_frames 从默认 3 调整为 1 或 2或者检查动作过渡帧是否在画面中产生了遮挡干扰。更优的做法是降低视频处理帧率让每一帧之间的动作变化量更大状态切换更快被感知到。5.4 摄像头实时检测时画面延迟越来越高最终卡到不可用现象开始运行时正常几十秒后画面越来越慢按下的动作要一秒后才显示在窗口里。原因MediaPipe 的推理速度跟不上摄像头帧率OpenCV 的视频读取线程持续往队列里塞帧主线程处理不过来积压帧越来越多延迟持续累积。解决把输入帧率限制在 15 到 20 FPS常见做法是对读取到的帧做跳帧处理——只处理每两帧中的一帧。另外确认 model_complexity 设为 1不要开 2精度提升有限但计算量翻倍。5.5 引体向上计数总比实际次数多一次最后挂杠休息也被算进去了现象做完标准动作后挂在杠上不动计数器偶尔还会跳一次。原因挂在杠上静止时姿态保持 up但训练样本里没有覆盖静止挂着这个状态KNN 可能把它归到 down 类或 unknown 类状态机从 down → up 完成一次切换。解决在训练集中增加动作间暂停样本标签设为 rest 或 unknown。更好的办法是利用 distance_threshold 过滤——静止挂杠的姿态与训练样本中的运动帧距离偏大分类器返回 unknown 后状态机不切换自然就不会计数。5.6 换了视频后全程无计数但骨架正常现象新视频中人体姿态检测正常骨架绘制完整但分类器一直返回 unknown计数器纹丝不动。原因新视频的拍摄角度、人物体型、画面远近与训练样本差异太大归一化编码虽然消除了部分尺度影响但视角变化导致的关键点投影差异无法被抵消特征向量距离超过 distance_threshold。解决如果计划检测多个拍摄角度的视频训练样本必须覆盖对应角度至少包含正面和 45 度侧面两个视角。否则只能针对单一角度重新录制训练数据这是 KNN 方案的天花板。6. 让三种动作共用一个分类器K 值、EMA 平滑与决策边界的联合调优把人从单杠放下来代码还跑在内存里这套系统真正好玩的点在共用。三个动作共用一个 poseembedding 和一个 poseclassifier区别只在训练集和传入的运动类型参数。如果我想加一个动作比如悬垂举腿流程是录 5 组视频、提取特征、生成 CSV、在入口加一个枚举值计数器逻辑一行不用改。这种扩展方式比角度阈值法舒服太多。调优的核心杠杆有三个。第一是 K 值决定单帧分类的敏感度第二是 EMA 平滑系数决定时序上的稳定度第三是 distance_threshold决定分类器对陌生姿态的拒绝程度。三者要一起调单独拧一个容易顾此失彼。resultsmooth.py 的指数移动平均本质上是在给分类结果做一个低通滤波。假设原始分类输出是 o1, o2, o3...平滑输出 s_i alpha * o_i (1 - alpha) * s_{i-1}。alpha 越大越相信当前帧越小越相信历史帧。alpha 设为 0.5 时单帧跳变会被削减一半设为 0.8 时跳变的影响大需要配合较大的 K 值来兜底。验证是否调好的方法不是看延时是否为零而是看两个指标一是漏计率标准动作做 10 次应该至少计 9 次二是误计率做 10 次不能计出 12 次来。我每次调完参数都会拿三段不同场景的视频做回归测试——一段快速连续动作、一段慢速标准动作、一段有中途休息的动作。只有三段都满足了才认为这次调参是有效的。我自己的经验是先用 K5 跑一遍基线记录漏计和误计位置再看误计帧对应的分类结果是噪声多还是偏差多。噪声多就调大 K 到 7 或调高平滑系数偏差多就补训练样本别靠调参数硬撑。每当我在新场景里测试翻车现在都会强制自己先检查训练样本的类间距离而不是急着拧超参数——样本质量才是这套方案的天花板。希望这套拆解能帮你少走几步弯路。本文还有配套的精品资源点击获取