☰
基于LSTM与MediaPipe的人体动作姿态识别实战指南
2026/10/14 15:20:59 网站建设 项目流程

简介:本资源是一套基于LSTM与MediaPipe实现人体动作姿态识别的Python项目源码,面向计算机、人工智能、通信、物联网等专业的在校学生、教师及企业开发者,可用于毕业设计、课程设计、大作业或项目立项演示。项目通过MediaPipe提取视频流中人体关键姿态信息,再交由LSTM网络完成动作分类识别,可应用于教育、运动分析、康复训练等场景。压缩包共280个文件,约22.09MB,其中240个npy为训练与测试数据,16个py为模型构建与推理脚本,16个mp4为动作演示视频,另有h5模型权重、md说明文档及txt配置说明,结构完整、便于按模块学习。目前已有186人学习下载。代码经完整验证可稳定运行,读者可据此掌握姿态关键点提取、时序建模、模型训练与推理的完整流程,并在此基础上二次开发,DIY其他动作识别功能。

1. 从一段视频到动作标签:LSTM 加 MediaPipe 到底在做什么

很多人第一次听到「基于 LSTM 和 MediaPipe 实现人体动作姿态识别」,脑子里浮现的是电影里那种实时骨骼追踪画面,觉得门槛高得离谱。其实拆开看,这件事的骨架非常清晰:MediaPipe 负责把每一帧画面里的人体关键点抠出来,LSTM 负责把这些随时间变化的关键点序列翻译成一个动作标签。一个管「看」,一个管「记」,合起来就是一套能跑在普通笔记本上的动作识别流水线。

它适合谁?做毕业设计的学生、想给健身 App 加动作计数功能的开发者、做康复训练监测的工程团队,都能直接复用这套思路。你不需要自己训练姿态估计模型,MediaPipe 已经把这件事做到了开箱即用;你真正要花心思的地方,是关键点序列怎么组织、LSTM 怎么设计、数据怎么切窗口。这篇笔记就按「先跑通、再调优、最后避坑」的顺序,把这条链路讲透。

2. 关键点序列怎么造:MediaPipe 提取与窗口切分

2.1 为什么选 MediaPipe 而不是自己训姿态模型

姿态估计这条路,常见做法有三条:OpenPose、MediaPipe、以及自己拿 HRNet 之类的骨干网络去训。OpenPose 精度不错,但推理速度在 CPU 上很难看,部署依赖也重;自训模型灵活,可你需要标注大量关键点数据,对个人项目来说成本太高。MediaPipe 的 Pose 模块把 33 个身体关键点做成了轻量级推理,CPU 上单帧几毫秒到十几毫秒,还能直接给出归一化坐标,省掉了大量预处理。

我一般会这样判断:如果你的动作类别是「深蹲、举手、挥手、跌倒」这种粗粒度分类,MediaPipe 的关键点精度完全够用;如果你要做手指级别的精细动作,那才需要考虑更高精度的方案。对绝大多数动作识别项目,MediaPipe 是性价比最高的起点。

2.2 用 MediaPipe 提取 33 个关键点的最小代码

先装依赖,再写提取脚本。注意 MediaPipe 对 Python 版本有要求,建议 3.8 到 3.10 之间,太新的版本偶尔会碰到 wheel 不匹配。

pip install mediapipe opencv-python numpy
import cv2 import mediapipe as mp import numpy as np mp_pose = mp.solutions.pose # static_image_mode=False 表示按视频流处理,会做关键点跟踪,速度更快 # model_complexity=1 是精度和速度的平衡点,0 最快,2 最准但慢 with mp_pose.Pose( static_image_mode=False, model_complexity=1, smooth_landmarks=True, min_detection_confidence=0.5, min_tracking_confidence=0.5 ) as pose: cap = cv2.VideoCapture("action.mp4") frames = [] while cap.isOpened(): ret, frame = cap.read() if not ret: break # MediaPipe 要求 RGB 输入,OpenCV 默认是 BGR rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) result = pose.process(rgb) if result.pose_landmarks: # 每个关键点取 x, y, z, visibility 四个值 kp = np.array([[lm.x, lm.y, lm.z, lm.visibility] for lm in result.pose_landmarks.landmark]) frames.append(kp.flatten()) # 33 * 4 = 132 维 cap.release() data = np.array(frames) # shape: (帧数, 132) np.save("keypoints.npy", data)

这段代码的逻辑是:逐帧读视频,把每帧的 33 个关键点展平成 132 维向量,最后存成一个二维数组。smooth_landmarks=True会让关键点在时间上更平滑,减少抖动,这对后续 LSTM 很关键——抖动大的序列会让模型学到噪声。min_detection_confidence和min_tracking_confidence都设 0.5 是稳妥起点,画面质量差可以降到 0.3,但会引入更多误检。

