☰
MediaPipe手指计数实战:基于21个关键点判断伸了几根手指
2026/10/21 16:43:46 网站建设 项目流程

MediaPipe基础系列写到了第九篇,这次要聊一个很多朋友从入坑第一天就会问的问题:能不能让程序自己数出我伸了几根手指?

实现方式其实不复杂,但它有一个很微妙的价值:你已经从“拿来一个现成检测器”迈向了“自己动手写判断逻辑”。MediaPipe的Hands模型会返回一只手上21个关键点的坐标,但它不会告诉你“这是几根手指”,也不会告诉你哪根手指竖着、哪根弯着。要做手指计数,真正的工作在于我们怎样解读这21个点之间的几何关系。这篇文章我会从关键点结构开始讲,陪你搭好环境、跑通手部检测的最小Demo,再一步步写出判定逻辑和实时视频流里的完整计数程序。全程不需要深度学习基础,懂一点Python和OpenCV就能跟上。

1. 21个关键点到底在哪儿:手指计数的坐标系基础

1.1 手部模型并没有“手指数量”这个概念

很多人有一个误会,以为MediaPipe检测到手之后,顺手就返回了“手指数量”之类的语义信息。真不是这样。它手部模型输出的东西,是一组关键点Landmarks,一共21个,从手腕一路覆盖到五个指尖。手指到底是伸直还是弯曲,完全靠我们自己拿这些关键点的坐标去推。

我记得第一次看到这21个点的打印结果时,脑子里只有一个想法:这不就是一堆浮点数吗?确实,每个关键点只有三个数字,x、y、z。但就是这三个数字,足够把一根手指的状态判断出来,前提是你知道这21个点各自代表手部的哪个位置。

1.2 关键点索引与手指的对应关系

手部关键点的索引分布如下:

  • 0号:手腕根部
  • 1到4号:拇指,从掌根关节到指尖依次是1(CMC)、2(MCP)、3(IP)、4(指尖)
  • 5到8号:食指,5(MCP)、6(PIP)、7(DIP)、8(指尖)
  • 9到12号:中指,9(MCP)、10(PIP)、11(DIP)、12(指尖)
  • 13到16号:无名指,13(MCP)、14(PIP)、15(DIP)、16(指尖)
  • 17到20号:小指,17(MCP)、18(PIP)、19(DIP)、20(指尖)

这套命名里MCP、PIP、DIP都是关节术语,你可以理解成指根、中间关节、末端关节。真正判断一根手指伸直与否,我们最常用的是指根MCP和中间关节PIP,以及指尖点。

我把本文要用到的对应关系整理成一张表,后续写代码时你可以直接对照:

手指指尖关键点判定用基准点判定依据
拇指4号3号(IP关节)x方向相对位置,分左右手
食指8号6号(PIP关节)y方向相对位置
中指12号10号(PIP关节)y方向相对位置
无名指16号14号(PIP关节)y方向相对位置
小指20号18号(PIP关节)y方向相对位置

1.3 归一化坐标为什么能跨分辨率使用

还有一件重要事:这21个点的x和y坐标,不是像素值,而是被归一化到了0到1之间的小数。什么意思呢?意思是无论你把画面压到320乘240,还是放大到4K,同一个关键点返回的x、y比例数值基本是一致的。

这个特性对手指计数非常关键。我们写判断逻辑时,不需要关心摄像头分辨率,也不需要根据距离去标定像素比例,只需要比较坐标值之间的相对大小关系。这个设计让我少写了很多麻烦的适配代码,你也要记住:当坐标是归一化的时候,永远用它做相对比较,而不是直接拿来做绝对阈值。

z坐标也得提一句。它表示手腕到该点的相对深度,数值上不是真实距离,但在判断手指是否朝摄像头方向伸出时有点用。本文做手指计数用不到z,但如果你以后想做“指尖朝着屏幕点过来”的动作识别,z就派上用场了。

2. 环境与最小Demo:先把MediaPipe的手部关键点跑出来

2.1 安装依赖

