ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+MediaPipe实现仰卧起坐自动计数:姿态估计与状态机详解

Python+MediaPipe实现仰卧起坐自动计数:姿态估计与状态机详解

简介:这份资源面向对人工智能与计算机视觉感兴趣的开发者、健身应用方向的学习者,以及需要完成体能测试类课程设计的学生,核心解决的是如何用 Python 实现仰卧起坐动作的自动计数与个数统计问题。包内共 5 个文件,以 py 脚本与 mp4 演示视频为主,另含一个 zip 压缩包,整体约 284.42MB,脚本负责姿态识别与计数逻辑,视频用于展示实际运行效果与动作判定过程。资源围绕 MediaPipe 姿态估计展开,涉及关键点提取、动作幅度判断与计数状态机等环节,可帮助读者理解从视频流到计数结果的完整链路。目前已有 756 人学习,适合希望快速上手人体姿态分析、并将算法落地到健身计数场景的读者参考,也可作为相关项目二次开发的起点。

1. 从摄像头到计数:AI健身体能测试里仰卧起坐统计到底难在哪

健身房里做体能测试,仰卧起坐是最常见的项目之一,但人工计数一直是件麻烦事:教练盯着看容易走神,学员自己数又容易偷懒,一组 60 秒下来到底做了多少个、动作是否标准,往往各执一词。用 Python 加摄像头做仰卧起坐自动计数,就是把这件"凭肉眼"的事变成"凭数据"的事——摄像头采集画面,姿态估计模型提取人体关键点,再用几何逻辑判断"躺下—坐起"的完整周期,最后累加计数。这套方案适合有 Python 基础、想快速搭一个可跑原型的人,也适合做 AI 健身方向产品验证的开发者。它不需要昂贵的压力传感器或穿戴设备,一台普通笔记本加一个 USB 摄像头就能起步,核心难点不在模型本身,而在"怎么把关键点的坐标变化翻译成一次有效计数"。

2. 仰卧起坐计数的技术选型:为什么用姿态估计而不是目标检测

2.1 目标检测和姿态估计的本质区别

做仰卧起坐计数,第一反应可能是"训练一个模型识别'躺下'和'坐起'两个状态"。这是目标检测的思路:给画面框出人体,再分类当前姿态。但目标检测有个致命问题——它输出的是边界框,而仰卧起坐的核心动作是躯干角度的连续变化,边界框在躺下和坐起时可能都是"一个横着的人形框",区分度极低。你很难靠一个矩形框判断躯干和地面的夹角是 30 度还是 80 度。

姿态估计(Pose Estimation)输出的是人体关键点坐标,比如肩膀、髋部、膝盖、脚踝。有了这些点的坐标,躯干角度就是一个简单的三角函数计算。仰卧起坐的判定逻辑天然适合用关键点几何关系来表达:肩髋连线与水平面的夹角从接近 0 度(躺下)变到接近 90 度(坐起),再回落,就是一个完整周期。所以选型结论很明确:用姿态估计提取关键点,用几何逻辑做状态机计数,而不是用目标检测做分类。

常见做法是用 MediaPipe Pose 或 YOLOv8-Pose。MediaPipe 的优势是轻量、CPU 上就能跑到实时,适合快速验证;YOLOv8-Pose 精度更高但需要 GPU 才流畅。如果只是做体能测试原型,MediaPipe 足够用,而且安装简单,不需要配 CUDA 环境。

2.2 环境搭建与依赖安装

先把 Python 环境准备好。如果你还没装 Python,去官网下载 3.8 以上版本即可,安装时记得勾选"Add Python to PATH"。装完在命令行验证:

python --version pip --version

接下来装核心依赖。MediaPipe 和 OpenCV 是必装的,numpy 用于坐标计算:

pip install mediapipe opencv-python numpy

如果你用的是较新的 Python 版本(比如 3.12),MediaPipe 可能暂时没有对应的 wheel 包,这时候建议用 Python 3.10 或 3.11 建一个虚拟环境:

python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install mediapipe opencv-python numpy

参数说明:opencv-python是完整版,包含 GUI 功能,能直接cv2.imshow显示画面;如果你在服务器上跑无头模式,可以换成opencv-python-headless,体积更小。mediapipe自带预训练的姿态模型,不需要额外下载权重文件,安装完就能用。

提示:MediaPipe 对 numpy 版本有要求,如果安装后 import 报错,先检查 numpy 是否被降级到了不兼容的版本,用pip install "numpy<2"通常能解决。

