☰
cvzone手势识别与虚拟键盘:从关键点检测到可交互输入
2026/10/27 10:35:25 网站建设 项目流程

简介:这份合辑面向计算机视觉与人工智能方向的Python开发者及进阶学习者,聚焦手势识别、虚拟键盘、姿态检测等可落地应用,提供经过调试运行的参考项目,帮助读者理解从摄像头采集、手部特征分析到交互控制的完整思路。压缩包共35个文件,约16.91MB,以16个Python脚本为核心,配合11张jpg测试素材、6个ino Arduino端程序,以及少量pyc缓存和png图示,兼顾算法实现与硬件联调场景。已有436人学习下载,说明其在同类实践中具备一定参考价值。内容覆盖手势计数、虚拟键盘、音量手势控制、虚拟鼠标、面部网格与姿态检测等模块,并含Arduino章节的电路与代码素材;读者可据此对照复现实验、梳理模块组织方式,并借鉴调试与参数调整经验,适合用作课程设计、毕业项目或自学练手的起点。

1. 从摄像头到"能按下去":这份 cvzone 合辑解决的是什么问题

很多人第一次跑计算机视觉 Demo 都停在同一个地方:Python 里 MediaPipe 的字幕能出来,21 个手部关键点也能画在屏幕上,但接下来就不知道怎么办了——点怎么变成动作?手指怎么变成输入?这份 cvzone 部分合辑的价值正好落在这个断点上。它把手势识别、虚拟键盘、姿态检测几个场景都调试通了,摄像头取帧、关键点检测、坐标判定、UI 反馈、连续帧防抖这几层一个没落,解压就能跑。适合两类人:想把课上作业从"能识别"推进到"能交互"的同学,以及想给桌面工具加一条免接触输入通道的 Python 开发者。接下来我按工程结构把这份合辑拆开,重点讲那几段真正决定它能不能用的代码。

2. 技术栈与工程骨架:cvzone 在 OpenCV 和 MediaPipe 之间补了哪一层

2.1 三层结构:谁在取帧、谁在推理、谁在拼 UI

这类计算机视觉项目拆开看永远是三层。最底下是 OpenCV,负责VideoCapture取帧、imshow显示、rectangle/putText绘制;中间是 MediaPipe,负责跑手部关键点模型和人体姿态模型,输出的是归一化到 0~1 的浮点坐标;最上面才是 cvzone,它的定位很明确——不碰模型,只把中间层输出的原始结果整理成开发者能直接用的结构,再搭一组画图工具函数。

所以 cvzone 并不是一套"算法",它是一个薄封装。理解这一点很关键:遇到识别不准,需要调的是 MediaPipe 的置信度参数;遇到画面卡,需要优化的是 OpenCV 的取帧和绘制;遇到坐标对不上,八成是没搞清 cvzone 已经把归一化坐标转成像素坐标了。这份合辑里用到的几个核心类大致是这样分布的:

类 / 函数导入路径职责返回结构
HandDetectorcvzone.HandTrackingModule手部 21 点 + 左右手判定每只手一个 dict,含lmList、bbox、center、type
PoseDetectorcvzone.PoseModule人体 33 点骨架 + 三点夹角lmList为[id, x, y]列表
FaceMeshDetectorcvzone.FaceMeshModule468 点人脸网格lmList同上
SelfieSegmentationcvzone.SelfiSegmentationModule人像分割换背景前景 mask 图
stackImagescvzone.Utils多路画面拼成宫格拼接后的 ndarray
findDistanceHandDetector的方法两个关键点之间的像素距离(length, img, lineInfo)

一个容易翻车的细节:MediaPipe 原生输出的是归一化坐标,而 cvzone 的findPosition()返回的lmList里每一项是[id, cx, cy],cx/cy已经是像素值。这两套坐标混着用,画出来的点就会挤在左上角一个几十像素的小方框里,第一次见会以为是模型坏了。

2.2 环境安装与摄像头自检:先确认能拿到帧,再谈识别

