石头剪刀布手势识别是我在计算机视觉入门案例里最常推荐别人先做的一个项目。它看起来像个玩具但把“摄像头取流 → 手部检测 → 特征提取 → 分类决策 → 实时反馈”这条视觉处理链路完整串了一遍。我用的方案是 OpenCV 负责图像采集和画面绘制Google MediaPipe 负责手部关键点检测最后用一组简单的几何规则完成石头、剪刀、布的判别。整套代码在普通笔记本上可以跑出接近实时的帧率识别稳定度也在可接受范围内。如果你正在学计算机视觉或者手上刚好缺一个能演示的课程设计/大作业项目这篇把实现细节、参数调优和踩坑记录全部摊开讲。1. 整体设计与方案选型1.1 为什么不用“轮廓凸包”的老路子早期做手势识别最经典的方法是基于肤色分割把 RGB 转到 HSV 或 YCbCr 空间用颜色阈值把手从背景里抠出来再提取轮廓、计算凸包和凸缺陷靠手指之间的凹陷数量来判断伸出了几根手指。这套方案我大学时也折腾过最大的问题是鲁棒性太差。肤色阈值一换环境就要重新调室内暖光和窗外自然光下拍出来的手完全是两个色域背景里一旦出现和肤色接近的物体木桌、纸箱、皮肤色沙发分割结果直接崩。而且轮廓要求手部必须完整出現在画面中且尽量远离身体实际操作起来非常别扭。更致命的是石头和布这种“没有指尖凹陷差异”的手势用凸缺陷数量根本没法区分。你总不能为了做一个猜拳识别先训练一个手势分割模型再训练一个分类模型这对入门项目来说完全不合理。1.2 MediaPipe 手部关键点方案的实际优势后来我换成了 MediaPipe Hands。它的核心是一个在大量真实手部数据上训练过的关键点检测模型单帧图像输入直接输出 21 个手部关键点的归一化坐标x、y以及可以忽略的 z 深度值。这套方案的好处在于不需要自己准备数据集不需要训练模型开箱即用真正把精力集中在“如何用特征做判断”上。不依赖肤色光照和背景的变化对它影响小只要手部轮廓隐约可见就能检测。输出的是结构化坐标而不是像素掩膜。我可以直接计算指尖到指根的距离、手指之间的夹角用规则完成分类整个逻辑可解释、可调试。对比起来传统肤色方案像是你手动做一把粗糙的尺子MediaPipe 则是直接给了你一把精度不错的游标卡尺剩下的只是学会怎么读数。1.3 识别规则的本质把三选一变成几何判断石头、剪刀、布为什么适合用“关键点距离/角度”来判断因为它们本质上是四根手指食指、中指、无名指、小指的伸展组合石头四根手指全部弯曲指尖缩向掌心。剪刀食指和中指伸直无名指和小指弯曲。布四根手指全部伸直展开。也就是说我不需要理解“手”这个高维概念只需要判断“每一根手指是否伸直”。一根手指是否伸直在关键点坐标上非常直观伸直时指尖到手腕的距离接近“指根到手腕的距离”加上“指尖到指根的距离”弯曲时指尖被拉向掌心这两个距离会明显缩短。所以整个项目最核心的环节就是把手势识别问题转化为“若干根手指的伸直度判断”问题。把这个想清楚代码其实只是一个很薄的封装。2. 开发环境与核心参数准备2.1 依赖安装与最小环境验证我用的是 Python 3.10 OpenCV 4.8 MediaPipe 0.10操作系统 Windows 11。安装命令很简单pip install opencv-python mediapipe如果你之前没装过 OpenCV建议同时装一个opencv-contrib-python部分追踪算法在扩展包里更全。MediaPipe 在 0.10.x 版本后不再提供mediapipe.solutions之外的复杂 API 变化老教程里的代码大概率还能用这点不用担心。装完先跑一个最小验证脚本确认摄像头能打开、MediaPipe 能正常加载模型import cv2 import mediapipe as mp mp_hands mp.solutions.hands hands mp_hands.Hands(static_image_modeFalse, max_num_hands1) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break result hands.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) if result.multi_hand_landmarks: print(detected) break cap.release()如果打印出detected说明环境和摄像头都没问题可以继续往下做。如果一直没输出优先检查摄像头索引0 可能被占用改成 1 或 2以及 Python 进程是否有摄像头权限。2.2 手部关键点分布与参考点选取MediaPipe Hands 返回的 21 个关键点编号分布如下编号区间对应位置说明0手腕全局基准点很多距离计算都以它为起点1-4拇指1 是腕掌关节4 是拇指尖5-8食指5 是食指根部 MCP8 是食指尖9-12中指9 是中指根部 MCP12 是中指尖13-16无名指13 是无名指根部16 是尖17-20小指17 是小指根部20 是尖我实际使用中最常用的是三类点手腕0、指尖4/8/12/16/20、指根5/9/13/17。指根点选 MCP 而不是更靠外的 PIP因为 MCP 位置相对稳定不会因为手指弯曲幅度变化而剧烈移动。还有一个容易忽略的细节MediaPipe 返回的坐标是归一化的0 到 1绘制到画面上时要乘以图像的宽和高h, w, _ frame.shape cx, cy int(lm.x * w), int(lm.y * h)如果不乘你会发现所有点都挤在左上角那一小片区域几乎画不出手型。2.3 特征量化距离比和角度到底用哪个判断手指是否伸直我试过三种方式第一种是指尖到手腕的绝对像素距离。这个方案最直觉但基本不可用。因为当手靠近摄像头时同样伸直的食指像素距离可能从 300 跳到 500手离远时可能只有 80。绝对阈值的适配性极差。第二种是“距离比”用“指尖到手腕的距离”除以“对应指根到手腕的距离”。这个比值受手和镜头之间的距离影响较小因为我是在同一张图里做除法远近缩放会同时作用在分子和分母上。实测下来手指完全伸直时比值通常在 1.6 到 2.2。手指弯曲收起时比值一般在 1.1 到 1.4。这个方案简单有效而且不需要额外计算角度是很多手势识别教程里的默认做法。第三种是“夹角法”计算“指尖-指根-手腕”三点形成的角度。伸直时接近 180 度弯曲时明显小于 150 度。这个方案的优点是角度对距离完全不敏感缺点是当手指弯曲过度时指尖的位置可能和指根重叠导致角度计算出现不稳定跳变。我的最终选择是主力使用距离比同时用指尖和指根之间的相对位置作为辅助过滤条件。这样做的好处是逻辑简单出问题时容易定位到是哪根手指的阈值设置不合理。3. 完整代码实现与逐段解析3.1 初始化配置与模型参数说明先给出初始化代码import cv2 import mediapipe as mp import math mp_drawing mp.solutions.drawing_utils mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeFalse, max_num_hands1, min_detection_confidence0.7, min_tracking_confidence0.5 )这几个参数我逐个调过说下结论static_image_modeFalse告诉模型这是视频流模式模型会优先使用上一帧的跟踪结果来定位手速度更快。max_num_hands1猜拳场景只用一只手限制数量能减少计算量。min_detection_confidence0.7第一次检测出手的置信度阈值。低于 0.5 时容易在复杂背景里误检高于 0.9 时偶尔会漏检0.7 是我实测的平衡点。min_tracking_confidence0.5跟踪置信度阈值。这个值设太高会导致手快速移动时频繁回到重新检测状态画面判定会卡顿。然后写一个通用的欧氏距离函数def distance(p1, p2): return math.sqrt((p1[0] - p2[0]) ** 2 (p1[1] - p2[1]) ** 2)3.2 手部检测结果转坐标列表MediaPipe 的返回值result.multi_hand_landmarks是一个由NormalizedLandmarkList组成的列表每个元素代表一只手。我需要把归一化坐标转成像素坐标方便后续计算def landmarks_to_coords(hand_landmarks, w, h): coords [] for lm in hand_landmarks.landmark: coords.append((int(lm.x * w), int(lm.y * h))) return coords这里有一个我想额外提醒的点lm.x和lm.y是相对整个输入图像的归一化坐标。如果你在送入模型前手动对图像做了裁剪坐标参照系也会跟着变化返回来的坐标同样是相对于裁剪后图像的。我在早期调试时裁过图结果所有关键点都偏离了手掌位置排查了很久才意识到是坐标参照系的问题。3.3 手势判定逻辑实现现在到最核心的部分。我定义了一个judge_gesture函数输入是 21 个关键点坐标输出是字符串rock、scissors或paper。先实现一个辅助函数判断单根手指是否伸直def is_finger_extended(coords, tip_idx, mcp_idx): wrist coords[0] tip coords[tip_idx] mcp coords[mcp_idx] dist_tip_wrist distance(tip, wrist) dist_mcp_wrist distance(mcp, wrist) return (dist_tip_wrist / dist_mcp_wrist) 1.6这里的 1.6 是核心经验阈值。不同人手型会有差异但正常情况下伸直手指比值一定远大于 1.6弯曲手指很难超过 1.6。我试过 1.5石头手势时食指如果轻微翘起就容易被误判成伸出状态试过 1.7布手势时如果手指没有完全张开又可能被当成弯曲。1.6 对多数成年人手型最稳。接着是整体判定def judge_gesture(coords): index_ext is_finger_extended(coords, 8, 5) middle_ext is_finger_extended(coords, 12, 9) ring_ext is_finger_extended(coords, 16, 13) pinky_ext is_finger_extended(coords, 20, 17) if index_ext and middle_ext and ring_ext and pinky_ext: return paper if index_ext and middle_ext and (not ring_ext) and (not pinky_ext): return scissors if (not index_ext) and (not middle_ext) and (not ring_ext) and (not pinky_ext): return rock return unknown我没有单独判断拇指原因很简单在猜拳规则里拇指的弯曲与否不影响这三个手势的区判结果。布要求四指全伸剪刀要求食指和中指伸石头要求四指全屈。拇指在绝大多数人出拳时都会保持在自然位置把它加进去反而会因为个体差异引入额外噪声。还有一个小细节石头手势下很多人的食指不会完全贴在掌心而是自然翘起。这导致index_ext有可能在 1.5 左右离 1.6 阈值很近。我最终加的兜底逻辑是如果四根手指都不满足伸直条件就判石头即使其中一根手指的比值在 1.5 到 1.6 之间略高于阈值也因为它还没有达到“明显伸直”的程度不影响整体判断。3.4 主循环与实时显示主循环负责读摄像头、做检测、判手势、绘制反馈cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while cap.isOpened(): ret, frame cap.read() if not ret: continue frame cv2.flip(frame, 1) rgb_frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) result hands.process(rgb_frame) if result.multi_hand_landmarks: for hand_landmarks in result.multi_hand_landmarks: coords landmarks_to_coords(hand_landmarks, frame.shape[1], frame.shape[0]) gesture judge_gesture(coords) mp_drawing.draw_landmarks(frame, hand_landmarks, mp_hands.HAND_CONNECTIONS) cv2.putText(frame, fGesture: {gesture}, (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow(Rock Paper Scissors, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()有几个操作层面的经验分享cv2.flip(frame, 1)这一步千万别省略。摄像头默认画面是镜像的你的左手在屏幕里会出现在右侧。如果不做水平翻转识别结果本身虽然不受影响但你在画面里看自己的动作会非常别扭演示效果也差。输入给hands.process()的图像必须转成 RGB因为 MediaPipe 训练时用的是 RGB 输入而 OpenCV 读帧默认是 BGR。如果忘了转可靠率会显著下降虽然不至于完全失效但手部关键点会偶尔跳动。3.5 界面增强与简单反馈只显示一句Gesture: rock其实有点单调。我后续给这个项目加了两样东西推荐你也试试。第一样是在画面上把每根手指的是否伸直状态画出来。比如食指伸直时在指尖画一个绿色圆点弯曲时画红色圆点。这样你就能实时观察阈值工作是否正常if is_finger_extended(coords, 8, 5): cv2.circle(frame, coords[8], 8, (0, 255, 0), -1) else: cv2.circle(frame, coords[8], 8, (0, 0, 255), -1)第二样是把识别结果和电脑出拳做一个简易猜拳对战随机生成电脑手势算输赢并显示在画面顶部import random COMPUTER_CHOICES [rock, scissors, paper] def judge_winner(player, computer): if player computer: return draw if (player rock and computer scissors) or \ (player scissors and computer paper) or \ (player paper and computer rock): return win return lose这里有个反直觉的小细节字典顺序上我写的是rock, scissors, paper因为random.choice对列表顺序没有特殊要求但如果你后续要按概率加权出拳列表顺序会影响权重配置逻辑建议提前想清楚。4. 常见问题与调优思路4.1 检测不到手或者手时有时无如果你跑起来发现画面里手偶尔消失、判定结果一顿一顿先别怀疑代码逻辑大概率是检测置信度或者拍摄环境的问题。我遇到最多的情况是手离摄像头太远或太近。最佳距离是 40 到 60 厘米手部占画面高度约三分之一到二分之一。太远时模型要被迫缩小视野关键点精度直线下降太近时手掌会超出画面模型只能在画面边缘看到半只手检测直接失败。光线也是一个重要变量。MediaPipe 对光照的容忍度远高于肤色分割但如果你手背处于强光逆光状态画面里手部轮廓和背景融为一体检测照样会失败。我的建议是优先保证手部有均匀的环境光不要逆着窗户光拍摄。参数层面如果检测率实在上不去可以尝试把min_detection_confidence从 0.7 降到 0.5但要注意背景误检可能增加。把图像分辨率从 640x480 提到 1280x720给模型更多像素细节。代价是推理耗时增加帧率下降。如果 CPU 性能有限可以把输入分辨率固定在 640x480但优先保证帧率稳定因为模型内部会缩放到 256x256 做检测过高的原始分辨率并不等于更高的关键点精度。4.2 三种手势偶发互串特别是剪刀被识别成布这个是我实际调试时最常遇到的问题。原因通常出在无名指的判定上很多人出剪刀时无名指会跟着中指一起轻微抬起导致ring_ext的比值在 1.4 到 1.7 之间波动。你可以用我前面提到的彩色圆点可视化方法实时观察。如果屏幕里无名指尖显示绿色圆点判定为伸直而肉眼看上去它只是微抬说明阈值 1.6 对你来说太近了。可以提高到 1.8代价是布手势时如果手指没有完全张开可能会被漏判。另外一个更稳健的做法是加“状态保持”逻辑也叫防抖窗口。因为视频流里相邻帧的判定结果不应该频繁跳变我们可以要求连续 N 帧都出现同一个手势才真正切换输出class GestureStabilizer: def __init__(self, window5): self.window window self.history [] def update(self, gesture): self.history.append(gesture) if len(self.history) self.window: self.history.pop(0) if len(self.history) self.window and len(set(self.history)) 1: return self.history[0] return None把每一帧的judge_gesture结果喂给stabilizer.update()只有返回非 None 时才刷新画面上的手势文本。这样即使偶尔有一两帧把剪刀误判成布整体显示还是会保持稳定。窗口长度建议 3 到 8 帧太长会让操作感觉“慢半拍”太短起不到过滤效果。4.3 多人同框时识别到手但不稳定如果有人从画面边缘路过或者另一只手进入画面max_num_hands1会强制只保留一个手。MediaPipe 默认返回置信度最高的那只手但“置信度最高”在两个人的手同时出现时不一定是你想控制的那只手。解决思路有两个方向第一个是物理隔离也是最实用的方法不要让手在画面里反复进出尽量保持只有目标手出现在镜头范围内。第二个是用multi_hand_landmarks遍历所有检测到的手并自行选择“面积最大”或“最靠近画面中心”的那只手。这个逻辑需要自己实现因为 MediaPipe 本身不提供手部 ID 的稳定跟踪。4.4 判定结果反应慢像有延迟延迟有两个来源一个是推理耗时一个是上面的防抖窗口。推理耗时可以通过提高帧率来缓解。我实测 640x480 分辨率下单手检测在 i5 笔记本的 CPU 上大概需要 25 到 40 毫秒加上界面绘制和显示能到 25 到 30 帧每秒交互手感可以接受。如果你觉得卡优先检查是不是每次循环都手动绘制了太多 Landmark。mp_drawing.draw_landmarks会画 21 个点和 20 条连线对性能有一点消耗。测试时可以把绘制代码临时注释掉对比帧率。还有一个技巧是隔帧推理奇数帧只显示上一帧的缓存结果偶数帧才做检测。这样能把有效推理次数减半帧率几乎提升一倍但画面的逻辑延迟会增加大约一帧对猜拳这种低实时性场景影响很小。4.5 不同人手型和手势幅度差异同一个阈值 1.6在小孩子手上可能完全不适用。小孩手指短弯曲时指尖到手腕的绝对距离本来就小距离比偶尔会跳到 1.5 以上。如果要给别人演示最好在程序里留一个可调阈值变量而不是写死在函数里EXTEND_THRESHOLD 1.6甚至可以用“手指弯曲程度”替代“是否伸直”的二值判断输出一个 0 到 1 之间的分值。猜拳虽然只需要三分类但如果你想在这个项目基础上扩展出“数字识别”或者其他手势连续分值会让你后续好做很多。我在实际使用中发现手势识别项目最容易翻车的不是模型选择而是“判定规则写得过于理想化”。真实世界里每个人的手型、手指关节灵活度、出拳习惯都不一样同一个手势在不同人身上可能产生完全不同的关键点比值。所以做这类项目时一定要给自己留一个可视化调试的窗口把原始关键点画出来、把中间量打印出来。先让逻辑对“你的手”稳定再考虑推广到其他人。这个案例调完之后我对“识别距离、阈值、抖动”这三件事有了完全不一样的体感。后面不管你是继续做人体姿态识别还是做其他基于关键点的交互应用这套“关键点提取 几何规则 防抖输出”的组合都可以复用。建议你亲手把代码跑一遍调一调阈值看看彩色圆点在手指弯曲和伸直时怎么变化那比看十篇原理文章都管用。
阅读完成 · 觉得有帮助?