基于MediaPipe的手势识别:数字0-9与石头剪刀布的实现
2026/9/14 23:25:26 网站建设 项目流程

简介:一套基于MediaPipe的手势识别项目源码,面向计算机视觉入门者、AI爱好者以及希望在项目中快速加入手势交互能力的开发者,使用门槛较低,无论是学习参考还是快速验证想法都很合适。项目覆盖数字(0-5)识别、石头剪刀布等常见手势判断,借助MediaPipe高效的手部关键点检测技术,可在普通摄像头画面中实时定位并识别手部姿态。压缩包体积约3KB,包含2个Python脚本,分别承担主程序与扩展示例的功能,结构简单、依赖清晰,便于直接阅读、运行和二次调试。已有272人学习/下载,代码逻辑紧凑,能帮助读者快速理解手部21个关键点的提取流程、手势特征构造与分类判断思路;在此基础上,还可根据实际场景扩展更多自定义手势,应用于课堂演示、交互小游戏或手势控制原型开发。

1. 基于 mediapipe 的手势识别到底在解决什么问题

做交互控制、教学演示或者无障碍输入的时候,摄像头手势识别往往是成本最低的入口。可是直接拿原生 mediapipe 跑一遍,你会发现它只给你 21 个手部关键点坐标,并不告诉你“这是数字 3”还是“这是剪刀”。标题里那个“基于 mediapipe 的手势识别数字石头剪刀布等手势识别源码.zip”,落地时真正要解决的就是两件事:怎么从 21 个坐标点提炼出手指弯曲、伸展的状态,以及怎么把这些状态映射成 0 到 9 的数字和石头剪刀布。这套识别方案不依赖 GPU,普通笔记本摄像头能跑实时,核心代码量也不大,适合做课设、开源项目二次开发,或者给嵌入式板子做手势交互原型。我下面按自己常用的工程路线把模型原理、特征构造、分类实现和调参排错完整拆开讲,跟着做就能跑通一个可用的手势识别 demo。

2. 先搞懂 mediapipe hands 的输出:21 个关键点与手势特征

2.1 mediapipe 的手部检测管线为什么适合自定义手势

mediapipe hands 是 Google 开源的手部关键点检测方案,内部走的是两阶段管线:先由 palm detector 在整帧图像里定位手掌区域,再做 landmark regression 回归出 21 个手部关键点。两阶段的好处是检测精度高,而且不要求手必须出现在画面正中央;坏处是手掌检测偶尔会丢帧,处理时需要做时序平滑或者丢帧补偿。

以我实际测试为例,mediapipe 返回的每个关键点包含 x、y、z 三个值,其中 x 和 y 是相对于图像宽度和高度的归一化坐标,取值范围约在 0 到 1 之间,z 表示关键点相对于手腕的深度估计,单位为米,且在同一只手上不同关键点的 z 值有可比性。正是因为有完整的几何结构输出,我们才能脱离官方预训练手势分类器,自己定义“0 到 9 的手势长什么样、石头剪刀布长什么样”。常见的手势识别源码包也都会基于这一层坐标来做二次开发,因为你拿到的就是一个稳定的手部骨络数据流。

下面这段代码展示了怎么用 mediapipe 在 OpenCV 帧里提取 21 个关键点,并把它们转成 numpy 数组以便后续做特征工程:

import cv2 import mediapipe as mp import numpy as np mp_hands = mp.solutions.hands hands = mp_hands.Hands( static_image_mode=False, max_num_hands=2, 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 frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = hands.process(frame_rgb) if results.multi_hand_landmarks: for hand_landmarks in results.multi_hand_landmarks: # 把21个关键点转成 (21, 3) 的坐标数组,便于后续几何计算 pts = np.array([[lm.x, lm.y, lm.z] for lm in hand_landmarks.landmark]) # pts[0] 是手腕,pts[4] 是拇指尖,pts[8] 是食指尖 print(pts.shape) cap.release() cv2.destroyAllWindows()

这段代码里min_detection_confidence=0.5是手掌检测置信度阈值,低于 0.5 时直接返回空结果;调高到 0.7 可以降低误检,但也更容易漏检。min_tracking_confidence=0.5是帧间跟踪置信度,如果视频流里手在快速移动,把它调低到 0.3 能减少跟丢概率。代码里把坐标统一转成(21, 3)的数组,是因为后续计算角度、距离时可以直接用 numpy 向量化操作,不用再遍历 landmark 对象。需要说明的是,z坐标的可靠性不如 x、y,实际做手势分类时我一般只用 x、y 计算角度和平面距离,把 z 留作辅助特征。

2.2 21 个关键点的编号规则与手势特征的关系

要做自定义手势识别,首先得背熟 21 个关键点的编号语义。mediapipe 的编号顺序从手腕到手尖依次展开,0 号是手腕根部,1 到 4 号是拇指关节链,5 到 8 号是食指,9 到 12 号是中指,13 到 16 号是无名指,17 到 20 号是小指。在指尖识别场景里,4 号、8 号、12 号、16 号、20 号这五个指尖点最重要;在石头剪刀布这个具体场景里,8 号食指指尖、12 号中指指尖和 4 号拇指尖的几何关系决定大部分判定逻辑。

下面这张表是我常用的关键点索引速查表,在写特征提取代码时对照它来选点,可以避免把食指和中指搞混——这种错误在 7 到 9 这几个数字手势的识别里特别常见,因为它们的指间角度差异很小。

关键点编号对应手部位置在手势识别中的用途
0手腕距离归一化基准点
1拇指腕掌关节拇指伸展判定辅助点
4拇指指尖数字 4、OK 手势、石块判定
8食指指尖数字 1、2、6、7、8 判定核心
12中指指尖数字 2、7、8、9 判定核心
16无名指指尖数字 3、8、9 判定
20小指指尖数字 4、9 判定

以 8 号点为例,它的实际意义不只是“食指位置”,而是食指指尖在图像平面上的投影坐标。做数字 1 和数字 7 的区分时,光看 8 号点不够,还要结合 6 号和 7 号点算食指弯曲角度。很多新手直接比较指尖和手腕的距离来判断手指是否伸展,结果发现手前后移动时距离变化很大,识别不稳定。更好的做法是计算手指关键点之间的夹角——手指在三维空间里弯曲时,骨骼之间的夹角变化和摄像头距离无关。之所以强调用角度而不是原始坐标,是因为角度具备尺度不变性和平移不变性,正好抵消手在画面里远近、上下移动带来的干扰。

2.3 手势识别源码里常见的坐标预处理:平滑与帧率适配

拿到 21 个坐标点之后的第一个隐藏坑是抖动。普通摄像头在暗光环境下,landmark 的 x、y 坐标会有 ±0.02 左右的随机抖动,这个幅度在手势边界判断时会造成识别结果在两类之间反复横跳。常见的源码包处理方式是滑动窗口平均或一阶低通滤波。

下面这段一阶低通滤波代码我常用在送给分类器之前,效果比滑动窗口更跟手且没有明显延迟:

class OneEuroPoint: def __init__(self, min_cutoff=1.0, beta=0.007): self.min_cutoff = min_cutoff self.beta = beta self.prev_x = None def filter(self, x, dt=1.0 / 30): if self.prev_x is None: self.prev_x = x return x alpha = 1.0 / (1.0 + (self.min_cutoff / (dt * 2 * np.pi)) ** 2) x_hat = alpha * x + (1 - alpha) * self.prev_x self.prev_x = x_hat return x_hat

代码里的min_cutoff控制滤波强度,值越小曲线越平滑但延迟越大;beta控制速度自适应,手快速移动时可以自动减小滤波力度避免拖影。使用这个类时,需要对 21 个点的 x、y 分别维护一个滤波器实例,注意不能把三个坐标混在一个滤波器里。我之所以用一阶低通而不是均值滤波,是因为均值窗口在帧率波动时会产生可感知的相位延迟,低通滤波没有窗口长度概念,帧率变化时只影响截止频率的精度,不影响稳定性。做完这步,后面的角度计算就不会出现明显的边沿毛刺了。

3. 把坐标变成“手指状态”:角度特征与距离特征的计算

3.1 为什么不能用原始坐标直接训练分类器

有一个常见的错误做法:直接把 21 个关键点的 x、y 坐标拼接成 42 维特征向量,扔给 SVM 或随机森林训练。这种做法的准确率在固定摄像头、固定距离下看着还行,但只要手在画面里稍微平移或者人前后移动一点,坐标值整体变化,分类器立即失效。因为原始坐标包含了手在画面中的绝对位置信息,而手势分类只关心手的内部几何结构,两者是耦合的。

更稳的做法是提取手势的几何特征。我一般分两类特征来提取:第一类是关节角度,描述每根手指自身的弯曲程度;第二类是长度比例,描述指尖与手掌中心的相对关系。这两类特征都满足旋转不变性和缩放不变性,摄像头位置变了、手转了个角度,特征值基本不变。下面重点讲这两类特征的具体计算公式和工程实现。

3.2 指间角度特征:用三点的向量夹角算手指弯曲度

每个手指有四个关键点,比如食指是 5、6、7、8 号点。正常情况下手指伸直时,这四点近似落在一条直线上;弯曲时,相邻线段之间出现明显夹角。我取三个关键点为一组,计算中间点的夹角作为手指弯曲度,比如食指用 6、7、8 号点,中指用 10、11、12 号点,无名指用 14、15、16 号点,小指用 18、19、20 号点。拇指比较特殊,用 1、2、4 号点跳过掌指关节,直接看拇指尖相对食指根部的夹角。

def calc_angle(p1, p2, p3): """计算三个点构成的角度,p2 是顶点(中间关节)。返回角度值,单位是度。""" v1 = p1 - p2 v2 = p3 - p2 cos_theta = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-6) cos_theta = np.clip(cos_theta, -1.0, 1.0) angle = np.arccos(cos_theta) * 180.0 / np.pi return angle

