☰
Python+OpenCV手势识别:肤色分割与凸包缺陷判别
2026/11/6 2:13:53 网站建设 项目流程

简介:基于Python与OpenCV的手势识别课程设计源码包,面向正在做计算机视觉课程设计、毕业设计或希望入门图像处理的学生与开发者。代码在PyCharm中调用摄像头按帧采集画面,完成镜像翻转、形态学处理与固定区域ROI选取,用红线标注手势识别框,再以HSV肤色检测、高斯滤波、轮廓提取定位手势,并求取手势凹凸点与指间角度,可帮助读者理解从预处理到特征计算这一完整流程。整包共67个文件,以30个py源码为主,配合26个pyc编译文件、6张png调试结果图、2份license、2个aip_client及opencv_python的whl离线安装包,压缩后约42.46MB,源码与运行截图较为齐备,便于对照复现。已有1894人学习,适合按模块拆读代码、验证各步骤效果并积累排错经验。

1. 手势识别不是"跑通就行":从一个换背景就崩的场景说起

很多手势识别 Demo 在作者自己的桌面上跑得很顺,摄像头一挪、背景一复杂、灯光一偏色,识别率立刻掉一半。这套基于 Python + OpenCV 的手势识别算法设计,要解决的就是这条链路:从摄像头画面里把手稳定分割出来,再判断伸了几根手指、对应哪个手势。它不训练大模型,走的是传统图像处理路线——肤色分割、轮廓提取、凸包与凸缺陷分析、几何规则判别,每一步都摆在明面上,参数可调、中间结果可视、出错能定位。适合两类人:做毕设或课设、需要一套完整算法流程加源代码结构的学生;以及想快速搭手势交互原型的开发者,拿它当 baseline,后面接控制逻辑或者换更重的模型都不冲突。整篇按"需求—架构—分割—特征判别—排查—验收"的顺序展开,代码集中在关键环节,能直接对着改。

2. 总体架构设计:三层处理管线与四个模块的职责划分

手势识别最容易写成一坨:读帧、处理、判断、画图全塞在while True里,跑起来没问题,一旦要改阈值或者换判别规则,就得满文件找变量。我一般会把整条链路拆成三层——数据层负责取帧,处理层负责把 BGR 图像变成一张干净的手部掩膜,决策层负责从掩膜里抽特征、给结果。层与层之间只传数组和字典,不共享全局状态,这样任何一层出问题都能单独拿出来复现。

2.1 从摄像头到判别结果的数据流

一帧画面的完整处理顺序是固定的,顺序错了后面全白搭:采集帧 → 水平翻转(让画面像镜子,符合直觉)→ 裁剪 ROI(画面中下部一块矩形,手通常出现在这里)→ 高斯模糊(压掉传感器噪点)→ 颜色空间转换 → 阈值二值化 → 形态学开闭运算 → 找最大轮廓 → 计算凸包与凸缺陷 → 几何规则判别 → 输出手势编号。这一串里,模糊必须在二值化之前,形态学必须在找轮廓之前,凸缺陷必须在凸包之后,这三条顺序是硬约束。

分辨率上有个取舍:720p 够用,再高帧率掉得厉害,实时预览会卡;320×240 帧率漂亮但手指边缘糊成一团,凸缺陷深度算不准。通常的做法是采集用 640×480,ROI 再裁掉上下各四分之一,实际参与运算的像素只剩三成左右,帧率能稳在 25 帧以上。帧率低于 15 帧时,人手移动带来的运动模糊会让掩膜边缘发虚,这是后面很多误判的根源,所以宁可降分辨率也要保帧率。

数据流的另一个设计点是"中间结果要不要留"。我的习惯是默认关闭,调试时用一个开关把掩膜、轮廓图、缺陷点分别输出到独立窗口。毕设答辩现场最怕的就是评委问"你这儿为什么判错了",手里有中间图,三十秒就能解释清楚是分割问题还是判别问题。

2.2 四个模块的边界与接口约定

拆成四个模块,每个模块只做一件事,接口用最朴素的结构:

  • capture:输入摄像头编号和分辨率,输出numpy.ndarray的 BGR 帧,不做任何处理;
  • segment:输入 BGR 帧和配置字典,输出单通道uint8掩膜,手部为 255,背景为 0;
  • feature:输入掩膜,输出一个字典,含最大轮廓、凸包点集、筛选后的缺陷点列表、轮廓面积与外接矩形;
  • classify:输入特征字典,输出(gesture_id, confidence)二元组。

