☰
手语视频识别实战:YOLOv5+MediaPipe构建基于USTC数据集的手势分类系统
2026/10/5 1:13:33 网站建设 项目流程

简介:这是一套基于USTC数据集、融合MediaPipe与YOLOv5算法的手语视频识别系统Python源码,面向计算机视觉方向的研究者、算法工程师及深度学习初学者,可用于手语动作检测、关键点提取与实时识别等相关任务的二次开发与学习。压缩包共42个文件,大小约13.77MB,其中包含19个Python脚本(如人手上扬检测、字典处理、主程序等核心逻辑)、4个UI界面文件、5个XML工程配置、6张功能示意图以及4段AVI测试视频,还有文本词典与README说明,结构清晰、便于按模块阅读。目前已有219人下载学习。资料不仅给出完整的模型训练与推理链路,还附带RNN分类、手部/姿态关键点检测、单帧OpenPose效果对照、中英文手语互译界面等实用模块;结合UI与示例视频可快速还原系统运行效果,适合直接基于源码进行功能扩展、界面定制或算法调优。

1. 手语视频识别不是视频分类:先定位手,再读手势

把 USTC 手语视频数据集里的一段段视频,变成可被程序理解的手势类别标签,这是“基于USTC数据集MediaPipe和YOLOv5算法实现的手语视频识别系统”要解决的核心问题。这套技术栈组合起来能避开一个很实际的坑:直接用整帧视频做分类,模型学到的往往是背景和肤色,而不是手的动作;先让 YOLOv5 把手的位置框出来,再用 MediaPipe 从框内提取 21 个手部关键点,最后对关键点时间序列做分类,训练稳定性与识别准确率都会明显上一个台阶。下面按数据预处理、YOLOv5 检测器训练、MediaPipe 特征提取、分类器训练到推理验证的顺序展开,适合正在做动作识别、人机交互或无障碍应用,且需要自己从视频样本开始落地的开发者。

2. 数据先行:USTC 数据集的目录结构、抽帧策略与样本划分

2.1 先写遍历脚本,把 USTC 数据集的目录结构摸清楚

USTC 手语数据集解压后的常见结构是「类别目录 / 样本目录 / 视频文件」,类别名直接用中文手语词,比如「谢谢」目录下是同一个志愿者或不同志愿者录制的多个视频样本。拿到数据后的第一步不是急着搭模型,而是先把所有视频路径和对应的中文标签整理成一份清单。没有这份清单,后面所有脚本都要重复写路径拼接逻辑,很容易在某个子目录结构不一样时悄悄漏掉样本。

import csv from pathlib import Path data_root = Path("ustc_data") # 替换成 USTC 数据集解压后的实际路径 rows = [] # 预期目录结构:data_root/手语词标签/样本编号/视频文件 for label_dir in sorted(data_root.iterdir()): if not label_dir.is_dir(): continue label = label_dir.name for sample_dir in sorted(label_dir.iterdir()): if not sample_dir.is_dir(): continue for video_file in sample_dir.glob("*.mp4"): rows.append([str(video_file), label]) with open("manifest.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["video_path", "label"]) writer.writerows(rows) print(f"共找到 {len(rows)} 个视频样本,清单已写入 manifest.csv")

这里用 utf-8-sig 而不是 utf-8,是因为类别名是中文,Windows 下的 pandas 和 Excel 读 utf-8 容易乱码。glob(".mp4") 只匹配当前目录,如果视频还套了一层子目录,要改成 rglob(".mp4")。这份 manifest 后面所有环节都靠它定位样本,标签直接从目录名取,避免再手工维护一份标签映射表。

2.2 抽帧参数:fps、分辨率与关键帧筛选

手语词视频通常在 1~5 秒之间,30fps 的视频一口气全抽出来会产生大量相邻重复帧,既占磁盘又让后续 YOLOv5 标注阶段的时间翻倍。常见做法是每隔 2~3 帧取一帧,并把画面缩放到统一尺寸,比如 640x640。缩放插值方式也有讲究:从大图缩到小图用 INTER_AREA 不容易出锯齿,放大则用 INTER_LINEAR。

