ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手语视频识别实战:YOLOv5+MediaPipe构建基于USTC数据集的手势分类系统

手语视频识别实战:YOLOv5+MediaPipe构建基于USTC数据集的手势分类系统 简介这是一套基于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, encodingutf-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、分辨率与关键帧筛选手语词视频通常在 15 秒之间30fps 的视频一口气全抽出来会产生大量相邻重复帧既占磁盘又让后续 YOLOv5 标注阶段的时间翻倍。常见做法是每隔 23 帧取一帧并把画面缩放到统一尺寸比如 640x640。缩放插值方式也有讲究从大图缩到小图用 INTER_AREA 不容易出锯齿放大则用 INTER_LINEAR。import cv2 from pathlib import Path def extract_frames(video_path, out_dir, sample_every2, 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(parentsTrue, exist_okTrue) 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, interpolationcv2.INTER_AREA) cv2.imwrite(str(out_dir / fframe_{saved_idx:05d}.jpg), resized) saved_idx 1 frame_idx 1 cap.release() return saved_idxsample_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(parentsTrue, exist_okTrue) for label, sample_dir in split_samples: dest dest_root / label / sample_dir.name dest.parent.mkdir(parentsTrue, exist_okTrue) if not dest.exists(): try: dest.symlink_to(sample_dir.resolve(), target_is_directoryTrue) 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而不是只用 MediaPipeMediaPipe 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数值全部归一化到 01。手动给几千帧画面标框不现实常见做法是先用 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_modeTrue, max_num_hands2, min_detection_confidence0.6) def boxes_to_yolo_txt(img_path, txt_path, expand_ratio0.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(f0 {cx:.6f} {cy:.6f} {box_w:.6f} {box_h:.6f}) with open(txt_path, w, encodingutf-8) as f: f.write(\n.join(lines)) return len(lines)static_image_modeTrue 表示每一帧独立检测不做帧间跟踪标注过程虽然慢一点但不会把上一帧的结果继承过来造成框的位置滞后。max_num_hands2 是因为中文手语存在双手配合的词单只手时缺省一只即可。外扩 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.83.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_modeFalse, max_num_hands2, model_complexity1, min_detection_confidence0.5, min_tracking_confidence0.5, ) def roi_to_landmark_features(roi_bgr, feature_dim84): 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_modeFalse 走的是视频跟踪模式上一帧找到的手会在下一帧用更小的搜索范围继续跟踪速度更快如果发现关键点漂移优先把 min_detection_confidence 调到 0.6 以上而不是无脑降低阈值。4.3 归一化从「手在画面哪里」变成「手的形状」把原始 lm.x 和 lm.y 直接喂给分类器是新手最容易犯的错。原始坐标里包含了手在画面中的位置和大小换个位置、换个拍摄距离同一个手势的特征值全变了模型学到的是「手在右下角且很大时对应某个词」而不是手势本身。正确做法是以手的内部结构做归一化取 0 号点手腕为原点取 9 号点中指掌指关节到手腕的距离做尺度。import numpy as np def normalize_hand(features, feature_dim84): arr np.array(features, dtypenp.float32).reshape(-1, 2) if arr.shape[0] 21: return np.zeros(feature_dim, dtypenp.float32) wrist arr[0] middle_mcp arr[9] scale np.linalg.norm(middle_mcp - wrist) if scale 1e-6: return np.zeros(feature_dim, dtypenp.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_len32): 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]), dtypenp.float32) for col in range(features.shape[1]): aligned[:, col] np.interp(x_new, x_old, features[:, col]) return alignedtarget_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.91.1 倍或者做慢速/快速播放的模拟都能提升少数类的泛化能力。5.5 现象推理速度不够视频流实时性上不去手语识别系统如果部署到树莓派这类边缘设备整条链路里最贵的是 YOLOv5 检测。常见做法是让 YOLOv5 每 35 帧才检测一次中间帧直接沿用上一帧的 bbox 裁剪 ROIMediaPipe 的跟踪模式本身能承接这种跨帧跟踪。检测频率降下来后整条流水线的 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, pathbest.pt, force_reloadFalse) classifier load_lstm_classifier(lstm_hand.pt) # 自封装的分类器加载函数 def predict_video(video_path, target_len32): cap cv2.VideoCapture(video_path) frame_feats [] while True: ret, frame cap.read() if not ret: break # YOLOv5 后处理置信度阈值 0.25NMS IoU 阈值 0.45 results model(frame, size640) boxes results.xyxy[0].cpu().numpy() boxes boxes[boxes[:, 4] 0.25] if len(boxes) 0: continue # 取最大面积的手部框避免手语视频里偶尔出现的脸部误检 best_box max(boxes, keylambda 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, axis0) aligned align_sequence(feats_matrix, target_len) with torch.no_grad(): logits classifier(torch.tensor(aligned).unsqueeze(0)) return class_names[int(logits.argmax(dim1)[0])]置信度阈值 0.25 是 YOLOv5 的默认设置手语视频背景相对干净实际可以提到 0.30.4。取最大面积框的假设是手是画面里最主要的对象如果要做双手独立词识别就改为保留 NMS 后的前两个框再按左右手分别提取关键点。6.2 三个提升稳定性的实测技巧第一个技巧是滑动窗口投票。单帧预测跳变频繁时把最近 10 帧的预测结果做多数投票能压掉一半以上的抖动错误。第二个技巧是跳帧检测加关键点插值YOLOv5 每 3 帧跑一次中间两帧沿用上一帧 bbox 并外扩一点作为 ROIMediaPipe 每帧都跑既保住关键点流畅度检测开销也降到约三分之一。第三个技巧是 ONNX 导出后部署到边缘设备分类器固定输入长度后导出很顺板子上只装 onnxruntime 即可跑推理。技巧主要收益代价 / 注意点滑动窗口投票输出稳定跳变错误减少增加约 10 帧的输出延迟跳帧检测 插值检测耗时降为约 1/3快速移动时 bbox 会滞后需要外扩 ROIONNX 导出去掉 PyTorch 推理环境板端可用需固定输入长度动态轴处理麻烦这套方案真正跑稳之后你会发现瓶颈往往不在模型结构而在数据划分和特征归一化。我自己的习惯是先花两天把数据管线彻底打通再回头改模型模型部分反而很少大动。推理结果的每一层都可视化出来检查比盲目调参管用得多。希望这些排查记录和一个能直接改着用的推理脚本能帮你少走几段弯路。本文还有配套的精品资源点击获取
返回列表