姿态估计模型跑起来之后,很多人都会问一句:这些关键点画在人身上,除了看着酷,到底能拿来做什么?ultralytics.solutions里的ai_gym算是一个很直接的答案——它把姿态估计接到了健身房里,自动识别俯卧撑、引体向上、深蹲这些动作,并且实时计数。ai_gym.py这个子模块的完整链路是:用 YOLO 姿态模型检测人体关键点,再用关键点坐标计算关节角度,再根据角度变化判断动作阶段,最终把计数、角度、骨架绘制到画面上。这篇文章就把这整条链路从代码层面拆开讲透,包括模块设计、核心 API、实际接入方法,以及我实测中踩过的坑。适合已经会跑 ultralytics 基础推理、想往具体应用落地走一走的开发者。
1. 模块定位与设计思路:ai_gym 到底帮你做了什么
1.1 solutions 包里的“即插即用”应用模块
ultralytics不只是提供 YOLO 模型,官方在工程里还维护了一个solutions模块,里面放了一堆“模型加业务逻辑加绘图”的完整应用样板。除了ai_gym,你还能看到物体计数、速度估计、安全警报这类现成方案。它们的共同特点是:你不需要从零写推理、画框、统计逻辑,只要传入视频帧,拿回的就是处理好的画面。
ai_gym在这个包里的定位很清晰:针对健身房场景的运动监测。它内部组合了姿态估计模型、关节角度计算、动作计数状态机、画面标注几部分。对你来说,调用层非常薄——初始化一个对象,每帧丢给它一张图,返回一张被画好骨架和计数结果的图。但如果你以为它只是个“黑盒玩具”,那就浪费了,因为这模块的代码本身就是一份很好的姿态应用教学材料,把逻辑拆开讲,能帮你理解这类应用的通用套路。
1.2 为什么姿态估计能用来计数,目标检测不行
要理解ai_gym的价值,得先搞清楚姿态估计和目标检测的区别。目标检测给的输出是“人的外接矩形框”,它只能告诉你画面里“有一个人”,至于这个人是站着、蹲着还是趴着,框是表达不清楚的。而姿态估计输出的是人体的“骨架关键点”,以二维坐标形式标出鼻子、肩膀、手肘、手腕、髋部、膝盖、脚踝这些位置。有了这些坐标,你才能计算关节角度。
打个比方:检测模型告诉你“这里有个人”,姿态模型告诉你“这个人的手肘现在弯曲了 45 度还是 150 度”。健身动作的本质就是关节角度随时间变化,所以姿态估计天然适合做动作计数。ai_gym的核心逻辑其实就是两件事:算角度、看角度变化。
1.3 三层结构:模型层、逻辑层、展示层
读ai_gym.py的源码时,我建议你把它理解成三层结构。
第一层是模型层,负责通过 YOLO 姿态模型拿到所有人的关键点。第二层是逻辑层,根据你选择的运动类型,决定关注哪几个关键点、计算哪个关节的角度、维护当前动作阶段和计数结果。第三层是展示层,用Annotator在画面上画骨架、关节角度、计数文字、阶段提示。
这种分层思路特别值得学。很多初学者写视觉应用,习惯把推理、业务、绘图全塞在一个循环里,代码一百行不到就乱成一团。ai_gym用类组织状态,把模型加载、逐帧监控、姿态绘制分得清清楚楚,即便你最后不用这个模块,这套组织方式也几乎可以直接抄到你自己的项目里。
2. 核心数据结构与关键 API 拆解:姿态关键点与角度计算
2.1 先搞清楚 results 里的关键点长什么样
不管是读源码还是自己改代码,第一步一定是理解姿态模型输出的数据结构。用 ultralytics 跑一帧推理之后,结果存在results对象里。最常用的数据是results[0].keypoints.data,在常见模型下它的 shape 是torch.Size([1, 17, 3])。
- 第 1 维:检测到的人数,通常一次推理可能有多人;
- 第 2 维:关键点数量,COCO 姿态数据集是 17 个关键点;
- 第 3 维:每个关键点的
(x, y, confidence),前两个是像素坐标,最后一个是模型对这个点的置信度,范围在 0 到 1 之间。
这点很关键:算角度时必须用置信度做过滤。如果模型本身对某个关键点没把握,conf会很低,这时候还硬拿错误坐标去算角度,计数值就会乱跳。实际工程里我一般只让conf大于 0.5 的关键点参与计算,低于这个值的帧直接跳过计数,效果比强行纠偏稳定得多。
COCO 17 个关键点的索引也是要记住的核心信息:
| 索引 | 关键点 | 索引 | 关键点 |
|---|---|---|---|
| 0 | 鼻子 | 9 | 左腕 |
| 1 | 左眼 | 10 | 右腕 |
| 2 | 右眼 | 11 | 左髋 |
| 3 | 左耳 | 12 | 右髋 |
| 4 | 右耳 | 13 | 左膝 |
| 5 | 左肩 | 14 | 右膝 |
| 6 | 右肩 | 15 | 左踝 |
| 7 | 左肘 | 16 | 右踝 |
| 8 | 右肘 |
ai_gym这个场景主要用的就是三组点:肩、肘、腕组成的手臂链,髋、膝、踝组成的腿部链。俯卧撑和引体向上看手臂链,深蹲看腿部链。
2.2 关节角度计算的实现原理
有了三个关键点坐标,怎么算关节角度?数学上就是求两条向量的夹角。假设我们想算肘关节角度,取肩膀坐标 a、手肘坐标 b、手腕坐标 c,以 b 为顶点求角 abc。
实际写代码时有个容易踩的坑:图像坐标系里 y 轴是向下的,但这对夹角计算没影响,因为我们用的是向量点积,和坐标轴方向无关。真正要注意的是三点可能退化成一条线,导致向量模长为零。所以计算时最好给分母加一个极小值,防止除零报错。
import math def calculate_angle(a, b, c): """计算三个点之间以 b 为顶点的夹角,返回角度制数值""" # 构造向量 ba 和 bc ba = (a[0] - b[0], a[1] - b[1]) bc = (c[0] - b[0], c[1] - b[1]) # 点积 dot = ba[0] * bc[0] + ba[1] * bc[1] # 模长,加 1e-6 防止分母为 0 norm_ba = math.sqrt(ba[0] ** 2 + ba[1] ** 2) + 1e-6 norm_bc = math.sqrt(bc[0] ** 2 + bc[1] ** 2) + 1e-6 # 夹角的余弦值,clip 到 [-1, 1] 避免 arccos 入参越界 cos_theta = max(-1.0, min(1.0, dot / (norm_ba * norm_bc))) angle = math.degrees(math.acos(cos_theta)) return angle这个函数是整套动作计数的基础。ai_gym内部计算角度的思路和我上面这个函数基本一致,区别只在于传参时取哪几个关键点,以及最后求的是肘角还是膝角。
2.3 计数状态机:从角度到动作次数
有了角度,下一步要解决“怎么算一次完整动作”。这里必须引入状态机,否则计数会非常不稳定。
以俯卧撑为例:人撑起的时候,手臂接近伸直,肘角大于 120 度;身体下放之后,肘角变小,通常小于 60 度。但实际情况里,角度不会稳稳待在一个值,它在阈值附近上下抖动是常态。如果只用“当前角度小于某值就加一”,一次动作可能会被重复计算三五次。
所以工程上会维护一个动作阶段标志:只有当角度从“小于下阈值”切换到“大于上阈值”时,才认为完成了一次完整动作。简化逻辑是这样的:
# down: 当前处于下放阶段 if angle < down_threshold and not down: down = True elif angle > up_threshold and down: count += 1 down = Falseai_gym.py里常见的做法是同时维护count_aup和count_adown两个计数,分别记录“进入高位状态”和“进入低位状态”的次数。这种设计在只需要动作次数时似乎多此一举,但在引体向上这类动作里特别有用。引体向上的拉升和下放阶段更容易被误判,分别统计能让你在调试时看清楚,到底是一个动作拆成了两次,还是漏了一次。
2.4 reveal 关键属性和方法
读源码时,关注几个常用属性和方法就够了。首先是构造函数里要传的model、pose_type、line_width。model是加载好的 YOLO 姿态模型;pose_type决定内部用哪组关键点、按什么逻辑计数;line_width是画骨架线条的宽度,调大一点在视频里更醒目。
每个视频帧的入口是monitor方法。传入当前帧im0,经过推理、角度计算、计数更新、绘图之后,返回处理好的帧。如果你只想拿数据,完全可以只调它然后不显示,直接从对象上取count、angle、count_aup、count_adown这几个属性。我自己调试阈值时就是这么干的,打印gym.angle比看画面直观得多。
3. 实操接入:把 ai_gym 接进自己的视频/摄像头流程
3.1 最小可运行示例
先把整套流程跑起来,再谈深度定制。下面这段代码是从视频文件读取、用ai_gym处理、实时显示的标准流程:
import cv2 from ultralytics import YOLO from ultralytics.solutions import ai_gym # 1. 加载姿态估计模型 model = YOLO("yolo11n-pose.pt") # 2. 创建健身房监测对象,运动类型先选 pushup gym = ai_gym.AIGym( model=model, pose_type="pushup", line_width=2 ) # 3. 打开视频 cap = cv2.VideoCapture("workout.mp4") while cap.isOpened(): success, frame = cap.read() if not success: break # 4. 核心调用:只传一帧,拿回处理后的帧 frame = gym.monitor(frame) cv2.imshow("AI Gym Monitor", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()补充一个经验:不同版本的 ultralytics 在solutions包里的导出名略有差异,有的直接能从ultralytics.solutions里导入AIGym,有的则需要from ultralytics.solutions import ai_gym再取ai_gym.AIGym。这不是什么大问题,你打开本地安装目录下的solutions/__init__.py看一眼导出了什么即可。调用逻辑基本一致,入口方法都叫monitor。
3.2 三种常见动作的参数建议
pose_type是决定行为的关键参数。工程里最常见的取值是pushup、pullup、squat三种,对应的关键点链和阈值逻辑可以参考这张表:
| 运动类型 | 关键点链 | 判断阶段的核心角度 | 参考阈值 |
|---|---|---|---|
| 俯卧撑 pushup | 肩 - 肘 - 腕 | 肘关节角度 | 下放 < 60°,撑起 > 120° |
| 引体向上 pullup | 肩 - 肘 - 腕 | 肘关节角度 / 下巴过杠 | 拉起时肘角 < 80°,下放 > 150° |
| 深蹲 squat | 髋 - 膝 - 踝 | 膝关节角度 | 下蹲 < 90°,站起 > 150° |
这里要特别强调,表格里的阈值是参考值,不是金标准。你的相机安装位置、人的动作习惯、镜头畸变都会影响实际角度曲线。比如正面拍俯卧撑,肘角的数值范围和侧面拍差别很大。我自己的习惯是,先跑一段自己的动作视频,把gym.angle实时打印出来,观察角度曲线的峰谷,再确定阈值。很多人的计数不准,不是代码写错,而是把参考阈值当成了固定真理。
3.3 把处理结果保存成视频
如果想把ai_gym的输出存下来,加一个VideoWriter就够了。注意要在循环外初始化,循环里写帧,循环结束后释放:
fps = cap.get(cv2.CAP_PROP_FPS) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) writer = cv2.VideoWriter( "output.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height) ) while cap.isOpened(): success, frame = cap.read() if not success: break frame = gym.monitor(frame) writer.write(frame) writer.release()有一点要提醒:monitor内部会重新处理输入帧,如果传入的帧尺寸和VideoWriter初始化尺寸不一致,写出来的视频会打不开或画面变形。尽量让输入保持一致的分辨率,处理完直接写回。
3.4 从视频切换到摄像头
把VideoCapture参数从文件路径改成摄像头索引号,比如cv2.VideoCapture(0),代码逻辑不用动。但实时场景下要考虑推理耗时。姿态模型比纯目标检测要贵,如果直接跑大模型会明显卡顿。我的建议是优先用yolo11n-pose.pt或yolov8n-pose.pt这类轻量模型;如果还是卡,可以在循环里做跳帧处理,每隔一帧调一次monitor,计数精度不会有太大损失,画面流畅度却能好不少。
4. 常见问题与排查技巧实录
4.1 计数重复和漏计,问题总出在状态切换上
我把这个放在第一位,因为这是几乎所有动作计数应用的通病。现象是:明明只做了 10 个俯卧撑,屏幕上却显示 15 次,或者只显示 7 次。
排除代码 bug 之后,最常见的原因就是角度在阈值附近反复穿越。比如做了个半程俯卧撑,肘角恰好卡在 100 度上下,状态机可能在一帧之内来回切换,一次动作就被计成了多次。解决办法是给状态切换加“死区”,让下放判定和撑起判定之间留出足够间隔,另外可以加一个“连续帧数”要求,比如连续 3 帧都满足条件才真正切换状态:
if angle < 50: down_frames += 1 else: down_frames = 0 if down_frames >= 3 and not down: down = True down_frames = 0这个思路我在自己的项目里用了很多次,对消除单帧噪声非常有效。
4.2 关键点置信度低,算出来的角度不可信
身体遮挡、镜头距离远、动作角度刁钻,都会让某个关键点的置信度掉得很低。关键点坐标一旦不准,后续所有角度计算就都是错的,而且这种错误比漏检更隐蔽。
排查时不要只看画面,要把results[0].keypoints.data里的置信度打出来看看。如果持续偏低,优先考虑调整机位和距离,而不是靠代码硬扛。代码层面的兜底方案是:关键点conf低于 0.5 的关键点不参与角度计算,当一条关键点链里存在低置信点的时候,宁可跳过这一帧不更新计数,也算不出一个“看起来很合理但实际是垃圾”的值。
4.3 多人场景下只跟踪一个人怎么办
ai_gym默认的处理逻辑通常只关注画面里的一个主要目标。原因很简单:它要为“一个人”维护一套计数状态,如果同时处理五个人,就得给每个人各维护一套状态,复杂度立刻上升。
如果你想做多人同时计数,我建议不要直接魔改ai_gym,而是把它的思路拆出来:先用姿态模型得到所有人的关键点,再给每个人分配一个跟踪 ID,为每个 ID 维护独立的angle和count状态。这样每个目标各算各的,互不干扰。工程上可以搭配 ByteTrack 这类多目标跟踪器,你自己写的话,只需要按检测框中心距离做最简单的帧间匹配也能跑起来。
4.4 机位问题让角度失真,这锅不该模型背
同样一个深蹲,正面拍摄时髋膝踝三点几乎在一条竖直线上,膝角变化很小,根本看不出来下蹲深度;侧面拍摄时角度变化就非常明显。这是几何问题,不是模型问题。
我做动作数据采集时的经验是:摄像头尽量放在人的侧面,和人体的运动平面成 45 度到 90 度角。俯卧撑、深蹲这类垂直面动作,侧面机位最好。如果实在只能正面拍,那就别依赖绝对角度,尝试用角度变化量做计数,也就是看“角度相对初始值变化了多少”,能缓解一部分问题。
4.5 性能吃紧,先优化推理再优化逻辑
姿态估计的耗时大头在模型推理,角度计算和绘图可以忽略不计。想提速,顺序是:换轻量模型 -> 调小推理尺寸 -> 跳帧处理 -> 开多线程读帧。我个人不太建议一开始就上 TensorRT 或者半精度,那是推理速度还不够时才考虑的优化手段,日常调试阶段先把模型和输入尺寸定好,观察帧率能不能接受,再决定要不要做更深层的加速。
5. 更进一步:如何用 ai_gym 的思路定制自己的动作
5.1 自定义动作的本质:把动作拆成角度循环
ai_gym并没有真正“理解”俯卧撑或者深蹲,它只是用一组关节角度变化来定义动作的阶段。所以,任何能表达成“A 阶段 -> B 阶段 -> A 阶段”循环的运动,都可以套用这套计数逻辑。
举个例子,哑铃弯举:手臂伸直时肘角接近 180 度,弯举起来后肘角明显变小,然后再伸直。这就是一个标准的“大角度 -> 小角度 -> 大角度”循环。类似地,仰卧起坐可以用肩髋角度变化来做;康复训练里的抬腿次数、膝关节屈伸次数,本质也是同一个套路。你换一个场景,真正要改的只有两件事:选哪几个关键点,阈值设多少。
5.2 一个简单的自定义计数器骨架
如果你要做的动作不太复杂,可以不用完整读透ai_gym的每个细节,直接照着状态机写一个精简版。下面是一个最小骨架:
class CustomActionCounter: def __init__(self, threshold_down=60, threshold_up=120): self.threshold_down = threshold_down self.threshold_up = threshold_up self.count = 0 self.is_down = False def update(self, current_angle): # 进入下放阶段 if current_angle < self.threshold_down and not self.is_down: self.is_down = True # 回到撑起阶段,完成一次计数 elif current_angle > self.threshold_up and self.is_down: self.count += 1 self.is_down = False return self.count你需要自己补一个compute_angle函数,从关键点数组里取对应索引算出角度。建议把关键点索引做成常量字典,比如:
JOINTS = { "shoulder": 6, "elbow": 8, "wrist": 10, }这样代码可读性会好很多,也方便以后换动作。
5.3 用角度曲线来确定阈值,而不是拍脑袋
自定义动作最容易被忽略的环节是阈值标定。很多人拿到threshold_down=60、threshold_up=120就直接跑,结果发现计数值和自己的主观感受对不上。
我的建议是,先录一段你自己的标准动作视频,离线跑一遍姿态模型,把每一帧的目标关节角度保存下来,然后画出一条角度随时间变化的曲线。你会发现曲线里有规律的波峰和波谷:波谷对应动作的最低点角度,波峰对应最高点角度。阈值就取在波峰和波谷之间的中间位置。比如波谷是 50 度、波峰是 140 度,那下放阈值可以设 70、上举阈值设 110。这样设置,即使动作速度有快有慢,计数也能保持稳定。
5.4 把 ai_gym 当作模板,而不是黑盒
读完成套代码之后你会发现,ai_gym里最值得带走的不是那些类名和属性名,而是“模型给出关键点,逻辑层算角度,状态机判阶段,绘图层做展示”这套工程范式。我自己后来做关节活动范围检测时,就是照着这个思路重新组织代码:推理部分用 YOLO 姿态模型,数据分析部分自己写,绘图部分复用Annotator。核心逻辑没变,换的只是阈值和应用场景。
我还建议你在自己的项目里把“计算逻辑”和“绘图逻辑”分开,不要全都塞在同一个函数里。实测下来,这样调试效率高很多,改一个业务条件不用来回翻绘图代码,甚至你可以直接去掉绘图,只在后台跑计数,性能会更好。
5.5 这个思路还能延展到不少场景
健身房只是最直接的应用场景。把同一套“关键点 -> 关节角度 -> 状态机”的思路往下推,可以做康复训练时的关节活动度记录,也可以做久坐场景下的起身次数提醒,甚至连简单的人体异常姿态报警都可以用类似逻辑实现。核心都是先想清楚一件事:你要监测的那个动作,是靠哪几个关节、哪个角度的变化来定义的。想清楚这一点,代码只是抄作业的问题。
我个人在大量实测中的体会是,ai_gym最值钱的地方不在那几行计数代码,而是把姿态估计应用工程化的完整思路。自己动手改造一遍之后,你会对“模型推理结果怎么变成业务数据”有更直觉的理解。最后再分享一个实操技巧:别直接拿摄像头实时调阈值,先把视频录下来,离线把角度曲线打印出来,看清楚再上实时。调参的速度会快非常多。