LSTM+姿态估计:羽毛球动作预测工程实现

发布时间:2026/10/10 13:49:31
LSTM+姿态估计:羽毛球动作预测工程实现 简介面向姿态估计与动作预测学习者的LSTM羽毛球动作预测生成完整项目基于PyTorch实现覆盖从数据集制作、数据处理、模型搭建到可视化全流程。资源共64个文件包含26个Python源码脚本、19个pyc编译模块、5个CSV数据表、4个PB模型文件、2个H5权重文件以及sh启动脚本和txt/docx/xlsx说明文档压缩包约388.84MB。目录下区分Pose姿态估计、DeepSort目标跟踪、行为识别、训练等模块结构清晰。pyc文件可直接复用编译模块pb/h5为训练好的权重配合使用说明能快速上手。已有1509人学习下载。借助训练脚本可复现动作预测流程修改LSTM层参数、损失函数即可观察效果适合深度学习、运动分析方向开发者作为工程参考。1. 羽毛球动作预测为什么说动作识别本质上是个时序问题在羽毛球视频里杀球和吊球落到某一帧上往往长得几乎一样——同样的持拍姿势类似的躯干角度只差球拍的加速和引拍的轨迹。单帧图像很难判断但把时间轴拉出来杀球从引拍到击球可能只要0.4秒吊球却多出明显的减速和拍面控制过程。深度学习里能处理这种前后依赖关系的网络就是LSTM长短期记忆网络项目标题里写的“LTSM”只是常见笔误。这套“行为预测”工程把羽毛球动作预测做成了标准时序任务先用姿态估计把每帧压成关节坐标再按时间顺序喂给LSTM输出动作类别最后把预测结果画回视频帧。它不绑定特定比赛画面数据、模型、训练和可视化四个环节都有代码文件对应适合动作识别和姿态估计项目从零搭起一套最小可跑流程。2. 构建时序样本姿态估计、坐标归一化与滑动窗口切片拿到一份行为预测工程时我会先把文件脉络理顺再动手跑训练否则很容易出现“模型改了但不知道改的是哪部分数据”的情况。这套工程的目录职责梳理下来主线是这样的Pose文件夹负责把视频帧变成关键点data_deal.py负责把关键点切成长度一致的序列Action文件夹定义动作类别并保存已训练权重train_0.py和train_1.py负责训练。下面按数据流向逐层展开这部分也是整个工程最花时间的地方。文件路径在流程里的职责Pose/pose_estimator.py对视频帧做姿态估计输出关键点Pose/human.py保存单个人的关键点数据和置信度Pose/pose_visualizer.py把关键点画回图像帧用于可视化Action/action_enum.py定义动作类别枚举标签data_deal.py滑窗切片、清洗、生成训练样本train_0.py / train_1.py基础训练脚本和调优训练脚本Action/recognizer.py加载权重文件做推理预测Action/framewise_recognition.h5训练保存下来的权重文件2.1 Pose 模块从视频帧到关键点坐标动作识别不用原始视频帧直接喂网络而是先做姿态估计这一步不是可选项而是必需项。原视频帧包含背景、球衣、场地和镜头运动LSTM如果直接学这些像素很容易记住“这个场地”“这件衣服”而不是动作本身。骨架坐标对背景和外观不敏感输入维度也从几万像素压缩到几十个浮点数训练速度快且不容易过拟合。资源里的Pose文件夹正是按这个思路设计的。Pose 文件夹里四个文件的职责很清晰pose_estimator.py对单帧图像做姿态估计human.py定义人物对象容器coco_format.py把检测结果转成COCO风格的标注结构pose_visualizer.py把关键点绘制回原始帧。整个流程就是逐帧调用姿态估计把坐标累积到文本文件。工程里已经提供了origin_data.txt如果你想从一段新视频重新生成坐标常见做法是下面这样# 逐帧调用姿态估计保存关节坐标到文本 import cv2 from Pose.pose_estimator import pose_estimator def extract_poses(video_path, output_path): cap cv2.VideoCapture(video_path) out open(output_path, w) frame_id 0 while True: ret, frame cap.read() if not ret: break humans pose_estimator(frame) # 返回该帧中的人物列表 for human in humans: joints human.joints parts [str(frame_id)] for j in joints: parts.append(f{j.x:.3f},{j.y:.3f},{j.score:.3f}) out.write( .join(parts) \n) frame_id 1 cap.release() out.close() extract_poses(match.mp4, origin_data.txt)逻辑说明这里把姿态估计结果落成文本而不是直接拼成张量是因为后续滑窗之前要检查帧序号是否有跳变也方便在脚本里快速定位某一帧的关键点是否异常。j.score是模型对该关节的置信度后续清洗和归一化都要用到它不能省略。参数部分坐标保留三位小数就够用1080p量级的画面里再多的小数位不会给LSTM分类带来实际收益只会让文本文件无谓变大。多人场景要特别注意pose_estimator返回多个人物时必须为每个人都分配一个稳定的跟踪ID否则帧间会把不同运动员的骨架混在一起。资源里的Tracking文件夹就是为了解决这个问题先通过generate_dets.py生成检测框再交给Tracking/deep_sort给每个人分配全局ID最后按ID保存同一个人的关节序列。不跟踪就切窗训练数据里会混入大量“换人”的坏样本后果在第4章排查部分细说。2.2 归一化与数据增强先消除尺度差异再进网络原始坐标不能直接进入训练。不同机位的视频分辨率不同同一个运动员站在画面近处和远处时骨骼长度在像素尺度上的表现完全不同如果把这些绝对值直接输入模型网络会去记忆“这个人站在画面的什么位置”而不是记忆动作结构。归一化的常见做法是以颈部或躯干中心为原点做平移再按骨骼长度做缩放让所有样本落入同一尺度。utils.py里通常就放这类公共函数典型实现是这样的# utils.py以颈部为原点做平移和缩放 import numpy as np def normalize_keypoints(joints, neck_idx8, hip_idx9, eps1e-6): center joints[neck_idx] translated joints - center scale np.linalg.norm(joints[hip_idx] - joints[neck_idx]) return translated / (scale eps)逻辑说明函数先取颈部关节作为原点把整个人体平移到画面中心附近再除以颈部到髋部的骨骼长度让离镜头远近不同的人拥有同一尺度。加号eps是为了防止骨骼长度为零时产生除零NaN这在姿态估计偶尔失败的帧里是真实会发生的情况。参数说明neck_idx和hip_idx对应COCO骨架的编号如果你换成MPI或自定义标注格式这两个索引必须重新核对否则整条骨骼就会被错误地拉伸。数据增强在这里也可以做但要克制。图像可以随便旋转裁剪骨骼坐标的增强要小心语义水平翻转后左右关节必须交换比如左肩和右肩互换否则骨架结构错乱时间轴上可以随机丢几帧再线性插值模拟不同运动员挥拍速度的差异。直接在坐标上加高斯噪声是最不建议的方式它会破坏关节与关节之间的连接关系让模型学到断裂的骨架。2.3 滑窗切片与动作标签映射把连续动作切成固定长度样本一个完整羽毛球动作持续20帧到60帧不等而LSTM要求输入序列长度固定所以要用滑动窗口把所有数据切成等长片段。data_deal.py里的关键逻辑是设置窗口长度win_len和步长stride从左往右滑动每个窗口取一段连续帧序列再把窗口内出现次数最多的动作标签作为这个窗口的整体标签。核心代码如下# data_deal.py滑动窗口切片 import numpy as np def sliding_window(seq, labels, win_len30, stride5): samples, targets [], [] for start in range(0, len(seq) - win_len 1, stride): win_seq seq[start:start win_len] win_label labels[start:start win_len] label np.bincount(win_label).argmax() samples.append(win_seq) targets.append(label) return np.array(samples), np.array(targets)逻辑说明这里对窗口内所有帧的动作标签做多数投票因为手动标注时往往只在动作起点打一个标记后续帧沿用前一标签窗口切片后标签会出现轻微抖动。参数说明win_len选30在30fps的视频里对应1秒基本覆盖一个标准羽毛球动作的完整过程stride选5会让相邻窗口有约25帧重叠样本量增大但重叠过多会导致训练样本高度相似、模型偏保守。如果只是为了增加样本量把stride从5调到2不如去做时间轴增强效果更好且不增加冗余。标签用枚举而不是裸数字管理。资源里Action/action_enum.py用IntEnum把动作定义成可读的枚举结构类似这样# Action/action_enum.py动作类别枚举 from enum import IntEnum class Action(IntEnum): READY 0 # 准备动作 CLEAR 1 # 高远球 SMASH 2 # 杀球 DROP 3 # 吊球 NET 4 # 网前球逻辑说明IntEnum的好处是既能用名称访问又能在数值上参与One-Hot编码训练代码里不出现硬编码的数字标签。新增动作类别时在枚举类里追加一项并同步修改data_deal.py的标签映射逻辑即可。数据拆分我始终坚持按视频分组同一段视频的帧不能同时出现在训练集和验证集里否则模型见过验证集运动员的动作习惯验证准确率会虚高。这个拆分逻辑看起来损失了一些样本但换来的是可信的评估结果值得。3. LSTM 模型搭建输入张量、网络结构与训练流程数据和代码都有了接下来选网络。为什么用LSTM而不是单帧CNN动作是随时间展开的过程LSTM通过门控机制保留重要的历史信息、遗忘无关信息在处理几十帧的动作窗口时非常合适。虽然Transformer在时序任务上热度很高但通常需要更长序列和更大数据量而羽毛球动作窗口只有几十帧LSTM在这个数据量级上更容易训练也更容易调参。资源的train_0.py是基础训练版本train_1.py是调优版本前者跑通流程后者压指标。3.1 输入张量格式把关键点窗口整理成 LSTM 的输入PyTorch中LSTM的输入形状是(batch_size, seq_len, input_size)。在羽毛球动作预测这个场景里batch_size是每批送入的窗口数量seq_len是窗口帧数input_size是每帧的特征维度。假设使用COCO的17个关键点每帧特征由x坐标、y坐标和置信度组成那么一个时间步的特征维度是17乘以3等于51。窗口长度为30时单个样本的维度就是(30, 51)一批32个样本就是(32, 30, 51)。data_deal.py生成的数据是numpy数组需要经过一步reshape才能进入模型这个转换通常放在数据集类里# 将窗口数据转换为LSTM输入张量 import torch def prepare_inputs(windows): # windows: (N, win_len, num_joints, 3) 最后一维是x, y, conf N, T, J, C windows.shape x windows.reshape(N, T, J * C) # 每个时间步展平成一个特征向量 x torch.FloatTensor(x) return x逻辑说明reshape时把每个时间步上的所有关节连同坐标拼成一个扁平向量而不是保留四维结构因为LSTM的一个时间步只能接收一个一维特征向量网络会自己学习每个关节的重要性不需要人为区分关节编号。参数说明如果打算做输入维度消融可以在reshape前按关节筛选特征比如只保留上半身12个关节input_size同步变为36模型参数量下降约三成适合快速验证数据量不足时的表现。3.2 两层 LSTM 加全连接分类头网络结构选型train_0.py里的模型结构是典型的两层LSTM加全连接分类头。第一层LSTM把原始特征映射成隐状态第二层LSTM继续捕捉更高层次的时序模式最后取最后一个时间步的隐状态经过全连接头映射到动作类别空间。两层LSTM在这个场景下是平衡选择一层往往学不出动作内部的先后关系三层以上在几千条样本的小工程里几乎必过拟合。Dropout放在LSTM层之间和全连接头前面直接压制过拟合。模型结构如下# train_0.py 中的LSTM网络结构 import torch.nn as nn class ActionLSTM(nn.Module): def __init__(self, input_dim, hidden_dim, num_layers, num_classes, dropout0.5): super().__init__() self.lstm nn.LSTM( input_dim, hidden_dim, num_layers, batch_firstTrue, dropoutdropout ) self.head nn.Sequential( nn.Linear(hidden_dim, hidden_dim // 2), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim // 2, num_classes) ) def forward(self, x): out, _ self.lstm(x) # out: (batch, seq_len, hidden_dim) last out[:, -1, :] # 取最后一个时间步的隐状态 return self.head(last)逻辑说明batch_firstTrue表示输入的第一个维度是batch而不是seq_len这样省去转置、维度不容易搞混。out返回所有时间步的输出取[:, -1, :]拿到最后一步的隐状态再交给全连接头意味着模型必须把整个动作窗口的信息压缩进最后一个状态这正是LSTM对时序依赖的表达方式。参数说明hidden_dim取128在多数小样本序列任务上够用加到256收益不明显且训练时间接近翻倍num_layers取2是折中取3时dropout参数会作用在第二个LSTM层内部正则效果更强。如果想做真正意义上的“动作预测生成”而不是“动作分类”可以把分类头替换成线性回归头输出下一帧所有关节坐标损失从交叉熵换成MSE。这套资源的主体还是分类模型生成式视角体现在最终可视化环节——把预测标签作为时间轴上的动画帧画回视频里。3.3 训练参数设置与两个训练脚本的分工训练流程是标准的三段式加载训练集和验证集定义损失函数与优化器循环迭代并定期验证。先看一组和资源配套的典型参数方便你跑的时候对照超参数train_0.py 典型取值调整说明input_dim5117个关键点 × (x, y, conf)hidden_dim128LSTM隐层维度num_layers2两层LSTMwin_len30序列长度对应30帧约1秒batch_size32窗口数量lr1e-3Adam优化器初始学习率weight_decay0train_0.py不设置lossCrossEntropyLoss多分类动作任务train_0.py的训练循环写得很直白适合逐行理解# train_0.py 训练循环核心 import torch import torch.optim as optim import torch.nn as nn model ActionLSTM(input_dim51, hidden_dim128, num_layers2, num_classes5) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(80): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: optimizer.zero_grad() out model(x_batch) loss criterion(out, y_batch) loss.backward() optimizer.step() train_loss loss.item() if epoch % 5 0: val_acc evaluate(model, val_loader) print(fepoch {epoch} | loss {train_loss / len(train_loader):.4f} | val_acc {val_acc:.4f}) torch.save(model.state_dict(), Action/framewise_recognition.h5)逻辑说明反向传播前先调用optimizer.zero_grad()很关键PyTorch默认会在backward()时累加梯度不清零就会把多个batch的梯度叠在一起。每5轮在验证集上算一次准确率用来判断模型是在正常学习还是已经开始过拟合。最后把state_dict保存为工程约定的.h5后缀权重文件后续由recognizer.py读取推理。参数说明epoch不一定要跑满80常见做法是验证集准确率连续10轮不再提升就早停省下的时间可以用来调滑窗参数。train_1.py作为调优版本主要改动集中在正则化上加上weight_decay、加入学习率衰减、把dropout提到0.6。如果你在训练集和验证集准确率之间看到超过20个百分点的差距优先打开train_1.py把正则项相关参数调起来这比盲目加大数据量见效更快。4. 避坑排查动作边界错位、样本倾斜与验证集泄漏前两章把主线流程讲完了但真正耗时的地方通常在模型之外。以下5个坑我在做动作识别项目时几乎每轮都至少踩过两个按现象、原因、解决的顺序写出来方便对照排查。4.1 关键点缺失导致 Loss 变成 NaN现象训练到第三个epoch左右loss突然变成NaN验证集准确率直接掉到随机水平有时候重启训练又消失。原因某个动作切片里关节坐标全为0或者某一帧姿态估计完全失败归一化时骨骼长度为0、分母为0NaN一旦出现会通过梯度传播污染整个网络后续所有更新都失效。解决在滑窗之后增加清洗步骤统计窗口内所有帧的平均置信度低于阈值直接丢弃归一化的分母加上极小正数。顺序必须是先过滤再归一化否则无效。# data_deal.py 滑窗后的清洗片段 windows, labels sliding_window(seq, labels) clean_windows, clean_labels [], [] for win, lbl in zip(windows, labels): if np.mean(win[..., 2]) 0.5: continue # 置信度均值低判定为姿态估计失败丢弃 clean_windows.append(win) clean_labels.append(lbl)逻辑说明win[..., 2]取的是每个关节的置信度均值低于0.5说明这个窗口里有大量关键点是无效检测把这些样本保留进训练只会让模型学到随机抖动。这个阈值可以按实际数据调整姿态估计质量好的话设为0.6也不过分。4.2 动作边界错位训练集和验证集表现反差极大现象训练集准确率达到92%验证集却只有58%。模型单看训练集的表现非常好验证集却一塌糊涂而且回归训练多次结果都一样。原因手工标注时动作起点和终点通常不够精确滑动窗口又跨过了动作边界。比如一个窗口前半段是杀球后半段是吊球多数投票把它标记成杀球实际网络在训练时被迫学习了两段混合语义验证集一遇到干净动作就露馅。解决把训练集里预测错误的样本全部打印出来按帧画出真实标签和预测标签的时间对齐图。如果看到预测标签的跳变点整体比真实标签滞后或提前若干帧回data_deal.py调整窗口起点偏移量即可。我习惯直接借pose_visualizer.py把关键点和标签文字画回视频帧逐帧播放几段失败样本比看数字更直观。4.3 过拟合严重参数太多样本太少现象训练集准确率98%验证集65%。模型几乎记住了训练窗口但对没见过的动作毫无泛化能力。原因LSTM参数量大而羽毛球动作数据通常只有几千个窗口滑窗得到的大量样本又相互重叠真实信息量远低于样本数量模型很容易直接背下训练分布。解决优先做三件事——把dropout提到0.6加上weight_decay到1e-4设置早停。如果验证集准确率仍然上不去就把LSTM层数降到1层、hidden_dim降到64。注意小样本时序任务里降模型规模往往比硬加数据管用不要一上来就想着扩充数据集。4.4 类别不平衡模型无脑预测成 READY现象验证集整体准确率看似有60%打开混淆矩阵发现几乎全部预测成了READY杀球、吊球这类动作完全没被识别出来。原因羽毛球视频里准备状态的帧占大多数滑窗之后样本比例依然严重倾斜。模型只要无脑输出READY就能拿到五成以上的准确率少数类被大类别完全带偏。解决在损失函数里给低频动作类别加大权重这是最直接的干预方式。权重一般按类别样本量的反比缩放到[1, 3]区间内太大反而会让少量样本过拟合# 对低频动作类别加权 class_weights torch.tensor([0.5, 1.0, 2.0, 2.5, 2.5]) criterion nn.CrossEntropyLoss(weightclass_weights)逻辑说明权重列表的顺序必须与action_enum.py里的枚举数字严格一致READY是0逃不过权重0.5的惩罚杀球和吊球用更高权重拉高分类边界。加了权重后如果训练loss在某轮突然上升说明少数类被拉起来而多数类被压得过低把权重数组整体往中间收一收即可。4.5 没有按跟踪 ID 提取序列多人视频预测乱跳现象离线测试准确率不错但输入一段双人比赛视频时预测标签混乱跳变同一个动作前半段判成杀球、后半段变成吊球。原因多人画面中两套骨架在画面里交叉数据准备时没有绑定跟踪ID滑窗窗口前半段采的是A的骨架、后半段换成了B的骨架模型看到的根本不是一个连贯动作。解决在Pose阶段之后先运行Tracking/generate_dets.py生成检测框交给Tracking/deep_sort为每个人分配全局ID只保留同一个ID连续帧的关节坐标再进滑窗。如果原始数据已经生成且没有跟踪信息最直接的救急办法是过滤掉任何一帧同时包含两个人的窗口损失一些样本也比让脏数据破坏训练好。5. 推理、评估与可视化把预测结果画回视频帧用train_1.py训练完得到framewise_recognition.h5权重文件后要验证的不只是离线准确率还要看模型在一段从未参与训练的真实比赛视频上表现如何。推理时资源中的recognizer.py封装了预测接口传入一个已归一化的窗口即可得到动作类别和置信度。以PyTorch风格为例加载权重后要做的关键操作是这两步# 加载训练好的模型进行窗口推理 model ActionLSTM(input_dim51, hidden_dim128, num_layers2, num_classes5) model.load_state_dict(torch.load(Action/framewise_recognition.h5, map_locationcpu)) model.eval() with torch.no_grad(): probs torch.softmax(model(torch.FloatTensor(x_window)), dim1)逻辑说明eval()和no_grad()缺一不可。eval()会把dropout层切到评估模式否则每次推理结果会随机跳动no_grad()停止梯度追踪节省内存并避免反向传播图累积。这段代码输入的x_window是一个已完成归一化和reshape的单个窗口形状是(1, 30, 51)softmax输出的5个概率中最大值对应的索引就是动作类别索引对应action_enum.py里的枚举值。5.1 滑动预测与混淆矩阵检查逐窗口预测只能看到离散标签建议把整段视频的所有帧都跑一遍得到一条完整的“预测动作随时间变化”曲线然后用混淆矩阵查看哪两类动作容易被混淆# 对整段视频做滑窗预测输出混淆矩阵 from sklearn.metrics import confusion_matrix y_true, y_pred [], [] for window, label in test_windows: pred recognizer.predict(window) # 返回动作类别编号 y_true.append(label) y_pred.append(pred) print(confusion_matrix(y_true, y_pred))如果混淆矩阵显示杀球SMASH和吊球DROP相互混淆最多先别急着调模型回去抽查两类失败窗口的骨架动画。多数情况下是窗口长度设置不合适吊球的动作时间跨度比杀球长30帧的窗口只截到了吊球的前半段。把win_len从30调整到40或20做对比观察混淆是否缓解比盲目改学习率更有针对性。5.2 骨架动画复演验证模型理解的是动作而非单帧姿势最后一个值得做的验证是可视化。pose_visualizer.py里已经有把关键点画回帧的函数配合back.jpg背景和预测标签可以逐帧复演模型“看到”了什么# pose_visualizer.py 的常见绘制逻辑 def render_prediction(frame, joints, label, prob): for x, y, _ in joints: cv2.circle(frame, (int(x), int(y)), 3, (0, 255, 0), -1) cv2.putText(frame, f{label} {prob:.2f}, (12, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2) return frame把这个绘制函数接到滑窗循环里逐帧保存为视频就能看到两个关键信息模型是在动作开始后多久识别出类别的以及预测标签是否在相邻帧间频繁抖动。前者反映模型对早期运动特征的利用能力后者直接指向标签对齐和滑窗设置问题。从那以后我做动作识别项目养成了一个习惯任何模型迭代先随机抽10个预测窗口把骨架画回原视频逐帧播放一遍然后才看训练曲线和混淆矩阵。这个习惯至少帮我抓出过三次标签错位和一次跟踪ID混用比盯loss管用得多。这套工程跑通不难想跑出可信的结果这步检查别省。希望帮到你。本文还有配套的精品资源点击获取