很多人做手势识别,第一反应就是手部关键点检测,把21个关键点坐标回归出来再判断手势。我之前也这么干过,绕了一圈之后反而发现,对于大多数"静态手势分类"场景,YOLOv5这种目标检测方案才是性价比最高的路线——既不需要复杂的后处理流程,也不需要额外的时序建模,一个检测框加一个类别标签就能把事情搞定。这篇文章就把我用YOLOv5做手势识别的完整过程整理出来,从数据准备、标注、训练到摄像头实时推理,再到踩过的坑,适合刚入门目标检测、想快速落地一个手势识别Demo的朋友参考。
1. 为什么我不建议一上来就做手部关键点检测
先说说我最初的想法。当时想做一个手势识别功能,需求很明确:识别几种固定手势(握拳、OK、竖起大拇指、手掌张开、比心),不需要关心手指关节的具体位置,只要知道画面里出现的是哪种手势就行。
1.1 关键点方案和目标检测方案的本质区别
手部关键点检测的思路是把整只手当作一个结构化对象,先定位手腕和21个手指关节点,然后根据这些点的空间关系推导出手势含义。这个方案的优势是精细,能处理手指弯曲程度、指尖朝向这类细粒度信息,但代价是整套流程复杂得多:要跑一个关键点模型,还要写一堆基于几何关系的判断逻辑,比如指尖和指根的距离、手指之间的夹角、关节的弯曲角度。更麻烦的是,如果画面里同时出现两只手,还得先做手部追踪,否则关键点ID会跳变。
目标检测的方案就直接多了。我把每个手势当作独立的类别,模型只输出一个矩形框加一个类别置信度。整个过程被简化成"框出目标并分类",省掉了所有关节几何推理,模型自己就能从像素层面学会不同手势之间最具有区分度的视觉特征。对于固定几类的静态手势识别,检测方案不但精度完全够用,而且代码量少一个数量级。
1.2 YOLOv5在这个场景下的不可替代性
我比较过的框架包括Faster R-CNN、YOLOv5和YOLOv8。Faster R-CNN虽然是两阶段检测器的标杆,精度理论上更高,但那个推理速度放到摄像头实时场景里真的有点吃力,而且配置复杂度高,对新手很不友好。YOLOv8的anchor-free设计确实新,但训练参数和默认配置的调整方式变化比较大,社区里的教程和坑位记录远不如YOLOv5多。
YOLOv5的好处在哪?第一,单阶段检测速度极快,在普通显卡上跑摄像头实时流毫无压力;第二,仓库的成熟度太高了,数据集格式、数据增强、训练调参都有非常成熟的默认值,几乎不需要从零写代码;第三,模型文件大小友好,转ONNX导出部署也方便。说白了,在这种"固定手势分类"的任务上,YOLOv5就是那个最稳、最省心、最容易跑出结果的选择。
提示:如果你的需求是"识别手指弯曲了几度""给出精确的指尖坐标",那确实要去做关键点检测。但如果你只是要"判断画面里这个手势是啥",目标检测绝对是投入产出比最高的路线。
2. 手势数据集的"脏活累活":采集、筛选与标注的完整链路
数据是所有识别模型的地基。我之前见过不少同学直接拿公开数据集训练,效果不错,但一放到自己的实际场景里就垮掉,原因就是数据分布差太远了。所以我这次选择了"公开数据集冷启动 + 自采数据微调"的组合路线。
2.1 数据集来源:公开数据探路,自采数据定胜负
公开数据集的好处是量大、类别通用,适合先跑通整个流程。但问题也很明显:公开数据集里的手势大多是国外志愿者拍的,肤色、光照、背景分布和你实际使用的场景差别很大,而且类别定义也未必完全匹配你的需求。
所以我的建议是,先用公开数据集预训练模型验证流程,然后花几天时间自己采集小规模但高度贴近实际场景的数据,用来做微调训练。哪怕只有几百张图,只要覆盖了你实际使用中的光线条件、拍摄角度和人物,效果提升都会非常明显。
我这次定义的手势类别有六种:
| 类别ID | 手势含义 | 备注 |
|---|---|---|
| 0 | 握拳 | 拳头正面或者侧面都收录 |
| 1 | OK | 拇指和食指圈成环状,其余手指张开 |
| 2 | 竖起大拇指 | 关注正面和侧面角度 |
| 3 | 手掌张开 | 五指张开,掌心朝向镜头 |
| 4 | 比心 | 双手配合或者单手比心 |
| 5 | 指向上方 | 单指指向天空方向 |
2.2 采集过程中的光线与背景控制
自采数据的时候最容易犯的错就是"以为拍得越多越好"。说实话,数量重要,但多样性比数量更重要。我在采集过程中把摄像头分别放在室内灯光明亮环境、窗户旁自然光环境、手机屏幕补光环境三种条件下各拍一遍。背景方面刻意覆盖了纯色墙壁、杂乱桌面、书架、窗帘等场景,目的就是让模型见过足够多的背景变化,避免把背景纹理误当成手势特征。
拍摄距离也要控制好。YOLOv5的输入分辨率一般是640,如果手在画面里太小,标注之后的有效像素只有二三十个,模型根本学不到细节。我要求拍摄时手部宽度占画面的三分之一以上,这样标注框里的手势主体才足够大。
同时要注意遮挡问题。实际操作中,手背挡住部分手指、手指交叉都是很常见的情况。我在采集时候专门加了一部分"略微遮挡"的样本,让模型提前适应这些低质量输入,这比单纯堆数量有用得多。
2.3 数据清洗:删掉"脏样本"比多拍一百张都有用
拍完原始素材,下一步是逐帧抽帧和清洗。我把视频素材用脚本按固定间隔抽帧,然后人工快速过一遍,把以下类型的图片直接删除:
- 手势不完整的(比如OK手势的手指被切出画面一半)
- 运动模糊严重的(手部边缘糊成一片,标注框都画不准)
- 手势定义不清晰的(介于握拳和半握拳之间,类别归属模糊)
- 画面中意外出现多只手重叠的
这一步工作量不小,但绝对不能跳过。我实测过,同样的训练配置,清洗后的数据训练出的模型mAP能提升七八个百分点。数据质量差的时候,模型loss降不下去,你还会误以为是自己训练参数调错了,实际是数据在拖后腿。
2.4 标注工具与标注规范
标注工具我用的是LabelImg,纯开源,界面简单,导出格式默认是Pascal VOC的XML。
我的标注操作流程是这样的:先用OpenCV脚本把抽帧出来的图片重命名为四位数字编号,然后按类别建文件夹,让标注员(我自己的主观劳动)按类别分组标注,同类手势标注习惯一致性会好一些。接下来每张图只画一个框——手势区域的外接矩形,切记要稍微留点边距,不要把手指尖正好卡在框线上。
标注规范上要统一三个标准:
- 框要包住整个手势,包括所有手指指间缝隙,不要只包手掌
- 类别拿不准的图就删掉,不硬标
- 同一类手势的正反面角度尽量均衡,避免模型学到"只看得到手背"
2.5 格式转换:从XML到YOLO的txt
LabelImg默认导出的是XML格式,YOLOv5训练需要的是txt格式。格式转换没什么技术含量,但里面有一个特别容易踩坑的点:YOLO格式的坐标是归一化后的中心点坐标和宽高,XML里的是左上角、右下角的绝对像素坐标。
核心转换公式:
# XML坐标转YOLO归一化坐标 x_center = (xmin + xmax) / 2 / image_width y_center = (ymin + ymax) / 2 / image_height width = (xmax - xmin) / image_width height = (ymax - ymin) / image_height # 这四行代码看似简单,但xmax和ymin的顺序搞错、 # 或者忘记除以图片尺寸,会导致训练时框完全错位转换完之后的目录结构长这样:
gesture_dataset/ ├── images/ │ ├── train/ # 训练集图片 │ └── val/ # 验证集图片 └── labels/ ├── train/ # 训练集标签txt └── val/ # 验证集标签txt需要额外说明的是,图片文件名和标签文件名必须严格一一对应,YOLOv5按名字匹配,不是按顺序读取的。
3. 训练参数怎么调才不白跑:我的配置思路与实验结果
训练部分我尽量给到可以直接抄的配置,同时说清楚每个参数为什么这么设。
3.1 环境配置与模型选型
先说环境。我的训练机器配置是CPU + 一张入门级显卡,这个配置跑YOLOv5s足够。PyTorch版本用的2.0以上,CUDA 11.8,Python 3.9,直接克隆YOLOv5官方仓库,然后安装requirements.txt里的依赖就行。
我选的是yolov5s.pt作为预训练权重,理由很实际:yolov5s是速度和精度的平衡点,对我来说手势识别是实时任务,不是离线批量任务,推理延迟必须要低。yolov5n参数更少但精度损失明显,yolov5m和更大的模型精度会好一些,但对我的场景来说收益不大,训练时间却要翻倍。如果你算力充足且对精度有更高要求,用yolov5m是没问题的。
3.2 数据配置文件和训练命令
数据配置文件我写在gestures.yaml里:
# gestures.yaml train: gesture_dataset/images/train val: gesture_dataset/images/val nc: 6 # 类别数量,我的是6类 names: ['fist', 'ok', 'thumb_up', 'palm', 'heart', 'point_up'] # 类别名称要和标注时的类别ID严格对应训练命令如下:
python train.py \ --data gestures.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 100 \ --device 0我把关键参数的设置思路列出来:
| 参数 | 我的设置 | 设置理由 |
|---|---|---|
| --img 640 | 640 | 默认值,YOLOv5在这个尺寸上特征提取最成熟,手部小目标也能兼顾 |
| --batch 16 | 16 | 单卡显存不够大时选这个,能跑满显存但不溢出 |
| --epochs 100 | 100 | 手势识别算中等复杂度任务,100轮足够收敛,再多容易过拟合 |
| --weights yolov5s.pt | 预训练权重 | COCO预训练权重已经学到了通用特征,迁移到手势任务效果远比从零训练好 |
3.3 训练过程的loss曲线怎么看
训练过程中我重点看两个指标,一个是train_loss的下降曲线,一个是验证集上的mAP@0.5。
我第一次训练的时候,loss下降很快,前20个epoch就降了一大截,后面60个epoch几乎是缓慢爬行。这时候千万别急着加训练轮数,先看一下自己在验证集上的mAP是多少。如果mAP还在缓慢上升,说明模型还在稳定收敛,继续训练是值得的;如果mAP已经横盘甚至开始下降,那就得考虑是不是过拟合了。
正常情况下的趋势是:box_loss稳步下降,cls_loss下降速度比box_loss稍慢,obj_loss居中。如果cls_loss迟迟不降,大概率是类别标注有错误或者某个类别数量太少。
3.4 数据增强的影响
YOLOv5默认开了不少数据增强,包括马赛克增强(mosaic)、随机仿射变换、HSV扰动。对于手势这种非刚性目标,我发现mosaic增强特别关键。它可以把四张图拼成一张,让模型看到手势在不同尺度、不同位置的变化,相当于免费扩大了训练集。
但我建议关闭的一个增强是翻转增强中的上下翻转(flipud),因为现实中不会出现倒立的手势,这种增强反而会误导模型。左右翻转(fliplr)是可以保留的,左手的OK和右手的OK在语义上没有区别,模型应该学到这样的不变性。
注意:在训练命令里加上 --flipud 0.0 可以关闭上下翻转,具体参数名以项目里的说明为准。
3.5 我的实验结果
我的数据情况是训练集约3800张,验证集约600张,各类别相对均衡。训练100轮之后的结果:
- mAP@0.5 达到 0.96 左右
- mAP@0.5:0.95 达到 0.83 左右
- 单张图片推理耗时在显卡上约 6ms
这个精度放到实际使用里已经非常能打了。唯一需要说明的是,mAP@0.95偏低的类别基本集中在"比心"上——因为这个手势需要双手配合,标注框范围大,手部姿态变化比较多样。
4. 摄像头实时推理的落地细节
训练出权重文件之后,下一个关键点是把它接到摄像头实时流上,做一个能实际运行的识别演示。这一步也有不少细节值得单独说一说。
4.1 官方自带脚本先用起来
YOLOv5仓库自带一个detect.py,直接跑摄像头输入的话只需要一行命令:
python detect.py \ --weights runs/train/exp/weights/best.pt \ --source 0 \ --conf-thres 0.5source 0表示使用第一个摄像头设备。这个脚本的好处是能直接输出带检测框的视频流,但有个问题:它是一帧一帧跑,中间没有跳帧逻辑。分辨率和帧率稍微高一点,就会感觉视频有明显的延迟感。
如果想做更精细的控制,比如在检测到特定手势时触发某个动作,就得自己写推理脚本。核心代码如下:
import cv2 import torch # 加载模型 model = torch.hub.load('ultralytics/yolov5', 'custom', path='runs/train/exp/weights/best.pt', force_reload=True) model.conf = 0.5 # 置信度阈值,低于这个值的框会被过滤 model.iou = 0.45 # NMS的IoU阈值 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(frame) # 提取检测框信息 boxes = results.pandas().xyxy[0] # 包括xmin, ymin, xmax, ymax, confidence, class, name for _, row in boxes.iterrows(): x1, y1, x2, y2 = int(row['xmin']), int(row['ymin']), int(row['xmax']), int(row['ymax']) label = f"{row['name']} {row['confidence']:.2f}" cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cv2.imshow('Gesture Recognition', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()为什么用torch.hub加载而不是直接把整个仓库clone下来?因为这个方式代码量最少,能快速跑起来。它的内部其实也是调用YOLOv5的推理函数,输出格式规范,直接用pandas解析很方便。
4.2 帧率优化:跳帧和分辨率是最大的杠杆
这一节非常关键。如果你不优化,跑起来明显感觉画面跟不上手部动作,那么识别流畅度就成了最大的体验痛点。我实际操作中做了三件事:
第一,降低输入分辨率。把摄像头分辨率从1280x720降到960x540,推理速度直接提升一倍多。代价是远处的小手势更容易漏检,但在正常使用距离下影响不大。
第二,跳帧处理。不是每帧都跑推理,而是每两帧跑一次推理,中间帧直接复用上一帧的结果。这样画面看起来还是流畅的,但GPU负载减半。具体实现就是在循环里加一个计数器,奇数帧用最近一次的结果,偶数帧才真正推理。
第三,把推理放到单独的线程里。读摄像头和推理分离,保证视频流不会被推理速度拖住。如果不会写多线程也没关系,先做降低分辨率和跳帧这两步,体感差别已经很明显了。
4.3 导出ONNX部署
如果想脱离PyTorch环境部署到其他设备上(比如树莓派、手机端或者换用其他推理框架),把模型导出成ONNX格式是必经之路。
python export.py --weights runs/train/exp/weights/best.pt --include onnx --img 640导出后可以用onnxruntime来加载推理,速度和PyTorch原生相差不大。我实际测下来,在CPU上同等条件下ONNX推理比PyTorch快20%~30%左右,主要原因是onnxruntime的图优化做得更彻底,省去了PyTorch统一调度的开销。
5. 训练和部署阶段踩过的坑
这个部分我想重点写一写踩坑的完整链路,因为很多问题表面上看是模型参数导致的,实际根因在数据或者前后处理上。
5.1 第一个坑:标注框太小导致的resize信息丢失
第一次训练出来的模型对远处小目标手势几乎完全漏检,近处的手势又一切正常。我一开始以为是模型容量不够,换了yolov5m也没有明显改善。
后来我逐帧检查模型输出,发现验证集里所有漏检目标的手部像素面积都小于40x40。问题根因在于,YOLOv5训练时会把输入图片resize到640x640,原图里的小目标经过resize后可能只剩十几个像素宽。再加上标注框虽然包住了手势,但手势本身的边缘特征(手指尖、手指缝)在那么小的区域内几乎不可见,模型自然学不到有效的判别特征。
解决办法有三个,我最终是组合使用的:
- 在标注时更严格地裁剪手势区域,框尽量贴合手势边缘,减少无效背景占比
- 提高输入分辨率,用--img 1280训练,这样小目标相对更大
- 训练集中增加更多手势占画面比例大的样本,强制模型把手势和背景区分的特征学到
5.2 第二个坑:类别不平衡导致OK手势和比心手势混淆
训练完第二轮之后,我统计了验证集的混淆矩阵,发现比心手势被误判成OK手势的比例极高。查了数据分布才发现,OK手势有900多张,比心只有200多张,数量悬殊直接导致模型对比心这个类别的判别力不足。
解决思路不只是简单的欠采样或者过采样。我先把比心类别的图片通过左右翻转、旋转、小幅缩放做了扩充,数量加到和OK均衡,然后又把OK类别里一些"拇指和食指成环但整体手势形态偏向比心"的边界样本删掉了。同时给这两个类别的损失权重做了调整。效果是混淆比例从15%降到了2%以内。
5.3 第三个坑:NMS参数导致的重复框和漏检并存
推理阶段我一开始直接用了默认的conf-thres 0.25,结果画面上出现了大量置信度在0.25~0.4之间的重复框,同一只手被框出两三个。调高到0.5之后,重复框是少了,但一些因为遮挡而置信度偏低的真目标也被过滤掉了。
这里的核心矛盾在于,低置信度阈值会带来重复框,高置信度阈值会漏检。单纯调阈值是治标不治本,更合理的做法是把NMS的IoU阈值调高,让互相重叠的框合并得更充分。我把conf-thres设为0.35、IoU阈值设为0.6,既减少了重复框也保住了大多数真目标。
5.4 第四个坑:同一手势在不同角度下的表现差异
我的手势类别里,竖起大拇指是最典型的例子。大拇指正面朝上、手背朝上、侧面朝上这三种视觉形态差异非常大,原始数据里大部分是大拇指正对镜头的样本,导致模型对侧面的竖起大拇指识别率明显偏低。
后来我把"竖起大拇指"这个类别拆成两种视觉形态来看,发现侧面的样本确实长得更接近握拳的侧面。数据层面我补充了大量侧面样本,并在类别名称上不做拆分——这是"类别内多样性"问题,不是"类别数设计"问题。模型看到足够多的侧面角度后,泛化能力明显提升。
6. 部署完之后的几点个人体会
把整个项目流程跑通之后,我最大的体会是:目标检测不是从算法开始,而是从数据定义开始的。很多所谓的"模型效果不好",追根溯源都是"数据定义不清楚"或者"数据收集时引入了主观偏差"。比如我在采集OK手势的时候下意识只拍了手心朝向,完全没拍手背朝向,这个偏差在后来测试时才显现出来。
最后再分享一个小技巧:训练完成后,最好把模型拿回到采集数据的原始视频上跑一遍,看看检测结果在连续帧上的稳定性如何。单帧静态精度高不代表视频流上的体验好,如果框的位置左右跳、类别在相邻帧之间来回变,那说明模型的帧间稳定性还不够。这种时候可以给输出加上一个简单的平滑逻辑,比如连续三帧都判定为同一类别才输出该类别,体验会提升一个档次。
如果后续你想扩展这个项目,可以考虑的方向包括:加入手势之间的动态切换识别(比如从OK切换到竖起大拇指的过程)、多手势同时识别、或者把模型迁移到嵌入式设备上跑。不过这些都是后话了,先把静态手势识别这版做扎实,后面的事自然水到渠成。