装依赖这一步看似简单,实际最容易耗掉半小时。合辑里几个 Demo 是opencv-python+mediapipe+cvzone三件套,我用的是虚拟环境隔离,避免和系统里其他 OpenCV 版本打架:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 先把 numpy 钉在 1.x,mediapipe 对 numpy 2.x 早期版本不友好 pip install "numpy<2" opencv-python mediapipe cvzone

numpy<2这个约束不是洁癖。mediapipe 的部分旧版本在导入批处理会踩到np.float之类的别名移除,报错信息又完全指不到 numpy 版本上,属于典型的"玄学报错"。先把 numpy 降下来,能省掉一轮排查。

装完先别急着跑 Demo,单独写个十几行的自检脚本,确认摄像头能被抢到、分辨率能被设置:

import cv2 print("opencv:", cv2.__version__) for i in range(3): # 0/1/2 挨个试,笔记本常见外接摄像头占 1 cap = cv2.VideoCapture(i, cv2.CAP_DSHOW) # Windows 下指定 DSHOW 后端,启动快很多 ok, frame = cap.read() if ok: print(f"index={i} ok, shape={frame.shape}") else: print(f"index={i} 拿不到帧") cap.release()

三件事说明白。第一,CAP_DSHOW是 Windows 专属后端,不加的话有时要等两三秒摄像头才响应,加了基本是秒开;在 Linux 上把它换成cv2.CAP_V4L2或者直接不传第二个参数。第二,cap.read()返回(ok, frame),ok为False的情况比想象中多——摄像头被别的软件占着、被系统休眠策略挂起、USB 带宽不够都算,主循环里必须判这个返回值。第三,frame.shape打印出来是(720, 1280, 3),也就是高在前、宽在后,这个顺序写绘制代码时经常搞反。

分辨率设置也有讲究,合辑里统一用的是 1280×720,因为虚拟键盘要摆下三行十个按钮,720p 下每个按钮差不多 85 像素见方,手指点下去还有余量;480p 下按钮被压到 50 像素出头,指尖抖动两下就出界了。想改分辨率就改这两行:

cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)

注意cap.set是建议值不是命令,某些摄像头驱动会给你一个最接近的档位,所以设完之后再读一次cap.get(3)和cap.get(4)确认实际生效的分辨率,比凭感觉调 UI 布局靠谱。

2.3 合辑工程的目录分层与主循环骨架

合辑本身是多个独立 Demo 放在一起,每个场景一个入口文件,共享一份检测器初始化和主循环写法。我按这个思路把它整理成下面这种结构,后面所有模块都挂在这套骨架上:

cvzone-demos/ ├── app.py # 统一入口,负责调度当前激活的场景 ├── config.py # 分辨率、置信度、颜色常量集中放这里 ├── modules/ │ ├── hand_track.py # 手势识别:21 点 + 手指状态 │ ├── virtual_keyboard.py # 虚拟键盘:按钮布局 + 命中判定 + 状态机 │ ├── pose_detect.py # 姿态检测:33 点 + 角度 + 计数 │ └── ui.py # Button 类、绘制函数、文字底框 ├── assets/ # 键盘图标、背景图 └── requirements.txt

分层的意义在于:modules/里每个文件只关心"我拿到lmList之后干什么",不碰摄像头,不碰imshow。这样调某个场景时,主入口切一行导入就换过去了,而不是在几千行的单文件里翻来翻去。主循环的骨架长这样:

# app.py —— 所有场景共用的主循环 import cv2 import time from cvzone.HandTrackingModule import HandDetector W, H = 1280, 720 cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) cap.set(cv2.CAP_PROP_FRAME_WIDTH, W) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, H) # 参数含义见下方说明 detector = HandDetector(staticMode=False, maxHands=1, modelComplexity=1, detectionCon=0.7, minTrackCon=0.5) pTime = 0 while True: ok, img = cap.read() if not ok: # 拿不到帧直接退出,不要在这里 while 重试 print("摄像头读取失败") break img = cv2.flip(img, 1) # 镜像,让"手往右"和"画面往右"方向一致 img = detector.findHands(img, draw=True) lmList, bboxInfo = detector.findPosition(img, draw=False) if lmList: # 只有检测到手才进入业务分支 fingers = detector.fingersUp() # 各场景的判定逻辑从这里接出去 cTime = time.time() fps = 1 / (cTime - pTime) if pTime else 0 pTime = cTime cv2.putText(img, f"FPS:{int(fps)}", (20, 50), cv2.FONT_HERSHEY_PLAIN, 2, (0, 255, 0), 2) cv2.imshow("cvzone", img) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()

逐段说。HandDetector的五个参数是后续所有调参的入口:staticMode=False表示按视频流模式跑,模型会利用上一帧的位置做跟踪,帧率更高;如果要做单张图片检测,必须改成True,否则第二张图会沿用上一张的跟踪状态,结果莫名其妙。maxHands=1限制只找一只手,单手场景下别设成 2,多出来的手会让findPosition()返回的顺序不稳定。modelComplexity=1是手部关键点模型的复杂度,0 更快更糙,1 更准更慢,720p 下用 1 基本能稳在 25~30 帧。detectionCon是"第一次检测到手"的阈值,minTrackCon是"跟踪阶段"的阈值,后者一般给得比前者低一点,这样手快速移动时不会因为置信度掉一点就丢跟踪。

cv2.flip(img, 1)这行别省。不加镜像的话,你在摄像头前把手往右移,画面里手是往左走的,虚拟键盘这种需要"指哪打哪"的场景会彻底错位。

waitKey(1)返回的是按键的 ASCII 码,& 0xFF是为了在不同平台上取低八位,让 ESC 的 27 能稳定判出来。顺带一提,如果把waitKey(1)改成waitKey(30)测帧率,你会发现 FPS 显示立刻掉到 30 左右——那是被waitKey卡住的,不是模型慢,这个假象坑过不少人。

FPS 这段用time.time()算瞬时值,数字会跳得很厉害,看趋势就行,想看平滑值可以做十帧滑动平均。

3. 手势识别与虚拟键盘:从 21 个关键点到一次有效的按键输入

3.1 21 个关键点的坐标语义与 fingersUp 的判定逻辑

HandDetector输出了 21 个点,编号 0 是手腕,1~4 是拇指四个关节,5~8 是食指,9~12 中指,13~16 无名指,17~20 小指。每根手指的最后一个编号就是指尖:拇指 4、食指 8、中指 12、无名指 16、小指 20。做交互几乎只用得到指尖和少数几个关节,记住这五个指尖编号,剩下的事情就是取坐标。

findPosition()返回的lmList是一个 21 项的列表,每项[id, cx, cy],cx/cy是像素坐标。bboxInfo是一个元组(x, y, w, h),描述整只手的外接框,可以用来做"手在画面哪个区域"的粗判定,比逐点判断便宜。

手指状态的判定逻辑很简单但很实用:对食指到小指,比较指尖的 y 坐标和它下面第二个关节(PIP)的 y 坐标,指尖在上就是伸出;拇指因为朝向横着,比较的是 x 坐标。

from cvzone.HandTrackingModule import HandDetector detector = HandDetector(detectionCon=0.8, maxHands=1) # 在拿到 img 之后 img = detector.findHands(img, draw=True) lmList, bbox = detector.findPosition(img, draw=False) if lmList: fingers = detector.fingersUp() # 返回 5 个 0/1,顺序是 [拇指, 食指, 中指, 无名指, 小指] print(fingers) # 手写一遍食指的判定,理解 fingersUp 在干什么 index_tip = lmList[8] # [8, cx, cy] index_pip = lmList[6] index_up = 1 if index_tip[2] < index_pip[2] else 0 print("食指是否伸出:", index_up) # 拇指指尖和食指指尖的像素距离,捏合手势靠它 length, img, lineInfo = detector.findDistance(4, 8, img, draw=True) if length < 40: print("捏合")

