☰
基于OpenCV的人体姿态识别与深蹲计数系统实战解析
2026/10/17 3:35:27 网站建设 项目流程

前阵子帮一位做运动康复的朋友验证一个想法:让一个普通人站到摄像头前做深蹲,系统能自动报出"这组做了几个、动作质量如何、哪里要改"。我一开始以为这事很简单,OpenCV嘛,把人框出来,量一量角度不就完了。真做下去才发现,从"画面里有个人"到"系统敢下结论说这个深蹲有效还是无效",中间隔着人体姿态识别、人体关键点建模、动作阶段判断、姿态评估规则、部署调优一整条链路,绝不是接一个现成模型就能糊弄过去的。

这篇文章就把这条链路完整拆开讲:基于OpenCV怎么做人体姿态识别、关键点如何参与深蹲计数、姿态评估和康复训练矫正怎么落到代码上,最后聊一聊部署时那些没人提前告诉你的坑。适合已经能写基础Python、但还没接触过姿态识别的朋友,也适合想快速搭一个健身或康复辅助Demo的工程师。

1. 方案选型与整体链路:先把"AI健身教练"拆成小问题

1.1 这个项目要解决的其实不是识别问题,而是判断问题

标题里一口气列了三件事:人体姿态识别、关键点识别、深蹲计数与姿态评估。很多人会误以为只要把关键点检测模型跑起来,后面一切自动成立。实际不是这样。

"AI健身教练"这个目标可以拆成四个递进的子问题:

  1. 从画面中找到人,并定位关键点,也就是人体关键点识别环节。
  2. 把关键点坐标翻译成有物理意义的动作指标,比如关节角度、躯干倾角。
  3. 根据连续帧的指标变化,判断动作阶段,完成深蹲计数。
  4. 结合一组可解释的规则或评分策略,判断动作质量,输出矫正建议。

我在一开始犯的错就是盯着第1步不放,反复对比模型,结果在第2、3步上浪费了大量时间。真实项目里,模型只是入口,从"识别到人"到"判断动作"之间的后处理逻辑,才是功能和差异化所在。整套系统也从"单模型"变成了一条轻量管线。

说人话:关键点识别是地基,但上面的判断逻辑才是房子。

1.2 关键点识别方案选择:我为什么最后用了MediaPipe Pose

"基于OpenCV的人体姿态识别"这个描述有一点容易误导人:许多人以为OpenCV自带姿态识别能力,实际上OpenCV更多承担的是图像采集、画框、计算和IO工作,真正给出人体关键点的是一个单独的推理模型。OpenCV DNN模块可以把现成的姿态模型权重加载进来跑,也可以把其他库输出的人体关键点拿来继续做几何计算。

我自己实测下来,主流的低成本方案有三类,各有取舍:

方案关键点数量部署成本CPU实时性参考适用场景
OpenCV DNN + OpenPose18/25需要下载权重,中等约3-8 FPS离线批处理,精度优先
MediaPipe Pose33pip安装即可,低约25-30 FPS实时单人场景,首选
MoveNet Thunder17依赖库安装,低30 FPS以上边缘设备、低算力硬

我最后选了MediaPipe Pose,不是因为精度碾压,而是因为三个现实原因。第一,它一次给出33个关键点,髋、膝、踝、肩、脚趾都有,深蹲分析需要的点全齐了。第二,它自带Visibility置信度,关键点被遮挡时能明确告诉我们"这个点不可信",这对后续判断安全很关键。第三,纯CPU也能跑到25帧以上,部署成本极低。

OpenCV在这个方案里的定位更纯粹:读帧、画骨架、叠加角度文字、写视频。两者各干各的,配合非常舒服。

1.3 一条完整的数据流长什么样

整个系统内部数据的走向是这样一步步传下去的:

摄像头帧或者视频帧进来后,先送入关键点检测器,拿到33个点的x、y像素坐标和visibility置信度;然后经过坐标平滑和无效点剔除,进入几何计算模块,算出膝关节角、髋关节角、躯干倾角等量化指标;接着由计数状态机消费这些角度序列,判断当前处于下蹲还是站立阶段,进行深蹲计数;每帧结束前,姿态评估模块会额外检查膝盖内扣、前移、前倾等质量问题,叠加到渲染画面里。

有人可能会问,直接拿一帧判断不行吗?行,但会非常抖。后面第3节会专门说状态机这件事,这里先记住一个结论:姿态识别类项目,单帧识别只是起点,帧与帧之间的时序判断才决定了项目能不能用。

