本文介绍一个不到200行的Python脚本,利用MediaPipe HandLandmarker模型识别手指数量,并通过稳定窗口和armed标志实现单次触发,自动打开Windows下载文件夹中最新的一张或多张PNG图片。适合希望通过摄像头做手势控制的开发者参考。
一、背景与目标
写代码时常需要查看刚下载的截图,手动打开“下载”文件夹再按时间排序双击,步骤繁琐。因此通过摄像头识别手势:比划“1”打开下载夹里最新的PNG,“2”打开第二新的,依此类推到“5”;握拳(0根手指)解除武装等待下一次触发。
二、环境准备
MediaPipe 0.10.x版本后全面采用Tasks API,不再支持旧的mp.solutions.hands。Python版本方面,目前MediaPipe对Python 3.13没有发布wheel,推荐使用Python 3.10。建议创建虚拟环境:- py -3.10 -m venv venv
- venv\Scripts\activate
- pip install mediapipe opencv-python pillow numpy
复制代码 模型文件需要单独下载:
https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/latest/hand_landmarker.task
放入项目根目录的models/hand_landmarker.task。项目目录结构:- MediaPipe/
- ├── models/hand_landmarker.task
- ├── demo_hand_landmarker.py
- └── venv/
复制代码
三、MediaPipe Tasks最小可用骨架
旧版mp.solutions.hands在0.10.35之后已移除,必须使用新的Tasks API。以下是最小骨架:- import mediapipe as mp
- from mediapipe.tasks import python as mp_python
- from mediapipe.tasks.python import vision
- options = vision.HandLandmarkerOptions(
- base_options=mp_python.BaseOptions(model_asset_path="models/hand_landmarker.task"),
- running_mode=vision.RunningMode.LIVE_STREAM, # 摄像头流式推理
- num_hands=1,
- min_hand_detection_confidence=0.5,
- min_hand_presence_confidence=0.5,
- min_tracking_confidence=0.5,
- result_callback=on_result, # 异步结果回调
- )
复制代码 LIVE_STREAM模式下,detect_async()立即返回,结果通过回调异步传递。主循环不能阻塞等待,应直接读取“上一次最新结果”。
使用一个线程安全的容器保存最新结果:- class LatestResult:
- def __init__(self):
- self.result = None
- latest = LatestResult()
- def on_result(result, output_image, timestamp_ms):
- latest.result = result
复制代码 每一帧将BGR转RGB包成mp.Image,再传入单调递增的时间戳:- start_ns = time.perf_counter_ns()
- # ...
- rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
- mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
- ts_ms = (time.perf_counter_ns() - start_ns) // 1_000_000
- landmarker.detect_async(mp_image, ts_ms)
复制代码 时间戳必须严格递增,否则MediaPipe会直接抛错丢弃帧。这是新手最容易踩的坑。
四、HandLandmarker输出解析
模型输出21个3D归一化坐标(x,y ∈ [0,1],z相对于手腕深度)。索引含义:- 0 WRIST
- 1-4 THUMB
- 5-8 INDEX
- 9-12 MIDDLE
- 13-16 RING
- 17-20 PINKY
复制代码 每根手指有4个点:MCP(指根)、PIP、DIP、TIP(指尖)。本项目只关心TIP和PIP。绘制连线的连接表(原mp.solutions.hands.HAND_CONNECTIONS已不可用,需手动写死):- HAND_CONNECTIONS = (
- (0,1),(1,2),(2,3),(3,4),
- (0,5),(5,6),(6,7),(7,8),
- (5,9),(9,10),(10,11),(11,12),
- (9,13),(13,14),(14,15),(15,16),
- (13,17),(0,17),(17,18),(18,19),(19,20),
- )
复制代码
五、朴素而鲁棒的手指计数算法
很多教程比较TIP和PIP的y坐标,但手歪时容易误判。更鲁棒的办法:计算TIP到WRIST的距离与PIP到WRIST的距离之比,若比值大于1.1则认为手指伸直。因为弯曲时TIP会靠近手腕,距离必然减小。- FINGER_TIPS = (4, 8, 12, 16, 20)
- FINGER_PIPS = (3, 6, 10, 14, 18)
- def count_extended_fingers(landmarks) -> int:
- wrist = landmarks[0]
- def d(a, b):
- return math.hypot(a.x - b.x, a.y - b.y)
- n = 0
- for tip, pip in zip(FINGER_TIPS, FINGER_PIPS):
- if d(landmarks[tip], wrist) > d(landmarks[pip], wrist) * 1.1:
- n += 1
- return n
复制代码 1.1是经验阈值,调整时需平衡灵敏与误判。
六、防抖:稳定窗口+armed状态机
直接使用瞬时值会导致识别抖动时反复触发。分两步处理:
1. 稳定窗口:连续N帧均为同一值才算稳定。使用deque(maxlen=10)(约0.3-0.5秒):- history = deque(maxlen=10)
- history.append(cur_n)
- stable = history[0] if len(history) == history.maxlen and all(v == history[0] for v in history) else None
复制代码 2. armed标志:触发后必须回到0才能再次触发。实现边沿触发而非电平触发:- if stable == 0:
- armed = True
- elif stable is not None and 1 <= stable <= 5 and armed:
- open_nth_png(stable)
- armed = False
- history.clear() # 清空缓冲,避免同一稳定段重复识别
复制代码
七、按时间倒序打开第N新的PNG
使用Pathlib和os.startfile(Windows专属):- from pathlib import Path
- import os
- DOWNLOADS_DIR = Path.home() / "Downloads"
- def open_nth_png(n: int):
- files = sorted(
- DOWNLOADS_DIR.glob("*.png"),
- key=lambda p: p.stat().st_mtime,
- reverse=True,
- )
- if len(files) < n:
- return False, f"Only {len(files)} PNG(s), need #{n}"
- target = files[n - 1]
- os.startfile(str(target))
- return True, f"Opened #{n}: {target.name}"
复制代码 如果需要跨平台,macOS用subprocess.run(["open", path]),Linux用xdg-open。
八、画面镜像与左右手反转
cv2.flip(frame, 1)让画面照镜子,但MediaPipe返回的handedness也跟随镜像。纠正方法:- raw = result.handedness[hand_idx][0].category_name
- label = "Right" if raw == "Left" else "Left"
复制代码
九、完整主循环
整合以上部件:- with vision.HandLandmarker.create_from_options(options) as landmarker:
- while True:
- ok, frame = cap.read()
- if not ok:
- break
- frame = cv2.flip(frame, 1)
- rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
- mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
- ts_ms = (time.perf_counter_ns() - start_ns) // 1_000_000
- landmarker.detect_async(mp_image, ts_ms)
- res = latest.result
- cur_n = count_extended_fingers(res.hand_landmarks[0]) if (res and res.hand_landmarks) else 0
- history.append(cur_n)
- stable = history[0] if len(history) == history.maxlen \
- and all(v == history[0] for v in history) else None
- if stable == 0:
- armed = True
- elif stable is not None and 1 <= stable <= 5 and armed:
- ok2, msg = open_nth_png(stable)
- armed = False
- history.clear()
- draw_landmarks(frame, res)
- cv2.imshow("Gesture -> Open PNG", frame)
- if cv2.waitKey(1) & 0xFF in (ord("q"), 27):
- break
复制代码
十、常见坑点
- No module named 'mediapipe':多半没激活虚拟环境,或使用了系统Python 3.13。先用where python确认路径。
- module 'mediapipe' has no attribute 'solutions':装了0.10.x新版本,请使用mediapipe.tasks.python.vision。
- 时间戳报错:detect_async必须传入单调递增的timestamp_ms,重复值会被拒绝。
- 回调线程:on_result运行在另一个线程,不要在其中调用OpenCV GUI函数;只更新共享变量供主循环读取。
十一、扩展方向
- 将“打开PNG”改为启动指定程序、切换桌面、切歌等。
- 双手识别(num_hands=2):左0+右N组合更多手势。
- 配合OBS摄像头做演讲翻页器。
- 利用21个关键点训练自定义手势分类器(MLP)识别OK、❤等。
实际运行效果:CPU推理(i5笔记本)可达25-30 FPS。MediaPipe把摄像头到关键点的脏活全干了,剩下的只是几何判断。本方案核心:HandLandmarker + 稳定窗口 + armed标志 + os.startfile,就是Windows上可用的手势触发器。下一篇将用同一骨架换成FaceDetector,实现按‘s’一键存所有人脸。 |