import cv2 from pathlib import Path def extract_frames(video_path, out_dir, sample_every=2, target_size=(640, 640)): cap = cv2.VideoCapture(str(video_path)) if not cap.isOpened(): print(f"打开失败: {video_path}") return 0 out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) frame_idx = 0 saved_idx = 0 while True: ret, frame = cap.read() if not ret: break if frame_idx % sample_every == 0: resized = cv2.resize(frame, target_size, interpolation=cv2.INTER_AREA) cv2.imwrite(str(out_dir / f"frame_{saved_idx:05d}.jpg"), resized) saved_idx += 1 frame_idx += 1 cap.release() return saved_idx

sample_every 设为 2 意味着只用一半帧,对手语动作识别来说通常足够,因为关键动作的变化周期不会快到 15fps 以下。如果发现某个类别的视频特别短,只有十几帧,可以把 sample_every 降回 1。这一层抽出来的帧数统计建议回填到 manifest 里,后面做序列长度对齐时要用到每个样本的帧数分布。

2.3 按人划分样本,别让数据泄漏毁了你的指标

手语视频最隐蔽的坑是划分方式。如果直接把视频目录随机分成 train/val,同一个志愿者录的多个样本会同时出现在两边。肤色、手掌大小、录像环境这些与手势无关的信息会被模型记住,验证集指标虚高,一到新环境就崩。所以要尽量按志愿者编号或按样本目录整体划分,而不是按帧随机分。

import random import shutil from pathlib import Path data_root = Path("ustc_data") save_root = Path("split_data") random.seed(42) samples = [] for label_dir in sorted(data_root.iterdir()): if not label_dir.is_dir(): continue for sample_dir in sorted(label_dir.iterdir()): if sample_dir.is_dir(): samples.append((label_dir.name, sample_dir)) random.shuffle(samples) n = len(samples) train_end = int(n * 0.7) val_end = int(n * 0.85) splits = { "train": samples[:train_end], "val": samples[train_end:val_end], "test": samples[val_end:], } for split_name, split_samples in splits.items(): dest_root = save_root / split_name dest_root.mkdir(parents=True, exist_ok=True) for label, sample_dir in split_samples: dest = dest_root / label / sample_dir.name dest.parent.mkdir(parents=True, exist_ok=True) if not dest.exists(): try: dest.symlink_to(sample_dir.resolve(), target_is_directory=True) except OSError: shutil.copytree(sample_dir, dest) for split_name, split_samples in splits.items(): print(f"{split_name}: {len(split_samples)} 个样本目录")

这里用符号链接而不是拷贝,省空间也快,但数据集在跨盘符或 Windows 未开启开发者模式时 symlink 会失败,异常处理里回退成 copytree。随机划分前建议按类别统计一下样本数,如果某个手势样本特别少,可以改成按类内百分比分层划分,否则这个类可能全掉进测试集,训练阶段一次都见不到。

3. 训练 YOLOv5 手部检测器:用 USTC 视频帧做自己的数据集

3.1 为什么先上 YOLOv5,而不是只用 MediaPipe

MediaPipe Hands 本身就能从一张图里把手部关键点检测出来,但它的检测器对手部占画面比例小、背景杂乱、快速移动的场景并不稳。手语视频里手常在胸前快速运动,还会两只手相互遮挡,直接用整帧推理容易出现关键点抖动、跳到脸部或衣服褶皱上。先让 YOLOv5 框出「手在哪」,再在框内做 MediaPipe,等于把目标检测和关键点提取拆成两级:粗定位交给 YOLOv5,细特征交给 MediaPipe。

YOLOv5 是 anchor-based 的单阶段检测器,backbone 用 CSPDarknet 提取多尺度特征,neck 用 PANet 融合浅层位置信息与深层语义信息,head 在三个尺度上输出预测框。对「手」这种类别单一、形态变化大的目标,yolov5s 起步就够用,训练成本和推理速度都友好。如果测试时发现小尺寸手部大量漏检,再考虑 yolov5m,而不是一上来就用最大模型。