我在这个系列里一直用的是Python搭配OpenCV,MediaPipe负责检测,OpenCV负责读摄像头和画界面。安装命令很简单:

pip install opencv-python mediapipe numpy

有两点要提醒。第一,网上的老教程有一些是针对旧版本MediaPipe(0.8、0.9时期)写的,新版本API有一些细微变化,但核心用法还是那套,遇到报错大概率是API参数名变了,查一下当前版本的官方文档即可。第二,MediaPipe的推理逻辑在C++层实现,Python包只是接口,所以实时性是有保障的,普通笔记本跑摄像头流完全没有问题,后面我会再聊性能优化的事。

2.2 最小手部检测Demo

在写手指计数逻辑之前,建议先把“检测到手并画出关键点”这一步跑通。这一步只要没跑通,后面所有判断代码都没有意义。

import cv2 import mediapipe as mp 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.5, min_tracking_confidence=0.5 ) cap = cv2.VideoCapture(0) while cap.isOpened(): success, frame = cap.read() if not success: 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: mp_drawing.draw_landmarks( frame, hand_landmarks, mp_hands.HAND_CONNECTIONS ) cv2.imshow("Hand Detection", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

这个Demo跑起来后,你对着摄像头伸手,画面上会出现一张手部骨架图,也就是21个关键点连线。能出现这张图,说明MediaPipe的检测链路已经全通了。

我想展开解释三个细节,它们都是新手最容易踩坑的点。

第一个是BGR到RGB的转换。OpenCV读取图像时默认通道顺序是BGR,而MediaPipe模型内部用的是RGB顺序,所以要先做一次转换。顺序弄反了通常不会报错,但检测效果会莫名变差,有时候手离镜头近一点就检测不到。这个问题我当初排查了挺久,后来一查,发现就是通道顺序的问题。

第二个是static_image_mode。在视频流场景下应该设成False,让MediaPipe进入跟踪模式。它会把上一帧的检测结果传递到当前帧,配合光流机制加速推理,速度更快,稳定性也更高。如果是处理单张图片,就把它设成True。

第三个是max_num_hands。做单只手手指计数,设成1就够了,推理压力会小一些。如果你的项目需要识别两只手,再改成2,代价是CPU占用和延迟都会增加,具体增加多少取决于你的机器配置。

2.3 确认关键点坐标真的读出来了

跑通上面的Demo之后,下一步是验证坐标数据真的可以拿到。在results.multi_hand_landmarks里,每个hand_landmarks对象都含有一个landmark属性,它是一个包含21个元素的列表,每个元素都有x、y、z三个值。

for idx, lm in enumerate(hand_landmarks.landmark): print(idx, lm.x, lm.y, lm.z)

看到0到20号坐标随着手部动作不断变化,就说明数据已经就位了。从这一步开始,后面的工作不再是“做检测”,而是“读坐标、算关系”。做一个形象一点的比喻:模型已经帮我们把手的骨骼骨架摆到了坐标平面上,我们接下来要做的,是根据骨架的几何形态推断手指的状态,有点像只凭火柴人的姿势判断它是在招手还是握拳。

3. 判定“手指伸直”的核心逻辑:拇指和四指要分开处理

3.1 为什么不能只看指尖

有些同学会想,检测指尖是不是比手腕高,不就能判断手指是否伸直了吗?这个思路在大部分情况下是错误的。

原因很简单:手掌本身会旋转和倾斜。你把整个手转个45度角,或者从侧面伸到摄像头前,指尖和手腕的相对位置就会产生巨大变化。同一根伸直的手指,会因为手掌朝向不同,被误判成弯曲状态。所以判断手指伸直与否,必须找“手内部的稳定参考”,比如同一根手指上相邻关节点之间的位置关系。

3.2 四指判定:指尖和PIP关节比较更可靠

对食指、中指、无名指、小指,我采用的是最通用的方法:比较“指尖”和“PIP关节”的y坐标。

为什么选PIP而不选MCP?你自己可以做一个实验。慢慢弯曲食指,你会感觉到指尖真正开始明显下降,是在PIP关节(第二个指关节)弯折之后。MCP点(指根)离指尖太远,作为参考基准会显得迟钝。用PIP做基准,伸直和弯曲之间的区分度更明显,代码判定也更稳定。

判定逻辑可以写成这样:

tips = [8, 12, 16, 20] pips = [6, 10, 14, 18] for tip, pip in zip(tips, pips): if lm[tip].y < lm[pip].y: # 指尖在PIP上方,视为伸直 pass else: # 指尖低于PIP,视为弯曲 pass

图像坐标系里y轴方向朝下,所以“数值更小”意味着“位置更高”。手指竖直向上伸直时,指尖的y值一定小于PIP关节的y值;手指弯下来,这个大小关系就反转了。

3.3 拇指的特殊处理:它主要是横向运动

拇指和另外四根手指的几何关系很不一样。它在手掌的侧边,伸直和弯曲更多体现在x方向上,而不是y方向。如果你用“指尖y值小于关节y值”去判断拇指,大概率会得到一堆随机结果,尤其是当手掌保持自然角度时,拇指的y坐标几乎没有什么稳定的规律。

所以在处理拇指时,需要看x方向。但这里有个绕不开的坑:方向取决于你伸的是左手还是右手。

你可以做个简单体验。右手掌心朝自己,大拇指往外张开,指尖在画面的右边,也就是4号点的x大于3号点的x;换成左手,同样掌心朝自己,大拇指张开后,指尖在画面的左边,4号点的x小于3号点的x。这意味着“拇指伸直”在坐标上的表现,左手和右手是完全相反的。

因此,代码里需要一个方向系数,根据MediaPipe返回的handedness信息来决定比较方向。这个系数特别重要,我身边有人照抄了一份只适配右手的代码,结果换左手测试时数字就乱了,还以为是模型不行。

3.4 从“单指状态”到“手指总数”

把每根手指的判断结果存成一个布尔值列表,最后求和,就是手指总数。为了后续扩展方便,我会按照“拇指、食指、中指、无名指、小指”的顺序存,这样得到的就不只是一个总数,还能精确知道是“哪几根”手指伸着,后续做手势识别时可以直接复用。

4. 完整可运行的手指计数器:代码一步步拆开讲

4.1 核心函数:count_fingers

下面这是本文的核心函数,输入是一只手的关键点数据和左右手标签,输出是0到5的整数。

def count_fingers(hand_landmarks, handedness): lm = hand_landmarks.landmark fingers = [] # 拇指:左右手方向相反 if handedness == "Right": fingers.append(1 if lm[4].x > lm[3].x else 0) else: fingers.append(1 if lm[4].x < lm[3].x else 0) # 其余四指:指尖与PIP关节做y方向比较 tips = [8, 12, 16, 20] pips = [6, 10, 14, 18] for tip, pip in zip(tips, pips): if lm[tip].y < lm[pip].y: fingers.append(1) else: fingers.append(0) return sum(fingers)

这段代码的逻辑很简单,但有一个前置条件:你必须拿到handedness这个信息,否则拇指方向没法判断。

4.2 handedness信息从哪里来

MediaPipe的检测结果里,除了multi_hand_landmarks,还有一个multi_handedness。它保存了每只手的左右手标签,取法如下:

handedness = results.multi_handedness[idx].classification[0].label

这里有一个容易误解的点:这个标签指的是“真实世界的左右手”,不是你在画面里看到的左右位置。尤其在你开启摄像头镜像显示后,画面上自己的右手会出现在左边,但标签仍然是“Right”。我们处理拇指方向时,要用真实的左右手属性,而不是凭视觉猜。

不过有一个例外,就是当你对输入帧做了水平翻转再送入检测时,MediaPipe看到的就是一帧镜像后的手部画面,此时检测结果会变成镜像后的手性。这一点在下一节展开说,因为它是很多人的困惑点。

4.3 实时主循环:把手指数显示到画面上

现在把核心函数接到摄像头循环里。

import cv2 import mediapipe as mp 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.5, min_tracking_confidence=0.5 ) cap = cv2.VideoCapture(0) while cap.isOpened(): success, frame = cap.read() if not success: break # 自拍习惯,让画面像镜子一样 frame = cv2.flip(frame, 1) rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = hands.process(rgb) if results.multi_hand_landmarks: for idx, hand_landmarks in enumerate(results.multi_hand_landmarks): handedness = results.multi_handedness[idx].classification[0].label cnt = count_fingers(hand_landmarks, handedness) mp_drawing.draw_landmarks( frame, hand_landmarks, mp_hands.HAND_CONNECTIONS ) text = f"Fingers: {cnt} ({handedness})" cv2.putText( frame, text, (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 220, 0), 3 ) cv2.imshow("Finger Counting", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

