做健身动作计数很多人第一反应是“这不就是摄像头对着人然后程序数一下次数吗”。真上手之后才发现难点根本不在“数数”而在于怎么稳定地判断一个动作是否标准地完成了一次。健身房的摄像头角度乱七八糟、背景人来人往、灯光忽明忽暗再加上动作本身有快有慢稍微处理不好计数就会乱跳。我这次用YOLO系列姿态估计模型做了一个能识别俯卧撑和引体向上的计数系统从模型选型、动作判定逻辑到人机交互走完了一整套流程。这篇文章把整个思路和实操细节拆开讲包含可直接复用的代码逻辑、调参心得和踩坑记录适合有Python基础、想用姿态估计做运动分析类项目的开发者参考。1. 思路拆解为什么用姿态估计来做健身计数1.1 动作分类计数和姿态估计计数完全是两回事如果用“动作分类”的思路本质是训练一个视频分类器输入连续N帧输出“俯卧撑一次”或“不是俯卧撑”。这种方案最大的问题是能判断“这是什么动作”但很难判断“这个动作是否完成了一次”。比如人有做半程俯卧撑的习惯模型可能每次都输出“俯卧撑”计数就失控了。更麻烦的是分类模型对场景非常敏感换个背景、换个衣服效果断崖式下跌。姿态估计的方案则完全不同它先定位人体骨架把肩、肘、腕、髋、膝、踝这些关键点的坐标提取出来然后我自己定义“什么算完成一次”的几何规则。这相当于把“认动作”和“判标准”两步彻底分开每一步都可以单独调试和优化。比如想区分“半程俯卧撑”和“全程俯卧撑”只需修改一个肘关节角度的阈值不需要重新训练模型。这种解耦带来的灵活性是纯分类方案给不了的。1.2 为什么选YOLO系列而不选OpenPose或MediaPipe市面上成熟的人体姿态估计方案不少我在选型时重点比较了三类MediaPipe、OpenPose、AlphaPose和YOLO系列。MediaPipe上手最快移动端优化好但它的关键点稳定性在剧烈运动场景下不够俯卧撑下压过程容易出现肢体抖动对后续角度计算非常不利。OpenPose精度高但速度慢配置繁琐而且它对单人场景的优化不如现代端到端方案。AlphaPose精度不错但工程化封装相对复杂。相比之下YOLOv8-Pose这类模型在精度和速度上做到了很好的平衡关键是Ultralytics把它打包得非常干净几行代码就能完成推理适合快速搭建原型。后期如果要部署到嵌入式设备YOLO的生态也有现成的TensorRT、ONNX导出工具链迁移成本低。所以最终选型定为YOLOv8-Pose这也是目前做实时姿态分析项目比较稳妥的起点。1.3 系统整体架构整个计数系统的数据流是这样走的视频帧摄像头/视频文件传入姿态估计模型模型输出每个人体的检测框和17个关键点坐标关键点经过角度计算和几何逻辑判断进入状态机做动作识别最后更新计数器并叠加可视化结果。这个链路里最核心的其实不是模型本身而是状态机和动作判定规则。模型负责“看”状态机负责“懂”。我在调试过程中发现很多人项目失败并不是模型不好而是动作判定逻辑太粗糙一个俯卧撑被重复计数或者引体向上卡在中间不算数。所以后半篇文章会花大篇幅讲清楚这部分怎么设计。2. 核心原理关键点定义与动作判定算法2.1 YOLOv8姿态模型的输出到底是什么YOLOv8-Pose的输出包含两部分人体检测框和关键点。检测框就是目标检测的常规输出关键是每个目标附带的关键点坐标。这里用的是COCO数据集的17点定义具体是0-鼻子1-左眼2-右眼3-左耳4-右耳5-左肩6-右肩7-左肘8-右肘9-左腕10-右腕11-左髋12-右髋13-左膝14-右膝15-左踝16-右踝。注意这17个点没有包含诸如“下巴”“胸口”这种对健身很关键的位置这就要靠组合现有关键点来推导。比如引体向上判断下巴是否过杠我没有下巴点就用鼻子或头部关键点来近似。所以理解COCO关键点定义是做后续动作逻辑的基础。每个关键点还有一个置信度分数在遮挡场景下这个分数特别有用低于阈值的关键点应该直接丢弃而不是参与角度计算。2.2 俯卧撑计数逻辑三个关键角度和一条直线俯卧撑动作的特点是身体保持一条直线肘部弯曲下压胸口接近地面然后撑起。用几何语言描述就是肩、肘、腕三点的夹角在“伸直”和“弯曲”两个状态间切换同时髋、膝、踝三点应该维持接近直线。我在实际实现中只用了一个最核心的指标肘关节角度。定义是肩-肘-腕三点形成的夹角当身体撑起时角度接近180度当身体下压到最低点时角度小于90度。但是只靠一个角度容易误判比如手臂没动、只是屁股撅起来肘关节角度也可能变化。所以我又加了一个辅助条件髋、膝、踝三点是否基本在一条直线上。如果身体明显弯曲说明动作不标准这个状态不参与计数。具体判定逻辑是这样的肘关节角度大于160度时认为处于“撑起态”小于90度时认为处于“下压态”。状态机从“撑起态”进入“下压态”再回到“撑起态”才算完整的一次俯卧撑。两个状态直接切换不算必须在两个状态之间走完一个循环。2.3 引体向上计数逻辑下巴过杠与肘部角度引体向上的判定比俯卧撑复杂因为它的关键参照物是单杠而姿态模型不认识单杠。我用的方法是用身体本身的几何关系来近似引体向上时双手握住单杠固定不动下巴从杠下方上升到杠上方。一个常见做法是用鼻子或头部中心点和手腕的纵向位置关系来判断。当鼻子高度超过手腕高度一定阈值时认为“下巴过杠”。但这里有个问题如果人身体后仰较多鼻子可能还没过杠但高度已经超过手腕了。所以更稳的是结合肘关节角度引体向上的起始状态是手臂伸直肘关节接近180度向上拉的过程中肘关节弯曲到小于90度同时鼻子超过手腕。只有当“肘部弯曲”和“下巴过杠”两个条件同时满足才判定为一次完整动作的顶部。由于单杠高度固定人在杠下悬垂时如果摄像头固定手腕位置也相对固定这就简化了计算。我在代码里写的是相对逻辑不依赖单杠的绝对坐标这样即使摄像头角度略有偏移也能正常工作。2.4 状态机设计防重复计数和防抖动动作计数最容易出的问题就是“一个动作数了两次”或者“没做但计数跳了一下”。其根本原因是角度数据在阈值附近反复波动导致状态频繁切换。所以我引入了有限状态机每个动作有三个状态初始化、A态下压或下拉、B态撑起或引体完成。状态转换规则是严格单向的从A态必须转换到B态从B态必须再转换回A态每次A到B的转换才计数值加一。同时给状态切换加了滞回区间也就是进入和退出同一个状态的角度阈值不同。比如俯卧撑里从下压态回到撑起态需要肘关节角度大于160度但下次进入下压态需要角度小于100度中间这60度就是缓冲区间能有效防止临界值抖动带来的误判。经验数据是俯卧撑用160度和100度比较稳引体向上用160度和90度比较稳。具体阈值还要结合摄像头角度和你自己的动作幅度微调。3. 实操过程从环境搭建到计数系统落地3.1 环境准备与模型获取我用的是Python 3.9 PyTorch 2.0 Ultralytics 8.0组合硬件平台是一块消费级显卡整体运行起来毫无压力。实际上如果不追求极致速度CPU上用ONNX推理也能跑就是帧率低一些。安装过程很简单pip install ultralytics pip install opencv-python模型权重第一次运行时会自动下载也可以通过下面代码手动指定from ultralytics import YOLO # 官方预训练姿态估计模型 model YOLO(yolov8n-pose.pt)这里我用的是nano版本因为计数逻辑对实时性要求高精度也完全够用。如果想追求更高精度可以换yolov8s-pose.pt或m但帧率会有明显下降。我的建议是从nano开始调通整个流程然后再根据实际效果决定是否升级模型尺寸。3.2 摄像头输入与关键点提取系统的主循环比较简单OpenCV读取视频帧YOLOv8-Pose推断返回结果包含boxes检测框和keypoints关键点。我顺手封装了一个关键点提取函数方便后续调用import cv2 from ultralytics import YOLO model YOLO(yolov8n-pose.pt) cap cv2.VideoCapture(0) # 0是自带摄像头也可以是视频文件路径 def get_skeleton(result, person_idx0): 从YOLO结果中提取指定人的关键点坐标和置信度。 返回一个17x3的numpy数组每行是[x, y, conf] if result.keypoints is None or len(result.keypoints.data) 0: return None person_data result.keypoints.data[person_idx] # 取第一个人 return person_data.cpu().numpy()这里有一个很重要的点person_idx取0只代表当前帧里看到的第一个检测目标如果画面中有多个人需要用某种策略锁定目标人否则模型会把不同人关键点交叉使用。我的做法是先检测最大面积的人体框用检测框宽高乘积作为目标人选择标准这个方法简单且稳定。def get_main_person(result): 挑选画面中面积最大的人体作为跟踪目标 if result.boxes is None or len(result.boxes) 0: return None boxes result.boxes.xyxy.cpu().numpy() max_area -1 max_idx 0 for i, box in enumerate(boxes): w box[2] - box[0] h box[3] - box[1] area w * h if area max_area: max_area area max_idx i return max_idx用面积最大的人作为目标好处是逻辑简单不需要引入跟踪网络。缺点是两个人靠近时可能跳变但如果场景是自己在家锻炼这个方案完全够用。3.3 角度计算函数实现角度计算是整个几何逻辑的基础。我给肩、肘、腕这样的三点组合写了一个通用函数。import numpy as np def calculate_angle(a, b, c): 计算三点形成的角度abcb为顶点 返回角度值单位是度 a np.array(a[:2]) # 只取x,y做平面角度计算 b np.array(b[:2]) c np.array(c[:2]) 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 np.degrees(np.arccos(cos_angle)) return angle这里我保留了原本关键点数组的前两个值x和y没有用z坐标因为单目摄像头下z方向本身的可靠性不高。如果后续要做更精确的三维角度分析才需要考虑深度信息。俯卧撑用的关键点是左肩5、左肘7、左腕9对应计算函数就是calculate_angle(shoulder, elbow, wrist)。身体直线度的判断用左髋11、左膝13、左踝15的角度如果这个角度偏离180度太多说明腰塌了或者屁股撅起来了。3.4 俯卧撑状态机实现下面是完整的俯卧撑计数函数我把上面所有逻辑都整合了进去class PushUpCounter: def __init__(self): self.state INIT # 初始状态 self.count 0 # 完成次数 def update(self, skeleton): 输入17x3关键点数组输出当前状态和总计数 # 提取关键点注意keypoint缺失时给予默认值-1后续会跳过 left_shoulder skeleton[5][:2] left_elbow skeleton[7][:2] left_wrist skeleton[9][:2] left_hip skeleton[11][:2] left_knee skeleton[13][:2] left_ankle skeleton[15][:2] # 先判断关键点置信度是否足够 if skeleton[7][2] 0.5 or skeleton[9][2] 0.5 or skeleton[5][2] 0.5: return self.state, self.count elbow_angle calculate_angle(left_shoulder, left_elbow, left_wrist) body_angle calculate_angle(left_hip, left_knee, left_ankle) # 如果身体弯曲过大动作不标准不参与状态切换 if abs(body_angle - 180) 20: return self.state, self.count changed False # 状态机逻辑 if self.state INIT: if elbow_angle 160: self.state UP elif self.state UP: if elbow_angle 100: self.state DOWN elif self.state DOWN: if elbow_angle 160: self.state UP self.count 1 # 只有从DOWN回到UP才算一次完整动作 return self.state, self.count这里有一个细节要说明body_angle的理想值是180度也就是髋、膝、踝成一条直线但如果用这个函数计算角度值会接近180。我判断标准是偏离不超过20度也就是160度到200度之间都算身体伸直。阈值设定太严会导致误判太松又会把不标准动作计入20度是我在实测中比较折中的一个值。3.5 引体向上状态机实现引体向上的状态机与俯卧撑类似但判定条件变成了“鼻子过腕”和“肘关节弯曲”两个条件同时满足。class PullUpCounter: def __init__(self): self.state INIT self.count 0 def update(self, skeleton): 输入17x3关键点数组输出当前状态和总计数 # 鼻子、左腕、左肘、右肘等 nose skeleton[0][:2] left_wrist skeleton[9][:2] right_wrist skeleton[10][:2] left_elbow skeleton[7][:2] right_elbow skeleton[8][:2] # 用两只手的平均高度作为单杠近似高度 bar_y (left_wrist[1] right_wrist[1]) / 2 # 计算两边肘关节角度的平均值 left_elbow_angle calculate_angle(skeleton[5][:2], skeleton[7][:2], skeleton[9][:2]) right_elbow_angle calculate_angle(skeleton[6][:2], skeleton[8][:2], skeleton[10][:2]) avg_elbow_angle (left_elbow_angle right_elbow_angle) / 2 # 判断鼻子是否高于杠注意图像坐标系y向下所以小于bar_y表示更高 chin_over_bar nose[1] bar_y - 10 # 状态机 if self.state INIT: if avg_elbow_angle 160 and not chin_over_bar: self.state HANG elif self.state HANG: if avg_elbow_angle 90 or chin_over_bar: self.state PULL elif self.state PULL: if avg_elbow_angle 160 and not chin_over_bar: self.state HANG self.count 1 return self.state, self.count这里我把bar_y用两手腕的平均高度近似因为引体向上时双手固定握杠手腕位置基本等同于单杠位置。实际测试中这个近似的稳定性很好。不过需要说明的是这只是针对单杠在画面中相对固定、人垂直上拉的场景。如果是高难度动作比如双力臂手腕位置会明显移动这种简化就不适用了。在判断“下巴过杠”时我用了鼻子位置因为COCO关键点里没有下巴。鼻子到下巴的距离在解剖学上大约是10到15厘米反映到图像上大约就是10到20个像素所以我在代码里加了10像素的余量。这个值不是固定的需要根据摄像头距离人物远近调整。摄像头距离人越远这个余量就应该越小。3.6 可视化与实时反馈计数系统不能只输出一个数字否则用户做动作的时候根本不知道当前状态是什么。我在画面上覆盖了骨架、关键点、角度值和计数信息效果更直观。def draw_skeleton(frame, skeleton, state, count): # 画关键点 for i, point in enumerate(skeleton): x, y, conf point if conf 0.5: cv2.circle(frame, (int(x), int(y)), 4, (0, 255, 0), -1) # 画骨架连线 connections [(5,7), (7,9), (6,8), (8,10), (5,6), (5,11), (6,12), (11,13), (13,15), (12,14), (14,16)] for conn in connections: p1 skeleton[conn[0]] p2 skeleton[conn[1]] if p1[2] 0.5 and p2[2] 0.5: cv2.line(frame, (int(p1[0]), int(p1[1])), (int(p2[0]), int(p2[1])), (255, 0, 0), 2) # 显示状态和计数 cv2.putText(frame, fState: {state}, (30, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) cv2.putText(frame, fCount: {count}, (30, 80), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)实际运行时我还会在画面角落显示当前的肘关节角度数值这对调参非常有帮助。调试阶段可以直接把这个角度打印出来观察它在动作过程中的变化范围然后据此设定状态机的阈值而不是凭空拍一个数字。3.7 视频文件处理与离线测试实时摄像头调试比较麻烦因为你不能让动作重复很多次找问题。前期我建议先用视频文件测试。我在代码里保留了视频文件入口录像后反复分析逻辑。用一段30秒的自己做俯卧撑的视频循环播放几十次就能发现状态机里的各种边界问题比实时调试效率高得多。cap cv2.VideoCapture(test_pushup.mp4) while cap.isOpened(): ret, frame cap.read() if not ret: break results model(frame, verboseFalse) main_idx get_main_person(results[0]) skeleton get_skeleton(results[0], main_idx) ...这段代码就是在线和离线测试的通用框架。我强烈建议所有开发者在正式上线前先准备至少3段录像一段标准动作、一段快速动作、一段动作不标准的视频用这三类数据跑通逻辑能过滤掉大部分问题。4. 常见问题与调参心得4.1 计数总是跳两次或漏一次问题出在哪这类问题90%出在状态机的阈值区间上。我一开始用单阈值比如俯卧撑肘关节小于90度算下压大于90度算撑起。结果动作稍微快一点角度在90度附近来回抖动状态就跟着乱跳一次动作可能被计成两次。解决办法就是我在前面提到的滞回区间把进入下压态的阈值降到100度回到撑起态的阈值设到160度。这相当于给状态机加了一个“记忆”只有动作幅度足够大才能切换状态小小的抖动根本不会导致状态变化。如果还是误触发可以把区间进一步拉大比如90到170或者引入连续确认帧数比如连续5帧满足条件才切换状态。这两个方法都是实践中验证有效的。另外一个隐蔽的问题是动作速度过快时一个动作完成可能只用了10帧状态机还没反应过来就已经过了点。这种情况下需要降低帧率或者增加缓冲我一般会提前预测关键点位置用前后帧的角度变化来判断更快的趋势。不过这就属于进阶玩法了基础版本先保证稳妥更重要。4.2 引体向上判定不稳定鼻子过杠判断飘这个问题的核心是鼻子和杠的相对位置。如果摄像头在侧面鼻子位置正好和杠重叠判断就会非常不稳定。我的经验是引体向上的测试最好用侧后方45度视角这样鼻子和杠在画面里有足够距离判断稳定得多。另外手腕位置作为杠的参考也有隐患当人从悬垂到拉起的过程中手腕会有微小的旋转位移虽然整体不大但局部抖动会影响bar_y的精度。我的处理方案是对bar_y做一个滑动平均滤波用最近5帧的平均值作为当前杠的位置估计能有效平滑抖动。from collections import deque bar_history deque(maxlen5) def update_bar_y(left_wrist, right_wrist): current_bar_y (left_wrist[1] right_wrist[1]) / 2 bar_history.append(current_bar_y) return sum(bar_history) / len(bar_history)这种方法简单有效非常推荐。4.3 画面里有其他人乱入目标跟丢了我前面提到用面积最大的人体框来选目标但两个人靠近时还是会互相干扰。更稳的方案是再加一个简单的IOU跟踪记录上一帧目标人的检测框下一帧在所有检测框中找一个和上一帧框重叠度最大的框作为同一个人。def iou(box1, box2): x1_min, y1_min, x1_max, y1_max box1 x2_min, y2_min, x2_max, y2_max box2 inter_w max(0, min(x1_max, x2_max) - max(x1_min, x2_min)) inter_h max(0, min(y1_max, y2_max) - max(y1_min, y2_min)) inter_area inter_w * inter_h area1 (x1_max - x1_min) * (y1_max - y1_min) area2 (x2_max - x2_min) * (y2_max - y2_min) return inter_area / (area1 area2 - inter_area 1e-6)我实践下来这个方法顺手且不需要额外依赖能解决绝大多数多人场景的跟踪问题。当你再进一步画面里出现遮挡、人快速移动导致检测框短暂丢失时再考虑引入专门的跟踪算法比如ByteTrack但对健身计数场景来说IOU跟踪已经足够。4.4 推理速度慢帧率上不去到了性能优化阶段一般有几个标准方案。最简单的就是降分辨率把输入图片从原始尺寸缩放到416或320YOLO系列模型本身对低分辨率有一定容忍度尤其是姿态估计任务关键点之间的相对关系不会因为分辨率降低而完全失效只要人形清晰可辨缩小40%都不影响判断。更进一步就是模型导出为ONNX或TensorRT格式。Ultralytics官方提供了导出接口yolo export modelyolov8n-pose.pt formattensorrt halfTrue dynamicFalse simplifyTrue导出后用TensorRT推理运行速度能提升2到3倍在消费级显卡上跑到60帧基本无压力。需要注意的是TensorRT导出后模型对输入尺寸有固定要求运行时需要用letterbox来预处理图片。还有一个比较容易忽略的问题是OpenCV的读取速度。摄像头读取默认可能使用CPU解码如果分辨率太高CPU占用高会拖慢整体速度。调低摄像头分辨率比如从1080p降到720p读取延迟能明显降低对姿态估计来说720p足够得出靠谱的关键点了。4.5 动作标准的判定争议电脑判断得对吗很多人会问这系统判断的“标准”到底怎么定义的。其实我这里的“标准”只是几何意义上的标准俯卧撑要求身体接近直线、肘部下压到足够角度引体向上要求下巴过杠、手臂基本伸直。这些规则来自日常健身的基本要求但每个人的身体结构不同比如肩关节活动度小的人俯卧撑下压角度天然就到不了90度只能到100度。所以我在代码里做了可配置化处理把角度阈值、身体偏离角都提取成全局变量。发布给别人用的时候使用者可以根据自己情况调整。在实际操作中我建议给自己测试时先做一次10个标准动作记录所有关键角度的最大和最小值然后根据数据来设定阈值这样得到的结果才符合自己的身体特点。5. 项目扩展方向这部分算是一个经验补充项目做完后我发现这套框架很容易迁移到别的动作上。比如深蹲计数逻辑和俯卧撑类似用髋、膝、踝的角度变化就可以判断只是状态机的阈值不同。仰卧起坐则用躯干角度和头部位置变化来判定。YOLOv8的姿态估计模型本身并不关心具体动作动作逻辑全靠在关键点上做几何计算这决定了它的泛化能力极强。可以进一步做的方向还包括结合规则引擎判断动作速度比如太快完成的不计数或者用关键点轨迹识别动作的不规范性给出受伤风险提示。这些拓展不需要更换模型只需要在姿态数据上做更高级的分析。如果把骨架序列喂给一个LSTM或Transformer还可以训练一个动作分类器来识别“错误动作类型”形成完整智能私教闭环。我在实际使用中还有一个真实感受任何模型的输出都存在噪声关键帧的判断不能只看一帧。最初我的状态机是逐帧判断结果就是各种抖动。后来把所有关键点先做时间维度的平滑滤波再进入状态机准确率一下子就上来了。只要动手做这个项目你会更深刻理解“数据预处理比模型调参重要得多”这句话。健身计数是一个很好的姿态估计入门实践它不涉及复杂的三维重建但又覆盖了目标检测、关键点提取、规则设计、状态管理、实时可视化全流程学完会对整条技术链路有清晰的认识。