3.2 自动标注:用 MediaPipe 反推边界框,再做人工抽检

YOLOv5 训练需要每张图片配一个同名的 txt 标注文件,每行是 class x_center y_center width height,数值全部归一化到 0~1。手动给几千帧画面标框不现实,常见做法是先用 MediaPipe 的静态图模式跑一遍抽好的帧,把 21 个关键点的包围盒外扩 20%,输出成 YOLO 格式。自动标注能省大量时间,但握拳、手掌遮挡这类 MediaPipe 本身识别不好的情况会漏标,所以必须抽检修正,不能全部无脑喂进去。

import cv2 import mediapipe as mp from pathlib import Path mp_hands = mp.solutions.hands hands = mp_hands.Hands(static_image_mode=True, max_num_hands=2, min_detection_confidence=0.6) def boxes_to_yolo_txt(img_path, txt_path, expand_ratio=0.2): img_bgr = cv2.imread(str(img_path)) h, w = img_bgr.shape[:2] img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) result = hands.process(img_rgb) lines = [] if result.multi_hand_landmarks: for hand_landmarks in result.multi_hand_landmarks: xs = [lm.x for lm in hand_landmarks.landmark] ys = [lm.y for lm in hand_landmarks.landmark] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) # 外扩 20%,把指尖和腕部边缘的检测余量留出来 box_w = x_max - x_min box_h = y_max - y_min x_min = max(0.0, x_min - box_w * expand_ratio) x_max = min(1.0, x_max + box_w * expand_ratio) y_min = max(0.0, y_min - box_h * expand_ratio) y_max = min(1.0, y_max + box_h * expand_ratio) cx = (x_min + x_max) / 2 cy = (y_min + y_max) / 2 lines.append(f"0 {cx:.6f} {cy:.6f} {box_w:.6f} {box_h:.6f}") with open(txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) return len(lines)

static_image_mode=True 表示每一帧独立检测,不做帧间跟踪,标注过程虽然慢一点,但不会把上一帧的结果继承过来造成框的位置滞后。max_num_hands=2 是因为中文手语存在双手配合的词,单只手时缺省一只即可。外扩 20% 的经验依据是:关键点包围盒紧贴皮肤轮廓,直接按原始包围盒训练,会让 YOLOv5 学到「框必须切着手边缘」的假规律,推理时框稍有抖动 IoU 就掉得厉害。

3.3 数据配置与训练命令:YOLOv5 超参数只动这几个

训练自己的手部数据集,目录结构按 YOLOv5 惯例把 images 和 labels 分开,train/val 各一份。数据集配置文件用一个 yaml 指向这些目录。注意类别数 nc 写 1,不要随手填 80,类别越少收敛越快、误检越少。

# hand_dataset.yaml train: ./yolo_dataset/images/train val: ./yolo_dataset/images/val nc: 1 names: 0: hand

训练命令按 YOLOv5 官方 train.py 的参数风格写即可。第一次跑建议直接用官方预训练权重做迁移学习,而不是随机初始化,否则 80 轮很难收敛到可用水平。

python train.py \ --data hand_dataset.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 80 \ --workers 4 \ --cache ram

--img 640 是训练分辨率,手部目标小,分辨率太低漏检严重;显存不够可以降到 512,代价是小手的召回率下降。--batch 受显存限制,16 对应 8G 左右的显卡,24G 显存可以尝试 32。--cache ram 把图片提前加载进内存,能省大量磁盘 IO,但数据集总量几十 GB 时不建议。训练时盯两样东西:loss 曲线是不是稳定下降,以及 val 的 mAP 能不能过 0.3。如果 mAP 一直很低,先补标注错误和漏标,不要急着改超参数,hand 这种单类检测任务,标注质量比超参数敏感得多。

4. MediaPipe 手部关键点提取:21 个点怎么变成时序特征

4.1 环境关:mediapipe 安装与 Python 版本

