Python+OpenCV手势识别:HSV分割、凸包缺陷与Hu矩判别 简介该资源面向学习计算机视觉与图像处理的高校学生及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_index0, frame_width640, skip_frames1): 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_frames1表示每帧都算机器吃力就改成 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 而不是 RGBRGB 三个通道都在同时受光照影响同样一只偏黄的手白天摄像头下是 (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_size5): 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, iterations2) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations3) return cv2.GaussianBlur(mask, (5, 5), 0)顺序不能反。先闭后开的话闭运算会先把噪点和手掌连成一片开运算再想砍就得用更大的核结果把手指也一起削掉。核形状选椭圆而不是矩形是因为手指轮廓是弧形的椭圆核腐蚀膨胀后边缘更接近原始形状矩形核会在指尖留下方角后面算凸缺陷时角度判断容易误判。S 下限设 40 是过滤掉灰白色物体V 下限设 60 是过滤掉暗部阴影。这两个值不是拍脑袋S 太低会把白墙、白纸全收进来V 太低会把阴影也算成肤色而阴影通常在手的下方一合并轮廓就拖出一条尾巴。3.3 最大轮廓与面积过滤掩膜出来往往不止一个连通域脸、胳膊、背景里一块木头都可能是候选。取面积最大的那个是性价比最高的策略但必须加面积下限否则手完全离开画面时程序会把一个 200 像素的噪点当成手然后一本正经地数出 0 根手指看起来像识别失败其实是根本没检测到手。def largest_contour(mask, min_area3000): contours, _ cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None c max(contours, keycv2.contourArea) if cv2.contourArea(c) min_area: return None # 返回 None 表示这一帧没有手与识别为 0区分开 return cmin_area3000对应 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_thresh20000, angle_thresh90): if contour is None or len(contour) 5: return 0, [] # returnPointsFalse 才返回索引True 返回坐标后续无法传给 defects hull_idx cv2.convexHull(contour, returnPointsFalse) 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, tipsreturnPointsFalse这一行是整段代码里最容易错的地方。传True拿到的是凸包顶点坐标convexityDefects要的是顶点在原始轮廓里的下标类型对不上直接抛异常。网上不少示例把两种写法混着贴跑起来就报错。4.2 两个阈值的实际含义d是缺陷最远点到凸包边的距离但它是定点数真实像素距离大约要除以 256。所以depth_thresh20000换算下来约等于 78 像素。这个量级怎么定在 640×480、ROI 320×320 的条件下两根手指之间的 V 形凹陷深度通常在 60–150 像素指缝浅的时候会掉到 40 左右。设 20000 是宁可漏数也不误数——把阈值调低手掌边缘的微小起伏都会被当成指缝一根手指能给你数出三根来演示时非常尴尬。angle_thresh90是第二道闸。只靠深度判断手腕和手臂连接处的凹陷也够深会被误算成一根手指。加角度限制后只有夹角小于 90 度的锐角凹陷才计入手腕处的凹角通常在一百多度正好被挡掉参数典型值调大的后果调小的后果depth_thresh20000指缝浅时漏数轮廓抖动被当成指缝angle_thresh90手腕凹陷混进来张开的指缝被误排除min_contour_area3000手离得远就检测不到噪点被当成手调参的顺序建议是先把掩膜调对确保手的轮廓完整再调面积下限让手在画面各个位置都能稳定检出最后才碰深度和角度。跳过前两步直接调后两个永远调不准。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_dist0.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_dist0.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_frames2显示前把窗口尺寸固定避免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 通道放宽的实测数据后者的答案在于可解释性和无依赖部署——每一个判断都能回溯到具体的几何量这本身就是这套方案的价值。从那以后我每次带这类项目都会在写完第一版代码的当天就把测试表和标定脚本建起来而不是等到答辩前一晚才发现换台机器就跑不动。希望帮到你。本文还有配套的精品资源点击获取