Python+OpenCV手势识别:凸缺陷、肤色分割与时序稳定化
简介:面向学习计算机视觉与图像处理的学生及开发者,这套基于Python与OpenCV的手势识别课程设计源码围绕摄像头逐帧采集、镜像翻转、HSV肤色检测、高斯滤波、形态学处理和轮廓提取展开,并进一步求取手势凸包与凹凸点,通过手指间角度判断手势形态,适合课程设计、毕业设计原型和视觉算法入门练习。压缩包共67个文件,约42.46MB,包含30个py源码、26个pyc字节码、6张png调试图,以及license、aip_client和opencv_python的whl安装包,便于直接运行、对照调试与复现实验结果,调试截图也可用于核对各阶段输出。已有1894人学习,源码保留基础版与修改版脚本,可帮助读者理解识别框绘制、图像预处理、轮廓筛选和角度求取流程,并据此完成功能改进与排错,对课程设计报告撰写和答辩演示也有参考价值。
1. 手势识别为什么要先数凸缺陷:一条从像素到“几根手指”的判定链
很多人第一次接触 Python + OpenCV 做手势识别,会本能地以为背后一定有个模型在跑。但这套源码里没有一行训练代码,它靠的是一件反直觉的事:把“伸出手指”这个动作,翻译成手掌轮廓上凹进去的几道缝。深究下去,整条链路其实只有四步——图像处理把肤色从背景里抠出来,轮廓提取拿到手的外边界,凸包与凸缺陷算出凹陷数量,规则引擎把凹陷数映射成手势语义。真正决定成败的不是最后那几行 if,而是中间那一步:凸缺陷。理解了它,你才知道为什么手掌正对镜头时识别很准,手一斜就立刻失灵;也才知道该去调哪个参数,而不是盲目改阈值。这份源码值得逐行读的原因,就藏在这条链路的每一环里。
2. HSV 与 YCrCb 肤色分割:为什么 RGB 阈值一换光照就翻车
2.1 RGB 三个通道为什么要拆开:相关性与亮度耦合
如果直接在 BGR 空间里给肤色定一个区间,比如 R 在 95 到 220 之间、G 在 40 到 200 之间、B 在 20 到 150 之间,白天在窗边调好,晚上开个灯就全废。原因不在阈值本身,而在于 R、G、B 三个通道是强相关的:环境光变暗时三个数值同时往下掉,肤色像素会整体滑出你划定的立方体。更要紧的是,RGB 里没有单独的“颜色”维度,亮度变化和色度变化纠缠在一起,你没法只调其中一个。
HSV 的价值就在于把这件事解耦。H 是色相,只表示这个像素落在色环的哪个角度上;S 是饱和度,表示颜色有多深;V 是明度。阴影只会压低 V,对 H 的影响很小,所以给 H 一个宽区间、给 S 和 V 各自一个下限,就能在相当宽的照明范围里稳住。这里有个很容踩的细节:OpenCV 读进来的图像是 8 位三通道,H 通道只能存 0 到 255,但色相实际是 0 到 360 度,所以 OpenCV 把色相折半存进了 0 到 180。你在别处看到的“色相 0 到 360”的教程直接搬过来,红色就会漏掉一半。
YCrCb 是另一条更稳的路。它把亮度 Y 单独拎出来,色度放在 Cr 和 Cb 两个分量上,而且这个变换是线性的,不像 HSV 在低饱和度区域会发生非线性挤压。肤色在 Cr-Cb 平面上聚集得相当紧,经验区间大概是 Cr 在 133 到 173、Cb 在 77 到 127,这个区间对不同人种的覆盖度反而比 HSV 更好。常见的实现思路是两路都算一遍再按位与,用 HSV 挡掉背景里的鲜艳色块,用 YCrCb 压掉高光区域,代价是多一次转换、多几毫秒。
提示:调肤色区间时不要只看最终的黑白图。把 mask 和原图并排显示,观察漏检发生在高光区还是阴影区,这决定了你该放宽 V 的下限还是上限。
2.2 一次 inRange 远远不够:形态学与轮廓筛选的位置
cv2.inRange出来的是一张二值图,但它离“一只手”还差得远。手上会有戒指、指甲高光、汗毛造成的空洞,背景里会有一粒粒和肤色接近的噪点,边缘还会因为压缩伪影变得毛刺。直接拿去提轮廓,你会得到几十个碎块,最大的那个甚至不是手。
处理顺序是有讲究的。先做一次高斯模糊,核大小取奇数,5 或 7 都行,作用是抹掉高频噪点,让后续阈值判断不要被单个像素带偏。然后才是 inRange。接着是形态学:开运算(先腐蚀后膨胀)去孤立白点,闭运算(先膨胀后腐蚀)补内部空洞。核的形状建议用椭圆cv2.MORPH_ELLIPSE而不是矩形,矩形的对角会把手的外轮廓啃出直角,这个直角在后一步的凸包计算里会被误判成凹陷。核的大小和手在画面里占的像素数直接相关:手占画面三分之一时,5×5 够用;手离镜头很近占了半屏,核要加到 9×9 才压得住手背上的纹路。
很多源码里还漏了一步:对 mask 做一次轻微的膨胀。原因是肤色分割天然会把手边缘“吃”进去一两个像素,指尖变圆,凸缺陷的起止点就偏了。
2.3 参数怎么改:分割链路的可复现写法与失败信号
下面这段是分割环节的骨架,参数都写成了常量,方便你一处改全局生效:
import cv2 import numpy as np # HSV 与 YCrCb 双路肤色区间,H 在 OpenCV 中是 0-180 HSV_LOWER = np.array([0, 40, 80], dtype=np.uint8) HSV_UPPER = np.array([25, 255, 255], dtype=np.uint8) YCRCB_LOWER = np.array([0, 133, 77], dtype=np.uint8) YCRCB_UPPER = np.array([255, 173, 127], dtype=np.uint8) def skin_mask(bgr): # 先模糊降噪,核必须是奇数 blur = cv2.GaussianBlur(bgr, (5, 5), 0) hsv = cv2.cvtColor(blur, cv2.COLOR_BGR2HSV) ycc = cv2.cvtColor(blur, cv2.COLOR_BGR2YCrCb) m1 = cv2.inRange(hsv, HSV_LOWER, HSV_UPPER) m2 = cv2.inRange(ycc, YCRCB_LOWER, YCRCB_UPPER) mask = cv2.bitwise_and(m1, m2) # 两路取交集,压掉误检 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 去噪点 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 补空洞 mask = cv2.GaussianBlur(mask, (5, 5), 0) # 柔化边缘 return mask三个参数最关键。HSV_LOWER的第二个分量是饱和度下限,调到 30 以下会把灰白墙面吸进来;第三个分量是明度下限,调到 60 以下暗光环境会把袖口吸进来。HSV_UPPER的第一个分量 25 决定了能覆盖到多少偏黄偏橙的肤色,如果你在暖色台灯下发现手背被漏掉,先动这个值。形态学核从 5 加到 7,效果是噪点更干净但指尖更圆,会直接影响后面凸缺陷的深度。
失败信号要看两张图:如果 mask 里手和桌面连成一片,说明饱和度下限太低,背景被当成了皮肤;如果 mask 里手的中间有黑洞,说明闭运算的核太小,或者明度上限把高光区切掉了。这两种情况在凸包那一步的表现完全不同——粘连会让凸包把桌面一起包进去,缺陷数直接归零;空洞会让轮廓变成多段,最大的那一段可能只是半只手。
3. 凸包与凸缺陷:cv2.convexityDefects 的四个数字到底在说什么
3.1 从轮廓到凸包:为什么必须先丢掉细节
cv2.findContours拿到的是一串边界点,沿顺时针或逆时针走一圈。切换到cv2.RETR_EXTERNAL只取最外层轮廓,cv2.CHAIN_APPROX_SIMPLE只保留拐点,能把点数从几千压到几百,这是后面所有几何计算能在实时帧率下跑起来的前提。如果保留全部点,convexityDefects的耗时会成倍上升,而且噪声点会制造大量假凹陷。
有了轮廓,接下来要判断哪个轮廓是手。最常见的做法是按cv2.contourArea排序取最大的那个,但这个规则在两种场景下会失效:画面里有人脸,脸的肤色面积往往比远处的手更大;或者手臂垂在画面下沿,手的轮廓和手臂连成一条,面积很大但形状完全不对。我一般会加两个过滤条件——面积要落在合理区间,以及轮廓的外接矩形长宽比不能太离谱,一只竖直举起的手宽度远小于高度。
凸包是包含轮廓的最小凸多边形。可以把它理解成:拿一根橡皮筋套在手的轮廓外面松手,橡皮筋形成的形状就是凸包。五指并拢时,轮廓本身就接近凸形,橡皮筋几乎贴着轮廓走;五指张开时,指缝处凹进去,橡皮筋会跨过指尖直接连成一条直线,把凹进去的那块区域隔在外面。这块被隔出来的区域,就是凸缺陷。
这就是整个方案的核心思想:手指张开不张开,等价于轮廓是不是凸的。而“不凸”这件事,是可以被精确量化的。
调用上有两个必须同时满足的条件。第一,凸包必须用returnPoints=False计算,返回的是轮廓点的索引而不是坐标,convexityDefects要的是索引。第二,凸包索引必须来自同一条轮廓,拿别人的索引进来会直接抛异常。这两点在这套源码里最容易看漏,因为函数签名不长,错误又发生在运行期。
3.2 四元组 [起点, 终点, 最远点, 深度] 的几何含义
cv2.convexityDefects返回的是一个 N×1×4 的整型数组。每一行代表一个凹陷,四个数依次是:凹陷所在凸包边的起点索引、终点索引、轮廓上离这条边最远的点索引、以及这个最远点到边的距离。前三个是索引,需要回头去轮廓数组里取出坐标;第四个不是坐标,是定点数。
定点数这一条会坑掉很多人。OpenCV 为了用整型存浮点距离,把真实距离乘以 256 再取整。所以拿到depth之后必须先除以 256.0 才能和像素阈值比较。如果你直接写if depth > 30,结果几乎是所有缺陷都被判定为有效,因为 30 除以 256 还不到 0.12 个像素。
几何上,最远点就是指缝最深的那一点,起点和终点是相邻两个手指的指尖(或指尖附近)。深度反映的是这道缝有多深,指尖分得越开、手指越长,深度越大。方向的判断要靠起止点索引的相对大小:如果起点索引小于终点索引,说明这段凹陷沿轮廓的前进方向走;否则是反向。这个方向信息在后面算角度时要用到,因为三个点的排列顺序会影响余弦定理算出来的角是内角还是外角。
还有一点:convexityDefects只返回真正凹进去的部分,如果轮廓在某条凸包边上完全贴合,这条边不会产生任何记录。所以“缺陷数量为零”不等于“没有手”,它等于“这个手的轮廓是凸的”——握拳、手掌平摊、甚至一段圆柱形的手臂,都符合这个描述。
3.3 深度阈值与余弦定理:把指缝从噪声里筛出来
原始缺陷列表里混着大量垃圾:手腕和手臂交界处的凹陷、手指并拢时指尖之间的浅缝、分割边缘毛刺造成的假凹陷。过滤是这道工序的全部内容,而过滤靠的是三个互相独立的判据。
第一个是深度。除以 256 之后,真实距离小于 20 到 30 像素的凹陷基本可以扔掉,它们多半是边缘噪声。这个阈值要跟着画面分辨率走,640×480 下 25 是个不错的起点,1280×720 下要提到 50 左右。
第二个是角度。把起点、最远点、终点构成一个三角形,用余弦定理算出最远点处的夹角。手指并拢时这道缝很浅,夹角接近 180 度;手指张开时夹角会收缩到 60 度以内。所以angle < 90是一个很自然的过滤条件。
第三个是最远点的位置。手腕处的凹陷深度往往很大、角度也小,光靠前两个判据删不掉。解决办法是先算出掌心的位置——用图像矩cv2.moments求轮廓质心,或者用距离变换找最大内切圆圆心——然后要求最远点必须落在掌心的上方(图像坐标里 y 更小)。手臂在画面下方,这条规则能精准地把手腕凹陷排除。
import math def count_defects(contour, min_depth=25.0, max_angle=90.0): # 凸包必须返回索引,returnPoints 默认 True 会直接报错 hull_idx = cv2.convexHull(contour, returnPoints=False) if hull_idx is None or len(hull_idx) < 3: return 0, [] try: defects = cv2.convexityDefects(contour, hull_idx) except cv2.error: return 0, [] if defects is None: return 0, [] m = cv2.moments(contour) palm_y = m['m01'] / m['m00'] if m['m00'] else 0 # 掌心 y 坐标 valid = [] for i in range(defects.shape[0]): s, e, f, d = defects[i, 0] start = tuple(contour[s][0]) end = tuple(contour[e][0]) far = tuple(contour[f][0]) depth = d / 256.0 # 定点数还原成像素 if depth < min_depth: continue a = math.hypot(far[0] - start[0], far[1] - start[1]) b = math.hypot(far[0] - end[0], far[1] - end[1]) c = math.hypot(start[0] - end[0], start[1] - end[1]) if a * b == 0: continue angle = math.degrees(math.acos(max(-1.0, min(1.0, (a*a + b*b - c*c) / (2*a*b))))) if angle > max_angle: continue if far[1] > palm_y: # 排除手腕下方的凹陷 continue valid.append((start, end, far, depth, angle)) return len(valid), valid代码里max(-1.0, min(1.0, ...))那层夹逼不能省。浮点误差会让括号里的值偶尔溢出到 1.0000002,math.acos直接抛 ValueError,程序在某一帧突然崩掉,而你复现不出来。far[1] > palm_y这个判断基于图像坐标 y 轴向下,掌心在下方,指缝在上方,所以最远点的 y 应当更小。如果你把摄像头倒过来装,这一行就是第一个要改的地方。
4. 从缺陷计数到手势语义:规则引擎与时序稳定化
4.1 手指数为什么是“有效缺陷数 + 1”
一只手有五根手指,张开时指缝有四个,所以有效缺陷数加一就是伸出的手指数。这个公式在五指全开时给出 5,在握拳时因为轮廓接近凸形、缺陷数为零而给出 1。
问题就出在这个 1 上。握拳应该是 0 根手指,公式却给 1。根源是公式的隐含前提——“至少有一根手指伸出来形成轮廓的凸起”。握拳时手掌整体是一个凸形,没有指缝,也就没有缺陷,公式失去了意义。
补丁是引入实心度(solidity):轮廓面积除以凸包面积。五指张开时,凸包把四个指缝的空隙都包了进去,凸包面积明显大于轮廓面积,实心度大概落在 0.6 到 0.8;握拳时橡皮筋紧贴轮廓,实心度接近 0.95 以上。所以规则可以写成:实心度高于 0.9 且有效缺陷为零,判为握拳;否则用缺陷数加一。这一条把 0 和 1 区分开了,也就把“石头剪刀布”里最难分的两个手势分开了。
再往下还有一层。食指单独伸出时缺陷数是一个,但如果大拇指也微微张开,可能变成两个,被判成“二”。这类误判的解决办法不是继续堆阈值,而是看角度分布——真正张开的指缝,夹角通常在 40 到 70 度之间,而误判产生的那道缝往往角度更小、深度更浅。给角度加一个下限(比如大于 20 度),能砍掉不少这种情况。
4.2 单帧判定为什么不可靠:滑动窗口投票的必要性
哪怕分割和几何都调得不错,逐帧看结果仍然会跳。手指轻微抖动、一帧里刚好有只手从画面边缘掠过、自动曝光突然调整,都会让某一帧的手指数从 5 跳到 3 再跳回来。如果把这个数字直接喂给下游逻辑——比如控制一个虚拟滑块——用户体验就是剧烈的抽搐。
解决办法是引入时序。最朴素的写法是维护一个长度为 N 的队列,每帧把当前判定推进去,取队列里出现次数最多的那个值作为输出,并且要求它的票数达到一个下限才允许切换。窗口取 7、下限取 5,是实时性和稳定性之间一个不错的折中:7 帧在 30 帧每秒下是 230 毫秒,人感觉不到延迟,但足以抹平绝大多数单帧噪声。
from collections import deque, Counter class Stabilizer: def __init__(self, window=7, min_votes=5): self.buf = deque(maxlen=window) self.min_votes = min_votes self.state = 'none' def update(self, gesture): self.buf.append(gesture) if len(self.buf) < self.buf.maxlen: return self.state # 窗口没填满,维持旧状态 top, votes = Counter(self.buf).most_common(1)[0] if votes >= self.min_votes: self.state = top # 票数达标才切换 return self.state这里有个反直觉的地方:窗口填满之前直接返回旧状态,而不是返回当前帧的判定。这样做是为了避免程序刚启动时输出乱跳,代价是前 7 帧没有输出。min_votes调高会让切换更迟钝但更稳,调低则相反。如果你的场景是静态手势拍照,可以把min_votes调到 6、窗口调到 9,多等 60 毫秒换一次干净的结果。
另一个容易忽略的点是:投票的对象应该是离散的手势标签,而不是原始的手指数。如果下游要的是连续量(比如手指张开程度),投票就不适用了,得换成指数加权平均或者卡尔曼滤波。这两种方法在源码里没有,但接口留得很清楚,update里换一行就行。
4.3 规则表与扩展:把几何量映射成业务手势
到了这一步,手里有的是三样东西:有效缺陷数、实心度、以及每个缺陷的角度和深度。规则表就是把这几个数值组合成标签。
| 条件组合 | 判定结果 | 典型误判来源 |
|---|---|---|
| 实心度 > 0.9 且缺陷数 = 0 | 握拳 | 手掌完全平摊也会命中 |
| 缺陷数 = 0 且实心度 < 0.85 | 五指并拢 | 手背朝向镜头时实心度失真 |
| 缺陷数 = 1 且夹角 30~80 | 一指 | 大拇指微张会变 2 |
| 缺陷数 = 2 且两缝夹角接近 | 二指 | 中指无名指并拢时漏检 |
| 缺陷数 ≥ 4 | 五指张开 | 边缘毛刺会虚增 |
这张表能覆盖剪刀石头布这类基础玩法,但表达不了“OK”“点赞”这类需要方向信息的手势。要往上走,得引入第二个几何特征:指尖相对掌心的方向向量。取缺陷的起点和终点(它们大致在指尖位置),减去掌心坐标,得到一组方向;再对整组方向做一次聚类,就能知道伸出的手指是朝上、朝左还是朝右。加上这一维之后,规则表的行数会翻几倍,但仍然是可枚举的。
我一般会把这部分抽成一个纯函数,输入是几何特征字典,输出是标签,中间不碰任何 OpenCV 调用。这样做的好处是可以拿录制好的特征数据离线跑回归测试,改了阈值立刻知道哪些样本会翻车,不用每次都举着手对着摄像头试。这份源码如果只改一处,我会优先改这里——把判定逻辑从主循环里拆出来。
5. 工程避坑:版本、凸包参数与摄像头这三处最容易翻车
5.1 现象:findContours一解包就报“too many values to unpack”
原因很明确,OpenCV 3.x 返回三个值(图像、轮廓、层级),OpenCV 4.x 改成了两个(轮廓、层级)。大量网上流传的示例还是 3.x 的写法,直接抄过来就在 4.x 上崩。更麻烦的是某些发行版里两个大版本共存,你在 PyCharm 里配的解释器和系统里python指向的不是同一个。
解决方式是显式做兼容,而不是赌版本:
found = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = found[-2] # 3.x 取倒数第二个,4.x 取第一个,结果一致用found[-2]这个写法,两个版本都能拿到轮廓数组。顺带说一句,4.x 里findContours不再修改传入的图像,3.x 会,所以如果你有依赖原图未被修改的后续逻辑,3.x 上要先copy()一份。
5.2 现象:convexityDefects抛error: (-215) hull must be ...
原因只有两个,而且都跟凸包的返回形式有关。一是cv2.convexHull忘了加returnPoints=False,默认返回的是坐标点集,而convexityDefects要的是索引;二是凸包索引的 dtype 或维度不对,某些版本要求是int32且形状为 N×1。
解决方式是固定写法,并且做类型转换:先hull = cv2.convexHull(cnt, returnPoints=False),再用hull.astype(np.int32)。另外注意轮廓本身也要是int32坐标,如果你做过approxPolyDP或手动拼接过点集,类型可能已经变成float32。
5.3 现象:深度值动辄上万,阈值判断完全失效
原因是convexityDefects返回的第四个分量是 8 倍定点数,真实距离乘以 256 后取整。看到depth=7680不要惊讶,它对应 30 个像素。任何跟像素有关的阈值都必须先除以 256。
解决方式是在解析缺陷的那一行就做除法,别留到后面。同时把这个除法写进注释,因为半年后回来看代码的人(包括你自己)一定会怀疑这个 256 是哪来的。
5.4 现象:摄像头打开要等好几秒,或者干脆打不开
原因通常是后端选择。默认的自动探测在某些系统上会去试多个后端,每个都超时一次,加起来就是好几秒;如果机器上接了多个摄像头,索引 0 未必是你想用的那个。
解决方式是指定后端和索引。Windows 上cv2.VideoCapture(0, cv2.CAP_DSHOW)能显著缩短启动时间,Linux 上可以试cv2.CAP_V4L2。另外记得设分辨率,默认值可能是 640×480 也可能是 1280×720,取决于驱动,而分辨率直接影响你所有跟像素有关的阈值。
5.5 现象:画面是镜像的,往左挥手识别成往右
原因在cv2.flip要么没加,要么加了两次。摄像头原始输出对人来说本来就是镜像的(你抬右手,画面里像在左边),所以显示前一般要cv2.flip(frame, 1)。但如果这一步在读取帧的时候做了一次,在显示前又做了一次,方向就翻回去了。
解决方式是只在一个地方翻转,并且把翻转后的帧作为后续所有处理的输入,不要出现“检测用原图、显示用翻转图”的分裂。这种分裂最阴险的地方是:静态手势计数看不出来,等到做方向判断的手势才突然全错。
6. 规则法还是学习法:这套源码的能力边界在哪里
把规则法、模板匹配、传统机器学习、深度网络放在一张表里对比,取舍会清楚很多:
| 方案 | 需要样本 | 单帧延迟 | 光照鲁棒性 | 可解释性 | 适合的手势规模 |
|---|---|---|---|---|---|
| 凸缺陷规则 | 无 | 1~3 ms | 弱 | 强 | 5~10 个 |
| 模板匹配 | 每类几张 | 5~15 ms | 弱 | 中 | 10~20 个 |
| HOG + SVM | 每类上百张 | 10~30 ms | 中 | 中 | 20~50 个 |
| 轻量 CNN | 每类上千张 | 20~60 ms | 强 | 弱 | 不限 |
规则法的优势全在左半边:零样本、极低延迟、每个判定都能追溯到某个具体阈值。它的劣势也全在右半边:光照一变就重调,手势一多规则表就爆炸,手背朝向镜头时凸缺陷基本失效。这不是实现质量的问题,而是方法本身的信息量上限——凸缺陷只描述了轮廓的凸性,它天生就丢掉了纹理、深度和运动信息。
实际的工程决策点在这里:如果你的场景是固定光照、固定机位、手势种类少于十个、且需要给出“为什么判定成这个”的解释,规则法是正确选择,因为它能在几百行内跑通,还能在嵌入式设备上跑。反过来,只要有一条不满足——比如用户会在不同房间用、或者需要区分手掌正反——就该尽早换到学习法,不要试图用更复杂的规则去补,那是一条看不到头的路。
验证方法上,我建议先做一件很朴素的事:录一段包含各种光照和各种手势的视频,离线跑一遍检测,把每一帧的手指数、实心度、有效缺陷数写进 CSV。有了这份数据,你才能回答“到底是分割漏了还是缺陷滤掉了”这类问题。靠盯着实时窗口猜,永远猜不准。
这套源码我读过之后的习惯是:每次动阈值之前,先把中间结果落盘成图,跑完再删。因为手势识别这行的玄学在于,同一组参数在下午三点的自然光和晚上八点的顶灯下表现能差出一倍,而你调参时只会记住最后一次看到的结果。从那以后我每次改任何跟肤色或几何相关的常量,都强制走一遍“存图—对比—再改”的流程,宁可多花十分钟,也不愿意在演示前半小时发现全崩。希望帮到你。
本文还有配套的精品资源,点击获取