2. 深蹲的几何建模:像素坐标如何变成"动作语义"

2.1 为什么判断深蹲要用角度而不是坐标

深蹲分析最常见的办法是看关键点之间的几何关系,而不是直接比较像素距离。原因是像素坐标天然受人的身高、离摄像头远近、画面分辨率影响。

举个简单例子:同一个深蹲动作,距离镜头1米和2米时,膝盖在画面里的y坐标差了上百个像素,直接用坐标阈值去判断动作,换个人、换个机位,规则就要推翻重来。

角度没有这个问题。膝关节角定义为大腿和小腿的夹角,无论训练者高矮、画面远近,站直时大约180度,蹲到底时通常小于100度,这个关系基本稳定。

这和量身高一个道理:你判断一个人比另一个人高,要看比例而不是照片上差了几个像素。

2.2 关节角度的计算方法:向量点积

人体关键点坐标有了之后,计算关节角用的是向量点积加反余弦。比如要算左膝关节角,就用左髋、左膝、左踝三个点,把"膝盖指向髋部"和"膝盖指向脚踝"看作两条向量,夹角就是膝关节角。

下面是核心代码,这个函数在项目里到处被复用:

import math def calc_angle(a, b, c): # a、b、c 是 (x, y) 坐标,b 是角的顶点 ab = (a[0] - b[0], a[1] - b[1]) cb = (c[0] - b[0], c[1] - b[1]) dot = ab[0] * cb[0] + ab[1] * cb[1] ab_len = math.hypot(ab[0], ab[1]) cb_len = math.hypot(cb[0], cb[1]) # 浮点误差会导致 cos 值略超出 [-1, 1],必须先夹取 cos_angle = max(-1.0, min(1.0, dot / (ab_len * cb_len))) return math.degrees(math.acos(cos_angle))

很多新手写到这里容易漏掉clamp那一步。向量点积累积浮点误差后,dot / (ab_len * cb_len)可能出现1.0000001,直接扔进acos会得到nan,而这个nan会顺着管线一直污染到计数逻辑。调试了半天发现角度突然变成空值,多半就是这里的问题。

2.3 关键点置信度与平滑滤波:别拿脏数据去算角度

MediaPipe Pose 的每个关键点都带一个visibility字段,数值在0到1之间,表示模型对这个点位置的信心。遮挡、转身、出画时,visibility 会急剧下降。

一个稳健的做法是:计算前先过滤无效点,任何相关关键点 confidence 低于0.5,这次角度计算就不参与后续判断。注意不要硬填默认值,如果一个关键点根本不在画面里,不是靠脑补一个坐标能解决的。

姿态点还有一个问题是抖动。单帧检测出来的关键点在静止时也会有几像素的随机波动,算成角度后表现为几度的小幅震荡,虽然幅度不大,但叠加到状态机里容易在阈值附近来回横跳。我试过几种平滑方式,最实用的还是轻量EMA滤波:

alpha = 0.35 smoothed_x = alpha * cur_x + (1 - alpha) * smoothed_x smoothed_y = alpha * cur_y + (1 - alpha) * smoothed_y

alpha越大,跟随当前帧的速度越快,平滑效果越弱;alpha越小,曲线越丝滑,但滞后越明显。深蹲动作本身不算快,0.3到0.4的取值实测体验比较好。这里明确一点:EMA是经验参数,不是理论最优值,我针对普通成年人的慢速深蹲标定过,如果做快速深蹲,alpha要调大一些,否则角度会显得拖沓、状态切换变慢。

2.4 可视化:OpenCV在项目里的主要体力活

可视化不是功能必需,但调试时没有可视化简直寸步难行。我的做法是把关键点、骨架和实时角度直接画在帧上,跑一次就能肉眼看出计算逻辑哪里不对。

import cv2 def draw_skeleton(frame, landmarks, connections): h, w = frame.shape[:2] for lm in landmarks: x, y = int(lm.x * w), int(lm.y * h) cv2.circle(frame, (x, y), 4, (0, 255, 0), -1) for conn in connections: p0 = landmarks[conn[0]] p1 = landmarks[conn[1]] x0, y0 = int(p0.x * w), int(p0.y * h) x1, y1 = int(p1.x * w), int(p1.y * h) cv2.line(frame, (x0, y0), (x1, y1), (0, 255, 255), 2) # 在膝盖附近叠加角度文字 cv2.putText(frame, f"knee: {knee_angle:.0f}", (knee_x, knee_y), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)

