☰
基于LSTM与MediaPipe的人体姿态识别源码实战:从骨架提取到动作分类
2026/10/14 15:20:59 网站建设 项目流程

简介:本资源是一套基于LSTM与MediaPipe实现人体动作姿态识别的Python项目源码,面向计算机、人工智能、通信、物联网等专业的在校学生与教师,可用于毕业设计、课程设计、大作业或初期项目立项演示。项目通过MediaPipe提取视频流中人体关键姿态信息,再交由LSTM网络完成时序建模与动作分类,可应用于教育、运动分析、康复训练等场景。压缩包共280个文件,约22.09MB,包含16个py源码文件、240个npy数据文件、16个mp4示例视频、2个h5模型权重及说明文档等,覆盖数据、模型与演示全流程。目前已有186人学习关注。代码完整且功能验证通过,读者可据此理解姿态关键点提取、时序特征构建与动作分类的完整链路,并在此基础上二次开发或DIY其他功能,具备较高的学习借鉴价值。

1. 从一段 30 帧视频说起:这套 LSTM+MediaPipe 姿态识别源码到底能跑出什么

带过几届毕设之后,我总结出一个规律:凡是标题里同时出现「姿态识别」和「毕业设计」的包,十有八九是拿现成模型套个壳,跑起来要么缺权重,要么视频路径写死。所以拿到这个基于LSTM和mediapipe 实现人体动作姿态识别python源码的压缩包时,我第一反应是先把action.h5和那几个 mp4 单独拎出来验证——权重能不能加载、视频能不能被逐帧读进去、关键点序列喂给 LSTM 之后输出的类别是不是稳定。这套东西的核心链路其实很清晰:MediaPipe 负责从每一帧里抠出人体骨架关键点,LSTM 负责把这些按时间排列的关键点序列分类成具体动作。它适合两类人:一类是课程设计或毕设需要一份能演示、能改、能写进论文的完整流程;另一类是想快速验证「骨架序列 + 时序模型」这条技术路线到底靠不靠谱的从业者。包里给的l0.mp4、r0.mp4、r1.mp4就是现成的测试素材,介绍.md里大概率写了动作类别和采集方式,action.h5是已经训练好的权重。接下来我不讲空泛的「深度学习如何如何」,只拆这份源码怎么落地、参数怎么调、哪里最容易翻车。

2. 骨架序列为什么必须过 LSTM:MediaPipe 输出与动作分类的衔接逻辑

2.1 MediaPipe 给出的不是「动作」,是每帧的坐标点

MediaPipe Pose 在单帧图像上做的事情是定位 33 个关键点,每个点有x, y, z, visibility四个值,其中x, y是归一化到[0,1]的图像坐标,z是相对深度,visibility表示该点是否被遮挡或落在画面外。也就是说,一帧画面进来,你拿到的是一个 33×4 的数组,而不是「挥手」「下蹲」这种语义标签。很多人第一次跑姿态识别会卡在这里:以为调个 MediaPipe 就能直接输出动作名,实际上它只给几何信息,分类得自己接。

那为什么不能直接拿单帧的 33×4 去分类?因为动作的本质是「随时间变化的姿态序列」。一个「抬手」动作,单看某一帧可能和「挥手」的中间态完全一样,只有把前后若干帧连起来看,才能区分。这就是 LSTM 的用武之地:它吃的是形状为(时间步, 特征数)的序列,通过门控机制记住哪些帧的变化对分类更重要,哪些帧是过渡可以忽略。

2.2 从关键点到序列样本:窗口切分与归一化

把视频变成 LSTM 能吃的样本,中间要做三件事:逐帧提取关键点、按固定长度切窗口、对坐标做归一化。下面这段是我从包里介绍.md的描述反推、并按常见做法补全的提取逻辑,你可以直接对照自己的代码改:

import cv2 import mediapipe as mp import numpy as np mp_pose = mp.solutions.pose def extract_keypoints_from_video(video_path, max_frames=60): """ 从视频中逐帧提取姿态关键点。 max_frames: 每个视频最多取多少帧,避免长短不一。 返回 shape=(max_frames, 33*4) 的数组。 """ cap = cv2.VideoCapture(video_path) pose = mp_pose.Pose( static_image_mode=False, # 视频流模式,启用跟踪 model_complexity=1, # 0/1/2,越大越准也越慢 smooth_landmarks=True, # 平滑关键点,减少抖动 min_detection_confidence=0.5, min_tracking_confidence=0.5 ) frames_data = [] while cap.isOpened() and len(frames_data) < max_frames: ret, frame = cap.read() if not ret: break rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) result = pose.process(rgb) if result.pose_landmarks: # 把 33 个点的 x,y,z,visibility 拉平成一维 one_frame = [] for lm in result.pose_landmarks.landmark: one_frame.extend([lm.x, lm.y, lm.z, lm.visibility]) frames_data.append(one_frame) else: # 没检测到人时补零,保持时间轴对齐 frames_data.append([0.0] * (33 * 4)) cap.release() pose.close() # 不足 max_frames 的用零填充,超出的截断 arr = np.array(frames_data, dtype=np.float32) if arr.shape[0] < max_frames: pad = np.zeros((max_frames - arr.shape[0], 33 * 4), dtype=np.float32) arr = np.vstack([arr, pad]) return arr[:max_frames]

这段代码里有几个参数直接决定后面 LSTM 能不能收敛。model_complexity设成 1 是精度和速度的折中,毕设演示够用;设 2 会更准,但普通笔记本跑实时视频会掉帧。smooth_landmarks=True很关键,MediaPipe 原始输出的关键点会有高频抖动,不平滑的话 LSTM 会把抖动当成动作特征,训练时 loss 震荡得厉害。max_frames=60是我按常见动作时长估的,包里l0.mp4、r0.mp4这类短视频大概 2 到 3 秒,30fps 下 60 到 90 帧,统一到 60 帧既能覆盖完整动作,又不至于让序列太长拖慢训练。

2.3 归一化:不做这一步,换个人站远一点就废了

原始x, y是相对画面宽高的比例,但人站在画面左边和右边,同一个动作的坐标值完全不同。常见做法是以髋部中心为原点做平移,再用肩宽或躯干长度做尺度归一化。包里如果没做这一步,你拿l0.mp4训练出来的模型去测r1.mp4,准确率会掉得很明显。我一般会加这么一段:

def normalize_keypoints(sequence): """ sequence: (T, 33*4) 以左右髋中点为中心平移,用肩宽做尺度归一化。 MediaPipe 索引:左髋 23,右髋 24,左肩 11,右肩 12。 """ seq = sequence.reshape(-1, 33, 4).copy() hip_center = (seq[:, 23, :2] + seq[:, 24, :2]) / 2.0 # (T, 2) shoulder_width = np.linalg.norm(seq[:, 11, :2] - seq[:, 12, :2], axis=1, keepdims=True) shoulder_width[shoulder_width < 1e-6] = 1.0 # 防止除零 seq[:, :, :2] = (seq[:, :, :2] - hip_center[:, None, :]) / shoulder_width[:, None, :] return seq.reshape(-1, 33 * 4)

逻辑说明:先按帧把 33×4 还原出来,取左右髋坐标均值作为身体中心,把每一帧的所有点平移到以中心为原点;再用左右肩距离作为尺度参考,把坐标缩放到统一量级。这样同一个人站远站近、站左站右,同一个动作的数值分布基本一致。参数上唯一要注意的是除零保护,画面里人太远或关键点丢失时肩宽可能接近 0,不处理会直接出 NaN,后面 LSTM 全盘崩掉。

3. 把 action.h5 跑起来:加载权重、对齐输入形状、输出类别

3.1 先确认权重期望的输入形状

action.h5是 Keras/TensorFlow 保存的完整模型或权重文件。加载之前别急着model.predict,先用下面几行把结构打印出来,看清楚它期望的input_shape到底是(60, 132)还是(30, 99)还是别的:

import tensorflow as tf model = tf.keras.models.load_model("action.h5") model.summary() print("input shape:", model.input_shape) print("output shape:", model.output_shape)

这一步是血泪经验:很多毕设包里的权重和提取代码不是同一个人写的,输入长度对不上,直接 predict 会报ValueError: Input 0 is incompatible。如果input_shape是(None, 60, 132),说明它要 60 帧、每帧 132 维(33×4),和你上面提取的完全一致;如果是(None, 30, 99),那它只用了x, y, visibility三个值、30 帧,你就得把提取代码里的z去掉、max_frames改成 30。以权重为准,不要以你的直觉为准。

3.2 端到端推理:从 mp4 到动作标签

确认形状之后,把提取、归一化、预测串起来:

import numpy as np # 假设 action.h5 期望 (60, 132) CLASSES = ["动作A", "动作B", "动作C"] # 按介绍.md里的类别顺序填 def predict_video(video_path): seq = extract_keypoints_from_video(video_path, max_frames=60) seq = normalize_keypoints(seq) seq = np.expand_dims(seq, axis=0) # (1, 60, 132) probs = model.predict(seq, verbose=0)[0] idx = int(np.argmax(probs)) return CLASSES[idx], float(probs[idx]) label, conf = predict_video("l0.mp4") print(f"预测: {label}, 置信度: {conf:.3f}")

逻辑说明:extract_keypoints_from_video输出(60, 132),normalize_keypoints保持形状不变,np.expand_dims在最前面加一个 batch 维度变成(1, 60, 132),这是 Keras 推理的标准输入。CLASSES的顺序必须和训练时标签编码的顺序一致,这个顺序通常在介绍.md里,或者看训练脚本里LabelEncoder或to_categorical的类别列表。如果顺序搞反,模型明明预测对了,你映射出来的名字却是错的,这种坑最隐蔽。

3.3 用包里的 mp4 做一轮自检

包里给了l0.mp4、r0.mp4、r1.mp4,文件名里的l和r大概率对应左右方向的动作。拿它们各跑一遍predict_video,看输出是否符合预期。如果三个视频全预测成同一类,先别怀疑模型,按这个顺序排查:关键点是否真的提取到了(打印seq的非零比例)、归一化后数值范围是否合理(应该在 -2 到 2 之间)、CLASSES顺序是否对。我见过最离谱的情况是视频路径里有中文,OpenCV 的VideoCapture静默返回空帧,提取出来全是零,模型自然全预测同一类。项目说明里专门提醒「路径不要用中文」,不是客套话。

4. 训练自己的动作数据:窗口长度、类别平衡与过拟合排查

4.1 采集与标注:每个动作至少 30 段

如果你想用自己的动作替换包里预置的类别,采集阶段就要控制变量。同一个动作,换人、换角度、换距离各录几段,每段 2 到 3 秒,每个类别至少 30 段起步。少于这个数,LSTM 很容易记住训练者的体型而不是动作本身。标注文件用最简单的 CSV 就行:一列视频路径,一列类别名。别一上来就搞复杂的数据增强,先把基线跑通。

4.2 训练脚本的关键参数

下面是一个最小可用的训练骨架,参数我按毕设场景调过:

from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder from tensorflow.keras.utils import to_categorical from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout # X: (样本数, 60, 132), y: 类别名列表 le = LabelEncoder() y_enc = to_categorical(le.fit_transform(y)) X_train, X_val, y_train, y_val = train_test_split( X, y_enc, test_size=0.2, random_state=42, stratify=y_enc ) model = Sequential([ LSTM(64, return_sequences=True, input_shape=(60, 132)), Dropout(0.3), LSTM(32), Dropout(0.3), Dense(32, activation="relu"), Dense(len(le.classes_), activation="softmax") ]) model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"]) model.fit(X_train, y_train, validation_data=(X_val, y_val), epochs=80, batch_size=16) model.save("action_retrained.h5")

参数说明:第一层 LSTM 设 64 个单元并return_sequences=True,把序列信息传给第二层;第二层 LSTM 设 32 个单元,只输出最后时间步的摘要。两层之间和之后都加Dropout(0.3),这是防过拟合最直接的手段——姿态数据样本量通常不大,不加 dropout 训练准确率能到 99%,验证集却只有 60%。batch_size=16适合几百到一千个样本的规模,太大收敛慢,太小梯度震荡。epochs=80配合验证集看,如果验证 loss 连续 10 轮不降就停,别硬跑。

4.3 过拟合与欠拟合的区分

训练完看两条曲线:训练准确率高、验证准确率低,是过拟合,加 dropout、减 LSTM 单元数、增数据;两条都低,是欠拟合,先检查关键点提取有没有问题,再考虑加层或加单元。还有一个容易被忽略的点:如果验证集准确率波动特别大,多半是样本太少或者类别不平衡,某个类别只有几段视频,模型在这类上纯靠猜。

5. 避坑与排查:这套源码最容易翻车的五个地方

5.1 现象:predict 报输入形状不匹配

原因:action.h5训练时的max_frames或特征维度和你提取代码不一致,常见的是权重用 30 帧、代码用 60 帧,或者权重只用了x,y,visibility三维而代码用了四维。 解决:先model.summary()看input_shape,以它为准改提取代码的max_frames和每帧特征数,改完再跑。

5.2 现象:所有视频都预测成同一类

原因:关键点没提取到,序列全是零;或者视频路径含中文导致VideoCapture读空;或者CLASSES顺序和训练标签顺序不一致。 解决:打印提取后序列的非零元素占比,低于 10% 基本就是没读到人;把视频重命名成纯英文路径再试;对照介绍.md或训练脚本确认类别顺序。

5.3 现象:换个人或换个角度准确率暴跌

原因:没做归一化,或者归一化只做了平移没做尺度缩放,模型学到的是绝对坐标而不是相对姿态。 解决:加上以髋部为中心的平移和以肩宽为基准的尺度归一化,确保不同体型、不同距离下的数值分布一致。

5.4 现象:训练 loss 不下降或变成 NaN

原因:归一化时除零没保护,肩宽接近 0 导致坐标爆炸;或者学习率默认值对当前数据太大。 解决:在尺度归一化里加1e-6下限保护;把优化器换成tf.keras.optimizers.Adam(learning_rate=1e-3)甚至5e-4,观察前几个 epoch 的 loss 是否平稳下降。

5.5 现象:实时摄像头推理卡顿

原因:model_complexity=2加上每帧都跑完整 LSTM,CPU 扛不住。 解决:把model_complexity降到 0 或 1,smooth_landmarks保持开启;推理时不要每帧都预测,攒够一个窗口再跑一次,中间帧复用上一次结果。

6. 进阶技巧:把逐帧预测改成滑动窗口,让识别不再「跳」

上面所有推理都是「一个视频出一个标签」,但真实场景里你想要的是连续识别——人做完一个动作,系统立刻给出反馈,而不是等整段视频结束。做法是把固定窗口改成滑动窗口:维护一个长度为 60 的队列,每来一帧新关键点就挤掉最旧的一帧,队列满时跑一次预测,再对连续几次预测结果做多数投票,避免单帧抖动导致标签乱跳。

from collections import deque, Counter class SlidingWindowPredictor: def __init__(self, model, classes, window=60, vote_size=5): self.model = model self.classes = classes self.window = window self.buf = deque(maxlen=window) self.votes = deque(maxlen=vote_size) def push(self, one_frame_keypoints): """one_frame_keypoints: 长度 132 的一维数组""" self.buf.append(one_frame_keypoints) if len(self.buf) < self.window: return None seq = normalize_keypoints(np.array(self.buf, dtype=np.float32)) seq = np.expand_dims(seq, axis=0) probs = self.model.predict(seq, verbose=0)[0] self.votes.append(int(np.argmax(probs))) # 多数投票,平票时取最新 most = Counter(self.votes).most_common(1)[0][0] return self.classes[most]

逻辑说明:deque(maxlen=window)自动维护固定长度,新帧进来旧帧自动弹出,不用手动切片。vote_size=5表示用最近 5 次预测投票,这个值太小起不到平滑作用,太大又会让动作切换有延迟,5 到 8 之间比较合适。normalize_keypoints在这里对整段窗口做归一化,保证和训练时一致。实际部署时,把 MediaPipe 的process结果转成 132 维向量后调push,返回非 None 就更新界面上的动作标签。

验证滑动窗口是否生效,有个简单办法:拿l0.mp4连续喂进去,看输出的标签序列是不是在动作发生的那几帧才切换,而不是从头到尾乱跳。如果切换点明显滞后,把vote_size调小;如果标签频繁闪烁,调大。这个参数没有标准答案,取决于你的动作时长和帧率,我一般会拿包里的几个 mp4 各跑三遍,取一个既不滞后也不闪烁的值。

从那以后我每次拿到这类姿态识别源码,都强制先跑一遍model.summary()确认输入形状,再拿包里自带的测试视频做端到端自检,最后才动训练代码。顺序反了,后面全是无用功。希望帮到你。

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

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

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

立即咨询