ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MediaPipe手势识别与机器学习:基于21个关键点的数字分类实战

MediaPipe手势识别与机器学习:基于21个关键点的数字分类实战 简介基于MediaPipe实现手势数字识别的机器学习项目面向计算机相关专业学生、算法爱好者及课堂展示需求的学习者解决从手部关键点定位到数字分类的完整建模流程。压缩包共2014个文件其中1991个npy为可直接加载的模型权重或特征数据省去自行采集样本与重新训练的时间3个py涵盖数据预处理、模型训练与推理调用5个xml为配置或标注文件另有1个md项目说明文档整体11.64MB体量小、易部署目录结构清晰。当前已有318人学习/下载。项目代码经测试运行稳定流程覆盖MediaPipe手部关键点提取、特征组织、机器学习模型构建与数字识别输出说明文档对数据目录、运行环境、参数调整和关键步骤均有细致拆解既适合新手循序渐进地复现也可作为课程设计、大作业或毕设开题演示的基础工程便于二次开发与功能扩展。说明文档还提供环境依赖与运行配置说明可帮助学习者缩短环境搭建时间。1. 基于 MediaPipe 的手势数字识别在做什么检测交给模型分类交给特征这个项目做的是用普通摄像头实时识别 0 到 9 的手势数字检测交给 MediaPipe 手部关键点定位分类自己训练一个轻量模型Python 源码加项目说明一起打包是手势识别入门到实战的一条龙结构。我最早接触这类项目时有个误解以为手势识别得先自己训练手部检测模型数据要标几千张框。实际完全不用MediaPipe 已经把 21 个手部关键点从画面里抠出来了要处理的是 21 个点的坐标序列不是整张图像。难点从「手在哪」变成「怎么区分数字」轻了一个量级。适合谁呢想入门口视觉但不想碰目标检测的初学者以及有 Python 基础、想快速做一个交互演示项目的从业者。采集样本、提取特征、训练分类器、实时推理四步就能看到效果这也是这套源码最值得拆开跑一遍的地方。2. 拆解 MediaPipe 手部关键点21 个 landmark 如何变成可训练的特征2.1 手部检测与跟踪两条管线static_image_mode 和 confidence 参数的关系MediaPipe Hands 的处理分两段。第一段是全图检测从画面里找出手的位置并回归出 21 个关键点的初始坐标这个动作只在两种情况下触发一是 static_image_modeTrue 时对每一帧都做二是跟踪状态丢失后重新初始化。第二段是跟踪在上一帧关键点附近的小窗口内做优化把关键点位置从上一帧预测到当前帧速度远快于全图检测。static_image_mode 默认是 False适合视频流如果处理的是文件夹里的图片要改成 True让每张图都走全图检测。我在实际项目里见过把 static_image_mode 设成 True 跑视频的情况结果是每帧都做完整检测CPU 占用拉满帧率从 30 掉到个位数。反过来用 False 跑独立图片会出现隔几张图丢一次检测的现象因为跟踪在手指移出画面再回来时会失效。min_detection_confidence 控制检测阶段对「这是不是一只手」的最低置信度默认 0.5调高到 0.8 以上能减少背景误检但手部姿态复杂或光照差时会直接漏检。min_tracking_confidence 控制跟踪阶段的置信度低于阈值就触发重新检测默认 0.5在快速挥手、运动模糊严重的场景里调到 0.6 到 0.7 反而更稳因为提前重检比带着偏掉的跟踪窗口继续跑要好。这两个参数一个管「找手」一个管「跟手」调参顺序是先调检测、再调跟踪。2.2 从原始坐标到特征向量归一化、指尖距离与角度特征MediaPipe 返回的 landmark 包含 x、y、z 三个值x/y 是相对图像宽高的归一化坐标范围在 0 到 1 左右z 是相对手腕的深度会随手到摄像头的距离变化。直接把 63 维原始值送进分类器有两个问题手在画面里位置变了特征就变手大小不同特征也变。特征工程第一步是消除位置和尺度的影响。我一般做两步归一化先把所有点平移到以手腕landmark 0为原点再按手掌宽度缩放。手掌宽度取 wrist 到 middle_mcplandmark 9的欧氏距离也有人在 z 值噪声大时改用 wrist 到 index_mcplandmark 5的距离效果差别不大。关键是不能拿指尖到手腕这种会随手指弯曲明显变化的距离做尺度基准否则同一个数字的不同姿态会被拉得很开。import numpy as np def extract_features(landmarks): # landmarks: mediapipe 返回的 21 个点每个点有 x, y, z coords np.array([[lm.x, lm.y, lm.z] for lm in landmarks]) # 1. 平移归一化以手腕(索引0)为原点 coords coords - coords[0] # 2. 尺度归一化用 wrist 到 middle_mcp(索引9)的距离 scale np.linalg.norm(coords[9] - coords[0]) if scale 1e-6: return None # 尺度异常直接丢弃这一帧 coords coords / scale return coords.flatten() # 63 维特征向量这段代码是项目里特征提取的核心。scale 加个极小值防止除零返回 None 让上层跳过异常帧。这里只做平移和缩放不做旋转归一化因为手势数字识别里手掌朝向本身是区分角度的关键信息旋转归一化反而会削弱数字 1 和数字 2 这类姿态差异较小的类别。如果想进一步提升鲁棒性可以补角度特征计算相邻关键点构成的向量方向角比如每个手指的近端、中间、远端指节之间的夹角。角度特征对画面中手的旋转不敏感代价是维度从 63 涨到 100 多小样本下更容易过拟合。我的建议是先只用坐标特征准确率卡在 90% 左右时再考虑补角度。2.3 为什么手势数字识别用轻量分类器而不是 CNN很多人看到「机器学习项目」会默认上深度学习其实在这个任务里随机森林或 SVM 往往比小型 CNN 更稳。原因在于输入是 21 个关键点而不是图像63 维特征已经高度抽象CNN 的优势是自动学习像素级的空间局部性而关键点坐标里没有这种结构可学。样本量也是现实约束。自己做数据采集每个数字录 200 到 300 帧是常态总共两三千条样本。这个量级下随机森林不容易过拟合训练时间在秒级改特征重跑的成本极低。我做过对比同样数据随机森林测试准确率 94%一个小型 CNN 反而只有 89%而且 CNN 要调的参数多得多。所以这套源码的常见做法是 scikit-learn 的 RandomForestClassifier训练快、可解释、调参少。这也是为什么项目说明里模型训练部分通常写得比较短——真正花时间的是数据采集和特征工程模型本身在这个任务里不是瓶颈。3. 把项目源码跑通并自己训练一遍采集、特征与推理全流程3.1 环境准备mediapipe 和 OpenCV 的最小依赖组合先确认 Python 版本mediapipe 建议在 3.8 到 3.11 之间太新的版本可能没有对应轮子。安装依赖pip install mediapipe opencv-python numpy scikit-learn joblibmediapipe 负责关键点检测opencv-python 负责读摄像头和画画面numpy 做特征向量scikit-learn 提供分类器joblib 保存模型。这五个包是这个项目能跑起来的全部依赖不需要装深度学习框架。装完先用一段最短代码验证摄像头和 mediapipe 是否正常import cv2 import mediapipe as mp cap cv2.VideoCapture(0) mp_hands mp.solutions.hands with mp_hands.Hands() as hands: while True: ok, frame cap.read() if not ok: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) print(hand count:, len(results.multi_hand_landmarks or [])) if cv2.waitKey(1) 0xFF ord(q): break cap.release()能看到 hand count 变化说明环境通了。这里有个很容易踩的坑mediapipe 的 process 接收的是 RGB 而不是 BGROpenCV 读出来是 BGR不转换的话关键点位置会整体偏移数字识别准确率明显下降。我在帮 A 同学排查时见过他在灰度图上反复调参数其实只是颜色通道顺序的问题。3.2 数据采集脚本每个数字录一批关键点样本数据采集是这个项目里最费时间但决定上限的环节。我的习惯是每个数字录 200 到 300 帧分正对摄像头、偏左、偏右三种姿态比例大概 6:2:2。采集时手保持在画面中央距离摄像头 30 到 60 厘米这个区间检测最稳。import csv import os import cv2 import mediapipe as mp def collect_digit(digit, save_dir, target_frames250): os.makedirs(save_dir, exist_okTrue) mp_hands mp.solutions.hands cap cv2.VideoCapture(0) rows [] with mp_hands.Hands(max_num_hands1) as hands: while len(rows) target_frames: ok, frame cap.read() if not ok: continue frame cv2.flip(frame, 1) # 镜像录的时候更符合直觉 rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) if results.multi_hand_landmarks: hand results.multi_hand_landmarks[0] row [] for lm in hand.landmark: row.extend([lm.x, lm.y, lm.z]) rows.append(row) mp.solutions.drawing_utils.draw_landmarks( frame, hand, mp_hands.HAND_CONNECTIONS) cv2.imshow(fcollect digit {digit}, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() with open(os.path.join(save_dir, landmarks.csv), w, newline) as f: writer csv.writer(f) writer.writerow([frame] [f{a}{i} for i in range(21) for a in (x, y, z)]) for i, row in enumerate(rows): writer.writerow([i] row) collect_digit(1, gesture_data/digit_1, 250)这段代码把关键点直接存成 CSV不存图像好处是数据量极小250 帧只有几十 KB后续训练省去图像预处理。代价是数据被锁死在当前 mediapipe 版本的关键点定义上换关键点模型版本后旧数据可能要重采。提示采集时每换一个姿态就按一次键做标记方便检查每段数据的分布同时盯着预览确认关键点有没有稳定跟住手否则可能录进一堆漏检帧。3.3 特征提取与训练脚本从 CSV 到分类模型数据采完把每个数字的 CSV 读进来套用 2.2 的特征提取函数生成特征矩阵再训练并评估分类器import numpy as np import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report import joblib def load_dataset(data_root): X, y [], [] for digit in range(10): csv_path f{data_root}/digit_{digit}/landmarks.csv try: df pd.read_csv(csv_path) except FileNotFoundError: continue coords df.iloc[:, 1:].values.reshape(-1, 21, 3) for sample in coords: feat extract_features(sample) # 复用 2.2 的函数 if feat is not None: X.append(feat) y.append(digit) return np.vstack(X), np.array(y) X, y load_dataset(gesture_data) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy) model RandomForestClassifier(n_estimators300, max_depth14, random_state42) model.fit(X_train, y_train) print(classification_report(y_test, model.predict(X_test))) joblib.dump(model, hand_digit_model.joblib)train_test_split 里 stratifyy 是必须的它保证每个数字在训练集和测试集里的比例一致。不加的话某个数字的样本可能全落到测试集造成指标虚高或虚低。RF 的两个参数里n_estimators 300 在几千样本上已经足够再大收益很小max_depth 限制在 14 左右防止单棵树记住采集顺序里的噪声。训练完一定要看 classification_report 而不是只看准确率。这个任务的典型局面是整体准确率 94%但某个类别 recall 只有 70%说明该数字的姿态覆盖不够要补数据而不是调模型。3.4 推理脚本摄像头实时识别与置信度显示训练完成后的推理脚本是整个项目最直观的部分检测到手就提取特征、预测数字、把结果和置信度画在画面上import cv2 import mediapipe as mp import numpy as np import joblib model joblib.load(hand_digit_model.joblib) mp_hands mp.solutions.hands cap cv2.VideoCapture(0) with mp_hands.Hands(max_num_hands1, min_detection_confidence0.7, min_tracking_confidence0.5) as hands: while True: ok, frame cap.read() if not ok: break frame cv2.flip(frame, 1) rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) if results.multi_hand_landmarks: hand results.multi_hand_landmarks[0] feat extract_features(hand.landmark) if feat is not None: pred model.predict(feat.reshape(1, -1))[0] prob model.predict_proba(feat.reshape(1, -1)).max() cv2.putText(frame, fdigit{pred} ({prob:.2f}), (30, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow(hand digit, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()predict_proba 返回每个类别的概率取最大值作为置信度。如果数字预测对了但置信度一直抖在 0.5 附近说明特征离分类边界很近这时候别急着调模型回去补那个数字的偏转样本更有效。max_num_hands1 避免背景里的手干扰多人场景可以改成 2但要注意选对手5.4 会展开讲。4. 必调参数与效果优化让识别从「能跑」到「好用」4.1 MediaPipe 三个关键参数的调法参数默认值适用场景调参建议static_image_modeFalse视频流用 False图片集用 True跑视频误设 True 会掉帧到个位数min_detection_confidence0.5控制全图检测的最低置信度室内固定光 0.5~0.7强背景噪声提到 0.8min_tracking_confidence0.5跟踪丢失后触发重新检测快速挥手、运动模糊提到 0.6~0.7max_num_hands1画面里最多检测几只手单人示范固定 1多人才设 2这些参数在创建 Hands 对象时一次性传入运行中改不了调参就得重建对象所以建议把参数集中放在脚本开头方便反复试。调参这件事有点玄学但顺序是有章法的先固定手势和距离调 min_detection_confidence 让检测稳定再快速挥手调 min_tracking_confidence 减少丢帧最后才动 max_num_hands 和模型侧参数。有一个常见误用是以为 confidence 越高越准。实际在 30 到 60 厘米的正常使用距离上0.7 和 0.9 的检测结果几乎没有差别但 0.9 会让手稍微倾斜就漏检。我的经验是保持默认附近把精力放在数据多样性上参数对效果的贡献远小于样本质量。4.2 数据层面的优化样本均衡与姿态多样性模型效果的上限由数据决定。首先是样本均衡10 个数字的样本量应该基本一致采集时用目标帧数控制而不是录到「差不多就行」。其次是姿态多样性只录正对摄像头的样本模型在真实使用时会频繁翻车因为人不会永远把手端平。在样本量有限的前提下可以给关键点坐标做轻量数据增强。注意要在归一化之后做否则增强引入的扰动会被缩放抵消import numpy as np def augment(coords, noise0.01, angle0.15): # coords: 已归一化的 21x3 坐标 rng np.random.default_rng() noisy coords rng.normal(0, noise, coords.shape) # 绕 z 轴小幅旋转模拟手腕偏转 rot np.array([[np.cos(angle), -np.sin(angle), 0], [np.sin(angle), np.cos(angle), 0], [0, 0, 1]]) return noisy rot噪声幅度 0.01 对应归一化坐标下约 1% 的抖动在摄像头画面里已经能明显感知angle 取 0.15 弧度约 8.6 度模拟轻微手腕转动。增强倍数一般控制在 3 到 5 倍太多会让模型学到噪声本身。增强后的样本要混进原始数据一起训练不能只用增强样本否则手势的锐利边界会被抹平。另外同一个数字建议在不同光照、不同背景各录一组。mediapipe 的关键点检测对光照敏感阴影重的环境下手指之间的关键点容易粘连这类样本对模型的泛化帮助比增大多姿态更明显。4.3 模型层面的取舍随机森林、SVM 与小型 MLP特征维度只有 63 时模型选择不需要纠结太久但三个候选各有适用边界模型优点缺点适合场景RandomForest训练快、不用归一化、抗过拟合模型文件较大默认首选LinearSVC推理最快、模型文件小需要特征缩放、线性边界有限嵌入式或低功耗设备小型 MLP能学非线性边界小样本易过拟合、调参成本高特征工程做得深、样本量充足我一般先用 RandomForest 跑一版确认特征工程没问题后再对比 SVC。特征缩放对 SVC 很重要随机森林不需要所以如果打算在项目里同时对比这两个模型特征提取脚本里要预留一个标准化开关from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train_s scaler.fit_transform(X_train) X_test_s scaler.transform(X_test) # 用训练集的均值方差不能重新 fit from sklearn.svm import SVC svc SVC(kernelrbf, C1.0, gammascale, probabilityTrue) svc.fit(X_train_s, y_train) print(SVC acc:, svc.score(X_test_s, y_test))这里最容易出错的是对测试集单独做 scaler.fit_transform导致测试数据被自己的统计量污染准确率虚高。正确的做法是先 fit 训练集再 transform 测试集。离线评估时用 joblib.dump 把 scaler 和模型一起存下来推理脚本加载时先 transform 再 predict和训练流程保持一致。5. 手势数字识别避坑指南现象、原因与解决5.1 数字 1 和数字 2 总是混淆现象预测结果在 1 和 2 之间来回跳置信度都接近 0.5。原因有两层一是这两个数字的关键点坐标本来就接近特别是食指和中指并拢时二是采集时手势不标准数字 1 的中指自然蜷缩和数字 2 的食指中指并拢在 21 个点的描述里非常相似。解决采集时要求手指明确分开数字 1 保持食指竖直、其余四指并拢蜷起同时检查这两类的样本数是否均衡必要时给数字 2 补几个食指中指明显分开的样本并在特征里加上指尖间距维度。5.2 手一靠近摄像头就检测不到关键点现象手在 20 厘米以内靠近镜头时landmark 要么消失要么剧烈跳动。原因手在画面里占比过大超出训练数据覆盖的尺度范围同时近距离下运动模糊和透视畸变更严重。解决控制使用距离在 30 到 60 厘米检查摄像头帧率如果只有 15 帧手一移动就是一片糊mediapipe 自然跟不住实在要近距离用把 min_detection_confidence 降到 0.5 并加大数据集里近距样本的比例。5.3 训练集 99%、测试集掉到 80%采集顺序造成的隐藏过拟合现象离线评估准确率很高一开摄像头实测就露馅。原因采集时按数字顺序连续录制同一段视频的相邻帧高度相似train_test_split 随机切分后训练集和测试集里混着大量来自同一段视频的相似帧模型记住的是采集顺序和当时的光照不是手势本身。解决按录制段做分组切分保证同一段视频的帧只出现在一个集合里。用 scikit-learn 的 GroupShuffleSplit给每段视频一个组号或者简单点每段数据前 70% 训练、后 30% 测试。这个坑的隐蔽性在于整体准确率仍然好看只有换环境测试才会暴露。5.4 背景出现人脸或其他手导致预测乱跳现象画面里出现第二只手或人脸时预测在多个数字间乱跳。原因max_num_hands 设成 2 时模型可能取到了背景那只手人脸在某些姿态下也会被当成手。解决优先取面积最大的手手作为交互主体通常离镜头更近、面积更大def pick_largest_hand(results): best, best_area None, -1 for hand in results.multi_hand_landmarks: xs [lm.x for lm in hand.landmark] ys [lm.y for lm in hand.landmark] area (max(xs) - min(xs)) * (max(ys) - min(ys)) if area best_area: best, best_area hand, area return best用归一化坐标算面积不受画面分辨率影响。如果场景里人脸误检频繁再把 min_detection_confidence 提高到 0.7 以上。多人同时入镜的场景面积最大这个启发式不一定可靠这时候可以考虑用左右手标签过滤——mediapipe 返回的 handedness 是可靠的但注意它给出的是「镜像后视角」的左右手需要配合画面是否翻转一起判断。5.5 同一个数字不同人比划差异大模型只认一个人现象采集者自己用得挺好换一个人识别率骤降。原因手指长度比例、关节柔韧性、习惯姿势都因人而异单个采集者覆盖不了分布。解决至少找 3 个人采集每个人录全部 10 个数字归一化时保持基准一致并且不要在数据里混入过多同一人的帧否则模型会把这个人学成「标准手势」。如果团队里只有一个人也要通过数据增强模拟不同手指长度的变化比如对每个手指的远端点做小幅伸缩。注意这类避坑条目不是看完就完的建议每一条对应改一次代码并重新训练单独验证效果。一次同时改多个地方出了问题很难定位是哪个调整起了反作用。6. 让识别更稳的最后一公里帧间投票与动态手势扩展静态数字识别做到 94% 准确率之后实时演示里用户感知到的往往是那 6% 的单帧误报。单帧预测每次独立决策抖动在所难免一个特别简单的办法是加帧间投票维护一个长度为 10 的队列取出现次数最多的数字作为最终结果。from collections import deque vote_win deque(maxlen10) # 每帧预测后 vote_win.append(pred) if len(vote_win) vote_win.maxlen: final max(set(vote_win), keyvote_win.count)10 帧窗口在 30 帧率下只引入约 300 毫秒延迟人几乎无感但能消掉绝大多数单帧误检。注意队列满之前不要输出结果否则前几帧会有一闪而过的错误数字。如果项目要控制响应速度可以把窗口缩到 5延迟减半抖动会多一点点。投票稳定了静态识别后可以往动态手势走。常见做法是用关键点 9middle_mcp的 x 坐标记录最近 15 帧的轨迹判断手掌整体移动方向def detect_swipe(traj_x): if len(traj_x) 15: return None dx traj_x[-1] - traj_x[0] if abs(dx) 0.08: # 归一化坐标下约 8% 的位移 return None return left if dx 0 else right阈值 0.08 是我按 640 宽的画面标定出来的大约对应 50 像素的位移太灵敏会把手的自然抖动误判成滑动。这属于必须实测标定的值换摄像头分辨率后要重新算。我自己的习惯是每加一个阈值就写进配置常量并注释标定条件不然过两周回来看代码根本想不起来 0.08 是怎么来的。照这个链路走下来一个手势数字识别的完整项目成本很低不碰目标检测、不碰深度学习框架、单机摄像头就能演示。它值得做的价值在于把「手部关键点 轻量分类」这条路线跑通之后换成表情识别、物体计数、网球动作分析都是同一套特征工程思路。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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