画完之后我会把每一帧的角度值同时打印到终端,形成一条时间序列。跑几组动作下来,如果发现角度变化趋势和肉眼看到的不一致,优先查上游的平滑和遮挡,而不是改下游阈值。

3. 深蹲计数:为什么不能靠"膝角小于90度"硬数

3.1 单帧阈值看似合理,实际会被抖动坑死

最直觉的计数方案是:膝关节角小于某个阈值算一次下蹲,大于某个阈值算一次站立,然后数一数切换了几次。真去跑一遍就会发现,角度在阈值附近会反复横跳,一次标准的深蹲可能在临界区来回穿越好几回,计数瞬间变成3次甚至5次。

问题还不止噪声。如果人只是弯腰绑鞋带,膝关节角度也会短期变小,但这不是一个深蹲。一个动作是否能被定义为"深蹲",必须有完整的"站→下→停→起→站"过程,单帧条件完全抓不住这个过程。

这就像判断一部电影是不是喜剧,不能只看某一帧画面里有没有人在笑,要看整段情节的推进结构。动作计数也需要"情节结构",也就是状态。

3.2 状态机设计:给动作一个完整生命周期

我设计的状态机包含两个核心状态:上升态和下降态。从站直开始,膝角小于进入阈值时切换到下降态,并持续记录这个过程中最小的膝角;从下降态回到上升态时,用最小膝角判断刚才那一次下蹲是否够深,够深才算一次有效深蹲,不够就记录一条"深度不足"的质量事件。

核心代码如下:

class SquatCounter: def __init__(self): self.phase = "UP" self.lowest_angle = 180.0 self.count = 0 self.quality_events = [] def update(self, knee_angle): if self.phase == "UP": # 连续低于进入阈值,切换到下降态 if knee_angle < 115: self.phase = "DOWN" self.lowest_angle = knee_angle elif self.phase == "DOWN": # 记录下蹲过程的最小膝角,作为深度依据 self.lowest_angle = min(self.lowest_angle, knee_angle) # 膝角恢复到较高值,说明人基本站直了,一次动作结束 if knee_angle > 145: if self.lowest_angle < 90: self.count += 1 else: self.quality_events.append("depth_insufficient") # 回到初始状态,恢复最低角度记录 self.phase = "UP" self.lowest_angle = 180.0

这里三个阈值要一起理解,不能单独调:

  • 进入下降态的115度,用来确认动作确实在下蹲。它不能设太高,否则轻微屈膝就会被误判为深蹲开始。
  • 回到站立态的145度,用来确认人确实站起来了。它和进入阈值之间的空档就是滞后带,刻意制造一段"都不作数"的区间,让临界抖动不再触发误计数。
  • 判定有效深蹲的90度,指达到动作最低点时膝关节角必须小于90度。这个值对应普通成年人蹲到大腿接近水平的位置,如果做的是浅蹲,可以放宽到100甚至110。

滞后带是这个状态机的灵魂。我在调试时遇到过一种情况:不设滞后带,一组10个深蹲数出来24个;加上滞后带并配合下一节的连续帧确认,计数瞬间变得稳定。

3.3 连续N帧确认:再给状态机加一道保险

滞后带解决了阈值附近的抖动,但还没有解决偶发的单帧检测错误。我实测中遇到过这种场景:训练者站着没动,某一帧因为手部遮挡导致腿部关键点突然错位,膝角瞬间被算成80度,状态机当场进入下降态。

为此我在切换状态前增加一个最小停留帧数要求:连续5帧膝角低于115度,才真正进入下降态;连续5帧高于145度,才允许结束一次动作。代码上用一个持续计数器实现:

class SquatCounter: def __init__(self): # ... self.buffer = {"DOWN": 0, "UP": 0} def update(self, knee_angle): if self.phase == "UP": if knee_angle < 115: self.buffer["DOWN"] += 1 if self.buffer["DOWN"] >= 5: self.phase = "DOWN" self.lowest_angle = knee_angle self.buffer["DOWN"] = 0 else: self.buffer["DOWN"] = 0 elif self.phase == "DOWN": # 更新最低角度的逻辑不变 ...

这个"先缓冲、再切换"的思路在动作识别里非常好用,几乎可以套用到所有阶段型动作上。它的代价是最多少识别一两帧的起始位置,但对计数结果来说,牺牲这种程度的实时性完全值得。

4. 姿态评估与康复矫正:从"数几个"到"做得好不好"

4.1 常见深蹲问题与判定规则

深蹲计数只是第一层功能,"AI健身教练"的核心价值在姿态评估。现实里新手深蹲最常犯的错误基本集中在四类:蹲得不够深、膝盖内扣、膝盖过度前移、躯干过度前倾。每类问题都可以用关键点几何关系定义成规则。

