简介这是一份面向毕业设计场景的基于MediaPipe的手语识别Python源码包适合高校计算机相关专业学生用于毕业设计、课程设计或期末大作业。项目同时覆盖静态手语与动态手语识别包含数据采集、模型训练、界面演示等完整流程并附有训练日志图表与说明文档。压缩包共21个文件以Python脚本、模型权重和训练过程记录图片为主脚本负责数据集构建、手部关键点检测及交互界面模型文件涵盖了不同参数配置下的长短期记忆网络与门控循环单元训练结果便于对比分析。资源整体约9.39MB目录结构清晰源码均已在本地编译并通过运行验证评审分为98分难度适中适合作为高分毕业设计参考。已有215人浏览学习对于希望快速上手手语识别项目、理解关键点提取与序列建模流程的读者具有较强参考价值。1. 手语识别用MediaPipe本质上是在做关键点序列分类很多第一次接触手语识别的同学会觉得这是一个图像识别问题应该训练一个CNN去把每帧画面里的手势直接分类。我当年也是这么打算的直到真正动手才发现视频里的手语是动态的手从一个词移动到下一个词的过程往往持续十几帧甚至几十帧单帧静态分类几乎没法用。换一个思路先用MediaPipe把每一帧里的21个手部关键点提取出来然后把连续帧的关键点坐标串成序列用LSTM之类的时序模型去做分类。这样一来模型不再需要“看懂图像”只需要“看懂坐标轨迹”工程量小了一个量级精度却反而更容易做上去。这篇笔记就按这条路线把从数据采集到训练再到实时推理的完整方案拆开讲包括我踩过的坑和调过的参数适合手头有Python基础、准备做毕业设计但还没定技术方案的同学直接照着复现。2. 手语识别整体方案为什么选MediaPipeLSTM以及模型架构怎么定2.1 静态手语与动态手语的技术路线差异手语词汇在形态上分两类静态手语和动态手语。像数字0到9、部分字母手势保持一个固定手型就能表达这类可以直接用单帧关键点做分类一个普通的多层感知机甚至随机森林都能解决问题。但绝大多数手语词汇是动态的表达“谢谢”“你好”“爱”的时候手有一个移动轨迹语义在轨迹里不在一帧画面里。如果你强行用单帧分类模型学到的只是“这一帧手长什么样”而不是“手从这个位置移动到了那个位置”换任何一个没见过的姿势就会出现明显的识别错误。所以项目选型时第一件事是把手语词汇表里每个词的形态写清楚。静态词归为单帧分类动态词汇归为序列分类。实际操作上为了保证整个项目逻辑统一我会把所有词汇都按序列处理静态词的序列里所有帧的关键点大体相同模型也能正常学。这样代码里只有一条数据链路不必为两类词各写一套训练逻辑。2.2 MediaPipe的手部关键点输出与关键点编号MediaPipe的Hand Landmark模型输出的是三维坐标。虽然叫三维但实际是“图像内的x、y坐标加上一个相对深度值z”z的单位不是真实毫米而是以手腕点为基准的相对深度。拿到模型输出后每个手有21个关键点编号从0到20固定的顺序如下编号位置编号位置0手腕11食指近端指节1拇指腕掌关节12食指远端指节2拇指近端指节13中指掌指关节3拇指远端指节14中指近端指节4拇指指尖15中指远端指节5食指掌指关节16无名指掌指关节6食指近端指节17无名指近端指节7食指远端指节18无名指远端指节8食指指尖19小指掌指关节9中指掌指关节20小指指尖10中指近端指节——这套编号在后面的特征工程里非常关键。比如计算手指是否伸直可以用指尖坐标减去手掌根部坐标算向量长度和方向判断手是张开还是握拳可以取指尖到手腕的平均距离。我建议先把每个编号对应的位置打印到图片上比对着看几遍再写特征工程否则后面很难把人脸关键点和手部关键点搞混。MediaPipe还有一个容易被忽略的参数是min_detection_confidence。采集数据时如果手在画面里占的比例很小这个阈值要往低调我一般设置在0.5到0.6。但低阈值带来的问题是背景噪声也会被误检成手所以数据清洗环节必须跟上后面第3章会专门讲。2.3 LSTM模型结构的选择理由时序模型不是只有LSTM一个选项。Transformer这两年很火MediaPipe Model Maker里也提供了自定义训练能力但做手语识别这种中小规模词汇表的毕业设计我不建议一上来就堆Transformer。手语数据集通常自己采集每个词几百个样本、每个样本几十帧数据量在几千到几万这个量级Transformer需要的数据规模和调参成本都偏高很容易过拟合。LSTM在这个数据规模下表现得相当稳定训练速度快推理时延低部署也简单。我的模型结构是这样的输入形状是(seq_len, 63)seq_len是采样帧数63是21个关键点乘以3维坐标。第一层是LSTMhidden size取128第二层是LSTMhidden size也取128输出层接一个Dropout比例0.5最后全连接层输出类别数。两层LSTM比单层多学到的时序特征有限但多一层能稍微抑制过拟合整体参数规模也在可控范围内。关于MediaPipe模型选择本身我一般用mp.solutions.hands.Hands因为它返回的检测结果里包含multi_hand_landmarks和multi_handedness后者能告诉我这只手是左手还是右手。这个信息在数据处理时很有用比如某些手语词汇要求左右手同时参与后面按手别分开建模的时候依赖它。2.4 项目目录与源码模块划分一个能拿得出手的毕设项目源码结构要清晰到答辩老师不用问就能看懂。我常用的目录组织方式hand_sign_project/ ├── data/ │ ├── raw/ # 原始视频或图片 │ ├── keypoints/ # 提取后的关键点CSV │ └── labels.csv # 类别映射 ├── src/ │ ├── collect_data.py # 数据采集 │ ├── extract_keypoints.py # 关键点提取 │ ├── train.py # 训练入口 │ ├── model.py # LSTM模型定义 │ └── inference.py # 实时推理 ├── models/ # 训练好的权重 ├── requirements.txt └── README.md这个结构把数据、模型、脚本分开增量采集数据的时候不需要动训练代码训练脚本也只认数据集目录的格式。很多同学喜欢把所有代码堆在一个notebook里采集、训练、评估全放一起到后面数据多了就跑不动这是一个会在答辩时被追问的大问题。3. 用MediaPipe在本地跑通数据采集从摄像头到CSV的完整链路3.1 环境准备Python版本、依赖安装与MediaPipe验证先确认Python版本。MediaPipe对Python版本有要求太新的版本有时候没有对应轮子。我目前用的是Python 3.9到3.11之间的环境3.12也能装但建议优先3.10踩坑概率最小。安装依赖pip install mediapipe0.10.14 opencv-python numpy pandasmediapipe的版本号变化会影响API比如mp.solutions.hands在0.10.x里还在往上走可能会被新的任务API替代。装完后写一个最小验证脚本确认能用的同时顺便看看手部检测是否正常工作import cv2 import mediapipe as mp mp_hands mp.solutions.hands cap cv2.VideoCapture(0) with mp_hands.Hands( static_image_modeFalse, max_num_hands2, min_detection_confidence0.5, min_tracking_confidence0.5, ) as hands: while cap.isOpened(): ok, frame cap.read() if not ok: break frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results hands.process(frame_rgb) if results.multi_hand_landmarks: print(检测到手关键点数量, len(results.multi_hand_landmarks[0].landmark)) cv2.imshow(Hands, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码里有两个关键点值得解释。第一frame_rgb cv2.cvtColor(...)是必须的MediaPipe的process方法接收RGB输入直接用OpenCV默认的BGR帧会检测不出来或者结果异常。第二static_image_modeFalse表示视频流模式依赖上一帧的跟踪结果检测速度更快如果是处理单张图片要设置成True否则两张不连续的图片之间没有跟踪关系也会产生问题。3.2 数据采集脚本按类别保存关键点序列手语识别的数据采集目标是每个类别攒够一批“关键点序列文件”。不用存原始视频那太占空间了。我的采集方式是先设定一个类别名然后持续录制几十帧关键点自动保存成一个numpy数组文件import cv2 import mediapipe as mp import numpy as np import os import time mp_hands mp.solutions.hands SAVE_DIR data/keypoints class_name input(输入要采集的手语词名称如 thank_you) os.makedirs(os.path.join(SAVE_DIR, class_name), exist_okTrue) cap cv2.VideoCapture(0) frames [] with mp_hands.Hands( static_image_modeFalse, max_num_hands2, min_detection_confidence0.5, min_tracking_confidence0.5, ) as hands: print(准备采集按空格键开始) while True: ok, frame cap.read() if not ok: break frame_show frame.copy() # 提示文字写到画面顶部 cv2.putText(frame_show, fRecording {class_name}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) # 已经采集到的帧数显示在左上角 cv2.putText(frame_show, fFrames: {len(frames)}, (10, 70), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(Collect, frame_show) key cv2.waitKey(1) 0xFF if key ord( ): # 开始或暂停录制 recording not recording frames [] # 每次按下空格清空重新录 if key ord(s): # 保存当前帧序列 if frames: np.save( os.path.join(SAVE_DIR, class_name, f{int(time.time())}.npy), np.array(frames), ) print(f已保存 {len(frames)} 帧) frames [] if key ord(q): break if recording and results.multi_hand_landmarks: landmarks results.multi_hand_landmarks[0].landmark sample [] for lm in landmarks: sample.extend([lm.x, lm.y, lm.z]) frames.append(sample) cap.release() cv2.destroyAllWindows()录制逻辑里我每次按空格清空当前帧序列再重新录制保证每个保存的npy文件是一个完整的手势动作而不是多段动作混在一起。采集的时候建议一个词一个词地录左手有问题就删掉重录别想着事后清洗采集时的脏数据靠后处理多半救不回来。保存的每个npy文件是一个(帧数, 63)的数组后面训练脚本统一读这个格式。3.3 数据清洗与序列对齐采集到的原始序列直接拿来训练效果大概率不好因为按下空格到真正做出手势之间有一个缓冲期同样一个词不同人录制出来的帧数也差很多。所以训练之前要做两件事裁剪有效片段和序列对齐。裁剪有效片段的思路是用关键点运动幅度来判断。手静止的时候关键点坐标变化很小动作开始时坐标变化加大动作结束时又趋于静止。我一般计算相邻两帧手腕点的欧氏距离设置一个阈值例如0.01把连续超过这个阈值的帧段截出来。这样做还有一个额外好处录制的时候如果手突然出画面再回来那些缺帧的片段会被自动丢掉否则模型会学到一段“缝合出来的假动作”。序列对齐统一到一个固定长度SEQ_LEN。第一版我直接用了截断和填充帧数超过SEQ_LEN就从尾部截断不够就复制最后一帧补齐。这样做的缺陷是动作的加速度信息会被破坏手在做快速动作时尾部被截断了就不完整。后来我换成线性插值重采样把原始序列映射到一个0到1的时间轴上再按SEQ_LEN个等距时间点重新采样关键点坐标。这种做法能保留完整动作的节奏建议直接按这个做法来import numpy as np from scipy.interpolate import interp1d def resample_sequence(seq, target_len30): 把不固定帧数的关键点序列线性插值到固定长度。 seq: (原始帧数, 63) 返回: (target_len, 63) cur_len seq.shape[0] x_old np.linspace(0, 1, cur_len) x_new np.linspace(0, 1, target_len) resampled np.zeros((target_len, seq.shape[1])) for col in range(seq.shape[1]): f interp1d(x_old, seq[:, col], kindlinear, fill_valueextrapolate) resampled[:, col] f(x_new) return resampled为什么对每一列单独做插值而不是对整个数组做因为关键点的x、y、z三个维度是独立的坐标变量插值需要逐通道进行。把seq[:, col]看成一条一维信号对每一列做插值结果才是准确的关键点轨迹重构。顺带一提interp1d在边界上如果fill_value设置得不对会产生巨大的离群值我踩过一次坑训练损失直接NaN所以一定加boundary_errors相关的参数或者像我这样显式传fill_valueextrapolate。序列对齐之后再做一个标准化所有坐标减去第一帧坐标。这样模型的输入就从“绝对坐标”变成“相对位移”一个词无论从画面哪个位置开始做都映射到统一的起始坐标系下能明显提升跨场景识别能力。4. 训练手语识别模型关键点归一化、时序窗口与超参数调整4.1 关键点归一化锚定手腕点解决出镜漂移手语识别的特殊之处在于手在画面里的位置是漂移的。同一个动作在画面左边做和右边做原始关键点坐标值就完全不同。如果不做归一化模型会学到“这个类别的手总是出现在某个区域”这种错误的经验换个位置就失效。我的归一化策略是锚定手腕点也就是关键点编号0的位置def normalize_landmarks(seq): 输入 seq: (seq_len, 21, 3) 输出原点对齐到手腕点并按手腕到中指根部的距离做尺度归一 wrist seq[:, 0:1, :] # (seq_len, 1, 3) aligned seq - wrist # 手腕到中指掌指关节(编号9)的距离作为尺度参考 scale np.linalg.norm(aligned[:, 9, :], axis-1, keepdimsTrue) scale np.expand_dims(scale, axis1) # (seq_len, 1, 1) normalized aligned / (scale 1e-6) return normalized这里用中指掌指关节而不是指尖做尺度参考是因为指尖可能弯曲或伸直距离变化很大而掌指关节位置相对稳定用它的距离做归一化手的尺寸缩放更稳健。1e-6是避免除数为0的保护项。归一化之后整段序列的尺度就不随摄像头距离变化而大幅变化了。这个细节经常被忽略但它直接影响模型跨人、跨距离、跨摄像头的泛化能力。很多同学毕设答辩时被问“你的模型换个摄像头还能用吗”如果做了这一步就能理直气壮地回答“坐标已经归一化到手腕锚点”。4.2 数据增强时序平移、缩放与旋转的组合手语数据集的规模一般有限每个类别几十到一两百个样本直接用原始数据训练很容易过拟合。增强手段不需要太花哨关键是在时序域做变换而不是像图像那样裁裁剪剪。我常用的增强有三类时序平移是最实用也最容易被忽略的。采集的样本记录动作的起始时间有一定随机性比如同一句话说了两遍动作的第一帧不一定对齐。对训练样本做微小的时序平移相当于让模型在“动作稍微早点开始或稍微晚点开始”的情况下也能识别。代码实现上一般用numpy的roll函数对序列做一步到三步的循环位移这样能生成多个变体。关键点噪声也很管用。给每帧的关键点坐标加上一个符合标准正态分布的小扰动模拟摄像头轻微抖动和手势不稳带来的测量误差。噪声幅度不能太大太大动作形态被破坏了太小又没有增强效果。我常用std0.001到0.01具体数值要结合归一化后的坐标尺度来调。缩放增强则模拟手离摄像头远近不同的情况。把手腕锚点不动其他所有关键点围绕手腕点按比例放大或缩小5%到10%。还有史无前例的旋转增强围绕z轴转动但要注意手语某些词汇的手势朝向本身具有语义旋转范围要控制在小角度内我一般不超过10度。4.3 标签编码与数据集划分标签编码非常简单把类别名字符串映射成整数索引import json class_names [hello, thank_you, love, eat, drink] label_map {name: idx for idx, name in enumerate(class_names)} with open(data/label_map.json, w, encodingutf-8) as f: json.dump(label_map, f, ensure_asciiFalse, indent2)数据集划分有一个关键讲究按人划分而不是按样本划分。如果采集了三个人的数据把三个人的所有样本混在一起随机切分训练集和测试集里会出现同一个人的相似样本测试准确率虚高。正确做法是留出一个人的全部数据作为测试集另外两个人作为训练集这样才能验证模型对新任用户的泛化能力。这种划分方式在论文里叫person-split在答辩时也会是一个很加分的细节。4.4 训练脚本与超参数直接贴一份我调通了的最小训练脚本import numpy as np import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from sklearn.preprocessing import LabelEncoder SEQ_LEN 30 INPUT_SIZE 63 HIDDEN_SIZE 128 NUM_CLASSES len(label_map) class SignDataset(Dataset): def __init__(self, data, labels): self.data torch.FloatTensor(data) self.labels torch.LongTensor(labels) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx], self.labels[idx] class SignLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_classes): super().__init__() self.lstm1 nn.LSTM(input_size, hidden_size, batch_firstTrue) self.lstm2 nn.LSTM(hidden_size, hidden_size, batch_firstTrue) self.dropout nn.Dropout(0.5) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): out, _ self.lstm1(x) out, _ self.lstm2(out) out out[:, -1, :] # 取最后一个时刻的输出 out self.dropout(out) return self.fc(out)训练参数我一般这么设置batch size 32学习率 0.001 并配合ReduceLROnPlateau训练轮数上限 100同时使用早停。早停的耐心值设成15也就是说如果验证集准确率连续15个epoch没有提升就回滚到最佳模型权重。还有一个经验LSTM的batch_firstTrue建议显式写出来否则默认输入形状为(seq, batch, feature)经常有人在这里翻车。优化器我用Adamweight_decay设置为0.0005以避免过拟合。损失函数用交叉熵因为类别数量一般不超过50个交叉熵足够支持不需要用大间隔损失或者Triplet Loss那种复杂设计。5. 手语识别训练与部署避坑5个让模型翻车的常见问题5.1 左右手翻转导致识别和训练不是同一只手现象训练时用的全是右手采集自己测试时却习惯性伸左手模型准确率骤降。原因MediaPipe的关键点坐标是按图像坐标系输出的当摄像头处于镜像模式默认大多数笔记本前置摄像头都镜像你伸右手画面里看到的是“左撇子”的动作。如果不加判断所有人采集到的都是镜像后的关键点。解决采集脚本里加一个multi_handedness的判断把右手关键点做水平翻转统一到左手坐标系。更简单粗暴的方法是始终要求采集右手数据测试时也只用右手。但更稳妥的做法是采集时记录手别标签训练时按手别分开或做翻转增强。最好在采集脚本里就加上翻转逻辑不要等训练阶段再去修补数据。5.2 采集时手部关键点检测不到、帧全空现象对着摄像头MediaPipe报告检测不到手保存出来的npy文件里一片空白。原因摄像头在室内较暗环境下手部对比度低加上min_detection_confidence设置太高就检测不到。或者是把static_image_mode设成了True处理视频流时跟踪性能反而变差。解决先把min_detection_confidence降到0.5以下0.3到0.4都能正常工作。然后确认摄像头不是对准纯色背景比如手放在白墙前面就会严重降低检测置信度因为手和背景颜色过于接近会混淆纹理边界。建议在采集场景里放一些有纹理的背景最低限度也要保证摄像头自动白平衡把手的颜色和背景区分开。还有一个细节是手和脸同时出现在画面里时MediaPipe可能存在冲突采集时尽量避开面部大范围入镜。5.3 模型只会输偏置类真实准确率和我看到的精度不一致现象训练完测试集准确率显示95%当面演示却连续识别错模型每次都输出同一个类别。原因数据集中每个类别的样本数量严重不平衡比如“谢谢”录了200条“喝”只录了80条模型学到的就是“全部输出谢谢也能做到60%以上准确率”。测试集如果也继承了这种分布准确率自然虚高。解决先统计每个类别的数据量低于最大类别80%的就去补采。实在补不了就去重采样训练时每个batch按类别均匀采样或者用WeightedRandomSampler。调的时候打印出每类的召回率和精确率矩阵别只看总准确率。当某个类别是其他类别样本数三倍以上时模型几乎必然偏向那个类。5.4 序列长度设置不准动作被截断了现象识别“谢谢”经常识别成“你好”单独看两个词的动作形态前段很像后段不同。原因我第一版把SEQ_LEN设成了15但采集的时候“谢谢”的动作普遍持续25帧以上重采样时把整段动作压缩到15帧节奏信息被破坏两个动作的轨迹区分度下降。解决采集的时候统计每个类别的平均帧数和帧数分布SEQ_LEN取所有类别帧数的中位数或者取70%分位数偏上一点。我建议先打印每个npy的shape看一眼再定不要盲目拍脑袋。手语动作一般节奏是1到2秒30帧是一个比较稳健的起点快动作的词汇可以单独调低慢动作的词汇需要调高。5.5 损失降到很低但是识别语义不对现象val_loss从1.8降到0.1测试集准确率98%但把模型接到摄像头前面实测很多词都识别错。原因关键点归一化和数据清洗切掉了太多有效信息模型学到了训练集上的一种“捷径”比如总是从特定起始位置开始动作而不去学真正的手势模式。这个坑非常隐蔽因为训练指标看起来很漂亮只有实测才会暴露。解决归一化后把序列可视化把每个维度的坐标轨迹画成折线图和原始的手势动作摆在一起对比。如果轨迹图看起来只剩一个光秃秃的起点和终点说明信息丢太多了。保留原始坐标作为参考让归一化后的数据保留动作的完整时间结构。另外清洗时阈值不要设太高否则会只会留下动作幅度最大的那几帧前后缓冲区的大量上下文全被切没了。6. 让手语识别模型在摄像头前真正跑起来实时推理优化与可视化验证6.1 最小实时推理脚本训练完成后接入摄像头做实时推理是演示环节最容易出效果的场景。核心逻辑持续读帧做关键点检测把坐标缓存到一个定长队列每当队列有足够帧数就送入模型推理得到预测类别和置信度class RealTimeInference: def __init__(self, model_path, label_map_path, seq_len30): self.model SignLSTM(INPUT_SIZE, HIDDEN_SIZE, NUM_CLASSES) self.model.load_state_dict(torch.load(model_path, map_locationcpu)) self.model.eval() self.seq_len seq_len self.buffer [] with open(label_map_path, r, encodingutf-8) as f: self.label_map json.load(f) def process_frame(self, landmarks): sample [] for lm in landmarks: sample.extend([lm.x, lm.y, lm.z]) self.buffer.append(sample) if len(self.buffer) self.seq_len: seq normalize_landmarks(np.array(self.buffer)) seq_tensor torch.FloatTensor(seq).unsqueeze(0) with torch.no_grad(): output self.model(seq_tensor) pred torch.argmax(output, dim1).item() conf torch.softmax(output, dim1).max().item() self.buffer [] return self.label_map[str(pred)], conf return None, 0.06.2 用置信度滑动窗口抑制闪烁单个帧序列的预测结果会抖动同一动作前半段和后半段可能判成不同类别。我在推理脚本里加了一个类似滑动窗口的逻辑保存最近10次滑动窗口的识别结果只有当同一个类别累积出现超过7次时才更新最终显示结果。这样做虽然会有大约半秒的延迟反馈但演示时看起来稳定很多。6.3 一张对照表验证净准确率实时测试时不要只看感觉建议准备好一张数据对照表每测一个词计一次分数。例如测试20个预设动作每个动作做5遍记录正确和错误次数最后统计出一个净准确率。这一步看起来朴素但学术味十足而且在答辩现场很能说明问题比“演示还挺好”有说服力得多。我自己的经验是把预测结果结合画面左上角显示出来同时保留一份动作的关键点轨迹热力图画成视频流上方的引导动画。这样演示时观众能同时看到“动作是什么样”和“模型怎么理解这个动作”要比单纯的文字输出直观很多。这一路做下来最大的教训就是千万别被训练集上的漂亮曲线骗了手语识别这个场景数据质量和输入特征的合理性远比模型本身的复杂程度更重要。MediaPipe帮我们省下了几千小时的关键点标注时间但规则的设定、归一化的方式、数据的采集质量仍然需要自己动手打磨。希望这篇笔记的思路和踩坑记录能帮你少走几个弯路把更多时间花在真正有价值的调优上。本文还有配套的精品资源点击获取