这个程序跑起来后,你伸出手,画面上会实时出现骨架和“Fingers: 3”之类的文字。你做一个握拳、张开、比个二,计数值都会跟着变,整个过程非常直观。

有一点我要特别说明:代码里我加了cv2.flip(frame, 1),把画面做了水平镜像,这样摄像头画面看起来更像照镜子,符合直觉体验。但这行操作会影响后续的左右手判断。因为我们在翻转后的帧上运行检测,MediaPipe在所有摄像头帧上看到的都是镜像手,因此对于真实右手,它报告的是“Left”标签。这完全不影响 count_fingers 的结果,因为拇指逻辑会根据“Left”分支使用对应的方向比较。换句话说,无论你有没有做镜像,代码都能正确计数,因为手性和镜像始终是同步的。

如果你不想做镜像,去掉cv2.flip那行即可,检测逻辑和计数结果同样成立。这里的关键是:要么统一处理镜像,要么统一不处理,一旦你一边翻转画面到显示区域、另一边又用原始翻转前的检测结果,脑子就会彻底绕晕。

5. 让计数稳定下来的实战细节:镜像、左右手和抖动

代码能跑通只是第一步,真正要在项目里使用,你还会遇到几个细节问题。我把最容易让人困惑的点集中到这一节说明。

5.1 左右手标签到底怎么读懂

