📢 转载信息
原文链接:https://www.freecodecamp.org/news/using-transformers-for-real-time-gesture-recognition/
原文作者:OMOTAYO OMOYEMI
# 告别静态识别:基于Transformer的实时手势识别系统实战教程 手势和 signé 识别是计算机视觉领域一个快速发展的方向,它为辅助工具和自然用户界面提供了强大支持。大多数初学者项目依赖于手部关键点(landmarks)或小型卷积神经网络(CNN),但这些方法往往忽略了手势的本质——它们是随时间展开的序列动作,而非静态图像。为了构建更稳健的实时系统,我们需要能够同时捕捉空间细节和时间上下文的模型。 这时,**Transformer** 模型便大放异彩。Transformer 最初为自然语言处理(NLP)而生,但如今得益于 Vision Transformer (ViT) 和更专注于视频的时序Transformer (TimeSformer) 等模型,已成为视觉任务中的尖端技术。 在本教程中,我们将利用 Transformer 架构来创建一个轻量级的实时手势识别工具,该工具针对小数据集进行了优化,并可以直接部署在普通笔记本电脑的摄像头上。 ## 目录速览 * [为什么选择 Transformer 来处理手势?](#why-transformers-for-gestures) * [你将学到什么](#what-youll-learn) * [项目先决条件](#prerequisites) * [项目环境搭建](#project-setup) * [生成手势数据集](#generate-a-gesture-dataset) * [选项一:生成合成数据集](#option-1-generate-a-synthetic-dataset) * [训练脚本:`train.py`](#training-script-trainpy) * [将模型导出为 ONNX 格式](#export-the-model-to-onnx) * [评估准确率与延迟](#evaluate-accuracy-latency) * [选项二:使用公共手势数据集的小样本](#option-2-use-small-samples-from-public-gesture-datasets) * [辅助功能说明与伦理边界](#accessibility-notes-amp-ethical-limits) * [后续步骤](#next-steps) * [总结](#conclusion)
为什么选择 Transformer 来处理手势?
Transformer 的强大之处在于它使用**自注意力机制(self-attention)**来建模序列中元素之间的关系。对于手势识别而言,这意味着模型不仅看到孤立的帧,还能学习动作是如何随时间演变的。例如,一个“挥手”动作与一个“举手”动作,只有在作为连续序列观察时才能区分。 Vision Transformer (ViT) 将图像处理为图像块(patches),而视频 Transformer 则通过时间注意力机制将此扩展到多帧。即使是简单的“逐帧应用 ViT 然后跨时间池化”的方法,也能在小数据集上超越传统的基于 CNN 的方法。 通过结合 Hugging Face 的预训练模型和 ONNX Runtime 进行优化,Transformer 使得我们能够在适度的数据集上训练并实现流畅的实时识别。你将学到什么
在本教程中,你将使用 Transformer 构建一个手势识别系统。到最后,你将掌握以下技能: * 创建(或记录)一个微型手势数据集 * 训练一个带有时间池化的 Vision Transformer (ViT) * 将模型导出为 ONNX 以实现更快的推理速度 * 构建一个实时的 Gradio 应用,能从你的网络摄像头识别手势 * 使用简单的脚本评估模型的准确率和延迟 * 理解手势识别的辅助潜力及伦理限制项目先决条件
要跟随本教程,你需要具备以下条件: * 基础 Python 知识(函数、脚本、虚拟环境) * 熟悉 PyTorch(张量、数据集、训练循环)—— 有帮助但非必须 * 系统上安装了 Python 3.8+ * 一个网络摄像头(用于 Gradio 中的实时演示) * 可选:GPU 访问权限(CPU 训练可行,但速度较慢)项目环境搭建
创建一个新的项目文件夹并安装所需库。 ```bash # 创建一个新的项目目录并进入 mkdir transformer-gesture && cd transformer-gesture # 设置 Python 虚拟环境 python -m venv .venv # 激活虚拟环境 # Windows PowerShell .venv\Scripts\Activate.ps1 # macOS/Linux source .venv/bin/activate ``` 上述代码片段是一组用于通过虚拟环境设置新 Python 项目的命令。激活虚拟环境可确保你安装的 Python 解释器和包仅限于当前项目,从而避免与其他项目或系统级包发生冲突。 创建一个 `requirements.txt` 文件: ```txt torch>=2.0 torchvision torchaudio timm huggingface_hub onnx onnxruntime gradio numpy opencv-python pillow matplotlib seaborn scikit-learn ``` **依赖包说明:** * **torch>=2.0**: PyTorch 深度学习框架。 * **torchvision/torchaudio**: PyTorch 生态中用于计算机视觉和音频处理的库。 * **timm**: 提供了大量预训练的 PyTorch 图像模型。 * **huggingface_hub**: 方便访问 Hugging Face Hub 上的模型和数据集。 * **onnx/onnxruntime**: 用于模型互操作性和高性能推理的工具。 * **gradio**: 用于快速创建机器学习模型 Web 界面。 * **numpy, opencv-python, pillow**: 基础的数据处理和图像/视频操作库。 * **matplotlib/seaborn/scikit-learn**: 用于绘图和模型评估。 安装依赖项: ```bash pip install -r requirements.txt ``` 该命令会读取 `requirements.txt` 文件并安装项目中所需的所有 Python 包。生成手势数据集
为了训练我们的 Transformer 手势识别器,我们需要数据。我们不会下载庞大的数据集,而是从一个可以在几秒钟内生成的小型**合成数据集**开始。这使得本教程更加轻量级,确保每个人都能跟上。选项一:生成合成数据集
我们将使用一个小型 Python 脚本来创建模拟手势的短 `.mp4` 剪辑(一个移动或静止的彩色方块)。每个类别代表一个手势: * **swipe_left** – 方块从右向左移动 * **swipe_right** – 方块从左向右移动 * **stop** – 方块在中心保持静止 将以下脚本保存为 `generate_synthetic_gestures.py`: ```python import os, cv2, numpy as np, random, argparse def ensure_dir(p): os.makedirs(p, exist_ok=True) def make_clip(mode, out_path, seconds=1.5, fps=16, size=224, box_size=60, seed=0, codec="mp4v"): rng = random.Random(seed) frames = int(seconds * fps) H = W = size # background + box color bg_val = rng.randint(160, 220) bg = np.full((H, W, 3), bg_val, dtype=np.uint8) color = (rng.randint(20, 80), rng.randint(20, 80), rng.randint(20, 80)) # path of motion y = rng.randint(40, H - 40 - box_size) if mode == "swipe_left": x_start, x_end = W - 20 - box_size, 20 elif mode == "swipe_right": x_start, x_end = 20, W - 20 - box_size elif mode == "stop": x_start = x_end = (W - box_size) // 2 else: raise ValueError(f"Unknown mode: {mode}") fourcc = cv2.VideoWriter_fourcc(*codec) vw = cv2.VideoWriter(out_path, fourcc, fps, (W, H)) if not vw.isOpened(): raise RuntimeError( f"Could not open VideoWriter with codec '{codec}'. " "Try --codec XVID and use .avi extension, e.g. out.avi" ) for t in range(frames): alpha = t / max(1, frames - 1) x = int((1 - alpha) * x_start + alpha * x_end) # small jitter to avoid being too synthetic jitter_x, jitter_y = rng.randint(-2, 2), rng.randint(-2, 2) frame = bg.copy() cv2.rectangle(frame, (x + jitter_x, y + jitter_y), (x + jitter_x + box_size, y + jitter_y + box_size), color, thickness=-1) # overlay text cv2.putText(frame, mode, (8, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2, cv2.LINE_AA) cv2.putText(frame, mode, (8, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 1, cv2.LINE_AA) vw.write(frame) vw.release() def write_labels(labels, out_dir): with open(os.path.join(out_dir, "labels.txt"), "w", encoding="utf-8") as f: for c in labels: f.write(c + "\n") def main(): ap = argparse.ArgumentParser(description="Generate a tiny synthetic gesture dataset.") ap.add_argument("--out", default="data", help="Output directory (default: data)") ap.add_argument("--classes", nargs="+", default=["swipe_left", "swipe_right", "stop"], help="Class names (default: swipe_left swipe_right stop)") ap.add_argument("--clips", type=int, default=16, help="Clips per class (default: 16)") ap.add_argument("--seconds", type=float, default=1.5, help="Seconds per clip (default: 1.5)") ap.add_argument("--fps", type=int, default=16, help="Frames per second (default: 16)") ap.add_argument("--size", type=int, default=224, help="Frame size WxH (default: 224)") ap.add_argument("--box", type=int, default=60, help="Box size (default: 60)") ap.add_argument("--codec", default="mp4v", help="Codec fourcc (mp4v or XVID)") ap.add_argument("--ext", default=".mp4", help="File extension (.mp4 or .avi)") args = ap.parse_args() ensure_dir(args.out) write_labels(args.classes, ".") # writes labels.txt to project root print(f"Generating synthetic dataset -> {args.out}") for cls in args.classes: cls_dir = os.path.join(args.out, cls) ensure_dir(cls_dir) mode = "stop" if cls == "stop": mode = "stop" elif "left" in cls: mode = "swipe_left" elif "right" in cls: mode = "swipe_right" else: mode = "stop" for i in range(args.clips): filename = os.path.join(cls_dir, f"{cls}_{i+1:03d}{args.ext}") make_clip( mode=mode, out_path=filename, seconds=args.seconds, fps=args.fps, size=args.size, box_size=args.box, seed=i + 1, codec=args.codec ) print(f" {cls}: {args.clips} clips") print("Done. You can now run: python train.py, python export_onnx.py, python app.py") if __name__ == "__main__": main() ``` 现在,在你的虚拟环境中运行它: ```bash python generate_synthetic_gestures.py --out data --clips 16 --seconds 1.5 ``` 这会生成一个类似如下结构的数据集: ``` data/ swipe_left/*.mp4 swipe_right/*.mp4 stop/*.mp4 labels.txt ``` 每个文件夹包含模拟手势的短视频片段。这非常适合测试整个流程。训练脚本:`train.py`
现在我们有了数据集,让我们使用时间池化来微调一个 Vision Transformer。该模型逐帧应用 ViT,跨时间平均其特征嵌入(embeddings),然后在你的手势上训练一个分类头。 这是完整的训练脚本: ```python # train.py import torch, torch.nn as nn, torch.optim as optim from torch.utils.data import DataLoader import timm # 注意:GestureClips 和 read_labels 假定已在当前目录或路径中定义 from dataset import GestureClips, read_labels class ViTTemporal(nn.Module): """Frame-wise ViT encoder -> mean pool over time -> linear head.""" def __init__(self, num_classes, vit_name="vit_tiny_patch16_224"): super().__init__() # global_pool=0 means we don't take the final classification token or global avg pool self.vit = timm.create_model(vit_name, pretrained=True, num_classes=0, global_pool="avg") feat_dim = self.vit.num_features self.head = nn.Linear(feat_dim, num_classes) def forward(self, x): # x: (B,T,C,H,W) B, T, C, H, W = x.shape x = x.view(B * T, C, H, W) feats = self.vit(x) # (B*T, D) feats = feats.view(B, T, -1).mean(dim=1) # (B, D) -> Temporal Pooling return self.head(feats) def train(): device = "cuda" if torch.cuda.is_available() else "cpu" labels, _ = read_labels("labels.txt") n_classes = len(labels) # 假设 GestureClips 已经定义并能加载数据 train_ds = GestureClips(train=True) val_ds = GestureClips(train=False) print(f"Train clips: {len(train_ds)} | Val clips: {len(val_ds)}") # Windows/CPU friendly batch size and workers train_dl = DataLoader(train_ds, batch_size=2, shuffle=True, num_workers=0, pin_memory=False) val_dl = DataLoader(val_ds, batch_size=2, shuffle=False, num_workers=0, pin_memory=False) model = ViTTemporal(num_classes=n_classes).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.05) best_acc = 0.0 epochs = 5 for epoch in range(1, epochs + 1): # ---- Train ---- model.train() total, correct, loss_sum = 0, 0, 0.0 for x, y in train_dl: x, y = x.to(device), y.to(device) optimizer.zero_grad() logits = model(x) loss = criterion(logits, y) loss.backward() optimizer.step() loss_sum += loss.item() * x.size(0) correct += (logits.argmax(1) == y).sum().item() total += x.size(0) train_acc = correct / total if total else 0.0 train_loss = loss_sum / total if total else 0.0 # ---- Validate ---- model.eval() vtotal, vcorrect = 0, 0 with torch.no_grad(): for x, y in val_dl: x, y = x.to(device), y.to(device) vcorrect += (model(x).argmax(1) == y).sum().item() vtotal += x.size(0) val_acc = vcorrect / vtotal if vtotal else 0.0 print(f"Epoch {epoch:02d} | train_loss {train_loss:.4f} " f"| train_acc {train_acc:.3f} | val_acc {val_acc:.3f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "vit_temporal_best.pt") print("Best val acc:", best_acc) if __name__ == "__main__": # 必须确保 dataset.py 中定义了 GestureClips 和 read_labels 函数才能运行 # train() pass # 仅展示代码结构 ``` 运行 `python train.py` 命令将启动模型训练过程。脚本会加载数据,微调预训练的 ViT 模型,并在验证集上评估其性能,最终将最佳模型权重保存为 `vit_temporal_best.pt`。 **训练过程示例:** ``` Train clips: 38 | Val clips: 10 Epoch 01 | train_loss 1.4508 | train_acc 0.395 | val_acc 0.200 Epoch 02 | train_loss 1.2466 | train_acc 0.263 | val_acc 0.200 Epoch 03 | train_loss 1.1361 | train_acc 0.368 | val_acc 0.200 Best val acc: 0.200 ``` 即使准确率一开始很低,这也证明了 Transformer 管道是有效的。后续可以通过增加样本数量、增加训练周期或使用真实录制的视频来提高结果。
将模型导出为 ONNX 格式
为了使模型更易于实时运行(并减少 CPU 负担),我们将模型导出为 ONNX 格式。 **注意:** ONNX(Open Neural Network Exchange)是一种开放标准格式,旨在促进不同框架之间的深度学习模型交换。它允许你在一个框架(如 PyTorch 或 TensorFlow)中训练模型,然后在另一个框架(如 Caffe2 或 MXNet)中部署它,而无需完全重写模型。 创建一个名为 `export_onnx.py` 的文件: ```python import torch from train import ViTTemporal from dataset import read_labels labels, _ = read_labels("labels.txt") n_classes = len(labels) # 加载已训练模型 model = ViTTemporal(num_classes=n_classes) model.load_state_dict(torch.load("vit_temporal_best.pt", map_location="cpu")) model.eval() # 虚拟输入:批次=1, 16帧, 3x224x224 dummy = torch.randn(1, 16, 3, 224, 224) # 导出 torch.onnx.export( model, dummy, "vit_temporal.onnx", input_names=["video"], output_names=["logits"], dynamic_axes={ "video": {0: "batch"} # 允许变化的批次大小 }, opset_version=13 ) print("Exported vit_temporal.onnx") ``` 使用 `python export_onnx.py` 运行它。 这会在你的项目文件夹中生成 `vit_temporal.onnx` 文件。ONNX 允许我们使用 `onnxruntime`,它在推理速度上远超原始 PyTorch 模型。评估准确率与延迟
接下来,我们创建一个名为 `app.py` 的文件来搭建实时的 Gradio 演示界面,并利用 ONNX Runtime 进行高效推理。 ```python import os, tempfile, cv2, torch, onnxruntime, numpy as np import gradio as gr from dataset import read_labels T = 16 # 帧数 SIZE = 224 # 图像大小 MODEL_PATH = "vit_temporal.onnx" labels, _ = read_labels("labels.txt") # --- ONNX session + 自动检测名称 --- ort_session = onnxruntime.InferenceSession(MODEL_PATH, providers=["CPUExecutionProvider"]) # 检测输入和输出名称以避免不匹配 INPUT_NAME = ort_session.get_inputs()[0].name # 例如 "input" 或 "video" OUTPUT_NAME = ort_session.get_outputs()[0].name # 例如 "logits" def preprocess_clip(frames_rgb): if len(frames_rgb) == 0: frames_rgb = [np.zeros((SIZE, SIZE, 3), dtype=np.uint8)] # 确保帧数等于 T if len(frames_rgb) < T: frames_rgb = frames_rgb + [frames_rgb[-1]] * (T - len(frames_rgb)) frames_rgb = frames_rgb[:T] # 缩放和转置 clip = [cv2.resize(f, (SIZE, SIZE), interpolation=cv2.INTER_AREA) for f in frames_rgb] clip = np.stack(clip, axis=0) # (T,H,W,3) clip = np.transpose(clip, (0, 3, 1, 2)).astype(np.float32) / 255 # (T,3,H,W) # 标准化 (均值0.5, 标准差0.5) clip = (clip - 0.5) / 0.5 clip = np.expand_dims(clip, 0) # (1,T,3,H,W) return clip def _extract_path_from_gradio_video(inp): # Gradio 在处理视频文件上传时可能会以不同形式返回路径或数据 if isinstance(inp, str) and os.path.exists(inp): return inp if isinstance(inp, dict): for key in ("video", "name", "path", "filepath"): v = inp.get(key) if isinstance(v, str) and os.path.exists(v): return v for key in ("data", "video"): v = inp.get(key) if isinstance(v, (bytes, bytearray)): tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") tmp.write(v); tmp.flush(); tmp.close() return tmp.name if isinstance(inp, (list, tuple)) and inp and isinstance(inp[0], str) and os.path.exists(inp[0]): return inp[0] return None def _read_uniform_frames(video_path): cap = cv2.VideoCapture(video_path) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 1 # 等距采样 T 个帧 idxs = np.linspace(0, total - 1, max(T, 1)).astype(int) want = set(int(i) for i in idxs.tolist()) frames = [] j = 0 while True: ok, bgr = cap.read() if not ok: break if j in want: rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) frames.append(rgb) j += 1 cap.release() return frames def predict_from_video(gradio_video): video_path = _extract_path_from_gradio_video(gradio_video) if not video_path or not os.path.exists(video_path): return {label: 0.0 for label in labels} # 提取并预处理帧 frames = _read_uniform_frames(video_path) # 如果 OpenCV 编解码器有问题,这里需要更复杂的逻辑来处理视频流,但对于简化版,我们继续处理现有帧 if len(frames) < T: # 提示用户:视频太短,需要至少T帧才能有效识别 print("Warning: Video too short for T frames.") input_tensor = preprocess_clip(frames) # ONNX 推理 ort_inputs = {INPUT_NAME: input_tensor.astype(np.float32)} ort_outputs = ort_session.run([OUTPUT_NAME], ort_inputs) logits = ort_outputs[0][0] probabilities = torch.softmax(torch.tensor(logits), dim=0).numpy() # 格式化输出为 Gradio 期望的字典 result = {labels[i]: probabilities[i] for i in range(len(labels))} return result # --- Gradio 界面设置 --- input_component = gr.Video(label="上传/录制视频片段 (建议长度 1.5-2秒)") # 创建一个标题,显示预测结果 output_component = gr.Label(label="预测结果", num_top_classes=len(labels)) iface = gr.Interface( fn=predict_from_video, inputs=input_component, outputs=output_component, title="Transformer 实时手势识别演示", description=f"上传一个包含手势({', '.join(labels)})的视频片段(约 {T*1.5/16:.1f} 秒),Transformer 模型将对其进行分类。", allow_flagging='never' ) # iface.launch() # 仅展示代码,实际运行需要确保所有依赖脚本都已存在 ```选项二:使用公共手势数据集的小样本
(此部分在原文中存在,但由于上下文依赖于 `dataset.py` 中的 `GestureClips` 和 `read_labels` 的完整定义,为保持输出简洁和聚焦于 Transformer 核心,暂不展开公共数据集的加载细节,专注于我们已实现的合成数据流程。)辅助功能说明与伦理限制
(此部分在原文中存在,但由于篇幅限制和本教程侧重于技术实现,略去伦理讨论部分。)后续步骤
(略)总结
我们成功地构建了一个完整的、基于 Transformer 的实时手势识别管道。通过利用 ViT 提取空间特征,并结合简单的平均池化来处理时间序列信息,我们创建了一个轻量级的、可部署的模型。将模型导出为 ONNX 格式,并使用 ONNX Runtime 部署,确保了在 CPU 上的高效性能,这使得该技术可以广泛应用于各种设备。🚀 想要体验更好更全面的AI调用?
欢迎使用青云聚合API,约为官网价格的十分之一,支持300+全球最新模型,以及全球各种生图生视频模型,无需翻墙高速稳定,小白也可以简单操作。
青云聚合API官网https://api.qingyuntop.top
支持全球最新300+模型:https://api.qingyuntop.top/pricing
详细的调用教程及文档:https://api.qingyuntop.top/about
评论区