简介本资源为基于USTC手语数据集、融合MediaPipe姿态估计与YOLOv5目标检测的手语视频识别系统Python源码包面向计算机视觉学习者、算法调优人员和手语识别项目开发者解决视频中手部关键点提取、手势目标定位及序列建模等完整链路问题。压缩包共42个文件核心包括19个py脚本覆盖手部检测、关键点识别、RNN模型训练与推理、5个xml工程配置、4个ui界面文件及4个avi测试视频整体约13.77MB结构清晰便于直接移植或二次开发。目前已有218人学习使用。读者可获得完整可运行的手语识别流水线从视频读取、MediaPipe关键点提取、YOLOv5手部检测到基于RNN的手势序列分类并附带6张可视化分析图、词典文本配置与中文手语数据库说明文档适合作为毕业设计、课程项目或算法对比实验的参考基座。1. 手语视频识别到底在做什么这个项目解决了什么问题打开这个项目压缩包的时候我第一反应是查它的数据来源和模型链路是否闭环。基于USTC数据集、MediaPipe和YOLOv5算法实现的手语视频识别系统核心不是“识别单个手势”而是把一段连续手语视频分解成“手部在哪 - 手部关键点是什么 - 关键点序列表达了什么词”三个环节。USTC数据集提供的是中国手语的视频样本MediaPipe负责从每一帧里提取21个手部关键点坐标YOLOv5则负责在复杂背景下先把手部区域框出来避免背景干扰关键点提取。最终用一个时序分类器把关键点序列映射成手语词标签。这个方案适合两类人一类是在校学生做课设或毕业设计需要一套能跑通、有数据集、有可视化界面的完整代码另一类是刚接触动作识别、想用现成模型组合快速验证手语识别可行性的工程师。它并不是工业级实时翻译方案但作为学习MediaPipe与YOLOv5协同工作的样例以及作为后续替换更强骨干网络的起点性价比很高。下面按我复现这个系统时的顺序把数据预处理、模型训练、时序分类和踩坑过程完整拆开讲。2. 先搭骨架MediaPipe手部关键点与YOLOv5检测的职责划分2.1 为什么两个算法要一起用而不是单独选一个手语视频识别的难点不在分类网络本身而在“手部特征是否稳定”。直接把手语视频原帧扔给3D卷积网络背景、光线、人脸和服饰都会参与特征提取模型需要大量数据才能学会忽略这些干扰。USTC数据集虽然包含多组手语词视频但样本量和环境多样性远不如ImageNet级别的通用数据集硬训3D CNN很容易过拟合。常见做法是用MediaPipe先做手部关键点提取。MediaPipe Hand Landmark模型能在单帧上给出21个关键点包括指尖、指关节和腕部并且自带手部检测器但它的检测框是“全图找手”一旦画面里出现人脸、手臂或者其他类似肤色的物体MediaPipe的手部检测器容易出现错误框选或关键点漂移。这时候就需要YOLOv5在更粗粒度上把手部区域锁定。YOLOv5的检测框作为MediaPipe的输入裁剪区域等于把“找手”和“追踪手内部结构”分给两个模型分别优化。我实际复现时发现单独用MediaPipe处理USTC视频部分帧会把脸部误检为手部关键点完全乱掉加上YOLOv5检测器之后误检比例大幅下降。两个模型的职责边界非常清晰YOLOv5负责目标检测回答“手在画面哪个区域”MediaPipe负责关键点检测回答“这个区域里手指怎么摆”。这样也能把YOLOv5换成更轻量的YOLOv8n或SSD不影响后续关键点序列的逻辑。2.2 USTC数据集的标注格式与预处理裁成“手部上下文”的训练样本USTC手语数据集收集的是中国手语词汇视频每个视频通常对应一个手语词动作由慢到快再由快到慢。拿到数据后第一步不是训练模型而是观察视频分辨率、帧率和标注形式。USTC数据集的标注一般是词级别标签即每个视频文件名或目录名对应一个词并没有逐帧的手部框。这意味着要用YOLOv5训练手部检测器就需要先做目标检测标注。常见做法是先跑一遍MediaPipe自带的手部检测逻辑把检测结果可视化成框然后借助LabelImg或X-AnyLabeling人工校正生成VOC格式的XML标注。也可以直接用MediaPipe的框作为伪标签但伪标签质量不稳定建议人工过一遍。预处理流程分三部分抽帧把每个视频按固定帧率比如10fps抽帧避免重复帧太多。裁剪为了保留手语动作中“手部少量前臂”的上下文不直接切到只含手掌的紧框而是把手部框四周外扩20~30像素作为目标框。统一尺寸YOLOv5训练时输入一般是640x640但USTC视频分辨率不一定满足需要做letterbox填充不能粗暴拉伸。下面是我在复现时写的一个视频预处理脚本它先用OpenCV抽帧再调用MediaPipe的hand detection模式生成初始框保存成VOC格式的XML供后续转YOLO格式使用。import cv2 import mediapipe as mp import os import xml.etree.ElementTree as ET mp_hands mp.solutions.hands hands mp_hands.Hands(static_image_modeFalse, max_num_hands2, min_detection_confidence0.5) def extract_frames(video_path, out_dir, fps_target10): cap cv2.VideoCapture(video_path) video_fps cap.get(cv2.CAP_PROP_FPS) frame_interval max(1, int(round(video_fps / fps_target))) frame_idx 0 save_idx 0 while True: ret, frame cap.read() if not ret: break if frame_idx % frame_interval 0: cv2.imwrite(os.path.join(out_dir, fframe_{save_idx:05d}.jpg), frame) save_idx 1 frame_idx 1 cap.release() def generate_initial_boxes(frame_path, xml_out_path, image_name): image cv2.imread(frame_path) rgb cv2.cvtColor(image, cv2.COLOR_BGR2RGB) results hands.process(rgb) if not results.multi_hand_landmarks: return # 取所有关键点中的最小外接框作为手部框 for landmarks in results.multi_hand_landmarks: xs [lm.x for lm in landmarks.landmark] ys [lm.y for lm in landmarks.landmark] xmin, xmax min(xs), max(xs) ymin, ymax min(ys), max(ys) # 外扩20%保留手腕和前臂上下文 w xmax - xmin h ymax - ymin xmin max(0, xmin - 0.2 * w) xmax min(1, xmax 0.2 * w) ymin max(0, ymin - 0.2 * h) ymax min(1, ymax 0.2 * h) # 写入VOC XML annotation ET.Element(annotation) size ET.SubElement(annotation, size) ET.SubElement(size, width).text str(image.shape[1]) ET.SubElement(size, height).text str(image.shape[0]) obj ET.SubElement(annotation, object) ET.SubElement(obj, name).text hand bndbox ET.SubElement(obj, bndbox) ET.SubElement(bndbox, xmin).text str(int(xmin * image.shape[1])) ET.SubElement(bndbox, ymin).text str(int(ymin * image.shape[0])) ET.SubElement(bndbox, xmax).text str(int(xmax * image.shape[1])) ET.SubElement(bndbox, ymax).text str(int(ymax * image.shape[0])) tree ET.ElementTree(annotation) tree.write(xml_out_path, encodingutf-8, xml_declarationTrue)这个脚本输出的XML是VOC格式。参数上重点注意max_num_hands2因为手语视频经常有双手动作单只手限制会丢掉信息。min_detection_confidence0.5是经验值太低会混入大量背景误检太高会漏检快速运动中的手部。抽帧帧率选10fps是为了控制后续训练样本量和标注工作量一个2秒的视频抽20帧双手动作最多生成40个候选框人工校验不累。2.3 关键点提取脚本批量处理视频帧的MediaPipe管线YOLOv5检测框做好之后系统进入关键点提取阶段。注意这里不是直接对原始帧做MediaPipe而是先根据YOLOv5框裁剪出感兴趣区域再送进MediaPipe。这样MediaPipe只需要在小范围区域里找手精度和稳定性都会明显提升。我一般会先把所有视频帧和对应的YOLO检测框放在一个目录里写一个批量处理脚本按帧读取框裁剪并缩放到224x224再由MediaPipe提取关键点。提取出的关键点要保存成统一格式比如每帧一个numpy数组形状为(num_hands, 21, 2)没有检测到手时填全0。这里有一个容易被忽略的细节MediaPipe的归一化坐标是相对于输入图像宽高的比例值而YOLOv5的坐标是相对于原图的像素值。裁剪之后需要把关键点坐标重新映射回原图坐标或者直接使用归一化比例坐标。我在保存关键点序列时统一保存为相对原图的比例坐标这样后续做时序分类时不用关心输入尺寸。关键点提取的另一个重要参数是min_tracking_confidence。批量处理离线视频时建议设成0.5以下因为手语动作快速运动时跟踪会丢帧模型会重新检测反而更稳定。在线实时推理时再设高一点比如0.7避免检测和跟踪切换太频繁导致抖动。import cv2 import mediapipe as mp import numpy as np import os import json mp_hands mp.solutions.hands hands mp_hands.Hands( static_image_modeTrue, max_num_hands2, min_detection_confidence0.5, min_tracking_confidence0.4 ) def extract_landmarks_from_roi(frame, box): # box: [xmin, ymin, xmax, ymax] 原图坐标 xmin, ymin, xmax, ymax [int(v) for v in box] h, w frame.shape[:2] xmin max(0, xmin) ymin max(0, ymin) xmax min(w, xmax) ymax min(h, ymax) roi frame[ymin:ymax, xmin:xmax] if roi.size 0: return None roi_rgb cv2.cvtColor(roi, cv2.COLOR_BGR2RGB) results hands.process(roi_rgb) landmarks np.zeros((2, 21, 2), dtypenp.float32) if results.multi_hand_landmarks: for hand_idx, hand_landmarks in enumerate(results.multi_hand_landmarks[:2]): for lm_idx, lm in enumerate(hand_landmarks.landmark): # 还原到原图归一化坐标 landmarks[hand_idx, lm_idx, 0] (lm.x * (xmax - xmin) xmin) / w landmarks[hand_idx, lm_idx, 1] (lm.y * (ymax - ymin) ymin) / h return landmarks # 示例遍历检测结果JSON提取关键点并保存 detections_json detections.json # 每帧的检测框 with open(detections_json, r) as f: detections json.load(f) for item in detections: frame_path item[frame_path] box item[box] frame cv2.imread(frame_path) landmarks extract_landmarks_from_roi(frame, box) np.save(item[save_path], landmarks)逻辑说明先裁剪再还原坐标这个顺序能保证关键点与YOLOv5框的坐标口径一致。static_image_modeTrue适合离线抽帧处理因为每个视频帧独立检测不依赖前后帧的跟踪状态。如果做实时视频流需要改成False并调整置信度参数。保存成(2,21,2)的数组是因为最多两只手没有检测到的位置补零时序模型里自然能区分有效和无效帧。3. 用YOLOv5训练手部检测器权重、超参和标注格式转换3.1 把USTC标注转成YOLO格式脚本与坐标归一化YOLOv5训练需要的是txt格式的标注文件每一行是class x_center y_center width height所有坐标都是归一化到0~1的小数。VOC XML转YOLO txt是一个常规操作但这里有一个坑必须强调VOC坐标是绝对像素YOLO是相对宽高转换时除的是图片的真实宽高不是网络输入尺寸640x640。很多人在这里把坐标除以640导致检测框全部偏移。下面是我常用的转换脚本片段它遍历所有XML生成与图片同名的txt文件。import os import xml.etree.ElementTree as ET def voc_to_yolo(xml_path, img_width, img_height, class_names): tree ET.parse(xml_path) root tree.getroot() yolo_lines [] for obj in root.findall(object): name obj.find(name).text if name not in class_names: continue class_id class_names.index(name) bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) # 归一化到原图尺寸而不是640 x_center ((xmin xmax) / 2) / img_width y_center ((ymin ymax) / 2) / img_height width (xmax - xmin) / img_width height (ymax - ymin) / img_height yolo_lines.append(f{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}) return yolo_lines参数说明class_names是按索引排序的类别列表这里只有[hand]。如果USTC数据里还标注了face之类可以一并加入。另外YOLOv5要求图片和txt文件名完全一致包括目录结构。把images和labels放到同一个父目录下YOLOv5会自动生成训练需要的路径索引。3.2 训练命令与三个必调参数训练手部检测器不必从零开始直接使用YOLOv5官方在COCO上的预训练权重yolov5s.pt做迁移学习。手部检测是相对简单的目标检测任务样本量有几千张就够收敛。我在复现时用的训练命令如下python train.py \ --data hand.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 32 \ --epochs 100 \ --workers 4 \ --cache ramhand.yaml内容很简单train: ./datasets/USTC_hand/images/train val: ./datasets/USTC_hand/images/val nc: 1 names: [hand]三个必调参数按优先级排--hyp训练超参数文件YOLOv5默认的hyp.scratch-low.yaml对通用目标检测比较保守手部检测任务简单可以把lr0从0.01调到0.02mosaic从1.0调到0.5。手语视频中手部尺度变化大mosaic增强虽然能提升鲁棒性但会引入大量拼接背景干扰小目标检测。实际测试中mosaic0.5比默认的1.0收敛更快。--multi-scale训练时随机缩放输入对检测尺度变化大的手部非常有用。手语视频中手离镜头近大远小--multi-scale能提升不同距离下的检测能力。代价是训练时间增加约20%。--patience早停耐心值默认是100手部检测数据集规模小调成50就够了省得浪费算力。训练完成后看runs/train/exp/weights/best.pt是否生成。如果val/obj_loss一直不降通常不是模型问题而是标注框不规范比如框太小、边界溢出、长宽比异常。3.3 用训练好的权重做视频流检测和MediaPipe串成整条推理链训练好的best.pt要作为中间环节接入推理管线的。推理时YOLOv5的Detect层输出的是经过NMS后的框但YOLOv5默认输出还有conf和cls。手语识别系统里我会设置conf_thres0.6iou_thres0.5。检测框低于0.6置信度就丢弃避免把背景当成手如果画面里同时出现多个人每个人手部都会被检测需要一个跟踪机制或者按面积筛选主手。推理脚本最核心的部分是提取检测框然后送进MediaPipe关键点提取最后把关键点序列缓存起来。这里我直接用一个简单的类封装整条链路import torch import cv2 from models.experimental import attempt_load class HandPipeline: def __init__(self, weights_path, devicecuda): self.model attempt_load(weights_path, map_locationdevice) self.model.eval() self.stride int(self.model.stride.max()) self.names self.model.module.names if hasattr(self.model, module) else self.model.names def detect_hands(self, frame): img cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img_tensor torch.from_numpy(img).to(self.device).float() img_tensor img_tensor.permute(2, 0, 1).unsqueeze(0) / 255.0 with torch.no_grad(): pred self.model(img_tensor)[0] boxes [] for det in pred: if det is not None and len(det): for *xyxy, conf, cls in det: if conf 0.6 and self.names[int(cls)] hand: boxes.append([int(v) for v in xyxy]) return boxes逻辑说明这里省略了letterbox处理实际操作时建议用YOLOv5内置的letterbox函数预处理输入否则检测精度会下降。attempt_load是YOLOv5自带的模型加载函数能处理best.pt里残留的module前缀。检测框返回后直接传给上一节的关键点提取函数就形成了原始帧 - YOLOv5手部框 - MediaPipe关键点的完整链路。4. 序列识别从单帧关键点到视频级手语分类4.1 关键点序列的时序特征表示手语词不是静态手势而是动作序列。同一个手语词包含起手、运动、落定三个时期如果只看某一帧关键点几乎无法区分“谢谢”和“不客气”。所以单帧21个关键点必须串联成时序特征。常见做法是把连续N帧的关键点拼接成一个特征矩阵形状是(N, 2, 21, 2)然后展平成(N, 84)。这里的N不是固定选择而是根据USTC视频长度来定。USTC数据集里不同词的动作时长不同有的0.8秒有的2秒统一取N30帧按10fps抽帧对应3秒会更稳妥。序列长度不足30帧的补零超过30帧的做均匀采样截断。光有关键点坐标还不够我通常还会额外堆叠两个特征手指弯曲度和关键点速度。手指弯曲度可以通过计算相邻关键点之间的夹角得到比如大拇指指尖与食指根部的距离关键点速度则是当前帧与上一帧同一关键点坐标的欧氏距离。这两个特征能大幅提升时序分类的区分度特别是对“手指静止但手腕移动”的手语动作。def build_sequence_features(landmark_sequence): landmark_sequence: list of (2, 21, 2) arrays seq_len len(landmark_sequence) features [] for i, landmarks in enumerate(landmark_sequence): # 每帧84维双手21点x/y坐标 flat landmarks.reshape(-1) # 添加速度特征与上一帧的位移 if i 0: prev landmark_sequence[i-1].reshape(-1) speed np.abs(flat - prev) else: speed np.zeros_like(flat) # 添加关键点对距离比如指尖到手腕 # 这里简化实际可以计算所有指尖到腕部的距离 frame_feat np.concatenate([flat, speed]) features.append(frame_feat) features np.array(features) if len(features) 30: pad np.zeros((30 - len(features), features.shape[1]), dtypenp.float32) features np.vstack([features, pad]) else: features features[:30] return features.astype(np.float32)参数说明flat把两只手的21个关键点坐标全部展开顺序固定为左手索引0~20、右手索引21~41。如果某只手未检测到对应位置就是全0模型会学到这部分信息。速度特征是对一阶差分的近似等价于给模型提供了动作方向线索。最终特征维度是84 84 168这比只用坐标信息更有区分度。4.2 训练一个轻量分类器LSTM还是全连接拿到序列特征后需要训练一个分类器。USTC数据集词表通常有几十到几百个词样本总量不大所以我建议用两层LSTM加一个全连接输出层而不是直接上Transformer或者TCN。LSTM对中短序列、样本量小的任务更友好过拟合风险低。以下是一个简单的LSTM分类器定义配合PyTorch实现import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, input_dim168, hidden_dim128, num_layers2, num_classes50): super().__init__() self.lstm nn.LSTM( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropout0.3 ) self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): # x: (batch, seq_len, input_dim) out, _ self.lstm(x) # 取最后一个时间步 out out[:, -1, :] out self.fc(out) return out逻辑说明用双向LSTM是因为手语动作的语义有时取决于“动作回归到起点”这个后续状态双向捕捉前文和后续上下文。batch_firstTrue让输入形状更直观。dropout0.3针对小样本防止过拟合。训练时用交叉熵损失优化器选AdamW学习率1e-3配合余弦退火调度。训练数据按视频文件划分同一个视频的所有帧不能同时出现在训练集和验证集里否则模型会记住视频背景或连续性导致验证精度虚高。这是动作识别任务最常见的错误后面会单独讲。5. 手语识别系统常见问题排查与避坑数据、精度和性能5.1 现象检测框抖动导致关键点跳变用YOLOv5检测手部时视频相邻两帧检测框可能上下左右移动十几个像素尤其在手部快速运动时。这会让MediaPipe提取的关键点在原图坐标上频繁跳变时序模型输入也随之剧烈波动识别精度下降。原因YOLOv5的NMS只考虑单帧检测框没有时序平滑。手部运动模糊会让检测框定位不准。解决在检测框输出后加一个一阶低通滤波比如box alpha * current_box (1 - alpha) * prev_boxalpha取0.4。也可以在MediaPipe关键点提取后对关键点坐标做同样的平滑处理。注意不要过度平滑否则会抹掉手语动作的快速运动特征。5.2 现象训练时loss下降但验证精度不动我遇到过YOLOv5训练100轮后mAP50在0.95左右但关键点序列分类验证精度只有60%。这通常不是检测器的问题而是数据集划分或序列对齐的问题。原因一是USTC数据集视频中存在大量动作幅度小但语义相近的词比如“知道”和“明白”。二是序列窗口N取固定30帧但不同词的有效动作只占其中很短一段模型学到的是大量静止帧的共性。解决对USTC视频做动作裁剪用MediaPipe关键点运动速度去掉首尾静止帧只保留运动段。同时把分类器的训练单位从视频改成“动作片段”每个片段作为一个独立样本。如果条件允许对每个词统计动作峰值位置按峰值中心对齐所有样本分类精度会明显提升。5.3 现象摄像头实时推理只有5帧/s如果要在实时视频里跑手语识别YOLOv5s MediaPipe LSTM的总推理耗时通常超过200ms帧率不到10fps。原因是YOLOv5和MediaPipe都使用GPU但没有共享CUDA流且每次调用都有大量内存拷贝。解决一是把YOLOv5的推理图像尺寸从640降到416检测速度提升近一倍手部检测精度损失很小。二是MediaPipe的hands.process输入可以直接使用RGB内存避免BGR转RGB再拷贝。三是把LSTM分类器的推理放到CPU上因为LSTM计算量小和GPU端模型并行执行。实测调整后能到12~15fps基本满足离线演示需求。5.4 现象一个手语词被拆成多段动作有些手语词包含手部的来回运动比如“再见”是手从头顶挥到胸前再回到头顶。逐帧分类模型可能会把前半段识别成“你好”后半段识别成“招手”导致一个连贯词被输出多个标签。原因LSTM最后一帧的分类结果只代表“到这一帧为止的动作片段”没有对整个视频序列做全局决策。解决使用滑窗投票策略。把完整视频按步长5帧切割成多个重叠窗口每个窗口输出一个预测标签最终统计所有窗口的众数作为视频级标签。如果支持连续识别还需要在输出后做抑制把短时间内相同标签合并不同标签的片段长度小于阈值时直接忽略。6. 让系统更可用的三个落地技巧关键点归一化、滑窗融合和模型导出6.1 关键点归一化的两类坐标基准MediaPipe输出的关键点坐标是相对于ROI宽高的比例但手语识别必须使用相对人体或相对手部的归一化方式。我一般会在系统里同时保留两种坐标相对于检测框宽高的归一化能反映手指在ROI中的相对位置但对ROI大小变化敏感。相对于手腕点的归一化以腕部关键点作为原点把所有其他关键点坐标减去腕部坐标。这样模型对“手在画面里移动的绝对位置”不敏感只关心手指相对于手腕的形状和姿态。实际测试第二种归一化对动作幅度大的词更好因为手的绝对位置在不断变化而形状特征才是关键。但缺点是会丢失手腕运动方向信息所以最好的方式是把两种特征拼接起来让模型自己学权重。6.2 滑窗投票融合避免逐帧误判连续手语视频识别不能每帧输出标签我采用的方案是def sliding_window_vote(sequence_data, window_size15, stride5): labels [] start 0 while start window_size len(sequence_data): window sequence_data[start:start window_size] pred model.predict(window) labels.append(pred) start stride if len(labels) 0: return None # 取众数 return max(set(labels), keylabels.count)参数说明window_size15在10fps下代表1.5秒足够覆盖一个手语词的主动作stride5让窗口有重叠避免动作被截断。如果窗口太小会把局部动作当完整词太大则会把两个词的过渡包含进来导致投票结果紊乱。6.3 把TensorRT或ONNX导出提升部署速度如果要在树莓派或Jetson上部署这个系统PyTorch原模型直接推理效率偏低。常见做法是把YOLOv5和LSTM分别导出为ONNX再用TensorRT做FP16优化。YOLOv5官方自带export.py转换命令如下python export.py --weights best.pt --include onnx --img 640LSTM模型需要手动转ONNXimport torch.onnx dummy torch.randn(1, 15, 168) torch.onnx.export(model, dummy, lstm.onnx, input_names[seq], output_names[logits], dynamic_axes{seq: {0: batch, 1: time}})导出后使用onnxruntime或TensorRT API加载推理时间能压缩30%以上。我习惯把检测和关键点提取拆分YOLOv5用TensorRTMediaPipe保留原始C或Python实现LSTM用ONNX Runtime。整体延迟控制在80ms以内实时性才真正可用。回到项目本身这套“YOLOv5检测框 MediaPipe关键点 时序分类”的组合在USTC数据集上能做到一个中间水平的baseline不是最先进方案但结构清晰、每个模块可替换、可调参适合做深入学习。我复现时最大的教训是不要一上来就调模型结构先把数据划分、序列对齐和关键点归一化做扎实精度自然上来。这些坑也写在了前面几章希望帮到你。本文还有配套的精品资源点击获取