如果要做实时手势识别,MediaPipe Hands 基本是当前最省事的一条路:图像进去,手部 21 个关键点坐标出来,中间检测和回归细节全被框架封装好了。不过很多初学者走到这一步就卡住了——坐标有了,接下来呢?这篇就围绕一个具体目标来拆:用这 21 个点实现从 0 到 10 的数字识别,把模型调用、关键点理解、业务规则落地、稳定性调优这整条链路走一遍。
做这个项目的人,可能是想给课程设计加个交互 Demo,也可能是接到手势翻页、虚拟遥控器、常见手语教学之类的需求。不管场景是哪一种,核心动作都是同一件事:把手部 21 个关键点翻译成“当前比的是几”。这个翻译过程看起来简单,真正动手才发现里面全是细节,比如每根手指怎么算伸直、拇指为什么特殊、0 和 10 怎么避免冲突、手在画面里转了个角度为什么识别就崩了。这些坑我都会在下面展开。
1. 为什么选 MediaPipe Hands,识别思路怎么拆
1.1 先搞清楚“21 点规则识别”和“端到端分类”的差别
要做一个手势数字识别,通常有两条路线。一条是直接训练一个分类模型,输入图像,输出数字类别,比如 0、1、2……10,这种方式需要准备大量标注数据,每个数字至少得几百张不同手的样本,而且换一批用户或者换一个场景,准确率往往会掉。另一条路线就是这里要讲的:先用现成的 MediaPipe Hands 拿 21 个关键点,再用手部结构关系设计判定规则。
我之所以推荐第二条,倒不是因为训练模型一定不好,而是对于 0 到 10 这种状态有限的识别任务,规则法可控性高得多。某个数字识别错了,你能立刻定位到是哪根手指的判断出了问题,调整一个阈值就行。如果是端到端模型,模式下错了只能重新收集数据、重新训练,时间和试错成本完全不是一个量级。更重要的是,规则法不依赖环境,只要关键点检测稳定,换摄像头、换背景、换人的影响都比较小。
MediaPipe Hands 在这条路线里扮演的是“感知层”角色,它负责把视频帧变成结构化的人手关键点。我们不需要关心它的内部神经网络长什么样,只需要知道输入是 RGB 图像、输出是每个关节点的归一化坐标,以及每个点的置信度。理解了这一点,整个项目的复杂度就降下来了:模型部分交给框架,业务部分自己写。
1.2 从 0 到 10 的手势剧本要先固定好
很多人拿到项目标题就开始写代码,结果写着写着发现一个很尴尬的问题:0 和 10 都用拳头怎么办?4 和 9 都是四指伸开怎么办?这个问题不是算法能解决的,是手势定义本身没定清楚。
实际做项目时,手势识别规则必须和业务方或者队友先对齐。比如日常交流里的“六”通常是拇指加小指,“七”是拇指、食指、中指指尖捏在一起,“八”是拇指和食指张开,但不同地区、不同文化背景下的手势含义完全不同。如果不提前约定,识别逻辑写得再漂亮也没法上线。
我这套方案使用一套明确的数字手势剧本,保证每个数字的状态在“手指伸开/收起 + 拇指食指是否捏合”这个维度上唯一:
| 数字 | 手势定义 |
|---|---|
| 0 | 拇指和食指捏合成圈(OK 手势),其余三指伸开 |
| 1 | 只有食指伸开 |
| 2 | 食指和中指伸开 |
| 3 | 食指、中指、无名指伸开 |
| 4 | 食指、中指、无名指、小指伸开,拇指收起 |
| 5 | 五根手指全部张开 |
| 6 | 拇指和小指伸开 |
| 7 | 拇指、食指、中指伸开 |
| 8 | 拇指、食指、中指、无名指伸开 |
| 9 | 拇指和食指伸开,其余收起 |
| 10 | 握拳 |
这套规则的好处是每个数字对应唯一的手指组合。0 用 OK 手势而不是拳头,就是为了和 10 的拳头区分开。实际部署时如果你想把 10 改成“双手张开”,也很简单,加一个双手检测的逻辑就行,这属于业务层的约定,不影响底层识别核心。
2. 21 个关键点到底在说什么
2.1 从检测手掌到输出关键点的两步流程
MediaPipe Hands 在手部识别上不是“每帧都从头检测”,而是采用了一套检测加跟踪的配合机制。第一步是用一个轻量级手掌检测器找到画面里的手掌区域,这个检测器专门负责定位手掌而不是整只手,因为在大量姿态下,手掌比整只手的形态更稳定,检测难度更低。第二步是在检测到的手掌区域里回归 21 个关键点坐标。
关键点回归模型拿到的是第一步裁剪出来的手掌图像,所以它不需要在全图范围内搜索手指,任务纯粹很多。而且一旦当前帧成功输出了关键点,下一帧会直接用上一帧的关键点作为跟踪初始值,形成一种时域上的接力。只有跟踪丢失以后,才需要重新回到第一步做全图检测。这个设计就是它能在 CPU 上保持实时性的核心原因。
这个机制对上层应用的影响很直接:在连续视频流里,如果手短时间被遮挡然后又出现,只要跟踪没丢,关键点输出的速度会非常快。可一旦出现手快速移动、大幅旋转、或者画面里出现多只相似的手,跟踪就会失效,下一帧又回到全图检测,响应会有一个肉眼可感知的延迟。所以我们在写识别逻辑时,不要把单帧的“检测到/没检测到”当作唯一标准,而是要结合帧间连续性做防抖,这个后面会专门讲。
2.2 21 个点的索引和对应位置
MediaPipe Hands 输出的 21 个点顺序是固定的,文档里通常叫 Landmark 0 到 20。理解每个点的位置是做规则判断的地基。
| 索引 | 含义 |
|---|---|
| 0 | 手腕根部 |
| 1 | 拇指掌指关节(手腕侧) |
| 2 | 拇指指间关节 |
| 3 | 拇指远端关节 |
| 4 | 拇指指尖 |
| 5 | 食指掌指关节(手背根部) |
| 6 | 食指近端指间关节 |
| 7 | 食指远端指间关节 |
| 8 | 食指指尖 |
| 9 | 中指掌指关节 |
| 10 | 中指近端指间关节 |
| 11 | 中指远端指间关节 |
| 12 | 中指指尖 |
| 13 | 无名指掌指关节 |
| 14 | 无名指近端指间关节 |
| 15 | 无名指远端指间关节 |
| 16 | 无名指指尖 |
| 17 | 小指掌指关节 |
| 18 | 小指近端指间关节 |
| 19 | 小指远端指间关节 |
| 20 | 小指指尖 |
这四个手指的编号规律非常整齐:每根手指 4 个点,从手掌根部到指尖依次是 MCP、PIP、DIP、TIP,手指之间按食指、中指、无名指、小指的顺序排列。拇指稍有不同,没有编号 0 的掌根,所以从 1 到 4 依次是 CMC、MCP、IP、TIP。
做数字识别时,我们最关心的就是 4、8、12、16、20 这五个指尖点,以及 1、5、9、13、17 这几个根部点。指尖和根部的相对位置关系,就是判断手指伸开还是收起的原始依据。
2.3 坐标系、归一化和可视化
MediaPipe Hands 输出的关键点坐标是归一化的,x 和 y 都是 0 到 1 之间的小数,分别对应当前帧图像宽度和高度的比例。举个例子,如果画面宽度是 640,某个点的 x 是 0.5,那它在画面中的像素坐标就是 320。所以不管你把输入分辨率设置成 480p 还是 720p,关键点的归一化数值基本不会因为分辨率变化而剧烈跳动。
另外还有第三个值 z 坐标,表示关键点相对于手腕的深度。z 的绝对值不可靠,不适合直接拿来判断“手离屏幕多远”,但在区分“手指是朝摄像头伸还是缩”这类需求上可以做参考。数字识别主要用 x 和 y 就足够了,加上 z 反而容易引入额外噪声。
调试阶段强烈建议把关键点画到原视频帧上看,我习惯用 MediaPipe 自带的 drawing_utils 来画,每一帧观察指尖点和对应的指根点是否落在正确位置。图像上的问题往往一眼就能看出来。真正进入规则判断阶段,就不能只看图像了,需要打印关键点坐标或者状态数组来定位问题。
3. 实战代码:从关键点到数字
3.1 环境准备和 Hands 初始化
第一步先把依赖装上,这里用的是 Python 环境,主要依赖是 opencv-python 和 mediapipe 两个库。
pip install opencv-python mediapipe初始化 MediaPipe Hands 的代码很简单,但有几个参数值得认真调一调。
import cv2 import mediapipe as mp import math mp_hands = mp.solutions.hands mp_drawing = mp.solutions.drawing_utils hands = mp_hands.Hands( static_image_mode=False, max_num_hands=1, min_detection_confidence=0.7, min_tracking_confidence=0.5, )第一个参数 static_image_mode 是决定用单帧检测还是视频跟踪模式。处理视频流时设为 False,处理单个图片时才考虑 True。min_detection_confidence 影响的是第一步手掌检测的敏感度,调高会减少误检,但手太小或者光线差时也更容易漏检。min_tracking_confidence 影响的是关键点跟踪的阈值,感觉手在画面里“一闪一闪”失踪,可以尝试把这个值调低。
max_num_hands 默认是 1,这里我们先跑通单手的逻辑。如果你最终需要辨别左右手、同时识别两只手,要把它改成 2,并且额外关注 multi_handedness 返回的手部标签。
打开摄像头的主循环如下:
cap = cv2.VideoCapture(0) while True: success, frame = cap.read() if not success: break frame_rgb = cv2.cvtColor(cv2.flip(frame, 1), cv2.COLOR_BGR2RGB) results = hands.process(frame_rgb) if results.multi_hand_landmarks: for hand_landmarks in results.multi_hand_landmarks: mp_drawing.draw_landmarks( frame, hand_landmarks, mp_hands.HAND_CONNECTIONS, ) cv2.imshow("Hand Tracking", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()这里我用了 cv2.flip 做了一次镜像翻转,效果上类似于前置摄像头的显示习惯,你伸手向左,画面里手也向左。需要注意,翻转之后的关键点 x 坐标也是镜像后的坐标,后续做左右手判断时要把这一点考虑进去,否则容易把“左手”和“右手”搞反。
3.2 判断每根手指伸开还是收起的核心逻辑
拿到 21 个点以后,最有意思的部分就是怎么判断每根手指的状态。
最开始我试过一个思路:直接比较指尖和指根的 y 坐标,食指指尖 y 小于指根 y 就认为是“伸开食指”。这个写法在摄像头正对手掌、手竖直朝上的时候是成立的,但手稍微转一点,或者手指朝侧面指的时候,结果就完全错误。所以后来换成了距离法,以手腕点为基准比较指尖和指根的相对距离。
原理并不复杂:手指伸直的时候,指尖到手腕的距离一定明显大于指根到手腕的距离。手指蜷缩的时候,指尖往回缩,这个距离差会缩小很多。用比值而不是距离差来判断,是为了适应手在画面里大小变化的情况,手离镜头远了,所有绝对距离都会变小,但比值相对稳定。
def distance(p1, p2): return math.hypot(p1.x - p2.x, p1.y - p2.y) def fingers_status(landmarks): lm = landmarks.landmark # 食指、中指、无名指、小指分别对应指尖和指根索引 finger_tips = [8, 12, 16, 20] finger_pips = [6, 10, 14, 18] status = [] for tip, pip in zip(finger_tips, finger_pips): d_tip = distance(lm[tip], lm[0]) d_pip = distance(lm[pip], lm[0]) status.append(d_tip > d_pip * 1.2) # 拇指单独处理,用拇指尖到小指根部的距离变化来判断 d_thumb_tip = distance(lm[4], lm[17]) d_thumb_ip = distance(lm[3], lm[17]) thumb_up = d_thumb_tip > d_thumb_ip * 1.05 return [thumb_up] + status这个函数返回一个长度为 5 的布尔数组,依次代表拇指、食指、中指、无名指、小指的状态。1.2 这个系数是经验值,表示指尖到手腕的距离需要比指根到手腕的距离大 20% 以上才认为是伸直。你可以在自己的摄像头下测几组数据,再微调这个值。
拇指的判断比另外四指麻烦很多,因为拇指的根部位于手腕附近,而且拇指的灵活度极高。这里选择了拇指尖和拇指根部分别到小指根部的距离来做比较,握拳的时候拇指会靠近掌心,距离自然变小,张开时距离变大。这个方案在实际测试中比单纯比较拇指尖和手腕距离要稳。
3.3 0-10 数字映射和完整识别流程
手指状态判断出来以后,数字识别就变成了一个查表过程。不过在此之前要先处理一个特殊情况:0 的 OK 手势。在 OK 手势中,拇指和食指指尖是捏在一起的,这时候拇指和食指的状态判断结果并不可靠,所以需要单独先判断“捏合”这个特征。
def is_ok_gesture(landmarks): lm = landmarks.landmark # 拇指尖到食指尖的距离 d_tip = distance(lm[4], lm[8]) # 拇指尖到食指近端指间关节的距离作为参考 d_ref = distance(lm[4], lm[6]) return d_tip < d_ref * 0.6这个判断利用的是距离比例:当拇指和食指捏合成圈时,拇指尖到食指尖的距离会明显小于拇指尖到食指近端关节的距离。选参考量的时候不能直接选整幅图像的绝对距离,因为手离摄像头远近会影响绝对像素距离,选一个手部自身尺度作为参照才通用。
完整的数字识别函数如下:
def gesture_number(landmarks): if is_ok_gesture(landmarks): return 0 s = tuple(fingers_status(landmarks)) gesture_map = { (0, 1, 0, 0, 0): 1, (0, 1, 1, 0, 0): 2, (0, 1, 1, 1, 0): 3, (0, 1, 1, 1, 1): 4, (1, 1, 1, 1, 1): 5, (1, 0, 0, 0, 1): 6, (1, 1, 1, 0, 0): 7, (1, 1, 1, 1, 0): 8, (1, 1, 0, 0, 0): 9, (0, 0, 0, 0, 0): 10, } return gesture_map.get(s, -1)映射表里的每一项都对应第一节里定义的手势剧本。如果手部状态不在表里,比如用户比了一个无法识别的手势,函数返回 -1,上层应用可以根据这个返回值做“未识别”提示。
手里有完整坐标流以后,主循环里只需要取 landmarks 传给 gesture_number,拿到数字后再绘制到画面上。一个最小可用的实时数字识别程序到这里就完成了。
4. 调优与常见问题
4.1 关键点抖动导致数字闪烁怎么办
第一个常见问题是数字在边缘状态来回变。某帧判定为 1,下一帧因为指尖差一点到不了阈值,就返回 -1。这是规则识别最容易遇到的问题,尤其手处于两个手势的中间过渡状态。
最简单有效的处理方式是加连续帧确认:连续 N 帧识别结果一致,才最终输出这个数字。比如连续三帧判定都是 2,才把“当前手势 = 2”告诉上层应用。这种方式牺牲一点点响应速度,换来稳定性的明显提升。
另一种更精细的方式是针对关键点坐标做平滑。MediaPipe 每一帧输出的坐标都可能带轻微抖动,直接对坐标做指数移动平均,数字切换就能更顺滑。
smoothed = previous * 0.7 + current * 0.3这个系数可以自己调。0.7 偏平滑,0.3 偏响应。需要说明的是,平滑不能解决“手型本身不标准”的问题,只能消除单帧噪声。如果数字识别经常出错,优先检查手势定义和阈值,而不是加平滑。
4.2 距离法阈值不稳定,改用角度法更通用
距离法写起来直观,但有一个天然弱点:手的大小和手指比例不同。成年人手掌大、手指长,儿童手掌小、手指短,但是归一化坐标空间里,这些差异不一定能被 1.2 这个固定系数完美消化。我测试下来,如果只是给成年人用,问题不大;要给孩子或者手型差异大的群体用,就要考虑角度法。
角度法的思路是:手指伸直时,从侧向看,掌指关节、近端指间关节、指尖三个点几乎是近似一条直线,弯曲时这条线会明显折起来。我们可以计算中间关节处形成的夹角,角度越接近 180 度,说明手指越直。
def point_angle(a, b, c): v1 = (a.x - b.x, a.y - b.y) v2 = (c.x - b.x, c.y - b.y) dot = v1[0] * v2[0] + v1[1] * v2[1] len1 = math.hypot(*v1) len2 = math.hypot(*v2) cos_angle = dot / (len1 * len2 + 1e-6) return math.degrees(math.acos(max(-1.0, min(1.0, cos_angle))))食指判断时,取索引 5、6、8 三个点,计算它们在食指近端指间关节处的夹角。伸直时角度通常在 150 度以上,弯曲时会掉到 110 度以下。这个方法的优势是它对画面中手的大小不敏感,只要检测出的关键点坐标稳定,角度就是稳定的。缺点是对手指侧向旋转比较敏感,手完全侧过来时角度也会失真。所以很多正式项目会同时用距离法和角度法,取两者共同判断,或者根据画面中手的状态动态选择。
4.3 不同环境下的检测失败和误识别
实操中遇到最多的问题,不是规则写得不对,而是关键点检测本身出错。手距离摄像头太近,指根点可能超出画面边界;手太远,21 个点虽然检测出来了,但坐标抖动幅度变大;光线强烈不均匀的情况下,手部分曝光过度或过暗,也会导致检测质量下降。
应对措施有几个方向。一是调整摄像头摆放距离,保证手部在画面中占据合适比例,通常手掌宽度占画面高度的一半左右最合适。二是在预处理阶段做点简单的光照控制,尽量保证手部区域亮度均匀。三是对输入分辨率做降采样,不要直接用 1080P 大图做过多的无效计算,分辨率并不是越高越好,640x480 对 MediaPipe Hands 来说已经够用。
还有一个容易被忽略的问题:背景中的人脸、海报上的手部图片、甚至远处晃动的物体,都可能触发手掌检测。如果发现误检越来越多,优先考虑调高 min_detection_confidence,从 0.5 调到 0.7 或者更高。代价是召回率下降,但实际互动场景中,稳定度往往比召回率更重要。
4.4 性能优化和部署场景的取舍
MediaPipe Hands 在 PC 上跑 CPU 推理,实时性是够的。但如果你把这段逻辑挪到手机端或边缘设备上,就不能不做取舍了。
首先是最基础的参数问题。检测模式里每帧重新检测手掌,会显著提高 CPU 占用率,而跟踪模式下计算量会小很多,默认的 static_image_mode=False 已经是更适合视频流的设置。如果仍然吃紧,可以把图像输入尺寸再调小,比如把摄像头采集分辨率先压缩到 480x360 再送 mediapipe,效果会在性能和精度之间产生一个平衡点。
其次,如果业务场景对延迟要求很高,可以考虑把 mediapipe 推理单独放到一个线程,主线程只负责渲染 UI 和响应用户操作。入库到交互应用的体积考虑,mediapipe 的模型文件集中在几 MB 的范围内,对于移动端来说并不算重。
还有一点很容易踩坑:当 max_num_hands=2 时,两个手可能同时出现在画面里,这时候 multi_hand_landmarks 返回数组的顺序和画面中左右手的位置并不保证一致,必须结合 multi_handedness 里的左右手标签来判断哪个结果对应哪只手。我做某个演示项目时,就是因为在双指控制需求里忽略了这一点,结果左右手操作经常串位,后来打印 handedness 标签才发现当前手和画面位置是错开的。
4.5 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 手部关键点时有时无 | 手离摄像头太近或太远 | 手动调整距离,保证手掌占画面高度一半 |
| 数字从 1 突然跳到 2 又跳回来 | 手指临界状态抖动 | 增加连续帧确认或坐标平滑 |
| OK 手势识别成 10 | 捏合阈值偏松或偏紧 | 打印拇指食指间距离和参考距离的关系再定阈值 |
| 两只手同时出现时识别混乱 | 没有区分左右手标签 | 读取 multi_handedness,按标签配对 |
| 手在画面中快速移动后消失 | 跟踪丢失,重新检测需要时间 | 调低 min_tracking_confidence,避免剧烈运动 |
| 手心能识别,手背识别率低 | 训练数据以手心为主 | 尽量用手心朝向摄像头,或补充手背样本 |
这番实操下来,我的体会是:整个项目真正的重点并不在 MediaPipe Hands 怎么调用,那部分很成熟,照着文档写就能跑通。真正决定一个手势识别 Demo 好不好用的,是你怎么定义手势剧本、怎么处理关键点状态的边界情况、怎么处理实际环境中乱七八糟的干扰。规则识别看起来很“土”,但做出来的效果稳定、可解释、可调整,这类特性在实际业务里恰恰比模型指标上的几个百分点更值钱。希望这篇文章能帮你把手上的这 21 个点,真正变成能用起来的东西。