常见做法是在 Python 3.8~3.10 的干净虚拟环境里安装。安装命令本身只有一行,但容易踩坑的是 opencv-python 和 mediapipe 内置的 opencv 版本冲突,现象是 import cv2 时报一堆符号找不到的错误。建议直接在虚拟环境里用 headless 版 opencv,避免 GUI 依赖带来的版本拉扯。

python -m venv .venv # Windows: .venv\Scripts\activate Linux/macOS: source .venv/bin/activate pip install mediapipe pip install opencv-python-headless

安装完先执行 python -c "import mediapipe, cv2; print('ok')" 做一次导入验证,两个库都能正常加载再继续。很多手语识别项目在环境上浪费的时间比模型调参还多,提前做导入检查能省下大量排查时间。

4.2 把 YOLOv5 的检测框变成 MediaPipe 的输入 ROI

推理阶段的顺序是:YOLOv5 输出手部 bbox,按 bbox 裁剪原图,把裁剪结果交给 MediaPipe Hands,拿回 21 个关键点。关键点就在这个裁剪框里做,手部占 ROI 面积的比例大了,关键点稳定性比整帧推理明显改善。这也是整套方案比单独用 MediaPipe 稳定的核心原因。

import cv2 import mediapipe as mp mp_hands = mp.solutions.hands hands = mp_hands.Hands( static_image_mode=False, max_num_hands=2, model_complexity=1, min_detection_confidence=0.5, min_tracking_confidence=0.5, ) def roi_to_landmark_features(roi_bgr, feature_dim=84): rgb = cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2RGB) result = hands.process(rgb) if not result.multi_hand_landmarks: return None feats = [] for hand_lms in result.multi_hand_landmarks[:2]: for lm in hand_lms.landmark: feats.extend([lm.x, lm.y]) while len(feats) < feature_dim: feats.extend([0.0, 0.0]) return feats

这里只取 x、y,特征维度是 21 点 × 2 坐标 × 2 只手 = 84。MediaPipe 还输出 z 和 visibility,但单目摄像头下的 z 是相对深度估算,噪声很大,多数开源实现不直接拼进特征。static_image_mode=False 走的是视频跟踪模式,上一帧找到的手会在下一帧用更小的搜索范围继续跟踪,速度更快;如果发现关键点漂移,优先把 min_detection_confidence 调到 0.6 以上,而不是无脑降低阈值。

4.3 归一化:从「手在画面哪里」变成「手的形状」

把原始 lm.x 和 lm.y 直接喂给分类器是新手最容易犯的错。原始坐标里包含了手在画面中的位置和大小,换个位置、换个拍摄距离,同一个手势的特征值全变了,模型学到的是「手在右下角且很大时对应某个词」,而不是手势本身。正确做法是以手的内部结构做归一化:取 0 号点手腕为原点,取 9 号点中指掌指关节到手腕的距离做尺度。

import numpy as np def normalize_hand(features, feature_dim=84): arr = np.array(features, dtype=np.float32).reshape(-1, 2) if arr.shape[0] < 21: return np.zeros(feature_dim, dtype=np.float32) wrist = arr[0] middle_mcp = arr[9] scale = np.linalg.norm(middle_mcp - wrist) if scale < 1e-6: return np.zeros(feature_dim, dtype=np.float32) normalized = (arr - wrist) / scale return normalized.reshape(-1)[:feature_dim].astype(np.float32)

归一化之后,同一手势在不同拍摄距离下的特征分布才能对齐。scale 小于 1e-6 说明关键点基本重合,通常是检测失败或手被完全遮挡,返回全零向量是一种显式缺省标记,后续分类器训练时要配合 mask 或直接丢弃这一帧,不要把全零帧当真值。

4.4 变长序列对齐:视频长度不一,特征矩阵怎么统一

每个样本抽出的帧数不同,归一化后的特征矩阵形状是 (帧数, 84)。LSTM 虽然支持变长输入,但要排序加 masking,工程复杂度高。常见做法是统一到固定长度,比如 32 帧。关键不是补零,而是怎么保留动作时序:短视频线性插值到 32 帧,长视频均匀抽 32 帧。