网上关于MediaPipe左右手标签的讨论非常多,因为不同版本的文档说法有出入。我基于自己的实测经验给你一个明确结论:

当你把视频帧原封不动送入检测器时,标签“Right”表示这是真实世界里的右手。当你把视频帧做了水平翻转再送入检测器时,真实右手会被识别为“Left”。这个结论在后续写代码时要记住,不要靠屏幕上的观感去猜。

5.2 最容易出现的两种误判场景

第一种是握拳时拇指被误判为伸直。整只手握紧时,拇指通常会搭在食指侧边,此时4号和3号点的x方向关系非常不稳定,偶尔会被算成“拇指伸出”,导致零变成一。这在自拍画面里尤其容易发生,因为你看到的镜头角度和手的真实空间姿态存在偏差。

一个有效的缓解方法:提高min_detection_confidence和min_tracking_confidence,比如从0.5提高到0.7,让检测器丢掉那些低置信度的结果。代价是手在画面边缘或者离镜头较远时,检测丢失的概率会升高。

第二种是手掌侧面朝向摄像头时计数乱跳。当手近乎垂直于镜头平面,关键点之间会互相遮挡,某些点在不同帧之间发生翻转,计数就会在几个数值之间来回跳。这个问题最好从使用姿势上规避:引导用户把掌心朝向镜头,而不是侧面。

5.3 坐标抖动与简单平滑

实时关键点坐标是有抖动的,这是所有视觉模型的通病。手指计数这种逻辑对抖动尤其敏感,因为指尖和PIP关节的y坐标在临界状态时会反复交替大小关系。

我的办法是做一个很轻量级的平滑:在连续N帧里,只有当手指数连续多次保持一致时才更新显示结果。这比单纯推动画帧率更加有效。当然,这会引入一点点延迟,但对计数场景来说,稳定比实时更重要。

