2026/11/6 11:32:49

Python+OpenCV手势识别:凸缺陷、肤色分割与时序稳定化

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], dtypenp.uint8) HSV_UPPER np.array([25, 255, 255], dtypenp.uint8) YCRCB_LOWER np.array([0, 133, 77], dtypenp.uint8) YCRCB_UPPER np.array([255, 173, 127], dtypenp.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排序取最大的那个但这个规则在两种场景下会失效画面里有人脸脸的肤色面积往往比远处的手更大或者手臂垂在画面下沿手的轮廓和手臂连成一条面积很大但形状完全不对。我一般会加两个过滤条件——面积要落在合理区间以及轮廓的外接矩形长宽比不能太离谱一只竖直举起的手宽度远小于高度。凸包是包含轮廓的最小凸多边形。可以把它理解成拿一根橡皮筋套在手的轮廓外面松手橡皮筋形成的形状就是凸包。五指并拢时轮廓本身就接近凸形橡皮筋几乎贴着轮廓走五指张开时指缝处凹进去橡皮筋会跨过指尖直接连成一条直线把凹进去的那块区域隔在外面。这块被隔出来的区域就是凸缺陷。这就是整个方案的核心思想手指张开不张开等价于轮廓是不是凸的。而“不凸”这件事是可以被精确量化的。调用上有两个必须同时满足的条件。第一凸包必须用returnPointsFalse计算返回的是轮廓点的索引而不是坐标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_depth25.0, max_angle90.0): # 凸包必须返回索引returnPoints 默认 True 会直接报错 hull_idx cv2.convexHull(contour, returnPointsFalse) 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.0000002math.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, window7, min_votes5): self.buf deque(maxlenwindow) 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忘了加returnPointsFalse默认返回的是坐标点集而convexityDefects要的是索引二是凸包索引的 dtype 或维度不对某些版本要求是int32且形状为 N×1。解决方式是固定写法并且做类型转换先hull cv2.convexHull(cnt, returnPointsFalse)再用hull.astype(np.int32)。另外注意轮廓本身也要是int32坐标如果你做过approxPolyDP或手动拼接过点集类型可能已经变成float32。5.3 现象深度值动辄上万阈值判断完全失效原因是convexityDefects返回的第四个分量是 8 倍定点数真实距离乘以 256 后取整。看到depth7680不要惊讶它对应 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。有了这份数据你才能回答“到底是分割漏了还是缺陷滤掉了”这类问题。靠盯着实时窗口猜永远猜不准。这套源码我读过之后的习惯是每次动阈值之前先把中间结果落盘成图跑完再删。因为手势识别这行的玄学在于同一组参数在下午三点的自然光和晚上八点的顶灯下表现能差出一倍而你调参时只会记住最后一次看到的结果。从那以后我每次改任何跟肤色或几何相关的常量都强制走一遍“存图—对比—再改”的流程宁可多花十分钟也不愿意在演示前半小时发现全崩。希望帮到你。本文还有配套的精品资源点击获取