ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于MediaPipe的手势识别实战:从关键点到分类

2026/9/15 0:46:21 拓冰建站 浏览量
基于MediaPipe的手势识别实战:从关键点到分类 简介基于MediaPipe的手势识别源码包面向计算机视觉入门者、AI应用开发者及高校学生解决摄像头实时识别数字手势与石头剪刀布等常见动作的需求。压缩包仅2个Python文件共3KB代码极为精简包含两个相互对照的独立脚本覆盖了MediaPipe手部关键点检测、特征提取和手势类别判断的完整流程。已有272人学习使用。通过这份源码读者可以快速掌握MediaPipe Hands模型与OpenCV的结合方式理解如何根据21个手部关键点计算指尖距离、角度等特征并设计出稳定的规则分类器两个脚本也可互相参照便于对比不同实现思路。轻量而完整的示例适合作为课程设计、毕业设计或小工具开发的起点也可在此基础上扩展手势指令、切换不同分类模型或迁移到其他视觉交互项目之中。1. 基于 mediapipe 的手势识别源码究竟在解决什么问题基于 MediaPipe 的手势识别源码名字里的“源码”往往不是指某个训练好的神经网络权重而是把「MediaPipe 输出 21 个手部关键点」和「数字、石头剪刀布的分类逻辑」拼到一起的可运行工程。很多人想当然认为手势识别必须重新训练模型实际上线方案更常见手部检测与关键点跟踪交给 MediaPipe Hands数字 0 到 9、石头剪刀布这类离散手势用几十行几何规则或轻量 KNN 就搞定了。这套思路很适合体感教学、桌面小游戏、展厅交互不依赖 GPU也不要求标注几千张图。本文按这条工程路径从关键点特征讲到分类实现再给调参位置适合刚接触 MediaPipe 的开发者照做。2. MediaPipe Hands 关键点坐标与手势特征工程2.1 21 个关键点坐标先搞懂数据从哪来MediaPipe Hands 做的事分为两段先由 palm detection 模型在整帧里找手掌框再把手掌区域送入 hand landmark 模型回归出 21 个关键点。这两段在mp.solutions.hands里对用户是封装的你拿到的结果直接就是 landmarks。21 个点的序号是固定的0 号是腕关节1 到 4 号是拇指4 是拇指指尖5 到 8 号是食指9 到 12 号是中指13 到 16 号是无名指17 到 20 号是小指。记住这几个指尖和指根的索引后面特征计算全部依赖它们。import cv2 import mediapipe as mp mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands2, min_detection_confidence0.5, 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: for landmarks in results.multi_hand_landmarks: tip landmarks.landmark[8] # tip.x / tip.y 是归一化到 [0,1] 的坐标tip.z 是相对腕部的深度 print(findex tip: x{tip.x:.3f}, y{tip.y:.3f}, z{tip.z:.3f})这段代码是所有后续判断的骨架。传给模型前要把 BGR 转 RGBMediaPipe 内部按 RGB 裁剪图像忘了转换通常不会崩但识别率和坐标一致性会受影响。static_image_modeFalse表示进入跟踪模式内部会复用上一帧的手部位置做关键点跟踪画面中手快速移动时更稳处理单张图片时改成 True否则第一帧会慢、后续帧可能跟丢。results.multi_hand_landmarks是一个列表每个元素是单只手的 21 个关键点元素顺序和results.multi_handedness一一对应多手场景下要同时处理两只手时按索引拿不要混淆。参数默认值作用与调法static_image_modeFalse单张图片批量处理设 True视频流维持 False 以启用跟踪max_num_hands2要识别双手时至少设 2过大会增加每帧耗时min_detection_confidence0.5手部检测置信度阈值手快速移动时降到 0.4 左右减少漏检min_tracking_confidence0.5关键点跟踪阈值手势频繁变化或遮挡时适当调低表格里的前两个参数影响资源占用和跟手程度后两个参数决定你后面特征计算拿到的坐标是否稳定。四个参数都不需要动模型文件改完重跑脚本立即生效。2.2 从归一化坐标到距离特征为什么用比值而不是绝对差值很多人拿到关键点后第一步就想算指尖到指尖的欧氏距离这个直觉得改。原因在于摄像头画面里人手离镜头远近距离变化很大同一只手伸到镜头前 20 厘米和退到 1 米外同样的“剪刀”手势在像素坐标系里的绝对距离能差出 5 倍以上。直接用绝对距离设阈值换一个人、换一个摄像头阈值就要重调。常见做法是先找一个不太受手指弯曲影响的“基准长度”再把所有几何量除以这个基准。基准首选腕部 0 号点到中指根部 9 号点的距离其次是 0 到 5 号的距离。def calc_distance(landmarks, idx1, idx2): p1 landmarks[idx1] p2 landmarks[idx2] return ((p1.x - p2.x) ** 2 (p1.y - p2.y) ** 2) ** 0.5 def normalized_finger_distances(landmarks): base calc_distance(landmarks, 0, 9) # 四指指尖到各自掌指关节(MCP)的归一化距离 finger_pairs [(8, 5), (12, 9), (16, 13), (20, 17)] return [calc_distance(landmarks, tip, root) / base for tip, root in finger_pairs], base函数返回四个归一化距离分别对应食指、中指、无名指、小指的伸直程度。手指弯曲时指尖到掌指关节的水平投影距离明显缩短伸直时这个比值通常能到 0.5 以上握拳时降到 0.2 以下。除以base后手远近导致的整体缩放被消除。注意这里 z 坐标没参与距离计算因为landmark.z的刻度与 x/y 不完全一致直接混进欧氏距离会把手指伸直的判断带偏。这里有个容易出错的地方也是源码里最常看到的 bug基准长度不能用指尖到指尖。比如做“布”的时候0 到 9 距离受腕部旋转影响小但拇指尖到小指尖的距离会随手掌展开程度大幅变化拿它做分母等于把分母和特征耦合在一起阈值没法收敛。记住这个原则基准长度必须选一个“无论做什么手势都基本不变”的骨架线段。2.3 handedness 标签的坑左右手要按场景取反results.multi_handedness会给出每只手的左右标签例如Left和Right。但注意这个标签是“从摄像头看到的画面视角”判定的摄像头拍到你举起右手时画面里那只手的外形其实等同于别人左手所以标签会给Left。如果你把画面做了水平翻转再显示自拍模式视觉上用户看到的是右手但 MediaPipe 看到的图像已经翻转了标签又变回Right。也就是说标签到底要不要取反取决于你最终给用户展示的是原始画面还是镜像画面。for idx, hand_landmarks in enumerate(results.multi_hand_landmarks): handedness results.multi_handedness[idx].classification[0] label handedness.label # Left / Right # 如果展示的是 cv2.flip(frame, 1) 之后的镜像画面 # 用户看到的右手对应这里的 Left需要反着用提示如果展示的是cv2.flip(frame, 1)之后的镜像画面用户看到的右手在结果里可能标记为Left取反逻辑要在绑定分类结果之前完成。对数字手势和石头剪刀布这种单手分类任务左右标签通常不影响结果但如果你要做“双手石头剪刀布”并记录两只手分别出了什么左右手就不要直接在画面上按位置判断用上述规则先映射好用户视角的左右手再绑定分类结果。这个细节不做时双手交叉或一前一后经常出现手势与手绑定错位的问题看起来像识别乱跳。3. 数字 0 到 9 手势识别从规则表到轻量 KNN3.1 手势约定要先定数字手势没有统一标准数字手势在不同地区的习惯并不一致0 可以是握拳也可以是 OK 圈3 可以是拇指、食指、中指三指捏合也可以直接伸三根手指。源码不会自动理解你脑子里那套标准任何数字识别模块的第一步都是先确定手型映射表。下面是一套默认约定实际使用可以根据自己的演示场景改数字手势描述关键特征0握拳拇指搭在其他手指上四指弯曲拇指未明显外展1伸出食指食指伸直其余相对弯曲2伸出食指和中指食、中指伸直与剪刀冲突3拇指、食指、中指指腹捏合三指聚拢无名指小指弯曲4拇指弯曲四指伸直四指伸直拇指不展开5五指张开五指全伸与布冲突6拇指小指这两指外展中间三指弯曲7拇指食指中指捏合三指尖距离极小8拇指食指张开成枪形拇指和食指伸展其余弯曲9食指弯钩食指指尖低于其 PIP 关节其余握拳这张表不是算法约束而是一次产品约定。你在源码里改映射字典时改的其实是对“伸/屈、外展/并拢”这些几何状态的解释。把映射表先写成注释放在代码最上方后面调试时能省掉大量反复试错的时间。3.2 规则判法按指尖到指根的归一化距离打分数字识别的规则实现最常见的做法是不要一上来就写十几个 if。先算出一组布尔特征食指、中指、无名指、小指分别是否伸直拇指是否外展。然后把这五个值拼成状态元组用字典映射到数字。只有 3、6、7、9 这类需要“捏合”或“钩指”的手势才额外检测指尖间距离。import math def dist(landmarks, a, b): p, q landmarks[a], landmarks[b] return math.dist((p.x, p.y), (q.x, q.y)) def recognize_number(landmarks, base_len): # base_len 由上层计算通常是 0 号到 9 号的距离 def extended(tip, root): return dist(landmarks, tip, root) / base_len 0.4 thumb_open dist(landmarks, 4, 5) / base_len 0.6 index_up extended(8, 5) middle_up extended(12, 9) ring_up extended(16, 13) pinky_up extended(20, 17) state (thumb_open, index_up, middle_up, ring_up, pinky_up) mapping { (False, False, False, False, False): 0, # 握拳 (False, True, False, False, False): 1, # 食指 (False, True, True, False, False): 2, # 剪刀 (False, True, True, True, True): 4, # 四指伸直拇指弯 (True, True, True, True, True): 5, # 布 (True, False, False, False, True): 6, # 拇指小指 (True, True, False, False, False): 8, # 枪形 } if state in mapping: return mapping[state] # 3拇指指尖、食指指尖、中指指尖三指捏合 three_pinch (dist(landmarks, 4, 8) / base_len 0.35 and dist(landmarks, 8, 12) / base_len 0.35) if three_pinch: return 3 # 9食指弯钩——指尖(8)比PIP关节(6)更靠近腕部或水平位置更低 if dist(landmarks, 4, 5) / base_len 0.6 and index_up and not middle_up: return 9 return -1extended()里的阈值设 0.4表示“指尖到指根的距离超过腕到中指根距离的 40% 就认为该手指伸直”这是多数 webcam 上比较稳的经验起点。状态元组里 5 个布尔量顺序固定字典直接精确匹配比嵌套 if 可读性好。没有命中精确匹配时再处理 3 和 9 这两类非伸指手势。注意 3 的捏合判断用的是指尖两两距离因为三指捏合时四指和小指虽然弯曲但食指中指的“伸直度”特征和数字 2 接近必须用额外距离把两者分开。提示extended()里的 0.4 只是经验起点正式用之前先打印特征分布再定。连续出一轮“握拳-张开-握拳”看伸直状态和弯曲状态的距离区间各在什么范围。3.3 不想手写规则用 63 维关键点特征直接训 KNN规则法的问题是碰到模糊手势时阈值特别难调。另一个常见做法是放弃手写特征直接把 21 个关键点的相对坐标拼成 63 维向量21 × 3x/y/z喂给 KNN。这样你不需要定义“伸直”和“外展”只需要录一批样本。import numpy as np from sklearn.neighbors import KNeighborsClassifier def landmarks_to_vector(hand_landmarks, base_len): wrist hand_landmarks.landmark[0] vec [] for lm in hand_landmarks.landmark: vec.extend([ (lm.x - wrist.x) / base_len, (lm.y - wrist.y) / base_len, lm.z - wrist.z, # z 原值做差不要除以 base_len ]) return np.array(vec) # 训练阶段把样本塞进 X / y然后 knn KNeighborsClassifier(n_neighbors5, weightsdistance) knn.fit(X, y)这里做了两个关键处理所有坐标减去腕部坐标得到平移无关表示x、y 再除以基准长度做尺度归一。z 不做尺度除因为 MediaPipe 的 z 本身以腕部为原点且归一化到大致 [-1, 1]再除尺度反而会放大抖动。n_neighbors5在几百个样本上表现稳定weightsdistance可以减弱少数近邻离群的影响。录制数据时每个手势至少采 30 帧、10 个不同角度避免因为手部朝向单一导致验证集评分虚高。KNN 相比规则法的收益是不再纠结阈值但代价是要采集样本而且类别不平衡时弱势手势容易被吞。实际工程里我通常是两种方案混用规则法兜底保证常见手势永远可识别KNN 负责规则法写不干净的模糊手势。4. 石头剪刀布识别与多手势冲突规避4.1 石头剪刀布和数字 0/2/5 的冲突先想清楚模式翻开任何一套手势约定石头剪刀布的三类手势和数字 0、2、5 在几何上完全重合石头握拳剪刀食中指伸直布五指张开。二者的区别只存在于语义层不在几何层。所以“一个函数同时完美识别两套手势”是不成立的必须和应用场景绑定。常见的源码实现是加一个模式参数让同一个 landmark 特征函数同时服务两种识别任务。手势伸指状态冲突数字常见处理石头五指全屈0模式切为 rps 时返回石头剪刀食中指伸2模式切为 rps 时返回剪刀布五指全伸5模式切为 rps 时返回布def classify(landmarks, base_len, moderps): moderps 时返回 石头/剪刀/布modenumber 时返回 0-9 if mode number: return recognize_number(landmarks, base_len) # rps 分支 ext [ dist(landmarks, 8, 5) / base_len 0.4, dist(landmarks, 12, 9) / base_len 0.4, dist(landmarks, 16, 13) / base_len 0.4, dist(landmarks, 20, 17) / base_len 0.4, ] thumb_ext dist(landmarks, 4, 5) / base_len 0.6 if not any(ext): return 石头 if ext[0] and ext[1] and not ext[2] and not ext[3] and not thumb_ext: return 剪刀 if all(ext) and thumb_ext: return 布 return 未知剪刀判定特意排除了拇指外展。如果不排除拇指外展的“数字 2 变体”会被误判成剪刀——虽然语义上很多人出剪刀时会自然把拇指张开但从维持两套手势可区分性的角度建议把拇指张开的剪刀归入未知留给上层决定。all(ext)加上thumb_ext是布五个手指都伸但没有明显外展时宁可返回未知也不要硬分。classify()这个入口函数建议单独放一个文件数字识别和石头剪刀布共用同一份 landmarks 输入上层只需要切换mode。4.2 单帧识别总跳变滑动窗口投票比卡尔曼滤波更省事视频流识别里只有几十行规则函数不够单帧误判率再低也会在关键帧之间产生“布→未知→布”的闪烁。新手常见的错误是引入卡尔曼滤波但手势标签是离散类别卡尔曼滤波的一套状态方程在这里收益很低。更常见的工程做法是滑动窗口投票保留最近 N 帧的识别结果取出现次数最多的类别作为最终输出只有达到一定投票数才更新。from collections import Counter, deque class GestureVoteSmoother: def __init__(self, window8, min_votes5): self.history deque(maxlenwindow) self.min_votes min_votes self.current None def update(self, pred): if pred ! 未知: self.history.append(pred) if len(self.history) self.window: return self.current candidate, count Counter(self.history).most_common(1)[0] if count self.min_votes: self.current candidate return self.currentwindow8在 30fps 下覆盖约 0.27 秒min_votes5意味着窗口内至少 5 帧投给同一个手势才更新当前状态相当于把单帧毛刺削掉。pred ! 未知的判断避免把“手没识别出来”的帧填进窗口因为这些帧不代表任何手势状态。手完全离开画面时建议上层根据multi_hand_landmarks为空主动把current清空而不是继续沿用最后一个手势。窗口大小和时间延迟是直接 trade-off窗口越大越稳但手势切换时的响应越慢做猜拳游戏时超过 0.5 秒就会让玩家觉得“卡手”。4.3 误判集中在哪手掌翻转、距离过近和光照阴影调这类源码遇到的误判多半不是分类代码写错而是输入侧变了。手掌朝向摄像头时指尖和掌指关节的投影距离基本符合伸直阈值当手背转向摄像头或其他角度大于 45 度时同一根手指的投影距离明显缩短extended()会连续输出 False。这不是阈值错了是几何信息确实不足。遇到这种情况我一般会先检查原始距离值的分布把calc_distance的结果打印出来观察目标手势在连续 20 帧内的数值区间再决定阈值是往 0.35 调还是往 0.5 调。手离摄像头太近小于 15 厘米会让关键点抖动变大离太远大于 1 米会掉关键点质量这两个边界在工程上比分类准确率更值得先排查。光照方面的典型问题是头顶直射光在手掌上造成大块阴影MediaPipe 的 palm detection 在阴影下可能把手掌区域截断此时表现是某一帧multi_hand_landmarks突然变空而不是输出错误手势。优先检查摄像头帧率是否有掉帧再考虑换到顺光环境而不是急着加滤波器。5. 把源码跑起来之后几个立刻要验证的参数位置5.1 最小环境与启动命令我的建议是先建虚拟环境不要直接往系统 Python 里装避免把 OpenCV 依赖弄乱python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install mediapipe opencv-python numpy scikit-learn python app.py --mode rps --camera 0首次运行会产生一次模型初始化延迟MediaPipe 会把 hand_landmark 相关的 tflite 模型加载到内存在离线环境或内网部署时把模型文件放到site-packages/mediapipe/modules/hand_landmark/对应目录下即可离线使用。--mode参数对应上面classify()里的模式--camera指定摄像头索引插了多路 USB 摄像头时从 0 开始试即可。5.2 三个必调参数与它们的提示信息参数位置改大的效果改小的效果min_detection_confidencemp_hands.Hands()误检少手快速移动时漏检变多检测更灵敏背景误检增加min_tracking_confidencemp_hands.Hands()关键点更稳定手势切换时响应变慢掉点减少坐标抖动增大手指伸直阈值规则代码里的 0.4判定变严格手指稍微弯曲即判屈判定变宽松屈指也被算作伸直这三个位置是完整跑通源码后最值得动手的地方。调阈值前先打印特征值别盲调在代码里加一行print(normalized_finger_distances(landmarks))连续出一轮“握拳-张开-握拳”看伸直状态和弯曲状态的距离区间各在什么范围取两个区间中位数的中间值当阈值比凭感觉设 0.4 可靠得多。5.3 把手势结果接到外部流程的轻量做法源码的最后一个常见诉求是从“在 OpenCV 窗口打印文字”变成“把手势结果喂给其他程序”。不建议直接调 pyautogui 做系统级点击权限和焦点问题在后台运行时特别多。更常见的做法是本地起一个轻量 UDP 服务把识别结果显示出来import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 在识别循环里 sock.sendto(pred.encode(utf-8), (127.0.0.1, 6000)) # 前端或游戏进程监听 UDP 6000 端口按收到的手势字符串触发逻辑这样前端、游戏或 PPT 控制端可以解耦手势识别进程只负责产出类别。UDP 不保证送达但这里丢一帧不影响整体如果对面进程要求稳定不丢就换成socket.SOCK_STREAM的 TCP 连接注意每次识别结果之间加换行符做帧分隔。本文还有配套的精品资源点击获取