p1p2p3是三个关键点的 x、y 坐标构成的 numpy 数组,输出的是以 p2 为顶点的夹角,范围在 0 到 180 度之间。手指伸直时这个角接近 180 度,完全弯曲时接近 30 到 60 度。分母加1e-6是为了防止两个向量长度同时为 0(比如指尖和关节坐标完全重合)时除零报错。这个角度的物理含义就是指节之间的弯曲程度,和手在画面中的大小完全无关。实际使用时我会把一根手指的三个关键角合并成特征,但更重要的是最后一个关节角——远端指间关节角,它最能反映指尖是否真的按下去。因为近端关节的弯曲可能被其他手指带动,而远端关节由独立的肌腱控制,在数字手势里区分度最高。测试时你会发现,数字 6 和数字 7 的关键差异,就在于小指的远端关节弯曲角度一个接近 120 度,另一个接近 60 度。

3.3 长度比例特征:用指尖到掌根的距离判断手指是否伸展

距离特征比角度特征更直观,但对尺度变化敏感,所以不能直接用绝对距离,必须做归一化。我做归一化时选择的基准是手腕点(0 号点)到中指掌指关节(9 号点)的距离,也就是手掌本身的宽度。这个基准比用中指根部到中指指尖的距离更稳定,因为中指指尖会随手指弯曲而靠近手腕,导致基准长度本身变化。

def finger_stretched(pts, tip_idx, pip_idx, base_dist): """判断手指是否伸展:指尖到手腕距离 / 手掌宽度大于阈值即为伸展。 参数: pts: (21, 3) 的坐标数组 tip_idx: 指尖关键点索引,如 8 是食指尖 pip_idx: 手指近端指间关节索引,如 6 是食指近端 base_dist: 基准距离,即手腕到中指掌指关节的距离 """ wrist = pts[0] tip = pts[tip_idx] pip = pts[pip_idx] dist_tip = np.linalg.norm(tip[:2] - wrist[:2]) dist_pip = np.linalg.norm(pip[:2] - wrist[:2]) ratio_tip = dist_tip / base_dist ratio_pip = dist_pip / base_dist # 指尖距离大且近端关节距离也大,说明整根手指是伸直的 return ratio_tip > 0.7 and ratio_pip > 0.4