import numpy as np def align_sequence(features, target_len=32): seq_len = features.shape[0] if seq_len >= target_len: idx = np.linspace(0, seq_len - 1, target_len).astype(int) return features[idx] else: x_old = np.linspace(0, 1, seq_len) x_new = np.linspace(0, 1, target_len) aligned = np.empty((target_len, features.shape[1]), dtype=np.float32) for col in range(features.shape[1]): aligned[:, col] = np.interp(x_new, x_old, features[:, col]) return aligned

target_len 的经验取值是统计训练集所有样本的帧数分布,取中位数或略高于中位数。取太小会丢掉动作细节,取太大则短样本靠插值补出了本不存在的运动信息,噪声被放大。每个样本处理后保存成 (32, 84) 的 npy 文件,文件名带上类别标签,训练分类器时直接 np.load 即可。

5. 手语识别避坑排查:五条反复出现的踩坑记录

5.1 现象:YOLOv5 框住了手,MediaPipe 关键点却飞出手部区域

典型表现是 21 个关键点里有一截手指的坐标突然跳到 ROI 边缘,画出来像是手被拉伸变形。原因多数是 YOLOv5 输出的 bbox 切得太紧,MediaPipe 在裁剪图上做检测时手部贴近边界,landmark 把外界背景误判成了手指边界。解决方法是把 bbox 外扩 15%~20% 后再裁剪,同时保持宽高比,不要直接拉伸填满。排查时把每帧的 bbox 和 keypoint 叠加可视化输出到视频里,一眼就能看出是哪一级出了问题。

5.2 现象:训练集准确率 99%,验证集只有 60%

这是手语视频项目里最典型的翻车。原因大概率是样本划分泄漏:同一个志愿者录了多个视频,随机划分视频时这些视频同时进了训练集和验证集,模型记住的是这个人的手掌纹理和肤色,而不是手势动作。解决方法是按志愿者 ID 划分数据;数据集中没有志愿者 ID 时,至少要保证同一个样本目录下的全部帧不被打散。可以用一个笨办法验证泄漏:把每帧图像整体做高斯模糊后只保留颜色直方图特征去训练一个分类器,如果验证集准确率依然显著高于随机水平,说明划分一定有问题。

5.3 现象:LSTM 训练 loss 卡住不降,前十轮几乎是平的

序列分类网络不收敛,先别怀疑数据量,按顺序查三件事。第一,输入特征有没有做归一化,坐标没归一化时不同关键点的数值量级差异过大会让梯度不稳定。第二,学习率,常见做法是 1e-3 起步,但序列模型里 3e-4 往往更稳,loss 不降时先降学习率而不是加层。第三,把 LSTM 换成 GRU 试试,GRU 参数少一截,在数据量不大的手语识别任务上收敛稳定性通常更好。隐藏层超过 128 在这个任务里基本属于玄学范畴,先小后大,不要上来就堆容量。

5.4 现象:某个手势总是被错分成另一个相近手势

这类问题优先查标注一致性和类别均衡。手语里动作结构相似的手势,例如「谢谢」和「你好」,抽帧后在关键点序列的某些帧几乎重合,分类器天然容易混淆。正确做法是先用混淆矩阵确认哪些类别对被搞混,再回看这两类的训练样本,检查标注错误和起始帧没对齐的问题。少数类样本不够时,做时序增强比图像增强更有效:把整段序列在时间轴上随机缩放 0.9~1.1 倍,或者做慢速/快速播放的模拟,都能提升少数类的泛化能力。

5.5 现象:推理速度不够,视频流实时性上不去

