从朋友让我帮他做一个“自动数俯卧撑”的小工具开始,这个项目前前后后折腾了将近一个月。一开始我也以为这不就是“看着视频里人的轮廓变高变低”嘛,但真正落到代码和实测之后才发现,把一个健身动作拆成“可以量化的数字”,里面全是细节。最后我选了 YOLO 系列姿态估计作为核心方案,基于它构建了一套健身动作智能计数系统,能同时覆盖俯卧撑和引体向上两种动作。这篇博文就把整个实战过程完整记录下来,包括方案选型、数据处理、模型训练、动作判定、实时部署和一堆踩过的坑,给同样想用视觉技术做运动计数、动作分析的朋友一个可以直接抄作业的参考。
1. 项目需求拆解:为什么偏要用姿态估计做计数
1.1 智能计数的核心逻辑
健身动作计数这件事,本质上是“把连续的视频帧转换成离散的次数”。人的动作再复杂,在物理上也可以抽象成一组关键点的空间位置随时间的变化。俯卧撑就是躯干下沉再撑起,引体向上就是身体被拉上去再放下来。只要我们能稳定追踪到肩膀、手肘、手腕、髋、膝等关键点的坐标,再通过角度、距离、相对高度这些几何量判断动作所处的阶段,计数就是水到渠成的事。
这里有两个关键点:第一,要知道人的身体部位在哪里,也就是目标检测;第二,要知道这些部位之间的连接关系,也就是关键点回归。YOLO系列姿态估计恰好把这两件事合并成一次推理,输出目标框外加每个关键点的坐标和置信度,比“先检测人再单独跑关键点模型”的两段式方案省掉一半时间,对实时计数来说特别友好。
1.2 技术选型对比:视觉方案与传统方案的差距
在做技术选型时,我认真比较过几种常见路线:
| 方案类型 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 穿戴式传感器/加速度计 | 精度高、不受遮挡影响 | 需要额外硬件、用户嫌麻烦 | 专业运动研究 |
| 普通目标检测+规则 | 实现简单 | 没有关节信息,动作阶段难以细分 | 只数人头、检测动作开始 |
| 通用姿态估计框架 | 关键点丰富、准确 | 速度慢、部署成本高 | 离线分析 |
| YOLO系列姿态估计 | 速度快、关键点与检测一体、工程生态成熟 | 对遮挡和极端视角仍敏感 | 实时动作计数 |
实测下来,YOLO系列姿态估计的推理速度比其他通用姿态估计框架快一倍以上,在普通独立显卡上即使不做过多的优化也能跑到几十毫秒一帧,完全满足摄像头实时计数需求。
2. 数据准备与关键点定义
2.1 俯卧撑和引体向上的关键点建模
训练一个能用的姿态估计模型,第一步是明确关键点语义。官方姿态估计模型通常输出人体的多个关键点,比如鼻子、眼睛、耳朵、双肩、双肘、双腕、双髋、双膝、双踝等。我们在计数任务中真正关心的子集是:
| 动作 | 核心关键点 | 作用 |
|---|---|---|
| 俯卧撑 | 肩膀、手肘、手腕、髋部、膝盖、脚踝 | 判断肘关节角度、躯干是否平直、身体离地高度 |
| 引体向上 | 鼻子、肩膀、手肘、手腕、髋部、膝盖、脚踝 | 判断下巴/鼻子是否超过横杠高度、手臂是否伸直 |
实际标注时,我采用了侧视角拍摄为主,因为侧面对算法最友好。俯卧撑在侧面能看到手肘明显弯曲角度,引体向上在侧面也能看到下巴过杠瞬间。我自己采集了一部分模拟数据,同时补充了公开数据集里包含运动场景的图像,最终整理出一份包含约两万张图像的数据集。标注工具用的是常见的开源标注工具,每个关键点记录归一化坐标和可见性标志,即:
关键点编号, x, y, 可见性这里有一个很重要的经验:可见性标志不要随便填。关键点被身体遮挡时,标注为不可见,而不是强行标一个猜测坐标。模型训练初期如果可见性标注混乱,推理阶段输出的关键点置信度会非常不稳定,计数也就无从谈起。
2.2 标注规范与数据增强
标注规范直接影响模型效果。我的标注规范是四句话:
- 侧视角为主,正面和斜侧面作为补充。
- 所有关键点严格对应解剖学位置,手肘不标在手臂外侧,膝盖不标在裤腿褶皱处。
- 遮挡时标为不可见,宁可缺失不预测。
- 每张图像至少保证双肩、双肘、双腕、双髋可见,否则不进入训练集。
数据增强方面,我用了旋转、镜像翻转、随机缩放、亮度扰动和随机遮挡。这里特别注意:俯卧撑和引体向上本身有上下方向语义,不能做垂直翻转,否则模型会混淆支撑和悬挂动作。随机遮挡用来模拟身体部分被器械或镜头边缘挡住的情况,能明显提升模型在真实场景下的鲁棒性。
我实际使用中发现,增强比例控制在适中范围比较稳,旋转角度在正负15度以内,缩放比例在正负20%以内,亮度扰动按原图灰度的10%左右。增强过猛会造成模型在合成分布上训练,真实场景反而掉点。
3. 模型训练与调优
3.1 模型选择与输入设置
YOLO系列姿态估计的模型体量有很多档,从轻量级到高精度级都有。我的选型原则是:实时计数对延迟敏感,优先选轻量级和次轻量级。轻量级模型在普通独立显卡上速度很快,我的笔记本无外部供电环境都能跑实时;次轻量级精度更高,适合对计数准确率更挑剔的场景。
输入尺寸我选的 640 乘 640。这个尺寸是速度与精度的较好平衡点。如果你希望细节更好,用 768 或 1024,但推理时间会明显上涨。摄像头画面本身是横屏,直接缩放会变形吗?不会,YOLO系列姿态估计会把图像保持比例后填充到目标尺寸,推理后再把坐标映射回原图。所以代码里不要忘记做坐标的缩放回去处理,否则画骨架时会错位。
3.2 训练参数和优化技巧
训练时我先把预训练权重加载进来,让它基于通用姿态估计能力做微调。直接用随机权重从头训练,两万张图像是不够的,效果会差很多。下面是几个关键参数:
| 参数 | 设定值 | 说明 |
|---|---|---|
| 输入尺寸 | 640x640 | 平衡速度与精度 |
| 批次大小 | 16 | 根据显存调整,越小越容易收敛但不稳 |
| 训练轮数 | 150 | 用早停机制监控验证集损失 |
| 初始学习率 | 0.001 | 配合余弦退火降学习率 |
| 优化器 | AdamW | 比SGD收敛更快,关键点回归更稳 |
| 关键点损失权重 | 适当加大 | 关键点回归是本任务核心 |
训练结果不能只看关键点精度,我额外写了一个评估脚本,直接统计模型输出在真实视频上触发计数动作的成功率。这个比单个关键点的像素误差更有实际意义。
实测下来,轻量级模型训练到后期验证集损失不再下降时,单帧推理时间在显卡上约二三十毫秒,精确率已经足够完成俯卧撑和引体向上的计数。
4. 计数算法:从关键点到动作判定
4.1 俯卧撑计数逻辑
有了姿势关键点之后,最核心的问题就是“怎么算一次”。俯卧撑的标准动作可以定义为:起始时手臂伸直,身体呈平板状;下降时肘关节弯曲到接近90度,胸部靠近地面;撑起时手臂重新伸直。从视觉上看,最稳定的判断信号是肘关节角度。
我定义了一个骨架角度计算函数,输入三个关键点坐标,输出它们形成的角度:
import math def compute_angle(point_a, point_b, point_c): # point_b 是角点,例如手肘 ab_x = point_a[0] - point_b[0] ab_y = point_a[1] - point_b[1] cb_x = point_c[0] - point_b[0] cb_y = point_c[1] - point_b[1] dot = ab_x * cb_x + ab_y * cb_y norm_ab = math.hypot(ab_x, ab_y) norm_cb = math.hypot(cb_x, cb_y) if norm_ab == 0 or norm_cb == 0: return 0.0 cos_angle = max(-1.0, min(1.0, dot / (norm_ab * norm_cb))) angle = math.degrees(math.acos(cos_angle)) return angle俯卧撑时,以肩膀为原点,手腕、手肘一起算肘关节角度:手肘-肩膀-手腕还是手腕-手肘-肩膀?这里取决于实际输出。我用的核心角度是肩-肘-腕三点构成的肘角。支撑状态下肘角接近180度,撑起时也接近180度,下降到最低点时通常小于90度。
但光靠一个角度还不够,因为人可能半程发力,肘角只到120度就撑起。为了增强鲁棒性,我又加入了两个辅助条件:
- 肩部与髋部的连线是否保持平直,防止塌腰或撅屁股。
- 手肘位置相对于肩膀的垂直距离变化。
当肘角小于90度且躯干倾斜角度小于设定容差时,判定为下降到位;当肘角大于150度时,判定为上升到位。
4.2 引体向上计数逻辑
引体向上的判定和俯卧撑不同。引体向上的核心是“下巴过杠”。但直接用下巴检测不稳定,因为下巴在侧视角经常被遮挡或显示不清晰。我在实际项目中改用两个替代信号的组合:
- 鼻子或头部关键点的纵坐标,代表头部的最高位置。
- 手腕关键点的纵坐标,代表手握杠的位置。
理论上引体向上拉起来时,头部会明显高于手腕。因此我定义“下巴过杠”的判定为:鼻子关键点纵坐标小于手腕关键点纵坐标,且竖直差距超过某个像素阈值。又因为不同人的身高和手臂长度不同,这个阈值不能是固定的像素值,我会用肩宽或躯干长度做归一化。
引体向上的完整状态机是:初始 hanging,手臂伸直;当头部拉起超过手腕且肘角小于某个角度,进入 up 状态;随后头部下降到重新低于手腕,回到 hanging 状态,计数器加一。
4.3 阈值判定与防抖动
阈值的选取是整个系统最容易出问题的地方。阈值太小会把半程动作误判为完整动作,阈值太大又会漏计。我采用的方案是滞回比较,而不是单阈值。
例如俯卧撑:定义 down 触发阈值是90度,up 触发阈值是150度。动作进入 down 状态后,必须肘角小于90度才算有效;动作回到 up 状态前,肘角必须大于150度才算伸直。中间90度到150度这段是滞回区,不产生状态切换。这样即使用户在最低点有些微抖动,也不会来回计数。
我还对角度做了平滑处理,避免单帧关键点跳变引起误判:
smooth_angle = alpha * raw_angle + (1 - alpha) * smooth_anglealpha 取0.3到0.4之间。平滑之后,动作状态切换的曲线很干净,实测基本不会出现连续帧抖动导致的重复计数。
5. 实时推理与系统集成
5.1 摄像头帧处理流程
整个实时系统的流程是这样的:
- 从摄像头读取一帧画面。
- 将画面缩放到模型输入尺寸。
- 执行 YOLO 系列姿态估计推理,获得人体框和关键点。
- 将关键点坐标映射回原图尺寸。
- 根据模式(俯卧撑/引体向上)计算角度和位置关系。
- 更新动作状态机,必要时计数器加一。
- 在原图上绘制骨架、关键点和计数值,显示出来。
摄像头采集和模型推理要避免放在同一个循环里同步等待。我用了双线程加队列的方案:一个线程持续采集,一个线程做推理。这样就算某帧推理偶尔超时,也不会导致画面卡死或延迟越积越大。
5.2 计数的流式状态机
计数逻辑我单独封装成一个计数器类,里面维护动作状态。这里贴一段伪代码,完整表达了状态机的思路:
class ActionCounter: def __init__(self, mode): self.mode = mode self.state = "up" if mode == "pushup" else "hanging" self.count = 0 self.smooth_angle = 180.0 if mode == "pushup" else 0.0 def update(self, keypoints, frame_height): # 计算角度和位置特征 angle = compute_elbow_angle(keypoints) self.smooth_angle = 0.35 * angle + 0.65 * self.smooth_angle if self.mode == "pushup": return self._update_pushup() else: return self._update_pullup(frame_height) def _update_pushup(self): if self.state == "up" and self.smooth_angle < 90: self.state = "down" elif self.state == "down" and self.smooth_angle > 150: self.state = "up" self.count += 1 return self.count def _update_pullup(self): # 判定头部是否高于手腕 head_above = nose_y < wrist_y if self.state == "hanging" and head_above: self.state = "up" elif self.state == "up" and not head_above: self.state = "hanging" self.count += 1 return self.count这个状态机的关键点是:一次完整计数必须经历两个边缘,而且每个状态都有明确的进入条件。只满足“下去”但没有“上来”,计数器不会增加;只满足“上来”但没有先进入“下去”,也不会增加。这就杜绝了摆动身体造成的假计数。
5.3 部署细节与性能优化
实时推理的性能优化,我按照优先级做了三件事:
- 推理尺寸保持640,不盲目调大。
- 推理时跳过置信度低于阈值的关键点,不参与角度计算。
- 如果模型较大或帧率仍然不够,可以把相邻几帧的检测结果做时间维度的融合,用上一帧的关键点位置平滑当前帧。
实际测试中,我在一款普通独立显卡的笔记本上做俯卧撑计数,稳定跑满摄像头帧率,CPU占用也不高。换到纯CPU环境后,轻量级模型仍然能跑到接近实时,只是帧率会降到接近15到20帧左右,使用体验在可接受范围。
6. 常见问题与排查实录
6.1 典型问题速查表
这个项目里我踩过的坑不少,整理成一张速查表,方便直接对照:
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| 关键点频繁跳动 | 光照变化、身体与背景颜色相近 | 增加数据增强、切换更亮或对比度更高的环境 |
| 俯卧撑计数偏多 | 状态机没有锁定半程动作 | 加入滞回区间,提高 up 触发阈值 |
| 俯卧撑计数偏少 | 肘角阈值设置过大 | 下调 down 触发角度,例如从90度改到80度 |
| 引体向上不计数 | 鼻子/头部和手腕位置关系判断错误 | 改用头部关键点加手腕位置组合判定,加入置信度过滤 |
| 推理速度太慢 | 输入尺寸过大、模型体量过大 | 切换到轻量级模型,或降至 512 输入尺寸 |
| 人离摄像头太远 | 关键点尺度过小 | 调整摄像头视角,让人体高度占画面的40%以上 |
| 多人同框互相干扰 | 只取目标框最大的人 | 根据人体框中心距离摄像头最近的原则选择一个目标 |
| 背景复杂误检人形 | 类间干扰 | 加入跟踪机制,只跟踪同一目标 |
6.2 独家避坑心得
第一,摄像头摆放角度非常重要。俯卧撑尽量从侧面拍,镜头高度和地面上的身体中线大致齐平;引体向上从斜前方拍,不要正对着人脸,否则头部被杠铃遮挡后关键点容易丢失。我实测过正面视角,同一个动作的计数成功率明显低于侧视角,这在投入大量数据采集前就应该想明白。
第二,每个人做动作的幅度不一样。有人俯卧撑只做半程,有人引体向上习惯轻微甩腰。我最后的方案是把角度阈值做成可配置参数,在程序启动时允许用户根据自己的动作习惯微调。不要试图用一套固定阈值适配所有人,这不是模型能力问题,是人体多样性决定的。
第三,关键点置信度要充分利用。很多关键点在快速动作过程中会出现一两帧的错误预测。我的做法是:任何参与角度计算的关键点,置信度低于0.5就直接跳过该帧,不更新状态机。宁可等待一两帧,也不要因为一个错误点造成重复计数。
7. 扩展方向与个人体会
这个系统做完之后,我最大的感受是:模型推理只是整个系统的一小块,真正的难点在于把“人体姿态”翻译成“符合实际语义的动作阶段”。角度计算、状态机设计、阈值滞回这些看似简单的代码,才是决定计数准不准的核心。
如果后续想扩展,我建议可以先加动作质量评估。既然已经有了连续的关键点轨迹,完全可以算出动作速度、幅度、关节角度变化曲线,再结合健身标准给出“偏快”“幅度不足”“躯干不稳”这类提示。再往后,可以做一个人运动档案,长期跟踪每周动作次数和完成的规范性,这就超出计数本身,进入数字健康管理的范畴了。
这个项目让我印象最深的反而是状态机设计。刚开始我也试图用纯粹的连续角度数值来计数,结果半程动作、停顿、抖动全部变成误计。后来老老实实把动作拆成离散状态,每一步都问“现在处于哪个阶段”,整个系统忽然变得异常稳定。如果你也在做类似的计数系统,建议先画出动作状态图,再写代码。这个思路比换更厉害的模型更能解决问题。