简介:这是一套基于MediaPipe与KNN分类算法的健身动作计数Python项目源码,面向具备一定Python基础、希望快速实现引体向上、深蹲、俯卧撑自动计数的开发者与健身应用爱好者。其核心思路是先提取人体关键点并归一化编码,再用k-NN完成姿态分类,配合指数移动平均平滑结果,因此切换运动类型几乎无需改动代码,只需在读取视频或调用摄像头时选择对应动作即可。资源包共61个文件、约18.45MB,包含10个py源码模块、6个csv训练特征文件、5个xml配置、3段mp4示例视频及若干图片与字体资源,覆盖姿态分类、结果平滑、运动计数、可视化、训练集提取与校验、视频处理等完整环节,并附项目说明文档。目前已有270人学习。读者可据此掌握从关键点编码到计数输出的完整链路,理解KNN在动作识别中的落地方式,并借助示例视频与训练集快速复现和二次开发。
1. 从摄像头到计数:MediaPipe + KNN 的健身动作识别到底怎么落地
健身房里对着镜子数引体向上,数到第七个就开始怀疑自己是不是多数了两个——这种场景几乎每个练过的人都遇到过。用 MediaPipe 加 KNN 做健身计数器,解决的正是这个"数不准"的问题:MediaPipe 负责从摄像头画面里提取人体骨骼关键点,KNN 负责判断当前处于动作的哪个阶段,两者配合就能自动计数引体向上、深蹲、俯卧撑。这套方案的核心优势在于不需要训练深度神经网络,KNN 是惰性学习算法,几十个样本就能跑起来,普通笔记本的 CPU 就能实时推理。适合有 Python 基础、想做一个能跑通的计算机视觉小项目的开发者,也适合想理解"姿态估计 + 分类器"这套组合拳的入门者。下面从环境搭建讲到参数调优,把这条链路拆开说清楚。
2. 环境搭建与 MediaPipe 骨骼提取:从零跑通第一帧
2.1 安装依赖与验证 MediaPipe 是否正常工作
很多人卡在第一步不是代码写错,而是环境没配对。MediaPipe 对 Python 版本有要求,目前稳定支持 Python 3.8 到 3.11,用 3.12 可能会遇到 wheel 包缺失的问题。我一般建议用 conda 建一个独立环境,避免和系统里的其他包打架。
# 创建独立环境,指定 Python 3.10 conda create -n fitness_counter python=3.10 -y conda activate fitness_counter # 安装核心依赖 pip install mediapipe opencv-python numpy scikit-learn # 验证安装 python -c "import mediapipe as mp; print(mp.__version__)"这里四个包各有分工:mediapipe 提供姿态估计模型,opencv-python 负责读摄像头和画图,numpy 做数组运算,scikit-learn 提供 KNN 分类器。版本方面不需要锁死,但 mediapipe 建议用 0.10.x 以上的版本,早期版本的关键点索引有差异。安装完成后跑一行验证命令,能打印出版本号就说明环境没问题。
如果 pip 安装 mediapipe 报错,大概率是两个原因:一是 Python 版本太新,二是系统缺少 Visual C++ 运行库(Windows 平台)。前者换 3.10 解决,后者去微软官网下载 vc_redist 安装即可。Linux 上如果报libGL相关错误,装一下libgl1-mesa-glue就行。
2.2 用 MediaPipe Pose 提取 33 个关键点
MediaPipe Pose 模型会在每帧画面中检测人体的 33 个关键点,包括鼻子、肩膀、手肘、手腕、髋部、膝盖、脚踝等。做健身计数只需要其中一部分——引体向上关注肩膀和手肘,深蹲关注髋部和膝盖,俯卧撑关注手肘和肩膀。
import cv2 import mediapipe as mp mp_pose = mp.solutions.pose mp_draw = mp.solutions.drawing_utils # 初始化 Pose 模型 pose = mp_pose.Pose( static_image_mode=False, # 视频流用 False model_complexity=1, # 0/1/2,越大越准但越慢 smooth_landmarks=True, # 平滑关键点,减少抖动 min_detection_confidence=0.5, # 检测置信度阈值 min_tracking_confidence=0.5 # 跟踪置信度阈值 ) cap = cv2.VideoCapture(0) # 0 表示默认摄像头 while cap.isOpened(): ret, frame = cap.read() if not ret: break # MediaPipe 需要 RGB 输入 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = pose.process(rgb) if results.pose_landmarks: # 绘制骨架 mp_draw.draw_landmarks( frame, results.pose_landmarks, mp_pose.POSE_CONNECTIONS ) # 提取关键点坐标 landmarks = results.pose_landmarks.landmark # 以左肩为例,x/y/z 是归一化坐标(0~1) left_shoulder = landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER] print(f"左肩: x={left_shoulder.x:.3f}, y={left_shoulder.y:.3f}") cv2.imshow('Pose', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这段代码是整个项目的地基。几个参数值得展开说:model_complexity控制模型大小,0 是最轻量的,适合树莓派这类边缘设备;1 是默认值,精度和速度平衡;2 最准但帧率会掉。smooth_landmarks开启后会对关键点做时序平滑,好处是画面不抖,坏处是快速动作时会有轻微延迟——做引体向上这种爆发式动作时,如果发现计数滞后,可以把它关掉试试。min_detection_confidence和min_tracking_confidence分别控制初始检测和后续跟踪的阈值,光线差的环境可以降到 0.3,但太低会引入误检。
关键点的坐标是归一化的,x 和 y 都在 0 到 1 之间,原点在画面左上角。这意味着不同分辨率的摄像头输出的坐标范围一致,后续计算角度时不需要做分辨率适配。z 轴表示深度,以髋部中心为原点,数值越小表示越靠近摄像头,做深蹲时可以用 z 轴辅助判断身体是否前倾。
2.3 从关键点到角度:计算关节夹角的通用方法
光有关键点坐标还不够,KNN 需要的是数值特征。最直接的特征就是关节夹角——手肘弯曲角度、膝盖弯曲角度、髋部角度。用向量点积公式就能算。
import numpy as np def calculate_angle(a, b, c): """ 计算三个点构成的角度,b 为顶点 a, b, c 格式为 [x, y] 返回角度值(0~180) """ a = np.array(a) b = np.array(b) c = np.array(c) # 向量 ba 和 bc ba = a - b bc = c - b # 点积公式求夹角 cosine = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc)) # 防止浮点误差导致超出 [-1, 1] cosine = np.clip(cosine, -1.0, 1.0) angle = np.degrees(np.arccos(cosine)) return angle # 以引体向上为例:计算左肩-左肘-左腕的夹角 def get_elbow_angle(landmarks, mp_pose): shoulder = landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER.value] elbow = landmarks[mp_pose.PoseLandmark.LEFT_ELBOW.value] wrist = landmarks[mp_pose.PoseLandmark.LEFT_WRIST.value] return calculate_angle( [shoulder.x, shoulder.y], [elbow.x, elbow.y], [wrist.x, wrist.y] )calculate_angle这个函数是整个项目的核心工具,三个动作的计数都靠它。逻辑很直白:把顶点到两个端点的向量算出来,用点积公式求余弦值,再反余弦得到角度。np.clip那行是血泪经验——浮点运算有时候会算出 1.0000001 这种值,不裁剪的话arccos会返回 NaN,整个计数就崩了。
不同动作关注的角度不同。引体向上看手肘角度:手臂伸直时约 160 到 180 度,下巴过杠时手肘弯曲到约 40 到 60 度。深蹲看膝盖角度:站立时约 170 到 180 度,蹲到底时约 70 到 90 度。俯卧撑看手肘角度:撑起时约 160 到 180 度,下降到最低点时约 80 到 100 度。这些角度范围是后续 KNN 分类的依据,也是判断动作是否标准的参考线。
3. KNN 分类器训练:用几十个样本教会模型识别动作阶段
3.1 为什么选 KNN 而不是 LSTM 或 SVM
做动作识别,第一反应可能是上 LSTM 做时序建模,或者用 SVM 做分类。但在这个场景下 KNN 有三个实际优势。第一,样本需求极低——每个动作阶段采集 20 到 30 帧就能训练,LSTM 至少需要几百条完整动作序列。第二,训练是瞬时的,KNN 没有显式训练过程,把数据存进去就完事,改样本后立刻生效,调试体验好很多。第三,可解释性强,预测时看最近的 K 个邻居属于哪一类,能直观判断模型为什么做出这个判断。
SVM 虽然也能用小样本,但需要调核函数和惩罚系数,多了一层调参负担。KNN 只有一个 K 值需要调,而且 K 值的物理含义很明确——看周围几个邻居投票。对于引体向上、深蹲、俯卧撑这种动作模式固定、阶段划分清晰的场景,KNN 的准确率完全够用。
当然 KNN 也有边界。如果动作速度变化很大,比如有人做引体向上快起快落,有人慢起慢落,同一角度值可能对应不同阶段。这时候需要加入角速度作为特征,或者用滑动窗口做时序平滑。另一个边界是多人场景,MediaPipe 默认只检测画面中置信度最高的人,多人同时训练需要额外处理。
3.2 采集训练数据:每个动作阶段至少 30 帧
数据采集的流程是:打开摄像头,摆出目标姿势,按一个键记录当前帧的角度特征和标签。我一般会写一个采集脚本,把特征和标签存成 CSV。
import csv import cv2 import mediapipe as mp from calculate_angle import get_elbow_angle # 前面定义的函数 mp_pose = mp.solutions.pose pose = mp_pose.Pose(min_detection_confidence=0.5, min_tracking_confidence=0.5) cap = cv2.VideoCapture(0) # 标签映射:0=手臂伸直,1=手肘弯曲 current_label = 0 data_rows = [] print("按 '0' 标记手臂伸直,按 '1' 标记手肘弯曲,按 's' 保存,按 'q' 退出") while cap.isOpened(): ret, frame = cap.read() if not ret: break rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = pose.process(rgb) if results.pose_landmarks: landmarks = results.pose_landmarks.landmark angle = get_elbow_angle(landmarks, mp_pose) # 画面显示当前角度和标签 cv2.putText(frame, f"Angle: {angle:.1f} Label: {current_label}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow('Collect', frame) key = cv2.waitKey(1) & 0xFF if key == ord('0'): current_label = 0 elif key == ord('1'): current_label = 1 elif key == ord('s') and results.pose_landmarks: data_rows.append([angle, current_label]) print(f"已保存 {len(data_rows)} 条: angle={angle:.1f}, label={current_label}") elif key == ord('q'): break # 写入 CSV with open('training_data.csv', 'w', newline='') as f: writer = csv.writer(f) writer.writerow(['angle', 'label']) writer.writerows(data_rows) print(f"共保存 {len(data_rows)} 条数据") cap.release() cv2.destroyAllWindows()采集时有个关键点:不要只在一个固定位置采集。手臂伸直的角度在不同人身上可能是 165 度也可能是 178 度,手肘弯曲的角度也因人而异。所以采集时要覆盖不同角度值——手臂伸直时稍微前后晃动一下,让角度在 150 到 180 之间都有样本;手肘弯曲时从 30 度到 70 度都采一些。每个标签至少 30 条,最好 50 条以上,这样 KNN 的决策边界才稳。
采集完成后打开 CSV 看一眼数据分布。如果两个标签的角度范围有重叠,比如手臂伸直采到了 140 度,手肘弯曲也采到了 140 度,那 KNN 在这个区间就会摇摆。解决办法是把重叠区的样本删掉,或者增加一个"过渡态"标签,让模型学会识别中间状态。
3.3 训练 KNN 并确定最佳 K 值
数据有了,训练 KNN 只需要几行代码。但 K 值怎么选有讲究——K 太小容易受噪声影响,K 太大边界模糊。
import pandas as pd from sklearn.neighbors import KNeighborsClassifier from sklearn.model_selection import cross_val_score import numpy as np # 读取数据 df = pd.read_csv('training_data.csv') X = df[['angle']].values y = df['label'].values # 用交叉验证选最佳 K 值 best_k = 3 best_score = 0 for k in range(1, 16): knn = KNeighborsClassifier(n_neighbors=k) scores = cross_val_score(knn, X, y, cv=5, scoring='accuracy') mean_score = scores.mean() print(f"K={k}, 交叉验证准确率={mean_score:.3f}") if mean_score > best_score: best_score = mean_score best_k = k print(f"最佳 K 值: {best_k}, 准确率: {best_score:.3f}") # 用最佳 K 值训练最终模型 final_knn = KNeighborsClassifier(n_neighbors=best_k) final_knn.fit(X, y) # 保存模型 import joblib joblib.dump(final_knn, 'knn_model.pkl')交叉验证选 K 值的逻辑是:把数据分成 5 份,轮流用其中 4 份训练、1 份验证,取平均准确率。K 从 1 试到 15,准确率最高的那个就是最佳 K。实际跑下来,单特征(只有角度)的情况下 K=3 或 K=5 通常最好。如果准确率曲线在某个 K 值后明显下降,说明 K 太大了,邻居里混入了其他类别的样本。
joblib.dump把训练好的模型存成文件,后续计数脚本直接加载,不用每次重新训练。模型文件很小,几十 KB,方便分发。如果要加更多特征(比如角速度、身体倾斜角),把X从单列改成多列就行,KNN 会自动处理多维特征空间。
4. 三个动作的计数逻辑:状态机 + KNN 预测结果
4.1 引体向上计数器:手肘角度 + 状态机
有了 KNN 分类器,每帧都能得到一个"当前处于哪个阶段"的预测。但计数不能只靠单帧预测——人可能在某个角度停留,导致连续多帧都预测为同一阶段。需要一个状态机来管理阶段转换。
import cv2 import mediapipe as mp import joblib import numpy as np from calculate_angle import calculate_angle # 加载模型 knn = joblib.load('knn_model.pkl') mp_pose = mp.solutions.pose pose = mp_pose.Pose(min_detection_confidence=0.5, min_tracking_confidence=0.5) cap = cv2.VideoCapture(0) counter = 0 stage = None # 'up' 或 'down' def get_angle(landmarks, p1, p2, p3): a = [landmarks[p1.value].x, landmarks[p1.value].y] b = [landmarks[p2.value].x, landmarks[p2.value].y] c = [landmarks[p3.value].x, landmarks[p3.value].y] return calculate_angle(a, b, c) while cap.isOpened(): ret, frame = cap.read() if not ret: break rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = pose.process(rgb) if results.pose_landmarks: lm = results.pose_landmarks.landmark # 计算左臂手肘角度 angle = get_angle(lm, mp_pose.PoseLandmark.LEFT_SHOULDER, mp_pose.PoseLandmark.LEFT_ELBOW, mp_pose.PoseLandmark.LEFT_WRIST) # KNN 预测阶段 prediction = knn.predict([[angle]])[0] # 状态机计数 if prediction == 1: # 手肘弯曲,引体向上拉起 stage = "up" if prediction == 0 and stage == "up": # 手臂伸直,完成一次 stage = "down" counter += 1 print(f"引体向上计数: {counter}") # 画面显示 cv2.putText(frame, f"Count: {counter}", (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 3) cv2.putText(frame, f"Angle: {angle:.1f}", (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 0), 2) cv2.imshow('Pull-up Counter', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()状态机的逻辑是:只有先进入"up"状态(手肘弯曲),再回到"down"状态(手臂伸直),才计一次数。这防止了在手臂伸直时反复预测为"down"导致重复计数。stage变量记录上一次的状态,只有状态发生"up → down"的转换时才counter += 1。
引体向上的一个常见问题是下巴过杠的判断。手肘角度只能判断手臂是否弯曲,但有些人手臂弯曲了但下巴没过杠。如果要严格判断,需要加入鼻子或下巴的 y 坐标与杠的 y 坐标比较。简化版可以只用手肘角度,适合自己训练时参考;严格版需要额外检测横杠位置,实现复杂度会高不少。
4.2 深蹲计数器:膝盖角度 + 髋部高度辅助
深蹲的计数逻辑和引体向上类似,但关注的是膝盖角度。站立时膝盖约 170 到 180 度,蹲到底时约 70 到 90 度。状态机逻辑一样:先蹲下(膝盖弯曲),再站起(膝盖伸直),计一次。
# 深蹲角度计算 def get_knee_angle(landmarks, mp_pose): hip = landmarks[mp_pose.PoseLandmark.LEFT_HIP.value] knee = landmarks[mp_pose.PoseLandmark.LEFT_KNEE.value] ankle = landmarks[mp_pose.PoseLandmark.LEFT_ANKLE.value] return calculate_angle( [hip.x, hip.y], [knee.x, knee.y], [ankle.x, ankle.y] ) # 在计数循环中替换角度计算 angle = get_knee_angle(lm, mp_pose) prediction = knn_squat.predict([[angle]])[0] if prediction == 1: # 蹲下 stage = "down" if prediction == 0 and stage == "down": # 站起 stage = "up" counter += 1深蹲有个额外问题:膝盖角度在站立时和蹲下时差异很大,但有些人蹲得浅,膝盖只弯到 120 度就起来了。如果训练数据里"蹲下"标签只采了 70 到 90 度的样本,那 120 度会被预测为"站立",导致不计数。解决办法是在采集数据时把"蹲下"的范围放宽,从 70 度到 130 度都采一些,让模型学会识别"部分下蹲"也算蹲下。
另一个辅助特征是髋部高度。站立时髋部 y 坐标较小(画面靠上),蹲下时 y 坐标增大(画面靠下)。可以把髋部 y 坐标作为第二个特征加入 KNN,提高区分度。但要注意不同人身高不同,髋部 y 坐标的绝对值没有可比性,需要做归一化——比如用髋部 y 除以肩膀到脚踝的距离。
4.3 俯卧撑计数器:手肘角度 + 身体直线检测
俯卧撑和引体向上都用手肘角度,但角度范围不同。俯卧撑撑起时手肘约 160 到 180 度,下降到最低点时约 80 到 100 度。如果直接用引体向上的模型,会因为角度范围不匹配导致误判。所以每个动作需要单独训练一个 KNN 模型,或者用同一个模型但重新采集对应动作的数据。
# 俯卧撑角度计算(和引体向上一样用手肘) def get_pushup_angle(landmarks, mp_pose): shoulder = landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER.value] elbow = landmarks[mp_pose.PoseLandmark.LEFT_ELBOW.value] wrist = landmarks[mp_pose.PoseLandmark.LEFT_WRIST.value] return calculate_angle( [shoulder.x, shoulder.y], [elbow.x, elbow.y], [wrist.x, wrist.y] ) # 俯卧撑状态机 if prediction == 1: # 手肘弯曲,身体下降 stage = "down" if prediction == 0 and stage == "down": # 手肘伸直,撑起 stage = "up" counter += 1俯卧撑的一个坑是身体是否保持直线。有些人塌腰或撅屁股,手肘角度到了但动作不标准。可以用肩膀-髋部-脚踝的夹角来判断身体是否直线,正常俯卧撑这个角度应该在 160 到 180 度之间。如果小于 150 度,说明塌腰了,可以在画面上给出提示,但不影响计数——计数和动作质量判断是两件事,分开处理更清晰。
三个动作的计数逻辑可以整合到一个脚本里,用一个变量切换当前动作模式,加载对应的 KNN 模型和角度计算函数。这样一套代码就能支持三种计数器,切换动作时只需要改一个配置项。
5. 避坑与排查:那些让计数器翻车的细节
5.1 关键点抖动导致计数跳变
现象:计数器偶尔会一次动作加两下,或者动作做到一半突然加一下。
原因:MediaPipe 的关键点估计在帧与帧之间有抖动,即使人站着不动,手肘角度也可能在 5 到 10 度范围内波动。如果这个波动刚好跨过 KNN 的决策边界,预测结果就会在 0 和 1 之间反复跳,状态机被反复触发。
解决:开启smooth_landmarks=True做时序平滑,或者在角度值上做滑动平均。我一般用 5 帧的滑动窗口,把最近 5 帧的角度取平均后再送进 KNN。这样单帧的抖动被平滑掉,计数稳定很多。代价是响应延迟增加约 5 帧,30fps 下约 160ms,对健身计数来说完全可以接受。
5.2 光线不足导致关键点丢失
现象:光线暗的时候,画面上的骨架突然消失,计数器停止工作。
原因:MediaPipe 的姿态估计模型依赖图像中的人体轮廓,光线不足时轮廓不清晰,检测置信度低于min_detection_confidence阈值,模型直接返回空结果。
解决:把min_detection_confidence和min_tracking_confidence都降到 0.3,同时改善照明——在摄像头旁边加一个补光灯,或者把训练位置换到窗边。如果还是不行,考虑用红外摄像头,但普通 USB 红外摄像头分辨率低,关键点精度会下降。最实用的方案还是加灯。
5.3 摄像头角度导致角度计算偏差
现象:同一个动作,换个位置站,计数结果就不一样了。
原因:MediaPipe 输出的是 2D 归一化坐标,如果摄像头不是正对训练者,身体在画面中会有透视变形,计算出的关节角度和真实角度有偏差。比如摄像头从侧面拍,深蹲时膝盖角度看起来比实际小。
解决:固定摄像头位置,正对训练者,高度与髋部齐平。如果必须侧面拍摄,需要在训练数据采集时就用同样的角度,让 KNN 学习这个视角下的角度模式。换视角就要重新采集数据,这是 KNN 方案的一个局限——它学的是特定视角下的角度分布,视角变了分布就变了。
5.4 KNN 模型加载后预测结果全为同一类
现象:训练时交叉验证准确率 95% 以上,但实际运行时所有预测都是 0。
原因:最常见的是特征尺度问题。如果训练时用的角度是 0 到 180 的原始值,预测时传入了归一化到 0 到 1 的值,KNN 的距离计算就完全错了。另一个原因是模型文件加载失败,knn.predict返回的是默认值。
解决:在预测前打印一下输入特征的值,确认和训练时的范围一致。如果训练时用了StandardScaler做标准化,预测时也必须用同一个 scaler 转换。我一般会在模型保存时把 scaler 一起存进去,加载时一起加载,避免遗漏。
5.5 多人入镜导致关键点串人
现象:画面里出现两个人时,骨架在两个人之间跳来跳去,计数完全乱套。
原因:MediaPipe Pose 默认只检测置信度最高的一个人,当两个人交替成为最高置信度时,关键点就在两人之间切换。
解决:最简单的办法是确保画面里只有一个人。如果无法避免,可以用 MediaPipe 的 PoseLandmarker 多姿态版本,但那个模型更大更慢。另一个思路是用目标检测先框出主要人物,再在框内做姿态估计,但实现复杂度高。对于健身计数这个场景,单人使用是默认前提,多人场景不是这个方案的目标。
6. 从能跑到好用:三个提升计数精度的小技巧
第一个技巧是动态阈值替代固定 KNN。KNN 的决策边界是训练时确定的,但每个人的关节活动范围不同。可以在运行时记录最近 30 帧的角度最大值和最小值,动态调整分类阈值。比如手肘角度的最大值是 175 度,最小值是 45 度,那中间值 110 度就是天然的阶段分界线。这个方法不需要训练数据,自适应性强,适合快速验证。
from collections import deque angle_buffer = deque(maxlen=30) # 每帧把角度加入缓冲区 angle_buffer.append(angle) if len(angle_buffer) == 30: max_angle = max(angle_buffer) min_angle = min(angle_buffer) mid_angle = (max_angle + min_angle) / 2 # 动态判断阶段 if angle > mid_angle + 10: stage = "down" elif angle < mid_angle - 10: if stage == "down": counter += 1 stage = "up"第二个技巧是加入角速度特征。单纯用角度判断阶段,在动作缓慢时容易在边界附近反复触发。加入角速度后,可以判断"角度正在快速减小"还是"角度稳定不变"。角速度用相邻两帧的角度差除以时间间隔得到。KNN 的输入从一维变成二维[angle, angular_velocity],分类边界更清晰。采集数据时也要同时记录角速度,训练流程不变。
第三个技巧是用双阶段验证。一次完整的引体向上应该包含"角度从大到小"和"角度从小到大"两个过程。可以在状态机里加一个计数器,记录连续多少帧处于"up"状态和"down"状态。只有"up"持续超过 5 帧且"down"持续超过 5 帧,才计一次数。这能过滤掉因为抖动导致的瞬时状态切换,代价是快速动作可能漏计——如果一次引体向上从拉到放只用了 10 帧,5 帧的阈值就太长了。实际调参时根据动作速度调整,一般 3 到 5 帧比较合适。
这三个技巧可以叠加使用。我自己的习惯是先用动态阈值快速验证摄像头和角度计算是否正常,然后切换到 KNN 加角速度特征做正式计数,最后用双阶段验证过滤抖动。整套跑下来,在光线充足、单人、正对摄像头的条件下,引体向上和深蹲的计数准确率能到 95% 以上,俯卧撑因为身体姿态变化大,稍低一些,约 90%。希望帮到你。
本文还有配套的精品资源,点击获取