手语识别系统如果部署到树莓派这类边缘设备,整条链路里最贵的是 YOLOv5 检测。常见做法是让 YOLOv5 每 3~5 帧才检测一次,中间帧直接沿用上一帧的 bbox 裁剪 ROI,MediaPipe 的跟踪模式本身能承接这种跨帧跟踪。检测频率降下来后,整条流水线的 fps 通常能接近翻倍。另一个有效手段是把 YOLOv5 和分类器都导出成 ONNX,用 onnxruntime 推理,不依赖完整 PyTorch 环境,在板子上部署时省掉一大块依赖。如果发现 LSTM 在 CPU 上很慢,先 profile 一下它到底占了多少时间,很多项目里瓶颈根本不在分类器,盲目换模型架构不如先砍检测频率。

6. 端到端推理验证:把 YOLOv5、MediaPipe 和分类器串成一条流水线

6.1 先写一个能跑通全流程的推理脚本

验证整套系统不是把三个模型分开测,而是串起来看最终输出。我习惯先把单条视频跑通,再用批量脚本压测试集。下面脚本里最需要注意的是 YOLOv5 后处理参数和 MediaPipe 的结果拼装:检测输出要按置信度阈值过滤,MediaPipe 输出要做归一化和序列对齐。

import cv2 import numpy as np import torch from pathlib import Path model = torch.hub.load("ultralytics/yolov5", "custom", path="best.pt", force_reload=False) classifier = load_lstm_classifier("lstm_hand.pt") # 自封装的分类器加载函数 def predict_video(video_path, target_len=32): cap = cv2.VideoCapture(video_path) frame_feats = [] while True: ret, frame = cap.read() if not ret: break # YOLOv5 后处理:置信度阈值 0.25,NMS IoU 阈值 0.45 results = model(frame, size=640) boxes = results.xyxy[0].cpu().numpy() boxes = boxes[boxes[:, 4] >= 0.25] if len(boxes) == 0: continue # 取最大面积的手部框,避免手语视频里偶尔出现的脸部误检 best_box = max(boxes, key=lambda b: (b[2] - b[0]) * (b[3] - b[1])) x1, y1, x2, y2 = map(int, best_box[:4]) roi = frame[y1:y2, x1:x2] feats = roi_to_landmark_features(roi) if feats is not None: frame_feats.append(normalize_hand(feats)) cap.release() if len(frame_feats) == 0: return "unknown" feats_matrix = np.stack(frame_feats, axis=0) aligned = align_sequence(feats_matrix, target_len) with torch.no_grad(): logits = classifier(torch.tensor(aligned).unsqueeze(0)) return class_names[int(logits.argmax(dim=1)[0])]

置信度阈值 0.25 是 YOLOv5 的默认设置,手语视频背景相对干净,实际可以提到 0.3~0.4。取最大面积框的假设是手是画面里最主要的对象;如果要做双手独立词识别,就改为保留 NMS 后的前两个框,再按左右手分别提取关键点。

6.2 三个提升稳定性的实测技巧

第一个技巧是滑动窗口投票。单帧预测跳变频繁时,把最近 10 帧的预测结果做多数投票,能压掉一半以上的抖动错误。第二个技巧是跳帧检测加关键点插值:YOLOv5 每 3 帧跑一次,中间两帧沿用上一帧 bbox 并外扩一点作为 ROI,MediaPipe 每帧都跑,既保住关键点流畅度,检测开销也降到约三分之一。第三个技巧是 ONNX 导出后部署到边缘设备,分类器固定输入长度后导出很顺,板子上只装 onnxruntime 即可跑推理。

技巧主要收益代价 / 注意点
滑动窗口投票输出稳定,跳变错误减少增加约 10 帧的输出延迟
跳帧检测 + 插值检测耗时降为约 1/3快速移动时 bbox 会滞后,需要外扩 ROI
ONNX 导出去掉 PyTorch 推理环境,板端可用需固定输入长度,动态轴处理麻烦

这套方案真正跑稳之后你会发现,瓶颈往往不在模型结构,而在数据划分和特征归一化。我自己的习惯是先花两天把数据管线彻底打通,再回头改模型,模型部分反而很少大动。推理结果的每一层都可视化出来检查,比盲目调参管用得多。希望这些排查记录和一个能直接改着用的推理脚本能帮你少走几段弯路。

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

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

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

立即咨询