这里的逻辑是:手指伸直时,指尖到手腕的距离接近手掌宽度的 1.2 到 1.5 倍;半弯曲时只有 0.6 到 0.9 倍;完全握拳时小于 0.5 倍。阈值 0.7 和 0.4 是经验值,适用于成年人手型。如果是儿童手或者摄像头离手特别近,需要适当下调到 0.6 和 0.35。base_dist的计算在每帧都要重新做,不能只用第一帧的值,因为手离摄像头距离变化时,手掌宽度在图像里的像素值也在变,但比例关系相对稳定。这套距离特征在石头剪刀布识别里尤其管用,布手势的五根手指距离比都接近 1.2 以上,而石头手势全部低于 0.6,差距非常明显。

另外要注意的是,单靠距离特征判断手指伸展有一个短板:手指在侧面对着摄像头时,指尖到手腕的投影距离会被压缩,本来伸直的手指看起来像弯曲的。所以在实际源码实现里,距离特征通常和角度特征组合使用,角度负责确认弯曲程度,距离负责确认整体伸展幅度,两者缺一不可。

4. 0 到 9 数字手势识别的落地实现:规则分类与轻量模型

4.1 把数字手势拆成“五根手指的状态组合”

数字手势识别的本质是建立手指状态组合到数字的映射。我按中国常用的手势习惯来定:数字 0 有两种表示——握拳或 OK 手势,代码里通过一个zero_style参数切换;数字 1 是食指伸直;数字 2 是食指和中指伸直;数字 3 是食指、中指和无名指伸直且小指弯曲;数字 4 是食指到小指四指伸直但拇指弯曲;数字 5 是五指全部张开;数字 6 是拇指和小指伸直,其余三指弯曲;数字 7 是拇指、食指和中指捏在一起,其余两指弯曲,这个手势在国内不同地区有变体,需要按实际场景定义;数字 8 是拇指和食指张开成 L 形;数字 9 是食指弯曲成钩状,其余手指可以弯曲也可以伸直。

下面这段代码用一组布尔状态表示每根手指是否伸展,然后用字典做映射:

def classify_number(finger_state, zero_style="fist"): """根据五根手指的伸展状态识别数字 0-9。 finger_state 是一个长度为 5 的布尔列表,依次代表 [拇指, 食指, 中指, 无名指, 小指] 的伸展状态,True 表示伸展。 """ thumb, index, middle, ring, pinky = finger_state if zero_style == "fist": if not any(finger_state): return 0 else: # OK 手势:拇指尖和食指尖指尖距离小于阈值,识别为 0 # 具体指尖距离由上层函数计算并传入,这里只做语义示例 return 0 if thumb and not (index or middle or ring or pinky): return 4 # 四指弯曲只有拇指伸开,按国内手势习惯是 4 的变体 if index and middle and not ring and not pinky: return 2 if index and middle and ring and not pinky: return 3 if index and not middle and not ring and not pinky: return 1 if all(finger_state): return 5 if thumb and pinky and not (index or middle or ring): return 6 if thumb and index and middle and not (ring or pinky): return 7 if thumb and index and not (middle or ring or pinky): return 8 if index and not (thumb or middle or ring or pinky): # 这里可以和数字 1 区分:9 的食指是弯曲的,1 的食指是伸直的 return 9 return -1

代码逻辑不复杂,但有两个细节要注意:一是数字 4 和数字 1 的区别在拇指,国内标准手势里 4 是四指伸直拇指弯曲,但如果用户习惯比出“大拇指点赞”来代表 4,特征提取阶段会得到完全相反的拇指状态,需要格外小心。二是数字 7 和数字 8 在实际手感上非常容易混淆,因为都涉及拇指和食指的配合,但 7 需要中指也伸出并触碰拇指,8 则只有拇指和食指张开,两者在“中指是否伸展”这个布尔状态上天然区分。参数表如下:

数字拇指食指中指无名指小指关键区分特征
0(握拳)全部手指距离比 < 0.5
1食指距离比 > 1.2
2食指和中指同时伸展
5五根手指距离比都 > 1.0
6拇指和小指伸展,中间三指弯曲