问题类型判定逻辑参考阈值
蹲深不足动作最低点时膝关节角过大大于100度提示"蹲低一点"
膝盖内扣左右膝连线方向与左右髋连线方向偏转偏转超过5度提示"膝盖向外打开"
膝盖过度前移膝关节点相对踝关节点的水平偏移过大偏移超过胯宽的20%时提示
躯干过度前倾肩髋连线与垂直方向的夹角大于45度提示"挺胸收腹"

下面以膝盖内扣为例,说清楚实现逻辑。左右髋连线可以近似看作胯的朝向,膝盖连线如果和这条线明显不平行,就说明膝盖在向内塌。利用两个线段的水平夹角就能计算偏转:

def knee_valgus(left_hip, right_hip, left_knee, right_knee): hip_vec = (right_hip[0] - left_hip[0], right_hip[1] - left_hip[1]) knee_vec = (right_knee[0] - left_knee[0], right_knee[1] - left_knee[1]) angle = calc_angle_between_vectors(hip_vec, knee_vec) if angle > 5: return "膝盖内扣,注意向外打开" return None

阈值不是拍脑袋定的,需要结合训练者的髋宽、身高和训练目标一起看。例如女性训练者骨盆宽度不同,膝内扣的敏感阈值严格来说应该用髋宽归一化,这里5度只是普通人群的参考值,落地时要针对目标群体重新标定。

4.2 实时反馈:文字、颜色与训练报告

规则的输出不能只是一堆日志。我在实际项目里做了三层反馈:

第一层是渲染层,OpenCV在帧上实时标注角度与提示语,同时把膝关节角颜色划分成绿、黄、红三档,代表正常、警告、严重偏差。这一层用于现场演示和调试。

第二层是语音提示,在检测到严重错误时通过简单的语音合成播放短句,比如"膝盖向外""蹲深一点"。用系统自带的语音接口就能做到,不需要额外质量非常高的合成音,关键是提示词要短、指令要明确。

第三层是训练报告,每次训练结束后把计数结果、质量问题列表、角度时间序列整理成JSON或CSV落盘。报告可以直接生成趋势线,展示连续多天动作质量的变化,这是康复训练场景真正需要的数据。

4.3 康复训练矫正场景的落地与边界

标题里提到的"康复训练矫正"我理解得很明确:不是让系统替代康复师,而是作为家庭训练的辅助工具,让训练者在没有人在旁指导时也能获得动作层面的客观提示。

这类场景有几个特点,和普通健身Demo不同。第一,训练者动作可能更慢、幅度更受限,状态机阈值必须可配置,不能假设每个人都能蹲到90度。第二,疲劳会导致动作质量逐渐下降,只看单帧评估不够,还要记录整组动作的角度方差和最低点变化趋势,比如第8次比第4次最低点多出了15度,这就是一个安全隐患信号。第三,系统里展示的所有提示都必须限定在动作姿势层面,不能给出任何"康复方案"建议。

我的做法是在报告的固定位置加一句边界说明:"本工具仅提供动作层面的量化提示,不构成任何医疗或康复建议。"这样既符合项目定位,也不会误导使用者。

5. 部署与实测调优:脚本能用和现场能用的差距

5.1 工程结构:五个文件撑起一个可用Demo

如果只是写几行脚本验证模型,后面一定会在调试中陷入混乱。我重构后沉淀了一套很轻的工程结构,每个文件职责单一,方便替换模型和调参:

squat_coach/ ├── main.py # 主循环:读帧、调用各模块、渲染 ├── detector.py # 关键点检测:封装MediaPipe/OpenCV DNN ├── analyzer.py # 几何计算:角度、平滑、问题规则 ├── counter.py # 状态机计数与质量事件记录 ├── feedback.py # 文字/语音/报告输出 └── config.json # 阈值、平滑系数、输出开关

主循环其实很短,核心逻辑基本是"读一帧→检测→分析→计数→渲染":