2.3 滑动窗口:把长序列切成 LSTM 能吃的样本

LSTM 需要固定长度的输入。一段 10 秒的视频可能有 300 帧,你不能整段丢进去,得用滑动窗口切。窗口长度的选择直接决定识别效果,这是最容易被忽略的参数。

def make_sequences(data, window=30, stride=5): """ data: (总帧数, 132) window: 每个样本包含的帧数 stride: 窗口滑动步长 返回: (样本数, window, 132) 和对应的帧索引 """ seqs = [] for start in range(0, len(data) - window + 1, stride): seqs.append(data[start:start + window]) return np.array(seqs) X = make_sequences(data, window=30, stride=5) print(X.shape) # 例如 (55, 30, 132)

窗口长度怎么定?我的经验是:动作越快,窗口越短。挥手这种动作 15 到 20 帧就够,深蹲这种慢动作需要 30 到 45 帧。步长一般取窗口的 1/5 到 1/3,太小会让训练样本高度重叠、容易过拟合,太大又会丢样本。如果你不确定,先用 30 帧窗口、步长 5 跑一版基线,再根据混淆矩阵调整。

提示:切窗口前一定要检查关键点序列有没有大段缺失(人走出画面)。缺失帧要么插值补上,要么整段丢弃,不要留一堆零向量进去,模型会学歪。

3. LSTM 模型怎么搭:从输入维度到训练循环

3.1 网络结构设计与三个必调参数

动作识别用的 LSTM 不需要很深。两层 LSTM 加一层全连接,参数量控制在几十万级别,就能在几百到几千个样本上收敛。层数堆多了,小数据集上必然过拟合。

