简介:这是一套基于OpenCV与MediaPipe手势识别的人机交互打地鼠项目完整工程,面向计算机专业做HCI课程设计、毕业设计或交互对比实验的开发者。项目通过识别食指与中指顶部骨节点位置判定手势,完成光标移动与地鼠打击,并设计有线鼠标、无线鼠标、触摸板、手势识别四种交互方式的对照实验,涵盖实验目的、过程、得分数据、问卷调查与结论分析。资源共27个文件,含6个Python源码、5个PNG图像、4个XML配置文件、4个MD说明文档,以及Excel数据表、PDF实验报告、演示视频、UI界面等,压缩包约60.1MB,目录按实验代码、报告、视频、数据归档,便于按需查阅。已有269人学习下载。对想复现手势识别控制、学习MediaPipe骨节点调用或参考人机交互实验报告写作者,这是一套可直接运行的完整素材与过程记录。
1. 人机交互与 OpenCV 手势识别游戏:先理清交互链路再写代码
打地鼠游戏本身逻辑极简:地鼠从洞里弹出,玩家在限定时间内敲中即得分。难点从来不在游戏循环,而在「手势如何变成一次有效的敲击」。很多课程设计走到最后才发现,不是游戏不会写,而是手势识别和游戏逻辑之间的交互链路没打通——手的位置没有被正确映射成游戏坐标,握拳动作总在抖动中误触发,摄像头延迟高到让人想砸屏幕。
这个项目的核心在人机交互三个字:手在摄像头画面中的空间位置对应游戏区域坐标,手指捏合或握拳对应敲击动作。它涉及 OpenCV 的肤色分割、轮廓提取、凸包缺陷检测以及坐标映射几个环节,难度不大,但坑全在参数。适合本科软件综合实践、人机交互课程设计,以及第一次接触计算机视觉交互的开发者。拿到标题里的「源代码+数据+项目报告+演示视频」,不要急着跑主程序,先拆交互链路:采集、识别、映射、响应、反馈。每一步有独立的调试窗口,联调时才不会手忙脚乱。
环境建议用 Python 3.8 到 3.10 配 opencv-python 4.5 以上版本,装好后先确认摄像头能开,再进主流程。下面从识别侧讲起,看手势如何一步步变成游戏里一次真实的敲击。
2. 基于 OpenCV 的手势识别:HSV 肤色分割与凸包缺陷检测
2.1 为什么选传统视觉方案而不是 MediaPipe
常见做法里,手势识别有两条技术路:一是 MediaPipe 关键点模型,直接输出 21 个手部关节坐标,鲁棒性最好,但会引入模型文件和推理依赖,安装和部署成本高出不少;二是纯 OpenCV 传统视觉路线,用肤色阈值分割皮肤,再用轮廓和凸包缺陷判断手指状态。对打地鼠游戏这种只需要「伸掌 vs 握拳」两态切换的场景,传统 CV 方案完全够用。
我一般会选择传统方案,原因有三。第一,代码量可控,所有逻辑都在函数里可见,答辩时老师问原理能逐行讲清楚;第二,OpenCV 的 HSV 肤色分割适合实时摄像头帧,在低配置笔记本上也能稳定跑到 30 帧;第三,打地鼠交互只要指尖和手掌中心两个特征量,凸包缺陷已经能表达。当然,如果你想把项目扩展成 0 到 9 手势识别,那我建议换用 MediaPipe 提取关键点后再做角度特征分类,那属于另一个工程量级,不在本次标题范围内。
2.2 HSV 肤色分割的初始参数与形态学处理
OpenCV 调用相机后,默认拿到的帧是 BGR 格式。肤色在 BGR 空间受光照影响极大,同一个手在台灯下和日光下的像素值能差出好几个数量级。常见做法是转换到 HSV 色相空间,对 H 和 S 通道做阈值,把 V 亮度通道放宽,以此压低光照干扰。下面这组参数我在多种环境下验证过,暗光场景仍能分割出完整手部:
import cv2 import numpy as np def build_skin_mask(frame: np.ndarray) -> np.ndarray: # 将 BGR 帧转换到 HSV 空间,OpenCV 的 H 范围是 0~179 hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # 肤色阈值:H 在 0~25 覆盖黄种人肤色, # S 下限定在 40 过滤偏白背景,V 下限定在 60 排除极暗区域 lower = np.array([0, 40, 60], dtype=np.uint8) upper = np.array([25, 160, 255], dtype=np.uint8) mask = cv2.inRange(hsv, lower, upper) # 闭运算先膨胀后腐蚀,填平手指缝隙里的暗纹, # 中值滤波去掉离散噪点同时保留手部边缘 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) mask = cv2.medianBlur(mask, 5) return mask逻辑说明:inRange对 HSV 三个通道分别做范围检查,符合肤色的像素置 255,其余置 0,生成二值图;MORPH_CLOSE先膨胀再腐蚀,专门补手背和掌心处的暗色纹理;medianBlur相当于给二值图做降噪,窗口太小效果不明显,窗口太大会把指尖磨平。
三个核心参数:H 上界 25、S 下界 40、V 下界 60。在黄光环境下手部偏黄,把 H 上界调到 30;背景里暖色物体多,把 S 下界提到 50 能显著减少误检。mask 的质量决定后面所有计算的成败,所以先单开一个窗口观察 mask 是否完整再往下走。这也是很多 opencv 图像处理项目调不通时最容易被跳过的一步。
2.3 轮廓筛选与凸包缺陷计算指尖数量
拿到干净的 mask 后,用 OpenCV 的findContours提取外轮廓,按面积过滤掉小块背景干扰。手通常是画面里最大的连通区域,面积阈值取画面总像素的 3% 到 8%。提取最大轮廓后求凸包,再算凸包缺陷。凸包缺陷在几何上的含义是轮廓凹进去的位置——张开的指缝就在这。握拳时缺陷数量通常 0 到 1 个,五指张开时一般 3 到 4 个,所以缺陷数是最直接的「伸掌/握拳」判据。
def analyze_hand(mask: np.ndarray) -> tuple: """ 入参 mask: 二值图 返回值: (contour, center, bbox, defect_count, is_open) contour: 最大手部轮廓 center: 掌心肌矩中心 bbox: 外接矩形 (x, y, w, h) defect_count: 凸包缺陷数量 is_open: 缺陷数 >= 2 视为伸掌 """ contours, _ = cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) if not contours: return None, None, None, 0, False frame_area = mask.shape[0] * mask.shape[1] contour = max(contours, key=cv2.contourArea) if cv2.contourArea(contour) < frame_area * 0.03: return None, None, None, 0, False # 用图像矩算掌心中心,比直接求像素均值抗噪 M = cv2.moments(contour) if M["m00"] == 0: return None, None, None, 0, False cx = int(M["m10"] / M["m00"]) cy = int(M["m01"] / M["m00"]) center = (cx, cy) bbox = cv2.boundingRect(contour) # 多边形逼近后求凸包缺陷,避免原始轮廓点过密导致缺陷抖动 epsilon = 0.02 * cv2.arcLength(contour, True) approx = cv2.approxPolyDP(contour, epsilon, True) hull = cv2.convexHull(approx, returnPoints=False) defect_count = 0 if hull.ndim == 2 and len(hull) >= 3: defects = cv2.convexityDefects(approx, hull) if defects is not None: for i in range(defects.shape[0]): _, _, _, depth = defects[i, 0] # 缺陷深度大于 40 像素才算明显指缝,经验阈值 if depth > 40: defect_count += 1 is_open = defect_count >= 2 return contour, center, bbox, defect_count, is_open参数说明:RETR_EXTERNAL只取最外层轮廓,屏蔽手部纹理产生的内层干扰;CHAIN_APPROX_SIMPLE压缩轮廓表达,减少冗余点;approxPolyDP的 epsilon 控制逼近精度,0.02 是轮廓周长的比例,值越大轮廓点越少、计算越快;缺陷深度 40 是针对 640×480 分辨率调出来的,分辨率翻倍时按像素比例放大。
这里最容易被忽略的是approxPolyDP逼近。不做这一步,缺陷数量会大幅抖动,原因在于原始轮廓点太密,凸包计算对锯齿极其敏感。多个课程设计项目最后都是在这个环节加上逼近后缺陷数才稳定下来。
3. 手势识别与打地鼠游戏联动:坐标映射与敲击语义
3.1 游戏主循环的状态设计与 OpenCV 帧循环结合
手势识别代码和打地鼠游戏怎么组合,是这类项目里最考代码组织能力的部分。常见做法是单线程内先读帧、再识别、再更新游戏状态,用 OpenCV 自己的窗口显示合成画面,省去 Pygame 窗口嵌入的麻烦,延迟也最低。下面是一个精简的游戏类,定义了地鼠出现、缩回、击中等核心状态:
import random class WhackAMoleGame: def __init__(self, cols=3, rows=3, appear_time=1.2, hit_cooldown=0.5): self.cols = cols self.rows = rows self.appear_time = appear_time # 地鼠单次停留秒数 self.hit_cooldown = hit_cooldown # 敲击冷却,防长按连击 self.mole_col = None self.mole_row = None self.mole_deadline = 0 self.score = 0 self.last_hit_time = 0 def spawn(self, now: float) -> None: """生成一只新地鼠,跳过当前格避免原地出现""" candidates = [ (c, r) for c in range(self.cols) for r in range(self.rows) if (c, r) != (self.mole_col, self.mole_row) ] self.mole_col, self.mole_row = random.choice(candidates) self.mole_deadline = now + self.appear_time def try_hit(self, col: int, row: int, now: float) -> bool: """敲击命中判定,带冷却去抖""" if (col, row) != (self.mole_col, self.mole_row): return False if now - self.last_hit_time < self.hit_cooldown: return False self.score += 1 self.last_hit_time = now self.mole_col = None self.mole_row = None return True参数说明:appear_time是地鼠存活时长,1.2 秒适合新手,熟练后可以压到 0.8 秒;hit_cooldown的作用是过滤同一姿势的重复触发,本质上是时间维度的防抖,0.5 秒意味着每秒最多两次有效敲击。spawn里跳过当前格子的逻辑,能避免地鼠原地连出,便于统计命中率。
3.2 从摄像头坐标到游戏格子坐标的映射
摄像头画面和游戏画布的坐标系不一致,手部中心点需要换算成格子索引。最常见的做法是线性映射:把整个画面均分成 3×3 九宫格,手落在哪个区域就敲哪个格子。零标定、容忍手部抖动,缺点是画面边缘精度差。更稳的方案是让玩家用cv2.selectROI划出一个交互矩形,再把矩形映射到游戏画布:
def map_point_to_cell(point, roi, cols=3, rows=3): """ 将手部中心点映射到 3x3 格子索引 roi = (x, y, w, h) 由 selectROI 划定 返回 (col, row),点在外时返回 None """ px, py = point rx, ry, rw, rh = roi if not (rx <= px <= rx + rw and ry <= py <= ry + rh): return None col = int((px - rx) / rw * cols) row = int((py - ry) / rh * rows) col = max(0, min(cols - 1, col)) row = max(0, min(rows - 1, row)) return col, row逻辑说明:先把绝对坐标转成相对 ROI 左上角的偏移量,再做等比例缩放。这里容易出错的是直接在原坐标上乘比例——ROI 不在原点时映射结果整体偏移,排查起来很隐蔽。max/min夹取是为了防止手在边界处越界返回非法索引。
我建议在主循环里用cv2.selectROI("calibration", frame)交互式选一次区域,然后把 ROI 持久化到一个config.json。这样每次启动不用重复标定,也方便调参时固定操作区。
3.3 伸手移动、握拳敲击的语义设计
敲击语义定义为:缺陷数大于等于 2 视为伸掌移动,缺陷数小于等于 1 视为握拳敲击。这个定义直觉、稳定,且和凸包缺陷的物理含义一致。但单帧识别结果不可信,需要连续多帧确认才触发动作,防止手抖造成误触发。下面的手势状态机是这类项目的常见实现:
class GestureMapper: def __init__(self, confirm_frames=4): self.confirm_frames = confirm_frames self.is_open = True self.open_counter = 0 self.fist_counter = 0 def update(self, is_open: bool) -> str: """ 返回当前稳定手势: "open" 或 "fist" 连续 confirm_frames 帧识别为同一手势才翻转状态 """ if is_open: self.fist_counter = 0 self.open_counter += 1 if self.open_counter >= self.confirm_frames: self.is_open = True self.open_counter = self.confirm_frames return "open" if self.is_open else "transition" else: self.open_counter = 0 self.fist_counter += 1 if self.fist_counter >= self.confirm_frames: self.is_open = False self.fist_counter = self.confirm_frames return "fist" if not self.is_open else "transition" if self.is_open: self.open_counter = 0 else: self.fist_counter = 0 return "open" if self.is_open else "fist"这段代码的要点:两个计数器分别统计伸掌和握拳的连续帧数,谁先达到阈值谁翻转状态。翻转后计数器固定住,避免在临界帧反复横跳。return "open"/"fist"/"transition"三种状态的语义,刚好覆盖「移动中、敲击中、状态未定」三类情况。游戏侧只在状态为fist时触发一次敲击,状态翻转触发一次,不做持续判断。
4. 手部识别参数调优与避坑:把误触发压到可玩程度
4.1 调参顺序:先定光照,再调 HSV,最后调后端参数
很多人拿到项目直接调算法参数,结果越调越乱,原因是没有按照依赖顺序排查。正确顺序是:先固定环境光照,再调 HSV 分割阈值,最后调凸包缺陷深度和确认帧数。换任何一项前置条件,后面参数都要重新验证。台灯直射和日光侧照会产生完全不同的 mask,这属于摄像头物理条件,代码解决不了。
在固定光照后,我的做法是先看 mask 单独窗口:手部是否完整、背景是否有色块、指尖是否断开。mask 稳了再进轮廓阶段,观察缺陷数量是否随张开手指稳定增减。最后一个阶段调confirm_frames,这几步必须分开验证,混合在一起会互相干扰。
4.2 误触发来源与对应参数表
以下是多年课程设计里最常见的误触发来源和处理方式,直接对表排查比逐帧调试快很多:
| 现象 | 根因 | 排查手段 | 参数调整 |
|---|---|---|---|
| 背景肤色物体误识别 | S 下界过低 | 观察 mask 背景残留 | S 下界从 40 提到 50~55 |
| 手指间缺陷抖动 | 轮廓锯齿过密 | 缺陷数在伸掌时跳变 | approxPolyDP的 epsilon 从 0.02 提到 0.03 |
| 握拳时缺陷数飙升 | 拇指压在掌心形成浅缝 | 握拳时打印缺陷深度 | 缺陷深度阈值从 40 提到 60 |
| 敲击动作频繁重复计分 | 无冷却或冷却过短 | 观察 score 增速 | hit_cooldown从 0.3 提到 0.5 |
| 手部离镜头过远 | 面积阈值比例过高 | 距离变化时跟踪面积 | 面积阈值从 0.05 降到 0.02 |
| 运动模糊导致轮廓断裂 | 帧率不足 | 移动时 mask 碎裂 | 降低分辨率到 640×480 |
4.3 性能瓶颈怎么定位:别一上来就怀疑算法慢
实时识别项目跑起来后帧率低,先分段计时定位瓶颈,不要凭感觉优化。常见做法是用time.perf_counter()围绕预处理、分割、轮廓、缺陷四个阶段分别计时,打印耗时的百分位数。按我的经验,瓶颈通常出现在medianBlur、approxPolyDP和convexityDefects三个位置,而不是cvtColor或inRange。
优化优先改三处:medianBlur核从 7 降到 5,耗时减少约三成,副作用是可以接受;approxPolyDP的 epsilon 从 0.02 提到 0.03,轮廓点变少,缺陷计算和绘图都提速;输入分辨率固定为 640×480,1080p 对手势识别没有精度收益,反而把每帧处理时间拉长三倍。以下是一套稳妥的摄像头配置:
cap = cv2.VideoCapture(0) if not cap.isOpened(): raise IOError("无法打开摄像头,请检查设备占用与驱动") cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30) # 关闭自动曝光,固定曝光值,防止亮度突变导致 mask 大面积翻转 cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) cap.set(cv2.CAP_PROP_EXPOSURE, 120)参数说明:CAP_PROP_AUTO_EXPOSURE在很多摄像头驱动里设为 0.25 或 1 表示关闭自动模式,不同摄像头厂家的取值有差异,实测后以画面亮度稳定为准;CAP_PROP_EXPOSURE是相机原始参数,建议从 100 起步小步调。关掉自动曝光能显著减少手部在镜头前快速移动时亮度闪跳导致的 mask 波动,这个细节是很多 opencv 安装教程和基础例程不会提的。
4.4 代码注释的技术组织方式
标题强调「代码有详细注释」,我建议不要在每行后面堆一行注释,而是在每个函数开头写清「入参、出参、边界条件」。比如analyze_hand函数注释里写明「mask 必须是二值图;返回的 center 为 None 表示未检测到手」。这种注释风格在答辩时价值极高,老师顺着函数签名就能还原整个交互流程。如果代码里每个函数都有这种头部注释,项目报告的第 4 章「系统实现」直接按函数展开就能成文,代码和报告能互相印证。
4.5 用录制数据回放定位问题
联调时遇到难复现的误触发,我一般会先用VideoWriter录一段原始帧到磁盘,再离线重放这份数据并叠加识别结果。由于重放不依赖实时摄像头,可以反复调整阈值,相同帧数据还能作为「数据」文件夹的一部分提交。标题里「源代码+数据」里的数据,相当一部分指的就是这种带标注的离线测试数据。录制放数据、cv2.waitKey前后衔接可以做得很简单,但实用的点在于,离线回放速度可调,调试效率比对着实时画面高很多。
def record_test_clip(duration=10.0): cap = cv2.VideoCapture(0) fps = 30 out = cv2.VideoWriter( "test_frames.avi", cv2.VideoWriter_fourcc(*"XVID"), fps, (640, 480), ) import time start = time.time() while time.time() - start < duration: ret, frame = cap.read() if not ret: break mask = build_skin_mask(frame) contour, center, bbox, defects, is_open = analyze_hand(mask) if contour is not None: cv2.drawContours(frame, [contour], -1, (0, 255, 0), 2) if center: cv2.circle(frame, center, 6, (0, 0, 255), -1) out.write(frame) cap.release() out.release()注意:向回放帧叠加识别结果时延迟状况是「回放帧率 × 当前处理时间」的组合,离线看到的现象与实时有一定偏差,所以要同时保留纯帧数据和叠加数据两个版本,方便对比。
5. 多手势扩展与参数收敛验证
5.1 在缺陷计数上扩展 0~9 手势识别
打地鼠只需要伸掌和握拳两类交互,但很多人做完后会想扩展成 0 到 9 手势识别。常见做法是在缺陷计数的基础上追加两个维度:手势轮廓的外接矩形长宽比、手部区域内白色像素占比。比如一指时外接矩形明显更「瘦长」,五指时更接近方形;握拳时白色像素占比低,张开时占比高。综合这两个特征,缺陷数配合长宽比、占空比,可以稳定区分 0~5 的多数手指数。不过要识别完整的 0~9,我建议切到 MediaPipe 关键点路线,用 21 个关节点的指尖坐标和关节夹角做分类,纯几何特征在复杂背景上不够稳。
def compute_shape_features(mask, contour): """计算用于手指数扩展的特征向量""" x, y, w, h = cv2.boundingRect(contour) aspect_ratio = w / h # 手部区域内的皮肤像素占比 contour_mask = np.zeros_like(mask) cv2.drawContours(contour_mask, [contour], -1, 255, -1) roi = mask[y:y + h, x:x + w] filled_area = cv2.countNonZero(roi) rect_area = max(1, w * h) fill_ratio = filled_area / rect_area return aspect_ratio, fill_ratio特征说明:aspect_ratio表示轮廓外接矩形的宽高比,一指朝上时明显小于 1,五指张开时接近 1;fill_ratio是轮廓内部皮肤像素占矩形面积的比例,握拳时手指收拢,填充率高于伸直状态。标准 0~9 分类器常以这两个特征配合缺陷数做决策树或 SVM,特征维度少、解释性强,答辩时能直接画特征空间分布图。
5.2 观察缺陷深度阈值对判据的敏感性
凸包缺陷深度阈值是这套识别里最敏感的参数。太小时握拳时拇指压缝会被误判成有效缺陷,太大时伸掌的细指缝又会被过滤掉。我调参时的经验做法是:在固定光照下把缺陷深度从 20 到 100 分别统计缺陷数的分布,画一张折线图,找到「握拳缺陷数一直小于 2」且「伸掌缺陷数稳定大于等于 2」的区间,取中值作为最终阈值。通常这个区间在 40~70 之间,光照不稳时会缩窄,说明需要先优化光照。
5.3 离线回放验证帧序列的语义翻转
录好一段完整测试视频后,将其作为VideoCapture的输入,逐帧打印手势状态变化的时间点。正常的动作序列应呈现出「open…open…fist…open…fist」的清晰翻转,且每段持续时间符合预期。如果出现「open → fist → open → fist」在几百毫秒内反复横跳,说明确认帧数不够或缺陷深度阈值落在敏感区间。离线回放比实时调试更快定位这类状态机问题,因为回放是确定性数据,每次改动只受参数影响,不引入摄像头噪声。
以下是完整的离线验证主循环骨架,可直接复用到自己的项目里作为回归测试入口:
def offline_validate(video_path: str, game=None): cap = cv2.VideoCapture(video_path) mapper = GestureMapper(confirm_frames=4) fps = cap.get(cv2.CAP_PROP_FPS) or 30 frame_idx = 0 while True: ret, frame = cap.read() if not ret: break mask = build_skin_mask(frame) _, center, bbox, defects, is_open = analyze_hand(mask) gesture = mapper.update(is_open) t = frame_idx / fps if game and gesture == "fist" and center: col, row = map_point_to_cell(center, bbox) if col is not None: game.try_hit(col, row, t) frame_idx += 1 cap.release() return frame_idx验证逻辑:先跑通这条离线路,确认手势翻转稳定和命中正确率达标,再切换到实时摄像头。到这一步,项目就从一个「跑得起来的打包源码」变成了「每一环都能复现、能解释、能扩展」的完整人机交互课程设计。
本文还有配套的精品资源,点击获取