配置不写死在函数里,统一放一个字典往下传,改参数不用动代码逻辑。判别结果用整数编号而不是字符串,好处是后面接映射表方便,手势加一个就改一张表。

# config.py —— 所有可调参数集中在这里,避免散落在各模块 CONFIG = { "camera_id": 0, "frame_width": 640, "frame_height": 480, "roi": (0.25, 0.75), # ROI 上下边界占画面高度的比例 "blur_ksize": (5, 5), # 高斯核,必须是奇数 "morph_kernel": (5, 5), # 形态学结构元素尺寸 "min_contour_area": 4000, # 小于这个面积的轮廓直接丢弃,滤掉噪点 "defect_depth_thresh": 8000 # 凸缺陷深度阈值,过滤指缝间的浅坑 }

这段配置是整个项目的"遥控器"。roi用比例而不是绝对像素,换分辨率不用重算;min_contour_area是抗噪的第一道闸,太小会把噪点当手,太大会把远距离的手漏掉,640×480 下 4000 左右是比较稳的起点;defect_depth_thresh直接决定数手指的灵敏度,这个值跟手离摄像头的距离强相关,后面第 4 章会展开怎么标定它。

2.3 工程目录与 PyCharm 运行配置

目录按模块平铺,不搞多层嵌套,毕设交付时评委翻代码一目了然:

gesture_recognition/ ├── main.py # 入口,串起采集-分割-特征-判别 ├── config.py # 参数配置 ├── capture.py # 摄像头读取 ├── segment.py # 肤色分割与形态学 ├── feature.py # 轮廓、凸包、凸缺陷 ├── classify.py # 手势判别规则 ├── utils.py # 绘图、帧率计算等辅助函数 └── requirements.txt

PyCharm 里两个设置值得提前配好:一是运行配置里把工作目录设为项目根,避免相对路径读文件失败;二是打开"Emulate terminal in output console",cv2.imshow在某些环境下不加这个选项窗口会闪退。调试时在最容易出错的segment返回处下断点,看掩膜里手部区域的像素占比,正常在 5% 到 25% 之间,低于 5% 说明阈值偏紧,高于 40% 说明背景被大量误检。

依赖只有三个:opencv-python、numpy、可选的mediapipe(如果后面想加手部关键点做对比实验)。版本上没有硬性要求,OpenCV 4.x 系列都兼容,cv2.convexityDefects这个核心函数从 3.x 到现在签名没变过。

3. 手势区域分割:ROI 裁剪、肤色空间与形态学清理

分割是整条链路的命门。特征提取再精巧,掩膜里手缺一块、背景多一块,后面的凸缺陷全是错的。这一章把分割拆成三步:先缩小战场(ROI),再选对颜色空间做阈值,最后用形态学把边缘收拾干净。

3.1 为什么用 YCrCb 而不是直接卡 RGB

RGB 三通道对光照是强耦合的,灯一亮,三个值一起往上走,阈值区间立刻就失效。YCrCb 把亮度 Y 和色度 Cr、Cb 分开,肤色在 Cr-Cb 平面上集中在一个小区域,跟亮度基本解耦,这是它比 RGB 和 HSV 更适合肤色分割的原因。HSV 也能用,但 H 分量在红色附近是环形的,跨越 0/180 边界,写阈值要分两段,多一层麻烦。

工程上的取值通常是 Cr 在 133 到 173、Cb 在 77 到 127 这个区间,这是公开资料里被反复验证过的经验范围,但它是针对特定光照条件的,直接照搬到你自己的摄像头上大概率要微调。我一般会先跑一遍采集,把手放在 ROI 里,打印 Cr、Cb 的实际分布,取覆盖 90% 像素的区间,比抄任何现成参数都靠谱。

import cv2 import numpy as np def segment_hand(frame, config): # 1. 裁剪 ROI:只保留画面中下部,减少背景干扰 h, w = frame.shape[:2] top = int(h * config["roi"][0]) bottom = int(h * config["roi"][1]) roi = frame[top:bottom, :] # 2. 高斯模糊压噪点,核必须是奇数 blurred = cv2.GaussianBlur(roi, config["blur_ksize"], 0) # 3. 转 YCrCb,按色度通道做阈值 ycrcb = cv2.cvtColor(blurred, cv2.COLOR_BGR2YCrCb) lower = np.array([0, 133, 77], dtype=np.uint8) upper = np.array([255, 173, 127], dtype=np.uint8) mask = cv2.inRange(ycrcb, lower, upper) # 4. 形态学:先开运算去孤立噪点,再闭运算补手掌内部空洞 kernel = cv2.getStructuringElement( cv2.MORPH_ELLIPSE, config["morph_kernel"]) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return mask, (top, bottom)

