拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于MediaPipe Pose的俯卧撑计数与姿势矫正系统实现

家里囤了张瑜伽垫刚开始练俯卧撑时我用手机记次数朋友在旁边用人眼帮我盯动作。后来发现两个问题数着数着就断了或者明明塌腰了自己还觉得挺标准。于是想着干脆自己写个工具用摄像头实时识别俯卧撑顺便帮我盯姿势有没有走样。调研了一圈最后选了Google的MediaPipe Pose配合OpenCV和Python做出了一个既能计数又能做基础动作质量评估的小系统。这个项目适合谁想用代码辅助力量训练的人带学生的健身教练还有想做一个有趣AI应用练手的开发者。MediaPipe的优势在于它足够轻CPU就能跑关键点输出稳定不用自己从头训模型。整套系统从思路到落地核心四块姿态关键点获取、俯卧撑动作建模与计数逻辑、姿势矫正规则、实时反馈与调试优化。下面把这四块拆开讲代码和阈值都给到你可以直接抄作业再按自己的身体参数微调。1. 项目背景与方案选型1.1 为什么选择MediaPipe做俯卧撑识别做俯卧撑识别本质上要解决两个问题人体姿态估计和动作阶段判断。人体姿态估计方向有几个选项OpenPose准确率高但重CPU上跑起来费劲一般人手里的笔记本风扇会起飞PoseNet在浏览器里好用但拿到Python里玩没MediaPipe方便直接用OpenCV做背景差分和轮廓分析干扰太大只能用在做“有没有人动”这种粗粒度判断没法精确拿到关节角度。MediaPipe Pose是Google开源的人体姿态估计方案输出33个关键点坐标landmark包含鼻子、肩膀、肘部、手腕、髋部、膝盖、脚踝等关键部位每帧还能给出每个点的可见度和置信度。对它做过对比测试在同一位居CPU的笔记本上分辨率设置成640x480时MediaPipe能跑到30FPS以上占用CPU在30%-50%之间OpenPose基本只有个位数帧率。对实时交互来说30FPS意味着你做完一个俯卧撑的下降、上升过程系统能捕捉到十几帧关键状态这足够支撑可靠的计数逻辑了。另一个原因是MediaPipe的跨平台属性Python、Android、iOS、Web全都有。我今天演示的是Python版本但同样的逻辑稍微改改就能搬到手机上做成App或者小程序扩展性很强。加上它自带的人体检测加姿态跟踪管线在单人场景下非常稳定不用额外处理多目标匹配这些麻烦事。1.2 系统能力边界明确一下系统能做什么输入是摄像头实时画面输出是三件事俯卧撑次数累计、当前动作阶段准备/下降/上升/完成、姿势异常提示塌腰、撅屁股、半程、手臂外展等。另外我会把每帧的关键点数据记录下来方便之后回看和分析。需要说明的是这套方案依赖单目摄像头最适合侧面视角。正面或斜向视角不是不能用但关节角度的计算误差会明显变大。系统默认假设人体基本侧对镜头这样“肩-肘-腕”构成的平面和成像平面平行角度测量最准。如果你想对着镜子练镜子里的画面会有镜像问题记得在代码里做水平翻转并且保持镜面干净光线均匀。还有一个边界MediaPipe是通用姿态估计模型不是专门为俯卧撑训练的。它的优势是泛化能力强但遇到极限动作或者大幅遮挡时关键点置信度会下降。所以系统里必须做置信度检查和状态机容错否则偶尔的某一帧误检会导致计数乱跳。后面第5节我会专门讲怎么处理。2. 姿态关键点映射与俯卧撑动作建模2.1 MediaPipe Pose的关键点输出跑起来之后每帧会得到results.pose_landmarks它是个NormalizedLandmarkList里面33个点每个点有x, y, z, visibility四个值。x和y是相对图像宽度和高度的归一化坐标范围0到1。z以臀部中心为原点的大致深度值单位不严格日常动作识别里基本用不上主要看x和y。visibility是一个0到1的置信度表示模型对这个点的可见程度取值在0.6以下基本不可信我们要拿它来过滤坏帧。33个点对应的索引在MediaPipe官方文档里有俯卧撑用得到的主要是这几个关键点名称索引作用左肩 / 右肩11 / 12判断躯干倾斜、手臂位置左肘 / 右肘13 / 14计算手臂弯曲角度左腕 / 右腕15 / 16支撑点位置左髋 / 右髋23 / 24判断躯干是否塌陷或者撅起左膝 / 右膝25 / 26判断腿部位置左踝 / 右踝27 / 28判断脚部支撑位置因为是侧面对着摄像头系统里取左右两侧中visibility更高的那侧来计算角度。这样无论你做左右手交替还是标准俯卧撑都能正确跟踪。2.2 俯卧撑关键角度定义有了三个关键点坐标就能计算关节角度。以肘部角度为例取肩-肘-腕三个点肘部是顶点问题就转化为求两个向量v1 shoulder - elbow和v2 wrist - elbow之间的夹角。余弦定理二维坐标直接用向量点积import numpy as np def calc_angle(a, b, c): 计算三点夹角b是顶点返回角度值 0~180 a np.array(a[:2]) # 只取x,y b np.array(b[:2]) c np.array(c[:2]) v1 a - b v2 c - b norm_v1 np.linalg.norm(v1) norm_v2 np.linalg.norm(v2) if norm_v1 0 or norm_v2 0: return 0.0 cos_theta np.dot(v1, v2) / (norm_v1 * norm_v2) cos_theta np.clip(cos_theta, -1.0, 1.0) angle np.degrees(np.arccos(cos_theta)) return angle俯卧撑过程里三个角度最有用肘关节角度由肩、肘、腕三个点算出。用来判断下降深度和上升完成度。标准俯卧撑下降到最低点时肘角大约在70度到90度之间撑起来时接近180度。髋关节角度由肩、髋、膝三个点算出。这个角度衡量躯干和腿部是否保持在一条直线上。正常平板姿态下这个角度接近180度处于160度到180度之间都算可接受。如果角度明显小于150度说明髋部向下塌就是俗称的“塌腰”如果明显大于180度髋部向上顶就是我们常说的“撅屁股”或者“臀部过高”。肩关节角度由肘、肩、髋三个点算出。用来检查手臂是否过度外展。做俯卧撑时上臂和躯干的夹角建议在20度到45度之间如果这个角度超过60度说明手肘往外张得太厉害对肩关节压力很大。这三个角度覆盖了俯卧撑质量评估最主要的信息手臂弯曲程度、躯干平板程度、肩胛稳定性。不用去看膝盖和脚踝也能应付大部分场景。2.3 动作状态机的设计计数不能只看“当前角度小于某个值”就加一次因为俯卧撑是一个动态过程如果只检测瞬时状态身体抖动一下或者关键点跳变就会引发误判。更稳的做法是用状态机。我设计了三个状态UP撑起、DOWN下降到底、WRONG错误姿态。状态转移逻辑初始状态是UP此时肘角大于等于设定阈值默认150度。当肘角持续降低小于等于下降阈值默认90度并且髋角正常进入DOWN状态。在DOWN状态下如果肘角开始回升持续大于等于上升阈值默认160度并且髋角正常判定完成一次计数加一状态回到UP。无论哪个状态如果连续若干帧检测到髋角异常切换到WRONG状态并发出姿势提醒。错误纠正后如果髋角恢复正常再回到正常状态继续计数。这种状态机的核心是“持续多少帧”的判定不是单帧生效不然噪声太大。我代码里用的frame_threshold 5也就是连续5帧满足角度条件才切换状态。用人的动作速度换算一下30FPS下5帧也就是约0.17秒不会感觉到延迟又能过滤掉短时间抖动。为什么要分下降阈值和上升阈值因为现实动作存在“滞后”和“弹性”你降到90度之后身体不会立刻回到160度中间会有个缓冲过程。如果下降上升用同一个阈值比如都用130度那肌肉在临界点轻微抖动就会来回触发。两个阈值形成滞回区间相当于给状态机加了一层物理“死区”稳定性立刻提升。3. 系统实现与核心代码3.1 环境搭建先交代环境。我用的版本是Python 3.9opencv-python 4.8mediapipe 0.10numpy 1.24安装就是一个命令的事情pip install opencv-python mediapipe numpy如果你还需要语音提示加一个pip install pyttsx3这里提醒一句MediaPipe版本更新比较快API在0.10.x系列中稳定后面如果出新大版本注意看一下Pose接口是否变化。遇到奇怪报错先排查依赖版本。3.2 核心流程代码先放一个完整的基础版本功能是实时读取摄像头绘制骨架计算肘角和髋角进行状态机计数输出文字提示。语音部分下一节加。import cv2 import mediapipe as mp import numpy as np mp_pose mp.solutions.pose pose mp_pose.Pose( static_image_modeFalse, model_complexity1, smooth_landmarksTrue, enable_segmentationFalse, min_detection_confidence0.6, min_tracking_confidence0.6, ) mp_draw mp.solutions.drawing_utils def calc_angle(a, b, c): a np.array(a[:2]) b np.array(b[:2]) c np.array(c[:2]) v1 a - b v2 c - b norm_v1 np.linalg.norm(v1) norm_v2 np.linalg.norm(v2) if norm_v1 0 or norm_v2 0: return 0.0 cos_theta np.dot(v1, v2) / (norm_v1 * norm_v2) cos_theta np.clip(cos_theta, -1.0, 1.0) return np.degrees(np.arccos(cos_theta)) def get_side_landmarks(landmarks): 根据可见度挑选更可靠的左右侧关键点 left_conf landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER.value].visibility right_conf landmarks[mp_pose.PoseLandmark.RIGHT_SHOULDER.value].visibility if left_conf right_conf: return landmarks[11:24] # 这是左半边的索引范围 else: # 右半边的索引需要单独取 ids [12, 14, 16, 24, 26, 28] return [(landmarks[i].x, landmarks[i].y, landmarks[i].z, landmarks[i].visibility) for i in ids] # 实际使用时更简单的方式是直接取可见度高的单个点 def pick_visible(landmarks, left_idx, right_idx): left landmarks[left_idx] right landmarks[right_idx] return left if left.visibility right.visibility else right # 状态机参数 UP_THRESHOLD 150 DOWN_THRESHOLD 90 HIP_LOW 150 HIP_HIGH 180 FRAME_THRESHOLD 5 state UP frame_count 0 pushup_count 0 warning_msg cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30) while cap.isOpened(): ret, frame cap.read() if not ret: break # 如果摄像头画面是镜像的可以这样翻转 # frame cv2.flip(frame, 1) rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results pose.process(rgb) if results.pose_landmarks: lm results.pose_landmarks.landmark # 选择可见度更高的左右点 shoulder pick_visible(lm, mp_pose.PoseLandmark.LEFT_SHOULDER.value, mp_pose.PoseLandmark.RIGHT_SHOULDER.value) elbow pick_visible(lm, mp_pose.PoseLandmark.LEFT_ELBOW.value, mp_pose.PoseLandmark.RIGHT_ELBOW.value) wrist pick_visible(lm, mp_pose.PoseLandmark.LEFT_WRIST.value, mp_pose.PoseLandmark.RIGHT_WRIST.value) hip pick_visible(lm, mp_pose.PoseLandmark.LEFT_HIP.value, mp_pose.PoseLandmark.RIGHT_HIP.value) knee pick_visible(lm, mp_pose.PoseLandmark.LEFT_KNEE.value, mp_pose.PoseLandmark.RIGHT_KNEE.value) elbow_angle calc_angle(shoulder, elbow, wrist) hip_angle calc_angle(shoulder, hip, knee) # 状态机 hip_ok HIP_LOW hip_angle HIP_HIGH if not hip_ok: frame_count 1 if frame_count FRAME_THRESHOLD: state WRONG if hip_angle HIP_LOW: warning_msg 塌腰了收紧核心 elif hip_angle HIP_HIGH: warning_msg 臀部抬太高了保持躯干一条直线 else: if state UP: if elbow_angle DOWN_THRESHOLD: frame_count 1 if frame_count FRAME_THRESHOLD: state DOWN frame_count 0 else: frame_count 0 elif state DOWN: if elbow_angle UP_THRESHOLD: frame_count 1 if frame_count FRAME_THRESHOLD: pushup_count 1 state UP frame_count 0 else: frame_count 0 elif state WRONG: # 姿态恢复后重置状态 frame_count 1 if frame_count FRAME_THRESHOLD: state UP warning_msg frame_count 0 # 绘制关键点和连线 mp_draw.draw_landmarks( frame, results.pose_landmarks, mp_pose.POSE_CONNECTIONS, mp_draw.DrawingSpec(color(0, 255, 0), thickness2, circle_radius2), mp_draw.DrawingSpec(color(255, 0, 0), thickness2)) # 在画面左上角显示数据 cv2.putText(frame, fCount: {pushup_count}, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.putText(frame, fElbow: {int(elbow_angle)}, (20, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2) cv2.putText(frame, fHip: {int(hip_angle)}, (20, 110), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2) cv2.putText(frame, fState: {state}, (20, 140), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2) if warning_msg: cv2.putText(frame, warning_msg, (20, 180), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) cv2.imshow(Pushup Counter, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码可以直接跑起来。里面有个细节我在状态机切换时用了frame_count分别对错误姿态计数和正常状态计数避免一边计数一边报错互相干扰。另外我故意把pose对象放在循环外初始化这样不会每帧重新加载模型否则性能会惨不忍睹。3.3 关键参数调优跑通之后最影响使用体验的是几个阈值。肘角下降阈值DOWN_THRESHOLD默认90度适合标准俯卧撑。但如果你力量不够下不到那么深或者膝盖着地做了简化版可以放宽到100度。反之追求深度刺激可以收紧到80度。需要注意的是阈值越严系统判定越保守越不容易误计但可能会漏计。上升阈值UP_THRESHOLD默认150度实际撑起来的动作肘角一般在160-175度之间。我建议150到155之间因为这个区间正好避开了肌肉的半程位置能有效防止“偷懒俯卧撑”被算作完整一次。如果你就是做半程训练可以把这个阈值调到130度。髋角上下限HIP_LOW和HIP_HIGH采用的是平板支撑的标准范围。但每个人关节活动度不一样腰椎前凸程度不同建议先用一段自己的标准动作录像跑一遍记录正常状态下髋角是多少再设成正常范围 ± 15度。这点很重要因为有人天生髋角就偏大强行用180度判断反而会误报。帧数阈值FRAME_THRESHOLD5是个均衡值。太快容易受噪声影响太慢会有大约0.3秒以上的延迟动作做完要愣一下才计数体验不好。另外摄像头分辨率不要贪高。640x480在30FPS下CPU占用还能接受如果硬上1280x720MediaPipe的推理时间会翻倍画质提升对关键点准确度帮助有限实际测试下来640x480的关键点位置已经足够稳。所以我建议优先保证帧率而不是分辨率。4. 姿势矫正的规则引擎与提醒策略4.1 常见错误姿势检测计数只是第一步姿势矫正才是这套系统的价值点。我整理了四个常见错误每个都能用角度规则直接判断错误类型关键几何特征判定规则建议阈值的含义塌腰髋关节角度明显小于正常平板直线髋角 HIP_LOW躯干核心没有收紧腰部下坠撅屁股髋关节角度明显大于正常平板直线髋角 HIP_HIGH臀部抬太高躯干和腿不成直线半程俯卧撑下降深度不够就开始上升肘角没有降到DOWN_THRESHOLD就回升手臂没有弯曲到位手臂外展过大肩关节角度过大手肘远离躯干肩角 60度上臂和躯干夹角太大伤肩隐患前两种用髋角判断第三种本质上已经被状态机覆盖了因为不降到阈值就不会从UP切到DOWN也就不会计数。第四种需要额外计算肩关节角度逻辑类似只是要监控的点变成了肩、肘、髋。我建议在状态机之外独立跑一个检测线程或者每帧检测一次因为手臂外展可能会在上升、下降阶段都存在如果只在特定状态才判断会漏报。除了角度判断还可以结合visibility做合理性检查。比如手腕的位置置信度很低说明手部被遮挡这时候关于手臂角度的判断都不可靠应当暂时屏蔽错误提示否则容易误报。4.2 矫正提示与语音反馈视觉文字提示在训练时其实很难注意到因为你眼睛得盯着地面或者前方不可能一直看屏幕。所以更实用的方式是语音提醒。用一个轻量级库pyttsx3就行它调用系统TTS不需要联网。import pyttsx3 engine pyttsx3.init() engine.setProperty(rate, 160) def speak(text): engine.say(text) engine.runAndWait()调用时机要控制好不能每帧都触发否则语音会连成一片。我建议同一个错误提示只播报一次直到状态恢复正常后再重置。比如检测到塌腰立刻语音“塌腰了收紧核心”然后设置一个标记在接下来3秒内不再播报同样的内容否则你整个训练过程会不停被打断。更进阶一点可以用edge-tts生成更自然的MP3声音然后播放。这需要异步处理对实时性要求不高的场景也可以。我自己的体验是pyttsx3已经足够因为提示词就那几句机械音反而更容易让大脑条件反射。4.3 实时展示与数据记录长期训练需要数据积累。我习惯把每次训练的计数、错误次数、平均髋角、平均肘角记录到CSV文件里。实现很简单每次状态机完成一次完整动作时把这段时间内的关键点数据汇总追加写入文件。import csv from datetime import datetime row { timestamp: datetime.now().isoformat(), count: pushup_count, avg_elbow_angle: round(np.mean(elbow_history), 2), avg_hip_angle: round(np.mean(hip_history), 2), wrong_posture_count: wrong_count, } with open(pushup_log.csv, a, newline) as f: writer csv.DictWriter(f, fieldnamesrow.keys()) writer.writerow(row)elbow_history和hip_history在每帧计算时append动作完成时用np.mean取平均值然后清空列表。这些数据后面可以用Excel或pandas做趋势分析看看每周训练的质量是不是在提升。另外画面显示上我会把状态颜色做区分正常状态显示绿色错误状态显示红色。姿态错误时在对应的关节点画一个明显的圆圈标记提示。比如塌腰时在髋关节画一个红圈这样即使不看文字也能通过颜色快速感知问题。5. 常见问题与调试心得5.1 Landmark抖动问题实际跑起来第一个遇到的问题就是关键点坐标会抖动。原因有很多摄像头噪声、图像压缩、模型推理本身的不确定性。抖动直接导致角度波动比如静止时肘角可能在5度范围内来回跳如果阈值卡得太死状态机就会在临界点反复横跳。解决方法有两个方向。第一个是降低角度敏感度也就是拉大滞回区间这我在状态机设计里已经提到了。第二个是对角度做平滑。我推荐用指数移动平均EMA代码极其简单def ema(new_value, old_value, alpha0.3): return alpha * new_value (1 - alpha) * old_value开始时old_value取第一帧的角度之后每帧更新。alpha越小平滑程度越高但反应越迟钝。实测下来alpha0.3比较合适既不会让动作看起来延迟又能滤掉大部分高频抖动。注意平滑的对象最好是角度值而不是原始坐标因为对坐标平滑之后重新计算角度会在运动的拐点处出现角度过冲反而更不可靠。5.2 摄像头角度与光线影响这是最容易踩坑的地方。我一开始把摄像头放在正面结果显示髋角一直在170到185之间波动怎么调阈值都容易误报。换到侧面且摄像头高度基本与胸部平齐之后角度计算稳定多了。原因是正面角度下肩、肘、腕在成像平面上的投影关系和实际三维空间中的角度差异很大余弦定理的前提是三者共面而在正面视角下这个条件被破坏得很严重。光线也很关键。焦距和帧率不变的情况下光线暗会导致图像噪声增加MediaPipe的landmark置信度明显下降。我的建议是训练区域保证足够的顺光尽量避开顶光造成的面部阴影。如果你晚上练至少得开一盏灯亮度能保证画面里人物轮廓清晰区分开。另外测试时我发现极小概率会出现关键点闪烁就是某一个点突然跑到画面另一侧去。这时候visibility会骤降所以我在计算角度前会检查所有参与计算的点的visibility是否大于0.5如果小于0.5就直接跳过该帧的状态更新宁可少计数也不乱计数。5.3 距离适配与人体检测不到MediaPipe对目标大小有偏好。你离摄像头太远整个人很小关键点间距只有几个像素角度计算误差会被放大数倍离太近手脚容易出画检测也会失败。经验值人体在画面中的高度占整幅画面的50%到70%效果最好。也就是说640x480的画面里你的头部到脚底大概占据320到340像素。如果出现检测不到先别急着改模型参数。调整摄像头位置确保全身从头到脚都在画面内。然后看results.pose_landmarks是不是空值。如果一直空再调min_detection_confidence从0.6降到0.4牺牲一些精度换取检测率。min_tracking_confidence也可以降低到0.5帮助短时遮挡后的重新跟踪。还有一点容易被忽略摄像头自动白平衡和自动曝光会在环境变化时突变导致画面忽明忽暗影响检测稳定性。用OpenCV的CAP_PROP_AUTO_WB和CAP_PROP_AUTO_EXPOSURE关闭自动调节固定ISO和快门速度。具体参数取决于摄像头型号可以先用默认值跑通等遇到画面闪烁再处理。5.4 性能优化如果你的电脑性能一般跑起来掉帧优先做三件事。第一把model_complexity从1降到0模型精度会下降一点但推理速度能提升30%以上对角度计算的影响可以通过平滑来部分弥补。第二把摄像头采集分辨率降为480x360分析图像缩小计算量直线下降。第三不要在一帧里重复调用process方法确保主循环里只推理一次。如果你有更高追求可以用cv2.VideoCapture配合threading做异步读取把图像采集和姿态推理放到不同线程避免I/O等待拖慢帧率。更极端的做法是使用MediaPipe的GPU后端但配置起来略繁琐对多数人来说没有必要。性能之外还有一个逻辑层面的坑你在状态机判断时使用了全局变量state和frame_count如果后期加入多视角或多用户就必须为每个目标单独保存一份状态变量否则不同人的动作会串扰。单用户场景下没问题但代码要预留扩展位置。写在最后的小建议这套系统我从能跑通到稳定使用中间调了大概两周。最大的感受是技术上的难点不是调用MediaPipe而是如何把动作语义转成可靠的状态判断。角度阈值没有绝对正确的值它跟你的训练目标、力量水平、身体结构都相关。我建议你在正式使用前先录一段自己认为最标准的动作视频回放时打印出每个关键角度的变化范围再根据这个范围设定阈值。所谓“标准”也是相对的你只需要让系统符合你自己的训练节奏。如果你想让系统更聪明下一步可以尝试用MediaPipe的输出序列训练一个简单的LSTM分类器对“标准、塌腰、撅屁股、半程”做端到端分类替代手工规则。这又是一个新项目了但底层的数据采集和角度计算今天这套代码已经给你打好了地基。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门