ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenCV与MediaPipe姿态检测实战:关键点稳定与动作计数

OpenCV与MediaPipe姿态检测实战:关键点稳定与动作计数

简介:这是一份面向计算机视觉初学者与开发者的OpenCV与MediaPipe人体姿态检测项目源码包,聚焦Windows环境下实时姿态识别的完整实现,可用于人机交互、运动分析、安全监控等场景的快速验证与二次开发。压缩包共5个文件,约7KB,包含py主程序脚本、txt依赖清单、html页面、md说明文档及inscode配置文件,分别承担核心检测逻辑、环境依赖声明、项目说明与工程配置等用途,结构精简、便于直接运行与阅读。目前已有96人学习下载。资源围绕姿势检测对象初始化、视频流处理、关键点与连接线绘制、FPS计算显示等核心环节给出可运行代码,读者可据此掌握从环境搭建到摄像头实时检测的完整链路,并在此基础上扩展自定义姿态应用,适合作为入门实践与项目参考。

1. 从「能跑就行」到「关键点稳定」:OpenCV 与 MediaPipe 姿态检测到底能解决什么

如果你做过健身计数、动作纠正、体感交互这类需求,大概率绕不开一个尴尬:摄像头画面里人一动,关键点就飘,肘关节跳到肩膀上,膝盖瞬间消失。用纯 OpenCV 做背景差分或者轮廓分析,光照一变、衣服颜色一深,整条链路直接崩。而 OpenCV 加 MediaPipe 这套组合,本质是把「检测人体关键点」这件事从手写特征工程里解放出来——MediaPipe 负责在单帧里回归出 33 个身体关键点的坐标和可见性,OpenCV 负责视频流读取、颜色空间转换、绘制骨架和后续几何计算。这份源码包就是围绕这条链路拆出来的可运行工程,适合想快速验证姿态检测可行性、又不想从零训模型的开发者。它不解决「多人拥挤场景下的身份追踪」,也不保证极端遮挡下的精度,但在单人、中近景、常规光照下,足够让你把动作计数、角度判断、姿态比对这些逻辑先跑通。下面按「环境怎么搭、关键点怎么读、角度怎么算、坑在哪」的顺序,把这份资源拆开讲。

2. 环境搭建与视频流接入:把摄像头和文件源都跑通

2.1 依赖选型:为什么是 mediapipe + opencv-python 而不是别的

常见做法是直接用opencv-python读帧,再把 BGR 帧转成 RGB 喂给 MediaPipe。这里有个选型细节:MediaPipe 的 Python 包对 numpy 版本和 protobuf 版本比较敏感,很多「装完 import 就报错」的情况不是代码问题,是依赖版本打架。我一般会先锁一个相对干净的组合,再动业务代码。

# 建议在虚拟环境里操作,避免污染系统 Python python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 先装 numpy 和 opencv,再装 mediapipe,顺序能减少解析冲突 pip install numpy==1.24.3 pip install opencv-python==4.8.1.78 pip install mediapipe==0.10.9

逻辑说明:numpy锁在 1.24.x 是因为部分 MediaPipe 轮子对 numpy 2.x 的 ABI 不兼容,装完会出现ImportError: numpy.core.multiarray failed to import。opencv-python选 4.8.x 是兼顾VideoCapture的稳定性和cv2.putText的中文支持边界。mediapipe0.10.x 的solutions.pose接口已经稳定,没必要追最新。参数上,如果你用的是 ARM 板子,opencv-python要换成opencv-python-headless并自行编译,否则 GUI 相关符号会缺。

2.2 读摄像头与读视频文件:两套初始化写法

源码里通常会把「实时摄像头」和「离线视频」做成两个入口。实时场景下cv2.VideoCapture(0)的 0 是设备索引,离线场景传文件路径。这里最容易翻车的是分辨率设置:不设CAP_PROP_FRAME_WIDTH和CAP_PROP_FRAME_HEIGHT,有些摄像头默认给 640×480,关键点在小分辨率下抖动明显。