inRange的三个通道里,Y 给 0 到 255 是全放行,真正起作用的只有 Cr 和 Cb,这样亮度变化不会直接掐断分割。MORPH_ELLIPSE比矩形核更贴合手部圆润的边缘,用矩形核闭运算之后轮廓会带直角,凸缺陷深度计算会偏。开运算在前、闭运算在后不能反:先开是去掉背景里零星的肤色噪点(比如木桌面、纸箱),再闭是补手掌中间被阴影切出来的洞,顺序反了会把噪点闭成块。

3.2 形态学核大小的取舍

核太小(3×3)去噪不干净,掩膜上全是芝麻点,找轮廓时会检出几十个小轮廓;核太大(11×11 以上)手指之间的缝隙被填平,五个手指粘成一个板子,数手指直接失效。5×5 到 7×7 是甜区,手离摄像头近、手指张得开就用 5×5,手远、手指并拢多就用 7×7。这个参数跟min_contour_area要联动调:核大了轮廓面积会缩水,面积阈值也得跟着降一点。

还有一个容易忽略的点是 ROI 边界。手如果伸出 ROI 下沿,轮廓会被画面边缘截断,凸包算出来是开口的,缺陷点位置全乱。处理办法是在 ROI 裁剪时留出余量,或者检测到轮廓外接矩形贴边时直接跳过这一帧,不要硬判。

3.3 光照变化的应对

室内灯光是最大的变量。白天靠窗和晚上开台灯,同一个阈值表现能差出一倍。常规做法有三层:第一层是采集端,把摄像头曝光锁死(cv2.CAP_PROP_AUTO_EXPOSURE关掉),避免画面忽明忽暗;第二层是处理端,在转 YCrCb 之前做一次 CLAHE 直方图均衡,只作用在 Y 通道上,把亮度拉平;第三层是阈值端,允许一个按键触发的重新标定——按一下键,采集当前画面中心区域的 Cr、Cb 均值,上下各扩 20 作为新阈值。

第三层听起来土,但答辩现场非常管用,评委换了个位置坐,你按一下键重新标定,演示继续。比硬编码一套参数然后现场翻车体面得多。

def calibrate_skin_range(frame, config): # 采集 ROI 中心区域的色度均值,动态生成阈值 h, w = frame.shape[:2] cx, cy = w // 2, h // 2 patch = frame[cy - 40:cy + 40, cx - 40:cx + 40] ycrcb = cv2.cvtColor(patch, cv2.COLOR_BGR2YCrCb) cr_mean = int(ycrcb[:, :, 1].mean()) cb_mean = int(ycrcb[:, :, 2].mean()) lower = np.array([0, max(0, cr_mean - 20), max(0, cb_mean - 20)]) upper = np.array([255, min(255, cr_mean + 20), min(255, cb_mean + 20)]) return lower, upper

标定时手掌要平摊在画面中央,占满那个 80×80 的采样块,采出来的均值才有代表性。±20 的容差是经验值,容差太小对光照变化不宽容,太大容易把脸和手臂一起吞进来。

4. 特征提取与判别:轮廓筛选、凸包缺陷与数指规则

掩膜干净之后,接下来的任务是从一张黑白图里读出"伸了几根手指"。这一步没有任何机器学习,全靠几何:手掌是一个凸形状,手指伸出来会在凸包上形成凹陷,凹陷的个数和位置基本对应指缝,指尖则对应轮廓上离手掌中心最远的那些点。

4.1 轮廓筛选与手掌定位

findContours在 OpenCV 4.x 里返回两个值(轮廓列表、层级),网上很多老代码还在按三个值解包,直接报错,这是版本差异带来的经典坑。拿到轮廓后按面积降序排,取最大的那个,因为手通常是画面里最大的肤色连通域。如果最大轮廓面积小于min_contour_area,说明画面里没有有效的手,直接返回空结果,不要往下算。

手掌中心的估计有两种:一种是轮廓质心(cv2.moments),简单但在手指伸得多的时候会往手指方向偏;另一种是最大内切圆圆心,更贴近掌心的真实位置,代价是要做距离变换。我一般用质心加外接矩形中心的加权平均,计算量小,稳定性够用。