实际测试中最容易误判的是 0 和 6,因为小指如果微微翘起,握拳状态就会被识别成 6。解决办法是把小指的伸展阈值从 0.7 提高到 0.8,同时要求拇指的距离比也大于 0.9 才算真伸开,两个条件同时满足才归类为 6。

4.2 用随机森林替代规则:什么时候值得上模型

纯规则的优点是解释性好、调试方便,但它有两个硬伤:一是不同地区的手势习惯不同,规则参数要跟着改;二是 7、8、9 这类手指部分弯曲的数字,状态判定容易踩阈值边界。为了兼顾准确率和可维护性,我一般会采集几百帧标注好的样本,用角度特征和距离特征组合成 17 维特征向量,扔给随机森林分类器。17 维特征包括:五个手指的远端关节角度、五个手指的伸展距离比、拇指和食指之间的角度、拇指和食指的距离比、手掌宽高比。

from sklearn.ensemble import RandomForestClassifier import joblib # 假设 X_train 是 (样本数, 17) 的特征矩阵,y_train 是 (样本数,) 的数字标签 clf = RandomForestClassifier( n_estimators=100, max_depth=8, min_samples_leaf=2, random_state=42 ) clf.fit(X_train, y_train) joblib.dump(clf, "gesture_model.pkl")

这里的n_estimators=100表示 100 棵决策树,再增加数量对精度提升很小但推理时间线性增加;max_depth=8限制单棵树深度,防止过拟合到采集时的固定光照和摄像头角度;min_samples_leaf=2保证每个叶子节点至少有两个样本,减少噪声影响。推理时把当前帧提取出的 17 维特征传入clf.predict,返回结果就是数字标签,单帧推理时间在 1 毫秒以内,比规则分类的逐分支判断还快。不过模型也有代价:需要采数据、标数据,而且换人种场景时可能出现分布偏移。我的建议是小范围 demo 用规则,要交付给别人在不同环境用的话上模型,中间用第 4.1 节的规则分类去做人工打标能省不少力气。

4.3 规则和模型的组合策略:先规则粗分再模型细分

还有一个中间路线,既保留规则的确定性又吸收模型的容错能力:先用规则把绝对不可能出现的候选排除掉,再让模型在剩下 2 到 3 个候选中做精分类。比如当前帧的 17 维特征显示所有手指距离比都大于 1.0,那就只可能识别成 5,不需要过模型;如果只有小指和拇指距离比大于 1.0,规则认为可能是 6,模型只需要区分 6 和 4(因为拇指伸展在 4 号手势里也可出现)。

这种组合策略的好处是:规则分支过滤掉明显错误的候选,缩小模型的预测空间;模型负责处理规则边界样本,两者互补。实际运行时,规则分类器可能每帧执行多次布尔比较,模型推理只跑在少数模糊帧上,整体 CPU 占用比全程跑模型还低,这对树莓派或者 Jetson Nano 这类边缘设备比较友好。需要注意的是,两个分类器对同一帧结果不一致时,以置信度高的为准;我通常打印两边的输出和置信度,方便定位是规则阈值有问题还是特征提取有bug。

5. 石头剪刀布的几何判定与鲁棒性优化

石头剪刀布比数字手势更容易做对,因为三类的整体几何结构差异巨大。石头是五根手指全部弯曲,特征是五个指尖到手腕的距离都小于手掌宽度的 0.6 倍;剪刀是食指和中指伸直但其他手指弯曲,特征是指尖距离比大于 1.2 但无名指和小指距离比小于 0.6;布是五根手指全部伸展,特征是五个距离比都大于 1.0。按第 3.3 节的逻辑实现一个石头剪刀布分类器会很简单,但工程里真正的坑不在这里,而是边界状态的误判。

以“剪刀”为例,很多用户做剪刀手势时无名指会跟着食指、中指一起微微伸直,此时距离比可能到 0.8 左右,如果阈值只设 0.6 就会误判成布。我的处理方式是把判定顺序从“依次判断”改成“先找矛盾证据”:先检查小指,小指距离比大于 0.8 直接判为布;再检查无名指,距离比大于 0.7 但小指没有伸开时,判为剪刀的可能性大,但要进一步验证食指和中指的指尖距离是否大于中指长度的 0.5 倍——剪刀手势里食指和中指张开角度比布手势明显更大。下面给出一个带参数表的参考实现:

def classify_rps(pts, base_dist): """基于距离比和角度阈值识别石头剪刀布。 返回字符串:'rock' / 'scissors' / 'paper' 或 'unknown' """ wrist = pts[0] tip8 = pts[8] # 食指尖 tip12 = pts[12] # 中指尖 tip16 = pts[16] # 无名指尖 tip20 = pts[20] # 小指尖 d8 = np.linalg.norm(tip8[:2] - wrist[:2]) / base_dist d12 = np.linalg.norm(tip12[:2] - wrist[:2]) / base_dist d16 = np.linalg.norm(tip16[:2] - wrist[:2]) / base_dist d20 = np.linalg.norm(tip20[:2] - wrist[:2]) / base_dist if d20 < 0.6 and d16 < 0.6 and d8 < 0.6 and d12 < 0.6: return "rock" if d8 > 1.0 and d12 > 1.0 and d16 < 0.8 and d20 < 0.6: return "scissors" if d8 > 1.0 and d12 > 1.0 and d16 > 1.0 and d20 > 1.0: return "paper" return "unknown"

参数选择依据如下:石头手势的小指几乎不发力,距离比通常在 0.3 到 0.5 之间,设成 0.6 留有裕量;布手势小指是自然伸直的,距离比稳定在 1.0 以上,和 0.6 之间有 0.4 的区分区间;剪刀手势最容易混的是无名指,所以单独给无名指设了 0.8 而不是 0.6,避免未完全弯曲的无名指触发布的误判。实际运行时如果你发现剪刀经常被误判成布,可以把无名指阈值下调到 0.7,代价是偶尔会把布误判成剪刀——哪个错误对你的交互场景影响更大,就优先保证另一边。

针对实时视频流,我还会在分类结果上做一个滑动窗口确认:连续 5 帧里同一分类出现 4 次才正式输出,避免单帧抖动造成的跳变。这个窗口逻辑在交互触发场景特别有效,因为用户做出石头或布的手势后通常会保持一段时间,滑窗确认既不增加延迟,又能滤掉手在动作切换过程中的中间态误判。如果你在做猜拳游戏或者课件交互,建议再叠加一个“手势稳定后再判定”的机制,拿到稳定信号之前不响应任何结果。

5.1 常见误判:摄像机左右手镜像与角度偏转

还有个必须处理的点是左右手镜像问题。mediapipe 默认摄像头画面是镜像的,如果用户用左手比数字 6,mediapipe 的关键点编号和右手是镜像对称的,直接做距离比判断时影响不大,但涉及“拇指在食指左边还是右边”的逻辑时必须清楚。我的常见做法是在特征提取前判断 handedness,然后统一把关键点镜像到同一个方向:

if handedness == "Left": pts[:, 0] = 1.0 - pts[:, 0]

这里把左手的关键点 x 坐标镜像到右手方向,使所有后续特征计算都在同一参考系下。注意只在同一帧内做镜像,不要跨帧累积,否则会出现手势在左右手之间来回跳的怪现象。另外手在画面里旋转超过 60 度时,距离比特征仍然有效,但指间夹角特征会失真,因为图像平面的投影关系改变了三维角度。对旋转敏感的应用,建议在分类前先根据手腕到中指根部的向量估算手的方向,把坐标旋转到竖直方向再提取特征。

5.2 做一个调试模式:把特征值实时打印出来

最后给一个调试技巧:在识别模型的 main 循环里增加一个 debug 开关,把每帧的五个距离比和五个角度值打印成一行,同时显示原始视频帧。这样你在调整阈值时能看到每个手势的数值分布区间,而不是盲目调整数字感受效果。比如你打印出布手势的小指距离比是 1.05,石头是 0.4,那你把阈值设在 0.7 到 0.8 之间就非常安全。这个习惯能帮你省掉大量“改了阈值好了,过一会儿又坏了”的排错时间。调试代码加在分类函数前面即可,打印格式固定、值之间用空格分隔,复制到 Excel 里就能做简单的分布分析。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询