我原本只是顺手帮朋友调一个运动记录的demo谁知道最后在健身房和电脑前耗了三周。需求听着很简单摄像机架在侧面人做俯卧撑、引体向上屏幕实时显示标准次数不能乱跳。可实际跑起来才发现健身动作智能计数系统真正难的不是“认出来这是个人”而是“判断这一下到底算不算完整动作”。后来我把整套逻辑切换到YOLO系列姿态估计实战这条路上也就是用YOLO-Pose的关键点输出自己定义动作边界和状态机计数稳定性才算真正立住了。这篇文章就是那次实战的完整复盘从为什么选型YOLO姿态估计到俯卧撑/引体向上的关键点几何判据、状态机消抖、可运行代码再到实测踩坑给正在做同类项目的同学一条能直接上手的路。1. 为什么健身计数不适合“先识别动作再数次数”1.1 计数问题的本质是找动作边界不是做动作分类很多人第一次写健身计数直觉是先训练一个动作分类模型识别出“俯卧撑”“深蹲”“引体向上”然后连续帧之间发现类别切换就加一次。这个思路放在离散动作上成立比如“站起来”和“坐下”边界明显但放到俯卧撑和引体向上这种连续重复动作上会崩。俯卧撑没有明显的“类别切换”信号从平板支撑到俯身下降再到推起整段时间都处于同一个动作类别里。不同人速度不同有的人快速爆发、10个一组几乎不带停顿有的人每个动作在底部停两秒。分类模型在这种情况下只能输出“俯卧撑”三个字至于“做到第几下了”分类模型拿不到任何有用信息。所以视觉计数的本质是对时间序列做波形分析每一帧提取一个或几个能代表动作位置的物理量比如肘关节角度、肩部相对手腕的高度差然后按这个量的波峰波谷来切割重复周期。后面的计数逻辑和心率监测、步数统计在原理上是一样的只是信号来源从加速度计变成了人体关键点。1.2 接触式传感器和通用姿态库的区别早期我考虑过几种替代方案说下取舍过程。接触式方案压力垫、惯性手环、重力传感器确实能解决部分计数问题但使用门槛太高俯卧撑需要在地面铺压感垫引体向上需要在单杠上装拉压力传感器而且传感器只给一个“有没有力”的开关信号无法判断“这一下动作是否标准”比如有没有半程、有没有借力。传感器方案更适合做体感游戏不适合做动作质量评估。视觉方案里当时对比过三条路技术路线关键点稳定性推理帧率部署复杂度多人场景适合健身计数MediaPipe类轻量方案中高单人特写效果好高很低弱容易串人原型验证传统两阶段姿态估计高热力图质量好低高中离线分析YOLO-Pose类单阶段方案中高检测关键点一体高低强自带目标框实时计数MediaPipe在单人正对镜头时精度不错但健身计数最典型的机位是侧面视角身体有遮挡、大幅度弯曲轻量方案的骨骼点经常漂移。两阶段方案先做人检测再对每个人裁剪并跑关键点网络准确率确实高但两段串联拖慢帧率部署也麻烦。YOLO系列姿态估计实战的优势在于单阶段同时输出检测框和17个关键点人和骨骼一起出来天然适合“人可能移动、画面可能有多人”的健身场景。1.3 YOLO-Pose为什么适合作为计数底座真正让我决定用YOLO-Pose不是因为它单个姿势估计精度碾压其他方案而是因为它三个特性恰好是健身计数需要的第一输出自带人框。这点非常关键。健身房里背景常有其他人两阶段方案要先对每个人裁剪再分别做姿态估计资源开销大。YOLO-Pose一次推理直接给出每个人的框和关键点我可以按框的大小、位置筛选出画面中间的动作目标单独对它做计数其他人干扰不到。第二速度足够。当前主流YOLO生态里的Pose权重在小模型档次上普通游戏显卡能跑到30到60帧CPU上用小尺寸输入也能跑到接近实时。计数逻辑本身每秒只需要处理5到10帧就能稳定工作推理速度富余很多。第三关键点质量对“动作几何量”够用。俯卧撑、引体向上核心看肘关节角度、肩部相对手腕高度、躯干角度这些量都来自肩、肘、腕、髋、膝等大关节关键点。YOLO-Pose对这些大关节的预测置信度很高反而鼻子、眼睛这些小点受遮挡影响大需要小心使用。2. 关键点坐标系和动作的“数字指纹”2.1 17个关键点怎么看、怎么取YOLO-Pose输出的关键点标准情况下使用COCO的17点方案。每个点的信息包含归一化坐标x, y和置信度坐标范围是0到1原点在图像左上角即y轴向下增大。索引关键点在俯卧撑/引体向上中的作用0鼻子近似头部位置可用于“下巴过杠”判定5, 6左肩/右肩躯干上端、引体向上行程参考7, 8左肘/右肘俯卧撑肘角、引体向上拉臂状态9, 10左腕/右腕手部位置通常视为杠的位置参考11, 12左髋/右髋躯干下端、身体直线度13, 14左膝/右膝身体直线度延伸参考有一个特别重要的点不要直接用这些坐标的像素距离做判断因为摄像头远近、画面尺寸都会改变像素距离。我统一用角度和归一化差值。角度天然尺度不变如果要看高度差就用归一化y坐标的差值因为这个差值乘以画面高度就是真实比例不随画面尺寸变化。2.2 俯卧撑的“数字指纹”肘角收缩加身体直线度俯卧撑标准动作的关键是两件事下放到肘关节接近90度身体从头到脚保持一条直线。对应到关键点就是两个几何量肘关节角度由肩、肘、腕三点构成以肘为顶点。平板支撑时肘角接近180度身体下降时肘角减小最低点通常小于110度。推起时肘角再回到接近180度。身体直线度由肩、髋、膝三点构成以髋为顶点。标准俯卧撑全过程这个角度应接近180度也就是肩髋膝基本共线。塌腰时肩-髋-膝夹角大于180度腹部下垂撅屁股时明显小于180度。这两个量组合起来就能给俯卧撑画出一个数字指纹手臂角波形从高到低、再从低到高身体角全程保持在高值附近。我计数时把身体直线度作为“闸门信号”只在身体角大于150度时允许手臂角触发计数这样塌腰撅屁股的代偿动作就不会被算进去。另一个常见直觉是“看手腕是否碰到地面”或用深度传感器检测胸口位置这个思路在单目摄像头下做不了。手腕在支撑动作里通常会遮挡自身体重关键点容易漂移胸口没有标注点也测不准确。相比之下手臂角和躯干角的几何关系最稳定。2.3 引体向上的“数字指纹”过杠信号加悬垂复位引体向上和俯卧撑最大的区别是引体向上有明确的物理参照物——横杠。标准动作要求身体上拉至下巴过杠然后下放至手臂完全伸直悬垂位这才算完整一次。COCO的17个点里没有下巴点只有鼻子。我的做法是用鼻子与手腕的归一化y坐标做差值作为“过杠接近度”信号。侧面视角下双手握杠的位置和腕关节几乎重合如果鼻子位置的y坐标小于腕关节的y坐标说明头的高度已经超过手抓杠的位置可以近似认为下巴过杠了。更严谨一点可以在判定时给这个差值加上一个偏置偏置约等于鼻子到下巴的距离占一个人头高的比例这样更接近真实下巴过杠而不是鼻尖过杠。实际执行中我发现单纯用过杠信号有个问题如果用户做半程动作鼻子每次只升到杠附近但没完全过杠计数系统很难区分。所以我又加了一个“悬垂复位”条件每次计数完成之前必须看到肘关节回到接近伸直的状态用肘角大于160度或肩部y坐标明显回落来判断。这等于把完整周期分成了两段上拉段和复位段。只有两段都走完才算一次这和俯卧撑状态机的思路完全一致。3. 状态机与消抖计数不乱跳的核心3.1 单帧阈值判断为何不可靠第一个版本我走了最简单的路每帧算出手臂角当角度小于设定阈值就记一次“下降”大于阈值就记一次“上升”然后两个事件配对加一。结果测试时计数疯狂乱跳问题出在三个地方。关键点坐标本身有噪声角度在临界阈值附近反复横跳会产生“下降-上升-下降-上升”的连续毛刺。人快速动作时某几帧会因为运动模糊导致关键点轻微偏移角度可能短时间穿破阈值。半程动作如果用户只下放到130度就不推了阈值判断会认为“下去又上来了”把这个半程算进去。动作停顿有人会在最低点停顿一秒单帧判断会把停顿前的“下沿”和停顿后的“上升”拆成两段导致多计数。这些问题的共同根源是把连续过程当成独立帧分类丢了时间维度的信息。3.2 三态状态机怎么管住动作波形解决思路是把“是否完成一次”变成一个有限状态机。我给俯卧撑定义了三个状态READY待机当前处于平板支撑起始位肘角在高值区域等待开始下一次下降。这个状态只在一轮动作开始时出现。DESCENDING下降段检测到肘角低于低阈值认为用户已经下放到有效深度。同时记录一个“已通过低点”的布尔标志防止半程动作误判。ASCENDING上升段检测到肘角高于高阈值认为用户正在推起。只有当状态从下降段完整走到上升段并且之前确实通过低点计数器才加一。引体向上的状态机逻辑完全一样只是把输入信号换成“过杠接近度”把状态名换成语义更准确的HANG悬垂、PULL上拉、RETURN复位。核心原则不变每次计数必须完整经历“动作段”和“复位段”而且动作段要满足最低完成幅度。3.3 消抖的三层保险EMA平滑、最小持续时间、幅度门槛状态机解决了流程问题但还要处理噪声问题我用了三层保险。第一层是指数平滑EMA。对输入的肘角或过杠信号做低通滤波公式是new alpha * current (1 - alpha) * old。alpha取0.3到0.4。alpha太大会让信号更跟手但噪声也进来太小会滞后动作快时会被甩开。实测中alpha取0.35时手感和稳定性都比较好。第二层是每个状态的最短停留时间。比如俯卧撑的下降段至少持续0.25秒上升段至少持续0.25秒。真实动作不可能在几十毫秒内完成一次下降如果状态切换太快基本可以断定是噪声或抖动。这个参数让快速连续动作时状态机不会来回乱跳。第三层是幅度门槛也就是前面说的passed_low标志。只有肘角真正越过了低阈值、身体直线度也满足上升段才有效。这就把半程动作和弯腰撅臀动作挡在门外。我给这三个参数留了较大的余量俯卧撑低阈值设在95到105度而不是精确的90度上升高阈值设在150到160度。因为平滑滤波有滞后实测中角度波形的最低点会被平滑抬升几度阈值不留余量容易漏计。4. 最小可复现代码从关键点到计数界面4.1 环境准备和推理链路下面这套代码是我实际在跑的版本核心依赖是OpenCV和YOLO生态的Pose权重。模型文件用带-pose后缀的权重比如yolo11n-pose.pt或者yolov8n-pose.pt输入尺寸设成640对侧面小目标也能有不错的命中率。pip install ultralytics opencv-python代码处理流程非常短对摄像头每一帧调用模型推理拿到框、关键点坐标和置信度从关键点里提取肩肘腕髋膝索引计算角度或过杠信号经过EMA平滑喂给状态机返回当前计数并画在画面上。4.2 角度计算与平滑的具体实现先定义三个点的夹角计算函数。这里要注意向量模长不能为0否则直接返回180度避免除0异常。import math def calc_angle(p1, p2, p3): p1-p2-p3 夹角p2 为顶点返回角度0-180 v1 (p1[0] - p2[0], p1[1] - p2[1]) v2 (p3[0] - p2[0], p3[1] - p2[1]) m1 math.hypot(v1[0], v1[1]) m2 math.hypot(v2[0], v2[1]) if m1 0 or m2 0: return 180.0 dot v1[0] * v2[0] v1[1] * v2[1] cosv max(-1.0, min(1.0, dot / (m1 * m2))) return math.degrees(math.acos(cosv))然后做一个EMA平滑器对每个输入信号各自维护一个平滑值。class EmaFilter: def __init__(self, alpha0.35): self.alpha alpha self.value None def update(self, x): if self.value is None: self.value x else: self.value self.alpha * x (1 - self.alpha) * self.value return self.value4.3 可复用的状态机计数类这是我整个代码里最核心的部分。一个类同时服务俯卧撑和引体向上只需要替换输入信号和阈值。class RepCounter: def __init__(self, low_th100, high_th155, min_phase_time0.25): self.low_th low_th self.high_th high_th self.min_phase_time min_phase_time self.state ready # ready - active - ready self.state_since 0.0 self.passed_low False self.count 0 def reset(self): self.state ready self.state_since 0.0 self.passed_low False def feed(self, value, ts): value 为平滑后的信号ts 为当前时间戳秒 if self.state ready: if value self.low_th: self.passed_low True self.state active self.state_since ts elif self.state active: if value self.low_th: self.passed_low True if value self.high_th and (ts - self.state_since) self.min_phase_time: if self.passed_low: self.count 1 self.state ready self.passed_low False self.state_since ts return self.count注意这个状态机的语义ready对应俯卧撑的平板位或引体向上的悬垂位active对应下降段或上拉段。fed value是输入信号当信号跌到low_th以下说明进入了动作段当信号重新回到high_th以上说明动作段完成并复位此时计数加一。所以它天然适合“上升然后回落”的周期信号。4.4 实际跑通的完整逻辑针对两类动作我用不同的信号构造方式。俯卧撑取左右肘角分别计算用两侧的平均值作为主信号如果某一侧关键点置信度低就改用另一侧。身体直线度作为额外约束只有当肩-髋-膝夹角大于150度时才允许肘角信号触发状态机。引体向上主信号是over_bar_score nose_y - wrist_y这里用的是归一化y坐标。悬垂时腕部在杠上、鼻子在下方nose_y大于wrist_y所以score为正上拉到下巴过杠时nose_y小于或接近wrist_yscore接近0或变负。所以低阈值可以设为0.0到0.03高阈值设为0.10上下表示复位回悬垂。import cv2 from ultralytics import YOLO # 关键点索引 NOSE, L_SHOULDER, R_SHOULDER, L_ELBOW, R_ELBOW 0, 5, 6, 7, 8 L_WRIST, R_WRIST, L_HIP, R_HIP 9, 10, 11, 12 L_KNEE, R_KNEE 13, 14 model YOLO(yolo11n-pose.pt) cap cv2.VideoCapture(0) rep_mode pushup # 或 pullup if rep_mode pushup: counter RepCounter(low_th100.0, high_th155.0, min_phase_time0.25) else: counter RepCounter(low_th0.03, high_th0.12, min_phase_time0.25) ema EmaFilter(alpha0.35) while True: ret, frame cap.read() if not ret: break results model(frame, verboseFalse)[0] boxes results.boxes for i in range(len(boxes)): kpts results.keypoints.xyn[i].cpu().numpy() # 17 x 2归一化坐标 conf results.keypoints.conf[i].cpu().numpy() # 17 个置信度 # 只处理关键点置信度足够的检测 if conf[L_ELBOW] 0.5 or conf[R_ELBOW] 0.5: continue if rep_mode pushup: left_elbow calc_angle(kpts[L_SHOULDER], kpts[L_ELBOW], kpts[L_WRIST]) right_elbow calc_angle(kpts[R_SHOULDER], kpts[R_ELBOW], kpts[R_WRIST]) elbow_angle (left_elbow right_elbow) / 2 body_angle calc_angle(kpts[L_SHOULDER], kpts[L_HIP], kpts[L_KNEE]) if body_angle 150.0: # 身体直线度闸门 signal ema.update(elbow_angle) else: signal ema.update(180.0) # 不满足直线度时强制回到待机 count counter.feed(signal, cv2.getTickCount() / cv2.getTickFrequency()) else: # 用鼻子和右手腕的归一化y坐标差 over_bar_score kpts[NOSE][1] - kpts[R_WRIST][1] signal ema.update(over_bar_score) count counter.feed(signal, cv2.getTickCount() / cv2.getTickFrequency()) cv2.putText(frame, fcount: {counter.count}, (30, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow(pose counter, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()4.5 视角、距离与ROI的关键设置代码能跑通之后真正影响准确率的是机位摆放我吃了不少亏。俯卧撑要求侧面机位镜头高度大约在腰部位置不要俯拍也不要仰拍。太俯视会让肘角和躯干角都被压缩角度失真。目标在画面里占高度50%以上人躺下后肩到脚踝应该占画面宽度70%左右。太远则关键点飘。引体向上要让人体在画面中间杠上方留出半个头的余量。因为判定过杠要用鼻子和手腕的y坐标差如果杠太贴近画面顶部鼻子过杠时关键点可能被截断。距离要能看到完整手臂伸展悬垂时手臂完全伸直、脚在画面下方这样复位状态才能识别。如果画面中有多个人我会先用results.boxes里的坐标筛选离画面中心最近的人框把这个人的序号记下来只对它做计数。否则状态机串到别人身上计数立刻乱。5. 实测复盘那些让我反复调整的坑5.1 阈值到底怎么标定很多人嫌阈值麻烦直接拍脑袋结果换个人动作习惯不同就失效。我的做法是先录一段标准动作视频离线跑一遍打印整个序列的信号波形比如每帧的肘角、过杠分数。找到所有波谷和波峰取波谷的中位数作为低阈值波峰的中位数作为高阈值再留一点余量。动作输入信号低阈值参考值高阈值参考值俯卧撑肘关节角度95-105度150-160度引体向上鼻子-手腕y坐标差0.0-0.030.10-0.15引体向上的分数是归一化差值受画面尺寸影响不大但因为人的头长、臂长不同不同测试者之间阈值差异比俯卧撑角度阈值大。我会让用户先做两次完整动作用于自动标定再进入正式计数这样比固定阈值实用得多。5.2 失败场景和对应解法测试过程中踩过这些坑列出来供参考。现象根因解决方式快速俯卧撑漏计状态机最小持续时间大于动作实际时长把min_phase_time降到0.2秒并提高EMA的alpha到0.4半程动作被计数只有下降段没有通过低点引入passed_low标志未到低幅度不许计数引体向上时手腕被杠或身体遮挡腕关键点置信度低改用鼻子-腕y差值并且对置信度低的帧继承上一帧有效信号画面边缘的人串入多目标未筛选按框中心到画面中心的距离筛选唯一目标动作底部停顿导致多计停顿期间角度在阈值附近抖动状态机切换必须满足最小时间余量印象最深的一次是某位朋友做引体向上时习惯在底部快速弹震一下再上拉。关键点显示他的肘角在悬垂位有瞬间过冲导致状态机认为他复位过两次计数直接翻倍。我加了一个简单处理复位信号必须持续至少0.2秒才认可也就是状态机切回ready之后必须稳定待机一段时间才能接收下一次启动。从那以后弹震误计基本消失。5.3 样式扩展从计数到动作质量评估这套关键点几何方案天然可以扩展。比如用左右肘角差值判断动作对称性差大于20度说明两侧发力不均用下降速度和上升速度的比例评估肌肉控制力用躯干角度最小值的波动幅度评估塌腰程度用引体向上的过杠分数峰值估计下巴超过杠的深度。我实际在demo里做了一版“质量打分”把肘角波形和标准动作模板波形做动态时间规整相似度低于阈值给出动作不标准的提示。这个功能对健身体验产品很有用很多用户不是不知道标准动作而是看不到自己做出来的动作差异——视觉反馈是唯一的纠错手段。整个系统做完后的体会是模型推理权重只占工作量一小半真正让计数系统从“偶尔灵”变成“稳定可用”的是动作定义、状态机设计和阈值标定。YOLO-Pose的输出只是给我一个稳定的信号源计数稳不稳全看信号处理层做得到不到位。如果你也在做同类系统先别急着调模型把动作标准用几何量写清楚再一步步把状态机打磨好效果会立竿见影。
阅读完成 · 觉得有帮助?