def extract_features(mask, config): contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None # 按面积取最大轮廓 contour = max(contours, key=cv2.contourArea) if cv2.contourArea(contour) < config["min_contour_area"]: return None # 凸包与凸缺陷 hull = cv2.convexHull(contour, returnPoints=False) defects = cv2.convexityDefects(contour, hull) # 质心作为手掌参考点 M = cv2.moments(contour) cx = int(M["m10"] / M["m00"]) cy = int(M["m01"] / M["m00"]) return {"contour": contour, "defects": defects, "center": (cx, cy)}

RETR_EXTERNAL只取最外层轮廓,手内部的空洞不关心;CHAIN_APPROX_SIMPLE压缩直线段上的冗余点,轮廓点数能少一个量级,后面算凸包快很多。convexHull这里必须传returnPoints=False,返回的是轮廓点的索引而不是坐标,convexityDefects要的就是索引,传错了会抛断言错误,这个坑我第一次写的时候就踩过。

4.2 凸缺陷的筛选逻辑

convexityDefects返回的每一行是四元组:起点索引、终点索引、最远点索引、到凸包边的距离(这个距离被放大了 256 倍,要除以 256 才是像素值)。原始输出里缺陷点很多,指缝之间的深坑是我们想要的,轮廓抖动产生的小凹槽要滤掉,靠的就是深度阈值。

def filter_defects(defects, contour, config): valid = [] if defects is None: return valid for i in range(defects.shape[0]): s, e, f, d = defects[i, 0] depth = d / 256.0 # 还原成像素距离 far = tuple(contour[f][0]) # 最远点坐标 if depth > config["defect_depth_thresh"] / 256.0: valid.append(far) return valid

深度阈值跟手掌在画面里的大小成正比,手离得近,指缝的绝对深度也大。所以更稳的做法是把阈值写成"轮廓外接矩形短边的比例",比如 0.08 倍,而不是一个固定像素值。这样人走近走远,判别逻辑不用重调。上面代码里为了直观还是用了配置项,实际工程里我建议换成比例形式,一行除法的事。

角度的处理也值得说一句。以手掌中心为原点,把筛选后的缺陷点按极角排序,相邻两个缺陷点之间如果夹角小于 90 度,中间大概率夹着一根伸出的手指。这是个近似规则,手掌旋转、手指弯曲都会让角度分布变形,但对"数手指"这个任务足够用。

4.3 手势映射表与置信度

数出指尖数目只是第一步,真正要输出的是手势语义。映射表单独维护,不要写进判断分支:

指尖数手势编号语义常见误判场景
00握拳手离太远,轮廓面积不足被丢弃
11数字一大拇指横伸时角度规则失效
22剪刀手两指并拢时缺陷点被形态学填平
33数字三无名指弯曲被误算作缺陷
55张开手掌手指张开过大,轮廓贴 ROI 边界

置信度不用搞得很复杂,用"当前帧缺陷点数量与历史帧的一致性"来估计就够了:最近 10 帧里同一手势出现 8 次以上,置信度记高;6 到 8 次记中;低于 6 次直接不输出,保持上一帧结果。这个机制能消掉大部分单帧抖动,成本只是一个定长队列。

4.4 帧间平滑:一个队列解决 80% 的抖动

单帧判别必然抖,因为掩膜边缘每一帧都在变。我一般用一个长度为 8 到 12 的collections.deque,每帧把判别结果压进去,输出取众数。队列长度太短平滑不够,太长响应迟钝,10 是折中值。手势切换时队列要清空或者快速衰减,否则从"五"切到"零"要等半秒才反应过来,交互体验很差。

from collections import deque, Counter class GestureSmoother: def __init__(self, window=10): self.history = deque(maxlen=window) def update(self, gesture_id): if gesture_id is not None: self.history.append(gesture_id) if len(self.history) < 5: return None, 0.0 counter = Counter(self.history) top, count = counter.most_common(1)[0] return top, count / len(self.history) # 出现频率作为置信度

window=10配合"至少 5 帧才给结果"的启动条件,既能压抖动又不至于开场卡顿。手势切换的响应速度靠maxlen自动淘汰旧帧来保证,不需要额外的衰减逻辑。

5. 常见问题排查:肤色漂移、轮廓断裂与抖动误判

这套算法跑通不难,跑稳很难,问题基本集中在这五处。每条按现象、原因、解决的顺序写,照着排查能省不少时间。

现象一:换了背景,掩膜里出现大片白色区域。原因通常是背景里有跟肤色接近的东西——木色桌面、牛皮纸箱、暖色墙面,YCrCb 阈值分不开。解决分两步:先看误检区域是否落在 ROI 内,如果 ROI 裁剪已经能挡掉大部分就调 ROI 比例;挡不掉就收紧 Cr、Cb 区间,每次收 5,同时观察手部掩膜有没有开始缺角,收到刚好不缺为止。实在分不开,加一个"轮廓圆形度"过滤,手的外接矩形长宽比和填充率跟方形纸箱差别很大。

