
简介基于Python与深度学习实现的中国交通警察指挥手势识别项目适合毕业设计、课程设计及项目开发实践。面向计算机视觉、深度学习方向的本科生与开发者提供从数据集准备、模型训练到推理验证的完整流程可帮助理解手势识别任务中的图像分类与关键点检测思路。压缩包共37个文件以31个Python源码文件为主包含模型定义、训练测试脚本及基础工具模块另有Markdown说明文档、许可证、配置说明等整体仅4.43MB轻量易部署。目前已有499人学习下载具备一定参考热度。项目基于ctpgr-pytorch框架内置aichallenger与pgdataset数据目录涵盖数据加载、模型构建、训练测试等环节读者可直接运行并在此基础上扩展改进是课程汇报、毕设演示与算法实战的便捷起点。1. 交通警察指挥手势识别这份源码包先解决哪个问题把一段交警指挥视频丢给一个图像分类模型它大概率会猜成“停止”——因为停止手势的静态姿态在画面里停留时间最长而指挥手势本身是一段连续动作不是一张照片。这份基于 Python 深度学习的中国交通警察指挥手势识别资料正好解决“把视频片段识别成具体手势”的问题它打包了 AI Challenger 相关任务整理出的数据集、PyTorch 源码、训练与推理入口适合毕设、课程设计和二次开发。模型走“ResNet 提帧特征 BiLSTM 建模时序”这条路线复杂度可控参数也好讲新手能照着跑通老手能直接换成自己的数据。我拆下来的主要感受是决定这类项目上限的往往不是模型而是片段采样和标签对齐这份代码恰好把这两块做全了。2. 数据组织方式AI Challenger 到 pgdataset 的取舍2.1 先看目录认清项目边界拿到压缩包的第一件事不是直接打开 readme 找训练命令而是把根目录列一遍。我习惯用 tree 或者直接看文件清单先弄清哪些是代码、哪些是数据、哪些是说明。这套代码的顶层结构按我解压后的实际内容整理如下路径作用ctpgr-pytorch-master/项目根目录ctpgr.py主入口训练/推理共用train/训练脚本相关代码models/模型定义constants/类别名、路径等常量pred/推理结果输出目录docs/说明文档basic_tests/冒烟测试脚本aichallenger/AI Challenger 原始数据说明与标注pgdataset/整理后的手势数据集readme.md / readme.en.md中英文说明ctpgr.py 是主入口通过 --mode 参数切换训练与推理models 目录放模型定义constants 里的常量脚本管理类别名和路径basic_tests 是冒烟测试我一般先跑它确认 CUDA、torch、数据和环境四样东西齐不齐5 分钟内能出结果比直接开训练省时间。根目录里那个“新建 文本文档.txt”大概率是作者留的笔记文件内容不影响运行忽略即可。重点看 aichallenger 和 pgdataset 两个数据目录前者是原始来源后者是整理后的训练数据。2.2 标注格式与类别分布pgdataset 是代码真正消费的数据目录。我按 AI Challenger 的习惯把标注整理成 pgdataset/annotations/train.json顶层结构是 COCO 风格images 列表记录帧文件路径annotations 列表记录每条标注videos 列表记录视频维度的信息。动作标签挂在视频维度每个 video 对应一个 gesture_class。这个设计很关键它意味着“一个视频片段整体是一个样本”而不是单帧一个样本。{ videos: [ {video_id: 0, frame_list: [pgdataset/frames/v0/frame_00001.jpg, ...], fps: 25} ], annotations: [ {video_id: 0, gesture_class: 1} ] }frame_list 里所有帧构成一段完整手势视频gesture_class 是类别编号常见是 0 到 8。不同改版可能用 8 类或 9 类以 constants 目录里的类名文件为准。拿到数据后先跑一段统计脚本看看类别分布再决定后面的训练策略import json from collections import Counter with open(pgdataset/annotations/train.json, r) as f: train_ann json.load(f) labels train_ann[annotations] cls_names [ 停止, 直行, 左转弯, 左转弯待转, 右转弯, 右转弯待转, 变道, 减速慢行, 示意车辆靠边停车 ] counter Counter(a[gesture_class] for a in labels) for cid, num in counter.most_common(): print(f类{cid} {cls_names[cid]:10}: {num:5} 条, {num / len(labels) * 100:5.1f}%)Counter 统计每个 gesture_class 的出现次数就能看出训练集的类别分布。cls_names 的顺序必须和 constants 里类名索引一致0 对应“停止”8 对应“示意车辆靠边停车”如果项目常量文件里少了靠边停车说明它用的是 8 类版本按 constants 来即可别强改索引。类别分布这个脚本我每次换数据都会跑一遍因为后面的加权采样、评估指标选择都依赖它。2.3 视频片段采样按时间而不是按帧序号AI Challenger 和大多数公开手势数据的帧率、时长都不统一。有的视频 30fps 录 8 秒有的 15fps 录 12 秒。如果按固定帧号抽帧比如每 10 帧取 1 帧那么同样 30 帧的 clip在两个视频里覆盖的物理时间完全不同手势节奏对不上模型学到的时序关系就是乱的。正确做法是按时间轴均匀采样再换算回帧索引。import numpy as np def build_clip_indices(video_meta, clip_len30, num_clips1): n_frames len(video_meta[frame_list]) fps video_meta.get(fps, 25.0) duration n_frames / fps indices [] for c in range(num_clips): seg_start c * duration / num_clips seg_end (c 1) * duration / num_clips t_seq np.linspace(seg_start, seg_end, clip_len, endpointFalse) idx_seq (t_seq * fps).astype(int) indices.extend(idx_seq.tolist()) return np.clip(indices, 0, n_frames - 1)先把片段按 num_clips 切成若干段段内用 np.linspace 在时间维度均匀采 clip_len 个点再乘 fps 转成帧号最后 clip 到有效范围。clip_len30 表示一个片段 30 帧num_clips1 表示训练时一个视频采样一段推理时通常把 num_clips 提到 4 到 8分别采样再投票。这里不要做随机抽帧随机帧会破坏手势动作顺序尤其对减速慢行这种“先抬臂再下压”的多阶段动作顺序一乱整个序列就没有时序意义了。3. 模型与训练ResNet50 BiLSTM 的完整跑通流程3.1 为什么选 ResNet50 BiLSTM而不是直接上 3D 卷积动作识别项目分两条路一条是 3D 卷积网络比如 I3D、SlowFast直接学习时空特征另一条是“2D 视觉特征 时序模型”先用单帧卷积网络提特征再交给循环网络或 Transformer 建模时序。这个项目走的是第二条路原因很实际公开手势数据量不大3D 卷积在同规模数据上很容易过拟合训练时显存占用也高而 ResNet50 拿 ImageNet 预训练权重初始化单帧特征非常稳LSTM 参数量小、跑得快答辩时模型结构也好展开讲。如果你啃过《动手学深度学习》里 CNN 部分看这套代码的 backbone 基本没有障碍。models 目录下默认就是这类结构。import torch import torch.nn as nn from torchvision.models import resnet50, ResNet50_Weights class FrameEncoder(nn.Module): def __init__(self, freezeTrue, feat_dim2048): super().__init__() self.backbone resnet50(weightsResNet50_Weights.IMAGENET1K_V1) self.backbone.fc nn.Identity() for p in self.backbone.parameters(): p.requires_grad not freeze self.proj nn.Linear(feat_dim, 512) def forward(self, x): return self.proj(self.backbone(x)) class ClipClassifier(nn.Module): def __init__(self, num_classes9): super().__init__() self.encoder FrameEncoder(freezeTrue) self.lstm nn.LSTM(512, 256, num_layers2, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(256 * 2, num_classes) def forward(self, x): B, T, C, H, W x.shape feats self.encoder(x.view(B * T, C, H, W)) feats feats.view(B, T, -1) out, _ self.lstm(feats) return self.fc(out.mean(dim1))输入形状是 B×T×C×H×WB 是 batchT 是一个 clip 的帧数。把 B×T 合并成一张大 batch 过 ResNet得到每帧的 512 维向量再 reshape 成 B×T×512 送入双向 LSTM。最后对时间维做平均池化再分类而不是只取最后时刻输出因为双向 LSTM 在最后时刻携带的时间信息并不对称池化更稳。freezeTrue 表示冻结 backbone只训练 proj 和 LSTM数据量小时解冻全模型很容易过拟合到背景所以默认冻结。想提上限可以等第一轮收敛后再解冻最后一个 stage学习率降到 1e-4。3.2 ctpgr.py 训练入口与关键超参训练入口是项目根目录的 ctpgr.py。不同改版会把训练脚本拆到 train/ 里但主流程参数大同小异。我复现时按以下参数组合跑得比较稳参数我的取值说明--modetrainctpgr.py 的入口开关--data-root./pgdataset数据集根目录--clip-len30单片段帧数显存紧张可降到 16--num-clips1训练时单视频采样段数--batch-size8视频样本显存占用大8 需要 12GB 左右--epochs60小数据集 60 轮足够收敛--lr1e-3冻结 backbone 时的初始学习率--weight-decay1e-4过拟合保护--workers4DataLoader 进程数python ctpgr.py --mode train \ --data-root ./pgdataset \ --clip-len 30 \ --batch-size 8 \ --epochs 60 \ --lr 1e-3 \ --weight-decay 1e-4 \ --workers 4 \ --output-dir ./checkpoints--data-root 指向第 2 章的 pgdataset--output-dir 保存每个 epoch 的权重和 loss 曲线。loss 默认交叉熵优化器用 AdamW。如果你的显存只有 8GB先把 --clip-len 降成 16再把 --batch-size 降成 4。注意降 clip-len 会牺牲动作完整性这个取舍在第 5 章避坑会细说。训练日志里我盯着两个东西一个是 val accuracy一个是每类的 recall只盯 accuracy 很容易被类别不均衡骗过去。3.3 显存不够先把帧特征抽出来存盘batch8、clip-len30、224×224 的分辨率看起来不大实际 ResNet 一次要同时吃 240 张图B×T单卡大概率直接报 CUDA out of memory。我的做法是先把所有帧过一遍 ResNet 特征层把特征存成 .npy训练时 DataLoader 只加载特征不再碰原图显存占用基本可以砍到 2GB 以内。# pre_extract.py import torch import torch.nn as nn import numpy as np from torchvision.models import resnet50, ResNet50_Weights backbone resnet50(weightsResNet50_Weights.IMAGENET1K_V1) backbone.fc nn.Identity() backbone.eval().cuda() def extract_video_to_npy(video_meta, save_path): frames load_frames(video_meta) # (clip_len, 3, 224, 224) feats [] with torch.no_grad(): for i in range(0, len(frames), 8): batch frames[i:i 8].cuda() feats.append(backbone(batch).cpu().numpy()) feats np.concatenate(feats, axis0) # (clip_len, 2048) np.save(save_path, feats.astype(np.float16))load_frames 负责把 video_meta 里的 frame_list 从磁盘读成 tensor循环里分小批过 backbone最后用 float16 存盘。一个 30×2048 的 float16 特征文件大约 120KB一个视频一个文件。float16 能把存储减半对后续 LSTM 的精度影响很小训练时把图像分支去掉只读 .npy 喂 LSTM这样即便只有一张 8GB 卡也能把 batch 稳定提到 16。代价是特征预抽取后不能再做随机裁剪、对比度这类图像增强所以它只是显存不够时的备选方案。4. 推理与评估单视频和多段投票4.1 用 ctpgr.py 跑通一段视频训练完会在 --output-dir 下生成 best_model.pt。推理入口还是 ctpgr.py用 --mode infer 切换。我习惯先拿一段没参与训练的实拍视频试跑比如自己用手机录的路口视频而不是直接用验证集图片。python ctpgr.py --mode infer \ --checkpoint ./checkpoints/best_model.pt \ --video ./samples/intersection.mp4 \ --clip-len 30 \ --save-dir ./pred脚本对输入视频做滑动窗口采样窗口长度默认 30 帧每个窗口出一个类别概率最大概率对应的手势名会被写回可视化图里保存到 --save-dir。pred 目录里既有带标签的视频帧也有每窗口的概率数组。--video 可以是视频文件路径也可以是图像序列目录如果你的输入是 60fps 的高帧率视频建议把 clip-len 调大或者用第 2.3 节的时间轴采样否则 30 帧窗口只覆盖 0.5 秒动作还没起来就结束了。4.2 评估结果怎么看Top-1 之外还要看混淆矩阵训练日志里的 loss 和 accuracy 只能看个大概真正要出来的是验证集评估Top-1 准确率、各类别的 precision/recall/f1以及混淆矩阵。我写评估代码一般用 sklearn直接喂 logits 就出报告。import torch import numpy as np from sklearn.metrics import classification_report, confusion_matrix model.eval() all_probs, all_labels [], [] with torch.no_grad(): for clips, labels in val_loader: probs torch.softmax(model(clips.cuda()), dim1) all_probs.append(probs.cpu().numpy()) all_labels.append(labels.numpy()) all_probs np.concatenate(all_probs, 0) all_labels np.concatenate(all_labels, 0) preds all_probs.argmax(1) print(classification_report(all_labels, preds, target_namescls_names, digits3)) print(confusion_matrix(all_labels, preds))classification_report 按类给出 precision、recall、f1混淆矩阵能直观看到哪些类别互相混淆。我的习惯是重点看 f1 最低的三个类。通常左转弯待转、减速慢行这类短动作的 recall 会很低因为手势动作短容易被前后手势吞掉。不均衡数据上 val acc 高不代表模型学得好如果少数类 recall 是 0说明模型在偷懒只会输出大多数类。4.3 连续手势与长视频用投票消除抖动交警在路口是连续指挥的一个手势做完马上接下一个。如果单窗口独立预测动作切换帧附近的预测会在两个类别之间反复横跳。处理办法是长视频按滑动窗口切段每个段独立出概率最后多数投票如果要更平滑就再加 EMA。from collections import Counter def sliding_vote(model, windows, top_k3): votes [] for win in windows: prob model.predict_clip(win) votes.append(int(prob.argmax())) counter Counter(votes) return counter.most_common(top_k)windows 是滑动窗口生成的若干片段步长越小重叠越多投票结果越稳。窗口 30 帧、步长 10 帧是我常用的组合一段 10 秒视频大概产出几十个窗口单窗口预测是毫秒级整段视频几秒钟就能出结果。结合 4.2 的评估报告如果某类 recall 还是低把该类窗口步长调小或者对概率做 EMA比换模型更直接。5. 全流程避坑五个常见问题的现象、原因与解决5.1 类别不均衡让验证集分数虚高现象训练日志准确率一路涨到 82% 甚至更高但打开混淆矩阵后发现一半以上的预测都落在“停止”上少数类如“变道”“靠边停车”几乎没有正确预测。 原因公共手势数据本身不均衡停止和直行样本多、动作时间长模型只要偏向输出这两类就能拿到不错的准确率并没有真正学会其他手势。 解决训练前先跑第 2.2 节的统计脚本观察最大类和最小类样本数量差距。超过 5:1 就改用 WeightedRandomSampler给每个样本按类别频率的倒数赋采样权重。from torch.utils.data import WeightedRandomSampler from collections import Counter labels [a[gesture_class] for a in train_ann[annotations]] class_count Counter(labels) weights [1.0 / class_count[a[gesture_class]] for a in train_ann[annotations]] sampler WeightedRandomSampler(weights, num_sampleslen(weights))weights 列表和样本列表一一对应每个 epoch 采样器会按权重重采样让少数类的出现次数和多数类持平。另一个轻量办法是给 loss 加 class weights但效果没有重采样直接我一般两个一起用。5.2 标签和片段不同步验证集卡在固定准确率上不动现象训练损失正常下降验证准确率却卡在 60% 多上不去调超参也没有明显改善。 原因片段采样没对齐手势动作中心。30 帧窗口可能横跨上一手势结尾和下一手势开头标签却来自中间帧模型一直在学“前后动作混合”的脏序列。 解决把片段锚定在手势动作中心。如果标注里有 start_frame 和 end_frame用 (start end) // 2 作为中心向后取 clip_len 帧。def build_clip_around_center(video_meta, center_frame, clip_len30): n len(video_meta[frame_list]) half clip_len // 2 start max(0, center_frame - half) indices np.linspace(start, min(n, start clip_len), clip_len, endpointFalse, dtypeint) return indices中心锚定后clip 跨手势的概率大幅下降对减速慢行这种短动作效果尤其明显。代价是手势起止附近的样本变少所以推理时配合第 4.3 节的滑窗投票验证和推理口径才一致。5.3 CUDA out of memoryBatch 降到 4 还是炸现象训练刚跑几个 batch 就报 CUDA out of memory把 batch-size 改成 4 甚至 2 依然爆。 原因报错本质不是 batch 不够小而是 B×T 张图同时过 ResNet 的显存开销太大。clip-len30、batch8 等效于 240 张图一起过骨干网络8G 卡扛不住。 解决优先做第 3.3 节的 feature bank 把帧特征离线存盘其次加梯度累积用多步小 batch 模拟大 batch再配合 AMP 混合精度。scaler torch.cuda.amp.GradScaler() accum_steps 4 optimizer.zero_grad() for step, (clips, labels) in enumerate(train_loader): with torch.cuda.amp.autocast(): loss criterion(model(clips.cuda()), labels.cuda()) / accum_steps scaler.scale(loss).backward() if (step 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()accum_steps4 相当于每 4 个 batch 更新一次权重batch8 等效成 32。注意 loss 需要除以累积步数不然梯度直接放大 4 倍训练会抖。混合精度依赖 torch.cuda.amp在 8G 卡上能把显存占用降到一半左右训练速度也明显变快。5.4 训练集 99% 验证集 75%模型在偷学背景现象训练集准确率接近满分验证集却明显掉点而且掉点样本集中在某一个路口的几段视频里。 原因训练和验证划分时按帧混洗同一视频的帧同时出现在训练集和验证集。这些帧背景完全相同模型学到的是“背景颜色位置”而不是手势本身。 解决按视频 ID 分组划分训练和验证一个视频的所有片段只能出现在一侧。import numpy as np video_ids sorted({a[video_id] for a in labels}) np.random.shuffle(video_ids) split int(len(video_ids) * 0.8) train_vids, val_vids video_ids[:split], video_ids[split:]划分完再做数据增强。RandomErasing 和 RandAugment 对小数据集很有用能压住模型对静态背景的依赖我一般再加 RandomResizedCrop让每帧视角略有变化模型就没法只靠背景纹理判断了。5.5 推理时预测类别来回跳变现象同一段视频前 1 秒预测“直行”后 1 秒变“右转”再过 1 秒又跳回“直行”输出序列像在抽风。 原因单个窗口只有 30 帧手势动作中段和末段的概率分布不同窗口移动到动作边界时预测自然会切换。 解决多窗口投票加指数平滑对连续概率做 EMA 再取 argmax。def ema_smooth(prob_seq, alpha0.7): smooth [] prev None for p in prob_seq: if prev is None: prev p else: prev alpha * p (1 - alpha) * prev smooth.append(int(prev.argmax())) return smoothalpha 控制历史概率的权重越大越平滑但对短动作越迟钝0.7 是我试下来“短动作少漏检”和“预测不抖”之间的折中。如果某个特定类别还是频繁抖动单独调该类判定的概率阈值比整体调 alpha 更精准。6. 在自己素材上微调把新视频变成训练样本6.1 快速对齐 pgdataset 的文件结构像 YOLOv8 训练自定义数据集先要把图片和标签转成规定目录一样动作识别项目的可扩展性完全取决于素材是否按 pgdataset 的格式组织。这套代码的场景不变新增素材只需三步用 ffmpeg 把视频抽帧登记 frame_list写一条 gesture_class 标注。ffmpeg -i ./custom_videos/left_turn_01.mp4 \ -vf fps25 -q:v 2 \ ./custom_frames/left_turn_01/frame_%05d.jpgfps25 是把视频统一到 25 帧每秒和第 2.3 节的时间轴采样直接相关-q:v 2 控制帧质量数值越小质量越高。文件夹名建议直接用类别拼音方便脚本批量扫描。抽完帧后再构造一条标注记录import glob frames sorted(glob.glob(./custom_frames/left_turn_01/frame_*.jpg)) record { video_id: 1000, gesture_class: 2, frame_list: frames, fps: 25 }gesture_class 必须和 constants 里的类名索引一致2 对应“左转弯”只是我习惯的排法换数据时以项目里的常量为准。把所有 record 合并进 train.json 的 annotations再把新视频的帧目录放进 pgdataset/frames/ 下一个可训练的自定义数据集就绪了。6.2 微调超参与一个改到吐血的教训新增素材只有几十段视频时别解冻整个 backbone。解冻 ResNet 全模型基本等于重新学特征几十段视频远远不够。我推荐先冻结 backbone 跑 40 轮lr 用 1e-3把 LSTM 训练稳再视情况解冻最后两层。python ctpgr.py --mode train \ --data-root ./pgdataset \ --clip-len 30 \ --batch-size 8 \ --epochs 40 \ --lr 1e-3 \ --freeze-backbone--freeze-backbone 打开后只更新 proj 和 LSTM参数量少训练时间能压缩一半小数据下泛化也更好。我自己的血泪教训是为了“让模型多学一点”直接解冻全模型结果新素材量不够训练损失很漂亮验证集直接掉点最后回滚重来白跑一晚上。从那以后我每次换素材都强制先冻结 backbone 跑通全流程确认有效再逐步解冻特征层绝不上来就全量微调。希望这个习惯能帮你在自定义数据上少走几步弯路。本文还有配套的精品资源点击获取