简介一份基于Python与Mediapipe的手势数字识别机器学习项目源码适合计算机视觉初学者或对实时手势交互感兴趣的开发者。项目利用Mediapipe的手部追踪模块捕捉手部关键点进而通过机器学习模型将手势映射为数字涵盖数据采集、特征提取、模型训练与实时推理等关键环节可帮助读者快速上手视觉与机器学习结合的实际应用。压缩包体积仅3KB共3个文件包括2个Python脚本和1个Markdown文档。其中Python脚本分别封装了手部追踪与主程序流程Markdown文档则提供项目介绍、运行说明便于按步骤复现。已有416人在CSDN学习使用该资源适合作为入门级手势识别项目的参考实现。通过阅读源码读者可以理解Mediapipe管道的基本原理掌握从摄像头实时获取图像、提取手部关键点、输入模型预测数字并展示结果的完整链路。尽管项目小巧但结构清晰能直观感受一个简易机器学习应用从模型到部署的落地方式也为后续扩展为更复杂的动作识别提供了良好的起点。1. 手势数字识别为什么先用 MediaPipe 而不是自己训练检测模型拿到这个项目标题最容易被“机器学习”四个字带偏以为难点在训练一个能“看见手”的检测模型。实际上一线做法刚好反过来用 Python 调 MediaPipe Hands 负责把 21 个手部关键点实时找出来这个环节本身已经是现成的机器学习模型真正需要我们动手的是把关键点翻译成数字 0-9 的那一层分类逻辑。这样拆分之后项目门槛大幅下降不需要 GPU、不需要自己标注数据集训练目标检测一台普通 CPU 笔记本就能跑实时手势数字识别。适合刚入门计算机视觉的 Python 开发者也适合想把手势交互快速做进桌面工具或嵌入式演示的从业者。2. 读懂 MediaPipe Hands 的输出21 个关键点才是后面所有逻辑的地基2.1 手部关键点坐标的坐标系与归一化约定MediaPipe Hands 输出的不是整只手的分割掩码而是 21 个 landmark每个 landmark 包含 x、y、z 三个浮点数。其中 x、y 是相对图像宽高的归一化坐标范围大致在 0 到 1 之间不管摄像头分辨率是 640 还是 1280想还原成像素坐标都得乘回图像的宽和高。z 轴比较特殊它表示该点相对手腕的深度单位是“归一化后的相对值”会随着手掌离镜头远近变化不能当作真实毫米深度使用。这个坐标约定决定了后面所有特征工程的写法。很多人第一次拿到 landmarks 直接存原始 x/y/z 去训练分类器结果发现手离镜头近一点、远一点同一套数据预测结果就变了原因就在 x/y/z 没有与手掌自身的尺寸对齐。我在实际项目里的习惯是先以手腕点 wrist下标 0为原点做一次平移再用手掌宽度wrist 到 middle_mcp 的距离做归一化这样特征对手的大小、离镜头远近、画面位置都不敏感。还需要记住 21 个点的下标分组0 是手腕1-4 是拇指5-8 是食指9-12 是中指13-16 是无名指17-20 是小指。后面算手指伸直度、算角度特征都要反复用到这个分组别每次都翻文档。另外hand_landmarks里还有一个容易忽略的WorldLandmarks输出单位是米以手腕为原点适合做 AR 叠加手势数字识别用不到它因为它剔除了与镜头的距离信息反而看不出“手在画面里有多大”这个线索。2.2 用最小 Python 脚本取到一帧手部关键点先跑通管道再谈识别最小脚本如下import cv2 import mediapipe as mp mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, # 视频流模式启用帧间跟踪 max_num_hands1, # 只处理一只手降低误检 min_detection_confidence0.7, # 首次检测置信度阈值 min_tracking_confidence0.5, # 跟踪置信度阈值 ) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # MediaPipe 按 RGB 输入处理 results hands.process(rgb) if results.multi_hand_landmarks: for hand_landmarks in results.multi_hand_landmarks: h, w, _ frame.shape for lm in hand_landmarks.landmark: cx, cy int(lm.x * w), int(lm.y * h) cv2.circle(frame, (cx, cy), 3, (0, 255, 0), -1) cv2.imshow(hands, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明hands.process()接收 RGB 图像返回检测结果multi_hand_landmarks是检测到的手列表每只手包含 21 个 landmark。把归一化坐标乘回宽高画点是为了先确认管道连通。这里最容易踩的坑是忘了cvtColorOpenCV 读进来是 BGR不转的话 MediaPipe 的检测结果会明显变差。绘制时我在原始 BGR 帧上画点所以最终显示颜色是正常的。参数说明static_image_modeFalse表示连续帧模式MediaPipe 会利用上一帧结果做跟踪速度更快处理单张图片时才设 True。min_detection_confidence0.7控制首次检测的严格程度误检多就往上调手在画面边缘检测不到就往下调。max_num_hands1对数字识别很关键后面做分类时不需要面对“该取哪只手”的选择题。另外注意不要在视频循环里反复创建mp_hands.Hands(...)实例。process()内部会缓存跟踪状态反复重建会让模型每次都走完整检测流程CPU 占用直接翻倍还会丢失上一帧的跟踪信息。正确做法是把hands对象在循环外建一次进程结束时调用hands.close()释放资源。不同 MediaPipe 版本的 API 参数名略有差异以你实际安装的版本为准。2.3 关键点特征怎么选landmark 原始坐标 vs 角度特征拿到 21 个点之后下一步是决定喂给机器学习模型的“特征”到底是什么。直接用原始 x/y/z 做 63 维向量是最简单的方案但效果最不稳因为它混入了手的位置、大小、旋转方向这些与数字无关的信息。我一般会先做一次参考系归一化每个点减去手腕坐标再除以手掌宽度这样同一个手势在不同位置、不同距离下特征基本一致。更稳一点的做法是计算手指角度。比如食指是否伸直可以用 wrist、mcp、pip 三个点构成的夹角判断曲指和伸指在这个角度上差距很大而且基本不受手的大小影响。角度特征在“换一个人、换一只手”的时候表现明显比原始坐标好代价是要自己写向量夹角计算稍微麻烦一点。下一步的规则法会同时演示这两种思路先跑通再决定要不要训练分类模型。3. 从关键点到数字先跑通规则法再谈机器学习分类3.1 规则法判定数字 1-5 的思路规则法的核心就一句话数字 1-5 的区别在于伸出了哪几根手指。MediaPipe 给了每根手指的 tip 和 pip 坐标通过计算 tip 到 wrist 的距离与 pip 到 wrist 的距离之比就能判断这根手指是伸直还是弯曲。伸直时指尖离手腕明显更远比值大弯曲时两个距离接近比值接近 1。这个思路看起来很“土”但作为项目第一步非常值它让你在没有训练数据的情况下先验证整条管道还能帮你摸清 MediaPipe 关键点的抖动程度。如果规则法连 1-5 都判不稳说明问题多半在特征而不是分类器这时候去训练模型也是白费。常见做法是先固定一个场景比如只识别 1、2、3 三个数字跑通再逐步扩到 5不要一上来就追求 0-9 全识别。3.2 把关键点转成特征向量距离、角度、比值代码实现import math def finger_state(landmarks, tip_idx, pip_idx, wrist_idx0): 判断一根手指是否伸直tip 到 wrist 距离 / pip 到 wrist 距离 wrist landmarks[wrist_idx] tip landmarks[tip_idx] pip landmarks[pip_idx] d_tip math.dist((tip.x, tip.y), (wrist.x, wrist.y)) d_pip math.dist((pip.x, pip.y), (wrist.x, wrist.y)) ratio d_tip / d_pip return ratio 1.4 # 经验阈值通常取 1.3~1.5 FINGERS { thumb: (4, 3), # tip, pip index: (8, 6), middle: (12, 10), ring: (16, 14), pinky: (20, 18), } def gesture_from_rules(landmarks): states {} for name, (tip, pip) in FINGERS.items(): states[name] finger_state(landmarks, tip, pip) # 数字 1-5 的手势码按“伸出哪几根手指”判断 if states[index] and not states[middle] and not states[ring] and not states[pinky]: return 1 if states[index] and states[middle] and not states[ring] and not states[pinky]: return 2 if states[index] and states[middle] and states[ring] and not states[pinky]: return 3 if states[index] and states[middle] and states[ring] and states[pinky]: return 4 if all(states.values()): return 5 return -1 # 无法判定逻辑说明每根手指取 tip 和 pip 两个关键点与手腕构成两条线段求距离比值大于阈值判定为伸直。4、3 是拇指的 tip 和 pip其余手指按 5-8、9-12、13-16、17-20 的下标分组取对应点。这个判定对食指、中指、无名指、小指很稳唯独拇指要小心拇指横向张开时 tip 到 wrist 的距离也很大容易误判为伸直。参数说明阈值 1.4 是经验值说实话有点玄学手小的人弯曲时比值可能到 1.35手大的人伸直时可能只有 1.45所以阈值不能定死就完。我会先采几组自己的手的数据把每个手势的比值打印出来取中间值作为阈值。这里没用到 z 坐标因为归一化距离已经把手掌大小的影响消掉大半。如果不喜欢距离比值也可以用向量夹角替代。以食指为例取 wrist、mcp、pip 三个点组成向量wrist-mcp和mcp-pip计算两向量夹角伸直时夹角接近 180 度弯曲时明显变小。角度特征的跨用户稳定性更好缺点是计算稍慢且对指尖这种小角度变化不敏感。实际项目里我通常两个特征都算出来规则法用距离比值训练模型时再拼上角度待选特征池留宽一点让模型自己挑。3.3 规则法的边界为什么 0、6、7、8、9 容易翻车规则法做到 1-5 已经能演示项目效果但继续往下会碰到三个问题。第一6、7、8、9 的定义在不同地区不一致比如伸出拇指和小指有人叫 6有人叫“打电话”7 和 8 也有两种比法规则法做死了就不好改。第二数字 0 是握拳所有手指都弯曲但要和“自然放松的手”区分开需要额外判断规则上很容易误触发。第三拇指参与的数字比如 6、7、8、9拇指在侧向张开时的距离特征和伸直特征非常接近单靠距离比值经常翻车。所以我的经验是规则法适合“固定场景、固定手势定义、只做 1-5”的项目如果项目目标是想让系统具备泛化能力那就要进入下一章用小数据集训练一个真正的分类模型让机器学习模型自己去学“哪些特征组合表示哪个数字”而不是手写一堆 if 分支。4. 采集手势样本到训练分类模型一份可复制的数据流4.1 采集手势样本录制脚本与数据增强训练分类模型的第一步是采集数据。真实项目里这个环节花的时间通常比训练还多因为 MediaPipe 输出的 63 维坐标很容易被“录制时手的位置固定”骗过去导致训练集分布很窄。我的采集习惯是每个数字录 200-400 帧录制时故意让手在画面里平移、旋转、前后移动让样本覆盖不同位置和距离。采集脚本如下import csv import cv2 import mediapipe as mp mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands1, min_detection_confidence0.7, ) label input(输入当前手势对应的数字0-9按回车开始采集: ) out_path gesture_data.csv with open(out_path, a, newline) as f: writer csv.writer(f) cap cv2.VideoCapture(0) frames 0 while frames 300: ret, frame cap.read() if not ret: continue rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) if results.multi_hand_landmarks: lm results.multi_hand_landmarks[0].landmark row [label] for p in lm: row.extend([round(p.x, 6), round(p.y, 6), round(p.z, 6)]) writer.writerow(row) frames 1 cv2.imshow(collect, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows() print(f已采集 {frames} 帧写入 {out_path})逻辑说明每一帧检测到手后把 21 个 landmark 的 x/y/z 按顺序展开成一维列表第一列是数字标签后面 63 列是特征。这样一个文件既能喂给随机森林也能用 pandas 直接读。采集 300 帧大概需要一两分钟中途不需要暂停。参数说明round 到 6 位是为了控制 CSV 体积对精度没有实质影响因为 MediaPipe 本身的抖动就在千分位级别。这里故意用static_image_modeFalse因为视频模式下关键点更平滑也顺便模拟最终推理时的输入形态。采集过程中手跟丢了frames不会增加脚本会一直循环等待这是有意为之保证写入 CSV 的都是有效样本。数据增强方面我一般会在训练前做三件事对坐标加少量高斯噪声模拟抖动把手掌宽度归一化后做随机缩放对整组坐标做水平镜像。注意镜像之后数字 6 和 9 可能互换语义如果你同时采集了这两个数字要么做镜像增强时保持标签不变并在验证时确认要么干脆别镜像。实操里我通常只加噪声和缩放镜像留给数据量确实不够的时候。4.2 训练一个轻量分类模型随机森林或 MLP63 维输入、10 个类别、几千条样本这个规模用不到深度学习我常用随机森林或单隐藏层 MLP。随机森林的好处是不用做特征缩放、不怕个别异常帧、调参少MLP 的好处是推理体积小适合以后嵌入实时循环。下面以随机森林为例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 df pd.read_csv(gesture_data.csv, headerNone) X df.iloc[:, 1:].values # 63 维 landmark 特征 y df.iloc[:, 0].values # 数字标签 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) clf RandomForestClassifier(n_estimators200, max_depth12, random_state42) clf.fit(X_train, y_train) print(classification_report(y_test, clf.predict(X_test))) joblib.dump(clf, hand_gesture_model.joblib)逻辑说明stratifyy保证每个数字在训练集和测试集里的比例一致避免样本多的类别主导评估结果。分类报告里重点看每个类别的 recall哪个数字 recall 低就回去补那个手势的样本而不是急着调模型参数。参数说明n_estimators200对这个规模足够再多收益很小max_depth12用来限制单棵树过拟合到某个人的手型。注意训练集来自你自己测出来的准确率通常很高这是“个人过拟合”的假象真正看泛化要等下一章换人测试。如果你更想走神经网络路线MediaPipe Model Maker 的自定义手势识别方案也能做但它默认走模板匹配路线对复杂手势的适配不如自己采集关键点训练来得直接。这里不展开因为标题里的“机器学习项目源码”大多数情况下是指自带采集和训练代码而不是依赖云端训练平台。4.3 模型推理接入 MediaPipe 的完整流程训练完成后把模型接回实时管道import cv2 import joblib import mediapipe as mp clf joblib.load(hand_gesture_model.joblib) mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands1, min_detection_confidence0.7, min_tracking_confidence0.5, ) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(rgb) if results.multi_hand_landmarks: lm results.multi_hand_landmarks[0].landmark feat [] for p in lm: feat.extend([p.x, p.y, p.z]) pred clf.predict([feat])[0] cv2.putText(frame, fdigit: {pred}, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 255, 0), 3) cv2.imshow(gesture, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明推理流程和采集流程完全一致只是把 CSV 写入替换成了模型预测。clf.predict([feat])接收的是二维数组所以哪怕只有一个样本也要包一层列表。putText只负责显示不影响识别逻辑。这里有一个容易被忽略的点训练时的特征顺序必须和推理时完全一致。如果你在训练前做了归一化、去均值或者数据增强推理时也要对实时帧走完全相同的变换否则模型会收到分布完全不同的输入。我见过不少翻车现场就是训练时归一化、推理时忘写。注意训练时做过的每一项预处理推理阶段都要原样复现少一步就是另一个分布的数据。5. 手势数字识别避坑排查五个最容易翻车的点做一个手势数字识别项目瓶颈通常不在“模型准不准”而在数据、特征和实时性。下面五条是我在这个方向上踩过、也帮别人排查过的高频问题按“现象、原因、解决”写清楚。5.1 手指明明张开着模型却识别成拳头现象摄像头前比数字 3屏幕稳定识别成 0 或拳头换一只手又正常。原因特征没有对手掌尺寸归一化。如果直接用原始 x/y/z手离镜头越近指尖到手腕的距离越大模型把“距离大”学成了“手指伸直”的信号一旦手的位置变化判断就跟着乱。解决训练前做参考系变换所有点减去手腕坐标再除以 wrist 到 middle_mcp 的距离。这样同一只手在不同距离、不同画面位置下特征分布基本一致。在 2.3 里提到的角度特征也可以避开这个问题但归一化距离实现更简单优先做这个。排查时可以把误判帧的特征打印出来和正常帧对比分布错位基本就是归一化没做或者没做对。5.2 换一个人测试准确率暴跌现象自己用准确率 95%同事一测掉到 60% 以下数字 2 和 5 尤其分不清。原因训练集只包含你的手型比例、肤色、镜头距离模型记住了“你的手长什么样”而不是“数字长什么样”。这是所有关键点小数据集项目逃不掉的个人过拟合。解决至少找 3-5 个不同的人各采一轮数据合并训练如果只有你一个人的数据把训练集里的 z 坐标丢掉只用 x/y 并做镜像增强能缓解一部分但不彻底。最有效的还是让别人也录一遍样本多样性比样本数量重要得多。验证时用留一人法用 A/B/C 的数据训练只测 D这个准确率才接近真实上线水平。5.3 CPU 推理卡顿实时性不达标现象数字文字有明显的滞后感录屏时 FPS 只有十几手一动画面就拖影。原因MediaPipe Hands 的检测模型本身不小在 CPU 上每帧全分辨率推理很吃算力同时 OpenCV 显示、putText绘制都在主线程里排队互相拖慢。解决把输入帧缩到 480p 再送进hands.process()显示窗口保持原尺寸即可检测精度在这个分辨率下损失很小。检查static_image_mode是否误设为 True视频模式会启用帧间跟踪速度差距明显。如果要更高 FPS可以每隔一帧跑一次 MediaPipe中间帧沿用上一帧的关键点做预测这是嵌入式端最常见的降载方案。5.4 画面里出现另一只手或人脸时预测乱跳现象两只手同时入镜或背景里有人脸数字标签在多个值之间来回跳一个手势还没换就变了三次。原因max_num_hands1只限制了输出数量不保证取的是“你正在比的那只手”MediaPipe 偶尔也会把面部区域误检成手。解决取multi_hand_landmarks时不要直接取[0]要结合results.multi_handedness的置信度排序取最确定的那只手。更稳妥的做法是在画面中央划定一个识别区域手必须完全进入区域内才触发预测区域外的检测结果直接丢弃。这个 ROI 方法对背景复杂、多人场景特别管用。5.5 数字 6、7、8、9 和 0 的语义冲突现象比 6 识别成 5比 0 识别成 1而且不是随机错是稳定错。原因这些数字涉及拇指独立动作而拇指的 tip 到 wrist 距离在侧向张开时和伸直几乎一样规则法区分不了另一个原因是手势语义在不同地区定义不同模型学到了你录的那套定义换人比划另一套就错了。解决在采集阶段就和配合测试的人约定统一手势定义标签语义写进文档特征上加拇指角度特征比如拇指 tip、ip、mcp 三点连线的夹角而不是只用距离。如果项目只要求 1-5直接砍掉这些数字识别会更稳。数字 0 单独处理时可以额外检查五个指尖是否都靠近手掌中心点这个条件比单纯“手指都弯曲”更严格。6. 把模型装进实时推理脚本帧间投票与验证技巧6.1 最小工程结构和帧间投票实时脚本里除了模型预测我建议加一个非常简单的帧间投票字面意思缓存最近 5 帧的预测结果取众数作为最终输出。手势变化是连续的人不会在一两帧内把 2 换成 5帧间投票可以把偶发误判压下去代价只是约 100ms 的输出延迟体感影响很小。from collections import deque, Counter pred_history deque(maxlen5) def stable_predict(feat): pred clf.predict([feat])[0] # 原始预测 pred_history.append(pred) return Counter(pred_history).most_common(1)[0][0] # 取最近 5 帧众数这个函数的执行位置就在原来clf.predict的地方deque(maxlen5)会自动丢弃最老的预测不需要手动清理。如果你需要更低延迟把 maxlen 改成 3需要更平滑就改成 7自己调。6.2 验证模型好坏的三个方法第一个是回放验证录一段自己比划 0-9 的动作视频逐帧跑推理把每一帧的预测结果打印到控制台或时间戳文件里人工检查跳变点。比起盯着实时画面看回放能让你反复看同一段错误定位是哪一帧、哪个手势出的问题。第二个是特征分布核对把预测错的那一帧特征打印出来和训练集的同类特征对比均值、方差。如果测试特征明显落在训练分布之外说明要么没做同样的预处理要么训练数据没覆盖这种姿态。这个习惯帮我找到了好几次预处理顺序不一致的问题。第三个是留一人验证如果有多人数据用 A/B/C 三个人训练留 D 测试循环四次得到的准确率才是真实泛化能力比随机划分的 95% 可信得多。我自己做手势项目时最常犯的错就是拿自己录的数据又训练又测试自我感觉良好一给别人演示就翻车。后来所有采集都拆成“训练人员”和“验证人员”两组演示时只测没参与录制的人。最后提一个工程上的小事把采集、训练、推理分成三个独立脚本放好CSV、模型文件、配置参数分开目录存放。这个习惯让我在调整阈值和特征的时候不用反复改主程序代码。这套流程跑通之后你会发现手势数字识别真正值钱的部分是“数据怎么采、特征怎么算”而不是模型选哪个。希望帮到你。本文还有配套的精品资源点击获取