简介:这份资源面向人机交互课程学习者、OpenCV 入门开发者及需要完成课程设计的学生,提供一套基于手势识别控制的打地鼠游戏完整项目。项目通过 Python 调用 mediapipe 库,识别食指与中指骨节点位置判定手势,实现光标移动与击打动画地鼠,并对比有线鼠标、无线鼠标、触摸板与手势识别四种交互方式的实验得分,最终得出交互效果排序结论。压缩包共 27 个文件、约 60.1MB,包含 6 个 py 源码、4 个 xml 配置、4 个 md 说明、1 个 xlsx 原始数据表、1 个 mp4 演示视频、1 个 pdf 项目报告及 ui、png 等界面素材,代码附有详细注释。已有 272 人学习。读者可获得可直接运行的完整工程、实验数据与分析方法、项目报告与演示录屏,便于复现实验、理解手势识别判定逻辑,并借鉴人机交互对比实验的设计思路与结论表达。
1. 手势识别打地鼠:从摄像头到锤子的那条链路到底怎么搭
很多人第一次听到「手势识别控制的打地鼠」,脑子里浮现的是挥挥手就能砸中洞口的爽感,但真正动手时才发现,摄像头画面里那只手和屏幕坐标之间隔着一整套坐标映射、帧同步和判定逻辑。这个项目要解决的核心问题很具体:用普通 USB 摄像头采集手部图像,通过 OpenCV 做预处理和轮廓分析,把手势位置映射到游戏窗口的九宫格上,再用手势的「下压」或「握拳」动作触发敲击判定。它适合两类人:一类是想找一个完整的人机交互练手项目、把图像处理和游戏循环串起来的学生;另一类是想验证手势交互在低算力设备上到底能不能跑顺的工程师。整条链路不依赖深度相机,也不依赖 GPU,一台带摄像头的笔记本就能跑通,这也是它作为教学和原型验证项目最实在的地方。
2. 手势识别打地鼠的视觉链路:从 BGR 帧到掌心坐标
2.1 为什么选肤色 + 轮廓而不是直接上深度学习
手势识别这条路,常见做法分两派:一派是 MediaPipe 这类预训练模型直接出 21 个关键点,另一派是传统 OpenCV 的肤色分割加轮廓分析。这个项目标题里写的是「基于 OpenCV」,而且强调「代码有详细注释」,说明它的定位是让读者看懂每一步图像处理在干什么,而不是调一个黑盒模型。MediaPipe 确实准,但它的关键点输出对初学者来说是个黑匣子,你很难解释为什么某个手指被判定为伸出。肤色加轮廓的方案虽然鲁棒性差一些,但每一步都能可视化:HSV 阈值调完能看到二值图,形态学操作完能看到干净的区域,轮廓筛选完能画出外接矩形。对于人机交互入门来说,这种可解释性比多几个百分点的准确率更重要。
选型上我一般会这样定:如果光照可控、背景干净、手部离摄像头不超过一米,肤色加轮廓完全够用,帧率还能跑到 30 以上;如果环境光变化大或者背景有大量肤色干扰,再考虑引入 MediaPipe 做兜底。这个项目的打地鼠场景恰好属于前者,游戏区域固定,玩家手部活动范围有限,所以传统方案是合理选择。
2.2 用 OpenCV 做手部区域提取的最小可跑代码
下面这段代码是整条视觉链路的核心,它完成了从读取一帧到输出掌心坐标的全过程。你可以把它单独跑起来,先确认摄像头和阈值没问题,再往游戏循环里嵌。
import cv2 import numpy as np # 打开默认摄像头,如果有多摄像头可改成 1 或 2 cap = cv2.VideoCapture(0) # 设置采集分辨率,640x480 在大多数摄像头上帧率最稳 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while True: ret, frame = cap.read() if not ret: break # 水平翻转,让画面像镜子一样,符合直觉 frame = cv2.flip(frame, 1) # 高斯模糊降噪,核大小 5x5 是经验值 blurred = cv2.GaussianBlur(frame, (5, 5), 0) # 转 HSV,肤色在 HSV 空间比 BGR 更集中 hsv = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV) # 肤色阈值,这个范围覆盖大多数黄种人肤色,光照不同要微调 lower_skin = np.array([0, 40, 80], dtype=np.uint8) upper_skin = np.array([20, 255, 255], dtype=np.uint8) mask = cv2.inRange(hsv, lower_skin, upper_skin) # 形态学开运算去噪,闭运算填孔洞 kernel = np.ones((5, 5), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 找轮廓,只取面积最大的那个,默认是手 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: hand_contour = max(contours, key=cv2.contourArea) if cv2.contourArea(hand_contour) > 3000: # 面积太小认为是噪声 x, y, w, h = cv2.boundingRect(hand_contour) # 掌心坐标用外接矩形中心近似 palm_x = x + w // 2 palm_y = y + h // 2 cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.circle(frame, (palm_x, palm_y), 8, (0, 0, 255), -1) cv2.imshow('mask', mask) cv2.imshow('frame', frame) if cv2.waitKey(1) & 0xFF == 27: break cap.release() cv2.destroyAllWindows()逻辑上分四步:采集、降噪转色、阈值分割、轮廓筛选。参数上最需要调的是lower_skin和upper_skin里的 H 分量,如果你在暖色灯光下,H 上限可能要拉到 25;如果在冷白光下,S 下限可以降到 30。cv2.contourArea的 3000 这个阈值也要根据你离摄像头的距离改,手离得远就调小,离得近就调大。跑通这段之后,你会看到两个窗口,一个是原始画面带绿框和红点,一个是黑白 mask,调阈值的时候盯着 mask 看,直到手部区域干净、背景没有大片白色。
2.3 掌心坐标到游戏九宫格的映射与平滑
拿到掌心坐标只是第一步,直接把它映射到屏幕九宫格会有一个明显问题:手抖。摄像头每帧的掌心坐标会有几个像素的抖动,映射到游戏格子上就会在相邻格之间反复横跳,体验很差。常见做法是加一个滑动窗口平均,或者用指数移动平均。我一般用后者,计算量小,延迟也可控。
# 指数移动平均平滑,alpha 越小越平滑但延迟越大 alpha = 0.3 smooth_x, smooth_y = 0, 0 def smooth(palm_x, palm_y): global smooth_x, smooth_y smooth_x = alpha * palm_x + (1 - alpha) * smooth_x smooth_y = alpha * palm_y + (1 - alpha) * smooth_y return int(smooth_x), int(smooth_y)映射逻辑是:把摄像头画面的宽高各分成三份,得到九个区域,每个区域对应游戏里的一个洞。这里要注意摄像头画面和游戏窗口的宽高比可能不同,直接按比例映射会导致边缘区域偏移。稳妥的做法是先算归一化坐标,再乘以游戏窗口的宽高。alpha取 0.3 是个折中,太小了手移动跟不上的感觉很明显,太大了平滑效果不够。你可以从 0.2 到 0.5 之间试,找到自己觉得跟手又不抖的值。
3. 打地鼠游戏循环:手势触发敲击的判定逻辑
3.1 游戏状态机与地鼠生成节奏
打地鼠的游戏逻辑本身不复杂,但和手势结合之后,状态机要处理的事情变多了。核心状态有三个:等待手势、检测到敲击、判定命中或落空。地鼠的生成节奏决定了游戏难度,常见做法是用一个计时器,每隔固定帧数随机选一个洞弹出地鼠,地鼠停留一段时间后自动缩回。如果在这段时间内检测到敲击且敲击位置和地鼠所在洞一致,就算命中。
这里有个容易翻车的地方:地鼠的停留时间不能太短,因为手势从「移动到目标洞」到「触发敲击」有一个物理延迟,人手挥动加上摄像头采集和处理,大概在 100 到 200 毫秒。如果地鼠只停留 300 毫秒,玩家根本来不及反应。我一般会把地鼠停留时间设在 800 到 1200 毫秒之间,生成间隔从 1500 毫秒起步,随着得分增加逐渐缩短。
import random import time class WhackAMole: def __init__(self): self.score = 0 self.mole_hole = -1 # 当前地鼠所在洞,-1 表示没有 self.mole_show_time = 0 # 地鼠出现的时间戳 self.mole_duration = 1.0 # 地鼠停留秒数 self.spawn_interval = 1.5 # 生成间隔秒数 self.last_spawn = time.time() def update(self): now = time.time() # 地鼠超时缩回 if self.mole_hole != -1 and now - self.mole_show_time > self.mole_duration: self.mole_hole = -1 # 到时间生成新地鼠 if self.mole_hole == -1 and now - self.last_spawn > self.spawn_interval: self.mole_hole = random.randint(0, 8) self.mole_show_time = now self.last_spawn = now def hit(self, hole_index): if hole_index == self.mole_hole: self.score += 1 self.mole_hole = -1 # 得分越高,节奏越快,但有下限 self.mole_duration = max(0.5, self.mole_duration - 0.02) self.spawn_interval = max(0.8, self.spawn_interval - 0.03) return True return False这段代码里mole_duration和spawn_interval的递减幅度是我试出来的,每命中一次减 0.02 和 0.03,玩到后面节奏会明显加快但不会快到无法反应。如果你想让难度曲线更陡,可以把递减值调大,但建议先跑一遍感受一下,别一上来就设成 0.1,那样三四个地鼠之后就没法玩了。
3.2 手势敲击的触发条件:下压还是握拳
触发敲击的手势判定是整个项目里最需要调的部分。常见有两种方案:一种是检测手部区域面积突然变大,模拟「下压」动作,因为手靠近摄像头时成像面积会增大;另一种是检测轮廓的凸缺陷数量,握拳时凸缺陷减少。前者实现简单,但对距离变化敏感,玩家手前后移动也会误触发;后者更符合「握拳敲击」的直觉,但需要计算凸包和凸缺陷,代码量稍大。
我一般会推荐面积变化法作为入门,因为它只需要在原有轮廓代码上加几行。具体做法是维护一个手部面积的滑动窗口,当当前面积超过窗口平均值一定比例时,判定为下压。比例阈值取 1.3 到 1.5 之间比较稳。
from collections import deque area_window = deque(maxlen=10) # 最近 10 帧的面积 PRESS_RATIO = 1.4 # 超过均值 1.4 倍认为下压 def detect_press(current_area): area_window.append(current_area) if len(area_window) < 5: return False avg_area = sum(area_window) / len(area_window) if current_area > avg_area * PRESS_RATIO: area_window.clear() # 清空避免连续触发 return True return Falsemaxlen=10意味着窗口里始终保留最近 10 帧,PRESS_RATIO是灵敏度调节旋钮。如果你发现轻轻一动就触发,把它调到 1.6 甚至 1.8;如果怎么压都不触发,降到 1.2。清空窗口那一步很关键,否则下压动作会持续好几帧,一次挥手打出好几次敲击,游戏就没法玩了。
3.3 把视觉和游戏接起来的主循环
主循环要做的事情是:读一帧、提取掌心、平滑、映射到洞索引、检测下压、更新游戏状态、绘制界面。顺序不能乱,尤其是下压检测要在映射之后,因为你需要知道当前手在哪个洞上方才能判定命中。
game = WhackAMole() cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if not ret: break frame = cv2.flip(frame, 1) # ... 此处省略肤色分割和轮廓提取,沿用 2.2 的代码 ... if contours: hand_contour = max(contours, key=cv2.contourArea) area = cv2.contourArea(hand_contour) if area > 3000: x, y, w, h = cv2.boundingRect(hand_contour) palm_x, palm_y = smooth(x + w // 2, y + h // 2) # 映射到 0-8 的洞索引 col = min(2, max(0, palm_x * 3 // frame.shape[1])) row = min(2, max(0, palm_y * 3 // frame.shape[0])) hole = row * 3 + col if detect_press(area): game.hit(hole) game.update() # ... 此处绘制游戏界面 ... if cv2.waitKey(1) & 0xFF == 27: break映射那两行用了min和max做钳位,防止掌心坐标偶尔超出画面导致索引越界。hole = row * 3 + col把二维行列转成一维索引,和游戏状态机里的 0 到 8 对应。这个主循环跑起来之后,你挥动手掌应该能看到红点在画面上移动,快速下压时游戏里的地鼠会被敲掉。
4. 避坑与排查:手势打地鼠最容易翻车的五个地方
4.1 摄像头画面延迟大,手挥完了游戏才反应
现象是手已经移到目标洞上方了,游戏里的判定还停留在上一个位置,敲击总是慢半拍。原因通常是采集分辨率设得太高,比如 1920x1080,OpenCV 处理一帧的时间超过 30 毫秒,加上显示延迟就明显了。解决办法是把CAP_PROP_FRAME_WIDTH和CAP_PROP_FRAME_HEIGHT降到 640x480,如果还慢就降到 320x240。另外检查一下是不是在循环里做了不必要的cv2.cvtColor或cv2.GaussianBlur重复调用,能复用的中间结果就复用。
4.2 肤色阈值在灯光变化后失效
现象是白天调好的阈值,晚上开灯之后 mask 里全是噪点或者手部区域残缺。原因是 HSV 里的 H 分量对色温敏感,暖光灯下肤色会偏黄,H 值整体偏移。解决办法是不要写死阈值,在程序启动时加一个校准环节:让玩家把手放在画面中央,按一个键自动统计当前手部区域的 HSV 均值,然后以均值为中心上下各扩 10 作为阈值。这个校准过程只需要三行代码,但能省掉大量手动调参的时间。
4.3 背景里有肤色物体导致轮廓选错
现象是画面里有一个木色桌面或者黄色墙壁,max(contours, key=cv2.contourArea)选中的是背景而不是手。原因是肤色分割只看了颜色,没有看位置和形状。解决办法有两个:一是限制感兴趣区域,只处理画面中央偏下的部分,因为玩家手一般在这个区域活动;二是加一个长宽比筛选,手部轮廓的长宽比通常在 0.5 到 2 之间,太扁或者太长的直接排除。
4.4 下压检测误触发,没压也敲
现象是手在画面里正常移动,游戏却不停地判定敲击。原因是面积窗口的均值被某一帧的噪声拉低了,导致下一帧正常面积就超过了 1.4 倍均值。解决办法是在面积进入窗口之前先做一次中值滤波,或者把PRESS_RATIO调高到 1.6 以上。还有一个隐藏原因是摄像头自动曝光,画面亮度突变会导致轮廓面积跳变,可以在代码里关掉自动曝光:cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25),不同摄像头这个值不一样,需要试。
4.5 游戏窗口和摄像头画面不同步
现象是摄像头画面里手在左边,游戏里判定却在右边。原因是摄像头画面做了水平翻转,但映射到游戏坐标时忘了翻转回来,或者游戏窗口的宽高比和摄像头不一致导致映射偏移。解决办法是统一坐标系:要么在映射前把掌心 x 坐标用frame.shape[1] - palm_x翻回去,要么在游戏绘制时也做一次翻转。宽高比的问题用归一化坐标解决,先除以画面宽高得到 0 到 1 之间的值,再乘以游戏窗口宽高。
5. 进阶技巧:用凸缺陷数手指数提升交互维度
基础版打地鼠只用到了掌心位置和下压动作,交互维度比较单一。如果你想让项目更有说服力,可以加上手指数识别,比如伸出几根手指就代表敲击几次,或者用特定手指数切换游戏模式。手指数识别的经典做法是凸包加凸缺陷:先求手部轮廓的凸包,再找凸包和轮廓之间的凹陷区域,凹陷的数量大致对应伸出的手指之间的缝隙数,手指数量约等于凸缺陷数加一。
def count_fingers(contour): hull = cv2.convexHull(contour, returnPoints=False) if len(hull) < 3: return 0 defects = cv2.convexityDefects(contour, hull) if defects is None: return 0 count = 0 for i in range(defects.shape[0]): s, e, f, d = defects[i, 0] # 凹陷深度阈值,太浅的不算手指缝 if d / 256.0 > 20: count += 1 return count + 1 # 凹陷数加一近似手指数量d / 256.0是因为convexityDefects返回的深度是定点数,需要除以 256 转成浮点。阈值 20 是我在 640x480 分辨率下试出来的,如果你换了分辨率要按比例调。这个函数返回的手指数量可以直接映射到游戏里的连击倍数,伸一根手指是单次敲击,伸三根就是三连击,这样玩家就有了更多操作空间。
验证这个功能是否正常,可以单独写一个测试脚本,只显示摄像头画面和识别出的手指数,不接游戏逻辑。把手放在画面里,从握拳到伸出一根、两根、三根,看输出数字是否跟得上。如果数字跳变厉害,可以在count_fingers外面再加一层多数投票,连续三帧结果一致才更新显示。
我自己的习惯是,每加一个新交互维度,都先把它从游戏逻辑里剥出来单独验证,确认输入稳定了再接回去。手势识别这个方向,玄学的地方在于光照和摄像头差异,同一套代码在不同机器上表现可能完全不同,所以参数一定要留出可调接口,别写死。希望帮到你。
本文还有配套的精品资源,点击获取