import cv2 from detector import PoseDetector from analyzer import PoseAnalyzer, SmoothFilter from counter import SquatCounter cap = cv2.VideoCapture(0) detector = PoseDetector() smoother = SmoothFilter(alpha=0.35) analyzer = PoseAnalyzer() counter = SquatCounter() while True: ok, frame = cap.read() if not ok: break landmarks = detector.detect(frame) if landmarks is not None: smoothed = smoother.apply(landmarks) angles = analyzer.compute_angles(smoothed) counter.update(angles["left_knee"]) frame = feedback.render(frame, angles, counter) cv2.imshow("Squat Coach", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break

一个容易被忽视的设计点:阈值参数不要散落在代码里,尽量放到config.json。因为不同人群的深蹲标准可能差很多,现场调参是常态,没有一个集中位置几乎没法维护。

5.2 性能优化:不要盲目追求30帧

很多人在部署阶段第一反应是"帧率不够,换个更重的模型",我的经验完全相反。

深蹲这个动作本身持续时间长、变化慢,系统真正需要的只是"能跟上动作节奏"的更新频率。我在低配机器上实测过,15到20帧每秒的推理频率已经完全够用,判断依然稳定。盲目追求30帧反而会带来两个问题:一是CPU占用率飙升,二是模型更复杂后帧间抖动反而可能增加。

三个最有效的优化手段,按收益排序:

  1. 降低输入分辨率。640x480对关键点检测足够,不需要1920x1080的原始分辨率。
  2. 跳过不必要的推理帧。比如每两帧推理一次,中间一帧直接沿用上一次角度结果。
  3. 限制检测区域。用上一帧人的包围盒稍微外扩作为下一帧的ROI,能显著减少无关区域干扰。

5.3 机位、光线、衣着:数据质量比模型更影响结果

这里要专门强调一个很多人忽视的事实:当模型已经选定时,结果质量的上限是由摄像头机位、光线和衣着决定的,而不是模型本身。

机位方面,摄像头要放在训练者正前方约1到1.5米处,镜头高度与胸部齐平,画面里要能看到完整的脚和手。侧面角度是深蹲分析的大忌,侧视时两只膝盖在画面里会高度重合,左右侧关键点极其容易串线,算出来的角度毫无意义。

光线方面,室内顶光导致膝盖以下落大块阴影时,脚踝关键点的confidence会掉到0.3左右,脚踝一旦不可信,膝关节角整个就是废的。处理方式很简单:前置一盏补光,让正面光线均匀打到小腿和脚上。

衣着方面,宽松裤子的褶皱会造成膝盖位置偏移,我实测过深色阔腿裤下膝关节点会频繁上移,角度计算偏差最多能到十几度。不能粗暴要求所有用户换衣服,但至少可以在界面里提示:"建议穿着贴合腿型的衣物进行训练。"

5.4 我记录的一批实测坑

最后把我反复踩过的坑集中列一下,每条都是真金白银换来的。

多人在画面里时,MediaPipe会返回所有人的关键点。计数逻辑必须做目标选择,我按"谁的检测框面积最大就锁定谁"处理,否则路过的人会让计数器突然跳数。

摄像头帧画面中出现人下半身出画的情况,此时脚踝关键点缺失,浏览器或视频流里的表现是角度突变。我用visibility过滤掉不可信点后,状态机自然不会把脏角度当成动作。

关于EMA平滑还有一个细节:在状态机的"下降到站立"切换瞬间,EMA会造成角度滞后,导致动作结束时刻的判定比真实情况晚几帧。这不是bug,是所有低通滤波的固有代价,只要阈值和缓冲帧数配合合理,最终结果不受影响。

还有一个部署时的诡异问题:同样的代码在性能好的机器上跑没问题,换到低配机器上经常出现一卡一卡的现象。后来定位到是画面渲染和推理在同一个线程里争用CPU。解决办法很朴素:把cv2.imshow的窗口目标帧率降下来,或者干脆每5帧刷新一次画面,推理逻辑不要跟着窗口一起急躁。

关于这套系统,最后分享三点实际体会

如果现在有人让我重新做一个类似的深蹲姿态识别项目,我大概率会把大部分时间继续花在第3、4节的内容上:状态机、规则标定、反馈体验。模型选型基本一天就能定下来,而真正决定项目能不能在真实场景里跑起来、用户愿不愿意每天打开它,靠的是后处理设计的细致程度。

我个人还有一个体会是:这类辅助工具做出来之后,一定要先让自己试着一个星期,记录每一次"它说对了"和"它说错了"的时刻。我最初的情绪反馈也踩了坑,提示语太模糊,用户根本不知道该怎么改;改成"膝盖向两侧打开一点"这种指向明确的话之后,训练者才真的会照做。

这套结构稍加改造,换成深蹲之外的动作也完全成立。比如站姿的肩部推举看肩肘角度、硬拉看躯干前倾和脊柱角度,核心分析方法是一致的。先把深蹲这一条链路跑通,后面面对其他动作时,你就知道每一层的关注点分别在哪里了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询