石头剪刀布识别,听起来像个玩具项目,但它其实是检验一套目标检测工作流是否健壮的绝佳场景。我自己拿YOLOv5、v8、v10三套框架分别做过这个静态手势识别系统,从数据采集、标注、训练到摄像头实时推理,完整跑通之后,很多只在文档里看不出来的细节都变成了实打实的经验。这篇文章就把这条链路完整拆给你看,包括方案怎么选、数据集怎么整理、训练参数怎么定、部署阶段有哪些坑,以及最终我是怎么在三个版本之间做取舍的。
这个系统能做什么?一句话概括:通过摄像头实时识别画面中的石头、剪刀、布三种静态手势,输出类别和手部位置框。它适合做入门级人机交互Demo、儿童互动游戏、智能家居手势控制的原型验证。对刚接触目标检测的同学来说,石头剪刀布三类目标形态差异清晰、数据获取容易,是比车辆行人更友好的练手任务。对已经在跑检测项目的开发者来说,这篇文章里的时序稳定策略和误检治理思路也可以直接迁移到别的场景中。
1. 项目概述与方案选型
1.1 手势识别方案怎么选,为什么锚定YOLO
静态手势识别不是只能走目标检测这一条路。我先说两个被很多人试过、最后都放弃的方案,你就明白为什么最终会锚定YOLO。
第一个是用传统视觉做肤色分割加轮廓分析。大致流程是把图像转到YCrCb空间,用肤色范围做二值化,再用轮廓提取去识别手指形态。实测下来,这个方案在纯色背景、固定光照下确实能跑,但只要你把摄像头移到窗边,阳光一照,肤色范围立刻漂移,二值化结果变成一堆噪点,误检率直线上升。石头、剪刀、布之间靠的是手指的分布差异,轮廓一旦被光照破坏,整个识别逻辑就崩了。
第二个方案是直接用分类网络,比如把整张图喂给ResNet输出三分类。这个做法看似简单,但有一个致命问题:模型要同时完成"找手"和"判断手势"两件事。当手在画面里占比不大、背景又杂乱时,特征响应会被背景主导,模型会开始学背景纹理而不是手势形态。你也可以先用一个人手检测器把手裁出来,再送进分类网络,相当于做了一个两阶段系统。两阶段不是不行,但工程复杂度翻倍,实时性也打了折扣,还要训练两个模型并处理两个模型的误差叠加。
目标检测方案把这两件事合并成一件。单阶段检测器直接在画面里输出"手部区域框+手势类别",一步到位。而在单阶段检测器里,YOLO系列是生态最成熟、权重最好找、部署兼容性最强的选择。它有从n/s到m/l/x的模型规模梯度,小模型可以在CPU上跑,大模型在高性能GPU上有足够精度冗余。所以这个项目从一开始就用YOLO,不是因为它最先进,而是因为它最稳。
1.2 YOLOv5、v8、v10三个版本的真实差异
三个版本不是简单的升级关系,它们的结构设计思路有明显区别,这直接影响你训练和部署时的体验。
| 版本 | 检测头设计 | 推理后处理 | 生态成熟度 | 适用场景 |
|---|---|---|---|---|
| YOLOv5 | anchor-based | 需要NMS | 资料最丰富,踩坑记录最好找 | 老项目维护、快速验证 |
| YOLOv8 | anchor-free | 仍需要NMS | 官方文档最友好,API统一 | 通用场景首选 |
| YOLOv10 | anchor-free | NMS-free | 新特性多,社区资料相对少 | 端侧部署、对延迟敏感的场景 |
YOLOv5的核心检测头基于预定义锚框,模型输出的是相对于锚框的偏移量,推理时要经过解码和NMS两次后处理才能得到最终框。它的优势在于历史包袱最轻,网上随便一搜就是大量训练经验和踩坑记录,非常适合拿来走通第一版流程。
YOLOv8把检测头改成了anchor-free,不再依赖手工设计的锚框尺寸,同时引入了C2f结构替换原来的C3模块。这个改动让梯度信息流通更充分,在目标尺度变化较大的场景下,精度表现一般会好一点。它的API设计很统一,训练、验证、导出都在同一个命令行体系下完成,相当省心。
YOLOv10在v8的基础上做了个重要变化:推理阶段彻底去掉了NMS。它通过一对一的匹配策略直接输出最终预测结果,省掉了传统检测器里"先出一堆候选框再逐个抑制"的步骤。这个设计在端侧部署时很讨喜,减少一个后处理环节意味着延迟更稳定,不容易出现偶发卡顿。但代价是社区资料密一点,很多老经验不完全适用,遇到问题需要自己翻源码排查。
我最终的选型结论留在后面训练对比里说,先给个方向性建议:如果你的核心诉求是跑通流程、学习原理,从v5开始;如果要做正式项目且GPU环境可控,v8更均衡;如果目标是端侧设备或对每一毫秒延迟都敏感,v10值得作为主力方案去啃。
2. 数据集构建:石头剪刀布识别的第一道坎
2.1 数据采集的讲究
很多人以为收集一两百张石头剪刀布图片就够了,实际训练出来的模型往往在真实摄像头面前表现很糟,因为真实场景和样本分布差距太大。
三类手势的形态差异要心里有数。石头是握拳,五指完全收拢,特征是一个圆润的拳头轮廓;剪刀是食指和中指伸直、其余手指弯曲,特征是两指形成的V形结构;布是五指张开、手掌展开,特征是整个手掌平面。这三个类别看起来简单,但存在大量边界态:手指微张算石头还是布?两指分开角度很小算剪刀还是布?这些边界的界定完全由你的标注一致性决定,所以采集阶段就要尽量覆盖各种形态。
我的数据来源有两部分。一部分是自己在室内拍的,大约600张,覆盖不同距离、不同角度、左右手交替、室内灯光和自然光。另一部分从公开手势图库里筛了约600张,原则是画面清晰、背景不过分复杂、手势形态标准。最终保留约1200张原始图像。这里有个容易被忽视的点:数据集里手部在画面中的面积占比要足够多样。如果所有样本里手都占画面一半以上,部署后用户站在一米外,模型基本找不到目标。
负样本也必须加。所谓负样本,就是画面里出现手但没有做目标手势的图像,比如握着杯子、自然弯曲、点击屏幕。这些样本不标注任何类别,作用是让模型学会"这些情况不该输出任何东西"。不加负样本的模型,会在真实场景里对着任意一只手乱猜一个类别,这是新手最容易踩的坑。
2.2 标注要点与质检
标注工具我不推荐纠结,任选一款支持YOLO格式导出的即可。重要的是标注规范和质检流程。
标注框要紧贴手部。石头就框住整个拳头轮廓,从手腕到指关节;剪刀要覆盖两根伸出的指尖,底部收在弯曲的手指位置;布则要框进全部五根张开的手指,四边尽量贴合指尖边缘。框不要留太多白边,否则目标在框内占比过小,模型学到的特征会混杂背景;框太小也不行,会截掉部分手指特征。我的经验是让手部主体占据标注框面积的70%以上。
剪刀的标注是最麻烦的。食指和中指分开角度小、指尖位置不清晰时,新手容易凭感觉把框标得偏大。我的做法是按手指实际可见范围标,哪怕两个指尖粘在一起也照实标。因为一旦标注尺寸不统一,模型对剪刀的定位就会忽大忽小,推理时框会来回跳。
标注完成后必须质检。我用两个手段:一是脚本统计每个类别的样本数量、标注框宽高分布、目标占比,快速发现某类样本过少或框尺寸异常;二是把所有标注框绘制回原图,人工快速过一遍,重点看边界样本是否一致。这一步会花一两个小时,但能避免模型训练完才发现标注错误导致精度上不去。标注质量直接影响模型上限,训练参数只能在这个上限内做文章。
2.3 数据增强与划分策略
原始1200张样本直接训练也能出结果,但泛化能力一般。我做了约2倍的离线增强,最终训练集大约2400张。
增强手段包括:水平翻转、小角度旋转(不超过10度)、亮度对比度随机调整、少量高斯噪声。水平翻转对石头剪刀布是安全的,因为左右手互换不会改变类别语义;旋转角度要控制,真实场景中手一般不会倒置,超过15度的旋转反而会让模型学到不存在的姿态。
这里还有一个重要细节:训练集和验证集不能来自同一画面序列的相邻帧。我最初按文件名随机划分8:1:1,结果验证集里混入了大量与训练集几乎相同的连续帧,mAP看着很高,一部署到摄像头就现出原形。正确的做法是先按采集场景分组,同一场景的一批帧要么全进训练集,要么全进验证集,保证验证集能真实反映模型遇到新画面的表现。
3. 模型训练实战:从配置文件到权重文件
3.1 环境搭建与项目结构
环境这块我先说结论:用conda创建管理虚拟环境是一个不折腾的选择。YOLOv5更习惯Python 3.8,v8和v10建议用3.9或3.10。如果你的机器有带CUDA的显卡,PyTorch版本要和CUDA驱动匹配,最简单的办法是直接用PyTorch官方安装命令指定CUDA版本安装,不要自己分别装CUDA Toolkit和cuDNN,版本对不上会浪费大量时间。
仓库的目录结构要保持统一。数据集目录我建议这样组织:
datasets/ RPS/ images/ train/ val/ test/ labels/ train/ val/ test/images和labels对应文件的文件名要一致,标注文件是txt格式,每一行表示一个目标:类别编号、归一化的中心x、中心y、宽度、高度。v8和v10对数据集格式的要求基本一致,v5也是这个格式,只是一个模型换到另一个模型时,数据集不用重建,省了很多事。
很多同学在这里遇到第一个报错是训练时找不到标签文件,多半是数据集路径配置不一致。建议把每种模型的data yaml里train和val路径写成绝对路径或统一相对路径,不要混用。
3.2 训练参数配置与计算逻辑
训练参数是影响最终效果最直接的因素。我先列出我最终使用的配置,再逐个解释为什么。
| 参数 | 数值 | 说明 |
|---|---|---|
| 模型规模 | v5s / v8n / v10n | 小模型足够,训练快,部署友好 |
| 图像尺寸 | 640x640 | 精度和速度的均衡点 |
| batch size | 16 | 视显存而定,6GB显存可用16 |
| epochs | 150 | 小数据集下150轮足够 |
| 优化器 | v5用SGD,v8/v10默认 | v5手调SGD更稳,v8/v10自动选择 |
| 初始学习率 | v5用0.01 | 配合warmup和余弦退火 |
| 置信度阈值 | 推理时0.25 | 训练时不太用管 |
| NMS阈值 | 推理时0.45 | 抑制重叠框 |
图像尺寸为什么选640?YOLO系列在640下的精度表现最标准,官方预训练权重也是在这个尺度下统计的。如果手在画面中占比很小,可以临时提高到800或960,但推理速度会成比例下降。我测试过将输入降到480,速度提升明显,但小目标漏检率上升,最终保留640。
batch size的选择逻辑是显存约束。640x640输入下,模型参数不算大,但激活值占显存较多。6GB左右的显卡在batch为16时需要开启混合精度,显存不足就降到8。batch调小的代价是梯度估计噪声变大,训练稳定性变差,此时可以适当降低学习率。
epochs设150是因为样本量只有两千多,模型通常在80到100轮时mAP就接近收敛,150轮是为了留出充分的学习率退火空间。继续加到300轮容易过拟合,验证集损失会开始回升。
v5和v8/v10在优化器习惯上不一样。YOLOv5默认SGD加动量0.937,初始学习率0.01,配合前3轮warmup和余弦退火,这种组合在中小数据集上表现稳定。v8和v10的默认配置会根据你的数据自动选择优化器,大多数情况下直接用默认就行,不需要手动改成AdamW。
3.3 v5/v8/v10训练结果对比与选型结论
三个模型在同样的数据集、同样的训练设置下跑完,结果放在一起看才分出差别。
| 模型 | mAP50 | mAP50-95 | GPU推理延迟(ms) | CPU推理延迟(ms) | 备注 |
|---|---|---|---|---|---|
| YOLOv5s | 0.982 | 0.954 | 6.2 | 115 | CPU偏慢 |
| YOLOv8n | 0.986 | 0.961 | 5.1 | 96 | 精度和速度均衡 |
| YOLOv10n | 0.978 | 0.944 | 4.7 | 91 | 无NMS,端到端更快 |
两个各自的指标都在0.97以上,视觉上几乎看不出差距。mAP50-95有约0.02的差异,说明v8n在更严格的定位标准下略好;v10n的mAP稍低,但推理延迟优势确实存在,尤其是在端侧环境中更明显。
最终我选择v8n作为主力模型。原因有两点:一是在相同成本下mAP50-95最高,离线验证结果最让人放心;二是v8的导出和部署资料最全面,导出ONNX、转TensorRT的坑都有人趟过了,遇到问题搜得到方案。v10n被我留作端侧部署的备选,推理延迟低5%左右,但在小数据集上精度略低,需要调参补齐。
如果你的显卡性能受限或目标设备是CPU,v5s反而是更稳妥的选择,不是因为它性能好,而是它的社区资料能支撑你把CPU部署的优化手段都试一遍。
4. 推理部署与实时识别实现
4.1 摄像头视频流的接入与预处理
模型训练完毕,下一步是接摄像头。我用的方案是OpenCV读视频流,YOLO负责推理,两者通过帧数据衔接。
import cv2 from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while True: ret, frame = cap.read() if not ret: break results = model.predict(frame, conf=0.25, iou=0.45, imgsz=640) # 可视化 annotated = results[0].plot() cv2.imshow("RPS Detection", annotated) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()这里有个关键点:我没有手动做letterbox预处理,而是直接用model.predict传入原始帧。YOLO内部会自动按640x640做等比缩放和填充,返回的检测框坐标已经逆变换到原始图像坐标系,可以直接绘制。
为什么不自己写letterbox?因为手动处理时容易漏掉坐标反变换这一步。很多新手踩坑就是把检测结果画到了一个缩放后的图上,导致框与真实位置错位。用官方API可以避免这个问题,同时保留了在缩放逻辑上做定制的空间,需要时再手动接管。
4.2 推理结果解析与坐标换算
推理返回的结果是一个列表,每个元素对应一张图的预测。对单帧摄像头画面,主要关注results[0].boxes里的属性。
boxes = results[0].boxes if len(boxes) > 0: cls_id = int(boxes.cls[0]) # 类别索引 conf = float(boxes.conf[0]) # 置信度 x1, y1, x2, y2 = boxes.xyxy[0].tolist() # 左上角、右下角坐标 label = model.names[cls_id] # 类别名xyxy给的是像素坐标,直接画框或做后续逻辑判断都可以。如果画面里同时出现多只手,boxes里会有多个检测结果,遍历即可。实际做游戏对局时,通常只关心最靠近画面中心的那只手,可以按中心点距离排序取最近。
4.3 时序稳定策略:让识别结果不闪
单帧推理的结果在高帧率下非常不稳定。你把手悬停在摄像头前,识别结果会时不时在石头和剪刀之间跳一下,不是因为模型差,而是因为单帧图像质量的波动被直接传递到了输出端。解决思路是引入时序信息,让输出结果有"惯性"。
我用的方案是滑动窗口投票加状态锁定,代码大概这个结构:
from collections import deque MAXLEN = 10 history = deque(maxlen=MAXLEN) current_state = None STATE_LOCK_FRAMES = 5 lock_count = 0 # 每帧推理之后 if len(boxes) > 0 and conf > 0.5: history.append(cls_id) else: history.append(-1) # 占位,表示无检测 if len(history) == MAXLEN: counter = {} for item in history: if item != -1: counter[item] = counter.get(item, 0) + 1 if counter: candidate = max(counter, key=counter.get) ratio = counter[candidate] / MAXLEN if ratio >= 0.6 and candidate != current_state: current_state = candidate lock_count = 0这里有两个关键经验。第一是置信度阈值分层:高于0.5的检测结果才进入投票集合,0.3到0.5之间的框信息量太低,混进去只会干扰统计;低于0.3直接视为无检测。第二是状态锁定:当连续5帧以上输出同一个类别时,才把最终状态切换过去,切换之后保持该状态至少几帧,避免手部在边界位置晃动时输出疯狂闪烁。
窗口长度选10帧,在30帧率的摄像头下对应约0.33秒的决策延迟。这个延迟肉眼几乎无感,但换来的是稳定的输出,值得。
5. 常见问题排查与调优经验
5.1 误检漏检怎么治理
训练完模型放到真实摄像头前,最常撞见的问题有三类,我挨个说对应的治理思路。
第一类是背景纹理误检。窗外的树影、墙上的海报纹理、甚至是键盘上的反光,都可能被模型当成手。这种问题最有效的手段是增加负样本并重新训练。我遇到树影误检后,专门拍了几百张包含复杂背景但没有手的图像,作为标注空文件夹放入训练集,让模型见过"没有手"的画面分布。重训后误检率明显下降。
第二类是剪刀和布之间的混淆。这个问题的根源在于样本形态的边界交集。剪刀两指分开角度不够大、或者摄像头距离远导致细节模糊时,模型在剪刀和布之间摇摆。我处理的办法是第一,在训练集里补充大量"两指微张"的剪刀样本,让模型见过足够多的小角度剪刀;第二,在部署端加入规则,当某帧检测框置信度低于阈值但又有较高类别响应时,保持前一帧的输出状态而不是立刻切换。
第三类是手部在画面边缘或快速移动时的漏检。模型训练时的目标框基本都在画面中央,边缘目标见过少,自然漏检。处理方式是对训练集做随机平移和随机裁剪增强,让目标框有相当概率落在画面边缘区域;部署时提醒用户把手放在画面中心区域,效果直接。如果手移动很快导致运动模糊,适当降低置信度阈值到0.2,配合时序投票,可以救回一些模糊帧。
5.2 性能优化与端侧移植经验
模型训练完只是个开始,部署性能和稳定性才是落地关键。
GPU环境下的第一步优化是导出ONNX。用官方导出命令即可:
yolo export model=best.pt format=onnx dynamic=True导出后的ONNX模型可以用ONNX Runtime加载,推理延迟比PyTorch原生推理低20%到30%。注意session要复用,不要每一帧都重新加载模型,这个错误会让帧率直接掉到个位数。
如果目标设备有NVIDIA显卡,TensorRT是更激进的选择。导出TensorRT引擎后在fp16精度下运行,实测能将v8n的推理延迟压到3毫秒左右,比PyTorch原生快一倍以上。TensorRT引擎的构建时间较长,首次运行会卡十几秒,可以把引擎序列化保存,后续直接加载。
CPU部署是另一个话题。没有显卡时,v8n在普通桌面CPU上大约90到110毫秒一帧,勉强算实时,但体验一般。可以考虑把输入尺寸从640降到480,延迟能降到60到70毫秒,代价是精度下降约1个百分点。如果精度损失可以接受,值得换流畅度。更进一步是尝试INT8量化,但量化后精度下降明显,手势识别任务中手部特征本来就细密,不太推荐。
5.3 实操心得速查表
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 光照变化时检测框抖动 | 肤色和亮度特征波动 | 训练数据加入多光照样本;降低conf阈值并搭配时序投票 |
| 手快速移动时漏检 | 运动模糊 | 降低conf到0.2;提高摄像头帧率;加入运动模糊数据增强 |
| 两指微张被误判为布 | 剪刀边界样本不足 | 补充小角度剪刀样本;部署端增加状态锁定规则 |
| 背景物体被当成手 | 负样本缺失 | 采集复杂背景空图像加入训练集 |
| 多只手同时出现时输出乱 | 未处理多目标 | 按框中心距离取最近目标;或按框面积排序取最大 |
| CPU推理卡顿 | 模型规模过大 | 换n模型、降输入尺寸、导出ONNX Runtime |
| 摄像头画面倒置 | 驱动或装载角度 | 用cv2.rotate翻转画面;确认检测结果与显示画面一致 |
这个表是我在实际调试过程中沉淀下来的,很多问题不是一次出现的,而是反复在不同环境里轮番碰到。排查顺序很关键,先看数据再看参数最后看部署环境,不要一上来就调模型结构。
结语与后续扩展
这几个模型用下来,我最深的体会是:手势识别项目的瓶颈往往不在模型结构,而在数据质量和时序策略。YOLOv5、v8、v10之间的精度差距在小数据集上其实微乎其微,决定最终体验的是你有没有把边界样本采够、有没有把时序决策逻辑写好。
对刚入门的同学,我建议先用v5s把全流程走一遍,不急着追新版本,等你对数据标注、训练评估、部署推理这一整套链路有了体感,再切到v8n做正式版本也不迟。最后分享一个我在项目中验证过的小技巧:如果你的摄像头位置固定,可以先采集一段无手的空背景做背景建模,推理前用背景差分把明显不属于手的区域粗滤掉,再送进YOLO,误检率能再降一大截。这个项目后续我还想把手势轨迹加进来,做成动态的"出拳、变拳"识别,等有稳定结果了再和大家详细聊。