import torch import torch.nn as nn class ActionLSTM(nn.Module): def __init__(self, input_dim=132, hidden_dim=128, num_layers=2, num_classes=5, dropout=0.3): super().__init__() self.lstm = nn.LSTM( input_size=input_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, # 输入形状 (batch, seq, feature) dropout=dropout, # 只在 num_layers > 1 时生效 bidirectional=False # 实时场景用单向,离线分析可用双向 ) self.fc = nn.Sequential( nn.Linear(hidden_dim, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, num_classes) ) def forward(self, x): # out: (batch, seq, hidden) out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的输出做分类 last = out[:, -1, :] return self.fc(last)

三个必调参数:hidden_dim一般取 64 到 256,动作类别少就取小;num_layers两层足够,超过三层在小数据上几乎必过拟合;dropout在 0.2 到 0.5 之间,样本越少取值越大。bidirectional要看你是否允许看未来帧——实时识别必须用单向,离线视频分析可以用双向,精度会高一些。

3.2 训练循环与类别不平衡处理

动作识别数据集天然不平衡,比如「站立」样本远多于「跌倒」。直接训练会让模型偏向多数类,跌倒这种关键动作反而识别不出来。常见做法是在损失函数里加权重。

from torch.utils.data import DataLoader, TensorDataset from sklearn.model_selection import train_test_split # 假设 X 是 (N, 30, 132),y 是 (N,) 的整数标签 X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42) train_ds = TensorDataset(torch.FloatTensor(X_train), torch.LongTensor(y_train)) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = ActionLSTM(num_classes=len(np.unique(y))).to(device) # 按类别频率的倒数给权重,缓解不平衡 class_counts = np.bincount(y_train) weights = torch.FloatTensor(1.0 / class_counts).to(device) criterion = nn.CrossEntropyLoss(weight=weights) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(50): model.train() total_loss = 0 for xb, yb in train_loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() print(f"epoch {epoch}, loss {total_loss / len(train_loader):.4f}")

clip_grad_norm_是 LSTM 训练的后悔药,梯度爆炸在长序列上很常见,不加这个偶尔会看到 loss 突然变 NaN。学习率 1e-3 配 Adam 是稳妥起点,收敛慢可以降到 5e-4,震荡就再降。batch_size 取 32 或 64,样本少就取 16。

3.3 验证与混淆矩阵:别只看准确率

准确率在类别不平衡时会骗人。一个把所有样本都预测成「站立」的模型,如果站立占 80%,准确率也有 80%。必须看混淆矩阵和每类召回率。

from sklearn.metrics import confusion_matrix, classification_report model.eval() with torch.no_grad(): val_logits = model(torch.FloatTensor(X_val).to(device)) preds = val_logits.argmax(dim=1).cpu().numpy() print(confusion_matrix(y_val, preds)) print(classification_report(y_val, preds))

如果某个动作的召回率特别低,先回去看这个动作的窗口长度是不是不合适,再看训练样本是不是太少。我踩过的坑是:挥手动作窗口取了 45 帧,结果一个窗口里包含了两次挥手,模型学到的特征被平均掉了,召回率一直上不去。改成 20 帧后立刻正常。

4. 避坑与排查:五个真实翻车现场

4.1 关键点坐标系不统一导致模型学不到东西

现象:训练 loss 一直不降,准确率在随机水平附近晃。

原因:MediaPipe 输出的 x、y 是归一化到 [0,1] 的,但 z 是相对深度,数值范围和 x、y 差很多。如果直接把 132 维丢进 LSTM,z 维度会主导梯度。

解决:对每个维度做标准化,或者干脆只用 x、y、visibility 三个值,丢掉 z。我一般会先试丢掉 z,精度掉得不多但训练稳定很多。

4.2 视频帧率不一致导致窗口语义漂移

现象:训练集用 30fps 视频,测试时用 60fps 视频,同一个动作识别结果完全不同。

原因:窗口是按帧数切的,30 帧在 30fps 下是 1 秒,在 60fps 下只有 0.5 秒,动作被压缩了。

解决:提取关键点前统一重采样到固定帧率,比如全部转成 30fps。用 OpenCV 的cap.set(cv2.CAP_PROP_FPS, 30)不一定可靠,稳妥做法是按时间戳抽帧。

4.3 训练集和验证集来自同一段视频

现象:验证准确率 99%,换一段新视频测试直接崩到 40%。

原因:滑动窗口让相邻样本高度重叠,随机划分会把几乎相同的窗口分到训练和验证两边,造成数据泄漏。

解决:按视频划分,而不是按窗口划分。同一段视频的所有窗口要么全在训练集,要么全在验证集。这是动作识别里最隐蔽的坑。

4.4 实时推理时 LSTM 状态没重置

现象:连续识别多个动作时,第二个动作的识别结果受第一个动作影响。

原因:LSTM 的隐藏状态在样本之间没有清零,上一个动作的「记忆」污染了下一个。

解决:每个独立动作序列开始时,把h_n和c_n重置为零。如果用model(x)每次前向传播,PyTorch 默认会重置,但如果你手动传状态,一定要记得清零。

4.5 关键点缺失时用零填充

现象:画面中人物被遮挡时,识别结果乱跳。

原因:MediaPipe 检测不到人时,pose_landmarks为 None,很多代码直接填零向量。零向量在特征空间里是一个有意义的点,模型会把它当成某种姿态。

解决:用前一帧的关键点做线性插值,或者标记该帧为无效并在窗口切分时跳过。宁可丢样本,不要喂零。

5. 进阶技巧:把识别延迟压到 100ms 以内

前面跑通的是离线流程,但很多场景要实时。实时化的核心矛盾是:LSTM 需要攒够一个窗口才能出结果,窗口 30 帧在 30fps 下就是 1 秒延迟。要压到 100ms 以内,得换思路。

第一个技巧是用「增量推理」。不要等整个窗口填满,而是每来一帧就更新一次 LSTM 状态,用当前状态直接分类。这样延迟只有单帧推理时间,代价是精度会掉几个点。实现上就是把 LSTM 的隐藏状态在帧之间传递:

class StreamingActionLSTM(nn.Module): def __init__(self, input_dim=132, hidden_dim=128, num_classes=5): super().__init__() self.lstm = nn.LSTM(input_dim, hidden_dim, batch_first=True) self.fc = nn.Linear(hidden_dim, num_classes) self.hidden = None def reset(self): # 每个新动作序列开始时调用 self.hidden = None def forward(self, x): # x: (1, 1, 132) 单帧 out, self.hidden = self.lstm(x, self.hidden) return self.fc(out[:, -1, :])

第二个技巧是降低 MediaPipe 的model_complexity到 0,单帧推理能压到 5ms 以内,精度损失在粗粒度动作上可以接受。第三个技巧是隔帧推理,每两帧处理一次,中间帧复用上一次的关键点,延迟和算力都减半。

验证实时效果时,别只看单帧耗时,要看端到端延迟:从摄像头捕获到输出标签的总时间。我一般会在代码里打时间戳,统计 P95 延迟而不是平均值,因为卡顿往往出现在个别帧上。

最后一个习惯:每次改窗口长度或模型结构,都固定用同一段测试视频跑一遍混淆矩阵,别凭感觉判断「好像变好了」。动作识别这行,玄学很多,只有数据不会骗你。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询