1. 项目概述与核心价值
最近在整理自己大学时期的项目资料,翻到了这个“基于机器视觉的俯卧撑计数模型”的毕业设计。当时为了完成它,几乎把OpenCV、MediaPipe和YOLO这些库的文档翻了个底朝天,也踩了不少坑。现在回头看,这个项目虽然基础,但麻雀虽小五脏俱全,它完整地串联起了从数据采集、模型选型、算法实现到应用部署的整个机器视觉应用开发流程。对于刚接触计算机视觉或者想找一个综合性练手项目的同学来说,这绝对是一个绝佳的起点。它不要求你一开始就去啃那些复杂的3D重建或者自动驾驶模型,而是从一个非常具体、可感知的“计数”任务切入,让你能快速看到成果,建立信心,同时又能深入理解目标检测、姿态估计这些核心技术的实际应用。
这个项目的核心目标很明确:通过摄像头实时捕捉人体运动视频,自动识别并统计俯卧撑的个数。听起来简单,但拆解开来,你需要解决几个关键问题:首先,如何在复杂的背景和变化的着装下稳定地“找到”人?其次,如何准确地定位人体的关键关节(如肩膀、肘部、髋部)?最后,也是最核心的,如何根据这些关节点的空间位置变化,定义并判断一个完整的“俯卧撑”动作周期?整个过程就像教一个刚学会看东西的机器人理解一项体育运动,既有挑战也充满乐趣。接下来,我就把自己当时的设计思路、技术选型的考量、具体的实现步骤,以及那些让我熬夜调试的“坑”和解决技巧,毫无保留地分享出来。
2. 整体方案设计与技术选型
做任何项目,最忌讳的就是拿到题目直接开始敲代码。在动手之前,花时间进行整体的方案设计和技术选型,往往能事半功倍,避免后期推倒重来。对于这个俯卧撑计数项目,我当时的思路是构建一个标准的处理流水线。
2.1 核心处理流水线设计
整个系统的流程可以清晰地划分为四个阶段:输入、检测、分析、输出。输入阶段负责从摄像头或视频文件中获取连续的图像帧。检测阶段是整个系统的基石,它的任务是从每一帧图像中找出人的位置,并进一步定位出人体的关键点。分析阶段是大脑,它接收检测到的关键点数据,通过一套逻辑规则或算法,判断当前人体姿态是否构成一个俯卧撑的起始、中间或结束状态,从而完成计数。输出阶段则负责将分析结果可视化地叠加到原始图像上,并显示计数结果。
这个流水线设计的好处是模块化。每个阶段相对独立,你可以单独优化或替换某个模块而不影响整体。例如,后期我想提升检测速度,就可以尝试换用更轻量级的姿态估计模型,而无需改动分析和计数逻辑。
2.2 关键技术选型与深度解析
技术选型是方案设计的核心,它直接决定了项目的可行性、性能和开发难度。我当时主要面临三个关键选择:用什么做人体检测?用什么做姿态估计?以及用什么作为主要的开发框架?
1. 人体检测方案:YOLO vs. 传统方法早期我尝试过使用OpenCV的Haar级联分类器或HOG(方向梯度直方图)结合SVM(支持向量机)的方法来检测人。这些传统方法在受限场景下(如固定背景、特定姿势)可能有效,但它们对光照变化、遮挡和姿势多样性非常敏感,鲁棒性不足。更重要的是,它们通常只能给出一个“有人”的矩形框,无法提供我们计数所需的关键点信息。
因此,我果断转向了基于深度学习的目标检测模型。在众多选择中,YOLO(You Only Look Once)系列成为了我的首选。原因有三:首先是速度,YOLO的单阶段检测架构使其在保持较高精度的同时,能够实现实时或准实时的处理速度,这对于需要流畅体验的计数应用至关重要。其次是易用性,像YOLOv5/v8这样的版本,有着非常活跃的社区和丰富的预训练模型,使用几行代码就能完成模型的加载和推理。最后是精度,在COCO等大型数据集上预训练的YOLO模型,对于“人”这个类别的检测已经非常成熟和准确。我最终选择了YOLOv5s(small版本),它在精度和速度之间取得了很好的平衡,在我的普通笔记本电脑上也能流畅运行。
注意:选择预训练模型时,务必确认其包含“person”这个类别。有些通用目标检测模型可能不包含,或者你需要在自己的数据集上微调(fine-tuning)。
2. 姿态估计方案:MediaPipe BlazePose有了人的边界框,下一步就是获取关键点。这里我选择了Google的MediaPipe框架,特别是其BlazePose模型。MediaPipe是一个跨平台的机器学习解决方案框架,而BlazePose是其专为实时人体姿态估计设计的模型。
选择MediaPipe BlazePose的理由非常充分:
- 轻量级与高性能:BlazePose针对移动设备和边缘计算进行了优化,即使在CPU上也能达到很高的帧率,完全满足实时视频处理的需求。
- 关键点丰富:它提供了33个3D人体关键点,涵盖了面部、躯干、四肢和手脚。对于俯卧撑计数,我们主要关注肩膀、肘部、髋部、膝盖、脚踝等几个点,但丰富的点集为未来扩展动作识别(如深蹲、引体向上)留下了空间。
- 集成度高:MediaPipe提供了完整的Python API,将检测和姿态估计封装成了简单的管道(Pipeline),开发者无需关心底层复杂的模型推理和前后处理,大大降低了开发门槛。
- 3D姿态:虽然我们主要用2D坐标,但BlazePose输出的是带深度信息的3D坐标(Z轴),这在一定程度上能缓解因人体旋转带来的二维投影歧义问题,虽然在本项目中不是必需,但属于“锦上添花”。
3. 开发框架与工具链
- 核心框架:Python+OpenCV。Python是机器视觉领域的事实标准语言,拥有最丰富的生态。OpenCV(Open Source Computer Vision Library)是计算机视觉的“瑞士军刀”,负责图像的读取、显示、绘制(画关键点、计数结果)等所有基础操作。
- 深度学习框架:对于YOLO,我使用了PyTorch版本的YOLOv5,因为它原生支持PyTorch,且社区资源丰富。MediaPipe本身是一个独立的框架,通过
pip install mediapipe即可安装。 - 辅助工具:NumPy用于高效的数值计算(如关键点坐标运算);Matplotlib偶尔用于可视化中间结果,辅助调试。
这个技术栈的组合,使得整个项目从原型到实现非常顺畅。YOLO负责“找到人”,MediaPipe负责“看清姿势”,OpenCV负责“呈现结果”,三者各司其职,协同工作。
3. 核心算法原理与计数逻辑实现
技术选型解决了“用什么工具”的问题,而计数逻辑则是整个项目的“灵魂”。它定义了如何将一系列动态的关键点坐标,转化成一个准确的数字“1”。这里没有复杂的机器学习模型,更多的是对运动规律的观察和数学逻辑的应用。
3.1 俯卧撑动作的关节运动学分析
一个标准的俯卧撑,可以简化为人体在矢状面(从侧面看)的一个周期性运动。我们选取几个最具代表性的关键点进行分析:
- 肩膀 (Shoulder):作为上肢的支点,在动作过程中主要做垂直方向的上下移动。
- 肘部 (Elbow):角度变化最显著的关节。从手臂伸直时的接近180度,到身体下降最低点时的90度或更小。
- 髋部 (Hip):代表身体核心(躯干)的位置,其垂直位移与肩膀基本同步,反映了身体的整体升降。
- 手腕 (Wrist)和脚踝 (Ankle):作为身体的远端支撑点,在标准俯卧撑中,理想情况下它们的位置是固定的(相对于地面)。但在实际视频中,由于拍摄角度和人体微调,它们也可能有轻微移动。
基于这个分析,我们计数逻辑的核心就可以聚焦在肘关节角度和身体核心(肩或髋)的垂直位置这两个维度上。
3.2 基于肘关节角度的计数逻辑
这是最直观、最稳健的方法之一。其核心是计算肘关节的弯曲角度。
1. 关键点选取与向量计算我们需要三个点来计算肘部角度:肩膀(S)、肘部(E)、手腕(W)。角度 ∠SEW 就是肘关节角。 首先,用向量表示:
- 向量SE= E - S (从上臂指向肘部)
- 向量EW= W - E (从肘部指向前臂) 在二维图像中,S, E, W的坐标分别为 (sx, sy), (ex, ey), (wx, wy)。那么:
- SE = (ex - sx, ey - sy)
- EW = (wx - ex, wy - ey)
2. 角度计算公式根据向量点积公式:SE · EW = |SE| * |EW| * cos(θ)所以,肘关节角度 θ = arccos( (SE · EW) / (|SE| * |EW|) ) 其中,点积 SE · EW = (ex-sx)(wx-ex) + (ey-sy)(wy-ey) 向量的模 |SE| = sqrt( (ex-sx)² + (ey-sy)² ),|EW| 同理。 计算出的 θ 是弧度值,通常我们会将其转换为角度制:angle_deg = θ * 180 / π。
3. 状态机与计数规则我们不能简单地每帧检测角度小于某个值就计数,那样会因抖动产生大量误计数。必须引入一个状态机 (State Machine)来跟踪整个动作周期。
我定义了四个状态:UP(撑起状态)、GOING_DOWN(下降过程)、DOWN(最低点状态)、GOING_UP(上升过程)。计数逻辑如下:
- 初始状态为
UP(假设视频开始时人是撑起的)。 - 当肘关节角度持续小于一个阈值(如100度),并且身体核心(如髋部)的Y坐标在下降(即数值增大,因为图像坐标原点在左上角),则进入
GOING_DOWN状态。 - 当肘关节角度达到一个更小的阈值(如70度,表示已充分下降),并且身体核心位置趋于稳定(变化率很小),则进入
DOWN状态。 - 当在
DOWN状态停留短暂时间(如0.2秒)后,检测到肘关节角度开始增大且身体核心位置上升,则进入GOING_UP状态。 - 当肘关节角度恢复到大角度(如大于160度),并且身体核心位置回到接近起始高度,则认为完成了一个完整的上升,此时状态回到
UP,并且计数器加1。
这个状态机有效地过滤了动作中途的抖动和停顿,只有当系统感知到一个完整的“下降-触底-上升-还原”周期后,才会计数一次。
3.3 基于身体核心垂直位移的辅助判据
单纯依靠肘关节角度在极端情况下可能不可靠(比如有人做半程俯卧撑,肘角变化不大)。因此,我引入了基于肩膀或髋部**垂直坐标(Y值)**的辅助判据。 计算身体核心在垂直方向上的移动幅度。记录在UP状态时核心的Y坐标作为参考点y_ref。在运动过程中,计算当前Y坐标与y_ref的差值delta_y。 可以设定一个位移阈值(如30个像素)。只有当delta_y超过这个阈值,并且结合肘关节角度的变化,才认为发生了有效的位移。这可以与角度状态机协同工作,例如,必须同时满足“角度小于阈值”和“位移大于阈值”才进入DOWN状态,进一步提高了计数的准确性。
3.4 关键点坐标的平滑处理
从MediaPipe获取的关键点坐标是逐帧的,难免存在噪声和抖动。直接使用这些原始坐标计算角度和位移,会导致状态机频繁误触发。因此,必须进行平滑滤波。 我使用了最简单的指数移动平均(EMA),它对最近的数据给予更高的权重,既能平滑噪声,又能对快速运动保持一定的响应性。 公式为:smooth_pt = alpha * current_pt + (1 - alpha) * smooth_pt_prev其中,alpha是一个介于0和1之间的平滑因子(如0.5)。current_pt是当前帧检测到的关键点坐标,smooth_pt_prev是上一帧平滑后的坐标。对肩膀、肘部、髋部等关键点分别进行这样的平滑处理,能显著提升后续计算的稳定性。
4. 系统实现与代码详解
理论清晰之后,就是动手实现的环节。我将按照处理流水线,分模块拆解代码实现的关键部分。这里会包含核心代码片段和详细注释。
4.1 环境搭建与依赖安装
首先,确保你的Python环境(建议3.8及以上)已经准备好。创建一个新的虚拟环境是个好习惯。然后通过pip安装所有必需的库。
# 创建虚拟环境(可选) python -m venv venv source venv/bin/activate # Linux/Mac # venv\Scripts\activate # Windows # 安装核心依赖 pip install opencv-python # OpenCV核心库 pip install opencv-contrib-python # 包含更多功能 pip install mediapipe # 姿态估计 pip install torch torchvision # PyTorch,用于YOLO(根据CUDA版本选择) # 例如,对于CPU: pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install numpy pip install matplotlib # 用于调试可视化 # 安装YOLOv5 git clone https://github.com/ultralytics/yolov5 # 克隆官方仓库 cd yolov5 pip install -r requirements.txt # 安装YOLOv5的依赖4.2 视频流读取与预处理模块
这个模块负责打开摄像头或视频文件,并读取每一帧图像。
import cv2 class VideoStream: def __init__(self, source=0): """ 初始化视频流 :param source: 摄像头索引(如0)或视频文件路径 """ self.cap = cv2.VideoCapture(source) if not self.cap.isOpened(): raise IOError(f"Cannot open video source {source}") # 获取视频的基本属性,用于后续处理 self.fps = int(self.cap.get(cv2.CAP_PROP_FPS)) self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) print(f"Video stream opened: {self.width}x{self.height} @ {self.fps}fps") def read_frame(self): """读取下一帧,返回是否成功和帧图像""" ret, frame = self.cap.read() if ret: # 可选:进行尺寸调整,加速处理 # frame = cv2.resize(frame, (new_width, new_height)) pass return ret, frame def release(self): """释放资源""" self.cap.release()4.3 人体检测与姿态估计模块
这是核心模块,整合了YOLOv5和MediaPipe。
import torch import mediapipe as mp import numpy as np class PoseDetector: def __init__(self, yolo_model_path='yolov5s.pt', conf_threshold=0.5): """ 初始化检测器 :param yolo_model_path: YOLOv5模型路径,可以是本地路径或官方模型名如‘yolov5s.pt’ :param conf_threshold: 检测置信度阈值 """ # 加载YOLOv5模型 self.yolo_model = torch.hub.load('ultralytics/yolov5', 'custom', path=yolo_model_path, force_reload=False) self.yolo_model.conf = conf_threshold # 置信度阈值 self.yolo_model.classes = [0] # 0 通常是‘person’类别,只检测人 # 初始化MediaPipe姿态估计 self.mp_pose = mp.solutions.pose self.pose = self.mp_pose.Pose( static_image_mode=False, # 视频流模式 model_complexity=2, # 模型复杂度 (0,1,2) smooth_landmarks=True, # 平滑关键点 enable_segmentation=False, # 不需要人体分割 min_detection_confidence=0.5, min_tracking_confidence=0.5 ) self.mp_draw = mp.solutions.drawing_utils # 绘制工具 def detect(self, frame): """ 在单帧图像中检测人体并估计姿态 :param frame: BGR格式的图像帧 :return: 处理后的图像帧,以及包含人体边界框和关键点的列表 """ results_list = [] rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # MediaPipe需要RGB输入 # 步骤1: 使用YOLO进行人体检测 yolo_results = self.yolo_model(rgb_frame) detections = yolo_results.xyxy[0].cpu().numpy() # 获取检测结果 [x1, y1, x2, y2, conf, class] for det in detections: x1, y1, x2, y2, conf, cls = map(int, det[:6]) if cls == 0: # 确保是‘person’类 # 提取人体区域ROI (Region of Interest) person_roi = rgb_frame[y1:y2, x1:x2] if person_roi.size == 0: continue # 防止空区域 # 步骤2: 在ROI上使用MediaPipe进行姿态估计 roi_results = self.pose.process(person_roi) if roi_results.pose_landmarks: # 关键点坐标是在ROI坐标系下的,需要转换回原图坐标系 landmarks_world = [] for lm in roi_results.pose_landmarks.landmark: # MediaPipe返回的是归一化坐标 (相对于ROI) cx, cy = int(lm.x * (x2 - x1)), int(lm.y * (y2 - y1)) # 转换到原图坐标 cx_global, cy_global = cx + x1, cy + y1 landmarks_world.append((cx_global, cy_global, lm.z)) # 包含Z坐标 # 存储结果:边界框和全局关键点 results_list.append({ 'bbox': (x1, y1, x2, y2), 'landmarks': landmarks_world, # 33个关键点的列表 'confidence': conf }) # 在原图上绘制边界框和关键点(可选,用于调试) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) # 这里可以调用一个自定义函数来绘制关键点,因为坐标已经转换 self._draw_landmarks(frame, landmarks_world) return frame, results_list def _draw_landmarks(self, frame, landmarks): """在图像上绘制关键点和连接线(简化版)""" # 定义一些重要的连接对,例如:肩膀到肘部,肘部到手腕 connections = [(11, 13), (13, 15), (12, 14), (14, 16), # 手臂 (11, 23), (12, 24), (23, 24), (23, 25), (24, 26)] # 躯干和腿 for idx, (x, y, z) in enumerate(landmarks): cv2.circle(frame, (x, y), 3, (0, 0, 255), -1) # 画点 for start, end in connections: if start < len(landmarks) and end < len(landmarks): x1, y1, _ = landmarks[start] x2, y2, _ = landmarks[end] cv2.line(frame, (x1, y1), (x2, y2), (255, 0, 0), 2)4.4 俯卧撑计数逻辑模块
这是项目的“大脑”,实现了之前阐述的状态机和计数规则。
class PushUpCounter: def __init__(self, angle_threshold_down=100, angle_threshold_up=160, dip_threshold_pixel=30): """ 初始化计数器 :param angle_threshold_down: 进入下降状态的角度阈值(度) :param angle_threshold_up: 完成上升状态的角度阈值(度) :param dip_threshold_pixel: 有效下降的垂直像素位移阈值 """ self.angle_thresh_down = angle_threshold_down self.angle_thresh_up = angle_threshold_up self.dip_thresh = dip_threshold_pixel self.state = "UP" # 初始状态 self.count = 0 self.prev_hip_y = None # 用于计算位移 self.up_hip_y_ref = None # 撑起状态时髋部的参考Y坐标 # 平滑滤波参数 self.smooth_factor = 0.5 self.smoothed_landmarks = None def calculate_angle(self, a, b, c): """计算由三点a, b, c构成的角abc(点b是顶点)""" a, b, c = np.array(a), np.array(b), np.array(c) ba = a - b bc = c - b cosine_angle = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc)) # 防止数值误差导致cos值略大于1或小于-1 cosine_angle = np.clip(cosine_angle, -1.0, 1.0) angle = np.degrees(np.arccos(cosine_angle)) return angle def update(self, landmarks): """ 根据新一帧的关键点更新状态和计数 :param landmarks: 当前帧的33个关键点列表 [(x1,y1,z1), ...] :return: 当前计数和状态 """ if not landmarks or len(landmarks) < 25: # 确保有足够的关键点(例如髋部索引是23,24) return self.count, self.state # 1. 关键点平滑 if self.smoothed_landmarks is None: self.smoothed_landmarks = landmarks else: self.smoothed_landmarks = [ (self.smooth_factor * np.array(cur) + (1 - self.smooth_factor) * np.array(prev)) for cur, prev in zip(landmarks, self.smoothed_landmarks) ] smooth_pts = self.smoothed_landmarks # 2. 提取所需关键点索引 (MediaPipe BlazePose 33点模型) # 左肩:11, 左肘:13, 左腕:15 # 右肩:12, 右肘:14, 右腕:16 # 左髋:23, 右髋:24 # 这里以左侧肢体为例,实践中可以取左右平均值或选择更清晰的一侧 shoulder = smooth_pts[11][:2] # 取x,y elbow = smooth_pts[13][:2] wrist = smooth_pts[15][:2] hip = smooth_pts[23][:2] # 用左髋代表核心 # 3. 计算肘关节角度和髋部垂直位移 elbow_angle = self.calculate_angle(shoulder, elbow, wrist) current_hip_y = hip[1] if self.up_hip_y_ref is None and self.state == "UP": self.up_hip_y_ref = current_hip_y # 记录参考高度 if self.up_hip_y_ref is not None: dip_depth = abs(current_hip_y - self.up_hip_y_ref) else: dip_depth = 0 # 4. 状态机逻辑 if self.state == "UP": if elbow_angle < self.angle_thresh_down and dip_depth > self.dip_thresh: self.state = "GOING_DOWN" print(f"状态切换: UP -> GOING_DOWN (角度:{elbow_angle:.1f}, 位移:{dip_depth:.1f})") elif self.state == "GOING_DOWN": if elbow_angle < 80: # 达到足够低的角度 self.state = "DOWN" print(f"状态切换: GOING_DOWN -> DOWN") elif self.state == "DOWN": # 在最低点短暂停留,防止抖动误判为上升 if elbow_angle > 85: # 角度开始增大 self.state = "GOING_UP" print(f"状态切换: DOWN -> GOING_UP") elif self.state == "GOING_UP": if elbow_angle > self.angle_thresh_up and dip_depth < self.dip_thresh * 0.5: self.state = "UP" self.count += 1 # 完成一个完整周期,计数! print(f"状态切换: GOING_UP -> UP >>> 计数+1! 当前总数: {self.count}") self.up_hip_y_ref = current_hip_y # 更新参考高度,适应位置漂移 self.prev_hip_y = current_hip_y return self.count, self.state, elbow_angle, dip_depth4.5 主程序与可视化集成
最后,我们将所有模块串联起来,形成完整的可运行程序。
def main(): # 初始化 video_source = 0 # 0 代表默认摄像头,也可以是视频文件路径如 ‘pushup_video.mp4’ stream = VideoStream(video_source) detector = PoseDetector(yolo_model_path='yolov5s.pt') counter = PushUpCounter() print("开始俯卧撑计数。按 ‘q’ 键退出。") while True: ret, frame = stream.read_frame() if not ret: break # 检测姿态 processed_frame, detections = detector.detect(frame) # 处理每个检测到的人(假设画面中只有一个人) for person in detections: landmarks = person['landmarks'] count, state, angle, depth = counter.update(landmarks) # 在图像上显示信息 cv2.putText(processed_frame, f"Count: {count}", (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 255, 0), 3) cv2.putText(processed_frame, f"State: {state}", (20, 100), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2) cv2.putText(processed_frame, f"Angle: {angle:.1f}", (20, 150), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2) cv2.putText(processed_frame, f"Depth: {depth:.1f}", (20, 180), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2) # 显示结果 cv2.imshow('Push-Up Counter', processed_frame) # 退出条件 if cv2.waitKey(1) & 0xFF == ord('q'): break # 清理 stream.release() cv2.destroyAllWindows() print(f"最终俯卧撑计数: {counter.count}") if __name__ == "__main__": main()5. 优化策略、常见问题与调试技巧
一个能跑通的Demo和一个健壮、可用的系统之间,往往隔着无数个需要优化的细节和需要填平的坑。在这一部分,我分享一些让模型更稳定、更准确的实战经验。
5.1 性能优化与精度提升
1. 处理速度优化实时性是本项目的关键体验。如果帧率(FPS)过低,会丢失大量动作细节,导致计数不准。
- 降低处理分辨率:这是最有效的提速方法。在
VideoStream的read_frame函数中,使用cv2.resize将图像缩放到一个较小的尺寸(如640x480或甚至320x240)再进行检测。YOLO和MediaPipe在小分辨率上运行会快很多,虽然会损失一些远处细节,但对于居中的人物姿态估计通常够用。 - 跳帧处理 (Frame Skipping):如果不需要严格的实时性,可以每处理2帧或3帧就跳过后面的帧。但这在快速运动时可能导致漏检,需要谨慎使用。
- 模型轻量化:使用更小的YOLO模型(如YOLOv5n Nano版本)和MediaPipe的
model_complexity=1或0。在CPU上,这能带来显著的帧率提升。 - ROI区域限制:如果摄像头视角固定,可以预先定义一个感兴趣区域(ROI),只对这个区域进行人体检测,减少不必要的全图扫描。
2. 检测与计数精度提升
- 多关键点融合判决:不要只依赖单侧手臂。同时计算左肘和右肘的角度,取平均值或选择更稳定的一侧(例如,始终选择角度变化更明显的一侧)。对于核心高度,可以取左右髋部Y坐标的平均值。
- 动态阈值调整:不同人的臂长、身高、做俯卧撑的幅度都不同。固定的角度和位移阈值可能不适用。可以考虑在程序开始时,让用户做两个标准动作,自动计算出该用户的初始角度和位移范围,作为其个性化的阈值。
- 引入时间窗口滤波:状态切换不能只基于单帧的判断。例如,从
GOING_DOWN切换到DOWN,可以要求连续5帧的角度都小于阈值,才确认状态切换。这能有效抵抗单帧的噪声干扰。 - 处理多人场景:如果画面中有多人,代码需要为每个人维护一个独立的
PushUpCounter实例。可以通过跟踪每个人的边界框中心点,使用简单的IOU(交并比)或质心跟踪算法(如cv2.Tracker)来关联前后帧的同一人,实现多人计数。
5.2 典型问题排查与解决方案
在开发过程中,你几乎一定会遇到下面这些问题:
1. 检测不到人或关键点
- 现象:画面中明明有人,但YOLO框不出来,或者MediaPipe返回的
landmarks为空。 - 排查:
- 检查摄像头权限和视频流:先用一个简单的OpenCV程序显示摄像头画面,确保视频流正常。
- 调整YOLO置信度阈值:在
PoseDetector初始化时,降低conf_threshold(如从0.5调到0.3)。可能是光照不足或距离太远导致置信度不高。 - 检查人体姿态:MediaPipe对严重遮挡或非常规姿势(如完全背对摄像头)的检测效果会下降。确保人体大部分正面或侧面朝向摄像头。
- 检查ROI区域:如果用了ROI,确保人确实在ROI区域内。
- 解决:确保环境光照充足,人物穿着与背景对比度明显,并面向摄像头。可以尝试在YOLO检测前对图像进行简单的预处理,如直方图均衡化,以增强对比度。
2. 关键点抖动严重
- 现象:画面上关键点像“果冻”一样不停跳动,导致计算出的角度和位移剧烈波动。
- 排查:首先确认是否已经应用了平滑滤波(如指数移动平均EMA)。如果没有,加上。如果已经加上但抖动依然明显,观察是某个点抖还是所有点一起抖。
- 解决:
- 增大平滑因子alpha:使用更小的
alpha值(如0.2),给予历史数据更高权重,平滑效果更强,但会引入延迟。 - 使用卡尔曼滤波 (Kalman Filter):对于这种有规律的运动,卡尔曼滤波是更高级、更有效的平滑与预测工具。它可以估计关键点的速度和加速度,从而更“聪明”地平滑轨迹。虽然实现稍复杂,但对于提升稳定性效果显著。
- 检查视频帧率:如果帧率太低(如低于15fps),动作在帧与帧之间位移过大,也会造成“抖动”的错觉。优先保证输入帧率。
- 增大平滑因子alpha:使用更小的
3. 误计数或漏计数
- 现象:动作没做完整就计数了,或者做了完整的动作却没计数。
- 排查:这是计数逻辑的问题。打开调试信息,打印每一帧的
状态、肘关节角度和髋部位移。- 误计数:观察是否在非俯卧撑动作(如抬手、调整姿势)时,角度或位移偶然满足了状态切换条件。这通常是因为阈值设置太宽松,或者状态机缺少必要的“缓冲”条件(如在
DOWN状态必须停留至少N秒)。 - 漏计数:观察在标准俯卧撑的底部或顶部,角度和位移是否未能达到切换阈值。可能是阈值设置太严格,或者由于关键点检测误差导致计算值有偏差。
- 误计数:观察是否在非俯卧撑动作(如抬手、调整姿势)时,角度或位移偶然满足了状态切换条件。这通常是因为阈值设置太宽松,或者状态机缺少必要的“缓冲”条件(如在
- 解决:
- 精细化状态机:在状态切换条件中加入更多约束。例如,从
GOING_DOWN到DOWN,不仅要求角度小,还要求髋部位移超过总阈值的80%,并且当前速度(Y坐标变化率)接近于0(表示到达最低点)。 - 自适应阈值:记录用户最近几次动作的角度和位移范围,动态调整阈值,而不是使用全局固定值。
- 引入“准备状态”:在初始
UP状态前,增加一个READY状态。只有当检测到人体处于标准的平板支撑姿势(肘关节接近180度,肩、髋、踝近似直线)持续一段时间后,才进入UP状态并开始计数。这可以防止初始姿势不规范导致的误触发。
- 精细化状态机:在状态切换条件中加入更多约束。例如,从
4. 计数延迟
- 现象:身体已经撑起来了,但计数显示要慢半拍才更新。
- 排查:这通常是平滑滤波或状态机“停留时间”设置过长导致的。检查EMA的
alpha值是否太小,或者DOWN状态到GOING_UP状态切换的“停留”判断是否过于保守。 - 解决:在实时性要求高的场景,可以适当增大
alpha(如0.7),减少平滑窗口。对于状态切换,可以用更灵敏的条件,比如一旦角度开始增大就立刻切换状态,而不是等待角度大于某个值。
5.3 项目扩展与进阶思路
这个基础项目可以作为一个平台,向多个有趣的方向扩展:
- 多动作识别:目前的逻辑是专门为俯卧撑设计的。你可以定义其他动作(如深蹲、开合跳、仰卧起坐)的关键点角度和位移规则,构建一个通用的“健身动作识别与计数系统”。这可能需要一个更复杂的、基于规则或简单机器学习分类器的动作识别模块。
- 姿势标准度评估:不止计数,还能评判动作质量。例如,检测俯卧撑时腰部是否下塌(通过脊柱关键点的曲率计算),或者下蹲时膝盖是否超过脚尖。这需要更精细的关键点分析和生物力学知识。
- Web或移动端部署:将训练好的模型(可以尝试将YOLO和姿态估计模型转换为ONNX或TFLite格式)集成到Flask/Django后端,提供Web API。或者使用MediaPipe的JavaScript版本或TFLite在手机端直接运行,开发一个手机App。
- 加入简单的UI界面:使用PyQt、Tkinter或更现代的
gradio库,为你的程序制作一个图形界面,方便设置参数、选择视频源、查看历史记录等。 - 数据收集与模型微调:如果你发现预训练的模型在某些特定场景(如特定服装、昏暗光线)下效果不好,可以自己收集一些数据,对YOLO或MediaPipe的模型进行微调(fine-tuning),以提升在特定场景下的鲁棒性。这将是迈向更专业CV工程师的一步。
回过头看,这个毕业设计项目就像一把钥匙,帮我打开了机器视觉应用开发的大门。它让我深刻体会到,一个好的项目不在于用了多高深的理论,而在于能否清晰定义问题、合理拆解模块、扎实地实现每个环节,并耐心地调试优化。从OpenCV的基本操作,到深度学习模型的调用集成,再到基于领域知识设计状态机逻辑,每一步都是宝贵的学习。如果你正在做类似的项目,我的建议是:先让最基本的流程跑通,哪怕计数不准;然后,像侦探一样,通过打印日志、可视化中间结果,去观察系统在哪里出了问题;最后,针对性地去优化那个环节。这个过程本身,就是最大的收获。