import cv2 import mediapipe as mp mp_pose = mp.solutions.pose mp_draw = mp.solutions.drawing_utils # 实时摄像头:索引 0;离线视频改成 "demo.mp4" cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 30) # 关键参数:static_image_mode=False 走视频追踪,model_complexity=1 平衡精度和速度 with mp_pose.Pose( static_image_mode=False, model_complexity=1, smooth_landmarks=True, min_detection_confidence=0.5, min_tracking_confidence=0.5 ) as pose: while cap.isOpened(): ret, frame = cap.read() if not ret: break # MediaPipe 要 RGB,OpenCV 给的是 BGR,这一步不能省 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) rgb.flags.writeable = False # 只读标记能省一次拷贝 results = pose.process(rgb) rgb.flags.writeable = True frame = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) if results.pose_landmarks: mp_draw.draw_landmarks( frame, results.pose_landmarks, mp_pose.POSE_CONNECTIONS ) cv2.imshow("pose", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

逻辑说明:static_image_mode=False表示按视频流处理,MediaPipe 会利用上一帧信息做追踪,比逐帧独立检测更稳、更快;如果你要处理的是单张图片,必须改成True,否则第一帧之后的关键点会受历史状态影响。model_complexity取 0/1/2,0 最快但精度最低,2 最准但帧率掉得厉害,普通 CPU 上 1 是甜点。smooth_landmarks=True会对关键点做时间平滑,代价是快速动作时会有轻微延迟,做高抬腿计数时如果发现「腿已经落下去了计数才加」,可以把它关掉试试。min_detection_confidence和min_tracking_confidence低于 0.5 会引入更多误检,高于 0.7 在遮挡时容易丢点,0.5 是常见起点。

2.3 关键点索引与坐标系:先搞清楚 33 个点分别在哪

MediaPipe Pose 输出的是归一化坐标,x、y在 0 到 1 之间,z是相对深度,visibility表示该点可见概率。要算角度,必须先把归一化坐标乘回像素宽高。源码里一般会封装一个landmark_to_pixel函数。常见的关键点索引:11/12 是左右肩,13/14 是左右肘,15/16 是左右腕,23/24 是左右髋,25/26 是左右膝,27/28 是左右踝。做深蹲计数时盯 23、25、27 三个点就够;做手臂弯举盯 11、13、15。这里有个容易忽略的点:左右是「人体左右」不是「画面左右」,人转身之后索引不变,但画面位置会互换,做左右侧判断时要结合髋部两点连线方向来定,不能硬编码。

3. 从关键点到业务逻辑:角度计算、动作计数与可视化

3.1 三点夹角计算:向量点积的工程写法

姿态检测落地到业务,八成绕不开「算角度」。比如肘关节角度就是肩—肘—腕三点形成的夹角。用向量点积公式,注意分母加一个极小值防除零。

import math def calc_angle(a, b, c): """ a, b, c 为像素坐标 (x, y) 返回 b 点处的夹角,单位度 """ ang = math.degrees( math.atan2(c[1] - b[1], c[0] - b[0]) - math.atan2(a[1] - b[1], a[0] - b[0]) ) ang = abs(ang) if ang > 180: ang = 360 - ang return ang def landmark_to_pixel(landmark, w, h): return int(landmark.x * w), int(landmark.y * h)

逻辑说明:这里用atan2差值法而不是点积反余弦,原因是atan2在接近 0 度和 180 度时数值更稳,点积法在夹角接近 0 时arccos对浮点误差极其敏感,会出现「明明手臂伸直却算出 3 度」的玄学。参数上,w和h必须用当前帧的实际宽高,不能写死 640/480,否则换分辨率后角度全错。visibility低于 0.5 的点建议直接跳过该帧的角度计算,而不是拿一个飘掉的坐标硬算,否则计数会乱跳。

3.2 动作计数状态机:别用单帧阈值硬判