stable_count = 0 last_count = -1 # 主循环内 if cnt != last_count: stable_count += 1 if stable_count >= 3: last_count = cnt stable_count = 0 else: stable_count = 0

这里的思路很简单:计数值连续三次相同才认定是稳定状态,否则就继续等待。你会感到画面上的数字“不再乱跳”,体验会好很多。

5.4 距离对判断结果的影响

还有一种情况是手部离摄像头太远。在远距离下,归一化坐标的精度会下降,指尖和PIP的y值差异变得很不明显,偶尔会出现同一种手势在不同距离下计数不同的情况。

相对简单的方案是控制使用距离,让手在镜头中占的面积足够大。比如在应用里提示用户“请把手放到胸前距离”,或者在检测到手部边框过小的时候输出一个提示状态。如果要硬解,可以做坐标归一化后的尺度判断,手部包围框的宽度低于某个像素阈值时就不输出结果,但这个方案比较依赖摄像头内参,我这里就不展开了。

6. 手指计数还能怎么玩:扩展思路与性能优化

6.1 从总数值到组合手势

只返回一个总数值,功能太单薄了。我在实际做交互Demo时,把count_fingers函数改成了返回“哪几根手指伸着”的列表,这样就能识别出几个经典手势:

  • 只伸出拇指加食指,表示数字“二”或者比枪动作
  • 只伸出中指和无名指,就是有名的“摇滚”手
  • 五个手指全部张开,然后快速握拳,可以设计成确认动作

这种组合手势可以触发不同的逻辑,比单纯数字的玩法丰富得多。手势识别的本质,就是从“手指状态向量”到“语义标签”的映射,而手指状态向量正是本文这个count函数能输出的东西。

6.2 CPU占用与画面延迟的取舍

如果你在低配机器上跑,可以考虑两条优化路径。

第一条是降低输入图像尺寸。常见做法是把帧缩放到640或者480的宽度后再送入MediaPipe。分辨率降级后,坐标的归一化精度不会有太大损失,但推理耗时会明显下降。

第二条是降低处理频率。比如每隔一帧或两帧才做一次检测,中间帧直接沿用上一次的坐标和计数结果。对于只需要显示数字的场景,这个策略非常划算。

我把三种常见做法的效果整理成一个对比:

优化方式思路适合场景副作用
降采样输入帧减少模型输入面积实时视频流远距离精度略降
跳帧处理隔帧检测数字显示类应用画面更新略卡顿
启用跟踪模式利用帧间运动信息视频流默认推荐无副作用,但要求手不突然消失

MediaPipe自带的static_image_mode=False本身就属于第三种优化。这三种方式可以叠加使用,我在一个低功耗设备上做过组合测试,把帧宽降到480并且每两帧检测一次,整体CPU占用比原始方案下降了不少,手指计数效果依然可用。

6.3 从2D坐标走向更丰富的手势空间

手指计数只是手部关键点应用的一个入门口。你掌握了坐标比较的思路之后,可以往几个方向继续扩展。

一个方向是计算关节夹角。有了21个关键点,你可以利用三点坐标算出指关节的弯曲角度,比如食指伸直与否不再用y坐标简单比较,而是计算8号、6号、5号三点形成的夹角,角度小于某个值时判定为伸直。这种方法对摄像头角度更鲁棒,业界很多手势识别方案就是这么做的。

另一个方向是把计数结果和具体业务结合。比如远程演示工具里,用食指单指控制鼠标移动,用食指加中指模拟滚动,用张开手掌触发翻页。手指计数只是中间信号,真正的价值体现在它驱动了什么交互。

如果连真个是零基础,可能连“从坐标算角度”都不熟,这个很正常。你先把手指计数跑通,再把本文的关节索引表保存下来,下一步慢慢玩夹角计算,手部交互的大门就打开了。这个系列前几篇一直在讲检测能力本身,这一篇开始,我们已经能基于检测结果做判定逻辑了,从数据到动作的转化,才是MediaPipe在真实项目里最值得深耕的部分。

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

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

立即咨询