这段代码里最值得看的是findDistance的返回值。第一个是两点间的欧氏距离,单位是像素;第二个是画好连线的图像,draw=True时会直接在你传进去的img上画一条带圆点的线;第三个lineInfo是[x1, y1, x2, y2, cx, cy],中间那个cx, cy是连线中点,用来在两点之间放提示文字很顺手。

有个绕不过去的坑:40 这个阈值是绝对像素值,手离摄像头近的时候,两根手指离得老远也可能小于 40;手一远,捏死了都超过 40。正确做法是用相对距离,把像素距离除以手掌尺寸(比如手腕 0 到中指根部 9 的距离)做归一化,阈值固定在 0.3 左右,远近都能用。

3.2 虚拟键盘的按钮布局与命中判定

虚拟键盘的 UI 层是一个Button类,每个实例记录自己的位置、尺寸、字符和一个"按压状态"。布局是标准的 QWERTY 三行,第三行短一点,整体居中。绘制时给每个键画一个填充矩形加字符,命中时把矩形换色并稍微外扩一点,形成按压的视觉反馈。

# modules/ui.py import cv2 class Button: def __init__(self, pos, text, size=(85, 85)): self.pos = pos self.size = size self.text = text self.pressed = False # 记录这一帧是否被按到,用于画高亮 def draw_all(img, button_list): for btn in button_list: x, y = btn.pos w, h = btn.size # 按下时外扩 5 像素并换成亮紫色,松手时恢复 if btn.pressed: cv2.rectangle(img, (x - 5, y - 5), (x + w + 5, y + h + 5), (175, 0, 175), cv2.FILLED) else: cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 255), cv2.FILLED) cv2.putText(img, btn.text, (x + 20, y + 65), cv2.FONT_HERSHEY_PLAIN, 4, (255, 255, 255), 4)

cv2.FILLED传-1是同一个意思,用常量更好读。文字坐标(x+20, y+65)是试出来的经验值:FONT_HERSHEY_PLAIN字号 4 的时候字符高度大概 40 像素,基线放在 y+65 刚好垂直居中;换字号就得跟着改这个偏移,不然字符会贴着下边框。

命中判定发生在主循环里,逻辑是"食指指尖 8 号点是否落在某个按钮的矩形内"。用指尖而不是用掌心,是因为掌心位置对"点哪里"没有分辨力,手指稍微一偏,掌心可能还在按键 A 上,指尖已经到 B 了。

# 主循环内 final_text = "" if lmList: for btn in button_list: x, y = btn.pos w, h = btn.size px, py = lmList[8][1], lmList[8][2] # 注意:索引 1/2 才是 x/y,0 是 id if x < px < x + w and y < py < y + h: btn.pressed = True # 捏合判定:食指指尖 8 与中指指尖 12 的距离 length, _, _ = detector.findDistance(8, 12, img, draw=False) if length < 35 and not btn.locked: final_text += btn.text btn.locked = True # 锁住,松手前不重复触发 else: btn.pressed = False btn.locked = False # 离开按钮才解锁

这段代码里lmList[8][1]和lmList[8][2]容易写错成[0]和[1],因为lmList的第 0 项是 id,[8]本身取到的是第 9 个点,而它的内部结构是[id, x, y]。写成[0]的话,x 永远是 8,按钮判定要么全中要么全不中,症状非常迷惑。

3.3 从"每帧触发"变成"按下-释放"状态机

上面那个btn.locked就是防抖的核心。如果只写if length < 35: final_text += btn.text,主循环一秒跑 30 帧,捏合动作会持续 10 帧左右,同一个字符被输入十遍,文字栏刷出一串AAAAAAAAAA。这是虚拟键盘 Demo 最常见的翻车点,也是"能识别"和"能用"之间的分水岭。

更稳的做法是把按键状态拆成"悬停"和"触发"两级:手指进入按钮区域只是悬停,捏合动作才触发,触发后必须等手指抬起来(距离超过阈值)或者离开按钮才复位。再补一个冷却帧数,防止手抖在边界来回横跳时连续触发。