很多人第一版计数逻辑是「角度小于 90 就加一」,结果一个动作加五六次。正确做法是维护一个状态机:只有从「伸直态」进入「弯曲态」再回到「伸直态」才算一次完整动作。

class RepCounter: def __init__(self, down_thresh=90, up_thresh=160): self.down_thresh = down_thresh self.up_thresh = up_thresh self.state = "up" # up 表示伸直,down 表示弯曲 self.count = 0 def update(self, angle): if angle < self.down_thresh and self.state == "up": self.state = "down" elif angle > self.up_thresh and self.state == "down": self.state = "up" self.count += 1 return self.count

逻辑说明:down_thresh和up_thresh之间留了一段「死区」,是为了防止角度在阈值附近抖动导致状态反复横跳。做弯举时down_thresh取 90、up_thresh取 160 比较通用;做深蹲时膝盖角度变化范围更大,可以设成 100 和 170。如果发现计数偏少,先看visibility是不是经常掉,再看阈值是不是太苛刻。这个状态机是单关节的,做全身动作(比如波比跳)需要多个关节状态做与逻辑,源码里如果有扩展位,一般会留一个multi_joint的钩子。

3.3 骨架绘制与自定义可视化:把关键点画成你要的样子

mp_draw.draw_landmarks默认画的是官方配色,实际项目里往往要突出某个关节或者叠加角度数值。常见做法是关掉默认绘制,自己用cv2.circle和cv2.line画。

def draw_custom(frame, landmarks, w, h, highlight_ids=(13, 14)): for idx, lm in enumerate(landmarks.landmark): cx, cy = int(lm.x * w), int(lm.y * h) color = (0, 0, 255) if idx in highlight_ids else (0, 255, 0) cv2.circle(frame, (cx, cy), 5, color, -1) # 画肘—腕连线示例 for a, b in [(13, 15), (14, 16)]: pa = landmarks.landmark[a] pb = landmarks.landmark[b] cv2.line(frame, (int(pa.x * w), int(pa.y * h)), (int(pb.x * w), int(pb.y * h)), (255, 0, 0), 2)

逻辑说明:highlight_ids用来把关心的关节标红,调试角度逻辑时非常直观。cv2.circle的半径 5 在 720p 下刚好,1080p 可以调到 7。注意landmarks.landmark是个列表,索引和前面说的 33 点索引一致。如果画面里出现「骨架画在背景上」的情况,八成是frame和rgb的转换顺序写反了,绘制必须发生在转回 BGR 之后。

4. 避坑与排查:姿态检测里那些让人怀疑人生的瞬间

4.1 现象:关键点整体偏移,骨架像「飘」在人旁边

原因:cv2.cvtColor转 RGB 之后又转回 BGR,但绘制时用的还是转回前的引用,或者rgb.flags.writeable没恢复导致后续写入被忽略。解决:严格按「BGR 读入 → 转 RGB → 只读标记 → process → 恢复可写 → 转回 BGR → 绘制」的顺序,中间不要插入其他颜色操作。如果用的是自己封装的read_frame,检查它有没有偷偷做一次 resize 而没同步更新宽高。

4.2 现象:帧率从 30 掉到 8,画面卡成幻灯片

原因:model_complexity设成了 2,或者每帧都在重新初始化Pose对象。解决:Pose对象必须在循环外创建,用with管理生命周期;CPU 场景把model_complexity降到 1 或 0;如果还卡,检查是不是在循环里做了cv2.imwrite或者把每帧都存进了列表。另一个隐藏原因是摄像头分辨率设到了 4K,MediaPipe 内部会先缩放,但 OpenCV 读 4K 帧本身就很贵,降到 720p 通常帧率翻倍。

4.3 现象:遮挡后关键点消失,恢复时计数乱加

原因:min_tracking_confidence太低,遮挡时 MediaPipe 用错误的历史信息硬推了一个点;或者状态机在visibility低时仍然更新。解决:在update之前加一道visibility过滤,低于 0.5 直接return self.count不更新状态;同时把min_tracking_confidence提到 0.6 左右。如果业务允许,遮挡超过 N 帧后重置状态机,避免「人回来之后凭空多一次」。