现象二:手指并拢时怎么都数不出三根以上。形态学闭运算把指缝填平了,凸包上没有凹陷,缺陷点数为零。解决是降核尺寸,5×5 换成 3×3,或者干脆去掉闭运算只保留开运算,代价是手掌内部可能出现空洞,用RETR_EXTERNAL取外轮廓可以规避这个副作用。还有一种情况是手指确实并得太紧,物理上就没有缝隙,这种只能靠轮廓的曲率分析补,属于另一套特征,不适合塞进当前流程。

现象三:逆光或者侧光下,手的一半从掩膜里消失。亮度突变让 Cr、Cb 整体偏移,固定阈值失效。解决是回到 3.3 节的 CLAHE 加按键重标定组合。另外检查一下摄像头自动曝光有没有关,自动曝光在逆光时会疯狂拉高增益,画面噪点暴增,模糊核都压不住。

现象四:画面明显延迟,手动了半秒画面才跟上。先量帧率,cv2.getTickCount卡一下每帧耗时。常见原因是分辨率开太高,或者形态学核开太大,5×5 以上的核在中低端 CPU 上开销不小。降到 640×480、核降到 5×5,一般能回到 25 帧以上。还有一个隐蔽原因是在while循环里频繁创建数组和结构元素,把它们提到循环外初始化,能省一截。

现象五:同一个手势在阈值边界反复跳变,编号 2 和 3 来回闪。这是单帧判别的固有缺陷,靠第 4.4 节的平滑队列解决,不需要动分割参数。如果加了平滑还跳,说明两个手势的特征确实重叠了,检查一下映射表里这两类是不是本来就在几何上难以区分,比如"二"和"三"在手指弯曲角度接近时确实容易混,那就得在特征里补一个指节长度比例,或者干脆把这两类合并成一个。

提示:排查顺序永远是"先看掩膜,再看轮廓,最后看判别"。掩膜不对,后面调什么都没意义。

6. 验收与答辩:一份能跑通的演示清单和追问准备

到这一步代码基本齐了,能不能过关取决于演示的稳定性和你对参数的解释能力。验收我一般按下面这张表逐项过,每项都有明确的判定标准,不靠"看起来还行"。

验收项判定标准测试方法
实时性稳定 20 帧以上连续运行 3 分钟看帧率显示
分割完整性手部掩膜缺角小于 10%五种手势各截一帧掩膜图目测
识别准确率五种手势各 20 次,正确率 85% 以上固定距离、固定光照下逐个做
鲁棒性换一次背景后重标定,准确率不低于 75%桌面上垫一张纸再测一轮
响应延迟手势切换后 0.5 秒内输出新结果秒表配合录像回放

准确率 85% 这个线看起来不高,但传统方法在自然光下就是这个水平,答辩时如实说清楚比吹到 99% 然后现场翻车强得多。测试时距离固定在一臂左右,太近手出 ROI,太远轮廓面积不够,这两个边界条件自己先摸清楚,评委问起来能直接答。

追问高频集中在三处。第一处是"为什么不用深度学习",答案是场景决定的:单手势分类、样本量小、要能解释每一步中间结果,传统方法在可解释性和部署成本上占优,而且这套流程本身就是深度学习方案的预处理环节,换成分类头就能升级。第二处是"参数怎么定的",不要回答"试出来的",要讲清楚每个参数的物理含义,比如缺陷深度阈值对应手掌尺寸的比例,这句话一出来,说明你是理解而不是抄的。第三处是"换个环境还能不能用",直接现场演示按键重标定,比任何解释都有说服力。

代码讲解控制在三分钟:先放一段实时演示,再打开掩膜窗口讲分割,最后指着凸缺陷图讲判别,全程不切代码文件。评委没耐心看你翻十屏代码,把中间结果可视化做好,比什么都有用。

从那以后我每次做这类视觉项目,都会在答辩前一天晚上按验收表完整走三遍,包括换背景、换光照、故意做错手势看会不会误判。血泪经验是:演示翻车从来不是因为算法不行,是因为没在别人的环境里跑过。这套源代码材料的结构和参数说明就是按这个标准整理的,拿过去对着改配置、跑一遍验收表,基本就能直接用在毕设或者原型验证里。希望帮到你。

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

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

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

立即咨询