简介:该资源面向学习计算机视觉与图像处理的高校学生及Python初学者,围绕手势识别这一典型课程设计场景,提供可直接运行、便于二次修改的完整工程材料。压缩包共67个文件,约42.46MB,以30个py源码文件为核心,配合26个pyc编译缓存、6张png调试结果截图,另含OpenCV的whl安装包与百度AI接口SDK等依赖组件,省去逐个查找配置的麻烦。代码覆盖调取摄像头按帧读取画面、形态学处理、cv2.flip镜像翻转、固定区域手势框绘制、基于HSV的肤色检测、高斯滤波、轮廓提取、手势凹凸点求解与指间角度计算等环节,并附多组运行结果图可供对照验证效果。目前已有1894人学习下载,适合作为课程设计参考、算法练手或手势交互入门项目,帮助读者理解从图像采集到手指角度判定的完整处理链路。
1. 手势识别不是调个库就行:这套 Python + OpenCV 方案到底解决什么
手势识别这四个字,很多人第一反应是上深度学习、跑 YOLO、挂个 MediaPipe 就完事。可真到要交一份课程设计或者毕设的时候,问题往往不是「能不能识别」,而是「能不能在没有 GPU、没有大模型权重、只有一台普通笔记本和一颗摄像头的情况下,把整条图像处理链路跑通并且讲清楚」。这套基于 Python + OpenCV 的手势识别算法设计源代码材料,走的就是传统图像处理路线:HSV 肤色分割把手从背景里剥出来,形态学把轮廓修干净,凸包缺陷数指尖,Hu 矩做二次判别,全程不依赖任何预训练模型,PyCharm 里装完 opencv-python 和 numpy 就能跑。它适合三类人:要交毕设/课设但不想被环境配置卡死的学生、想把图像处理基本功重新过一遍的从业者、以及需要一个可解释、可调参、能当场演示的轻量方案的人。
2. 总体架构设计:拆成四层链路,每层只干一件事
2.1 四层链路的职责边界
一套手势识别代码最容易烂掉的地方不是算法难,而是所有逻辑糊在一个 while 循环里:读帧、转色彩空间、阈值、找轮廓、算凸包、判断手指、画图,全挤在三十行里。参数一多就没人敢动,改一个阈值要翻半天。我一般会把它拆成四层,每层输入输出明确:
| 层 | 职责 | 输入 | 输出 |
|---|---|---|---|
| 采集层 | 打开摄像头、翻转镜像、裁剪 ROI、控制分辨率 | 摄像头索引 | BGR 帧 + ROI |
| 分割层 | BGR 转 HSV、双段阈值、形态学、取最大轮廓 | ROI 子图 | 二值掩膜 + 主轮廓 |
| 特征层 | 凸包、凸缺陷、Hu 矩、面积与周长 | 主轮廓 | 指数量、缺陷点、Hu 向量 |
| 判别层 | 规则判别 + 模板匹配复核 + 渲染标注 | 特征字典 | 手势标签 + 置信度 |
分层的直接好处是调试可定位。识别不准的时候,先存一张中间掩膜图看一眼:手是完整的、碎的、还是根本没被框住。掩膜对了但结果错,问题在特征层;掩膜本身就是一片噪点,问题在分割层,跟凸包一点关系都没有。没有这层隔离,你只能在几百行里靠打印语句猜,跟摸黑箱差不多。
2.2 主循环与目录结构
主循环只负责调度,不做计算。下面这段是这套材料的入口骨架,跑通它就等于把四层链路串起来了:
import cv2 import numpy as np from gesture_core import preprocess, extract_features, classify, render def main(camera_index=0, frame_width=640, skip_frames=1): cap = cv2.VideoCapture(camera_index) # 先设分辨率再设帧率,顺序反了在部分驱动上不生效 cap.set(cv2.CAP_PROP_FRAME_WIDTH, frame_width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(frame_width * 0.75)) cap.set(cv2.CAP_PROP_FPS, 30) if not cap.isOpened(): raise RuntimeError("摄像头未就绪:检查索引是否被占用,或换 1 试一次") idx = 0 while True: ok, frame = cap.read() if not ok: break frame = cv2.flip(frame, 1) # 镜像,符合用户抬手直觉 h, w = frame.shape[:2] roi = frame[80:80 + 320, 160:160 + 320] # 固定活动区,减少背景干扰 idx += 1 if idx % skip_frames != 0: # 跳帧,低配机器靠它保流畅 cv2.imshow("gesture", frame) if cv2.waitKey(1) & 0xFF == 27: break continue mask, contour = preprocess(roi) # 层 2 feats = extract_features(contour) # 层 3 label, score = classify(feats) # 层 4 render(frame, roi, mask, label, score) cv2.imshow("gesture", frame) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()几个参数值得单独说清楚。frame_width定在 640 是有原因的:1280 宽下每帧形态学要慢一倍多,而 320 宽又会让指尖细节糊成一团,640 是普通笔记本上精度和速度的平衡点。skip_frames=1表示每帧都算,机器吃力就改成 2,代价是手势切换会有一点迟滞。ROI 用固定窗口而不是全图,是因为全图里墙面、衣服、木质桌面都可能是接近肤色的区域,先裁一刀等于提前删掉一半误检来源。目录结构上建议按main.py、gesture_core/(分割、特征、判别三个文件)、config.py(所有阈值)、templates/(Hu 矩模板)、debug_out/(中间图输出)来分,答辩时打开目录就能讲清楚模块划分。
2.3 参数外置:把玄学变成可复现的数字
同一套代码在两张桌子上表现不一样,八成是阈值写死在函数里。这套材料的做法是把所有可调项收进config.py,用字典组织:
CONFIG = { "hsv_lower": (0, 40, 60), "hsv_upper": (20, 255, 255), "morph_kernel": 5, "open_iter": 2, "close_iter": 3, "min_contour_area": 3000, "defect_depth": 20000, "defect_angle": 90, }这么做的价值在验收环节会体现出来:答辩老师问「光照变了怎么办」,你不需要现场改代码,只要把hsv_lower的 V 通道从 60 调到 40 再跑一遍,当场演示鲁棒性调整。写死阈值的方案在这一步是没法演示的,只能嘴上说「理论上可以调」。
3. 手部区域分割:HSV 肤色模型、形态学与最大轮廓的三步走
3.1 为什么是 HSV 而不是 RGB
RGB 三个通道都在同时受光照影响:同样一只偏黄的手,白天摄像头下是 (190, 170, 150),晚上台灯下可能变成 (120, 105, 90),三个数全变了,你没法用一组固定区间框住它。HSV 把色相和亮度拆开,H 通道描述颜色本身,V 通道描述明暗,肤色在 H 上的分布相对集中,亮度变化主要冲击 V。这意味着阈值可以在 H 上卡得紧一点、在 V 上放得松一点,方案就自然抗一部分光照波动。这是选型层面的事,不是调参能弥补的——一开始用 RGB 做阈值,后面所有努力都是在给错误的地基打补丁。
还有个细节:OpenCV 读进来的 H 范围是 0 到 180,不是 0 到 360。网上大量示例按 0–360 写阈值,抄过来直接全黑,这是新手第一个翻车点。
3.2 双段掩膜与形态学的顺序
红色在 HSV 色环上跨越 0 度边界,肤色虽然主区间在 0–20,但偏红的肤色会掉到 160–180 这一段。只做单段阈值,暖光下的手会缺一块,轮廓直接破掉。所以掩膜要按位或两段:
import cv2 import numpy as np LOWER_A = np.array([0, 40, 60], np.uint8) UPPER_A = np.array([20, 255, 255], np.uint8) LOWER_B = np.array([160, 40, 60], np.uint8) UPPER_B = np.array([180, 255, 255], np.uint8) def skin_mask(bgr, kernel_size=5): hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, LOWER_A, UPPER_A) mask |= cv2.inRange(hsv, LOWER_B, UPPER_B) # 补红端缺口 k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) # 先开后闭:开运算砍掉零散噪点,闭运算补手掌内部空洞 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k, iterations=2) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=3) return cv2.GaussianBlur(mask, (5, 5), 0)顺序不能反。先闭后开的话,闭运算会先把噪点和手掌连成一片,开运算再想砍就得用更大的核,结果把手指也一起削掉。核形状选椭圆而不是矩形,是因为手指轮廓是弧形的,椭圆核腐蚀膨胀后边缘更接近原始形状,矩形核会在指尖留下方角,后面算凸缺陷时角度判断容易误判。
S 下限设 40 是过滤掉灰白色物体,V 下限设 60 是过滤掉暗部阴影。这两个值不是拍脑袋:S 太低会把白墙、白纸全收进来;V 太低会把阴影也算成肤色,而阴影通常在手的下方,一合并轮廓就拖出一条尾巴。
3.3 最大轮廓与面积过滤
掩膜出来往往不止一个连通域:脸、胳膊、背景里一块木头都可能是候选。取面积最大的那个是性价比最高的策略,但必须加面积下限,否则手完全离开画面时,程序会把一个 200 像素的噪点当成手,然后一本正经地数出 0 根手指,看起来像识别失败,其实是根本没检测到手。
def largest_contour(mask, min_area=3000): contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None c = max(contours, key=cv2.contourArea) if cv2.contourArea(c) < min_area: return None # 返回 None 表示"这一帧没有手",与"识别为 0"区分开 return cmin_area=3000对应 640×480 下 ROI 里一只手的典型面积量级,手掌加手指大概在 8000 到 20000 之间。设成 3000 是留了余量,手离摄像头远一点也不会丢。这里返回None而不是返回空列表,是刻意让「没检测到」和「检测到但结果为零」在类型上分开,上层才能给出不同的提示——前者提示「请把手放进框内」,后者才去判断手势语义。RETR_EXTERNAL只取外轮廓,是因为手掌内部被遮住的区域本来就不该参与形状计算。
4. 手势判别算法:凸包缺陷数手指,Hu 矩做二次确认
4.1 凸包与凸缺陷到底在算什么
把手掌轮廓想象成一根橡皮筋套在轮廓外面,橡皮筋形成的多边形就是凸包,轮廓凹进去的地方和橡皮筋之间的空隙就是凸缺陷。手指张开时,相邻两根手指之间必然形成一个深 V 形凹陷;手指并拢时凹陷很浅甚至没有。所以「缺陷数量 + 1」近似等于伸出的手指数,这是整套算法最核心也最省算力的一步。
关键在 OpenCV 的两个函数必须配合使用,参数不能搞错:
import cv2 import numpy as np def count_fingers(contour, depth_thresh=20000, angle_thresh=90): if contour is None or len(contour) < 5: return 0, [] # returnPoints=False 才返回索引,True 返回坐标,后续无法传给 defects hull_idx = cv2.convexHull(contour, returnPoints=False) if hull_idx is None or len(hull_idx) < 3: return 1, [] # 只有手掌没有凹陷,按握拳处理 defects = cv2.convexityDefects(contour, hull_idx) if defects is None: return 1, [] tips = [] for i in range(defects.shape[0]): s, e, f, d = defects[i, 0] start = contour[s][0] end = contour[e][0] far = contour[f][0] a = np.linalg.norm(end - start) b = np.linalg.norm(far - start) c = np.linalg.norm(end - far) if b * c == 0: continue # 余弦定理求凹角,指尖间的凹陷通常是锐角 cos_val = (b ** 2 + c ** 2 - a ** 2) / (2 * b * c) angle = np.degrees(np.arccos(np.clip(cos_val, -1.0, 1.0))) if d > depth_thresh and angle < angle_thresh: tips.append(tuple(far)) return len(tips) + 1, tipsreturnPoints=False这一行是整段代码里最容易错的地方。传True拿到的是凸包顶点坐标,convexityDefects要的是顶点在原始轮廓里的下标,类型对不上直接抛异常。网上不少示例把两种写法混着贴,跑起来就报错。
4.2 两个阈值的实际含义
d是缺陷最远点到凸包边的距离,但它是定点数,真实像素距离大约要除以 256。所以depth_thresh=20000换算下来约等于 78 像素。这个量级怎么定?在 640×480、ROI 320×320 的条件下,两根手指之间的 V 形凹陷深度通常在 60–150 像素,指缝浅的时候会掉到 40 左右。设 20000 是宁可漏数也不误数——把阈值调低,手掌边缘的微小起伏都会被当成指缝,一根手指能给你数出三根来,演示时非常尴尬。
angle_thresh=90是第二道闸。只靠深度判断,手腕和手臂连接处的凹陷也够深,会被误算成一根手指。加角度限制后,只有夹角小于 90 度的锐角凹陷才计入,手腕处的凹角通常在一百多度,正好被挡掉:
| 参数 | 典型值 | 调大的后果 | 调小的后果 |
|---|---|---|---|
| depth_thresh | 20000 | 指缝浅时漏数 | 轮廓抖动被当成指缝 |
| angle_thresh | 90 | 手腕凹陷混进来 | 张开的指缝被误排除 |
| min_contour_area | 3000 | 手离得远就检测不到 | 噪点被当成手 |
调参的顺序建议是:先把掩膜调对,确保手的轮廓完整;再调面积下限,让手在画面各个位置都能稳定检出;最后才碰深度和角度。跳过前两步直接调后两个,永远调不准。
4.3 Hu 矩模板匹配做兜底
凸缺陷能数出「几根手指」,但分不清「拇指和小指伸出」与「食指和中指伸出」——这两种都是 2,几何凹陷数量一样。要区分具体手势,就需要形状描述子。Hu 矩的七个矩对平移、旋转、缩放都不敏感,正好匹配手势这种姿态多变的场景:
def hu_signature(contour): m = cv2.moments(contour) hu = cv2.HuMoments(m).flatten() # 取对数压缩动态范围,否则后几个矩小到被浮点精度吃掉 return -np.sign(hu) * np.log10(np.abs(hu) + 1e-10) def match_template(contour, templates, max_dist=0.15): sig = hu_signature(contour) best_label, best_dist = None, float("inf") for label, tpl_contour in templates.items(): dist = cv2.matchShapes(contour, tpl_contour, cv2.CONTOURS_MATCH_I1, 0.0) if dist < best_dist: best_label, best_dist = label, dist return (best_label, best_dist) if best_dist < max_dist else (None, best_dist)取对数这一步不做,七个矩里第一个可能在 1e-3 量级,第七个在 1e-30 量级,匹配时后者完全不起作用,等于只用了一个特征。max_dist=0.15是拒识阈值,超过就返回None交给上层提示「未识别」,而不是硬塞一个最接近的标签。宁可承认不认识,也别给出错误答案——这套逻辑在答辩现场比多识别两个手势更值钱。
模板库不用很大,每个手势存三到五张不同角度、不同距离的轮廓就够,存在templates/*.npy里,启动时加载一次。判别流程是先按凸缺陷数粗分类,再在同一数量的手势里用 Hu 矩细分,两级串联比单用任何一个都稳。
5. 常见问题与排查:肤色漂移、指尖粘连、帧率断崖的翻车现场
5.1 换个房间就认不出手
现象:在自己桌上跑得好好的,搬到实验室演示,掩膜里一片黑,手完全检测不到。
原因:摄像头白平衡和室内色温差异会整体平移 HSV 分布。暖光下 H 偏低、S 偏高,冷白光下正好相反,写死的阈值区间覆盖不住。
解决:加一个一次性的标定入口,让用户在目标环境下把手放进 ROI,采集 30 帧取 H 和 S 的中位数,以中位数上下浮动一定范围生成阈值,写回config.py。这套材料里calibrate.py就是干这个的。演示前跑一遍标定,比现场改代码从容得多。血泪经验是:永远不要相信「在我机器上是好的」这句话。
5.2 手指并拢时数成一根,张开又数成五根
现象:握拳识别为 1,张开识别为 5,中间的三根、四根状态乱跳。
原因:depth_thresh定得太高,三四根手指时指缝深度不够,凹陷被过滤掉;同时角度阈值让部分斜向指缝被排除。
解决:把深度阈值按轮廓面积做归一化,而不是用固定像素值。用d / 256.0 / sqrt(cv2.contourArea(contour))得到一个相对深度,再对这个比值设阈值,手离摄像头远近就不影响了。归一化后阈值通常在 0.06–0.10 之间,具体值在标定时顺便扫一遍网格搜出来。
5.3 帧率从 30 掉到个位数
现象:刚启动很流畅,跑十几秒后画面明显卡顿,手势响应滞后一两秒。
原因:每帧都在做全分辨率的高斯模糊和多次形态学,加上cv2.imshow在主线程阻塞,处理速度跟不上采集速度,帧在缓冲区里越堆越多。
解决:三件事一起做。ROI 裁剪放在最前面,只对 320×320 的图做处理;跳帧策略打开,skip_frames=2;显示前把窗口尺寸固定,避免imshow每帧重算布局。还有一个隐藏项:cap.set之后确认实际分辨率,部分驱动会给你返回 1280×720,你以为在处理 640 的图,其实一直在处理四倍像素量,用cap.get(cv2.CAP_PROP_FRAME_WIDTH)打出来核对一次。
5.4 凸缺陷阈值改了半天,效果反而更差
现象:照着教程把depth_thresh从 20000 改成 10000,结果一根手指被数成三根,握拳也被识别成张开。
原因:没有意识到d是定点数。教程里写的可能是实际像素距离,直接填进convexityDefects的返回值比较,等于把阈值放大了 256 倍。也有人把 10000 当成「10 像素」在用,实际是 39 像素,刚好落在噪点起伏的量级上。
解决:在代码里统一用depth_px = d / 256.0转成像素再比较,或者干脆封装一个to_pixels()函数,所有阈值都按像素写注释。这类单位问题是最没技术含量、又最容易吃掉一整晚的坑,写注释的时候把单位标上,能省下后面所有人的时间。
6. 验收与答辩:用一组可复现的测试把算法讲清楚
到这一步代码能跑,但「能跑」和「能交」之间还差一份可复现的验证。我一般会准备一张测试表,每个场景固定采集一段 10 秒视频,跑完统计识别结果和耗时,答辩时直接翻这张表比口头说「准确率还不错」有说服力得多:
| 用例 | 场景 | 期望结果 | 观察指标 |
|---|---|---|---|
| T1 | 正面光照,手在 ROI 中心 | 五指、握拳、OK 三种稳定切换 | 单帧耗时、切换延迟 |
| T2 | 侧向 45 度,手距摄像头 40cm | 五指可识别 | 轮廓完整度 |
| T3 | 暖色台灯下,未重新标定 | 允许下降,但不应全黑 | 掩膜有效像素占比 |
| T4 | 背景含木质桌面 | 不出现误检轮廓 | 最大轮廓面积稳定性 |
| T5 | 连续运行 5 分钟 | 帧率不衰减 | 平均 FPS 前后对比 |
每项跑三遍取中位数,耗时用cv2.getTickCount()打点,别用time.time(),后者在 Windows 上精度不够,测出来的数会飘。T3 是特意设计的失败用例:允许它在未标定时表现下降,但必须在标定后恢复。答辩时主动展示「我知道它在哪里会失效,以及怎么恢复」,比声称「我的算法没有缺陷」可信得多。
现场演示还有几个技巧。提前把摄像头索引、窗口位置、ROI 框的颜色都固化,别在台上现场找手该放哪;准备一段录好的视频作为cv2.VideoCapture("demo.mp4")的输入,摄像头万一被占用可以立刻切换;把中间掩膜图开一个小窗口并排显示,老师问「你怎么知道分割是对的」,指着那张图就行,不用解释。答辩提问里最高频的两个是「光照变化怎么办」和「为什么不用深度学习」,前者的答案就是标定流程和 V 通道放宽的实测数据,后者的答案在于可解释性和无依赖部署——每一个判断都能回溯到具体的几何量,这本身就是这套方案的价值。
从那以后我每次带这类项目,都会在写完第一版代码的当天就把测试表和标定脚本建起来,而不是等到答辩前一晚才发现换台机器就跑不动。希望帮到你。
本文还有配套的精品资源,点击获取