2.3 用 MediaPipe 提取关键点的最小代码

先跑通"能拿到关键点"这一步,再谈计数逻辑。下面这段代码打开摄像头,实时检测人体姿态并画出骨架:

import cv2 import mediapipe as mp mp_pose = mp.solutions.pose mp_draw = mp.solutions.drawing_utils # 初始化姿态估计模型,model_complexity=1 是速度和精度的折中 pose = mp_pose.Pose( static_image_mode=False, # 视频流模式,逐帧跟踪 model_complexity=1, # 0最快/1均衡/2最准 smooth_landmarks=True, # 平滑关键点,减少抖动 min_detection_confidence=0.5, # 检测置信度阈值 min_tracking_confidence=0.5 # 跟踪置信度阈值 ) cap = cv2.VideoCapture(0) # 0 表示默认摄像头 while cap.isOpened(): ret, frame = cap.read() if not ret: break # MediaPipe 需要 RGB 输入,OpenCV 默认是 BGR 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 ) # 取出左肩(11)和左髋(23)的坐标,后面计数要用 lm = results.pose_landmarks.landmark shoulder = lm[mp_pose.PoseLandmark.LEFT_SHOULDER] hip = lm[mp_pose.PoseLandmark.LEFT_HIP] print(f"肩: ({shoulder.x:.3f}, {shoulder.y:.3f}) " f"髋: ({hip.x:.3f}, {hip.y:.3f})") cv2.imshow("Pose", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

逻辑说明:pose.process()接收 RGB 图像,返回 33 个关键点的归一化坐标(x、y 范围 0~1)。smooth_landmarks=True会让相邻帧的关键点做平滑,代价是动作切换时略有延迟,但对计数这种低频事件影响不大。model_complexity设成 1 是实测下来 CPU 上最舒服的档位,设 2 会明显掉帧,设 0 则关键点抖动偏大。

参数说明:min_detection_confidence和min_tracking_confidence都设 0.5 是默认值。如果光线差导致关键点频繁丢失,可以降到 0.3;如果背景里有多个人导致误检,可以升到 0.7。摄像头分辨率建议设成 640x480,太高会拖慢推理速度,对计数精度没有实质帮助。

3. 把关键点变成计数:角度计算与状态机设计

3.1 躯干角度的计算方法

仰卧起坐的核心判据是躯干与水平面的夹角。取左肩和左髋两个点,计算它们连线与水平方向的夹角:

import math def calc_torso_angle(shoulder, hip): """ 计算躯干与水平面的夹角(度) shoulder/hip 是归一化坐标 (x, y),y 轴向下为正 """ dx = shoulder.x - hip.x dy = shoulder.y - hip.y # atan2 返回弧度,转成角度 angle = math.degrees(math.atan2(abs(dy), abs(dx))) return angle

逻辑说明:dy是肩髋在垂直方向的差值,dx是水平方向差值。当人完全躺平时,肩和髋几乎在同一水平线上,dy接近 0,角度接近 0 度;当人坐起来时,肩明显高于髋,dy变大,角度接近 90 度。用abs()是为了兼容人面朝左或朝右两种情况,不需要区分方向。

参数说明:这里用的是归一化坐标,所以dx、dy都是 0~1 之间的小数,但atan2只关心比值,归一化不影响角度结果。如果你用的是像素坐标,结果一样。

3.2 状态机计数逻辑

有了角度,接下来要设计一个状态机,把连续的角度变化翻译成"一次有效动作"。最简单的做法是设两个阈值:角度小于 45 度算"躺下"状态,大于 90 度算"坐起"状态,从躺下到坐起再回到躺下,计一次数。

class SitupCounter: def __init__(self, down_thresh=45, up_thresh=90): self.down_thresh = down_thresh # 躺下阈值 self.up_thresh = up_thresh # 坐起阈值 self.state = "down" # 初始状态 self.count = 0 def update(self, angle): if self.state == "down" and angle > self.up_thresh: self.state = "up" elif self.state == "up" and angle < self.down_thresh: self.state = "down" self.count += 1 # 完整周期,计数加一 return self.count

逻辑说明:状态机只有两个状态,down表示当前处于躺下姿态,up表示当前处于坐起姿态。只有当角度从低于down_thresh上升到超过up_thresh,再从高于up_thresh回落到低于down_thresh,才完成一次计数。这样设计的好处是能过滤掉"半程动作"——如果只坐起一半就躺回去,角度没超过 90 度,状态不会切换,不计数。

