MediaPipe姿态检测实战:俯卧撑自动计数与动作质量监测系统

发布时间:2026/9/5 9:21:44
MediaPipe姿态检测实战:俯卧撑自动计数与动作质量监测系统 最近在给自己做健身数据记录时发现一个很头疼的问题徒手胸肌训练不像硬拉、深蹲那样容易量化很多人练完俯卧撑、卧推后说不清自己到底做了多少标准次数也不知道动作是否因为疲劳而逐渐变形。后来我在本地搭了一套基于摄像头姿态检测的“胸肌训练动作监测 MVP”内部代号就叫“钢铁之胸”。这篇文章会把整个项目的设计思路、代码实现、运行效果和踩坑记录完整拆开讲一遍。这套方案适合三类读者一是想用 Python OpenCV 做动作识别练手的开发者二是想给自己的训练过程做自动化记录的健身爱好者三是刚接触 MediaPipe Pose 姿态关键点、想知道怎么把“坐标”变成“动作次数”的初学者。读完你可以得到一份能直接运行的俯卧撑/胸肌训练计数工具并理解它背后的计数状态机、角度过滤和可视化逻辑。1. 项目背景为什么需要一个“钢铁之胸”监测工具1.1 没有教练时怎么判断动作是否标准胸部训练最基础的动作是俯卧撑、哑铃卧推、哑铃飞鸟它们的共性是主动肌是胸大肌动作路径集中在肩关节和肘关节的屈伸。没有教练辅助时新手最常见的问题是只追求次数忽略了动作幅度。比如俯卧撑只做了半程手肘没有下降到一定程度胸肌受力时间明显缩短或者在力竭后期塌腰、耸肩用腰部和三角肌代偿完成动作。如果只靠主观感觉很难发现这些问题。但如果有一路侧面机位的视频我们就可以通过追踪肩、肘、腕、髋、踝等关键点实时计算肘关节角度和躯干角度从而判断每次动作的幅度是否达标。这就是“钢铁之胸”最初的产品定位一个基于单目摄像头的动作幅度监测与自动计数工具。1.2 为什么选择 MediaPipe Pose OpenCVMediaPipe Pose 是 Google 开源的姿态估计解决方案它可以直接从一帧 RGB 图像中输出人体 33 个关键点的归一化坐标和置信度不需要 GPU 也能达到不错的实时效果。OpenCV 则负责摄像头画面读取、画面绘制和窗口显示。两者组合非常适合用来做动作识别 Demo因为不需要训练模型也不需要对关键点坐标做过多的数学处理。从工程角度看这两者都是 Python 生态里非常成熟的库安装简单、文档丰富。即使你对姿态估计不了解也只需要知道“每一帧都会返回每个关键点的 x、y、z 和可见度”就能往下写逻辑。因此我们可以在 200 行左右的代码内完成一个可运行的训练计数工具。这套方案的边界也很明确单目摄像头只能提供二维平面信息无法精准还原三维深度所以它更适合做动作幅度和节奏的粗粒度判断而不是医院康复级别的运动学分析。不过对于日常训练辅助来说已经足够了。2. 系统设计从动作到数据的完整链路2.1 关键点识别与坐标体系MediaPipe Pose 会返回 33 个关键点序号对应固定的身体位置。本项目主要用到以下 8 个关键点序号名称作用11left_shoulde左肩12right_shoulder右肩13left_elbow左肘14right_elbow右肘15left_wrist左腕16right_wrist右腕23left_hip左髋24right_hip右髋25left_knee左膝26right_knee右膝27left_ankle左踝28right_ankle右踝坐标是归一化坐标取值大约在 0 到 1 之间表示相对图像宽度和高度的比例。直接使用归一化坐标的好处是不用关心摄像头分辨率但它的尺度与人在画面中的远近有关。比如一个人靠近镜头时肩到肘的距离会变大如果单纯用“肘部移动了多远”来判断动作幅度就会出差错。所以更好的做法是计算角度因为角度不受目标在画面中的大小影响。俯卧撑这个动作中最核心的指标是肘关节角度手臂伸直时接近 180 度身体下落到最低点时角度可能减少到 60 度到 90 度。标准俯卧撑要求胸部尽量贴近地面因此肘角变化幅度是判断“是否做完整”的天然指标。2.2 核心指标肘关节角度与躯干角度为了识别动作我们定义两个几何量。第一个是肘关节角度。以左肘的点为顶点连接左肩点和左腕点两条线段之间形成的内角就是左肘角。同理可得右肘角。手臂完全伸直时左肩、左肘、左腕三点接近直线角度约 180 度手臂弯曲时角度变小。计算三维或二维空间角度用余弦定理即可。第二个是躯干直线度。将肩点、髋点、踝点放在同一个平面内观察如果三点夹角接近 180 度说明身体保持一条直线如果角度明显小于 160 度可能是塌腰或者屈髋。这两个指标结合起来可以覆盖大多数俯卧撑判断逻辑肘角从上到下再从下到上代表一次呼吸节奏中的离心和向心阶段。躯干角度始终保持在合理范围内代表核心没有松散。2.3 动作计数状态机单纯判断“肘角小于阈值”并计数会产生大量重复计数。比如一个动作在最低点停留了 20 帧如果不加状态判定这一帧算一次、下一帧又算一次次数就被放大了。因此程序里需要维护一个“当前处于什么状态”的变量只有当状态从“下降/低位”切换到“上位/伸直”时才把计数加一。可以定义一个两状态的状态机状态含义判定条件UP手臂伸直身体在最高点肘角大于上升阈值例如 150 度DOWN手臂弯曲身体在最低点肘角小于下降阈值例如 90 度进入回路后的逻辑是初始状态假设为 UP。如果当前状态是 UP且肘角小于 DOWN 阈值则状态切换为 DOWN。如果当前状态是 DOWN且肘角大于 UP 阈值则当前状态切换为 UP同时计数加一。这样设计可以保证一个动作只会计数一次而且能防止因为肘角在阈值附近抖动导致的计数跳变。2.4 输出与交互方式程序实时运行后摄像头画面中会绘制出人体骨架并在画布左上角显示计数。为了让使用者知道动作是否合格还可以在画布上用文字提示“动作标准”或“请降低幅度”。当动作低于质量要求时不计数而是显示警告这是“钢铁之胸”对传统计数器的一个重要改进。画面输出之外程序会在终端输出每一帧的当前肘角和计数便于调试。如果想保存训练数据可以继续扩展 CSV 或 JSON 日志模块这部分我们会放到最佳实践章节单独讨论。3. 环境准备与依赖安装3.1 运行环境项目依赖 Python 运行环境。我用的是 Python 3.9 以上的版本摄像头部分需要 OpenCV 支持。如果你的机器没有摄像头也可以使用本地视频文件做测试代码里会通过参数控制。需要说明的是不同操作系统下OpenCV 的摄像头索引规则可能不同。Windows 和 Linux 通常使用cv2.VideoCapture(0)打开默认摄像头macOS 第一次运行会弹出摄像头权限申请需要在系统设置里允许当前终端或 IDE 使用摄像头。如果打开失败建议先确认权限再继续排查。3.2 安装依赖只需要三个核心依赖mediapipe、opencv-python、numpy。建议先创建虚拟环境避免污染全局 Pythonpython -m venv venv source venv/bin/activate # Windows 下使用 venv\Scripts\activate然后安装依赖pip install -U pip pip install mediapipe opencv-python numpy这里没有锁定具体版本因为 MediaPipe 迭代比较快不同版本的可视化接口略有差别。示例代码使用的是经典的mp.solutions.pose风格 API这个接口在多数较新版本中仍然可用。如果你下载的 MediaPipe 已经引入了新版 API 标记请以官方文档为准做少量调整。安装完成后可以验证导入是否正常python -c import mediapipe as mp; import cv2; print(dependencies ok)如果出现No module named mediapipe说明安装过程出现问题如果出现模型权重下载失败的提示需要检查网络环境能否正常访问 Google 存储地址。由于网络环境限制个别情况下可能需要手动配置离线模型文件但一般默认安装后会从网络下载一次姿态模型之后的运行不需要重复下载。4. 核心代码实现4.1 角度计算工具先编写一个独立的角度计算模块后续主程序可以直接复用。这里使用余弦定理计算三个点构成的角度。传入的三个点各自包含 x、y、z 三个分量也可以退化为二维坐标使用。创建文件pose_utils.py# 文件路径pose_utils.py import math def calculate_angle(a, b, c): 计算三个关键点组成的角度。 参数 a、b、c 是包含 x、y、z 的坐标点b 为角点顶点。 返回角度值范围 0 到 180 度。 a np.array(a) b np.array(b) c np.array(c) ba a - b bc c - b cos_angle np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) 1e-6) cos_angle np.clip(cos_angle, -1.0, 1.0) angle math.degrees(math.acos(cos_angle)) return angle这里需要导入 numpy。另外在实际计算中我们需要的是 0 到 180 度的角度值所以直接使用math.degrees。np.clip的作用是防止因为坐标浮点数误差导致余弦值略大于 1 或小于 -1否则math.acos会返回nan这是角度计算最容易踩的坑。4.2 平滑滤波与状态管理类由于摄像头采集的原始关键点坐标存在抖动直接计算出来的角度在每一帧都可能上下波动 2 到 5 度。如果阈值正好设在 90 度角度在 88 到 93 度之间抖动时状态机会频繁切换导致计数不准确。一种简单的做法是使用滑动窗口平均。我们可以维护一个长度为 5 的队列每次计算完角度后把新值加入队列并返回队列的平均值。这样既能保留实时性又能滤掉高频抖动。继续在pose_utils.py中增加一个平滑器# 文件路径pose_utils.py from collections import deque class AngleSmoother: def __init__(self, window_size5): self.window_size window_size self.queue deque(maxlenwindow_size) def update(self, angle): self.queue.append(angle) if len(self.queue) 2: return angle return sum(self.queue) / len(self.queue)状态管理类负责维护动作的当前状态和计数。它的逻辑非常直观使用一个字符串记录当前状态初始为up。当状态为up且角度小于低位阈值时切换到down当状态为down且角度大于高位阈值时计数加一并回到up。# 文件路径pose_utils.py class PushUpCounter: def __init__(self, down_threshold90, up_threshold150): self.down_threshold down_threshold self.up_threshold up_threshold self.state up self.count 0 def update(self, angle): if self.state up: if angle self.down_threshold: self.state down elif self.state down: if angle self.up_threshold: self.count 1 self.state up return self.count使用不同的阈值会造成灵敏度差异。下降阈值越大越容易进入低位状态上升阈值越大越要求手臂充分伸直才计一次数。对于不标准动作可以把up_threshold调低到 140 度这样即使没完全锁死肘关节也能算一次对于严格要求全幅度的训练则应该把up_threshold保持在 160 度以上。4.3 主循环与可视化主程序读取摄像头帧送入 MediaPipe Pose 模型推理得到关键点坐标然后依次计算左右肘角、躯干直线度用平滑器处理角度用计数器更新状态最后使用 OpenCV 绘制骨架与文字信息。创建文件main.py# 文件路径main.py import cv2 import mediapipe as mp import numpy as np from pose_utils import calculate_angle, AngleSmoother, PushUpCounter mp_pose mp.solutions.pose mp_drawing mp.solutions.drawing_utils pose mp_pose.Pose(min_detection_confidence0.5, min_tracking_confidence0.5) smoother AngleSmoother(window_size5) counter PushUpCounter(down_threshold95, up_threshold150) def get_point(landmarks, index): return [landmarks[index].x, landmarks[index].y, landmarks[index].z] def draw_text(img, text, origin, color(0, 255, 0)): cv2.putText(img, text, origin, cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2, cv2.LINE_AA) cap cv2.VideoCapture(0) while cap.isOpened(): success, frame cap.read() if not success: break frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results pose.process(frame_rgb) if results.pose_landmarks: lm results.pose_landmarks.landmark left_shoulder get_point(lm, 11) left_elbow get_point(lm, 13) left_wrist get_point(lm, 15) right_shoulder get_point(lm, 12) right_elbow get_point(lm, 14) right_wrist get_point(lm, 16) left_hip get_point(lm, 23) left_ankle get_point(lm, 27) left_angle calculate_angle(left_shoulder, left_elbow, left_wrist) right_angle calculate_angle(right_shoulder, right_elbow, right_wrist) body_angle calculate_angle(left_shoulder, left_hip, left_ankle) avg_elbow_angle (left_angle right_angle) / 2 avg_elbow_angle smoother.update(avg_elbow_angle) count counter.update(avg_elbow_angle) mp_drawing.draw_landmarks( frame, results.pose_landmarks, mp_pose.POSE_CONNECTIONS, mp_drawing.DrawingSpec(color(0, 255, 0), thickness2, circle_radius3), mp_drawing.DrawingSpec(color(255, 0, 0), thickness2), ) draw_text(frame, fCount: {count}, (30, 50)) draw_text(frame, fElbow: {avg_elbow_angle:.1f}, (30, 90)) draw_text(frame, fState: {counter.state}, (30, 130)) if body_angle 150: draw_text(frame, Warning: body not straight, (30, 170), (0, 0, 255)) else: quality Good if avg_elbow_angle 100 or counter.state down else Full reps draw_text(frame, quality, (30, 170), (255, 200, 0)) print(fcount{count}, elbow{avg_elbow_angle:.1f}, state{counter.state}) cv2.imshow(Iron Chest Tracker, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows() pose.close()这段代码中每个关键点坐标都包含 x、y、z 三个分量。calculate_angle接收三个点列表后计算角度。需要注意的是如果 MediaPipe 没有检测到某个关键点对应 landmark 的可见度会较低坐标值可能不可信。示例只做了基础可见度判断实际应用建议增加visibility过滤我们会在下一节补充。4.4 运行与验证在终端运行python main.py程序会打开默认摄像头。站在距摄像头 1.5 到 2.5 米的位置让全身尽量完整出现在画面内侧身对着摄像头做俯卧撑更容易被识别。连续做几个标准俯卧撑后观察画面左上角的 Count 数值是否随动作变化。正常情况下下降时elbow从 160 度左右降低到 90 度以下状态从up变成down推起后角度回到 150 度以上状态变成up同时 Count 增加 1。如果画面中检测不到人体或者角度一直为 0可以按q退出再检查摄像头权限和光线条件。如果需要用视频文件代替摄像头测试可以把cv2.VideoCapture(0)改成视频文件路径例如cv2.VideoCapture(test.mp4)。这样即使没有摄像头也可以验证算法逻辑是否正常。5. 常见问题与排查思路问题现象常见原因解决思路打开摄像头失败摄像头被占用、驱动缺失或权限未开启关闭其他占用摄像头的程序检查系统隐私设置尝试cap cv2.VideoCapture(1)等索引号画面中没有人但程序不报错人体不在画面内、光线过暗、模型未检测到人调整站位和摄像头角度增加环境光线确认摄像头画面上能看到完整人体画面无骨架绘制MediaPipe 无法输出关键点或 main.py 中未绘制观察results.pose_landmarks是否为 None检查mp_drawing.draw_landmarks是否被放在人物可见分支内计数不增长肘角阈值或状态机未触发打印实时角度如果角度始终大于下降阈值则调高down_threshold如果始终无法回到高位则调低up_threshold一次动作被记多次状态机缺失或角度抖动确认使用PushUpCounter的状态字段不要只通过角度瞬时值计数增大平滑窗口长度运行卡顿、画面延迟摄像头分辨率过高或没有 GPU降低摄像头分辨率例如设置cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)和cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)角度输出为nan两个点的欧氏距离为 0 或余弦值越界检查三个点是否重复、是否都检测到使用np.clip保证余弦输入在 [-1, 1] 范围内在实际排查中最推荐的方法是打印原始角度。把calculate_angle得到的左右肘角和平均值打印出来观察一个完整动作下角度曲线的变化范围。如果下降过程中角度能连续低于下降阈值推起时能连续高于上升阈值那么问题大概率出在阈值参数上做针对性调整即可。6. 工程化最佳实践与扩展方向6.1 数据质量与算法调优这套系统的核心瓶颈不是模型识别能力而是数据质量。为了保证训练计数准确需要注意三点。第一视角选择。俯卧撑计数时让摄像头从侧面拍摄这样肩、肘、腕三点的角度变化最明显。如果摄像头放在正前方俯卧撑下降时手臂弯曲方向在画面内不够直观角度计算容易出错。卧推动作计数则更需要侧方位因为人体仰卧在训练凳上时如果摄像头在正上方关键点遮挡会比较严重。第二人体在画面中的占比。建议人体宽度占画面宽度的 30% 到 60%。太远时关键点抖动明显手肘的毫米级误差会被放大成几度误差太近时又会截断手腕、髋部等关键点导致无法绘制完整骨架。第三时间维度上的稳定性。单帧的置信度只能说明“这一帧检测到了关键点”不能说明“这个关键点位置准”。可以通过一段时间的曲线观察角度变化是否有明显振荡。如果振荡幅度持续超过 10 度说明当前机位的光线或距离不理想可以优先调整机位而不是盲目调大滑动窗口长度。6.2 代码工程化建议上文给出的代码是一个展开的 Demo。如果要把这个项目放在真实场景中使用还需要做一些改进。第一个改进是引入关键点可见度过滤。MediaPipe 的每个 landmark 都带有 visibility 属性它的取值范围是 0 到 1表示该点被模型识别到的置信度。应当只对visibility 0.6的关键点计算角度否则直接跳过本帧。这是因为当手掌或手肘被身体挡住时模型输出的坐标可能是通过周围点推断出来的噪声很大。def filter_landmarks(landmarks, index, confidence0.6): if landmarks[index].visibility confidence: return None return [landmarks[index].x, landmarks[index].y, landmarks[index].z]这样处理以后在少数帧中会因为没有有效关键点而无法更新状态但这种“保守策略”比使用错误数据要好得多。第二个改进是配置外置。不要把所有阈值都写在代码里建议用配置文件或命令行参数管理down_threshold、up_threshold、window_size等参数方便针对不同用户、不同动作快速调整。第三个改进是增加动作质量日志。每次计数加一时可以记录当前动作的完成时间、平均肘角、最小肘角、躯干角度范围等信息。长期收集这些数据后就能分析用户的训练量是否递增、动作是否在后半段变形这才是“钢铁之胸”作为训练记录工具的核心价值。6.3 后续扩展方向从当前基于肘角的状态机出发还可以继续扩展多种动作识别能力。比如哑铃飞鸟由于动作过程中手臂接近伸直不能用肘角变化判断需要改为计算手腕相对肩部的移动轨迹范围。双杠臂屈伸可以把关键点换成肩、肘、腕但在镜头朝侧面时躯干的下沉距离更明显计算肩点相对髋点的高度差会更稳定。卧推次数统计由于人体仰卧后姿态检测难度增加需要侧机位并且优先使用杠铃轨迹跟踪而不是单纯依赖手肘关键点。如果需要做更精细的左右不平衡检测还可以在每一帧将左右肘角分别计算并显示差值。如果左肘和右肘的角度差持续大于 15 度说明左右发力不均衡界面可以提示用户调整姿势。这一点对健身人群很有用但需要双肩、双肘、双腕同时可见因此更适合做站姿哑铃弯举或跪姿俯卧撑的辅助判断。7. 项目复盘与落地建议“钢铁之胸”这个项目做下来我的最大感受是姿态检测类应用真正的难点不在“识别姿势”而在“把识别的点转化为业务逻辑”。MediaPipe 在单帧图像上输出关键点非常稳定但训练动作要的是序列语义比如“手臂从伸直到弯曲再回到伸直”才算一次动作。如果不引入状态机而只看瞬时角度代码会在多人、遮挡、疲劳摇晃时迅速失控。另一个比较重要的经验是阈值不能一概而论。不同身高、臂长、柔韧性的人完成俯卧撑时肘角范围差异很大。较胖的人手臂伸直后肘角也可能只有 150 度追求 170 度的上升阈值会让他们很难正常计数。所以做成产品时最好让用户先做几次热身动作自动学习属于他的角度上下限再开始正式计数而不是把 90 度和 150 度写死在代码里。如果你准备把这个项目继续扩展我的建议顺序是先准备好测试视频素材覆盖不同光线、不同服装、不同拍摄角度再写一套离线回放脚本用同一段视频验证不同阈值参数下的计数结果最后才接入摄像头实时模式。这样既能提升调试效率又能避免每次测试都要做一组俯卧撑的尴尬。相信我做动作识别项目时最累的不是写代码而是测试时反复起身做动作。希望这篇教程能帮你跑通自己的“钢铁之胸”监测工具。代码部分虽然短但状态机、平滑器、阈值选择这几个设计点和很多复杂度更高的动作识别项目是相通的。你可以在此基础上增加自己的动作库让它从一个 Demo 成长为一套真正可用的训练辅助工具。