📢 转载信息
原文链接:https://www.freecodecamp.org/news/using-transformers-for-real-time-gesture-recognition/
原文作者:OMOTAYO OMOYEMI
实战:使用Transformer构建轻量级实时手势识别系统
手势和签名识别是计算机视觉领域一个快速发展的方向,它为辅助工具和自然用户界面提供了强大的支持。大多数初学者项目依赖于手部关键点或小型卷积神经网络(CNN),但这些方法往往忽略了手势的本质——它们是随时间展开的,而不是静态图像。为了构建更稳健的实时系统,我们需要能够捕捉空间细节和时间上下文的模型。
这时,Transformer模型就显现出其优势了。Transformer最初是为自然语言处理(NLP)设计的,但得益于Vision Transformer (ViT) 和针对视频的TimeSformer等模型,它们已成为视觉任务中的最先进技术。
在本教程中,我们将使用Transformer骨干网络来创建一个轻量级的实时手势识别工具,该工具针对小数据集进行了优化,并可以在普通笔记本电脑的摄像头上部署。

作者: OMOTAYO OMOYEMI
目录导览
为什么选择Transformer进行手势识别?
Transformer之所以强大,是因为它们利用自注意力机制(self-attention)来对序列中的关系进行建模。对于手势任务,这意味着模型不仅能看到孤立的帧,还能学习动作是如何随时间演变的。例如,一个“挥手”动作与一个“举手”动作,只有在被视为一个序列时才能区分开来。
Vision Transformers 将图像处理为块(patches),而视频Transformer则通过时间注意力机制将此扩展到多帧。即使是简单的处理方法,例如对每一帧应用ViT然后跨时间池化,也能在小数据集上超越传统的基于CNN的方法。
结合Hugging Face的预训练模型和ONNX Runtime进行优化,Transformer使得我们可以在适度的数据集上进行训练,并仍然实现流畅的实时识别。
你将学到什么?
在本教程中,你将使用Transformer构建一个手势识别系统。完成之后,你将知道如何:
创建(或录制)一个极小的手势数据集
训练一个带有时间池化的Vision Transformer (ViT)
将模型导出为ONNX以加速推理
构建一个实时的Gradio应用,通过你的网络摄像头对动作进行分类
使用简单脚本评估模型的准确性和延迟
理解手势识别的潜在应用前景和伦理限制
先决条件
要跟上本教程的进度,你需要具备:
基础 Python 知识(函数、脚本、虚拟环境)
熟悉 PyTorch(张量、数据集、训练循环)——有帮助但非必需
系统上安装了 Python 3.8+
一个网络摄像头(用于 Gradio 中的实时演示)
可选:GPU访问(在CPU上训练可行,但速度较慢)
项目设置
创建一个新的项目文件夹并安装所需的库。
# 创建一个新的项目目录并进入
mkdir transformer-gesture && cd transformer-gesture
# 设置Python虚拟环境
python -m venv .venv
# 激活虚拟环境
# Windows PowerShell
.venv\Scripts\Activate.ps1
# macOS/Linux
source .venv/bin/activate
上述代码片段是一组用于使用虚拟环境设置新Python项目的命令。它们的作用如下:
mkdir transformer-gesture && cd transformer-gesture
: 创建一个名为“transformer-gesture”的新目录并进入其中。python -m venv .venv
: 在当前目录下创建一个新的虚拟环境,存储在 “.venv” 文件夹中。- 激活虚拟环境:
- 对于 Windows PowerShell,使用
.venv\Scripts\Activate.ps1
激活。 - 对于 macOS/Linux,使用
source .venv/bin/activate
激活。
- 对于 Windows PowerShell,使用
激活虚拟环境可确保你安装的 Python 解释器和包仅限于此特定项目,避免与其他项目或系统级包冲突。
创建一个 requirements.txt
文件:
torch>=2.0
torchvision
torchaudio
timm
huggingface_hub onnx
onnxruntime gradio numpy
opencv-python
pillow matplotlib
seaborn
scikit-learn
此列表是Python项目中通常在 requirements.txt
文件中找到的包依赖项。每个包的简要说明如下:
- torch>=2.0: PyTorch,一个流行的深度学习框架。
- torchvision: PyTorch生态系统的一部分,提供计算机视觉工具。
- torchaudio: PyTorch生态系统的一部分,提供音频处理工具。
- timm: PyTorch图像模型库,提供预训练模型集合。
- huggingface_hub: 方便访问 Hugging Face Hub 上的模型和数据集。
- onnx: 开放神经网络交换格式,用于模型互操作性。
- onnxruntime: 用于执行 ONNX 模型的高性能运行时。
- gradio: 用于创建机器学习模型的用户界面的库。
- numpy: Python数值计算的基础库。
- opencv-python: 计算机视觉和图像处理库。
- pillow: Python图像处理库。
- matplotlib: Python绘图库。
- seaborn: 基于 Matplotlib 的统计图表库。
- scikit-learn: Python中的机器学习库,提供数据分析和建模工具。
安装依赖项:
pip install -r requirements.txt
命令 pip install -r requirements.txt
用于安装 requirements.txt
文件中列出的所有 Python 包。这确保了项目拥有运行所需的所有依赖项,是管理项目依赖的常见做法。
生成手势数据集
为了训练我们的基于Transformer的手势识别器,我们需要一些数据。我们不会下载一个庞大的数据集,而是从一个可以在几秒钟内生成的微小合成数据集开始。这使得本教程轻量化,并确保每个人都能跟上进度,而无需处理多GB的文件下载。
方案一:生成合成数据集
我们将使用一个小 Python 脚本来创建移动(或静止)彩色框的短 .mp4
剪辑。每个类别代表一个手势:
- swipe_left – 方框从右向左移动
- swipe_right – 方框从左向右移动
- stop – 方框在中心保持静止
将此脚本保存为 generate_synthetic_gestures.py
到你的项目根目录:
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
# 背景 + 方框颜色
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))
# 运动路径
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)
# 微小抖动,避免过于合成化
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)
# 覆盖文本
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, ".")
# 将labels.txt写入项目根目录
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"
else ("swipe_left"
if "left" in cls
else ("swipe_right"
if "right" in cls
else "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()
该脚本通过创建彩色方块移动或静止的视频剪辑来生成一个合成的手势数据集,模拟“向左滑动”、“向右滑动”和“停止”等手势,并将它们保存在指定的输出目录中。
现在,在你的虚拟环境中运行它:
python generate_synthetic_gestures.py --out data --clips 16 --seconds 1.5
上述命令运行一个名为 generate_synthetic_gestures.py
的 Python 脚本,它生成一个合成手势数据集,每个手势有 16 个剪辑,持续 1.5 秒,并将输出保存到名为“data”的目录中。
这将创建一个类似这样的数据集结构:
data/ swipe_left/*.mp4 swipe_right/*.mp4 stop/*.mp4
labels.txt
每个文件夹包含模拟手势的移动(或静止)方块的短剪辑。这非常适合测试整个流程。
训练脚本:train.py
现在我们有了数据集,让我们使用时间池化来微调一个 Vision Transformer。该模型逐帧应用 ViT,跨时间平均嵌入向量,然后在你的手势上训练一个分类头。
这是完整的训练脚本:
# train.py
import torch, torch.nn as nn, torch.optim as optim
from torch.utils.data import DataLoader
import timm
from dataset import GestureClips, read_labels
class ViTTemporal(nn.Module):
"""逐帧ViT编码器 -> 跨时间均值池化 -> 线性头。"""
def __init__(self, num_classes, vit_name="vit_tiny_patch16_224"): super().__init__() 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) return self.head(feats)
def train(): device = "cuda" if torch.cuda.is_available() else "cpu" labels, _ = read_labels("labels.txt") n_classes = len(labels) train_ds = GestureClips(train=True) val_ds = GestureClips(train=False) print(f"Train clips: {len(train_ds)} | Val clips: {len(val_ds)}")
# 对Windows/CPU友好
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):
# ---- 训练 ----
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
# ---- 验证 ----
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__": train()
运行命令 python train.py
会启动模型的训练过程。发生的情况如下:
- 从 data/ 加载数据集:脚本将访问并加载存储在“data”目录中的手势数据集,该数据集用于训练模型。
- 微调预训练的Vision Transformer:训练脚本将采用已在更大数据集上预训练的Vision Transformer模型,并使用你的特定手势数据集对其进行微调。微调有助于模型适应数据的细微差别,提高其在手势识别特定任务上的性能。
- 将最佳检查点保存为 vit_temporal_best.pt:在训练期间,脚本将评估模型在验证集上的性能。性能最佳的模型版本(基于某种指标,如准确率)将保存为一个名为“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 流水线有效。稍后可以通过以下方式提高结果:
为每个类别添加更多剪辑
训练更多轮次(epochs)
切换到真实录制的动作
图 1. train.py
的示例训练日志,其中 Vision Transformer 带有时间池化,在微小的合成数据集上进行了微调。
将模型导出为ONNX
为了使模型更易于实时运行(并在CPU上更轻量),我们将它导出为ONNX格式。
注意: ONNX(Open Neural Network Exchange)是一种开放标准格式,旨在促进深度学习模型在不同框架之间交换。它可以让你在一个框架(如 PyTorch 或 TensorFlow)中训练模型,然后在另一个框架(如 Caffe2 或 MXNet)中部署它,而无需完全重写模型。这通过提供模型架构和参数的标准表示来实现。
ONNX支持广泛的操作符,并会持续更新以包含新功能,使其成为跨各种平台和设备部署机器学习模型的通用选择。
创建一个名为 export_onnx.py
的文件:
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,这在推理时速度要快得多。
创建一个名为 app.py
的文件:
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 会话 + 自动检测名称 ---
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)]
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)
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):
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) frames = [] total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 1
idxs = np.linspace(0, total - 1, max(T, 1)).astype(int)
want = set(int(i) for i in idxs.tolist()) 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 {}
frames = _read_uniform_frames(video_path)
# 如果OpenCV因编解码器问题(录制的webm常见)而失败,则重新编码一次:
if len(frames) ==
运行 python export_onnx.py
会生成 vit_temporal.onnx
文件。ONNX 允许我们使用对推理速度更快的 onnxruntime。
创建文件 app.py
:
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 + auto-detect names ---
ort_session = onnxruntime.InferenceSession(MODEL_PATH, providers=["CPUExecutionProvider"])
# detect first input and first output names to avoid mismatches
INPUT_NAME = ort_session.get_inputs()[0].name
# e.g. "input" or "video"
OUTPUT_NAME = ort_session.get_outputs()[0].name
# e.g. "logits" or something else
def preprocess_clip(frames_rgb):
if len(frames_rgb) == 0: frames_rgb = [np.zeros((SIZE, SIZE, 3), dtype=np.uint8)]
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)
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):
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) frames = [] total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 1
idxs = np.linspace(0, total - 1, max(T, 1)).astype(int)
want = set(int(i) for i in idxs.tolist()) 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 {}
frames = _read_uniform_frames(video_path)
# 如果OpenCV因编解码器问题(录制的webm常见)而失败,则重新编码一次:
if len(frames) ==
评估准确性和延迟
在训练和导出模型后,你需要评估其性能。在部署到生产环境之前,了解模型的准确性和推理延迟至关重要。
方案二:使用公共手势数据集的小样本
虽然合成数据适用于管道测试,但要获得高精度,你需要真实数据。你可以从公共数据集(如 Jester 或 ChaLearn First Impressions)中抽取小样本,并将它们组织成与合成数据相同的目录结构(data/class_name/*.mp4
)。
无障碍说明与伦理限制
手势识别有巨大的潜力来增强无障碍功能,例如为听障人士提供实时字幕,或为运动障碍人士提供免提控制。然而,至关重要的是要意识到这些系统的局限性:
- 文化差异: 手势的含义在不同文化中差异很大。
- 背景敏感性: 模型的性能可能在不同的光照和背景下急剧下降。
- 隐私问题: 始终明确用户数据的使用方式。
后续步骤
要让实时应用运行起来,你需要完成 app.py
的缺失部分,特别是推理逻辑和 Gradio 界面定义。一旦完成,你就可以运行 python app.py
来启动 Web 界面,通过网络摄像头测试你的 Transformer 模型了。
总结
我们已经成功展示了如何利用预训练的Vision Transformer(结合时间池化)构建一个轻量级、实时可用的手势识别系统。通过使用合成数据和ONNX导出,我们证明了即使在资源有限的环境下,Transformer也能高效地处理时间序列视觉任务。
🚀 想要体验更好更全面的AI调用?
欢迎使用青云聚合API,约为官网价格的十分之一,支持300+全球最新模型,以及全球各种生图生视频模型,无需翻墙高速稳定,小白也可以简单操作。
青云聚合API官网https://api.qingyuntop.top
支持全球最新300+模型:https://api.qingyuntop.top/pricing
详细的调用教程及文档:https://api.qingyuntop.top/about
评论区