简介:面向学习计算机视觉与图像处理的本科生、课程设计者及Python初学者,这份材料围绕摄像头实时手势识别展开,涵盖调用摄像头、按帧读取、形态学处理、镜像翻转、固定区域手势框选取、HSV肤色检测、高斯滤波、轮廓提取,以及手势凹凸点与指间角度计算的完整算法链路,可作为课程设计或毕业项目的参考实现。压缩包共67个文件,以30个py源码、26个pyc编译文件为主,另含6张运行结果图、依赖whl安装包及license等,整体约42.46MB,源码与调试图并存,便于对照理解各处理环节。已有1893人学习,读者可据此复现实验环境、梳理OpenCV手势识别的处理流程,并借鉴轮廓与角度求取的实现思路,快速搭建自己的识别程序并排查常见问题。
1. 摄像头一开,掩膜就开始“晃”
最常见的翻车现场是这样的:摄像头打开,左边原始画面,右边二值掩膜,手刚抬起来,掩膜里手掌和身后的米色墙一起变白;手一放下,五根手指糊成一坨,数出来的数字在 0 和 5 之间来回跳。这套 Python + OpenCV 手势识别算法设计源代码材料,处理的就是这条链路:BGR 取帧、肤色分割、形态学去噪、轮廓与凸包、凸缺陷数手指,最后映射成手势语义。它不调现成模型,讲的是一套可解释、在普通笔记本 CPU 上能跑到 25 帧以上的几何算法。适合拿它做交互 Demo 的开发者,也适合想把 OpenCV 轮廓体系摸透的人,PyCharm 打开即可运行,不需要显卡和权重文件。
2. 工程结构与技术选型:一个能跑的手势识别工程长什么样
先把整体结构说清楚,否则后面看代码容易迷路。这个工程走纯传统图像处理路线,没有训练环节,所有判定都来自几何量:面积、实心度、凸包、凸缺陷深度和夹角。好处是每一帧的中间结果都能画出来看,坏处是对光照和背景极敏感,所以预处理和参数标定占了将近一半的工作量。这也是我建议先读完这一章再看算法部分的原因:算法本身三十行,剩下的全是给它铺路。
2.1 算法路线选型:为什么不用直接上 21 关键点
站在工程角度,手势识别大致有三条路,选哪条取决于你手上有什么资源,而不是哪个“更先进”。
| 方案 | 外部依赖 | 精度表现 | 适用场景 |
|---|---|---|---|
| 肤色分割 + 凸缺陷 | 仅 OpenCV、numpy | 中等,依赖背景干净 | 教学、算法演示、固定工位 |
| 自训练 CNN 分类 | 需要标注数据与权重 | 高,抗背景干扰 | 产品化、多光照 |
| 关键点回归模型 | 需要模型文件与推理框架 | 很高,能出手势骨架 | 复杂交互、AR 类 |
这份材料走的是第一条。原因很实际:整套逻辑不依赖任何二进制模型,读者改一个阈值就能看到效果变化,适合作为“算法设计”的交付物;凸缺陷数手指的过程本身就是一个完整的几何推理案例,讲得清、画得出。代价也很明确,背景里只要有和肤色接近的东西,分割就会破功,后面第五章的坑基本都出在这里。
我的建议是把它当作“基线”,先跑通再谈升级。基线能到 80% 左右的稳定识别,你才知道后面换分类器时到底提升了多少,否则连对照组都没有。
2.2 目录结构与每个文件负责什么
工程没有做成单文件脚本,而是按职责拆开,方便你只改其中一层。目录大致是这样的:
gesture-recognition/ ├── main.py # 入口:取帧、串流程、显示 ├── config.py # 所有可调参数集中在这里 ├── requirements.txt ├── src/ │ ├── __init__.py │ ├── capture.py # 摄像头封装,处理打开失败与分辨率 │ ├── preprocess.py # 高斯模糊 + 颜色空间转换 + 二值化 │ ├── contour.py # 找最大轮廓、凸包、实心度筛选 │ ├── gesture.py # 凸缺陷计算、手指计数、手势映射 │ └── smoother.py # 时间维度的多数投票平滑 ├── data/ │ └── samples/ # 存单帧截图,方便离线调参 └── tests/ └── test_geometry.py # 对纯几何函数做单元测试config.py单独拎出来是关键设计。手势识别调参极其频繁,阈值散落在各个函数里会导致“改一处忘一处”,最后自己都记不清哪组参数是能跑的。把所有魔法数字收进一个类,配合data/samples/里存的截图做离线回放,调参效率会高很多。
capture.py这层封装也别省。不同系统下摄像头索引和后端差异很大,把cv2.VideoCapture的重试逻辑、分辨率设置、释放逻辑收在一处,主流程里就不会出现一堆判断。
2.3 PyCharm 里的解释器与依赖配置
依赖只有两个,但版本要留意。OpenCV 4.5 之后convexityDefects对输入轮廓的 dtype 要求变严,老教程里那种float32轮廓会直接抛异常。
# 建议单独建虚拟环境,别装到系统 Python 里 python -m venv .venv # Windows .venv\Scripts\activate # macOS / Linux source .venv/bin/activate pip install "opencv-python>=4.5" "numpy>=1.21" pip freeze > requirements.txtPyCharm 里在Settings → Project → Python Interpreter选择刚建的.venv,别用系统解释器。装完在 Python Console 里跑一句import cv2; print(cv2.__version__)确认能导入,再跑cv2.VideoCapture(0).isOpened()确认摄像头可用。两步都过了再动代码,能省掉后面一半的“这代码是不是坏的”的怀疑。
提示:如果
cv2.VideoCapture(0)返回 False,先别改代码,用系统自带的相机应用确认硬件本身能被占用,再回来排查索引和后端。
3. 预处理链路:从 BGR 帧到一张干净的手部掩膜
预处理决定了这套算法的上限。后面数手指的准确率再高,如果掩膜里手掌缺一块、背景多一块,凸缺陷的几何关系就全乱了。这一层要做的事只有三件:把肤色区域挑出来、把噪点和小洞补掉、把最大的一坨轮廓选出来。每一步的参数都有物理含义,不是随便填的。
3.1 颜色空间:HSV 和 YCrCb 该信哪个
RGB 三个通道都和亮度强相关,直接设阈值遇到灯光变化就崩。常用做法是转到对亮度不敏感的空间,HSV 和 YCrCb 是两大主流。HSV 的 H 通道直观,但肤色在 H 上的分布跨越了红色和橙色两个区间,阈值不好收;YCrCb 把亮度 Y 单独剥离,肤色在 Cr(红差分)和 Cb(蓝差分)上聚成一个相对紧凑的椭圆区域,阈值更容易定。
我一般用 YCrCb,范围大概是:
| 通道 | 下限 | 上限 | 含义 |
|---|---|---|---|
| Y | 0 | 255 | 亮度,完全不限制 |
| Cr | 133 | 173 | 红差分,偏红偏高 |
| Cb | 77 | 127 | 蓝差分,偏低 |
这组数值不是万能公式,跟摄像头白平衡强相关。同一间屋子里,暖光灯和冷白灯下 Cr 的分布能差 10 以上,所以config.py里必须留出可调接口。
# src/preprocess.py import cv2 import numpy as np from config import Config def skin_mask(frame_bgr): """把 BGR 帧转成肤色二值掩膜,白色为疑似手部区域""" # 5x5 高斯先压掉传感器噪点;核再大手指缝隙会被糊死 blurred = cv2.GaussianBlur(frame_bgr, (5, 5), 0) # 只用 Cr/Cb 两个通道判肤色,Y 通道全部放开 ycrcb = cv2.cvtColor(blurred, cv2.COLOR_BGR2YCrCb) lower = np.array(Config.YCRCB_LOWER, dtype=np.uint8) # (0, 133, 77) upper = np.array(Config.YCRCB_UPPER, dtype=np.uint8) # (255, 173, 127) mask = cv2.inRange(ycrcb, lower, upper) return maskcv2.inRange做的是逐通道闭区间判断,三个通道同时落在范围内才置 255。注意np.array必须转uint8,OpenCV 4.x 对类型很敏感,传int64会报类型不匹配。高斯核用奇数,5x5 是平衡点,3x3 压不住噪点,7x7 会让细手指变粗。
3.2 滤波与形态学:参数不是拍脑袋
inRange出来的掩膜一定是脏的:手掌上有反光亮点造成的空洞,背景里零散的小白块。开运算去小白块,闭运算补小洞,这两个顺序不能反。
# src/preprocess.py(续) def clean_mask(mask): # 椭圆核比矩形核更贴合手部轮廓,边缘不会出现方块感 k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (Config.OPEN_K, Config.OPEN_K)) k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (Config.CLOSE_K, Config.CLOSE_K)) # 先开后闭:先去孤立噪点,再补手掌内部的洞 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close) # 中值滤波抹掉边缘毛刺,让后面凸包更贴合 mask = cv2.medianBlur(mask, Config.MEDIAN_K) return mask开运算核取 5,闭运算核取 7,这是我反复试出来的经验值。开运算核太大(比如 9)会把细长的手指直接削掉一截,手指一短,凸缺陷的深度就达不到阈值,数出来的数字少一根。闭运算核太大则会把手指之间的缝隙填平,五指张开和握拳在掩膜上长得一模一样,直接废掉整套判定。
参数调整的正确顺序是:先截一张能代表你实际环境的图存到data/samples/,然后离线跑skin_mask和clean_mask,只调这两步,调到手部掩膜边缘连续、缝隙清晰,再去碰后面的算法。反过来调会让你在“是分割问题还是判定问题”之间反复横跳。
3.3 轮廓筛选:面积、实心度与 ROI 裁剪
掩膜干净了,findContours可能还是返回七八个轮廓:手是一个,衣服袖口一个,背景杂物几个。靠面积取最大是最省事也最有效的办法,但要加两道保险。
# src/contour.py import cv2 def find_hand_contour(mask): # RETR_EXTERNAL 只要最外层,避免手掌内部空洞被当成轮廓 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None cnt = max(contours, key=cv2.contourArea) area = cv2.contourArea(cnt) # 面积门槛:低于这个值基本是噪点,不是手 if area < Config.MIN_AREA: return None # 实心度 = 轮廓面积 / 凸包面积,用来排除细长条 hull = cv2.convexHull(cnt) hull_area = cv2.contourArea(hull) if hull_area <= 0: return None solidity = area / hull_area if solidity < Config.MIN_SOLIDITY: return None return cntMIN_AREA和分辨率强相关。640x480 下手臂伸到镜头前大约是 15000 到 40000 像素,我一般取 8000 作门槛,低于这个值宁可判定为“无手”,也不要硬算——噪点算出来的凸缺陷毫无意义。
solidity(实心度)是用来筛形状的。张开的手掌实心度大概 0.6 到 0.8,握拳接近 0.95,而一条斜穿过画面的衣袖实心度会低于 0.4。设一个 0.5 的下限,能挡掉大部分“一条长条形物体”的误检。
注意:如果实际场景里手离镜头很近、经常贴边,建议在预处理阶段就裁一个 ROI 矩形,只在这个矩形里做分割。贴边的手轮廓会被画面边界截断,凸包算出来是畸形的,凸缺陷数量必然错。
4. 手势判定核心算法:凸包缺陷数手指到语义映射
真正数手指的部分短得出人意料,难点全在怎么理解convexityDefects返回的那组数据。它的思路很朴素:把手部轮廓想象成一根橡皮筋绷紧的形状(凸包),实际轮廓比凸包凹陷下去的地方就是手指缝。每两个相邻手指之间有一个凹陷,凹陷的数量加一就是伸出的手指数。
4.1 convexityDefects 的四个返回值怎么读
这个函数返回的是一个 N×1×4 的数组,每一行描述一个凹陷:
# src/gesture.py import cv2 import math def find_defects(cnt): # 注意 returnPoints=False,返回的是凸包点在轮廓里的下标而不是坐标 hull_idx = cv2.convexHull(cnt, returnPoints=False) if hull_idx is None or len(hull_idx) < 3: return [] try: defects = cv2.convexityDefects(cnt, hull_idx) except cv2.error: # 轮廓点太少或共线时会抛异常,直接当作无凹陷 return [] if defects is None: return [] result = [] for i in range(defects.shape[0]): s, e, f, d = defects[i, 0] start = tuple(int(v) for v in cnt[s][0]) # 凹陷起点,在凸包上 end = tuple(int(v) for v in cnt[e][0]) # 凹陷终点,在凸包上 far = tuple(int(v) for v in cnt[f][0]) # 凹陷最深处的轮廓点 depth = d / 256.0 # 定点数转像素,必须除 256 result.append((start, end, far, depth)) return result四个值里最容易踩坑的是最后一个d。OpenCV 内部用定点数存浮点,实际深度是d / 256.0,单位是像素。很多老代码直接拿d和几十、几百去比,结果一个凹陷都筛不出来,然后怀疑人生。我见过不止一个人在这卡了半天。
另一个细节是returnPoints=False。传True返回的是凸包顶点的坐标,传False返回的是这些点在原轮廓数组里的索引。convexityDefects要的是索引,传错了直接报错或给出错误结果。
4.2 深度与夹角双阈值过滤
原始凹陷不能全信。手指微微弯曲、轮廓毛刺、手腕处的自然弧度都会产生凹陷,只靠深度过滤不够,还要看夹角。
# src/gesture.py(续) def count_fingers(cnt): defects = find_defects(cnt) fingers = 1 # 只要检测到手,至少算一根(握拳的情况单独处理) valid = [] for start, end, far, depth in defects: # 深度小于 18 像素的凹陷是轮廓噪声,不是指缝 if depth < Config.MIN_DEPTH: continue # 余弦定理算凹陷处的夹角,指缝处夹角通常小于 90 度 a = math.hypot(end[0] - start[0], end[1] - start[1]) b = math.hypot(far[0] - start[0], far[1] - start[1]) c = math.hypot(far[0] - end[0], far[1] - end[1]) if b * c == 0: continue cos_val = (b * b + c * c - a * a) / (2 * b * c) cos_val = max(-1.0, min(1.0, cos_val)) # 防浮点误差越界 angle = math.degrees(math.acos(cos_val)) if angle > Config.MAX_ANGLE: continue valid.append((start, end, far, depth, angle)) fingers += 1 return min(fingers, 5), validMIN_DEPTH取 18 像素、MAX_ANGLE取 90 度,这两个数是配对的。只看深度,手腕处那个大弧度凹陷深度轻松超过 40 像素,会被误算成指缝;加上夹角约束,手腕处夹角接近 150 度,直接被刷掉。反过来只卡夹角不看深度,手指缝里那些浅毛刺会被算进来。两个条件同时成立才计数,误判率能降一大截。
cos_val那行钳位不能省。浮点计算中(b²+c²-a²)/(2bc)有可能算出1.0000000002,math.acos直接抛ValueError,程序当场挂掉,而且这种偶发崩溃极难复现。
提示:
MIN_DEPTH要和你的画面分辨率绑定。如果你把采集分辨率改成 1280x720,深度阈值基本要翻倍,否则一个指缝都过不了。
4.3 从手指数到手势语义映射
数出手指数之后,还要处理两个边界情况:握拳(0 个有效凹陷但手确实在画面里)和手指并拢。
# src/gesture.py(续) GESTURE_MAP = { 0: "fist", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", } def classify(cnt, fingers): # 实心度接近 1 说明轮廓几乎等于凸包,五指并拢或握拳 hull = cv2.convexHull(cnt) hull_area = cv2.contourArea(hull) solidity = cv2.contourArea(cnt) / hull_area if hull_area > 0 else 0 if solidity > Config.FIST_SOLIDITY: # 0.92 return "fist" return GESTURE_MAP.get(fingers, "unknown")光靠单帧判定,输出还是会抖。加一层时间平滑,用固定长度的队列做多数投票,成本极低但效果立竿见影:
# src/smoother.py from collections import deque, Counter class Smoother: def __init__(self, window=7): # 存最近 window 帧的判定结果 self.buf = deque(maxlen=window) def update(self, label): self.buf.append(label) # 取出现次数最多的标签,平票时 Counter 返回先出现的 return Counter(self.buf).most_common(1)[0][0]窗口取 7,对应 30fps 下大约 0.23 秒的延迟。窗口太大响应会拖沓,抬手后要等一下才变;窗口太小等于没做平滑。7 是我试下来响应和稳定的平衡点。
| 手指数 | 判定条件 | 语义标签 |
|---|---|---|
| 0 且实心度 > 0.92 | 轮廓贴近凸包 | fist |
| 1 | 0 个有效凹陷 | one |
| 2 | 1 个有效凹陷 | two |
| 3 | 2 个有效凹陷 | three |
| 4 | 3 个有效凹陷 | four |
| 5 | 4 个及以上有效凹陷 | five |
5. 常见问题排查:摄像头、阈值和窗口刷新这些坑
这套代码在别人机器上跑不起来,九成不是算法问题,而是环境、参数或窗口交互。下面四条是我被问得最多、也是我自己踩过的。
5.1 摄像头打不开,isOpened()一直是 False
现象:PyCharm 里一运行就抛Assertion failed或者黑屏,cap.isOpened()返回 False,但系统相机应用能正常打开。
原因:多数是索引和后端问题。笔记本往往有多个视频设备(内置摄像头、外接 USB 摄像头、虚拟摄像头),索引 0 不一定是你想用的那个。Windows 上还有 MSMF 后端和摄像头驱动不兼容的老问题。
解决:先遍历索引找到能用的设备,再显式指定后端。下面的写法在 Windows 上比默认后端稳定得多:
import cv2 def open_camera(max_index=4): for idx in range(max_index): # Windows 上加 CAP_DSHOW,绕开 MSMF 的兼容问题 cap = cv2.VideoCapture(idx, cv2.CAP_DSHOW) if cap.isOpened(): cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) return cap cap.release() raise RuntimeError("没有找到可用摄像头")CAP_DSHOW只对 Windows 有意义,其它平台传常量会被忽略。分辨率要在isOpened()之后设置,顺序反了不生效。
5.2 掩膜在手掌和背景之间来回跳
现象:手不动,掩膜里的白色区域却一帧大、一帧小,背景的墙或桌面时不时被当成手。
原因:摄像头的自动曝光和自动白平衡在持续调整,YCrCb 的 Cr/Cb 分布跟着漂移,固定阈值必然对不上。另外如果背景里有木色桌面、米色墙面这类和肤色接近的颜色,阈值再准也分不开。
解决:三层处理。第一,手动锁定曝光和白平衡,大多数摄像头支持CAP_PROP_AUTO_EXPOSURE相关属性;第二,在外层加一个固定的 ROI 矩形,只在这个框里做分割,把背景挡在外面;第三,在data/samples/里存当前环境的截图,离线调阈值,调好再上线。ROI 这一招最土但最有效,代价是手必须待在框里,交互设计上要提前说清楚。
5.3 手指数乱跳,帧率掉到个位数
现象:同一个手势,帧间判定的结果在 2、3、4 之间反复横跳;同时imshow的窗口越来越卡。
原因:抖动来自单帧判定没有时间约束,几何量在小范围内波动就会跨过阈值。掉帧则多半是每帧都在做重复的大计算,比如在循环里反复getStructuringElement创建核,或者对全分辨率图像做多次颜色空间转换。
解决:抖动用第四章那个Smoother做多数投票,先把输出稳住。掉帧先做性能剖析,最典型的三处优化是——形态学核在模块加载时创建一次复用,颜色转换前先把画面缩到 640 宽,findContours只在掩膜非空时调用。这三处改完,一般能从中个位数帧率回到 25 帧以上。
5.4 窗口无响应、文字显示乱码
现象:程序没报错,但imshow出来的窗口是灰的、拖不动,或者putText画出来的中文全是问号。
原因:cv2.waitKey的返回值没接收或参数传 0,事件循环被卡死。中文乱码是因为cv2.putText只支持 ASCII,底层用的是 Hershey 字体,压根没有中文字形。
解决:waitKey必须调用且参数给 1 到 30 之间的值,同时要接收返回值判断退出键:
while True: ok, frame = cap.read() if not ok: break # ... 处理逻辑 ... cv2.imshow("frame", frame) key = cv2.waitKey(1) & 0xFF if key == ord("q"): break cap.release() cv2.destroyAllWindows()中文显示别改putText,改用 PIL 的ImageDraw配合系统字体,把 OpenCV 的 BGR 帧转成 RGB 再交给 PIL 绘制,画完转回来。这套转换每帧有额外开销,只在需要显示中文标签时用,别放进主循环里对所有帧都做。
5.5 一个可复用的排查顺序
遇到“结果不对”,按这个顺序走,比乱改参数快得多:先看原始帧是不是正常(有没有过曝、模糊);再看掩膜是不是完整覆盖手部且缝隙清晰;然后看最大轮廓选对了没有,把轮廓和凸包用drawContours画在原图上确认;最后才看凸缺陷的深度和夹角分布。前两步错了,后面怎么调都是白费。我习惯在main.py里加一个调试开关,按d键切换显示中间结果,四张图并排看,定位问题的速度比读日志快。
6. 二次开发的改造思路:把规则判定换成可训练的分类器
如果你已经把这套流程跑通,下一步大概率会撞上准确率天花板,尤其是背景复杂或者光线不稳定的场景。这时候的改造方向不是继续微调阈值,而是把“几何规则判定”换成“特征 + 分类器”。核心思路是保留前面的预处理和轮廓提取,只替换最后的classify那一层,这样改造量可控,也能直接对比效果。
具体做法是把凸缺陷的统计量整理成一个特征向量,比如有效凹陷数量、平均深度、最大深度、平均夹角、实心度、轮廓面积与凸包面积之比、轮廓的 Hu 矩前四个分量。这些量加起来十几维,喂给cv2.ml.KNearest或者 sklearn 的 SVM 都够用。采集数据的方式很土:写一个采集脚本,每个手势按空格存一帧特征到 CSV,每个类别采两百帧,不同光照、不同距离各来一些。
import cv2 import numpy as np def to_feature(cnt, defects, solidity): """把一帧的几何量拼成定长特征向量,供 KNN/SVM 使用""" depths = [d for _, _, _, d in defects] or [0.0] angles = [] feats = [ len(defects), # 有效凹陷数 float(np.mean(depths)), # 平均深度 float(np.max(depths)), # 最大深度 solidity, # 实心度 ] hu = cv2.HuMoments(cv2.moments(cnt)).flatten()[:4] # Hu 矩跨越多个数量级,先取对数压缩动态范围 hu = [-np.sign(v) * np.log10(abs(v) + 1e-10) for v in hu] return np.array(feats + hu, dtype=np.float32)这里有个容易忽略的点:Hu 矩的数值范围从 1e-10 到 1e-1 都有,直接扔进 KNN 会被欧氏距离主导,必须取对数压缩。特征拼好后统一做标准化(减均值除标准差),训练集和推理端用同一组参数,否则线上和离线表现会对不上。
改造时有三个验证动作建议固定下来。第一,留出一个完整场景(比如换一间屋子)作测试集,别用训练集里的帧自测,那样准确率虚高得离谱。第二,混淆矩阵一定要打出来,看错误集中在哪些类别之间——常见的是“三指”和“四指”互串,说明角度阈值切得不够开。第三,记录每类的召回率而不是只看总体准确率,握拳样本多的时候总体准确率会被拉高,但实际用起来“五指”识别不到。
另一条更省事的路线是直接引入关键点检测,用现成的 21 点手部骨架模型替掉整个凸缺陷逻辑,判定改成数“伸直的手指”。这条路准确率高很多,代价是多一个模型依赖、推理耗时上升,而且需要处理模型文件的路径问题。如果你的项目能接受这个依赖,我建议直接上;如果交付要求是纯算法、可解释,那就老老实实走特征 + 分类器。
我自己在这类项目上吃过一次亏:早期版本全部靠手感调阈值,换一台机器演示就崩,现场只能硬着头皮解释“光线问题”。从那以后我每次交付前都强制走一遍固定流程:换两个不同光照的环境、用离线截图回放跑一遍全流程、把中间结果四图并排存下来对比。这套动作花不了十分钟,但能挡掉绝大多数演示当场的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取