class Button: def __init__(self, pos, text, size=(85, 85)): self.pos = pos self.size = size self.text = text self.hover = False self.armed = True # 是否处于"可触发"状态 self.cooldown = 0 # 触发后锁定的剩余帧数

主循环里维护cooldown:

for btn in button_list: if btn.cooldown > 0: btn.cooldown -= 1 inside = (x < px < x + w) and (y < py < y + h) if inside and btn.armed and btn.cooldown == 0 and length < 35: final_text += btn.text btn.armed = False btn.cooldown = 8 # 锁 8 帧,大约 0.25 秒 if not inside or length > 50: # 离开按钮或松手,重新武装 btn.armed = True

armed/cooldown这套写法在别的地方也用得上,比如手势切场景、握拳触发截图。它的本质是把"瞬时状态"翻译成"边沿触发",凡是靠摄像头输入的功能,不做这一步基本都不可用。文字栏用cv2.putText贴在画面底部一条半透明底框上,底框用cv2.rectangle加cv2.addWeighted叠出来,比裸文字在复杂背景下的可读性高一个档次。

4. 姿态检测与多画面拼接:33 点骨架怎么变成角度和计数

4.1 PoseDetector 的 33 点与 findPosition 的返回结构

手部是 21 点,人体是 33 点,编号体系可以分成四组记:0~10 是脸和上半身主干(0 鼻、11 左肩、12 右肩),11~22 是双臂(13 左肘、14 右肘、15 左腕、16 右腕),23~28 是双腿主干(23 左髋、24 右髋、25 左膝、26 右膝、27 左踝、28 右踝),29~32 是脚跟和脚尖。做动作识别和角度计算,常用的就是肩-肘-腕、髋-膝-踝这几组三元组。

from cvzone.PoseModule import PoseDetector detector = PoseDetector(staticMode=False, modelComplexity=1, smoothLandmarks=True, enableSegmentation=False, smoothSegmentation=True, detectionCon=0.5, trackCon=0.5) img = detector.findPose(img, draw=True) # 在原图上画骨架,返回画好的图 lmList, bboxInfo = detector.findPosition(img, draw=False) # 拿坐标,不重复画

enableSegmentation默认关着,一旦打开,模型会额外跑一个人像分割头,帧率直接掉一半。它只在需要"抠掉背景换虚化"时才值得开,纯角度计算别碰。smoothLandmarks=True是姿态模块比手部模块多出来的东西,它会对关键点做时间平滑,好处是抖动小,代价是快速动作会有轻微延迟——做深蹲计数这种慢动作是赚的,做拳击动作就是亏的。

和手部模块一样,findPosition返回的也是像素坐标[id, x, y],可以直接拿去画线、算距离。区别在于姿态的lmList在遮挡时可能返回空列表,或者长度不足 33 项,所以遍历之前除了判空,还得判长度:

if lmList and len(lmList) >= 33: # 安全访问膝、踝等靠后的点 pass

这行长度判断是从血泪经验里来的:半身出画的时候,模型只检测到上半身 25 个点左右,代码里访问lmList[27]直接IndexError,程序当场退出。

4.2 getAngle 三点夹角:从向量到计数

PoseDetector自带的getAngle(p1, p2, p3, img=None, draw=True)返回三个关键点在p2处的夹角,单位是度,p2是顶点。算深蹲时传髋 23、膝 25、踝 27,膝盖弯曲角度就出来了。

# 左右腿各算一次,取平均更稳 angle_l, img = detector.getAngle(23, 25, 27, img=img, draw=True) angle_r, img = detector.getAngle(24, 26, 28, img=img, draw=True) knee_angle = (angle_l + angle_r) / 2

想看它内部到底怎么算的,手写一遍就清楚了,核心是两次atan2相减:

import math def angle_3pts(a, b, c): """以 b 为顶点的夹角,a/c 为两端点,输入为 (x, y),输出角度制""" ang = math.degrees( math.atan2(c[1] - b[1], c[0] - b[0]) - math.atan2(a[1] - b[1], a[0] - b[0]) ) ang = abs(ang) return ang if ang <= 180 else 360 - ang

atan2返回的是[-π, π]区间的方向角,两个方向角相减得到夹角,再折算到 0~180 度。这个函数对坐标顺序敏感,b必须是顶点,把顶点和各端点搞反,算出来的永远是补角,深蹲会出现"蹲到底显示 30 度"的诡异现象。

拿到角度之后做计数,只靠阈值判断一样会疯狂累加,这里还是状态机:

stage = "up" count = 0 if knee_angle > 160: stage = "up" # 站直了,重新武装 if knee_angle < 90 and stage == "up": stage = "down" count += 1 # 完成一次下蹲

两个阈值之间留出 70 度的回差,是故意不写成>150和<90的对称值。回差太小,人在临界角度附近微动,计数会像抽风一样往上跳;回差给足,宁可漏掉一次半次的边缘动作,也不要多计。同样的套路可以用在手臂弯举、俯卧撑、开合跳上,只换关键点编号和阈值。

4.3 stackImages 把多路画面拼成一张

合辑里几个场景经常要把原图、骨架图、分割掩码并排显示对比效果,cvzone.Utils.stackImages就是干这个的:

from cvzone.Utils import stackImages # scale 是缩放比例,imgArray 支持一维列表(单行)或二维列表(宫格) imgStack = stackImages(0.6, ([img_raw, img_pose], [img_mask, img_blend])) cv2.imshow("compare", imgStack)

第一个参数scale是统一缩放系数,传 0.5 就是整体缩小一半。第二个参数的结构决定了排布:一维列表横着排一行,二维列表按行排成网格。拼接前所有图的尺寸必须一致,尺寸不同的那张会被拉伸或者直接报错,所以稳妥的写法是先cv2.resize到同一个尺寸再传进去。

如果导入stackImages时报ImportError,说明 cvzone 版本较老,这个函数不在cvzone.Utils里。它本身只有十几行,自己写一个更省事:按行np.hstack,再把每行np.vstack起来就行。依赖库里这种纯工具函数,与其跟版本较劲,不如抄进自己的utils.py。

5. 避坑与常见问题:摄像头、重复触发和坐标错位

5.1 摄像头打开黑屏或首帧卡两三秒

现象:程序跑起来,imshow窗口一片黑,等好几秒才出画面,有时干脆一直黑。

原因:多半是后端没指定,OpenCV 在 Windows 上默认走 MSMF,枚举和初始化都比较慢;另一种情况是摄像头被会议软件占着,cap.read()一直返回False。

解决:VideoCapture(0, cv2.CAP_DSHOW)指定后端,启动时间能压到一秒内;cap.read()返回False时直接break,不要在外面套while重试,那样只会把黑屏变成卡死。另外,cap.release()一定要放在try/finally或者退出分支里,程序异常退出没释放,下次运行大概率占不到设备。

5.2 同一个字符被重复输入几十遍

现象:虚拟键盘按一次键,文字栏刷出一长串相同字符。

原因:主循环每秒 30 帧,捏合动作持续十几帧,每一帧都满足"距离小于阈值"的条件。

解决:加armed/cooldown状态机,触发一次后锁若干帧,并且必须等手指抬起或离开按钮才重新允许触发。只加时间冷却不加"松开才算复位",手停在按钮上不动还是会重复,两个条件得同时满足。

5.3 左右手的手指状态是反的

现象:左手比"大拇指朝上",fingersUp()返回的结果和右手完全相反,拇指那一位判断错了。

原因:fingersUp()内部对拇指只比较了 x 坐标,没有根据左右手做镜像补偿。

解决:先看hand["type"]是"Left"还是"Right",对左手把拇指那一位取反;或者干脆在取帧之后统一cv2.flip(img, 1),让画面的手性和惯用手一致,再做判定。这个坑在做"拇指点赞"这类交互时特别隐蔽,因为其他四根手指的判断是对的,只有第一位错。