4.4 现象:换一台机器就报AttributeError: module 'mediapipe' has no attribute 'solutions'

原因:装成了mediapipe的某个精简版或者版本过旧,也可能是本地有个同名文件mediapipe.py把包覆盖了。解决:先pip show mediapipe确认版本在 0.10.x,再检查当前目录有没有mediapipe.py或mediapipe文件夹,有就改名。虚拟环境里如果同时装了mediapipe和mediapipe-silicon之类的变体,也会冲突,卸掉多余的。

4.5 现象:角度计算在特定角度突然跳变

原因:atan2差值法在跨越 ±180 度边界时会出现符号翻转,虽然代码里做了if ang > 180修正,但如果三点顺序传错(比如把腕和肩传反),修正逻辑会失效。解决:固定「近端—关节—远端」的传参顺序,肩—肘—腕就一直是肩在前。调试时把三个点的坐标和算出的角度一起打印出来,看跳变那一帧的坐标是不是有一个点突然从画面左侧跳到右侧,那通常是左右索引用错了。

5. 进阶技巧:用视频抽帧做批量验证与参数回归

把实时逻辑跑通之后,真正要调阈值、比模型复杂度,靠盯着摄像头手动试效率太低。我一般会先把测试视频抽成帧序列,离线批量跑一遍,把每帧的关键点和角度存成 CSV,再拿表格去回归参数。这样换阈值、换model_complexity都能快速对比,不用反复对着镜头做动作。

import cv2, csv import mediapipe as mp mp_pose = mp.solutions.pose cap = cv2.VideoCapture("test.mp4") fps = cap.get(cv2.CAP_PROP_FPS) frame_idx = 0 with mp_pose.Pose(static_image_mode=False, model_complexity=1, smooth_landmarks=True) as pose, \ open("landmarks.csv", "w", newline="") as f: writer = csv.writer(f) header = ["frame", "time_s"] for i in range(33): header += [f"x{i}", f"y{i}", f"v{i}"] writer.writerow(header) while cap.isOpened(): ret, frame = cap.read() if not ret: break h, w = frame.shape[:2] rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) res = pose.process(rgb) row = [frame_idx, round(frame_idx / fps, 3)] if res.pose_landmarks: for lm in res.pose_landmarks.landmark: row += [round(lm.x, 4), round(lm.y, 4), round(lm.visibility, 3)] else: row += [None] * 99 writer.writerow(row) frame_idx += 1 cap.release()

逻辑说明:static_image_mode=False在这里仍然合适,因为视频帧之间有连续性,追踪模式比逐帧检测更稳。smooth_landmarks=True会让 CSV 里的坐标更平滑,但如果你要分析的是「原始抖动有多大」,应该关掉它再跑一遍做对比。CSV 里同时存了visibility,后续用 pandas 过滤v13 < 0.5的行再算角度,就能排除掉遮挡帧的干扰。抽帧验证的另一个好处是可以把不同model_complexity的结果并排画出来,看 0 和 1 在关键点上的差距到底有多大——多数场景下差距比你想象的小,但帧率差距很明显。

参数回归时我习惯用一张表把候选配置列出来,跑完对比再定:

配置model_complexitysmooth_landmarks平均帧率角度抖动标准差
A0True423.1
B1True281.8
C1False294.6
D2True141.2

从这张表能看出来,model_complexity从 1 提到 2,精度提升有限但帧率腰斩;关掉平滑后抖动明显变大。所以实时交互类项目我一般锁 B,离线分析类可以上 D。这套抽帧加 CSV 的流程,最大的价值是把「感觉」变成「数据」,调阈值的时候不用再靠猜。从那以后我每次接姿态相关的需求,都强制先跑一遍抽帧验证再动实时逻辑,省下来的返工时间远比写脚本的时间多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进