简介:基于Python与深度学习实现的中国交通警察指挥手势识别项目,面向毕业设计、课程设计及项目开发场景,提供完整源码与配套数据集,帮助学习者快速掌握图像分类、手势识别等计算机视觉任务的工程实现流程。压缩包共37个文件,核心为31个Python脚本,涵盖模型定义、训练、测试及基础功能测试;另含2个Markdown说明文档和1个TXT文件,用于指导环境配置与项目运行;附带GIF演示、gitignore及license文件,整体仅4.43MB,轻量易获取。目前已有499人学习参考,适合计算机视觉、深度学习方向的学生作为课设或毕设的起步模板。资源内目录结构清晰,主模块与训练、预测代码分离,模型文件和文档一目了然;项目源码经过严格测试,可直接运行并在此基础上扩展功能。无论用于课堂展示、课程报告还是个人项目,都能帮助理解交警手势识别从数据准备到模型部署的完整链路,是兼顾实用性与学习价值的参考资料。
1. 把“基于Python和深度学习开发的中国交通警察指挥手势识别”当普通图像分类来做,大概率会翻车
把“基于Python和深度学习开发中国交通警察指挥手势识别”这类题目当普通图像分类来做,大概率会在答辩现场翻车。交警手势识别的核心难点不在“认出画面里有个人”,而在把连续骨架动作切分成有语义的手势片段,再判定是哪一种指挥动作。网上躺着很多相关源码,但能从头训练到实时演示的完整方案很少,数据集也大多是零散视频或未清洗的图片。这篇文章面向毕业设计、课程设计和想快速落地这个方向的开发者,我会把数据标注、关键点训练、时序分类、界面打包四块依次讲清楚,参数怎么设、坑在哪,一次说透。
2. 数据集与标注流程:评分点的第一块拼图
2.1 公开数据很难直接复用:自建为主,官方视频为辅
交警手势识别没有像COCO那样标准的公开数据集,网上能搜到的一些“yolo手势识别数据集”普遍存在三类问题:样本量太小、标签体系不统一(有人标8类,有人标6类,还有人把左转弯待转信号和左转弯信号混在一起)、视频拍摄视角都是正面固定机位。如果你带着这些数据去做课程设计,训练出来的模型一拿到实验室摄像头前就废,因为角度、距离、光照全变了。
所以我一般建议用“自建为主、官方宣传视频为辅”的路子。自建数据具体分两步:找几段交警手势教学视频或者自己比划着录,用手机三脚架固定机位,分别拍正面、左侧面、右侧面三个角度;然后按《道路交通安全法实施条例》规定的8种手势来建标签——停止信号、直行信号、左转弯信号、左转弯待转信号、右转弯信号、变道信号、减速慢行信号、示意车辆靠边停车信号。
数据量的底线是每类150到300张有效单帧,8类合计1500到2500张,这足够训练一个能交差的小模型。如果条件允许,每类做到500张,配合数据增强,泛化能力会明显上一个台阶。注意采集时人的身高、服装、距离要有点变化,否则后面推理阶段一换环境就崩,这个问题我在第五章展开讲。
2.2 从视频里切出训练图片:半小时做出一批干净样本
拿到视频后第一步是切帧。这里有个关键细节:不要每帧都存,连续帧之间相似度太高,训练时会让模型过拟合到背景上。我习惯每隔2到3帧抽一张,这样同样的动作量下,样本多样性会好很多。还要按标注好的时间段切,只保留“手势开始到手势结束”这一段。
切帧代码非常简单,核心是一个循环加一个区间判断:
import os import csv import cv2 def load_annotations(csv_path): """读取人工标注的手势时间区间表""" items = [] with open(csv_path, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: items.append({ "start": int(row["start_frame"]), "end": int(row["end_frame"]), "label": row["label"] }) return items def cut_frames(video_path, csv_path, save_dir, interval=2): """按标注区间抽样出单帧图片 interval: 每隔几帧取一张, 建议2或3 """ cap = cv2.VideoCapture(video_path) annotations = load_annotations(csv_path) frame_idx = 0 saved_count = 0 while True: ret, frame = cap.read() if not ret: break # 判断当前帧落在哪个手势区间 label = None for ann in annotations: if ann["start"] <= frame_idx <= ann["end"]: label = ann["label"] break if label and frame_idx % interval == 0: label_dir = os.path.join(save_dir, label) os.makedirs(label_dir, exist_ok=True) cv2.imwrite(os.path.join(label_dir, f"{saved_count:05d}.jpg"), frame) saved_count += 1 frame_idx += 1 cap.release()这个脚本的参数就三个值得调:interval控制抽样密度,动作慢的手势比如“减速慢行信号”建议设成2,动作快的“直行信号”设成3问题也不大;save_dir下按标签自动建子目录,后面做数据集划分时直接按目录操作;start_frame和end_frame在做标注表时按视频帧号填,千万不要填时间秒数,OpenCV的read()是按帧走,不按时间走。
标注表CSV用Excel就能做,三列就够了:start_frame,end_frame,label。如果视频太长不好人工数帧号,可以先用OpenCV把视频逐帧预览,找到动作开始帧和结束帧的帧号再填表。这个过程很枯燥,但值得耐心做,因为标签边界错10帧,切出来的图里就有大量“半动作”样本,后面训练出来的模型会在动作过渡处反复误判。
2.3 关键点标注:Labelme标注后转成YOLO-pose格式
有了单帧图片后,下一步是标注人的骨架关键点。这里我建议直接用Labelme做多边形/点标注,把交警的上半身关键点标出来。标注点数的选择有讲究:做手势识别时,双腿和脚踝基本用不上,交警手势的动作语义集中在大臂、小臂、肩膀和头部,所以标13个点或17个点都可以。我习惯用17个点,顺序对齐COCO格式,这样后面能直接套用YOLOv8-pose的预训练权重,不用改网络输出头。
Labelme导出的JSON格式里,shapes数组每一项记录一个关键点的label和points坐标。转换到YOLO-pose格式时,需要把每个点的像素坐标除以图片宽高归一化,并且把所有点写在一行里,行首是类别编号。
import json import glob import os def labelme_to_yolo_pose(labelme_dir, out_txt_dir): """把Labelme标注的JSON转成YOLO-pose需要的txt格式 关键点顺序保持COCO 17点顺序, 类别编号0代表person """ os.makedirs(out_txt_dir, exist_ok=True) for json_path in glob.glob(os.path.join(labelme_dir, "*.json")): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img_w = data["imageWidth"] img_h = data["imageHeight"] shapes = data["shapes"] if not shapes: continue points = shapes[0]["points"] # shapes[0]是person norm_points = [] for x, y in points: norm_x = round(x / img_w, 6) norm_y = round(y / img_h, 6) norm_points.append(str(norm_x)) norm_points.append(str(norm_y)) out_name = os.path.basename(json_path).replace(".json", ".txt") out_path = os.path.join(out_txt_dir, out_name) with open(out_path, "w", encoding="utf-8") as f: f.write("0 " + " ".join(norm_points) + "\n")转换脚本里最容易被忽略的就是坐标归一化。YOLO训练时会把图片自动缩放到imgsz指定的尺寸,如果标注坐标还是原始像素值而非归一化值,缩放后关键点位置全错,模型根本收敛不了。另外,Labelme里points的坐标顺序要和COCO约定一致,比如第0个点是鼻子、第1个点是左眼、第2个点是右眼。如果标注时点乱了,训练出来的模型关键点全是错位的,这个错误在图上肉眼都看不出来,只能靠画关键点连线检查。
3. 关键点提取与训练:用YOLOv8-pose跑通骨架识别
3.1 三个主流方案对比:YOLOv8-pose、MediaPipe、OpenPose怎么选
关键点提取是这个项目的地基,选型直接决定了后面时序分类的输入质量。目前做姿态估计有三个常用选择:MediaPipe、OpenPose、YOLOv8-pose。很多人在网上搜“mediapipe手势识别”教程,拿MediaPipe直接跑,确实开箱即用,但它的33个手部关键点做的是“手指级”识别,交警手势需要的是“整条手臂和躯干”的骨架信息,MediaPipe的人体姿态模式只有上半身贴合度还不错,一旦动作幅度大(比如左转弯信号手臂完全伸展),关键点会抖动得很厉害。
OpenPose是老牌方案,但配置环境比较痛苦,CPU推理帧率只有个位数,答辩现场的电脑不一定扛得住。我最后的选型是YOLOv8-pose,原因有三:检测和姿态估计在同一个模型里完成,不需要先跑一个行人检测器再跑姿态模型;训练和推理接口统一,换数据集只需改一个YAML文件;平时大家搜“yolov8训练自己的数据集”时找到的部署资料也最多,出了问题好查。
3.2 基于YOLOv8-pose训练自己的关键点模型:YAML和数据准备
训练前要把数据集整理成YOLO格式的目录结构:images/train放训练图片,images/val放验证图片,labels/train和labels/val放对应的txt标注文件。建议按8:2划分,每一类的图片都要均匀分到两边,不能按整个文件夹乱切。
模型配置文件是我调试时改动最多的地方,核心是kpt_shape和names:
# hand_pose.yaml path: ./dataset train: images/train val: images/val # COCO 17点, 每点包含(x, y, visible) kpt_shape: [17, 3] names: 0: person这里有个坑:很多人会把[17, 2]当成默认配置,但如果你的标注txt里只有归一化坐标没有visible标志,训练时不报错,模型输出的keypoints.data最后一维变成2,后面提取关键点坐标的代码就要改。我建议标注时统一写17点,没有的点用0或-1占位,换到推理阶段再按置信度过滤。
训练命令用Ultralytics的标准入口,参数对新手友好:
yolo pose train \ data=hand_pose.yaml \ model=yolov8n-pose.pt \ epochs=120 \ imgsz=640 \ batch=16 \ device=0几个参数值得解释一下。model=yolov8n-pose.pt是预训练权重,它会把你标注的17点和COCO的17点对应起来,千万不要用yolov8n.pt(检测权重)或者yolov8n-cls.pt(分类权重),它们没有姿态输出头。imgsz=640是训练分辨率,交警手势视频里人通常占画面比例不大,直接降到640会丢细节,如果显存够可以上imgsz=896,我实测能提升5到8个点的AP。batch=16在6G显存以下要降到8,否则会OOM。
训练完成后看两个指标:keypoints P和keypoints mAP。P到0.9以上、mAP50到0.85以上,基本够用。如果P一直卡在0.7上不去,回头查标注,大概率是有几张图的点顺序错了。
3.3 推理阶段的关键点预处理:归一化、置信度过滤与可视点判断
训练完模型,进入推理阶段。推理不只是把模型跑的keypoints拿过来用,还要做两步预处理:过滤低置信度点,然后把坐标从像素值转成相对位置特征。这一步做得不好,后面LSTM学到的就是“某个像素位置的手势”,换个人站远一点全废。
from ultralytics import YOLO import numpy as np model = YOLO("runs/pose/train/weights/best.pt") def extract_keypoints(frame, conf_threshold=0.5): """从单帧提取17个关键点坐标 返回: (17, 3) 每行是(x, y, visible) """ results = model(frame, verbose=False) if not results or results[0].keypoints is None: return None kpts = results[0].keypoints.data[0].cpu().numpy() # (17, 3) # 低置信度点直接置为不可见, 坐标置0, 避免把噪声喂给LSTM visible = kpts[:, 2] kpts[visible < conf_threshold, 0] = 0 kpts[visible < conf_threshold, 1] = 0 kpts[visible < conf_threshold, 2] = 0 # 至少要有6个可见点, 否则认为这一帧没有有效的人 if (kpts[:, 2] > 0).sum() < 6: return None return kpts关键点归一化的方式是整个预处理里最影响效果的一步。常见做法是选两个稳定点做基准——我一般用左肩和右肩的中心点作为原点,再除以肩宽,这样能把不同身高、不同画面距离的人拉到同一个坐标系里。注意不要用图片宽度做归一化,因为人站近站远会直接影响坐标尺度,LSTM学出来的特征就不稳定。手肘和手腕的坐标要根据肩膀中心做平移,而不是直接减图像中心点。
4. 从关键点序列到指挥动作:LSTM与滑窗投票的时序分类
4.1 手势是时序动作:为什么逐帧分类不可行
拿单帧关键点直接分类,十个里有九个会在“停止信号”和“左转弯待转信号”之间反复横跳。原因很简单,这两个手势在某个瞬间的骨架形态几乎一样,区别在于手臂是从“举起到放下”还是“保持不动”。也就是说,交警手势的语义信息藏在“动作过程”里,不在“某一帧的快照”里。
所以这个项目的第二层模型要处理序列数据。一段手势从开始到结束,通常持续0.5到2秒,在30fps的摄像头下就是15到60帧。我采用的方案是固定30帧的滑窗:每30帧关键点序列作为一条输入,模型判断这30帧对应哪个手势。
选LSTM而不是Transformer,不是LSTM更好,而是这个任务数据量只有几千条序列,LSTM更容易收敛,训练时间短,答辩现场改参数重训也来得及。你要是用Transformer,序列长度短根本发挥不出注意力优势,还容易过拟合。
4.2 34维输入、8类输出:一个能跑通的LSTM模型
输入维度17个关键点×2维坐标(x和y),共34维,输出8类手势。我把网络定义和训练循环写在一起,方便直接复用:
import torch import torch.nn as nn class GestureClassifier(nn.Module): """输入 (batch, seq_len=30, input_dim=34), 输出8类手势""" def __init__(self, input_dim=34, hidden_dim=128, num_layers=2, num_classes=8): super().__init__() self.lstm = nn.LSTM( input_dim, hidden_dim, num_layers, batch_first=True, dropout=0.3 ) self.head = nn.Sequential( nn.Linear(hidden_dim, 64), nn.ReLU(), nn.Dropout(0.5), nn.Linear(64, num_classes) ) def forward(self, x): out, _ = self.lstm(x) last_hidden = out[:, -1, :] # 取最后一个时间步 return self.head(last_hidden)这个模型有两个参数直接影响效果。hidden_dim=128对8类手势足够,加到256收益很小但训练时间翻倍。num_layers=2是平衡点,1层学不到复杂时序关系,3层在这个数据量下容易过拟合。注意nn.LSTM里的dropout=0.3只在层数大于1时生效,所以至少用2层。
训练循环用交叉熵损失加Adam优化器,学习率调度用StepLR:
# train_loader返回 (batch, 30, 34) 的关键点序列和对应标签 optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.5) for epoch in range(80): model.train() total_loss = 0 for seqs, labels in train_loader: optimizer.zero_grad() logits = model(seqs) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f"epoch {epoch:03d} loss {total_loss / len(train_loader):.4f}")step_size=30, gamma=0.5的意思是每30个epoch学习率减半,我试过不调学习率,训练后期loss会上下波动。如果数据量少于2000条序列,建议把epochs降到50,防止过拟合。
4.3 滑窗投票与置信度阈值:不让识别结果来回跳
LSTM输出的分类概率在单帧级别是不稳定的,连续几帧可能一会儿判断成“停止信号”,一会儿又跳成“变道信号”。这是因为相邻时间窗的内容几乎一样,模型在边界处摇摆。解决办法是加一个投票器:把过去15次预测结果放在一个队列里,少数服从多数,并且要求得票率超过60%才输出。
from collections import Counter class GestureVoter: def __init__(self, window_size=15, min_ratio=0.6): self.window_size = window_size self.min_ratio = min_ratio self.history = [] def update(self, gesture_id): self.history.append(gesture_id) if len(self.history) > self.window_size: self.history.pop(0) def get_vote(self): if len(self.history) < self.window_size // 2: return None counter = Counter(self.history) best_id, count = counter.most_common(1)[0] if count / len(self.history) < self.min_ratio: return None return best_idwindow_size=15在30fps下代表0.5秒的投票窗口,这个长度对手势识别刚刚好,太短滤不掉抖动,太长会让动作切换的响应慢半拍。min_ratio=0.6是经验值,想更稳定就调到0.7,但动作切换时的延迟会明显增加。实际使用中,我还会在投票器前面加一道置信度门槛,只有LSTM输出的最大概率超过0.4才进投票队列,否则直接丢弃这一帧,能滤掉很多背景噪声造成的误判。
5. 避坑与常见问题:5个让新手翻车的典型坑
5.1 训练loss不降,精度停留在20%上下
现象:LSTM训练跑了几十个epoch,loss稳定在2.0左右不下降,准确率跟随机猜差不多。
原因:最常见的是关键点序列没有归一化。像素坐标直接喂给LSTM,模型要去拟合“这个人站在画面左边还是右边”这种无关信息。其次是把不可见点的坐标填了0,但可见点坐标是几百的像素值,0和几百之间的数值差距让梯度更新失衡。
解决:把关键点坐标以肩部中心为原点做平移,并除以肩宽。不可见点全部置0,同时把可见点的x、y都做同样的平移和缩放变换。做好这一步,loss通常在10个epoch内就能降到1以下。
5.2 左右手镜像反转,识别结果跟实际动作左右互换
现象:训练集和测试集都是正面拍的,准确率很高,一到答辩现场用摄像头实时演示,左手动作被识别成右手动作。
原因:手机前置摄像头拍出来的画面是镜像的,后置摄像头和电脑摄像头不是。如果你采集数据时用的是手机前置录像,推理时用的是电脑摄像头,左右手天然是反的。这是个很隐蔽的坑,因为人眼看不出差异,模型对左右手非常敏感。
解决:训练和推理统一摄像头类型。如果确实无法统一,就在预处理阶段做一次水平翻转,用cv2.flip(frame, 1)把画面镜像回来,再做关键点提取。注意翻转后关键点的左右标签也要交换,否则语义就乱了。
5.3 显存溢出或内存持续上涨
现象:训练时CUDA out of memory,或者推理时程序跑几分钟后内存占用一路飙升直到卡死。
原因:显存溢出通常是batch太大或者imgsz太高,但我见过最奇葩的原因是有人在推理循环里不断调用模型而不释放中间变量。内存持续上涨多是被视频帧引用没释放,cap.read()读的frame如果没有被后续处理替换,旧帧一直留在内存里。
解决:训练时6G显存用batch=8, imgsz=640,12G显存可以batch=16, imgsz=896。推理时把extract_keypoints里模型推理的结果及时转成numpy数组,然后用del和gc.collect()清掉不再用的中间量。视频循环里每处理完一帧就把frame重新赋值,不要保留引用。
5.4 测试集准确率95%,现场一换环境就全乱
现象:自己录的数据测出来接近满分,换个教室、换个摄像头角度,识别准确率掉到50%以下,甚至某个手势永远识别不出来。
原因:这是数据集单一视角、单一背景导致的过拟合。我见过最快的翻车方式是:训练数据全是在同一面白墙前录的,模型表面在学手势,实际在学“白墙+固定机位下的人形轮廓”。换到实验室各种杂物背景、不同距离下,骨架提取本身没问题,但LSTM学到的关键点相对位置分布和现场不一样。
解决:采集数据时至少覆盖三个距离(近、中、远)、两种背景、两种光照。如果来不及补数据,可以做数据增强:对关键点序列加高斯噪声、随机缩放、随机偏移,模拟不同距离和画面抖动。注意不要在序列维度上做时间反转,那会让手势语义颠倒。
5.5 答辩演示时环境崩溃,模型加载失败
现象:在本地跑得好好的代码,拿到答辩用的电脑上,要么导入包报错,要么模型权重路径找不到,要么摄像头打不开。
原因:把项目从一台机器搬到另一台机器,最常出问题的是相对路径缺失和Python环境不一致。很多人把权重文件放在runs/pose/train/weights/这种嵌套目录里,一旦复制项目时漏掉一层,代码就找不到文件。另外答辩机器没装GPU版本的PyTorch,或者装的是另一个Python版本,导入torch直接报错。
解决:交付前把项目做成“一个文件夹拖走就能跑”的形态,权重文件和代码放同一级目录,代码里用os.path.join(os.path.dirname(__file__), "best.pt")定位。用Anaconda导出一个environment.yaml,答辩前先在一台干净电脑上按这个文件重建环境,跑一遍demo,把缺失的包补齐再带去现场。
6. 把模型包装成可演示的桌面工具:摄像头实时识别与准确率验证
6.1 PyQt5实时识别界面:从摄像头取流到输出八种手势
模型训练好了,最后一步是封装成能现场演示的工具。我习惯用PyQt5做界面,逻辑简单清晰,OpenCV负责取流,YOLO模型抽骨架,LSTM分类,结果用标签控件显示,比写终端版demo的观感好很多。
import cv2 import torch import numpy as np from PyQt5 import QtWidgets, QtGui # 初始化模型 keypoint_model = YOLO("best_pose.pt") gesture_model = GestureClassifier() gesture_model.load_state_dict(torch.load("gesture_lstm.pth", map_location="cpu")) gesture_model.eval() voter = GestureVoter() seq_buffer = [] def process_frame(frame): kpts = extract_keypoints(frame) if kpts is not None: seq_buffer.append(normalize_keypoints(kpts).flatten()) else: seq_buffer.append(np.zeros(34, dtype=np.float32)) if len(seq_buffer) > 30: seq_buffer.pop(0) if len(seq_buffer) == 30: seq = torch.tensor(np.array(seq_buffer), dtype=torch.float32).unsqueeze(0) with torch.no_grad(): probs = torch.softmax(gesture_model(seq), dim=1) gesture_id = int(torch.argmax(probs[0])) conf = float(torch.max(probs[0])) if conf > 0.4: voter.update(gesture_id) result = voter.get_vote() if result is not None: cv2.putText(frame, CLASS_NAMES[result], (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)这段代码里有三个交付要点:map_location="cpu"确保目标机器没有GPU也能加载权重;seq_buffer只保留最近30帧,避免内存膨胀;投票器输出前必须经过conf > 0.4过滤,否则背景状态下模型也会强行输出一个手势,界面上会一直跳字。
6.2 验收方法建议:每个手势录30段测试视频
最后别急着交差,先自己验收一轮。我建议每种手势录30段不同角度、不同距离的测试视频,每段2秒左右。跑一遍完整流程,统计两类指标:一是单段视频内投票器输出的正确率,二是动作切换延迟——也就是从停止信号切到直行信号,界面需要多久才跟着变。正确率90%以上、延迟不超过1秒,这个项目拿去答辩就有底气了。
这整套流程我前后带过几届学生走,最大的体会是:这个题目拿到一个“能跑出结果”的模型不难,难的是现场演示稳定不翻车。数据集采集和预处理阶段多花的每一小时,最后都会在答辩现场还给你。希望帮到你。
本文还有配套的精品资源,点击获取