5.4 帧率掉到个位数

现象:刚跑起来 30 帧,切到姿态检测或者开了分割,直接掉到 5 帧,画面像 PPT。

原因:modelComplexity开着最高档、enableSegmentation=True、每帧都做全尺寸原图绘制、多路画面没缩放就hstack,几个因素叠加。

解决:按收益从高到低排——先关分割,再把手部分辨率降到 640×480 喂给检测器(检测完再把坐标按比例映射回 1280×720 画图,精度损失很小),最后把modelComplexity降到 0。绘制上,能用cv2.line就别画圆,cv2.putText的字号别超过 4。

5.5 坐标对得上但画面错位

现象:手指明明点在按钮 B 上,程序判定成按钮 A,或者所有点都往左上角偏。

原因:lmList里的坐标是"喂给检测器的那一帧"的像素坐标。如果先resize再送去检测,返回的坐标就是小图上的坐标,直接拿去画在大图上必然偏。

解决:检测用原图,或者 resize 之后按原宽/缩放后宽的比例把坐标映射回去;另一种常见错因是把lmList[i][1]当成 y、[2]当成 x,索引写反了。养成一个习惯,取到点之后先打印一次坐标和画面尺寸,对着看一眼就知道是映射问题还是索引问题。

6. 二次开发:把 Demo 改成随手能用的小工具

这套合辑要往上走,最有价值的改造不是换更复杂的模型,而是把耦合拆开、把输入通道做稳。

第一件事是把手势层和业务层彻底分开。现在每个 Demo 里都写着if fingers == [0, 1, 0, 0, 0]:这种裸判断,场景一多就没法维护。我一般会先抽一个手势映射表出来:

# modules/gesture.py GESTURES = { (0, 1, 0, 0, 0): "pointer", # 只伸食指 (0, 1, 1, 0, 0): "peace", # 食指 + 中指 (1, 1, 1, 1, 1): "open_palm", # 五指张开 (0, 0, 0, 0, 0): "fist", # 握拳 } def resolve(fingers, hand_type="Right"): key = tuple(fingers) if hand_type == "Left": key = (1 - key[0],) + key[1:] # 左手拇指位取反 return GESTURES.get(key, "unknown")

这样上层只认pointer/peace这些语义名,改手势定义只动一张表。第二件事是把设备抽象出来——把cv2.VideoCapture(0)换成一个能传文件路径的工厂函数,调参时录一段视频离线跑,同一段输入反复调阈值,比对着摄像头原地挥手高效十倍,也解决了"这个参数下效果到底变好没有"没法复现的问题。

def open_source(src=0, w=1280, h=720): if isinstance(src, str): return cv2.VideoCapture(src) # 传路径就是离线视频,方便复现调参 cap = cv2.VideoCapture(src, cv2.CAP_DSHOW) cap.set(cv2.CAP_PROP_FRAME_WIDTH, w) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h) return cap

第三件是做一件事收一个工具,别贪多。虚拟键盘可以直接扩成带多页切换的版本(手势滑一下切大小写和符号页),姿态检测可以扩成动作计数器(深蹲、开合跳各一个状态机),手势识别可以映射成媒体控制——拇指食指捏合算播放/暂停,食指左右划算上一首下一首。这几类改造共用一个主循环,只是if lmList:之后走不同分支。

最后说一个验证习惯。摄像头输入是实时的,调参时很容易出现"感觉这次好多了"的错觉,实际上只是手的位置换了。我现在的做法是每个改动都先跑一遍录好的测试视频,记下三个数:触发成功率、误触发次数、平均帧率,三个数里有两个变差就回滚。从那以后我每次改判定阈值,都先强制走一遍离线视频回归,再上摄像头手测,图个安心。希望这套拆法能帮到你,把合辑里的几个 Demo 变成自己顺手的小工具。

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

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

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

立即咨询