简介:一份基于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_mode=False, # 视频流模式,启用帧间跟踪 max_num_hands=1, # 只处理一只手,降低误检 min_detection_confidence=0.7, # 首次检测置信度阈值 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) # 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。把归一化坐标乘回宽高画点,是为了先确认管道连通。这里最容易踩的坑是忘了cvtColor,OpenCV 读进来是 BGR,不转的话 MediaPipe 的检测结果会明显变差。绘制时我在原始 BGR 帧上画点,所以最终显示颜色是正常的。
参数说明:static_image_mode=False表示连续帧模式,MediaPipe 会利用上一帧结果做跟踪,速度更快;处理单张图片时才设 True。min_detection_confidence=0.7控制首次检测的严格程度,误检多就往上调,手在画面边缘检测不到就往下调。max_num_hands=1对数字识别很关键,后面做分类时不需要面对“该取哪只手”的选择题。
另外注意,不要在视频循环里反复创建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_idx=0): """判断一根手指是否伸直: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_mode=False, max_num_hands=1, min_detection_confidence=0.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_mode=False,因为视频模式下关键点更平滑,也顺便模拟最终推理时的输入形态。采集过程中手跟丢了,frames不会增加,脚本会一直循环等待,这是有意为之:保证写入 CSV 的都是有效样本。
数据增强方面,我一般会在训练前做三件事:对坐标加少量高斯噪声模拟抖动;把手掌宽度归一化后做随机缩放;对整组坐标做水平镜像。注意镜像之后数字 6 和 9 可能互换语义,如果你同时采集了这两个数字,要么做镜像增强时保持标签不变并在验证时确认,要么干脆别镜像。实操里我通常只加噪声和缩放,镜像留给数据量确实不够的时候。
4.2 训练一个轻量分类模型:随机森林或 MLP
63 维输入、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", header=None) 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_size=0.2, stratify=y, random_state=42 ) clf = RandomForestClassifier(n_estimators=200, max_depth=12, random_state=42) clf.fit(X_train, y_train) print(classification_report(y_test, clf.predict(X_test))) joblib.dump(clf, "hand_gesture_model.joblib")逻辑说明:stratify=y保证每个数字在训练集和测试集里的比例一致,避免样本多的类别主导评估结果。分类报告里重点看每个类别的 recall,哪个数字 recall 低,就回去补那个手势的样本,而不是急着调模型参数。
参数说明:n_estimators=200对这个规模足够,再多收益很小;max_depth=12用来限制单棵树过拟合到某个人的手型。注意训练集来自你自己,测出来的准确率通常很高,这是“个人过拟合”的假象,真正看泛化要等下一章换人测试。
如果你更想走神经网络路线,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_mode=False, max_num_hands=1, min_detection_confidence=0.7, 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 = 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, f"digit: {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_hands=1只限制了输出数量,不保证取的是“你正在比的那只手”;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(maxlen=5) 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(maxlen=5)会自动丢弃最老的预测,不需要手动清理。如果你需要更低延迟,把 maxlen 改成 3;需要更平滑就改成 7,自己调。
6.2 验证模型好坏的三个方法
第一个是回放验证:录一段自己比划 0-9 的动作视频,逐帧跑推理,把每一帧的预测结果打印到控制台或时间戳文件里,人工检查跳变点。比起盯着实时画面看,回放能让你反复看同一段错误,定位是哪一帧、哪个手势出的问题。
第二个是特征分布核对:把预测错的那一帧特征打印出来,和训练集的同类特征对比均值、方差。如果测试特征明显落在训练分布之外,说明要么没做同样的预处理,要么训练数据没覆盖这种姿态。这个习惯帮我找到了好几次预处理顺序不一致的问题。
第三个是留一人验证:如果有多人数据,用 A/B/C 三个人训练,留 D 测试,循环四次,得到的准确率才是真实泛化能力,比随机划分的 95% 可信得多。我自己做手势项目时最常犯的错,就是拿自己录的数据又训练又测试,自我感觉良好,一给别人演示就翻车。后来所有采集都拆成“训练人员”和“验证人员”两组,演示时只测没参与录制的人。
最后提一个工程上的小事:把采集、训练、推理分成三个独立脚本放好,CSV、模型文件、配置参数分开目录存放。这个习惯让我在调整阈值和特征的时候不用反复改主程序代码。这套流程跑通之后你会发现,手势数字识别真正值钱的部分是“数据怎么采、特征怎么算”,而不是模型选哪个。希望帮到你。
本文还有配套的精品资源,点击获取