参数说明:down_thresh=45和up_thresh=90是经验值。如果测试者柔韧性好、坐起幅度大,可以把up_thresh降到 80;如果测试者体型偏胖、坐起困难,可以降到 70。两个阈值之间要有足够间隔(至少 20 度),否则角度在阈值附近抖动会导致状态反复切换,出现"一个动作计好几次"的翻车情况。

3.3 完整可运行代码

把关键点提取、角度计算、状态机串起来:

import cv2 import mediapipe as mp import math mp_pose = mp.solutions.pose mp_draw = mp.solutions.drawing_utils pose = mp_pose.Pose( static_image_mode=False, model_complexity=1, smooth_landmarks=True, min_detection_confidence=0.5, min_tracking_confidence=0.5 ) class SitupCounter: def __init__(self, down_thresh=45, up_thresh=90): self.down_thresh = down_thresh self.up_thresh = up_thresh self.state = "down" self.count = 0 def update(self, angle): if self.state == "down" and angle > self.up_thresh: self.state = "up" elif self.state == "up" and angle < self.down_thresh: self.state = "down" self.count += 1 return self.count def calc_torso_angle(shoulder, hip): dx = shoulder.x - hip.x dy = shoulder.y - hip.y return math.degrees(math.atan2(abs(dy), abs(dx))) counter = SitupCounter() cap = cv2.VideoCapture(0) 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 shoulder = lm[mp_pose.PoseLandmark.LEFT_SHOULDER] hip = lm[mp_pose.PoseLandmark.LEFT_HIP] angle = calc_torso_angle(shoulder, hip) count = counter.update(angle) # 在画面上显示角度和计数 cv2.putText(frame, f"Angle: {angle:.1f}", (30, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.putText(frame, f"Count: {count}", (30, 100), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) mp_draw.draw_landmarks(frame, results.pose_landmarks, mp_pose.POSE_CONNECTIONS) cv2.imshow("Situp Counter", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

逻辑说明:每帧计算躯干角度,喂给状态机,状态机返回当前累计计数。画面上同时显示角度和计数,方便调试时观察角度变化是否符合预期。如果角度显示正常但计数不动,说明阈值设得不对;如果计数乱跳,说明关键点抖动严重,需要调平滑参数或换更稳定的拍摄角度。

参数说明:摄像头建议放在测试者正侧面,距离 2~3 米,确保肩、髋、膝、踝都能入镜。正面拍摄会导致肩髋在图像上重叠,角度计算失效。光线要均匀,避免逆光,否则 MediaPipe 容易丢失关键点。

4. 避坑与排查:仰卧起坐计数最常见的 5 个翻车现场

4.1 计数只加不减,但一个动作加了三四次

现象:做一次仰卧起坐,计数从 0 跳到 3 甚至 4。

原因:关键点在阈值附近抖动,角度在 45 度和 90 度之间反复穿越,状态机被反复触发。MediaPipe 的smooth_landmarks只能缓解,不能根除。

解决:在状态机里加一个"最小帧间隔"或"冷却时间"。比如每次计数后锁定 0.5 秒,期间不响应新的状态切换。或者把阈值间隔拉大,down_thresh=30、up_thresh=100,让抖动不容易穿越两个阈值。

4.2 躺着不动也在计数

现象:测试者躺在地上休息,计数自己往上涨。

原因:摄像头角度偏了,肩髋连线在图像上恰好处于某个角度区间,加上关键点抖动,状态机误判。

解决:加一个"运动检测"前置条件——只有关键点坐标在最近几帧内有明显变化时,才允许状态机更新。或者要求角度必须连续 N 帧超过阈值才切换状态,单帧穿越不算。

4.3 关键点频繁丢失,计数断断续续

现象:画面里人明明在动,但角度显示时有时无,计数漏掉好几次。

原因:光线不足、背景杂乱、衣服颜色和背景接近,都会导致 MediaPipe 检测置信度下降。

解决:先改善拍摄条件——正面补光、纯色背景、穿深色衣服。如果条件受限,把min_detection_confidence降到 0.3,同时把model_complexity升到 2 提高精度,代价是帧率下降。还可以在关键点丢失时保持上一帧的角度值,而不是直接跳过,避免状态机被重置。

4.4 不同体型的人用同一套阈值,计数偏差大

现象:同一个人计数准,换个人就不准了。

