交警手势识别实战:关键点估计与时序建模的工程实现
简介面向计算机、电子信息等专业的课程设计与毕业设计场景这份基于Python的交警指挥手势识别项目提供完整可运行的算法源码与配套手势数据集可直接用于模型训练、推理演示与二次开发也适合作为深度学习视觉方向的学习起点。压缩包共34个文件以31个Python脚本为核心覆盖数据预处理、关键点检测、模型训练、手势预测和结果评估等环节另含1份Markdown说明文档与1个GIF动态演示整体大小仅4.42MB结构轻量便于快速上手。项目内部分层清晰训练入口、推理模块与评估脚本相互独立数据处理流程涵盖图像缩放、增广、骨架提取与归一化等步骤能够帮助读者理解从原始视频到骨架特征再到手势分类的完整链路。目前已有188人浏览/学习包内提供说明文档与演示动画目录组织有序便于按模块查阅和调试下载后可直接运行也适合初学者在调试中掌握基于关键点与PAF的交警手势识别实现思路。1. 交警手势识别不是视频分类是“关键点估计时序建模”把交警指挥手势识别直接做成视频分类是新手最容易踩的坑。真实监控场景里交警只占画面一小块背景有车流和行人同一个动作在画面上的位置、尺度、拍摄角度全在变3D CNN 学到的是“交警在那个位置挥动”换一个机位就失效。而交警手势的标准定义本来就基于肢体角度和位置关系比如“停止”是左臂抬起、小臂上折“左转”是右臂抬平、左右摆动所以更稳的做法分两步第一步用姿态估计回归出人体关键点第二步把关键点序列当成时序数据做分类。这套源码正是按这个思路落地的姿态部分走 OpenPose 风格的高斯热图和 PAF 亲和场手势部分基于关键点序列建模适合计算机、电子信息类课程设计和毕设直接复现。2. 数据预处理AI Challenger 关键点热图、PAF 与手势序列标签怎么生成2.1 资源里的数据管线拆解拿到压缩包先看目录结构两个数据相关目录决定了整个训练流程的先后顺序。aichallenger/负责把原始图片变成姿态估计训练样本里面是s0_native.py、s1_resize.py、s2_augment.py、s3_gaussian.py、s4_affinity_field.py、s5_norm.py、visual_debug.py这套编号脚本从命名就能看出执行顺序。pgdataset/负责生成交警手势序列样本包含s0_label.py、s1_skeleton.py、s2_truncate.py、s3_handcraft.py。脚本输入输出作用s0_native.pyAI Challenger 原始标注 JSONCOCO 风格 JSON统一关键点格式过滤不可见点s1_resize.py原始图片固定尺度图片统一分辨率降低后续训练显存压力s2_augment.py缩放后图片增强后图片与标签随机旋转、裁剪、色彩抖动、时序扰动s3_gaussian.py关键点坐标高斯热图把关键点坐标展开成响应图s4_affinity_field.py关键点与骨架连接PAF 双通道图编码关键点之间的方向和关联强度s5_norm.py训练数据归一化后的 npy像素值归一化到[-1, 1]我一般建议先跑visual_debug.py看热图和 PAF 渲染在原始图片上的效果再决定要不要调后面的参数。资源里这一环是齐全的不用自己造轮子。2.2 高斯热图生成逻辑关键点坐标怎么变成训练目标姿态估计模型不直接回归(x, y)而是回归一张大小为输入图 1/8 的高斯热图每个通道对应一个关键点。以s3_gaussian.py的简化逻辑为例import numpy as np def gaussian_heatmap(shape, joints, sigma3): shape: (H, W)joints: [(x, y, visible), ...]返回 (num_joints, H, W) num_joints len(joints) heatmap np.zeros((num_joints, shape[0], shape[1]), dtypenp.float32) for i, (x, y, vis) in enumerate(joints): if vis 1: continue # 被遮挡或未标注的关键点不生成响应 xx, yy np.meshgrid(np.arange(shape[1]), np.arange(shape[0])) heatmap[i] np.exp(-((xx - x) ** 2 (yy - y) ** 2) / (2 * sigma ** 2)) return heatmapsigma这个参数很关键。它控制响应峰的覆盖范围和标注误差、网络下采样倍数强相关。如果输入图 368x368热图是 46x46下采样 8 倍那么坐标误差本身就有一个像素级别sigma取 2~4 都是常见区间。取太大热图会糊成一团取太小训练时正样本区域过窄损失收敛慢。训练时一般会对热图做逐像素 MSE 损失背景区域的权重设为 0.1 左右避免大量零值把前景信号稀释掉。2.3 PAF 亲和场怎么把关键点连成骨架有了关键点还要知道哪些点属于同一个人、哪两个点之间存在连接关系。PAF 的思路是对每一条骨架连接线生成一张两通道图存的是从起点指向终点的单位向量且只在连接线段周围的有限带宽内填充。s4_affinity_field.py对应这段逻辑def affine_field(shape, joints, skeleton, radius4): skeleton: [(joint_a_index, joint_b_index), ...]返回 (2, H, W) paf np.zeros((2, shape[0], shape[1]), dtypenp.float32) for a, b in skeleton: p1, p2 joints[a][:2], joints[b][:2] vec p2 - p1 norm np.linalg.norm(vec) if norm 1e-6: continue unit vec / norm # 对线段周围像素填充单位向量分量 ys, xs np.where( np.abs((xs - p1[0]) * unit[1] - (ys - p1[1]) * unit[0]) radius ) paf[0, ys, xs] unit[0] paf[1, ys, xs] unit[1] return pafradius决定填充带宽。取太小训练时正样本采样率低取太大不同人的连接线会交叉重叠产生歧义。交警场景下交警骨架跨度通常占画面 30%~50%radius取 4~6 像素比较均衡。实际源码里还会先判断点是否落在线段端点范围之内我这里只保留核心公式。推理阶段PAF 图配合关键点热图做匈牙利匹配就能把离散关键点连成完整骨架。2.4 手势序列截断与手工特征pgdataset/s1_skeleton.py把姿态估计结果整理成每帧 17 个关键点的序列s2_truncate.py负责把长视频切成固定长度的动作片段。交警手势不是瞬时动作单帧看不出“左转”“右转”的区别必须保留时间上下文。常见做法是检测手腕或肘部速度变化超过阈值的帧作为动作起始点向后取固定长度seq_len32def truncate(skeleton_seq, pad_len32): 从序列中截取动作片段不足补零 if len(skeleton_seq) pad_len: # 用首帧姿态填充到 pad_len避免引入无效的 0 坐标 pad [skeleton_seq[0]] * (pad_len - len(skeleton_seq)) skeleton_seq pad skeleton_seq return skeleton_seq[-pad_len:]截取长度不能拍脑袋。动作起始到结束按 25fps 视频算大约 1.5~2 秒也就是 37~50 帧。seq_len取 32 会截断动作收尾部分取 64 又混入大量静止帧。s3_handcraft.py还会计算肩肘腕夹角、双手与躯干相对距离、关键点速度等手工特征这些特征对手势区分度很高能帮分类模型降低对时序建模能力的依赖。3. 模型结构与训练PAF 姿态分支与手势分类分支如何联合工作3.1 整体设计一个姿态网络一个分类网络ctpgr/目录下分两套模型。pafs_network.py、pafs_resnet.py构成姿态估计网络输入是归一化后的单帧图片输出是多通道关键点热图和 PAF 亲和场。pose_estimation_model.py组合前两者对外提供统一接口。gesture_recognition_model.py是手势分类模型输入不是图片而是姿态网络输出的关键点序列输出是交警手势类别概率。模块文件输入输出说明pafs_resnet.py3xHxW 图片ResNet 特征图主干特征提取可换成 MobileNetpafs_network.py特征图热图 PAF 多阶段精炼阶段数决定感受野和参数量pose_estimation_model.py图片关键点热图、PAF训练入口封装损失计算gesture_recognition_model.pyT x K x C 关键点序列手势类别 logits含 LSTM 或 1D 卷积时序编码两个模型分开训练推理时串联起来。这样做的好处是姿态模型可以用 AI Challenger、COCO 等公开数据预训练手势模型只用交警手势数据集对数据量要求低很多。3.2 姿态网络训练脚本与参数train_keypoint_model.py负责训练姿态估计网络典型的启动命令python train_keypoint_model.py \ --data_dir aichallenger \ --input_size 368 \ --heatmap_size 46 \ --paf_size 46 \ --batch_size 16 \ --epochs 60 \ --lr 1e-3 \ --weight_decay 5e-4 \ --vis_dir vis_samplesinput_size决定图像缩放尺度。368 是 OpenPose 用的经典尺寸越大精度越高但显存占用按平方上涨batch_size 16 配 368 在单张 11GB 显卡上接近上限。heatmap_size是输入尺寸的 1/8这个比例由主干网络步长决定改它没意义必须和网络结构对齐。损失函数是热图的 MSE 加权加 PAF 的 MSE 加权背景像素权重通常设为 0.1~0.2代码里keypoints.py中的constants.enum_keys.py会定义权重常量。训练中我会盯两个现象。第一是热图损失跌到某个值后不再下降说明高斯核的sigma和标注误差不匹配可以调大sigma或者用动量修正坐标。第二是 PAF 损失下降慢多半是radius填得太窄正样本比例太低可以适当放宽。这个阶段不追求精度刷到最高关键点坐标粗糙一点没关系后续手势分类模型对轻微的关节抖动有容忍度。3.3 手势分类模型不一定要上 LSTMgesture_recognition_model.py的输入张量形状通常是(batch, seq_len, num_keypoints * 2)17 个关键点展平成 34 维。分类模型常用两套方案LSTM 或 1D 卷积。LSTM 对长序列的时序依赖建模更直接但训练慢、容易过拟合。1D 卷积感受野有限需要堆足够层数才能覆盖整个动作周期。我偏向用带残差连接的 1D 卷积class GestureClassifier(nn.Module): def __init__(self, in_channels34, num_classes8): super().__init__() self.conv1 nn.Conv1d(in_channels, 64, kernel_size5, padding2) self.conv2 nn.Conv1d(64, 128, kernel_size5, padding2) self.conv3 nn.Conv1d(128, 128, kernel_size3, padding1) self.pool nn.AdaptiveAvgPool1d(1) self.fc nn.Linear(128, num_classes) def forward(self, x): # x: (batch, seq_len, 34)需要转成 (batch, 34, seq_len) x x.transpose(1, 2) x torch.relu(self.conv1(x)) x torch.relu(self.conv2(x)) x torch.relu(self.conv3(x) x[:, :, :x.size(2)]) # 残差 x self.pool(x).squeeze(-1) return self.fc(x)kernel_size5在 25fps 下相当于 0.2 秒窗口三层叠加约 12 帧加上池化层能覆盖整个动作片段。代码里num_classes8对应停止、直行、左转、右转、左待转、右待转、变道、减速这 8 种常用交警手势具体以资源里的标签定义为准。训练命令python train_police_gesture_model.py \ --train_dir pgdataset/train \ --val_dir pgdataset/val \ --seq_len 32 \ --num_classes 8 \ --model_type conv1d \ --batch_size 64 \ --epochs 80 \ --lr 3e-3这里seq_len必须和s2_truncate.py截取长度一致否则数据读取报错。训练集和验证集按交警手势视频片段划分不要按帧划分否则同一段动作的相邻帧会同时出现在训练和验证里指标虚高。4. 推理链路从交警指挥视频到手势指令输出4.1 单帧关键点预测先跑通一张图训练完成后先用human_keypoint_pred.py验证单张图片的姿态估计效果python human_keypoint_pred.py \ --image frame_00124.jpg \ --pose_model checkpoints/pose_best.pth \ --output out_keypoints.jpg \ --show_paf脚本会加载姿态模型对输入图片做与训练一致的预处理前向推理拿到热图和 PAF再通过最大值定位和二分图匹配得到关键点坐标。--show_paf会把 PAF 向量渲染在原图上方便肉眼确认连接方向是否正确。常见输出是 17 个点的(x, y, score)score低于 0.3 的点在后续手势分类中应该被过滤掉不能作为有效输入。4.2 视频骨架提取帧与帧之间的稳定性处理prepare_skeleton_from_video.py把整段视频变成骨架序列文件。它按固定帧率抽帧比如每秒 10 帧对每帧跑一次姿态估计然后把所有关键点写入一个 npy 或 JSON 文件。实际跑的时候会遇到单帧抖动问题某一帧手腕被遮挡关键点突然跳到身体另一侧。常见做法是加一个轻量级的平滑对连续帧的同一个关键点做指数滑动平均smoothed 0.7 * prev_smoothed 0.3 * current平滑系数不能太大0.7 以下会让快速摆臂动作产生明显延迟。交警手势中“左转”的摆臂频率约每秒 2 次按 10fps 抽帧每 5 帧一个完整摆动周期0.3 的当前帧权重可以保留动作峰值。如果原视频帧率是 30fps建议抽帧到 10~15fps既保留动作节奏又减少计算量。4.3 手势分类与结果回放拿到骨架序列后用gesture_pred.py输出每个时间窗口的手势类别python gesture_pred.py \ --skeleton skeletons/traffic01.npy \ --model checkpoints/gesture_best.pth \ --label_map labels.json \ --window_size 32 \ --stride 8 \ --output result.jsonwindow_size32对应训练时的seq_lenstride8表示每 8 帧滑动一次窗口产生一个预测结果。stride决定输出帧率8 在 10fps 下意味着每秒输出 10/8 1.25 个预测能跟上动作切换速度。最后play_gesture_results.py把原始视频、骨架叠加层、识别文本合成一个可视化结果python play_gesture_results.py \ --video traffic.mp4 \ --skeleton skeletons/traffic01.npy \ --result result.json \ --output annotated.mp4这里有一个容易被忽视的点window_size窗口末端才是预测结果对应的时刻不是窗口起始。回放时如果发现识别结果比动作晚半拍往往就是这个对齐问题。play_keypoint_results.py则单独回放姿态估计输出不加载分类模型用于排查骨架提取阶段的问题。4.4 评估指标别只报准确率evaluation.py把预测结果和真值对比生成三组指标python evaluation.py \ --pred pred.json \ --gt val_gt.json \ --metrics pck mAP accuracy \ --keypoint_pck_threshold 0.2对姿态估计部分PCK 表示预测关键点与真值距离在阈值范围内的比例阈值为躯干直径或头部长度的比例通常取 0.2。对 PAF 的关联效果用 mAP 评估关键点连接正确率。手势分类部分直接看整体准确率和每类别的精确率、召回率。交警手势类别不平衡严重比如“停止”远多于“右待转”整体准确率会被多数类拉高必须看每类别的召回率。5. 调参与排错归一化、序列截取和类别不平衡的处理5.1 关键点坐标归一化到肩宽尺度直接把像素坐标喂给分类模型会让模型学到“交警站在画面左边就是左转”这种位置伪特征。我一般把关键点坐标转换到以肩宽为单位的相对坐标系torso_center (kpts[2] kpts[5]) / 2 # 左右肩中点 shoulder_width np.linalg.norm(kpts[2] - kpts[5]) scaled (kpts[:, :2] - torso_center) / shoulder_width肩宽归一化对交警手势尤其有效因为手势定义以肩关节为基准所有摆臂角度都以肩为原点。没有检测到左右肩时跳过该帧不补零避免引入极端值。这个预处理应该在s3_handcraft.py阶段完成而不是推理时临时改。5.2 序列截取边界动作起止如何找window_size选短了只截到动作中间段选长了混入上一动作的残留。一个实用的工具是看手腕点速度曲线静止时速度接近零动作开始瞬间加速度突变。取所有手腕点速度超过全局均值的第一个帧作为起点向后取固定窗口。验证集上做一次统计看 90% 的动作片段需要多少帧才能覆盖完整动作周期再定window_size避免凭感觉设。5.3 类别不平衡与损失加权交警手势数据里“直行”“停止”出现频率高“左待转”“右待转”样本少。用加权交叉熵比简单过采样更稳定from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight(balanced, classesnp.unique(labels), ylabels) criterion nn.CrossEntropyLoss(weighttorch.from_numpy(class_weights).float())注意权重不宜超过 1:5极端加权会让模型把多数类全部判错去迁就少数类。更稳妥的做法是先用原始分布训练看混淆矩阵再只对最容易混淆的类别加 1.2~1.5 倍权重。5.4 可视化验证的两个关键入口排查问题最快的方式是用visual_debug.py把训练样本里的热图和 PAF 叠加显示。热图中心应该正好落在关节中心如果偏移超过 2 像素检查s1_resize.py的坐标变换是否跟着图像缩放一起做了。PAF 图应该沿着骨架方向连续延伸如果出现断裂把radius加 1~2 再重新生成标签。这两步检查能过滤掉大量训练不收敛的情况比盯着损失曲线猜原因高效。运行python visual_debug.py --data_dir aichallenger --sample_idx 10即可直接出图。本文还有配套的精品资源点击获取