简介:基于mediapipe和KNN分类算法的健身计数器项目源码包,支持引体向上、深蹲、俯卧撑三种动作识别与计数。与传统依赖骨骼角度阈值判断的方案不同,它通过人体关键点归一化编码和k近邻分类完成动作判定,切换运动类型时几乎无需改动代码,适合python开发者用于运动AI入门、健身App原型落地等场景。压缩包共61个文件,约18.45MB,核心为10个py模块,覆盖姿态编码、KNN分类、指数移动平均平滑、计数、结果可视化、训练集预处理与视频检测等完整流程;另附csv特征库、可直接运行的视频示例、xml工程配置和jpg样例图片,方便查看效果。目前已有270人学习。项目说明中详细阐述了各模块职责与训练样本组织要求,配合示例视频和预置特征文件,可快速复现计数效果,也支持采集自定义样本、扩充自己的动作库。
1. 健身计数器难点不在“识别”,而在“状态”转换
拿摄像头对着自己练引体向上,最尴尬的时刻是模型把每个动作都识别对了,计数却跳到莫名其妙。这个问题在这个标题所指的 MediaPipe + KNN 方案里尤其常见:MediaPipe 负责把身体变成 33 个关键点,KNN 负责把这 33 个点变成“上拉 / 下放 / 悬停”的姿势标签,而真正决定屏幕上“1、2、3”的是两者之外的计数逻辑。换句话说,标题里最容易被忽略的“计数器”三个字,才是这个项目的大部分工作量。我接下来会围绕这个标题拆开讲:为什么选 MediaPipe + KNN 而不是更“高级”的时序模型,关键点和角度特征怎么构造,KNN 怎么训练,以及最终怎么用状态机把类别流变成可靠的计数值。这个方案适合手上只有一台笔记本摄像头、想快速搞定健身计数原型或毕业设计的人,整套跑通只需要 Python 和几个 pip 包。
2. 为什么是 MediaPipe 加 KNN:轻量方案的选型逻辑
先给结论:用 YOLO 做姿态估计再接入分类器,并不是不行,而是把简单问题复杂化。YOLO 给的是目标检测框,要做姿态估计还得额外接一个骨架分支模型,而这个分支恰好是 MediaPipe Pose 已经做好的事。MediaPipe 是端到端的人体姿态估计方案,输入一帧 RGB 图,输出人体 33 个关键点的三维坐标和可见度,不依赖 GPU 也能跑到十几帧以上;KNN 则是这 33 个点与最终分类之间最直接的桥梁。两个库加起来不到两百行核心逻辑,跑完数据采集、训练、计数全流程,这个体量非常适合作为健身计数器的第一版实现。
2.1 从 MediaPipe 拿到的 33 个关键点带什么信息
MediaPipe Pose 的输出是一组landmarks,每帧返回 33 个关键点,每个点包含x、y、z、visibility四个值。x和y是归一化到 [0,1] 的图像坐标,z是与髋部中心相对的深度估计,visibility是该点被算法“看见”的置信度。注意z不是真实摄影测量度,它只在单目图像里表达相对前后关系,所以这个值在俯卧撑、深蹲这类身体姿态大幅变化的动作里抖动会很明显,后期特征工程里建议降权或直接不用。
import cv2 import mediapipe as mp mp_pose = mp.solutions.pose mp_draw = mp.solutions.drawing_utils pose = mp_pose.Pose( static_image_mode=False, # 视频流模式,配合连续帧跟踪 model_complexity=1, # 0轻量、1平衡、2高精度 smooth_landmarks=True, # 平滑关键点,减少抖动 min_detection_confidence=0.5, # 首次检测阈值 min_tracking_confidence=0.5, # 跟踪阈值,低于则重新检测 ) cap = cv2.VideoCapture(0) 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: # 这里会拿到 33 个关键点,后文统称为 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()这段代码是做整个项目的起点。逻辑上先取一帧画面转成 RGB,MediaPipe 内部用的就是 RGB 顺序,直接喂 BGR 会得到明显偏色的姿态结果;pose.process()返回的对象里,pose_landmarks保存了全部关键点,pose_landmarks.landmark[11]是左肩,landmark[12]是右肩,landmark[23]和landmark[24]是左右髋。参数上model_complexity=1在普通笔记本上能跑到 25 帧左右,如果机器比较老就降到 0,代价是肩、髋这类大关节的定位精度会下降一点。这里还有个容易被新手忽略的点:min_tracking_confidence设得越高,人体快速移动时丢失跟踪的概率越大,健身动作普遍速度不慢,我一般保持在 0.5 而不是网上教程常见的 0.7。
2.2 KNN 只负责“这一帧是什么姿势”,不负责计数
KNN 是惰性学习算法,没有传统意义上的训练过程,它只是把训练样本的特征向量和标签存起来。预测时计算新样本到所有训练样本的距离,取距离最近的 K 个样本做投票,票数最多的类别就是结果。之所以在这个项目里够用,是因为健身计数的本质不是“识别动作”,而是“识别动作的相位”。引体向上、深蹲、俯卧撑都是周期性运动,每个周期里只有几个明显不同的静止姿态,比如引体向上的“顶端悬挂”和“底部悬挂”,深蹲的“站直”和“蹲到底”。KNN 对这种少量类别、特征明显的静态姿势分类非常擅长,几十到几百条样本就能出可用效果。
这也解释了为什么不需要 LSTM 或 Transformer 这类时序模型。时序模型解决的是“动作边界模糊、需要看前后文才知道在干什么”的问题,而健身动作的相位差异足够大,单帧姿态已经能给出很强的类别信号。引入时序模型意味着要标注动作起止、要处理变长序列、要调更多超参数,对一个摄像头固定、动作规范的使用场景来说,属于把简单问题复杂化。假如你之后想把方案扩展到“动作不标准提醒”,那再考虑用序列模型或者 MediaPipe Model Maker 自定义分类器也不迟。
2.3 装好 mediapipe 环境,避免 Python 版本翻车
这个标题下最容易劝退人的不是算法,而是环境。MediaPipe 在 Windows + Python 高版本上经常遇到装不上 wheel 的问题,报错信息常常是一大段红色日志,最后落在“No matching distribution found”上。常见做法是单独建一个 Python 3.9 的 conda 环境装这套依赖,避坑概率最高;Python 3.11 及以上版本搭配 MediaPipe 也不是完全不能装,但不同操作系统、不同版本之间兼容性表现差异很大,没必要把自己的时间搭进去。
conda create -n fit-counter python=3.9 -y conda activate fit-counter pip install mediapipe scikit-learn opencv-python numpy这里mediapipe负责姿态估计,scikit-learn提供 KNN 分类器和标准化工具,opencv-python负责读摄像头和画画面,numpy用来做向量运算。装完先跑一行python -c "import mediapipe; print(mediapipe.__version__)",能正常输出版本号就说明环境没问题。Windows 上如果报缺少 DLL,多半是缺 Visual C++ 运行库,装上对应运行库再重试即可。
3. 特征工程:把身体的 33 个关键点变成 KNN 能吃的向量
Feature engineering 是这个项目里决定上限的部分。同样一套 MediaPipe 和 KNN,有人做出来非常准,有人做出来在摄像头前稍微动一动就乱跳,差别基本全在喂给 KNN 的特征上。直接把 33 个点的原始坐标拼成一个 99 维向量喂进去是最简单的做法,但效果也是最差的。
3.1 原始坐标直接喂 KNN 是不行的
原因有三个:平移、缩放、姿态参考系。人在画面里的位置会移动,同一个下蹲姿态站在画面左侧和右侧,x坐标差异很大,KNN 的欧氏距离会被这种无意义的位移主导;人与摄像头的距离不同,同一个动作在画面里的大小编号差异也很大;引体向上时身体在画面里是倒的还涉及翻转。所以第一步必须做归一化,常见做法是以髋部中心为原点,再按肩宽做尺度归一化。
import numpy as np def pose_to_normalized_feature(landmarks): lm = np.array([[l.x, l.y, l.z] for l in landmarks]) # 33 x 3 left_shoulder = lm[11] right_shoulder = lm[12] left_hip = lm[23] right_hip = lm[24] hip_center = (left_hip + right_hip) / 2 lm = lm - hip_center # 平移到以髋部中点为原点 scale = np.linalg.norm(left_shoulder - right_shoulder) # 肩宽 lm = lm / (scale + 1e-6) # 防止除零 return lm.flatten()逻辑说明:先把所有关键点平移,让髋部中心跑到坐标原点,这样人站在画面左边还是右边就无所谓了;再用肩宽做除法,让身高和镜头距离不再影响特征尺度。之所以用肩宽而不是躯干长度,是因为肩宽在任何直立和悬垂姿态下都相对稳定,而且 MediaPipe 对肩膀的定位精度比脚踝高。加1e-6是为了避免肩宽为 0 时除零。这套特征构造完后,向量里的数值就只表达“身体形态”,不表达“人在画面里的绝对位置”。注意这里只建议用x和y,z在俯卧撑这类贴近地面的动作里抖动非常大,把z拼进去反而会引入噪声。
3.2 加一组关节角度,视角变化也不怕
坐标归一化解决的是平移和缩放问题,但解决不了“同一个动作在不同角度下拍摄,坐标值完全不同”的问题。比如摄像头放在正前方和侧面 30 度,肩、肘、腕三个点在画面里的相对位置差异很大。关节角度特征能缓解这一点,因为角度是几何不变量,不随拍摄视角旋转而改变。这里说的不是所有 33 个点,而是几个对健身动作最关键的角度:肘关节角、肩关节角、髋关节角、膝关节角。把这些角度和归一化坐标拼接起来,KNN 的判别会更鲁棒。
def calc_angle(a, b, c): """计算三个点构成的角度,b 是顶点""" a, b, c = np.array(a), np.array(b), np.array(c) ba = a - b bc = c - b cos_theta = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-6) return np.degrees(np.arccos(np.clip(cos_theta, -1.0, 1.0))) def pose_to_combined_feature(landmarks): lm = np.array([[l.x, l.y, l.z] for l in landmarks]) # 先做平移缩放归一化 hip_center = (lm[23] + lm[24]) / 2 lm = lm - hip_center scale = np.linalg.norm(lm[11] - lm[12]) lm = lm / (scale + 1e-6) angles = [ calc_angle(lm[11], lm[13], lm[15]), # 左肘角:肩-肘-腕 calc_angle(lm[12], lm[14], lm[16]), # 右肘角 calc_angle(lm[23], lm[11], lm[13]), # 左肩角:髋-肩-肘 calc_angle(lm[24], lm[12], lm[14]), # 右肩角 calc_angle(lm[11], lm[23], lm[25]), # 左髋角:肩-髋-膝 calc_angle(lm[12], lm[24], lm[26]), # 右髋角 calc_angle(lm[23], lm[25], lm[27]), # 左膝角:髋-膝-踝 calc_angle(lm[24], lm[26], lm[28]), # 右膝角 ] return np.concatenate([lm[:, :2].flatten(), angles])逻辑说明:先用第 3.1 节的方法做平移缩放,然后取x、y两个维度进特征,拼上 8 个角度值。这样最终特征维度是 33×2 + 8 = 74 维,比纯坐标少了 25 维,信息量却更大。角度计算时重点看顶点:膝关节角反映深蹲深度,肘关节角区分引体向上的顶端和底端,肩关节角用来区分俯卧撑推起和下压。参数上注意所有点的坐标都来自归一化后的lm,因为角度只和三点相对位置有关,和尺度无关,所以这一步其实不依赖归一化,但保持统一流程更顺手。这里有个经验:深蹲时膝盖角度从 170 度到接近 90 度变化,引体向上时肘角从 160 度到 30 度变化,这些区间本身就足够区分相位,不用再另外设计复杂特征。如果你发现单靠这几个角度在某个动作上不稳,可以再加手腕到髋部的距离这种“部位间距离”特征,但不建议一开始就堆特征维度。
3.3 类别标签设计:把动作拆成静态姿态
KNN 输出的类别不应该叫“引体向上”或“深蹲”,而应该是这些动作的相位标签。比如对引体向上,设计neutral(站在地上休息或悬垂放松)、pullup_top(下巴过杠)、pullup_bottom(手臂伸直悬垂)三类。这样设计的原因是计数需要“先上后下”或“先下后上”的完整过程,而不是只识别动作名称。对深蹲则是squat_up和squat_down,俯卧撑则是pushup_up和pushup_down。每个动作单独训练一个 KNN 模型,互不干扰,比把所有类别混在一个模型里更容易调优。
采集样本时我习惯让每个类别至少 100 条,稳定一点可以到 200 条,再多对 KNN 没有明显收益,反而让预测变慢。这里还涉及一个类别平衡问题:neutral类最容易采多,因为多按几次按键就多了一堆样本。KNN 不支持class_weight,所以类别不平衡得靠采样策略解决,常见做法是把neutral类随机欠采样到和其他类相近的数量,否则预测结果会偏向样本最多的类,现象就是你站直后被疯狂识别成neutral。
4. 训练与计数:KNN 出类别,状态机出次数
整个项目的执行顺序是:采集带标签的姿态特征 → 标准化 → 训练 KNN → 实时推理 → 状态机计数。前两步在离线完成,后两步在摄像头循环里完成。接下来我会按这个流程给出一套可以直接照抄的实现。
4.1 数据采集和打标签:实时按键采集比事后抽帧省事
数据采集有两种常见路子:一种是录制视频再事后逐帧抽帧并标注,另一种是实时摄像头前摆姿势按键采集。我强烈推荐后者,因为前者意味着你要给几百帧图像一帧帧打标签,枯燥还容易标错。按键采集时,你只需要在镜头前摆好目标姿势,按一个键就保存一条样本。
import csv import cv2 import mediapipe as mp mp_pose = mp.solutions.pose pose = mp_pose.Pose(model_complexity=1, min_detection_confidence=0.5) # 按键映射:n=neutral, t=top, b=bottom, q=退出 label_map = {"n": "neutral", "t": "top", "b": "bottom"} rows = [] cap = cv2.VideoCapture(0) 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: feature = pose_to_combined_feature(results.pose_landmarks) cv2.putText(frame, "waiting for key...", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow("collect", frame) key = cv2.waitKey(1) & 0xFF if key in (ord("n"), ord("t"), ord("b")): label = label_map[chr(key)] rows.append([label] + feature.tolist()) print(f"sample {len(rows)}: {label}") elif key == ord("q"): break cap.release() with open("collected.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["label"] + [f"f{i}" for i in range(len(rows[0]) - 1)]) writer.writerows(rows)代码逻辑是每帧实时计算特征,按键时把当前特征和标签一起攒起来,退出后写进 CSV。每个按键只采一帧,所以不要按住不放,而是每按一次换一下身体位置再按,让样本覆盖不同站位和微小的姿态差异。这里最容易犯的错是固定站在原地连续采集几百条,这样收集到的样本几乎一样,KNN 学到的形态过于单一,换一个站位就翻车。我一般每个类别采 150 到 200 条,中间会有意左右平移半步、靠近和远离摄像头一点,让平移和尺度归一化真正有东西可学。
4.2 训练流程:标准化加 KNN 的网格搜索
KNN 的核心参数就两个:K 值和投票权重。K 值太小容易受噪声点影响,太大则会把边界样本拉进类别;投票权重weights="distance"表示近邻按距离加权投票,比均匀投票更能突出“近的样本更像”的直觉。这里有一个比调参更重要的前置步骤:标准化。特征里归一化坐标和角度值的量纲不一样,角度在 0 到 180 之间,坐标在 -2 到 2 之间,不标准化的话 KNN 的距离计算就被角度特征完全主导了。
import pandas as pd from sklearn.model_selection import GridSearchCV from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier df = pd.read_csv("collected.csv") X = df.drop(columns=["label"]).values y = df["label"].values pipe = make_pipeline( StandardScaler(), KNeighborsClassifier(algorithm="kd_tree") ) param_grid = { "kneighborsclassifier__n_neighbors": [3, 5, 7, 9], "kneighborsclassifier__weights": ["uniform", "distance"], } grid = GridSearchCV(pipe, param_grid, cv=5, scoring="f1_macro") grid.fit(X, y) print("best params:", grid.best_params_) print("best f1:", grid.best_score_)逻辑说明:make_pipeline把标准化和 KNN 串成一条流程,标准化参数在每次交叉验证时只从训练折里学习,避免信息泄露。GridSearchCV用 5 折交叉验证在 8 组参数组合里挑出最优解,f1_macro对类别不平衡比准确率更敏感。这里要注意algorithm="kd_tree",虽然维度不高,但样本量几百上千条时比默认暴力搜索快一些,而ball_tree在这种维度下优势不明显。训练完把grid.best_estimator_用joblib.dump存盘,推理时joblib.load回来直接用。有一点容易被忽略:推理时传进去的特征必须走和训练时完全相同的流程,平移、缩放、拼角度一样都不能少,否则模型看到的分布跟训练时对不上,结果一定乱。
4.3 状态机计数:从类别序列变成计数的完整实现
训练好 KNN 之后,摄像头循环里会得到一长串类别标签,比如bottom → bottom → top → top → bottom。如果直接数“出现多少次 top”,那身体在顶端抖动一下就会多计一次。所以计数要用状态机,规则很简单:只有当你先观察到top,之后又观察到bottom,才算完成一次动作。引体向上、深蹲都是这个方向,俯卧撑把top和bottom对调即可。
import joblib import collections model = joblib.load("knn_model.joblib") scaler = model.named_steps["standardscaler"] knn = model.named_steps["kneighborsclassifier"] MIN_FRAMES = 3 # 连续 N 帧确认状态 count = 0 state = "neutral" candidate = "neutral" consistent = 0 history = collections.deque(maxlen=10) def feed_prediction(pred): global count, state, candidate, consistent if pred == state: candidate = pred consistent += 1 else: candidate = pred consistent = 1 if consistent >= MIN_FRAMES: state = candidate # 状态转换:先 top 后 bottom 视为一次 if pred == "bottom" and state == "top": count += 1 state = "bottom" 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: feat = pose_to_combined_feature(results.pose_landmarks).reshape(1, -1) feat_scaled = scaler.transform(feat) proba = knn.predict_proba(feat_scaled)[0] pred = knn.classes_[np.argmax(proba)] if np.max(proba) < 0.6: pred = "neutral" # 低置信度按中性处理 history.append(pred) feed_prediction(pred) cv2.putText(frame, f"count: {count}", (20, 60), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)逻辑说明:MIN_FRAMES=3意味着连续 3 帧预测为同一个类别才切换状态,这是一个简单有效的去抖手段,能过滤单帧抖动造成的误翻转。predict_proba返回每个类别的投票占比,取最高值作为置信度,低于 0.6 就视为neutral,避免模棱两可的姿势乱跳。计数条件是pred == "bottom" and state == "top",注意这里用的是当前帧的pred而不是state,原因是state可能已经是bottom,再用state判断会漏计。低置信度统一归为 neutral 意味着动作不标准或身体出画面时不会触发计数,稳定性好很多。状态机的两个参数值得调:MIN_FRAMES大一点能压抖动,但会引入延迟,动作快的用户做到每 0.5 秒一个完整动作时可容忍延迟不超过 5 帧;置信度阈值设在 0.5 到 0.7 之间,太低了噪声多,太高了容易漏计真实动作。
5. 避坑:摄像头里的 4 个血泪排查
这套方案看起来链路不长,但真正跑起来后你会发现,绝大多数问题不出在 KNN 分类上,而是出在“摄像头看到的东西”和“模型以为看到的东西”不一致。下面这四条是我自己踩过的,按现象、原因、解决的顺序写,便于你直接对照排查。
5.1 俯卧撑贴地时 MediaPipe 直接看不清
现象:做俯卧撑下压到最低点时,预测结果忽然变成neutral或者完全检测不到姿态,计数中断。
原因:身体贴近地面时,从摄像头视角看,躯干和四肢几乎是平行的,部分关键点相互遮挡,visibility置信度急剧下降。MediaPipe 在低可见度情况下输出的关键点位置漂移得厉害,反而比检测不到更麻烦。
解决:在特征提取前加一道可见度过滤——比如肩、髋、膝、踝这几个关键点的visibility低于 0.5 时,直接丢弃这一帧,不让它进 KNN。丢失帧用状态机里“保持上一状态”的策略顶过去,而不是强行补一个预测值。代码上只需要在feed_prediction之前判断一次可见度,不可见就continue,同时把计数逻辑里MIN_FRAMES稍微调大,比如 4 到 5 帧,俯卧撑这类动作的低置信度瞬间就不会击穿状态。
5.2 引体向上的杠把关键点带偏了
现象:人在单杠下方做引体向上时,肩部关键点在杠的位置上漂移,上升和下降的预测类别来回横跳。
原因:单杠本身具有水平和垂直的边缘特征,MediaPipe 的人体检测分支有时会把杠的一部分误认为是身体的一部分,尤其当人正好在杠正下方时,躯干和杠在图像上重叠严重。
解决:最直接的方案是换拍摄角度,把摄像头放在侧面 45 度而非正对单杠,让杠和人在画面上不要重叠。这个属于拍摄问题,改代码解决不彻底。如果必须正面拍,可以把model_complexity提到 2 来提高关键点定位精度,帧率会从 25 掉到 15 左右,但位置稳定性会好一些。另外在埋状态机时,对top类别做一次条件校验:要求手腕关键点的 y 坐标低于肩膀 y 坐标,否则不认为是引体向上顶端。这个校验是从动作语义上排除误识别,比单纯信任分类器更靠谱。
5.3 预测全是 neutral:类别不平衡和特征尺度问题
现象:模型训练完,在摄像头前怎么摆姿势,输出永远是neutral,其他类别的预测概率很低。
原因:两个可能性叠在一起。第一个是采集时neutral类样本量远大于其他类,KNN 距离相近时投票结果自然偏向大类别;第二个是推理时的特征没有走和训练时一模一样的标准化流程,比如直接用了原始坐标而没有经过StandardScaler转换。
解决:类别问题上,用pd.value_counts看类别分布,把neutral类随机欠采样到其他类的 1.2 倍以内再重训。特征问题上,确认推理时scaler.transform(feat)用的是训练时那个已经fit过的对象,而不是新fit一个;joblib.dump(grid.best_estimator_)存整个 pipeline 能从根本上避免这个问题,因为标准化和模型是绑在一起的。如果你发现自己的场景里 neutral 类确实天然比其他类多,那就在计数逻辑里做条件压制:只有当top或bottom的置信度超过 0.7 时才切换状态,低于这个阈值一律保持当前状态。
5.4 一次动作被计了两次:抖动与迟滞
现象:引体向上做一次,计数器跳了 2 或者直接跳 3。
原因:动作顶端或者底部有微小的上下晃动,预测在top和bottom之间反复横跳,每个来回都触发了一次计数条件。
解决:这就是MIN_FRAMES和迟滞机制要处理的问题。把连续确认帧数从 3 提到 5,能挡住大部分单帧抖动,但会让动作快的人感觉到延迟。另一个更结构化的办法是加迟滞区间:当状态是bottom时,必须连续看到 3 帧top才切到top;反过来也一样,而不是每帧无记忆地更新状态。这样状态切换需要“穿过一个缓冲带”,抖动幅度小于缓冲带的噪声就永远触不发翻转。迟滞的具体参数按你的摄像头帧率来,30 帧率下 3 到 5 帧比较合理,帧率低就相应减少,否则真实动作在慢速进行时也会被卡住。
6. 进阶验证:用回放把模型的每一帧决定都摊开看
训练完 KNN、写完状态机,你以为项目就结束了吗?真正的调优才刚刚开始。我在完成第一版后做了一个回放工具,把录好的视频重新喂给整套流水线,每一帧都叠加上“预测类别、置信度、当前计数、状态”四个信息,存成带标注的视频。然后逐帧回放,找出计数错误帧发生的前后几秒,对照画面里的实际姿态,你会很清楚地看到问题是出在 KNN 分类还是状态机逻辑上。这个验证手段比任何评估指标都直观,因为计数的错误往往集中在动作的过渡帧,而这些过渡帧在整体准确率上根本看不出来。
回放时的观察重点是“状态翻转的时机”。比如引体向上,你要确认从bottom到top的翻转发生在手臂伸直到弯曲的什么位置,反面案例是翻转发生得过早,导致还没完全到顶端就开始计数。我习惯在每个动作视频上回放三遍:第一遍看整体计数对不对,第二遍慢放过渡帧找翻转时机,第三遍遮住画面只看类别序列,专门挑那种“连续 20 帧全是 top”的异常段。这种验证方式还有一个附加好处,就是能顺便验证数据采集的覆盖度。如果你发现某个角度、某个站位下预测频繁出错,说明该补的样本没采够,回到第 4.1 节重新采十几条针对性样本就能解决。
最后一个能让方案更通用的经验:把状态机抽象成“举起 - 放下”通用模板。无论引体向上、深蹲还是俯卧撑,本质都是两个相位交替,唯一区别是哪个相位对应top、哪个对应bottom。把动作名、相位方向、计数方向做成配置项,换一个动作只需要换配置和重新采集数据,主流程代码一行都不用改。这样一来,你手里的这套 MediaPipe 加 KNN 方案就不是一个只能跑三个动作的脚本,而是一个能扩展的健身计数框架。我做完回放之后养成了一个习惯:每调一个参数,就录十秒视频看回放,而不是对着摄像头一遍遍试动作,这个习惯帮我少走了很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取