原因:down_thresh=45、up_thresh=90是针对标准体型调的。个子高的人躯干长,同样坐起角度变化更明显;柔韧性差的人坐起幅度小,角度到不了 90 度。

解决:在正式计数前加一个 3 秒校准阶段,让测试者躺平,记录此时的基准角度,再让测试者坐起一次,记录峰值角度,用这两个值动态设置阈值。常见做法是取基准角度加 20 度作为down_thresh,取峰值角度的 80% 作为up_thresh。

4.5 摄像头帧率不够,快速动作被漏计

现象:慢速做计数准,快速做会漏。

原因:普通 USB 摄像头在 640x480 下通常 30fps,但 MediaPipe 推理本身要耗时,实际处理帧率可能只有 15~20fps。如果一次仰卧起坐只用 1 秒,中间可能只采到十几帧,状态切换的瞬间可能被跳过。

解决:降低摄像头分辨率到 320x240 提高帧率,或者换用model_complexity=0的轻量模型。如果还是不够,可以在状态机里用"角度变化趋势"代替"绝对阈值"——只要角度从下降趋势转为上升趋势且幅度超过 30 度,就计一次,不要求必须穿越固定阈值。

5. 从能跑到好用:动态阈值校准与计数结果验证

上面那套固定阈值的代码能跑通,但离"好用"还有距离。我在实际测试里发现,最影响计数准确率的不是模型精度,而是阈值是否匹配当前测试者。所以最后一章讲一个具体技巧:用前两次动作自动校准阈值,再用"角度曲线"验证计数是否合理。

校准逻辑很简单:让测试者先做两次标准动作,程序记录这两次动作中的最低角度和最高角度,取最低角度加 15 度作为down_thresh,取最高角度减 15 度作为up_thresh。这样阈值就跟着人的实际动作幅度走,而不是硬编码。

class AutoCalibrateCounter: def __init__(self): self.angles = [] self.calibrated = False self.down_thresh = 45 self.up_thresh = 90 self.state = "down" self.count = 0 def calibrate(self, angle): """前 60 帧收集角度,自动设定阈值""" self.angles.append(angle) if len(self.angles) >= 60: lo = min(self.angles) hi = max(self.angles) # 留 15 度余量,避免阈值贴太近 self.down_thresh = lo + 15 self.up_thresh = hi - 15 self.calibrated = True print(f"校准完成: down={self.down_thresh:.1f}, " f"up={self.up_thresh:.1f}") def update(self, angle): if not self.calibrated: self.calibrate(angle) return 0 if self.state == "down" and angle > self.up_thresh: self.state = "up" elif self.state == "up" and angle < self.down_thresh: self.state = "down" self.count += 1 return self.count

逻辑说明:前 60 帧(约 2~3 秒)只收集角度不计数,用这段时间让测试者完成至少一次完整动作。min和max分别对应躺下和坐起的极值,各留 15 度余量是为了让阈值落在动作区间的中间位置,避免边界抖动。校准完成后自动切换到计数模式。

参数说明:60 帧是 30fps 下约 2 秒,如果测试者动作慢,可以加到 90 帧。15 度余量是经验值,动作幅度大的人可以加到 20 度,幅度小的人减到 10 度。校准阶段要在画面上给出提示,让测试者知道"现在在做校准动作,还不计数"。

验证计数是否合理,最直接的方法是看角度曲线。把每帧的角度值存下来,计数结束后用 matplotlib 画出来:

import matplotlib.pyplot as plt # angles 是计数过程中记录的角度列表 plt.plot(angles) plt.axhline(y=counter.up_thresh, color='r', linestyle='--', label='up') plt.axhline(y=counter.down_thresh, color='g', linestyle='--', label='down') plt.xlabel("Frame") plt.ylabel("Torso Angle (deg)") plt.legend() plt.show()

如果曲线是干净的"波峰波谷"形状,每个波峰对应一次坐起,计数就应该等于波峰数量。如果曲线毛刺很多,说明关键点抖动严重,需要回头调平滑参数或拍摄条件。如果波峰高度不一致,说明测试者动作幅度不稳定,动态阈值比固定阈值更合适。

我自己的习惯是:每次改完阈值或状态机逻辑,先录一段 30 秒的视频跑一遍,把角度曲线和计数结果对照着看。曲线骗不了人,计数对不对,看一眼波峰数量就清楚了。这套方法从原型到能用,大概花一个下午就能调稳,剩下的就是根据实际测试场景微调拍摄角度和光线。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进